02 · 网络层与粘包处理
本文聚焦
com.github.xingshuangs.iot.net包(9 个文件):剖析TcpClientBasic的连接/读写全流程与超时体系、SocketUtils的读取实现、TcpServerBasic的线程模型、UdpClientBasic的超时处理,并对本库的粘包处理策略做专项评价(与 Hsl 的INetMessage、Netty 的LengthFieldBasedFrameDecoder三方对比)。
本文目录
- 1. 网络层全景
- 2. TcpClientBasic:字段与状态机
- 3. TcpClientBasic:connect / close 全流程
- 4. TcpClientBasic:read / write 重载矩阵
- 5. SocketUtils:读取实现与粘包核心
- 6. 粘包处理专项评价
- 7. TcpServerBasic:服务端线程模型
- 8. UdpClientBasic:超时与丢包
- 9. TcpClientBasic 子类全景
- 10. 设计优点 / 缺点 / 可借鉴点
1. 网络层全景
net/
├── ICommunicable.java 接口:close / write×2 / read×2(弱契约,见 01 篇 §4.1)
├── SocketUtils.java 静态工具类:真正干活的读/写/关闭/状态判断
├── IAckListener.java 异步响应监听(ok/fail)—— 全库未被引用
├── AckCallback.java 响应回调 + wait/notify —— 全库未被引用
├── client/
│ ├── TcpClientBasic.java ★ TCP 客户端基类(376 行)
│ ├── UdpClientBasic.java ★ UDP 客户端基类(329 行)
│ └── UdpClientSample.java UDP 使用示例
└── server/
├── TcpServerBasic.java ★ TCP 服务端基类(360 行)
└── UdpServerBasic.java UDP 服务端基类
一句话概括:TcpClientBasic 负责"连接生命期 + 参数传递",SocketUtils 负责"真正的 IO 循环",两者是薄壳 + 实现的关系。
2. TcpClientBasic:字段与状态机
net/client/TcpClientBasic.java:46-133:
46→public class TcpClientBasic implements ICommunicable {
54→ protected String tag = ""; // 日志 TAG,子类填 "S7"/"ModbusTcp"/"MC"/"RTSP"
60→ protected Socket socket; // 唯一连接句柄,非 volatile、无池
66→ protected int connectTimeout = 10_000; // 连接超时,默认 10s
72→ protected int receiveTimeout = 10_000; // 接收超时(= SO_TIMEOUT),默认 10s
78→ protected final InetSocketAddress socketAddress; // final,构造后不可改
84→ protected final AtomicBoolean socketError; // 错误标记,AtomicBoolean
89→ protected boolean enableReconnect = true; // 自动重连开关
2.1 字段表
| 字段 | 行 | 类型 | 默认 | 说明 | 备注 |
|---|---|---|---|---|---|
tag |
54 | String |
"" |
日志标识 | 子类在构造里赋值,如 this.tag = "S7"(PLCNetwork.java:114) |
socket |
60 | Socket |
null |
连接句柄 | 无 volatile / 无锁保护,多线程直接读写存在可见性问题 |
connectTimeout |
66 | int |
10_000 |
连接超时 ms | 传给 socket.connect(addr, timeout) |
receiveTimeout |
72 | int |
10_000 |
接收超时 ms | 传给 socket.setSoTimeout() |
socketAddress |
78 | InetSocketAddress |
— | 目标地址 | final,构造即定 |
socketError |
84 | AtomicBoolean |
false |
错误标记 | 唯一用了并发原语的字段 |
enableReconnect |
89 | boolean |
true |
自动重连 | RtspNetwork 显式置为 false(RtspNetwork.java:154 附近注释:"不需要自动重连,连不上就停掉") |
2.2 状态判断
// net/client/TcpClientBasic.java:144-146
144→ public boolean checkConnected() {
145→ return !this.socketError.get() && SocketUtils.isConnected(this.socket);
146→ }
// net/SocketUtils.java:66-68
66→ public static boolean isConnected(final Socket socket) {
67→ return socket != null && (socket.isConnected() && !socket.isClosed());
68→ }
语义坑:JDK 的 Socket.isConnected() 只表示"曾经成功连接过",对端断开后仍返回 true(只有本地 close 才变 false)。所以 checkConnected() 只能检测"本地是否已关闭",无法检测半开连接;叠加"未启用 keepAlive"(源码中未见 setKeepAlive),断网可能要等 receiveTimeout 才暴露(需实测确认)。
3. TcpClientBasic:connect / close 全流程
3.1 connect() —— "先自杀再重生"
// net/client/TcpClientBasic.java:154-159
154→ public void connect() {
155→ this.close();
156→ // socket设置为null,添加这一行可以跳过是否重连的校验,保证肯定连接
157→ this.socket = null;
158→ this.getAvailableSocket();
159→ }
三行要点:close() 释放旧句柄;socket = null 是刻意绕过 enableReconnect 校验的 hack(注释写得很直白);真正的建连在 getAvailableSocket()。
3.2 getAvailableSocket() —— 核心建连逻辑
// net/client/TcpClientBasic.java:168-194
168→ public Socket getAvailableSocket() {
169→ // socket连接过了,同时又不支持自动重连,直接返回
170→ if (this.socket != null && !this.enableReconnect) {
171→ return this.socket;
172→ }
173→
174→ // 已连接的直接返回socket
175→ if (this.checkConnected()) {
176→ return this.socket;
177→ }
178→ // 未连接,表示已断开,需要手动关闭socket,创建新的socket
179→ this.close();
180→
181→ try {
182→ // 重新创建对象,并连接
183→ this.socket = new Socket();
184→ this.socket.setSoTimeout(this.receiveTimeout);
185→ this.socket.connect(this.socketAddress, this.connectTimeout);
186→ this.socketError.set(false);
187→ // 创建并连接{}服务端[{}]成功
188→ log.debug("Create socket and connect to {} server [{}] succeed", this.tag, this.socketAddress);
189→ this.doAfterConnected();
190→ return socket;
191→ } catch (IOException e) {
192→ throw new SocketRuntimeException(e);
193→ }
194→ }
关键设计点:
- lazy 建连——每个
read/write开头都先调它(TcpClientBasic.java:258、367),用户不必显式connect()。 doAfterConnected()是模板方法钩子(TcpClientBasic.java:216-218默认空实现):S7 用它做 TPKT/COTP 握手 + PDU 协商(PLCNetwork.java:135-140),RTSP 用它做option/describe/setup/play。全库最干净的一处模板方法应用。- 没有
setTcpNoDelay/setKeepAlive(源码中未见):小报文强交互场景下 Nagle 可能多一个 RTT(需实测确认);半开连接无法检测。
3.3 长连接 vs 短连接
本库的长短连接不在 TcpClientBasic 里实现,而是由协议层用 persistence 字段在每次通信后决定是否 close():
// protocol/s7/service/S7PLC.java 等处反复出现的模式(S7PLC.java:161-166 / ModbusTcp.java:161-166)
} finally {
if (!this.persistence) {
log.debug("Due to the short connection mode, the communication is triggered to close ...");
this.close();
}
}
| 模式 | persistence |
行为 | 适用 |
|---|---|---|---|
| 长连接 | true(默认) |
连接保持,靠 getAvailableSocket() 自动重连 |
高频采集 |
| 短连接 | false |
每次请求-响应后 close() |
PLC 连接数受限(如 S7-200 只有少数连接资源) |
协议层还重写 connect() 配合短连接(ModbusSkeletonAbstract.java:129-137),即短连接模式下显式 connect() 会立刻又关掉——因为连接是在第一次 read/write 时 lazy 建立的。反直觉但逻辑自洽。
4. TcpClientBasic:read / write 重载矩阵
read 有 6 个重载,write 有 4 个重载,最终都收敛到一个"全参数"版本。
4.1 read 重载矩阵
| 行 | 签名 | maxLength |
timeout |
waitForMore |
|---|---|---|---|---|
| 274 | read(byte[] data) |
-1 |
receiveTimeout |
false |
| 287 | read(byte[] data, int timeout) |
-1 |
指定 | false |
| 301 | read(byte[], int off, int len) |
-1 |
receiveTimeout |
false |
| 316 | read(byte[], off, len, boolean waitForMore) |
1024 |
receiveTimeout |
指定 |
| 331 | read(byte[], off, len, int timeout) |
-1 |
指定 | false |
| 347 | read(byte[], off, len, maxLength, timeout) |
指定 | 指定 | false |
| 364 | read(byte[], off, len, maxLength, timeout, waitForMore) |
指定 | 指定 | 指定 |
⚠️ 注意第 316 行的重载有个"隐藏行为":它把
maxLength硬编码成 1024(TcpClientBasic.java:317)。这是唯一一个默认带maxLength的入口。
最终实现(TcpClientBasic.java:364-373):
366→ try {
367→ Socket availableSocket = this.getAvailableSocket();
368→ return SocketUtils.read(availableSocket, data, offset, length, maxLength, timeout, waitForMore);
369→ } catch (IOException e) {
370→ this.socketError.set(true);
371→ throw new SocketRuntimeException(e);
372→ }
4.2 write 重载矩阵
| 行 | 签名 | maxLength |
|---|---|---|
| 229 | write(byte[] data) |
-1 |
| 242 | write(byte[], off, len) |
-1 |
| 256 | write(byte[], off, len, maxLength) |
指定 |
最终实现(TcpClientBasic.java:256-264)与 read 对称:先 getAvailableSocket(),再 SocketUtils.write(...),catch (IOException) 时置 socketError=true。这个标记是唯一的自愈机制——下次 getAvailableSocket() 就会走重连分支。
4.3 maxLength 与超时体系
maxLength = "单次系统调用允许读/写的最大字节数"(SocketUtils.java:162、225 的 Math.min(maxLength, length - count))。对 TCP 而言 OutputStream.write 本身就是完整写入,切分只增加开销;仅对限制单次读写长度的嵌入式协议栈有意义。本库主流程几乎都传 -1(忽略),属于参数冗余。
超时体系:
| 超时 | 设置点 | 默认值 | 影响 |
|---|---|---|---|
| 连接超时 | socket.connect(addr, connectTimeout)(TcpClientBasic.java:185) |
10 000 ms | 建连阶段 |
| 接收超时 | socket.setSoTimeout(receiveTimeout)(TcpClientBasic.java:184) |
10 000 ms | 建连时设定一次 |
| 每次读的超时 | socket.setSoTimeout(timeout)(SocketUtils.java:155) |
调用方传入 | 每次 read 前重设 |
⚠️ 隐式副作用:SocketUtils.read() 每次进入都 socket.setSoTimeout(timeout),所以 receiveTimeout 只是默认值,实际生效的是每次调用传入的 timeout,且读完不恢复。另外 timeout=0 表示永不超时(JDK 语义),timeout<0 抛 IllegalArgumentException(SocketUtils.java:151-153)。
5. SocketUtils:读取实现与粘包核心
5.1 核心 read 实现(逐行)
// net/SocketUtils.java:146-176
146→ public static int read(final Socket socket, final byte[] data, final int offset, final int length,
147→ final int maxLength, final int timeout, final boolean waitForMore) throws IOException {
148→ if (offset + length > data.length) {
149→ throw new IllegalArgumentException("offset+length");
150→ }
151→ if (timeout < 0) {
152→ throw new IllegalArgumentException("timeout>=0");
153→ }
154→ // 阻塞不是指read的时间长短,可以理解为没有数据可读,线程一直在这等待
155→ socket.setSoTimeout(timeout);
156→ // 读取个数
157→ int count = 0;
158→ // 读取位置平移量,会变
159→ int off = offset;
160→ InputStream in = socket.getInputStream();
161→ while (count < length) {
162→ int len = maxLength <= 0 ? length - count : Math.min(maxLength, length - count);
163→ int num = in.read(data, off, len);
164→ if (num < 0) {
165→ // 读取数据异常,未读取到数据,连接断开
166→ throw new SocketException("The end of the stream has been reached, and disconnected");
167→ }
168→ count += num;
169→ off += num;
170→ // 实际读取的数据长度比期望还小,则证明已经读取完了
171→ if (!waitForMore && num < len) {
172→ break;
173→ }
174→ }
175→ return count;
176→ }
5.2 三个决定性语义
(a) waitForMore = false(默认)时:读短即止。 第 171 行的 if (!waitForMore && num < len) break; 是整个粘包策略的开关:只要某次 in.read() 返回字节数小于本次期望的 len,就认为"这一帧读完了",立刻返回。好处是不会因"期望 1024 实际只有 20"而阻塞到 SO_TIMEOUT;坏处是一帧被拆成多个 segment 时会提前返回 → 半包。
(b) waitForMore = true 时:死等读满。 一直循环到 count == length,数据不够就阻塞在 in.read() 直到 SO_TIMEOUT 抛 SocketTimeoutException。用于"已确定长度、需读满剩余体"的场景(见 §6.2)。
(c) 返回值语义不一致。 waitForMore=false 时返回值可能 < length;waitForMore=true 时一定 == length(否则超时异常)。调用方必须知道自己传了什么——隐式契约,属于易错点。
5.3 write 实现
SocketUtils.write()(SocketUtils.java:215-230)与 read 对称,用 maxLength 切段循环 out.write(data, off, len)。由于 JDK 保证 OutputStream.write 对 Socket 完整写入,该循环实际只执行一次(maxLength<=0 时)——冗余但无害。
6. 粘包处理专项评价
6.1 本库的三种策略
本库没有统一的帧解码器(没有 FrameDecoder 接口、没有 INetMessage 等价物)。粘包处理散落在各协议的 readFromServer() 里,共出现三种形态:
| 策略 | 用于 | 机制 | 是否严格 |
|---|---|---|---|
| A. 两阶段读(有长度字段) | S7(TPKT)、Modbus TCP(MBAP)、MC 3E/4E | 先读定长头 → 解析长度 → 按长度读剩余体 | ✅ 严格,能正确处理粘包/拆包 |
| B. 一次读到不满即止 | Modbus RTU over TCP、Modbus ASCII over TCP、MC 1E 帧 | 分配 1024 字节 buffer,read() 默认 waitForMore=false,读短即返回 |
❌ 靠"运气",见 §6.4 |
C. 服务端 available() 试探 |
TcpServerBasic.readClientData() 默认实现 |
in.read() 读 1 字节 → in.available() 查剩余 → 按此分配 |
❌ 不可靠 |
6.2 策略 A:两阶段读(正确做法)
S7(protocol/s7/service/PLCNetwork.java:254-277):
258→ this.locker.lock();
259→ this.write(sendData);
260→
261→ byte[] data = new byte[TPKT.BYTE_LENGTH]; // TPKT.BYTE_LENGTH = 4
262→ len = this.read(data); // 阶段1:读 4 字节头
266→ tpkt = TPKT.fromBytes(data); // 解析出整帧长度
267→ total = new byte[tpkt.getLength()];
269→ len = this.read(total, TPKT.BYTE_LENGTH, tpkt.getLength() - TPKT.BYTE_LENGTH); // 阶段2
Modbus TCP(protocol/modbus/service/ModbusTcp.java:87-102):
91→ byte[] data = new byte[MbapHeader.BYTE_LENGTH]; // 7
92→ len = this.read(data);
96→ header = MbapHeader.fromBytes(data);
97→ total = new byte[data.length + header.getLength() - 1];
99→ len = this.read(total, data.length, header.getLength() - 1);
MC 3E/4E(protocol/melsec/service/McNetwork.java:189-200)—— 唯一显式传 waitForMore=true 的:
189→ int headerLength = this.frameType == EMcFrameType.FRAME_4E ? 15 : 11;
190→ byte[] data = new byte[headerLength];
191→ len = this.read(data);
196→ McHeader3EAck header = (McHeader3EAck) McHeaderAck.fromBytes(data, this.frameType);
197→ remainLength = header.getDataLength() - 2;
200→ len = this.read(total, data.length, remainLength, true); // ← waitForMore = true
注意:S7 和 Modbus TCP 的阶段 2 没传
waitForMore=true(走read(data, off, len)重载 →waitForMore=false),阶段 2 也可能"读短即止",靠随后的if (len < ...)长度校验兜底抛异常(PLCNetwork.java:274-277、ModbusTcp.java:104-107)。只有 MC 显式用了waitForMore=true——三处实现不一致的具体体现。
6.3 策略 C:available() 试探(不可靠)
net/server/TcpServerBasic.java:255-270:
255→ protected byte[] readClientData(Socket socket) {
256→ try {
257→ InputStream in = socket.getInputStream();
258→ int firstByte = in.read(); // 阻塞读 1 字节
259→ if (firstByte == -1) {
260→ SocketUtils.close(socket);
261→ throw new SocketRuntimeException("The client is actively disconnected");
262→ }
263→ byte[] data = new byte[in.available() + 1]; // ← 用 available() 定长度
264→ data[0] = (byte) firstByte;
265→ this.read(socket, data, 1, data.length - 1, 1024);
266→ return data;
267→ } catch (IOException e) {
268→ throw new SocketRuntimeException(e);
269→ }
270→ }
问题:InputStream.available() 返回的是"当前内核接收缓冲区中可读的字节数",不是"一帧的长度"。如果客户端分两次 send(或者网络层把包拆了),available() 只会返回先到的那部分 → 直接造成半包。
但本库做了补救:ModbusTcpServer 重写了这个方法(protocol/modbus/service/ModbusTcpServer.java:221-242):
231→ byte[] headerBytes = new byte[MbapHeader.BYTE_LENGTH];
232→ headerBytes[0] = (byte) firstByte;
233→ this.read(socket, headerBytes, 1, MbapHeader.BYTE_LENGTH - 1, 1024, 0, true); // waitForMore=true
234→ MbapHeader header = MbapHeader.fromBytes(headerBytes);
235→ byte[] total = new byte[headerBytes.length + header.getLength() - 1];
237→ this.read(socket, total, headerBytes.length, header.getLength() - 1, 1024, 0, true); // waitForMore=true
即服务端靠重写退化成"两阶段读 + waitForMore"。作者知道基类实现不可靠,但选择让子类兜底而非修基类。
6.4 策略 B:RTU/ASCII 的"读到满 1024 或读短即止"
protocol/modbus/service/ModbusRtuOverTcp.java:74-100:
79→ int len;
80→ byte[] data = new byte[1024];
81→ try {
82→ this.locker.lock();
83→ this.write(reqBytes);
84→ len = this.read(data); // read(byte[]) → waitForMore=false, timeout=receiveTimeout
85→ } finally {
86→ this.locker.unlock();
87→ }
这是本库最薄弱的一环。Modbus RTU over TCP 没有长度字段,帧边界靠"3.5 字符静默时间"(RTU)或 :...\r\n(ASCII)界定。本库只做"分配 1024 字节 + in.read() 返回多少算多少"。
- 正常路径:响应通常是一个完整 TCP segment 到达,一次
read拿全帧 → 正确。 - 错误路径:① 对端/网关分片 → 只读到前半 → 半包 → CRC 校验失败;② 上一帧有残留 → 一次 read 拿到"上一帧尾巴 + 本帧" → 错位;③ 响应 > 1024 字节被截断(Modbus PDU 上限 253 字节,实际不会触发)。
ASCII 模式额外用 : 与 \r\n 做界符(ModbusAsciiOverTcp.java:120:ackStr.replace(":", "").replace("\r\n", "")),但只在收到完整字符串后才有效,同样依赖"一次 read 拿全"。
结论(需实测确认):RTU/ASCII over TCP 在良好局域网下通常正常;跨网关、高延迟场景下存在半包/粘包风险。这是本库网络层最大的技术债。
6.5 三方对比表
| 维度 | iot-communication | HslCommunication(INetMessage) |
Netty(LengthFieldBasedFrameDecoder) |
|---|---|---|---|
| 抽象方式 | 无抽象,每个协议手写两阶段读 | 接口 INetMessage:;GetHeadBytesLength() + GetContentLength(headBytes) + CheckHeadBytesLegal() |
解码器 ChannelHandler,声明式配置 |
| 配置方式 | 硬编码在 readFromServer() 里 |
每个协议实现 INetMessage,绑定在 NetworkBase 上 |
构造参数:lengthFieldOffset/lengthFieldLength/lengthAdjustment/initialBytesToStrip |
| 两阶段读 | 手工:读定长头 → 解析 → 读剩余 | 框架驱动:基类按 INetMessage 自动两阶段 |
框架驱动:Cumulator 累积 + 按需拆帧 |
| 缓冲区 | 每次新建 byte[],用完即弃 |
每次新建 | 可复用 ByteBuf + 零拷贝,有 Cumulator 累积 |
| 半包处理 | waitForMore=true 时循环读满;否则读短即止(可能半包) |
循环读满 contentLength |
累积到 lengthField 指示长度才输出帧 |
| 粘包处理 | 依赖长度字段;无长度字段的协议靠"一次 read 拿全" | 同左 | 天然支持:多余字节留在累积区,下一帧继续用 |
| 无长度字段协议 | ❌ 退化为"读到不满即止" | ❌ 同样需要特殊处理(如 Modbus RTU 用 SpecifiedLength 等) |
✅ 可用 DelimiterBasedFrameDecoder / FixedLengthFrameDecoder / 自定义 |
| 异步 | ❌ 同步阻塞 BIO | ❌ 同步阻塞 BIO | ✅ NIO + EventLoop |
| 内存/性能 | 差(每次分配、线程阻塞) | 差 | 好(池化、零拷贝、单线程管多连接) |
| 代码量 | 每个协议约 15~25 行重复代码 | 每个协议一个 INetMessage 实现类 |
一行配置 |
| 可维护性 | 三处实现不一致(S7/Modbus 未传 waitForMore,MC 传了) | 统一 | 统一 |
| 学习成本 | 低(能看懂) | 中 | 高(需理解 Netty 管线) |
6.6 我的判断
iot-communication 的粘包处理:在有长度字段的协议上是对的,在没有长度字段的协议上是"赌运气"。
- 对 S7(TPKT)/ Modbus TCP(MBAP)/ MC 3E·4E 这三个真正的主战场,两阶段读是正确且充分的——因为请求-响应是严格串行的(有
ReentrantLock保护),且长度字段明确,粘包/拆包都能被长度字段自愈。 - 对 Modbus RTU over TCP / ASCII over TCP / MC 1E,缺乏帧界定机制,这是设计缺口。
- 与 Hsl 的
INetMessage相比:理念完全一致(都是两阶段读),差别在于 Hsl 把这个模式抽成了接口,本库是复制了三份。对于只支持 3 个协议的库,复制三份是可接受的工程权衡;但如果协议继续增加,就会失控。 - 与 Netty 相比:不在一个量级。Netty 是为"十万连接 + 高吞吐"设计的,本库是为"几台 PLC + 每秒几十次读写"设计的,场景不同,不能简单说本库落后。事实上对一个连 Netty 依赖都不肯引入的库来说,BIO + 两阶段读是合理选择。
如果要改进,最小代价的方案:把"两阶段读"抽成一个 IFrameDecoder 接口(或沿用 Hsl 的 INetMessage 形态),让 3 个协议共用一份实现,并给 RTU/ASCII 补一个"按 3.5 字符时间 / 界符"的定界策略。
7. TcpServerBasic:服务端线程模型
7.1 启动
// net/server/TcpServerBasic.java:90-103
90→ public void start(int port) {
92→ try {
93→ this.port = port;
94→ this.stop();
95→ this.executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
96→ this.serverSocket = new ServerSocket(port);
97→ Thread thread = new Thread(this::waitForClients);
98→ thread.setDaemon(true);
99→ thread.start();
100→ } catch (IOException e) {
101→ throw new SocketRuntimeException(e);
102→ }
103→ }
| 项 | 值 | 评价 |
|---|---|---|
| 线程池 | newFixedThreadPool(availableProcessors()) |
核心数 = 最大并发处理连接数。8 核机器 → 同时只能处理 8 个连接的业务 |
| accept 线程 | 1 个 daemon 线程 | ✅ 合理,daemon 不阻止 JVM 退出 |
ServerSocket |
无 setReuseAddress、无 backlog 参数 |
走 JDK 默认 |
| 队列 | LinkedBlockingQueue(无界) |
连接堆积时 accept 仍会成功,但业务处理排队 → 请求被无限延迟而非快速失败 |
7.2 accept 循环
// net/server/TcpServerBasic.java:138-154
138→ protected void waitForClients() {
139→ // 开启等待客户端线程,端口号[{}]
140→ log.debug("Open accept thread and waiting for clients, port number [{}]", this.port);
141→ while (this.isAlive()) {
142→ try {
143→ Socket client = this.serverSocket.accept();
144→ if (!this.checkClientValid(client)) {
145→ SocketUtils.close(client);
146→ }
147→ this.executorService.execute(() -> this.doClientConnected(client));
148→ } catch (IOException e) {
149→ if (this.isAlive()) {
150→ log.error(e.getMessage());
151→ }
152→ }
153→ }
154→ }
三个问题:
- 第 143-147 行有 bug 倾向:
checkClientValid返回false时先close(client),但没有continue,紧接着仍execute(() -> doClientConnected(client))把已关闭的 socket 丢进线程池。doClientConnected里SocketUtils.isConnected(client)返回 false 从而跳过循环,功能上"碰巧"没出错,但语义错(ModbusTcpServer.clientConnected()仍会getAndIncrement()—— 已拒连的客户端也被计入连接数)。 - 无 NIO / 无
Selector:accept()阻塞在单线程,BIO 服务端标准做法,可接受。 stop()里serverSocket.close()会让accept()抛SocketException,靠isAlive()判断抑制错误日志(第 148-151 行)——这个处理是对的。
7.3 连接处理循环(每连接一线程)
// net/server/TcpServerBasic.java:177-202
177→ protected void doClientConnected(Socket client) {
179→ log.debug("The client [{}] is connected", client.getRemoteSocketAddress());
180→ this.clientConnected(client);
181→ try {
182→ if (this.checkHandshake(client) && this.isAlive()) {
183→ while (SocketUtils.isConnected(client)) {
184→ this.doClientHandle(client);
185→ }
186→ }
187→ } catch (Exception e) {
188→ if (SocketUtils.isConnected(client)) {
189→ log.error(e.getMessage());
190→ }
191→ } finally {
192→ try {
193→ SocketUtils.close(client);
194→ } catch (Exception ex) {
195→ // NOOP
196→ }
197→ }
198→
199→ this.clientDisconnected(client);
201→ log.debug("The client [{}] is disconnected", client.getRemoteSocketAddress());
202→ }
线程模型图:
┌─────────────────────────┐
│ accept 线程 (daemon) │
│ while(isAlive) │
│ serverSocket.accept() │
└───────────┬─────────────┘
│ 每来一个连接
▼
┌──────────────────────────────────────┐
│ FixedThreadPool(N = CPU 核数) │
│ ┌────────┐┌────────┐┌────────┐ │
│ │线程 #1 ││线程 #2 ││线程 #N │ ... │
│ │ conn A ││ conn B ││ conn C │ │
│ │ while ││ while ││ while │ │
│ │ handle ││ handle ││ handle │ │
│ └────────┘└────────┘└────────┘ │
└──────────────────────────────────────┘
超出 N 的连接 → 在无界队列里排队(不拒绝)
每个连接占用一个线程直到断开——即经典的 Thread-Per-Connection (BIO) 模型。并发连接数上限 = 线程池大小 = CPU 核数。
ModbusTcpServer 用 checkClientValid 做了硬限制(protocol/modbus/service/ModbusTcpServer.java:132-135):
132→ @Override
133→ protected boolean checkClientValid(Socket client) {
134→ return this.connectedNumber.get() < this.maxAvailableNumber;
135→ }
其中 maxAvailableNumber = Runtime.getRuntime().availableProcessors()(ModbusTcpServer.java:122)。
评价:一个 Modbus TCP 服务端只能同时服务 CPU 核数个客户端(通常 4~16)。PLC 场景够用(一个 PLC 通常只有 1~3 个上位机),做"数据集中器/网关"(几十个下位机)会成为瓶颈。
7.4 模板方法钩子清单
| 方法 | 行 | 默认 | 被谁重写 |
|---|---|---|---|
checkClientValid(Socket) |
167 | return true |
ModbusTcpServer(连接数限制) |
clientConnected(Socket) |
210 | NOOP | ModbusTcpServer(计数 +1) |
clientDisconnected(Socket) |
220 | NOOP | ModbusTcpServer(计数 -1) |
checkHandshake(Socket) |
231 | return true |
S7PLCServer(COTP 连接请求 + Setup 两次握手) |
doClientHandle(Socket) |
242 | 读数据后 log.debug(new String(data)) |
ModbusTcpServer / S7PLCServer(按功能码分发) |
readClientData(Socket) |
255 | available() 试探 |
ModbusTcpServer(两阶段读) |
8. UdpClientBasic:超时与丢包
8.1 基本结构
// net/client/UdpClientBasic.java:107-121
107→ public DatagramSocket getAvailableSocket() {
109→ if (this.socket != null) {
110→ return this.socket;
111→ }
112→ try {
114→ this.socket = new DatagramSocket();
115→ // connect之后通信地址必须是这个地址
116→// this.socket.connect(this.serverAddress);
117→ return socket;
注意第 116 行 socket.connect() 被注释掉了:socket 处于"未连接"状态,可接收任意源的报文。灵活,但不做源地址过滤——任何主机发到该端口的包都会被当成本次响应。
8.2 超时
// net/client/UdpClientBasic.java:319-328
319→ public DatagramPacket read(DatagramPacket packet, final int timeout) {
320→ try {
321→ DatagramSocket availableSocket = this.getAvailableSocket();
322→ availableSocket.setSoTimeout(timeout);
323→ availableSocket.receive(packet);
324→ return packet;
325→ } catch (IOException e) {
326→ throw new SocketRuntimeException(e);
327→ }
328→ }
| 特性 | 实现 | 评价 |
|---|---|---|
| 超时 | setSoTimeout(timeout),0 = 永久阻塞 |
✅ 与 TCP 一致 |
| 超时异常 | SocketTimeoutException(IOException) → 包装成 SocketRuntimeException |
⚠️ 丢失了"超时"这个语义!调用方无法区分"超时"和"其他 IO 错误",因为 SocketRuntimeException 没有类型区分。而 exceptions/SocketTimeoutException.java 定义了却没被用(源码中未见引用) |
| 重传 | 无 | ❌ UDP 丢包后不会重试,直接抛异常 |
| 丢包处理 | 无 | ❌ 无序列号、无乱序重组(RTP 层的丢包/乱序由 protocol/rtp/service/ 另行处理) |
| 报文大小上限 | 无参 read() 固定 4096 字节(UdpClientBasic.java:187) |
超过 4096 会被截断 |
评价:
UdpClientBasic只是"能用"级别——它本质是给 RTSP over UDP(RTP/RTCP 通道)准备的数据通道,不承担可靠性责任。需要可靠性的场景(如 Modbus UDP,本库不支持)不能直接用它。
9. TcpClientBasic 子类全景
ICommunicable
└── TcpClientBasic (net/client/TcpClientBasic.java:46)
│
├── ModbusSkeletonAbstract<T,R> protocol/modbus/service/ModbusSkeletonAbstract.java:57
│ ├── ModbusTcp protocol/modbus/service/ModbusTcp.java:43
│ ├── ModbusRtuOverTcp protocol/modbus/service/ModbusRtuOverTcp.java:46
│ └── ModbusAsciiOverTcp protocol/modbus/service/ModbusAsciiOverTcp.java:50
│
├── PLCNetwork protocol/s7/service/PLCNetwork.java:65
│ └── S7PLC protocol/s7/service/S7PLC.java:53
│
├── McNetwork protocol/melsec/service/McNetwork.java:58
│ └── McPLC protocol/melsec/service/McPLC.java:72
│
└── RtspNetwork protocol/rtsp/service/RtspNetwork.java:63
└── RtspClient protocol/rtsp/service/RtspClient.java:45
ICommunicable
└── UdpClientBasic (net/client/UdpClientBasic.java:44)
├── UdpClientSample net/client/UdpClientSample.java:39
├── RtpUdpClient protocol/rtp/service/RtpUdpClient.java:46
└── RtcpUdpClient protocol/rtcp/service/RtcpUdpClient.java:49
9.1 子类对照表
| 子类 | 文件:行 | 主机协议 | 默认端口 | 帧界定方式 | persistence |
doAfterConnected() |
|---|---|---|---|---|---|---|
ModbusSkeletonAbstract<T,R> |
ModbusSkeletonAbstract.java:57 |
(抽象) | 502 | 由子类定 | ✅ 有 | — |
├ ModbusTcp |
ModbusTcp.java:43 |
Modbus TCP | 502 | MBAP 长度字段(两阶段读) | ✅ | ❌ |
├ ModbusRtuOverTcp |
ModbusRtuOverTcp.java:46 |
Modbus RTU over TCP | 502 | 无长度字段,一次 read | ✅ | ❌ |
└ ModbusAsciiOverTcp |
ModbusAsciiOverTcp.java:50 |
Modbus ASCII over TCP | 502 | : + \r\n 文本界符 |
✅ | ❌ |
PLCNetwork |
PLCNetwork.java:65 |
S7 over ISO-on-TCP | 102 | TPKT 长度字段(两阶段读) | ✅ | ✅ 握手 + PDU 协商 |
└ S7PLC |
S7PLC.java:53 |
同上 | 102 | 同上 | 继承 | 继承 |
McNetwork |
McNetwork.java:58 |
三菱 MC | 6000 | 3E/4E:dataLength;1E:无 | ✅ | ❌ |
└ McPLC |
McPLC.java:72 |
同上 | 6000 | 同上 | 继承 | 继承 |
RtspNetwork |
RtspNetwork.java:63 |
RTSP(TCP 或 UDP) | 554 | 文本协议:Content-Length + \r\n\r\n |
❌(无该字段) | ✅ option/describe/setup/play |
└ RtspClient |
RtspClient.java:45 |
同上 | 554 | 同上 | — | 继承 |
9.2 值得注意的差异
RtspNetwork关掉了自动重连(enableReconnect = false,注释"连不上就停掉")——RTSP 是有状态会话,重连后 session 失效,重连无意义。全库唯一正确使用enableReconnect的地方。- 只有 S7 和 RTSP 用了
doAfterConnected()钩子——只有它们需要 TCP 建连后的应用层握手。 - MC 的
monitoringTimer单位是 250ms:McHeader3EReq.java:60的this.monitoringTimer = timer / 250;,默认3000ms → 12。不看注释极易误解。 ModbusSkeletonAbstract<T,R>泛型——三个子类分别绑定MbTcpRequest/MbTcpResponse、MbRtuRequest/MbRtuResponse、MbAsciiRequest/MbAsciiResponse。全库唯一用泛型消除重复的尝试,也是最成功的一处抽象。
10. 设计优点 / 缺点 / 可借鉴点
✅ 设计优点
- Lazy 建连(
getAvailableSocket()在每个 read/write 前调用):用户不必显式connect(),socketError标记让 IO 异常后自动重连,自愈逻辑简洁有效。 doAfterConnected()模板方法钩子:把"TCP 连上之后的应用层握手"(S7 的 TPKT/COTP、RTSP 的 DESCRIBE/SETUP/PLAY)从网络层剥离,是教科书式的模板方法应用。TcpServerBasic的 6 个重写钩子:checkClientValid/clientConnected/checkHandshake/doClientHandle/readClientData/clientDisconnected,让 Modbus 和 S7 服务端各自只写业务,不碰线程与 accept。- 两阶段读在主流协议上正确:S7 / Modbus TCP / MC 3E·4E 三个主战场都有明确长度字段,处理是对的。
ReentrantLock保护请求-响应往返(ModbusSkeletonAbstract.java:69、PLCNetwork.java:70、McNetwork.java:63):保证同一连接上不会有两个请求交错——这是串行协议必需的。maxLength/timeout/waitForMore三个正交参数:虽然参数冗余,但组合能力确实覆盖了"大 buffer 分段写"、"指定超时"、"读满为止"三种需求。
❌ 设计缺点
- 无统一帧解码抽象:两阶段读的逻辑在 S7 / Modbus TCP / MC 各写一遍,且三处实现不一致(MC 传了
waitForMore=true,S7 和 Modbus TCP 没传)。 - RTU over TCP / ASCII over TCP / MC 1E 缺帧界定:靠"一次
read拿到全帧"赌运气,跨网关/分片场景有半包风险(需实测确认)。 TcpServerBasic.waitForClients()第 144-147 行缺continue:checkClientValid失败后仍把已关闭的 socket 丢进线程池(功能上碰巧不出错,语义上是 bug)。available()定长度不可靠(TcpServerBasic.java:263):基类默认实现有半包风险,靠子类重写兜底。- 超时语义丢失:UDP/TCP 的
SocketTimeoutException都被统一包装成SocketRuntimeException,调用方无法区分超时与其他错误;exceptions/SocketTimeoutException定义了却零引用。 - 并发数被 CPU 核数硬限制:
newFixedThreadPool(availableProcessors()),无法配置。 - 无
setTcpNoDelay/setKeepAlive:小报文交互可能多一个 RTT;半开连接无法检测。 socket字段非 volatile、无锁:getAvailableSocket()在多线程下调用存在竞态(虽然实际使用时都靠外层locker串行化了,但locker是各协议类私有的,TcpClientBasic自身并不安全)。- UDP 无重传、无乱序重组、无源地址过滤(
connect()被注释掉),超时语义还被吞掉。
💡 可借鉴点
| 做法 | 借鉴到 |
|---|---|
getAvailableSocket() lazy 建连 + socketError 标记自愈 |
任何需要"断线自动重连"的客户端 |
doAfterConnected() 模板方法钩子 |
需要在连接建立后做应用层握手的协议(MQTT、OPC UA、RTSP...) |
服务端 6 个重写钩子(尤其 checkClientValid 做连接数限制) |
自己写 BIO 服务端时的骨架 |
maxLength 分段 IO 参数 |
对接有限制单次读写长度的嵌入式设备 |
enableReconnect 开关按协议语义设置(RTSP 关、S7/Modbus 开) |
有状态会话 vs 无状态请求的区别对待 |
ModbusSkeletonAbstract<T,R> 泛型 + 三个抽象方法(readFromServer / checkResult / readModbusData) |
同一协议的多种报文变体(TCP/RTU/ASCII)用泛型消除重复 |
下一篇:协议实现详解(上):Modbus 与 S7 —— Modbus / S7 / MC 三大协议的报文封装、功能码、PDU 协商、地址解析与批量读写。
本文是《工业通信与数据采集》栏目第 15/22 篇 · 上一篇:iot-communication 项目总览与架构 | 栏目总览 | 下一篇:协议实现详解(上):Modbus 与 S7