夜雨聆风学习资料网

ARTICLE · 1225758

[NL源码精读] ITransport 传输层抽象:为什么 SendReceive 必须是一个方法

[NL源码精读] ITransport 传输层抽象:为什么 SendReceive 必须是一个方法

[NL源码精读] NewLife.IoT 专题系列 · 第 5 篇

ITransport:驱动与物理介质的解耦边界

前 4 篇讲清了接口、物模型、点位、插件体系。这一篇回到最底层——驱动写完了、扫描注册好了,但**字节从哪来、发去哪**?NewLife.IoT 用一个 62 行的 ITransport 接口,把串口 / TCP / UDP / HTTP 四种传输抽象成 4 属性 + 4 方法。这一篇逐行走一遍串口 186 行、Socket 153 行、TCP 20 行三个实现,讲清驱动与物理介质的解耦到底怎么落地。

引:驱动为什么要和传输层解耦?

没有 ITransport 之前,Modbus 驱动的代码会长这样:

// 反例:传输介质写死在驱动里 public class ModbusDriver {     private SerialPort _sp;           // 串口     // 或者 private TcpClient _tcp;  // TCP     // 或者 private UdpClient _udp;  // UDP      public void Read(...)     {         _sp.Open();         _sp.Write(frame);         // ... 读响应     } } 

Modbus 是协议,不是介质——它可以走串口、TCP、UDP、DTU、HTTP 隧道。一旦把串口写死,ModbusDriver 就永远不能通过 TCP 走——得再写一个 ModbusTcpDriver。NewLife.IoT 的答案是:传输层单独一个接口 ITransport,驱动只依赖这个抽象。

ITransport(ITransport.cs:15-61)只有 62 行:

// ITransport.cs:15-61 public interface ITransport : IDisposable {     #region 属性     Boolean IsConnected { get; }              // 是否已连接     Int32 Timeout { get; set; }                // 收发超时 ms     ITracer? Tracer { get; set; }              // 性能追踪     ILog Log { get; set; }                     // 日志      #region 核心方法     void Open();                              // 打开连接     void Close();                             // 关闭连接     void Send(ReadOnlySpan<Byte> data);       // 发送     Byte[]? Receive(Int32 timeout = -1);      // 接收     Byte[]? SendReceive(ReadOnlySpan<Byte> req,                         Int32 timeout = -1);  // 原子请求-响应     #endregion } 

4 属性 + 4 方法 + 1 个 IDisposable——这就是驱动能感知的全部物理层。驱动不关心下面跑的是串口还是 TCP,只关心"打开、发、收、关"。

先看整条抽象与实现的关系:

图 1:ITransport 抽象下的三种链路 —— 独立发送 / 独立接收 / 原子 SendReceive,SocketTransport 与 SerialTransport 各自实现

一、SendReceive:为什么单独一个方法?

4 个方法里 SendReceive 是关键设计。为什么不用 Send + Receive 组合?接口注释(ITransport.cs:50-57)解释得清清楚楚:

// ITransport.cs:50-57 /// 发送请求并等待响应。串行请求-响应模式的核心方法 ///  /// 对于Modbus、自定义串口协议等请求-响应型协议,推荐使用此方法。 /// 内部保证发送和接收的原子性,多线程场景下自动串行化。 /// 

原子性是 SendReceive 存在的核心理由。想象多线程场景:线程 A 调用 Send 后,线程 B 立刻调用 Receive——A 发的字节 B 收到了。这就是串口语境下的经典竞态。SendReceive 把 send + receive 放在同一把锁里,保证"发了就必须等到自己的响应"。

接口设计上的关键洞察:物理层不该暴露竞态条件。Send + Receive 组合留给特殊场景(比如推送型协议,Send 后立即触发回调),日常请求-响应走 SendReceive。这比让调用方自己去加锁安全多了。

二、SerialTransport:串口实现 186 行

SerialTransport(SerialTransport.cs:15-186)是 4 种实现里最复杂的(186 行)。它把 ISerialPort 封装成 ITransport:

// SerialTransport.cs:15-48 public class SerialTransport : DisposeBase, ITransport {     public String PortName { get; set; } = "COM1";     public Int32 Baudrate { get; set; } = 9600;     public Int32 DataBits { get; set; } = 8;     public Parity Parity { get; set; } = Parity.None;     public StopBits StopBits { get; set; } = StopBits.One;      /// 超时时间,ms。发起请求后等待响应的超时时间     public Int32 Timeout { get; set; } = 3000;      /// 字节超时,ms。数据包字节间隔     public Int32 ByteTimeout { get; set; } = 10;      public Boolean IsConnected { get; private set; }     public ITracer? Tracer { get; set; }     public ILog Log { get; set; } = Logger.Null;      private ISerialPort? _serialPort;     private readonly Object _lock = new(); } 

