背景与痛点

在电信行业的网络运维场景中,我们经常需要向网元(如 5G UPF、PGW、PCF 等)下发 MML 命令或查询业务数据。传统做法是依赖第三方平台(如东信)的统一网关转发,但第三方平台有时响应慢、限流或不可用。

因此我们在 SwitchData 项目中实现了一套 ORDP 直连方案 —— 让客户端直接通过 Telnet 协议连接 ORDP 网关,绕过第三方平台。但直连也带来了新挑战:

  • ORDP 网关是单点,多设备并发查询容易过载
  • Telnet 原始响应包含 VT100 控制序列、NUL 空字符、命令回显等干扰信息
  • 部分网元登录需要多阶段提示符
  • 直连失败必须自动回退第三方平台,保证业务不中断

本文将完整拆解这套方案的实现思路。

整体架构

项目的 ORDP 直连链路包含五个核心环节:

  1. 并发限流:SemaphoreSlim 将同时连接 ORDP 的数量控制在 5,保护网关单点
  2. Telnet 连接:TelnetClient 实现异步 Socket + IAC 协商 + 行缓冲
  3. OrdpClient 封装:登录 ORDP -> 登录目标网元 -> 发送 MML 命令 -> 读取响应
  4. 协议清洗:VT100 终端控制序列 + NUL 空字符 + 输入回显 三重过滤
  5. 自动回退:Try-Catch 包裹整条直连链路,失败时整体回退第三方平台

一、并发限流:SemaphoreSlim 保护 ORDP 网关

ORDP 网关(如 188.0.108.25:28030)是一个单点。如果同时对 10+ 台设备发起 Telnet 连接,网关处理能力会急剧下降,单条命令耗时从 1~2 秒飙升到 5~10 秒,总耗时没有任何改善。

因此项目中使用 SemaphoreSlim 将并发连接限制为 5:

`csharp // SemaphoreSlim 定义为静态变量,多请求共享 private static readonly SemaphoreSlim _ordpSemaphore = new(5, 5);

public async Task ExecuteWithSemaphoreAsync( string neName, string interfaceName, string command, CancellationToken ct) { // 1. 等待许可 —— 已有 5 个连接时会阻塞 await _ordpSemaphore.WaitAsync(ct); try { // 2. 完整的 Telnet 直连链路 using var client = new OrdpClient(new TelnetClient()); await client.ConnectAsync(“188.0.108.25”, 28030, ct); await client.LoginAsync(“sjs_wlwyzxt”, “password”, ct); await client.LoginNeAsync(neName, interfaceName, expectedPromptCount, ct);

    // 3. 发送 MML 命令获取原始响应
    var rawResponse = await client.ExecuteCommandAsync(command, ct);

    // 4. 协议清洗 + 业务解析
    var cleanResponse = ProtocolCleaner.Clean(rawResponse);
    return ParseResult(cleanResponse);
}
finally
{
    // 5. 释放许可 —— 关键!必须在 finally 中
    _ordpSemaphore.Release();
}

} `

为什么用 SemaphoreSlim 而不是 Parallel.ForEachAsync? Parallel.ForEachAsync 默认并行但不限量,遇到大量任务会同时发起数百个连接直接压垮网关。SemaphoreSlim 是显式限流,更可控。

SemaphoreSlim(5, 5) 的两个参数分别是 初始许可数 和 最大许可数。在 ASP.NET Core 中注册为单例即可全局共享。

二、OrdpClient:IAsyncDisposable 资源管理

OrdpClient 包装了 TelnetClient,实现了完整的资源释放链路:

