Skip to content

feat: 传输层抽象(serial / TLS)设计与实施计划(3.0 架构演进设计稿) #55

Description

@CSJ608

背景与目标

StreamFrame v2.5.0 的插件化已覆盖帧定界(IFramer)与编解码(ICodec<TMessage>),但传输仍硬绑 TCP socket:StreamConnection 直接持有 Socket。本 issue 把"传输"抽为可插拔层,使:

  • TLSSslStream,包装在 TCP 之上)与串口System.IO.Ports,工业 SECS-I 场景)可作为传输驱动接入;
  • 现有 TCP 行为与全部公共 API 语义逐字节、逐条保证不变(现有 98×3 测试全绿为回归底线);
  • 沿用 feat: 增加会话感知的发送确认与消息上下文 #39 的工作流:本 issue 为设计稿,评审定稿后再实施

非目标:不引入任何 SECS/HSMS 专用概念;不内置证书管理/续期、串口枚举发现;不做 UDP/WebSocket/蓝牙(抽象不为它们预留形状,但也不刻意堵死)。


一、现状盘点:socket 在 StreamConnection 里的全部触点

通读 src/StreamFrame/Connection/StreamConnection.cs(1157 行)后,socket 依赖共 7 处:

触点 位置 内容 去向
字段 L69–70 _socket / _server 改为 ITransportConnection? / ITransportListener?
ConnectAsync L271–296 按目标地址族建 socket(v4 字面量纯 v4 / 双栈)、连接、失败释放本次 socket 搬进 TcpTransportFactory(代码原样迁移)
AcceptAsync + InitServer L298–379 _acceptLock + _acceptLoopId 代次门控(#47 阶段二)、SO_REUSEADDR + Bind + Listen、accept 重试、单客户端关闭监听 锁与代次门控留在连接层(它们保护的是"谁拥有监听器对象",与传输无关);bind/listen/accept 搬进 TcpTransportListener
ConfigureSocket L381–412 非阻塞、接收缓冲、TCP KeepAlive(含 ns2.0 的 SIO_KEEPALIVE_VALS 分支) 搬进 TCP 传输(connect 与 accept 两条路径都归它)
ReceiveLoopAsync / ReceiveWithIdleTimeoutAsync L680–739 writer.GetMemory(SocketReceiveBufferSize) + socket.ReceiveAsync(memory) + 空闲超时包装;0 字节 = FIN → 重连 读调用换成 ITransportConnection.ReadAsync,其余不动
SendRawAsync L851–874 _sendLock 下循环 socket.SendAsync按实际写出分片回调 RawBytesSent 写调用换成 ITransportConnection.WriteAsync(返回本次写出计数),循环与钩子不动
拆线(StopSessionCore / Shutdown L660–675、L1134–1147 socket.Shutdown(Both) + Dispose_server.Dispose() 换成 ShutdownAsync()(尽力优雅)+ Dispose()

会话状态机、消息通道、发送队列、解码循环、RetryDelayScheduler、指标——全部在 IO 之上,不触碰 socket。

二、抽象面形状:为什么既不是"大 ITransport"也不是"裸 Stream"

候选 A:ITransport 自带完整 IO 面(Connect/Accept/Read/Write/Abort 全在一个接口)——重新发明 Stream,SslStream / SerialPort.BaseStream / NetworkStream 本来就是 Stream,接入还要再包一层适配,公共面最大、文档与测试负担最大。否。

候选 B:纯 Stream + 连接工厂(工厂产出已连接的 Stream,连接核心只消费 Stream)——方向正确(TLS/串口天然是 Stream),但裸 Stream 契约有四个表达不了的语义:

  1. 可中止拆除Socket.Dispose 会中止未决 IO(现在靠它解锁 ns2.0 上不可取消的接收);Stream.Dispose 无此承诺,SerialPort.Close 更有经典的"驱动有 pending 字节时挂死"问题,需要 DiscardBuffer + 后台关闭 + 超时的专用路径。
  2. 优雅关闭:现状 socket.Shutdown(Both);TLS 的对应物是 close_notifySslStream.ShutdownAsync);串口没有。需要一个"尽力优雅关闭、失败由调用方记日志"的显式成员。
  3. 部分写计数SendRawAsync 依赖 socket.SendAsync 返回本次写出字节数实现"按实际写出分片回调 RawBytesSent";Stream.WriteAsync 全量写或抛、无返回计数。直接换 Stream 会悄悄改变现有 TCP 的公共语义(约束禁止)。
  4. ns2.0/netfx 缺口Stream 没有 SocketTaskExtensions 那样的 Memory 重载等价物,net48 上 TLS/串口的读写需要 byte[] 路径的内部 shim。

推荐:折中形状——"连接工厂 + 已建连接的极小自有接口"(Stream-centric 精神 + 承载 Stream 表达不了的语义)。TCP 实现就是把现有代码原样搬进去(字节级等价重构);TLS/串口是薄适配层。

三、公共 API 草图(主包,根命名空间)

/// 传输工厂:一条连接的传输来源。连接级(StreamConnection 构造时固定)。
public interface ITransportFactory
{
    /// 诊断描述:metrics endpoint 标签与重试日志用它("127.0.0.1:5000" / "tls://127.0.0.1:5000" / "COM3@9600")
    string Description { get; }

    /// 主动建立一条传输连接(TCP connect / TLS connect+握手 / 串口打开)。失败抛异常,重试归连接层。
    Task<ITransportConnection> ConnectAsync(CancellationToken ct);

    /// 创建监听器。不支持被动模式的传输(串口)抛 NotSupportedException。
    ITransportListener CreateListener();
}

/// 传输监听器:被动模式接受一条连接。
public interface ITransportListener : IDisposable
{
    /// 接受一条连接(TLS:内层 accept + 服务端握手,返回即握手完成)。失败抛异常,重试归连接层。
    Task<ITransportConnection> AcceptAsync(CancellationToken ct);
}

/// 一条已建立的传输连接:全双工字节管道 + 拆线语义。
public interface ITransportConnection : IDisposable
{
    /// 诊断用对端描述;无地址概念的传输(串口)为 null
    string? RemoteDescription { get; }

    /// 读一段字节;>0 = 字节数,0 = 对端正常关闭(EOF;串口不会出现,拔线表现为 IO 异常)
    ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct);

    /// 尽力写出一段字节,返回本次写出数(TCP 可能部分写,调用方循环续写;TLS/串口成功即全量)
    ValueTask<int> WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct);

    /// 尽力优雅关闭(TCP: Shutdown(Both);TLS: close_notify;串口: no-op)。可抛异常,调用方捕获记日志。
    Task ShutdownAsync();

    /// 立即拆除:放弃未决 IO、不走优雅关闭(串口挂死规避路径:DiscardBuffer + 后台 Close + 超时)。之后 Dispose 幂等。
    void Abort();
}