几个关键设计:

① 双超时:Timeout=3000(请求-响应超时)+ ByteTimeout=10(字节间超时)。串口没有 TCP 那种"连接建立/关闭"语义,包边界靠字节间隔判断——ByteTimeout 就是"如果 10ms 内没有新字节,就算一个包收完了"。

② IsConnected 私有 set:驱动读不到,只能读;串口打开/关闭的状态只由 SerialTransport 内部维护。

③ lock(_lock):所有 I/O 操作都在同一把锁下。这是多线程安全的保证——4 个方法(Open/Close/Send/Receive/SendReceive)都在 lock 里。

④ CreateSerial 可重写:protected virtual ISerialPort CreateSerial()——用户不用改 SerialTransport 代码,写个派生类覆盖 CreateSerial 就能用自定义串口实现(比如加密串口、虚拟串口、Mock 串口)。

三、SendReceive 的原子性实现:sp.Invoke

SendReceive 是整篇文章的精华。看串口实现(SerialTransport.cs:154-174):

// SerialTransport.cs:154-174 public Byte[]? SendReceive(ReadOnlySpan<Byte> request, Int32 timeout = -1) {     lock (_lock)     {         if (!IsConnected) Open();         var sp = _serialPort!;          var oldTimeout = sp.Timeout;         if (timeout >= 0 && timeout != oldTimeout) sp.Timeout = timeout;         try         {             var pk = new ArrayPacket(request.ToArray());             var response = sp.Invoke(pk, 1);   // ★ 关键一行             return response?.ToArray();         }         finally         {             if (sp.Timeout != oldTimeout) sp.Timeout = oldTimeout;         }     } } 

4 层嵌套:lock(线程安全)→ 超时切换(try/finally 恢复)→ Invoke(原子请求-响应)→ 恢复超时。

关键在 sp.Invoke(pk, 1)——底层 ISerialPort 的 Invoke 方法本身就保证原子性。发送数组 + 1 个响应,锁在 Invoke 内部。SerialTransport 只是把它包了一层 ITransport 语义。

这里有个工业现场的实用技巧:try/finally 保存了 oldTimeout,允许 按请求覆盖超时。有些关键操作(比如紧急停机)要 100ms 超时,普通读操作要 3000ms——传 timeout 参数即可,不影响其它调用的超时配置。

四、SocketTransport:网络抽象基类

SocketTransport(SocketTransport.cs:13-153)是抽象基类,TCP/UDP 共享这个骨架。它把 ISocketClient 封装成 ITransport:

// SocketTransport.cs:13-42 public abstract class SocketTransport : DisposeBase, IoTTransport {     public String Server { get; set; } = "127.0.0.1";     public Int32 Port { get; set; } = 5500;     public Int32 Timeout { get; set; } = 3000;      public Boolean IsConnected => _client != null;     public ITracer? Tracer { get; set; }     public ILog Log { get; set; } = Logger.Null;      private ISocketClient? _client;     private readonly Object _lock = new(); } 

对比 SerialTransport 会发现几乎一模一样的模式:Server+Port+Timeout+Tracer+Log,一个 _lock,一个 _client 私有字段。区别只在两个地方:

① IsConnected 是只读属性:public Boolean IsConnected => _client != null;——Socket 没有"打开"概念,client 对象存在就认为已连接。串口需要显式 Open,Socket 是懒创建。

② CreateClient 是抽象方法:protected abstract ISocketClient CreateClient();——子类必须重写,因为 TCP 和 UDP 创建客户端的方式不同。

SendReceive 的网络实现(SocketTransport.cs:120-141):

// SocketTransport.cs:120-141 public Byte[]? SendReceive(ReadOnlySpan<Byte> request, Int32 timeout = -1) {     lock (_lock)     {         if (_client == null) Open();         var client = _client!;          var oldTimeout = client.Timeout;         if (timeout >= 0 && timeout != oldTimeout) client.Timeout = timeout;         try         {             var pk = new ArrayPacket(request.ToArray());             client.Send(pk);             var response = client.Receive();    // ★ TCP 分两步             return response?.ToArray();         }         finally         {             if (client.Timeout != oldTimeout) client.Timeout = oldTimeout;         }     } } 

网络版本的 SendReceive 是显式两步:client.Send(pk); client.Receive();——但整个操作在 同一把 lock 下,所以依然是原子的。原子性靠外层 lock 保证,不靠底层。

五、TcpTransport:20 行极简派生

TcpTransport(TcpTransport.cs:1-21)总共 20 行:

// TcpTransport.cs:1-21 namespace NewLife.IoTSocket.Transport;  /// TCP 传输层 public class TcpTransport : SocketTransport {     /// 创建 TCP 网络客户端     protected override ISocketClient CreateClient()     {         var uri = new NetUri(NetType.Tcp, Server, Port);         var client = uri.CreateRemote();         client.Timeout = Timeout;         return client;     } } 

12 行有效代码,只重写 CreateClient。模板方法模式的教科书级例子——SocketTransport 提供了完整的骨架(Open/Close/Send/Receive/SendReceive),TCP 只需要说"我是什么协议"。

UDP 也类似(153 行 SocketTransport 骨架 + 20 行 UDPTransport 派生),只是 CreateClient 里 NetType.Tcp 换成 NetType.Udp。HTTP 类似。这就是"一次抽象,多次复用"的价值。

六、超时策略:Timeout vs ByteTimeout

超时类型
语义
默认
适用介质
Timeout
请求发出到收到响应的总超时
3000 ms
串口 / TCP / UDP / HTTP
ByteTimeout
字节间的最大间隔,超过就认为包结束
10 ms
仅串口(TCP/UDP 有包头边界)

串口的 ByteTimeout 是工业现场必备——因为串口没有包边界。10 ms 是经验值:PLC 响应一般 <1ms 内连发,超过 10 ms 就说明"响应发完了"。TCP/UDP 因为 TCP 有自己的记录边界,不需要这个参数。

七、4 种实现矩阵

实现
行数
命名空间
SendReceive 原子性来源
SerialTransport
186
NewLife.IoTSerial
sp.Invoke(底层原子)
SocketTransport
153
NewLife.IoTSocket
外层 lock(Send+Receive)
TcpTransport
20
NewLife.IoTSocket
继承 SocketTransport
UdpTransport
约 20
NewLife.IoTSocket
继承 SocketTransport
HttpTransport
独立实现
NewLife.IoTSocket
HttpClient.Send 请求-响应

八、完整走一遍:Modbus 读 4 字节

假设走 TCP 的 Modbus,读寄存器 4x0010 起始 2 个寄存器(4 字节):

// 1) 创建传输 var transport = new TcpTransport {     Server = "192.168.1.10",     Port = 502,     Timeout = 3000, };  // 2) 驱动用 SendReceive 原子请求-响应 var request = new Byte[] { 0x01, 0x03, 0x00, 0x0A, 0x00, 0x02, 0x84, 0x0A }; //                       slave=1  FC=03  addr=10  qty=2  crc16 var response = transport.SendReceive(request);  // 3) 驱动收到响应字节,交给上层解析 // response = 0x01 0x03 0x04 0x00 0x00 0x01 0xF4 ... 

从驱动视角看,SendReceive 是一个方法——里面走串口还是 TCP 完全透明。切换介质只需要改 new TcpTransport() 为 new SerialTransport(),驱动代码一行不改。

这就是依赖倒置的核心:驱动依赖 ITransport 抽象,而不是具体实现。切换实现的成本从"改整个驱动"降到"改一行 new"。

九、总结:传输层解耦的三原则

三个设计亮点: ① SendReceive 单方法封装原子性——请求-响应型协议直接调 SendReceive,不需要调用方关心加锁/时序;Send+Receive 只留给推送型协议; ② 模板方法模式——SocketTransport 提供 153 行骨架,TCP/UDP/HTTP 各派生 20 行;SerialTransport 独立实现,但暴露 CreateSerial 让用户可插拔自定义串口; ③ 双超时 + 按请求覆盖——Timeout 通用请求-响应超时,ByteTimeout 串口包边界专用;try/finally 保存 oldTimeout,允许按请求临时覆盖,不影响其它调用的默认配置。 

下篇预告:从零写一个自定义 Driver 全流程。前面 5 篇讲清了 IDevice/IDriver/INode 接口、ThingSpec 物模型、PointModel + 表达式、DriverFactory 插件、ITransport 传输层。最后一篇把这些组件全部串起来——从零写一个自定义 Modbus Driver,从参数类到驱动类到物模型到扫描注册到运行时读写,全程可跑。

📖 NL源码精读系列 · 全 6 篇

✓ 第 1 篇:三大接口全景——IDevice / IDriver / INode

✓ 第 2 篇:ThingSpecification 物模型四规格

✓ 第 3 篇:PointModel + IExpressionEngine 表达式引擎

✓ 第 4 篇:DriverAttribute + DriverFactory 插件体系

✓ 第 5 篇(本篇):ITransport 传输层抽象

□ 第 6 篇:从零写一个自定义 Driver 全流程

相关学习资料