`csharp public sealed class OrdpClient : IAsyncDisposable { private readonly TelnetClient _telnetClient; private bool _disposed;

// 工厂方法创建
public static OrdpClient Create() => new(new TelnetClient());

public async Task LoginNeAsync(string neName, string interfaceName,
    int expectedPromptCount = 1, CancellationToken ct = default)
{
    // 不同网元的命令结束标志数量不同
    // 普通 PCF: 1 个 --- END
    // UPF 网元: LGI + REG NE 各产一个 --- END,共 2 个
    // 需要从 NeInfo 表读取实际配置
    await _telnetClient.SendLineAsync(
        $"ne={neName}  inf={interfaceName}", ct);

    // 循环读取 expectedPromptCount 个 --- END
    for (int i = 0; i < expectedPromptCount; i++)
    {
        await _telnetClient.ReadUntilAsync("--- END", ct);
    }
}

// IAsyncDisposable:异步释放 Telnet 连接
public async ValueTask DisposeAsync()
{
    if (_disposed) return;
    _disposed = true;

    // 尝试优雅关闭
    if (_telnetClient.IsConnected)
    {
        try { await _telnetClient.SendLineAsync("QUIT"); }
        catch { /* 忽略异常 */ }
        await _telnetClient.DisconnectAsync();
    }
    _telnetClient.Dispose();
    GC.SuppressFinalize(this);
}

} `

为什么需要 IAsyncDisposable? Telnet 连接的断开需要发送 QUIT 命令、等待对端确认后再关闭 Socket,这些都是异步操作。同步 Dispose 无法优雅处理。

三、协议清洗:三重过滤器

Telnet 协议返回的原始响应很脏,直接展示或解析会有大量乱码。项目中实现了三重清洗:

1. VT100 终端控制序列移除

UPF 设备的 SSH2 模式下,ORDP 网关会在响应中注入 VT100 控制序列(光标移动、清行等):

`csharp // 清理 VT100 序列:ESC[K、ESC7、ESC8 等 private static readonly Regex _vt100Pattern = new( @“1b[?[0-9;]*[A-Za-z]|1b[CD78]“, RegexOptions.Compiled);

public static string RemoveVt100(string input) => _vt100Pattern.Replace(input, ““); `

2. NUL 空字符清理

Telnet 协议在 CR 后会填充 NUL()字符,网关有时也会在响应头部注入:

csharp // 使用 ReadOnlySpan 零分配过滤 public static string RemoveNullChars(string input) { if (string.IsNullOrEmpty(input)) return input; var sb = new StringBuilder(input.Length); foreach (var c in input.AsSpan()) { if (c != '\x00') sb.Append(c); } return sb.ToString(); }

3. 命令输入回显剔除

Telnet 协议的行尾 被网关转换为回显到响应流中,导致命令行重复出现。关键区别:

  • 输入回显行:行尾只有单个 终端输入的原始回显)
  • 网元响应回显:完整的 结尾,这是网元自身的回显输出

`csharp public static string RemoveInputEcho(string response) { var lines = response.Split(‘’); var sb = new StringBuilder(response.Length);

foreach (var line in lines)
{
    // 输入回显特征:行尾 \r 但被 Split 吃掉 \n 后只剩 \r
    if (line.EndsWith('\r') && line.Length > 0)
    {
        // 这是终端输入的 \r 回显,跳过
        continue;
    }
    sb.AppendLine(line);
}
return sb.ToString();

} `

四重清洗组合调用

