02 · 网络层与粘包处理

本文聚焦 com.github.xingshuangs.iot.net 包(9 个文件):剖析 TcpClientBasic 的连接/读写全流程与超时体系、SocketUtils 的读取实现、TcpServerBasic 的线程模型、UdpClientBasic 的超时处理,并对本库的粘包处理策略做专项评价(与 Hsl 的 INetMessage、Netty 的 LengthFieldBasedFrameDecoder 三方对比)。

本文目录


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→    }

关键设计点:

  1. lazy 建连——每个 read/write 开头都先调它(TcpClientBasic.java:258、367),用户不必显式 connect()。
  2. doAfterConnected() 是模板方法钩子(TcpClientBasic.java:216-218 默认空实现):S7 用它做 TPKT/COTP 握手 + PDU 协商(PLCNetwork.java:135-140),RTSP 用它做 option/describe/setup/play。全库最干净的一处模板方法应用。
  3. 没有 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() 返回多少算多少"。

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 的粘包处理:在有长度字段的协议上是对的,在没有长度字段的协议上是"赌运气"。

如果要改进,最小代价的方案:把"两阶段读"抽成一个 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→    }

三个问题:

  1. 第 143-147 行有 bug 倾向:checkClientValid 返回 false 时先 close(client),但没有 continue,紧接着仍 execute(() -> doClientConnected(client)) 把已关闭的 socket 丢进线程池。doClientConnected 里 SocketUtils.isConnected(client) 返回 false 从而跳过循环,功能上"碰巧"没出错,但语义错(ModbusTcpServer.clientConnected() 仍会 getAndIncrement() —— 已拒连的客户端也被计入连接数)。
  2. 无 NIO / 无 Selector:accept() 阻塞在单线程,BIO 服务端标准做法,可接受。
  3. 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 值得注意的差异

  1. RtspNetwork 关掉了自动重连(enableReconnect = false,注释"连不上就停掉")——RTSP 是有状态会话,重连后 session 失效,重连无意义。全库唯一正确使用 enableReconnect 的地方。
  2. 只有 S7 和 RTSP 用了 doAfterConnected() 钩子——只有它们需要 TCP 建连后的应用层握手。
  3. MC 的 monitoringTimer 单位是 250ms:McHeader3EReq.java:60 的 this.monitoringTimer = timer / 250;,默认 3000ms → 12。不看注释极易误解。
  4. ModbusSkeletonAbstract<T,R> 泛型——三个子类分别绑定 MbTcpRequest/MbTcpResponse、MbRtuRequest/MbRtuResponse、MbAsciiRequest/MbAsciiResponse。全库唯一用泛型消除重复的尝试,也是最成功的一处抽象。

10. 设计优点 / 缺点 / 可借鉴点

✅ 设计优点

  1. Lazy 建连(getAvailableSocket() 在每个 read/write 前调用):用户不必显式 connect(),socketError 标记让 IO 异常后自动重连,自愈逻辑简洁有效。
  2. doAfterConnected() 模板方法钩子:把"TCP 连上之后的应用层握手"(S7 的 TPKT/COTP、RTSP 的 DESCRIBE/SETUP/PLAY)从网络层剥离,是教科书式的模板方法应用。
  3. TcpServerBasic 的 6 个重写钩子:checkClientValid / clientConnected / checkHandshake / doClientHandle / readClientData / clientDisconnected,让 Modbus 和 S7 服务端各自只写业务,不碰线程与 accept。
  4. 两阶段读在主流协议上正确:S7 / Modbus TCP / MC 3E·4E 三个主战场都有明确长度字段,处理是对的。
  5. ReentrantLock 保护请求-响应往返(ModbusSkeletonAbstract.java:69、PLCNetwork.java:70、McNetwork.java:63):保证同一连接上不会有两个请求交错——这是串行协议必需的。
  6. maxLength / timeout / waitForMore 三个正交参数:虽然参数冗余,但组合能力确实覆盖了"大 buffer 分段写"、"指定超时"、"读满为止"三种需求。

❌ 设计缺点

  1. 无统一帧解码抽象:两阶段读的逻辑在 S7 / Modbus TCP / MC 各写一遍,且三处实现不一致(MC 传了 waitForMore=true,S7 和 Modbus TCP 没传)。
  2. RTU over TCP / ASCII over TCP / MC 1E 缺帧界定:靠"一次 read 拿到全帧"赌运气,跨网关/分片场景有半包风险(需实测确认)。
  3. TcpServerBasic.waitForClients() 第 144-147 行缺 continue:checkClientValid 失败后仍把已关闭的 socket 丢进线程池(功能上碰巧不出错,语义上是 bug)。
  4. available() 定长度不可靠(TcpServerBasic.java:263):基类默认实现有半包风险,靠子类重写兜底。
  5. 超时语义丢失:UDP/TCP 的 SocketTimeoutException 都被统一包装成 SocketRuntimeException,调用方无法区分超时与其他错误;exceptions/SocketTimeoutException 定义了却零引用。
  6. 并发数被 CPU 核数硬限制:newFixedThreadPool(availableProcessors()),无法配置。
  7. 无 setTcpNoDelay / setKeepAlive:小报文交互可能多一个 RTT;半开连接无法检测。
  8. socket 字段非 volatile、无锁:getAvailableSocket() 在多线程下调用存在竞态(虽然实际使用时都靠外层 locker 串行化了,但 locker 是各协议类私有的,TcpClientBasic 自身并不安全)。
  9. 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