// 内建 TCP:现有 ConnectAsync/InitServer/ConfigureSocket/Accept 原样迁移
public sealed class TcpTransportFactory : ITransportFactory { /* ctor(IPAddress, int, TcpTransportOptions?, ILogger?) */ }

// TLS = 装饰器:升级任意 Stream 型内层传输(今天是 TCP,将来 NamedPipe/Unix 也可)
public sealed class TlsTransportFactory : ITransportFactory
{
    public TcpTransportFactory Inner { get; }  // ctor(TcpTransportFactory inner, TlsTransportOptions?, ILogger?)
}

public sealed class TcpTransportOptions   { /* TcpKeepAlive 三件套(从 StreamConnectionOptions 的对应语义拆出) */ }
public sealed class TlsTransportOptions  { /* TargetHost、RemoteCertificateValidationCallback、SslProtocols、
                                              ServerCertificate、RequireClientCertificate */ }

StreamConnection 新增构造重载(现有构造函数与 IStreamConnection / ISessionAwareStreamConnection 一字不动;旧构造内部组装 TcpTransportFactory,字段映射见第四节):

// 现有(不变,行为逐字节等价)
new StreamConnection<TMessage>(framing, codec, ipAddress, port, isActive, options, logger);

// 新增:任意传输
var tls = new TlsTransportFactory(
    new TcpTransportFactory(IPAddress.Parse("192.168.1.10"), 5000, new TcpTransportOptions { TcpKeepAlive = true }),
    new TlsTransportOptions { TargetHost = "equip-01" });
new StreamConnection<TMessage>(framing, codec, tls, isActive: true, options, logger);

ValueTask<int> 在 ns2.0 经 System.IO.Pipelines 传递的 System.Threading.Tasks.Extensions 获得(实现时复核依赖审计,不新增直接依赖)。

四、选项归属与 TCP-only 选项的处置

