ARTICLE · 1225758
[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
串口的 ByteTimeout 是工业现场必备——因为串口没有包边界。10 ms 是经验值:PLC 响应一般 <1ms 内连发,超过 10 ms 就说明"响应发完了"。TCP/UDP 因为 TCP 有自己的记录边界,不需要这个参数。
七、4 种实现矩阵
八、完整走一遍: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 全流程















































































