背景与痛点
在电信行业的网络运维场景中,我们经常需要向网元(如 5G UPF、PGW、PCF 等)下发 MML 命令或查询业务数据。传统做法是依赖第三方平台(如东信)的统一网关转发,但第三方平台有时响应慢、限流或不可用。
因此我们在 SwitchData 项目中实现了一套 ORDP 直连方案 —— 让客户端直接通过 Telnet 协议连接 ORDP 网关,绕过第三方平台。但直连也带来了新挑战:
- ORDP 网关是单点,多设备并发查询容易过载
- Telnet 原始响应包含 VT100 控制序列、NUL 空字符、命令回显等干扰信息
- 部分网元登录需要多阶段提示符
- 直连失败必须自动回退第三方平台,保证业务不中断
本文将完整拆解这套方案的实现思路。
整体架构
项目的 ORDP 直连链路包含五个核心环节:
- 并发限流:SemaphoreSlim 将同时连接 ORDP 的数量控制在 5,保护网关单点
- Telnet 连接:TelnetClient 实现异步 Socket + IAC 协商 + 行缓冲
- OrdpClient 封装:登录 ORDP -> 登录目标网元 -> 发送 MML 命令 -> 读取响应
- 协议清洗:VT100 终端控制序列 + NUL 空字符 + 输入回显 三重过滤
- 自动回退: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
// 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
// 回退第三方平台
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
// ===== 登录一次 =====
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
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 协议直连 + 并发控制 + 协议清洗的完整实现,可以作为网络设备自动化运维的参考模板。