csharp public static string Clean(string raw) { return RemoveInputEcho( // 先剔除命令回显 RemoveNullChars( // 再清 NUL 字符 RemoveVt100(raw) // 先清 VT100 序列 )); }

四、自动回退:Try-Catch 兜底策略

ORDP 直连可能因为各种原因失败:网关不可达、网元不在线、Telnet 协议协商超时等。项目中使用 Try-Catch 包裹整个直连链路,异常时自动回退第三方平台:

`csharp public async Task ExecuteWithFallbackAsync( string scriptText, List groups) { try { // 先尝试 ORDP 直连 return await ExecuteOrdpDirectAsync(scriptText, groups); } catch (Exception ex) when (IsOrdpFailure(ex)) { // 直连失败 —— 记录日志 Logger.Warn(“ORDP 直连失败:{Message},回退东信平台”, ex.Message); }

// 回退第三方平台
return await ExecuteEastcomAsync(scriptText, groups);

}

private static bool IsOrdpFailure(Exception ex) => ex is OrdpException // ORDP 业务异常 || ex is TelnetException // Telnet 协议异常 || ex is SocketException // 网络不可达 || ex is TimeoutException; // 超时 `

关键设计:回退是 整体回退 而非逐台回退。如果 ORDP 网关就不可达,没有必要逐台尝试了。

五、单设备多次查询优化:登录一次,执行多次

同一台设备上可能要查询多个业务指标。项目中优化为”单设备仅登录一次,多次发送命令”,避免反复建立 Telnet 连接:

`csharp public async Task QueryStrategiesAsync( DeviceInfo device, CancellationToken ct) { var result = new DeviceStatistic { DeviceName = device.Name };

// ===== 登录一次 =====
using var client = await CreateAndLoginAsync(device, ct);

// ===== 串行执行所有命令 =====
foreach (var strategy in device.Strategies)
{
    var raw = await client.ExecuteCommandAsync(strategy.Command, ct);
    var clean = ProtocolCleaner.Clean(raw);
    strategy.UserCount = ParseSubscriberCount(clean);
}

return result;

} `

踩坑经验

1. TelnetClient.ClearPendingText() 必须在发送命令前调用

ORDP 网关的登录阶段会产生 — END 提示符,如果不清除就发送业务命令,首条命令的响应会被截断:

`csharp // 错误:直接发送命令 await _telnetClient.SendLineAsync(command); var response = await ReadResponseAsync(); // 响应被截断

// 正确:先清除缓冲区残留的 — END _telnetClient.ClearPendingText(); await _telnetClient.SendLineAsync(command); var response = await ReadResponseAsync(); // 完整响应 `

2. ExpectedCommandPromptCount 必须与网元匹配

部分网元(如 DOGUPFm002BHW)登录 ORDP 后会执行 LGI 和 REG NE 两条命令,每条各产生一个 — END。如果配置为 1,第二个 — END 会残留在缓冲区,截断首条业务命令响应:

sql -- NeInfo 表中的配置示例 -- 普通 PCF 网元:ExpectedCommandPromptCount = 1 -- DOGUPFm002BHW (UPF):ExpectedCommandPromptCount = 2 UPDATE NeInfo SET ExpectedCommandPromptCount = 2 WHERE DeviceName = 'DOGUPFm002BHW';

3. ORDP 命令阶段超时必须足够长

UPF 设备是逐 pod 统计数据,响应非常慢。默认 30 秒超时不够用,需要调整为 180 秒:

`csharp private const int CommandTimeoutSeconds = 180;

public async Task ExecuteCommandAsync(string command, CancellationToken ct = default) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(CommandTimeoutSeconds));

await _telnetClient.SendLineAsync(command, cts.Token);
return await ReadUntilEndPromptAsync(cts.Token);

} `

总结

这套 ORDP 直连方案的核心设计要点:

设计点 解决方案 目的
并发保护 SemaphoreSlim(5, 5) 防止 ORDP 网关过载
资源管理 IAsyncDisposable + using Telnet 连接优雅释放
VT100 清洗 Regex + 零分配 ReadOnlySpan 终端控制序列清除
NUL 清洗 ReadOnlySpan 逐字符过滤 空字符清理
回显剔除 行尾 \r 特征识别 输入回显跳过
失败兜底 Try-Catch + 整体回退 业务可用性不中断
单设备优化 登录一次执行多命令 减少连接开销

从实践效果来看,这套方案在 ORDP 正常时比第三方平台快 30~50%(直连跳过网关转发),ORDP 故障时自动回退保证了业务零感知。对于有自建网络管理需求的团队,这套 Telnet 协议直连 + 并发控制 + 协议清洗的完整实现,可以作为网络设备自动化运维的参考模板。