在物联网工作台的对外接口设计中,有三个结构高度相似的查询接口:查询 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;
        }
    }
    // ……等待间隔(下一节详述)
}

三个参数各司其职:

  1. 总超时(10 分钟):保护调用方。没有它,一次卡死的任务会让 HTTP 请求永远挂着,占住连接池和线程。注意超时提示里特意保留了任务 ID 的获取途径——任务可能只是执行慢,不是失败。
  2. 轮询间隔(8 秒):平衡实时性与外部系统压力。网元脚本执行通常以十秒计,8 秒一次足够。
  3. 连续失败阈值(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) { /* 正常轮询间隔 */ }

拆解这三行代码:

  1. CreateLinkedTokenSource:创建一个联动外部令牌的派生令牌源。客户端断连时,外部 cancellationToken 被取消,派生令牌跟着取消,Task.Delay 立即醒来——轮询循环不会为了”等满 8 秒”而推迟响应断连。
  2. Task.Delay(Timeout.Infinite, ...) + CancelAfter:先无限等待,再由 CancelAfter 在 8 秒后主动取消。这样等待时长精确等于 8 秒,且本轮查询耗时不会叠加进间隔。等价于一个”可被两种信号唤醒的定时器”。
  3. 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 花哨,但在网元脚本这类”结果以十秒计、调用方是前端页面”的场景里,一个写得干净的轮询循环,比引入新协议更务实。