StreamConnectionOptions 成员 归属 自定义传输下的行为
TcpKeepAlive / KeepAliveTimeMs / KeepAliveIntervalMs TCP-only,映射进 TcpTransportOptions 文档化忽略(见下)
SocketReceiveBufferSize 连接级(读取块尺寸 + Pipe 段尺寸对齐 v2.5.0),TCP 实现兼用作 socket 接收缓冲 继续生效(对任何传输都是"读块/段尺寸")
ReceiveIdleTimeoutMs / IncompleteFrameTimeoutMs 连接级(读包装/解码层),传输无关 继续生效(串口强烈建议开启前者——见第五节)
AcceptFirstClientOnly 连接级监听策略(对任意 ITransportListener 语义成立:Dispose = 停止监听) 继续生效
retry 系列(ConnectRetryDelayMs 等) 连接级状态机 继续生效

TCP-only 选项在非 TCP 传输上:文档化忽略,不抛异常。 理由:options 是可跨连接共享的数据对象,"共享一份 options + 不同传输"是合理用法,抛异常会把它判死;静默忽略配一张生效矩阵写入 XML 文档 + README。若评审倾向 fail-fast,可降级为"设置即抛"(StreamConnection 构造时检查),请确认(见第十节 ②)。

TLS 的半开检测:内核 KeepAlive 在内层 TCP 上照常工作(TlsTransportFactory 透传内层 TcpTransportOptions),与应用层 ReceiveIdleTimeoutMs 互补,语义与裸 TCP 一致。

五、被动模式与状态机映射(问题 4)

状态机零改动Connecting → Connected → Retry → … → Disconnected 不动。Connecting 的语义拓宽为"建立传输"——TCP connect、TLS 握手串口打开都算;Retry 覆盖对端拒绝、握手失败、端口被占/设备未插(串口热插拔场景天然契合 ConnectRetryDelayMs 重试)。

传输 主动(isActive=true) 被动(isActive=false)
TCP connect(现状) bind/listen/accept(现状)
TLS 内层 connect + AuthenticateAsClient(握手计入 Connecting;握手挂起用 WaitAsync(ct) 包装,超时后 Abort() 底层连接中止握手——net7 前的 Authenticate 无 ct 重载) CreateListener 返回 TLS 监听器:内层 accept + AuthenticateAsServerAcceptAsync 返回即握手完成;握手失败与 accept 失败同路径重试(重新 accept 下一个连接)。无 ServerCertificateTlsTransportOptions 上则工厂构造时抛
串口 打开端口(Open(),失败→Retry 重试) 构造时抛 NotSupportedException(fail-fast:串口无监听概念,SECS-I 双端都是打开即用)

串口特有语义(写入文档):无 FIN/EOF——"对端正常关闭"不存在,拔线/驱动故障表现为 IOException/TimeoutException → 会话故障 → Retry(重开端口);半开连接无法由传输探测,必须开 ReceiveIdleTimeoutMs 或应用层心跳。有界 ReceiveQueueCapacity 在串口上不会产生 TCP 式背压,消费停滞可能导致驱动缓冲溢出丢字节——文档建议串口启用硬件流控(Handshake)或保持接收通道无界。

六、TFM 策略(问题 5)

核心抽象 + TCP + TLS 全部进主包(TFM 不变:netstandard2.0;net8.0;net10.0):SslStream 在三个目标上都有 API 面(ns2.0 ref 内置 System.Net.Security、netfx 内建 System.dll、net8+ 内置于共享框架),无新增包依赖

串口单独成包 StreamFrame.Serial,多目标 net48;net8.0;net10.0

  • 事实核查(与任务书的前提有出入,请评审知悉):System.IO.Ports确实有 netstandard2.0 资产,但该资产运行时仅 Windows(非 Windows 抛 PlatformNotSupportedException,即 Andrew Lock 批评过的"撒谎的 ns2.0 资产");跨平台(Windows + Linux)支持来自 net6/net8+ 资产;macOS 不支持。
  • 因此:net48 目标不引包,直接用 .NET Framework 内建的 System.IO.Ports.SerialPort(System.dll,2.0 起就有);net8/net10 目标引 System.IO.Ports 10.x(Windows + Linux)。不做 ns2.0 目标——避免发出一个跨平台名不副实的资产。ns2.0-only 的中间库接不了串口包,属可接受限制(串口消费者本来就是具体应用)。
  • net48 目标在 Linux CI 上可构建:加 Microsoft.NETFramework.ReferenceAssemblies(PrivateAssets,仅构建期)。
  • 串口包的 ns2.0/netfx Stream Memory 缺口与 TLS 共用一个内部 byte[] shim(见下)。

netfx 的 Stream Memory shimStreamTransportCompat.ReadAsync(Stream, Memory, ct) / Write——ns2.0/net48 编译目标用持久 byte[] + 一次拷贝;net8+ 直走 Memory 重载。只影响 netfx 上的 TLS/串口读路径(每次读多一次 memcpy),TCP 路径零改动(socket 直连,不经 Stream)——与仓库既有兼容取舍一致(PolySharp、SocketTaskExtensions 回退、StxEtx 手工扫描)。

