在物联网工作台的对外接口设计中,有三个结构高度相似的查询接口:查询 APN 下带用户数、GRE 隧道外层连通性测试、GRE 隧道内层连通性测试。它们业务含义不同,执行链路却完全一致:生成脚本 → 下发到网元 → 轮询任务结果。脚本下发后任务在后台异步执行,结果不会立即就绪,只能凭任务 ID 反复轮询,直到拿到最终文本或超时。本文以项目中的真实代码为例,聊聊这条长轮询链路里值得注意的几个工程细节。
一、为什么要提炼通用流程
三个接口如果各自复制一份”生成→下发→轮询”逻辑,就是三份注定漂移的重复代码:改一处超时策略、忘了另外两处,行为就不一致了。最终我们把整条链路收敛为一个私有方法
SearchScriptAsync,对外三个公开方法各自只传入一个
ScriptTextType:
/// <summary>
/// 查询APN下带用户数(对外接口):生成脚本→下发→轮询结果
/// </summary>
public static async Task<JsonDataResult> SearchUserNumAsync(SearchApnNumRequest request, CancellationToken cancellationToken)
{
return await SearchScriptAsync(request, ScriptTextType.QueryApnSubscriberCount, cancellationToken);
}
/// <summary>
/// GRE隧道外层连通性测试(对外接口):生成脚本→下发→轮询结果
/// </summary>
public static async Task<JsonDataResult> SearchGreOutAsync(SearchApnNumRequest request, CancellationToken cancellationToken)
{
return await SearchScriptAsync(request, ScriptTextType.GreTunnelOuterConnectivityTest, cancellationToken);
}
/// <summary>
/// GRE隧道内层连通性测试(对外接口):生成脚本→下发→轮询结果
/// </summary>
public static async Task<JsonDataResult> SearchGreInAsync(SearchApnNumRequest request, CancellationToken cancellationToken)
{
return await SearchScriptAsync(request, ScriptTextType.GreTunnelInnerConnectivityTest, cancellationToken);
}
这是模板方法思想的实用主义版本:不搞抽象基类、不定义钩子方法,只是把”不变的流程”收进一个方法,把”变化的业务类型”降级为一个枚举参数。新增第四个查询类型时,只需两行代码。
二、整体流程
flowchart TD
A[生成脚本] -->|失败| F1[直接返回错误]
A -->|成功| B{脚本是否为空}
B -->|空| F2[返回错误]
B -->|非空| C[下发脚本]
C -->|失败| F3[直接返回错误]
C -->|成功| D{是否返回任务ID}
D -->|无| F4[返回错误]
D -->|有| E[进入轮询循环]
E --> G{查询任务结果}
G -->|查询失败| H[失败计数+1]
H -->|连续5次| F5[放弃并返回任务ID]
H -->|未达阈值| I[等待8秒]
G -->|成功且未完成| I
I --> J{是否超过10分钟}
J -->|是| F6[返回超时提示]
J -->|否| G
G -->|完成| K[返回结果文本+任务ID]
三、快速失败:错误不要混进轮询
轮询循环里最忌讳的是”带着错误状态继续转圈”。通用流程在每个阶段都做了前置校验,失败立刻返回:
// 1. 生成脚本:失败直接返回,不再把错误消息当脚本下发
var gResult = await GenerateScriptAsync(new GenerateScriptRequest
{
Apn = request.ApnName,
Type = type
}, cancellationToken);
if (!gResult.success)
return gResult;
var script = gResult.data?.ToString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(script))
return JsonDataResult.Failure("生成的脚本为空,请确认输入APN是否匹配标准库数据");
// 2. 下发脚本:失败直接返回,避免 id=null 导致后续 NRE
var dResult = await DispatchScriptAsync(new DispatchScriptRequest
{
Text = script,
Type = type
}, cancellationToken);
if (!dResult.success)
return dResult;
if (!dResult.extension.TryGetValue("id", out var idObj) || idObj == null)
return JsonDataResult.Failure("任务创建成功但未返回任务ID,请稍后重试");
这里的注释值得强调:“失败直接返回,不再把错误消息当脚本下发”——如果生成失败后不拦截,错误文案会被当作脚本文本推给网元执行,排障时看到的是一堆莫名其妙的”执行结果”。同理,任务
ID 为空时不做保护,后面 DispatchResultRequest { id = null }
就是一个潜在空引用异常。把校验做在循环之外,轮询内部就可以假设”进入循环时状态是健康的”。
四、轮询三要素:总超时、间隔、连续失败阈值
长轮询的核心循环由三个参数共同约束:
var text = string.Empty;
var remark = string.Empty;
var maxTime = DateTime.Now.AddMinutes(10);
const int pollIntervalMs = 8000;
int consecutiveFailures = 0;
const int maxConsecutiveFailures = 5;
while (true)
{
// 响应客户端断连,避免空转占用线程
cancellationToken.ThrowIfCancellationRequested();
if (DateTime.Now >= maxTime)
return JsonDataResult.Failure("查询超时(超过10分钟仍未得到结果),可稍后通过任务ID再次查询");
var result = await DispatchResultAsync(new DispatchResultRequest
{
id = taskId,
type = type
}, cancellationToken);
var dto = result.data as DispatchResultDto;
if (!result.success || dto == null)
{
// 瞬时失败(外部系统抖动/网络瞬断):累计失败次数,超过阈值再放弃
consecutiveFailures++;
if (consecutiveFailures >= maxConsecutiveFailures)
{
return JsonDataResult.Failure(
$"连续{maxConsecutiveFailures}次查询任务状态失败:{result.message ?? "未知错误"}")
.WithExtension("id", taskId);
}
}
else
{
consecutiveFailures = 0;
if (dto.IsFinished)
{
text = dto.Text;
remark = dto.Remark;
break;
}
}
// ……等待间隔(下一节详述)
}
三个参数各司其职:
- 总超时(10 分钟):保护调用方。没有它,一次卡死的任务会让 HTTP 请求永远挂着,占住连接池和线程。注意超时提示里特意保留了任务 ID 的获取途径——任务可能只是执行慢,不是失败。
- 轮询间隔(8 秒):平衡实时性与外部系统压力。网元脚本执行通常以十秒计,8 秒一次足够。
- 连续失败阈值(5 次):容忍瞬时抖动。查询任务状态走的是外部系统,一次网络瞬断、一次 502 都属正常波动,单次失败就放弃会把可恢复的抖动放大成整体失败;但失败也不能无限容忍,连续 5 次(约 40 秒)仍不成功,大概率是外部系统真的挂了。失败不清零、成功即清零,这个计数器语义既宽容又有限度。
还有一个细节:失败返回时通过
.WithExtension("id", taskId) 把任务 ID
塞回结果。调用方拿到失败响应后,仍可凭这个 ID
稍后重查,不会因为一次查询失败就彻底丢失任务。
五、可取消的精确等待:Task.Delay(Timeout.Infinite) 的妙用
轮询间隔最常见的写法是:
await Task.Delay(8000, cancellationToken);
这有个隐蔽问题:间隔从”等待开始”起算,但每次查询耗时不同。查询耗时 3 秒时,实际间隔变成 3 + 8 = 11 秒;外部系统变慢时,间隔进一步漂移。更讲究的写法是让间隔从”本轮结束时”精确起算:
// 等待:优先使用 CancellationToken,客户端断连立即退出
using var delayCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
delayCts.CancelAfter(pollIntervalMs);
try { await Task.Delay(Timeout.Infinite, delayCts.Token); }
catch (OperationCanceledException) when (delayCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { /* 正常轮询间隔 */ }
拆解这三行代码:
CreateLinkedTokenSource:创建一个联动外部令牌的派生令牌源。客户端断连时,外部cancellationToken被取消,派生令牌跟着取消,Task.Delay立即醒来——轮询循环不会为了”等满 8 秒”而推迟响应断连。Task.Delay(Timeout.Infinite, ...)+CancelAfter:先无限等待,再由CancelAfter在 8 秒后主动取消。这样等待时长精确等于 8 秒,且本轮查询耗时不会叠加进间隔。等价于一个”可被两种信号唤醒的定时器”。catch ... when异常过滤器:Task.Delay被取消会抛OperationCanceledException,但取消有两种来源——8 秒到点的正常轮询间隔,以及客户端断连的真实取消。when过滤器只吞掉前者:delayCts.IsCancellationRequested(到点取消)且!cancellationToken.IsCancellationRequested(非外部取消)时静默继续;若是外部取消,异常继续上抛,由更上层统一处理。不加过滤器的catch (OperationCanceledException) { }会把断连也吞掉,调用方拿到的是”正常空结果”而不是取消信号,这是这类代码最常见的 bug。
六、扩展方法携带元数据:WithExtension
结果类 JsonDataResult
上挂了一个扩展方法,用字典承载”额外元数据”:
public static JsonDataResult WithExtension(this JsonDataResult result, string key, object value)
{
result.extension ??= new Dictionary<string, object>();
result.extension[key] = value;
return result;
}
它的价值在于不污染主结构:JsonDataResult
的 success/message/data 保持稳定契约,任务 ID
这类流程性信息放 extension 字典。成功返回
JsonDataResult.Success(remark, text).WithExtension("id", taskId),前端既拿到结果文本,也拿到任务
ID;失败路径同样可以带上 ID 供续查。相比给 DTO
每次加字段,扩展字典让”可选元数据”的演进不破坏既有调用方。
七、总结
这条长轮询链路的经验可以浓缩成四条:
- 相似流程尽早收敛:三个接口共用一个私有方法,变化点降级为枚举参数,新增类型零成本;
- 校验前置:生成失败、脚本为空、任务 ID 缺失都在进循环前拦截,轮询内部假设状态健康;
- 三要素约束轮询:总超时保护调用方、固定间隔平衡压力、连续失败阈值容忍瞬时抖动又设兜底;
- 精确且可取消的等待:
Task.Delay(Timeout.Infinite, linkedToken)+CancelAfter让间隔不漂移,when异常过滤器精确区分”到点唤醒”与”外部取消”。
长轮询是异步任务查询最朴素的方案,没有 WebSocket 花哨,但在网元脚本这类”结果以十秒计、调用方是前端页面”的场景里,一个写得干净的轮询循环,比引入新协议更务实。