CI 影响:串口测试并入现有 test/StreamFrame.Tests(测试项目已多目标 net8/net10/net48,加 ProjectReference 按目标自动匹配)——ci.ymldotnet test StreamFrame.slnx 循环自动带上,不新增必需检查名(AGENTS.md 警告的分支保护检查名问题不触发)。若评审倾向独立测试工程,则需同步改分支保护必需检查名,请确认(第十节 ⑦)。

AOT 门禁AotSmoke 增加 TLS 路径(SslStream 无反射重路径,预期绿);串口包 AOT 预期安全(SerialPort 无反射),首版不设门禁、留观察。

七、现有并发保证的传输无关性逐条核对(约束 1 审计)

机制 所在层 是否触碰 socket 抽象后 风险点
会话编号/纪元线性化(CommunicationStateChanging:分配/归零先于 Connected/Retry 发布,CurrentSessionId 语义) 纯状态机 不动 无——传输重构不进这一层
_acceptLoopId 代次门控(#47 阶段二) 连接层 间接(保护 _server 所有权) 锁与代次检查留在连接层,保护对象换成 _listenerInitServer 的 bind/listen/SO_REUSEADDR 搬进 TcpTransportListener,bind 失败从 CreateListener() 抛出、走既有异常路径(StartAsync catch → 退避重试)——与现状 InitServer 抛出路径等价 bind 失败时监听器对象不得残留(TCP 实现内自清理,对应现状"Bind 抛出时 _server 未赋值"语义)
未完成帧超时(IncompleteFrameTimeoutMs,T8 类) FrameDecoder 解码层 不动
接收空闲超时(ReceiveWithIdleTimeoutAsync 读循环包装 是(socket.ReceiveAsync 泛化为包装 ITransportConnection.ReadAsync;OCE→SessionFaultException 与"Windows 取消折算 0 字节"防御原样保留(防御性,对 Stream 语义无害)
发送串行化(_sendLock)/ 会话绑定条目三态 CAS / 提交点=认领 / "绝不跨会话重放" Channel + 状态字 否(SendRawAsync 只换底层调用) 不动WriteAsync 返回计数保住"循环续写"结构
RawBytesReceived/RawBytesSent 钩子 收发路径 Received 按读块回调(不变);Sent 按传输写出粒度:TCP 部分写计数原样保留(语义不变),TLS/串口为整帧粒度(SslStream.WriteAsync 无部分计数) 文档写明粒度差异;TLS 回调的是明文(加密前/解密后),密文不可见——请确认口径(第十节 ⑥)
会话拆线(StopSessionCore/ShutdownShutdown(Both)+Dispose) 拆线路径 ShutdownAsync()(尽力,catch+log 与现状一致)+ Dispose();TCP 实现逐字节等价;串口 Abort 走防挂死路径 串口 Close 挂死是经典坑,Abort 必须 Discard + 后台线程 + 超时兜底(实现项)
WaitForConnectedAsync / GetMessages 通道生命周期 状态机/通道 不动
metrics endpoint 标签 ConnectionMetrics 间接 factory.Description内建 TCP 路径生成与现状完全相同的字符串"{ip}:{port}"),现有仪表盘不受影响 自定义传输下标签值变化(必然,文档化)
Pipe 段尺寸对齐(v2.5.0) StartSession 不动(继续用 SocketReceiveBufferSize

结论:约束 1 的全部保证都是传输无关的,前提是上述三处搬运(监听器创建路径、读调用、写调用)保持异常路径等价——这将是 PR-A 评审的重点清单。

八、测试策略(问题 6)

回归底线:现有 98×3(net8.0 / net10.0 / net48)测试原样全绿,一个不改。

新增:

  1. 等价性:显式 TcpTransportFactory 构造 vs 旧构造,双连接对同一裸 TCP 回环对端,状态序列/消息收发/RemoteIpAddress/metrics 标签一致。
  2. Fake 传输(内存双工字节管道 + 故障注入:读/写抛异常、EOF 注入、部分写、握手挂起):在无 socket 下测状态机——连接失败退避重试、EOF→Retry、空闲超时、ShutdownAsync 失败不阻断拆线、僵尸接受循环复现 bug: 用户显式 Reconnect() 与自动重连竞速可长期楔死被动端监听(重绑失败 + 接受循环持有锁重试) #47 场景(fake listener 上两代 accept 竞速,断言无泄漏监听器)。这套测试三个 TFM 全跑。
  3. TLS e2e(net8/net10):运行时 CertificateRequest 生成一次性自签证书(无密钥进仓)做回环——收发往返、杀内层 socket 触发重连、证书校验拒绝路径、握手挂起→Abort、RawBytesReceived 收到明文断言。服务端用 TlsTransportFactory 被动模式全覆盖状态机。
  4. TLS on net48:运行时 e2e 跳过(CertificateRequest 无 netfx 实现;进仓 PFX 或 CI 生成证书两案成本/整洁度不佳——请确认,第十节 ④);ns2.0 的 Stream Memory shim 用 MemoryStream 单测覆盖正确性,net48 兼容由既有矩阵构建保证。
  5. 串口:选项→SerialPort 属性映射、无监听 NotSupportedException、端口打开失败→Retry 状态机(内部缝隙注入 fake Stream 模拟 BaseStream,SerialTransportFactory 预留 internal 构造缝隙)。真实 COM 不进 CI:com0com 本地手动验证写成 docs 手册(两台设备互测的 checklist)。
  6. soak/混沌:每夜 soak 增加 TLS 回环变体(复用 Soak_ReconnectRacing_LongRun 与随机故障混沌的全部动作);fake 传输变体可选(状态机并发正确性验收面)。
  7. 基准bench/EndToEndBenchmarks 改造前后各跑一轮,TCP 吞吐/延迟差异须在噪声内(每次 IO 多一次虚调用,量级可忽略,以数据说话)。

九、需要评审确认的决策点(对应 #39 的"请确认"环节)

  1. ITransportConnection 形状:小接口(推荐,第二节四缺口论证)vs 裸 Stream(更少概念但丢部分写可见性/Abort/优雅关)。特别确认:WriteAsync 返回部分写出计数这个形状。
  2. TCP-only 选项在自定义传输下文档化忽略(推荐)vs 构造时抛异常。
  3. 串口包 TFMnet48;net8.0;net10.0(推荐,第六节论证)vs 仅 net8.0;net10.0(受众收缩、net48 工业场景损失大)。
  4. net48 的 TLS e2e:跳过运行时测试(推荐)vs 进仓一次性测试证书 vs CI Windows 作业生成证书注入。
  5. IpAddress/Port/RemoteIpAddress 在非 TCP 传输的取值:接口不动;TCP/TLS 工厂构造时照常填充(工厂暴露地址信息,连接层识别采用),其它传输 IPAddress.None/0/null + 文档。可接受?
  6. TLS 下 RawBytes* = 明文、密文不可见:HEX 日志口径确认。
  7. 测试并入现有测试项目(推荐,无新必需检查名)vs 独立串口测试工程(需同步改分支保护必需检查名)。
  8. 版本号 3.0.0(主包与串口包同版起步;"3.0 级架构演进"定位)。

十、实施切分

定稿后按三个 PR、每个独立可合(都走 AGENTS.md 标准流程):

  • PR-A(核心重构,行为等价):传输三接口 + TcpTransportFactory/TcpTransportListener/TcpTransportConnection(代码原样迁移)+ StreamConnection 改造(字段/四条调用路径/新构造重载)+ Fake 传输 + 等价性测试。
  • PR-B(TLS)TlsTransportFactory + TLS 测试 + soak 变体 + AotSmoke TLS 路径 + README/DESIGN 传输章节。
  • PR-C(串口包)src/StreamFrame.Serial + CI 适配(无新检查名)+ docs 手册 + release.yml 增加第三个 csproj 的版本一致性校验与打包。

CHANGELOG Unreleased 按新增/变更分类记录;API 兼容性以"现有构造函数与两个接口签名不动"为硬约束自查。

验收标准

  • 现有 98×3 全绿(零修改);新增测试全绿。
  • 旧构造路径 TCP 行为逐字节等价:全部既有测试 + 基准在噪声内。
  • 第七节审计表的每一行在 PR 描述中逐条对应到实现与测试证据(v2.3.0 评审教训:并发边界写清线性化点)。
  • TLS 回环进入每夜 soak 且绿;混沌动作全复用。
  • AOT 门禁含 TLS 路径且绿。
  • 文档(README 双语 + DESIGN.md)含:选项生效矩阵、传输语义差异表(EOF/半开/背压)、串口手册、TLS 证书与校验回调指引。

参考#39(能力接口设计先例与评审流程)、#47(代次门控——本设计必须保住其语义)、PR #42#39 的实现)、CHANGELOG 2.3.1(v2.3.0 评审教训:线性化点、双检、提交点)。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions