02 · 通信内核 NetworkBase:Socket 层、两阶段读与四级继承
本篇定位:扒到最底下的那层。上半篇逐段拆解
Core/Net/NetworkBase/NetworkBase.java(1115 行),下半篇讲它上面叠的三层(NetworkDoubleBase/NetworkDeviceBase/NetworkXBase)各加了什么,最后汇到SimpleHybirdLock、多端口冗余与错误码体系。本篇是四篇里信息密度最高的一篇。
目录
- 1. NetworkBase 字段全表
- 2. Receive:三层重载与超时语义
- 3. 核心:两阶段读 ReceiveByMessage
- 4. 其它协议的内置接收入口
- 5. Send 与流传输
- 6. 连接建立 Socket 选项的基本没设
- 7. 四级继承:各层加码清单
- 8. NetworkDoubleBase 深度透视
- 9. PipeSocket:多端口冗余与共享连接
- 10. SimpleHybirdLock 与错误码体系
- 11. 可覆写钩子汇总
- 附录:实测编译结论
- 设计优点 / 设计缺点 / 可借鉴点
1. NetworkBase 字段全表
Core/Net/NetworkBase/NetworkBase.java:30 声明为 public abstract class NetworkBase。全部成员只有 4 个字段,非常克制:
| 成员 | 可见性 | 行号 | 类型 | 默认值 | 含义 |
|---|---|---|---|---|---|
LogNet |
public | 50 | ILogNet |
null |
日志门面,注入后以 DEBUG 级输出收发报文 |
Token |
public | 56 | UUID |
全零 UUID(行 39) | HSL 协议身份令牌;仅对 HSL 自有协议有效,对 PLC 协议无效 |
CoreSocket |
protected | 66 | Socket |
null |
注释写"客户端为通信套接字,服务端为侦听套接字",但客户端路径上未被使用——客户端 socket 全在 PipeSocket 里 |
fileCacheSize |
protected | 72 | int |
1024*100 |
文件/流传输缓冲区,只被 SendStreamToSocket(NetworkBase.java:794)使用 |
一个坏味道:LogNet/Token 是 public 可变字段,而同一份代码里其它成员走"private 字段 + 手写 getXxx/setXxx"(如 NetworkDoubleBase.java:96-107)。两种风格并存,是 C# property 直译不彻底的产物。
另外 ThreadPoolCheckTimeOut(NetworkBase.java:92-112)里有两处硬编码中文 System.out.println(行 93、行 102),绕过了 ILogNet 直接污染控制台;且未在本版本源码中见到该方法被调用的地方,疑为被 setSoTimeout 方案取代后的死代码。
2. Receive:三层重载与超时语义
Receive(Socket, int length, int timeout, ActionOperateExTwo<Long,Long> reportProgress) // 行 128 真身
Receive(Socket, int length, int timeout) // 行 165 → reportProgress = null
Receive(Socket, int length) // 行 177 → timeout = 60_000
真身(NetworkBase.java:128-154)的关键分支:
if (length == 0) return OperateResultExOne.CreateSuccessResult(new byte[0]); // 行 129
if (!Authorization.nzugaydgwadawdibbas( )) return new OperateResultExOne<>( StringResources.Language.AuthorizationFailed() ); // 行 131
try {
if(timeout < 0) timeout = 0; // 行 134 ← 0 在 Java 里 = 无限等待
socket.setSoTimeout(timeout); // 行 135
if (length > 0) {
byte[] data = NetSupport.ReadBytesFromSocket(socket, length, reportProgress); // 行 137 读满才返回
return OperateResultExOne.CreateSuccessResult(data);
} else {
byte[] bytes_receive = new byte[1024]; // 行 140
int n = socket.getInputStream().read(bytes_receive, 0, bytes_receive.length); // 行 142 有多少读多少
if(n <= 0) throw new RemoteCloseException();
return OperateResultExOne.CreateSuccessResult(SoftBasic.BytesArraySelectBegin(bytes_receive, n));
}
} catch (RemoteCloseException ex) { CloseSocket(socket); return new OperateResultExOne<byte[]>(...); } // 行 147-149
catch (IOException ex) { CloseSocket(socket); return new OperateResultExOne<byte[]>(...); } // 行 150-152
| 参数/行为 | 值 | 说明 |
|---|---|---|
length == 0 |
立即返回空数组 | 行 129 |
length > 0 |
精确读满 length 字节才返回 |
走 NetSupport.ReadBytesFromSocket 循环 |
length < 0 |
读一次、最多 1024 字节、有多少返多少 | 行 140-145,"随机长度"模式 |
timeout < 0 |
被改成 0,语义变成"无限阻塞" | 行 134 |
SocketTimeoutException |
属 IOException,关 socket 并失败返回 |
行 150-152 |
| 远程关闭 | 抛自定义 RemoteCloseException,关 socket |
行 147-149 |
注意第 134 行:-1 在上层多处表示"不限时",这里被翻译成了"永不超时"。useServerActivePush 模式下确实会传 -1(NetworkDoubleBase.java:388),意味着后台线程一旦卡住就只能等对端关连或来数据。
2.1 真正的循环读
// Core/Net/NetSupport.java:35-55
public static byte[] ReadBytesFromSocket(Socket socket, int receive, ActionOperateExTwo<Long, Long> reportProgress) throws IOException, RemoteCloseException {
byte[] bytes_receive = new byte[receive];
int count_receive = 0;
while (count_receive < receive) {
int receive_length = Math.min((receive - count_receive), SocketBufferSize); // 行 40,8192
//socket.setSoTimeout(10_000); // 行 42 ← 被注释掉了
int count = socket.getInputStream().read(bytes_receive, count_receive, receive_length); // 行 43-44
if (count < 0) throw new RemoteCloseException(); // 行 45
count_receive += count;
if (count <= 0) throw new RemoteCloseException(); // 行 48
if (reportProgress != null) reportProgress.Action((long) count_receive, (long) receive);
}
return bytes_receive;
}
三个可以批评的点:
- 行 42 注释掉了
setSoTimeout(10_000)。类注释(NetSupport.java:24-26)宣称"为了防止数据太大导致接收失败,所以此处接收到新的数据之后就更新时间",但代码里根本没实现这个更新时间的逻辑——SoTimeout在进入Receive时一次性设定,整个receive字节必须在同一窗口内读完。文档承诺与实现不符,传一个大 payload + 5 秒超时就会失败。 - 行 43 每次循环都调
socket.getInputStream(),内含状态检查,属于无谓开销。 - 行 48 把
count == 0当远端关闭。阻塞 IO 下read(b, off, len)只在len==0时返回 0,实际影响不大。
3. 核心:两阶段读 ReceiveByMessage
这是整套库解决 TCP 粘包/拆包的地方。
3.1 三个重载
| 签名 | 行号 | 说明 |
|---|---|---|
ReceiveByMessage(Socket, int timeOut, INetMessage, ActionOperateExTwo) |
290 | 真身,实现两阶段读 |
ReceiveByMessage(Socket, int timeOut, INetMessage) |
336 | return ReceiveByMessage(socket, timeOut, null, netMessage); |
ReceiveByMessage(Socket, int timeOut, byte[] sendValue, INetMessage) |
349 | 决策层:判断走"流式"还是"两阶段" |
注意第二个重载的参数位置有点绕:它把 null 作为 sendValue、把自己收到的 netMessage 作为第 4 参,实际落到的是 349 行那个 (Socket, int, byte[], INetMessage) 重载(重载解析唯一可行)。合法但可读性很差。
3.2 分支 A:帧头长度 = -1 的"我不知道多长"模式
适用于 Modbus-RTU、DLT645 这类"帧长不在固定偏移、要靠内容研判"的协议:
// Core/Net/NetworkBase/NetworkBase.java:350-373
if (netMessage != null && netMessage.ProtocolHeadBytesLength() == -1) {
Date startTime = new Date(); // 行 351
MemoryStream ms = new MemoryStream();
INetMessage nullTmp = null;
while (true) {
OperateResultExOne<byte[]> read = ReceiveByMessage(socket, timeOut, nullTmp, null); // 行 355 递归回 290 行
if (!read.IsSuccess) return read;
if (read.Content != null && read.Content.length > 0) ms.Write(read.Content); // 行 359
if (netMessage.CheckReceiveDataComplete(sendValue, ms)) // 行 363 协议自己判"齐了没"
return OperateResultExOne.CreateSuccessResult(ms.ToArray());
if (timeOut >= 0 && (new Date().getTime() - startTime.getTime()) > timeOut) // 行 367 超时兜底
return new OperateResultExOne<byte[]>(StringResources.Language.ReceiveDataTimeout() + timeOut
+ " Received: " + SoftBasic.ByteToHexString(ms.ToArray(), ' '));
}
}
行 355 的递归:把 nullTmp 与 reportProgress 都传 null,落到 290 行后由第 291 行 if (netMessage == null) return Receive(socket, -1, timeOut); 走"每次读 ≤1024 字节"的随机块,拼到 MemoryStream,再由协议自己 CheckReceiveDataComplete 判断"齐了没"(行 363),超时则由行 367 兜底。务实,但每次都重建 byte[1024],且高度依赖 SoTimeout 精度。
3.3 分支 B:标准两阶段读(本篇最重要的一段代码)
// Core/Net/NetworkBase/NetworkBase.java:290-331(省略 SpecifiedCharacterMessage 子分支)
protected OperateResultExOne<byte[]> ReceiveByMessage( Socket socket, int timeOut,
INetMessage netMessage, ActionOperateExTwo<Long, Long> reportProgress ) {
if (netMessage == null) return Receive(socket, -1, timeOut); // 行 291
if (netMessage.ProtocolHeadBytesLength() < 0){
/* 子分支:以结束符定帧(SpecifiedCharacterMessage 专用),见 §3.4 */ // 行 293-315
}
else {
// ★ 阶段一:读固定长度的头
OperateResultExOne<byte[]> headResult = Receive(socket, netMessage.ProtocolHeadBytesLength(), timeOut); // 行 318
if (!headResult.IsSuccess) return headResult;
netMessage.setHeadBytes(headResult.Content); // 行 321
// ★ 利用头解码:让协议对象告诉我们还剩多少
int contentLength = netMessage.GetContentLengthByHeadBytes(); // 行 322
if (contentLength <= 0) return headResult; // 行 323 无体,提前返回
// ★ 阶段二:精确读体
OperateResultExOne<byte[]> contentResult = Receive(socket, contentLength, timeOut, reportProgress); // 行 325
if (!contentResult.IsSuccess) return contentResult;
netMessage.setContentBytes(contentResult.Content); // 行 328
return OperateResultExOne.CreateSuccessResult(
SoftBasic.SpliceTwoByteArray(netMessage.getHeadBytes(), contentResult.Content)); // 行 329
}
}
ReceiveByMessage(...)
├─ netMessage == null → Receive(socket,-1,timeout) 「读一次,最多 1024 字节」
├─ HeadBytesLength() < 0 → 「结束符定帧」子分支:
│ headCode = Utilities.getBytes(帧头长) 行 295
│ codeLength = headCode[3] & 0x0f 行 296
│ 1 → ReceiveCommandLineFromSocket(socket, headCode[1], timeout) 行 298
│ 2 → ReceiveCommandLineFromSocket(socket, headCode[1], headCode[0], timeout) 行 299
│ 3+→ 行 301 直接返回失败(不支持)
│ 可选:再读 getEndLength() 字节尾部(如校验码) 行 305-312
└─ 否则 → 两阶段读:
╔══ 阶段一:读固定头 N = ProtocolHeadBytesLength() ══╗ 行 318
║ Receive(socket, N, timeout) ║
║ setHeadBytes(headResult.Content) ║ 行 321
╠══ 解码:M = GetContentLengthByHeadBytes() ══════════╣ 行 322
║ (33 个实现类各不相同 → 见第 03 篇) ║
║ M <= 0 → 把头当完整帧返回 ║ 行 323
╠══ 阶段二:精确读 M 字节体 ═══════════════════════════╣ 行 325
╠══ 拼接:SpliceTwoByteArray(head, content) ══════════╣ 行 329
╚══════════════════════════════════════════════════════╝
为什么这能解决粘包拆包:
- 粘包(两帧粘在一起):阶段一只读 N 字节,多余的留在内核缓冲区,下一次
Receive再从那里读。天然解耦。 - 拆包(一帧分两次到):
Receive内部有 while 循环(NetSupport.java:38)必定读满才返回。天然处理。 - 脏数据偏移:
INetMessage.PependedUselesByteLength(byte[])接口(Core/IMessage/INetMessage.java:38)允许协议报告"头前面有几字节是垃圾"。但该方法从未被ReceiveByMessage调用过——只有DLT645Message.java:18实现了它。这是一个定义了却没接线的接口方法(未在本版本ReceiveByMessage中见到调用)。
3.4 "编码黑魔法":负数帧头长度
SpecifiedCharacterMessage 用一个 int 同时编码"结束符 + 结束符个数 + 尾部长度":
// Core/IMessage/SpecifiedCharacterMessage.java:12-19
public SpecifiedCharacterMessage(byte endCode){
byte[] buffer = new byte[4];
buffer[3] = (byte)(buffer[3] | 0x80); // 行 14 最高位置 1 → 整个 int 为负
buffer[3] = (byte)(buffer[3] | 0x01); // 行 15 低 4 位 = 1 → 单字节结束符
buffer[1] = endCode; // 行 16
protocolHeadBytesLength = Utilities.getInt( buffer, 0 ); // 行 18
}
于是 ProtocolHeadBytesLength() 返回一个负数,四个字节的含义是:
byte[3] byte[2] byte[1] byte[0]
0x8N EndLength endCode1 endCode2
│ │ │ │ │
│ └─ 结束符个数 └─ 尾部长度 └─ 第1结束符 └─ 第2结束符
└── 最高位=1 → int 为负 → 触发 ReceiveByMessage 的 <0 分支
解码端在 NetworkBase.java:295-301:
byte[] headCode = Utilities.getBytes( netMessage.ProtocolHeadBytesLength() ); // 行 295
int codeLength = headCode[3] & 0x0f; // 行 296
OperateResultExOne<byte[]> receive = null;
if (codeLength == 1) receive = ReceiveCommandLineFromSocket( socket, headCode[1], timeOut ); // 行 298
else if (codeLength == 2) receive = ReceiveCommandLineFromSocket( socket, headCode[1], headCode[0], timeOut ); // 行 299
if (receive == null) return new OperateResultExOne<byte[]>( "Receive by specified code failed, length check failed" ); // 行 301
评价:这是典型的"位打包"技巧,把 3 个参数塞进一个 int 以回避 Java 没有 tuple 的事实。代价是可读性极差(配置与数值语义揉在一起),且扩展性为零——需要 3 字节结束符时行 301 直接返回失败。
配套的逐字节扫描接收(NetworkBase.java:196-205):
while (new Date().getTime() - st.getTime() < timeout) {
OperateResultExOne<byte[]> headResult = Receive(socket, 1, timeout); // 行 197 ← 一个字节一个字节读!
if (!headResult.IsSuccess) return headResult;
bufferArray.add(headResult.Content[0]); // 行 200 ← Byte 装箱
if (headResult.Content[0] == endCode) { bOK = true; break; }
}
性能备注:每字节一次 Receive,而每次 Receive 都执行一次 socket.setSoTimeout(timeout)(NetworkBase.java:135,native 调用)。一条 100 字节的文本协议报文 = 100 次系统调用 + 100 次装箱。**(具体损耗需实测确认)**但方向上明确不利高帧率场景。
4. 其它协议的内置接收入口
NetworkBase 除了通用机制,还内置了 4 套应用层协议的完整帧解析器,这是它区别于"纯 IO 库"的地方:
| 协议 | 方法 | 行号 | 帧结构处理 |
|---|---|---|---|
| HSL 私有协议 | ReceiveHslMessage |
1086 | 32 字节头 + getInt(head, len-4) 长度 + 自动解压/解码 |
| WebSocket | ReceiveFrameWebSocketPayload / ReceiveWebSocketPayload |
903 / 881 | 2 字节头 + 126/127 扩展长度 + 4 字节 mask + XOR 解码 + FIN 拼帧 |
| MQTT | ReceiveMqttMessage / ReceiveMqttRemainingLength |
987 / 957 | 变长剩余长度(最多 4 字节 7-bit 编码),注意 971-976 行的解码算法与 MQTT 规范的 7-bit 变长编码不是同一套(把每字节 +128 再加权,属自创,需实测确认跨设备兼容性) |
| Redis | ReceiveRedisCommand / ReceiveRedisCommandString |
1034 / 1014 | RESP 五种首字节 + - : $ *,$/* 递归解析 |
| HSL 自校验 | ReceiveAndCheckBytes |
615 | 收头 → 校验 token → 收体 → 回写一个 long 作为 ack |
ReceiveAndCheckBytes(NetworkBase.java:615-639)的"自校验"值得一提:每收完一帧都反向 ack 一个 long,发送侧在 SendBaseAndCheckReceive(NetworkBase.java:536-555)里等待这个 ack 并比对长度。这是简易的应用层确认,代价是每帧多一次往返。
5. Send 与流传输
// Core/Net/NetworkBase/NetworkBase.java:400-410(节选)
protected OperateResult Send( Socket socket, byte[] data, int offset, int size ) {
if (data == null) return OperateResult.CreateSuccessResult();
try {
DataOutputStream output = new DataOutputStream(socket.getOutputStream()); // 行 403 每次都新建
output.write(data, offset, size); // 无 flush
return OperateResult.CreateSuccessResult();
} catch (IOException ex) { CloseSocket(socket); return new OperateResult(ex.getMessage()); }
}
两点:一是每次发送都 new DataOutputStream(...)(行 403),只做字节转换的包装类,包一次一次对象分配且无缓冲,高频发送时应缓存;二是没有 flush()——DataOutputStream 对 socket 流是 unbuffered 的、write 直接落底层,但这是隐性假设。
SendStreamToSocket(行 793)与 WriteStreamFromSocket(行 841)是一对文件/流收发:前者从 InputStream 读 100 KB 后调 SendBytesAndCheckReceive 做自校验发送,后者循环 ReceiveBytesContentFromSocket 写入 OutputStream;两者都支持"按百分比报告"与"按字节数报告"两档(行 816-825 / 858-866)。
6. 连接建立 / Socket 选项:结论是"基本没设"
protected OperateResultExOne<Socket> CreateSocketAndConnect(SocketAddress endPoint, int timeOut, InetSocketAddress localAddress) // 行 422 真身
protected OperateResultExOne<Socket> CreateSocketAndConnect(SocketAddress endPoint, int timeOut) // 行 444 → localAddress=null
protected OperateResultExOne<Socket> CreateSocketAndConnect(String ipAddress, int port, int timeOut) // 行 456
protected OperateResultExOne<Socket> CreateSocketAndConnect(String ipAddress, int port) // 行 468 → timeOut=10000
真身只有 14 行(NetworkBase.java:422-435),也是全库唯一 new Socket() 的地方:
Socket socket = new Socket(); // 行 423 无参构造
try {
if (localAddress != null) socket.bind(localAddress); // 行 425
socket.connect(endPoint,timeOut); // 行 426
return OperateResultExOne.CreateSuccessResult(socket);
} catch (IOException ex) {
if(LogNet != null) LogNet.WriteException( "CreateSocketAndConnect", ex );
CloseSocket(socket); // 行 432
return new OperateResultExOne<>(ex.getMessage());
}
6.1 Socket 选项:穷尽式 grep 的结果
全树只有 3 条命中(不含 MQTT 的应用层 KeepAlive 字段):
| 文件:行 | 内容 |
|---|---|
Core/Net/NetSupport.java:42 |
//socket.setSoTimeout(10_000); ← 被注释掉 |
Core/Net/NetworkBase/NetworkBase.java:135 |
socket.setSoTimeout(timeout); |
Core/Net/NetworkBase/NetworkUdpBase.java:197 |
datagramSocket.setSoTimeout(getReceiveTimeOut()); |
明确结论:
| 选项 | 是否设置 | 影响 |
|---|---|---|
TCP_NODELAY |
没有设置 | Modbus TCP / S7 都是小包高频交互,Nagle 会把小包攒起来延迟发。这是本库默认行为下的真实延迟源。好在 GetPipeSocket().getSocket() 是 public(PipeSocket.java:121),有补救路径 |
SO_KEEPALIVE |
没有设置 | NAT/防火墙静默丢连接后 socket.isConnected() 仍为 true。这解释了为什么 PipeSocket 需要那个 isSocketError 布尔标志——在用软件手段补偿缺少 KeepAlive |
SO_REUSEADDR |
没有设置 | 服务端快速重启可能 Address already in use |
SO_LINGER |
没有设置 | 走系统默认,通常无害 |
SO_TIMEOUT |
设置了 | 唯一设置的选项,也是超时机制的基石 |
SO_RCVBUF/SO_SNDBUF |
没有设置 | 用系统默认 |
这也解释了 GetAvailableSocket 为什么只能用弱错误检测(PipeSocket.java:42-44):
public boolean IsConnectitonError( ) { return getIsSocketError() || this.socket == null; }
即"上一次失败了就标记错误,下次读写时重连"的被动重连。在工控场景说得通(交换频繁,坏连接很快暴露),但对"只读不写且间隔很久"的场景会滞留半开连接。
7. 四级继承:各层加码清单
NetworkBase (1115 行) ─────────────► Socket 收发原语 + 4 套应用层帧解析
│
├─ NetworkDoubleBase (734 行) ──► 长/短连接、帧收发闭环、重连、锁、帐号、推送模式
│ └─ NetworkDeviceBase (293 行) ──► IReadWriteDevice:把 byte[] 变成 short/int/float/String
│ └─ 39 个具体设备类(ModbusTcpNet / SiemensS7Net / MelsecMcNet / ...)
├─ NetworkXBase (403 行) ───────► 服务端异步发送、后台接收循环、文件流
├─ NetworkServerBase (274 行) ──► 服务端骨架
│ └─ NetworkDataServerBase (646 行) ──► ModbusTcpServer / SiemensS7Server / MelsecMcServer
├─ NetworkUdpBase (305 行) ─────► UDP 收发;└─ NetworkUdpDeviceBase (323 行)
└─ NetworkConnectedCip (323 行) ─► CIP 连接型协议(AB PLC)
纠正一个常见误解:很多资料(含本项目任务描述)以为
readFromCoreServer/GetAvailableSocket在NetworkBase里。实际上它们全部在NetworkDoubleBase里(NetworkDoubleBase.java:523 / 580 / 662)。NetworkBase只负责"给我一个连好的 socket,我帮你收发字节"。这个切分是清晰的:NetworkBase= IO 原语,NetworkDoubleBase= 连接生命周期。
| 能力 | NetworkBase | NetworkDoubleBase | NetworkDeviceBase | NetworkXBase |
|---|---|---|---|---|
| Socket 收发 | ✅ | — | — | — |
| 长/短连接 + 自动重连 | — | ✅ | — | — |
| 请求串行化锁 | — | ✅(PipeSocket) | — | (自己的 ReentrantLock) |
ReadFromCoreServer 帧闭环 |
— | ✅ | — | — |
| 记录上次是否错误 | — | ✅ | — | — |
| HSL 帐号登录 | — | ✅ | — | — |
WordLength 概念 |
— | — | ✅ | — |
ReadInt16/32/64 等类型化读取 |
— | — | ✅ | — |
ReadCustomer/WriteCustomer |
— | — | ✅ | — |
| 服务端异步发送 / 后台接收循环 | — | (可选 push 模式) | — | ✅ |
| 文件流收发 | — | — | — | ✅ |
8. NetworkDoubleBase 深度透视
8.1 字段表
| 字段 | 行号 | 默认值 | 含义 |
|---|---|---|---|
pipeSocket |
39 | new PipeSocket() |
连接参数 + socket + 错误标志 + 锁 |
byteTransform |
40 | null | 数据变换器,构造函数不初始化 |
isPersistentConn |
41 | false |
是否长连接 |
useServerActivePush |
42 | false |
是否开启服务端主动推送 |
autoResetEvent |
43 | null |
push 模式的同步信号 |
bufferQA |
44 | null |
push 模式的一帧缓存 |
threadBackReceive |
45 | — | push 模式后台线程句柄 |
localAddress |
46 | null |
绑定的本地地址 |
LogMsgFormatBinary |
52 | true |
日志以 hex 还是 ASCII 输出报文 |
isUseSpecifiedSocket |
54 | false |
是否异形(DTU/透传)客户端 |
connectionId |
55 | GUID+随机(行 36) | 连接 ID |
sleepTime |
56 | 0 | 发送后、接收前的休眠毫秒数 |
AlienSession |
238 | null | 异形会话 |
构造函数的缺陷(NetworkDoubleBase.java:34-37):
public NetworkDoubleBase() {
this.pipeSocket = new PipeSocket();
this.connectionId = SoftBasic.GetUniqueStringByGuidAndRandom();
}
byteTransform 保持 null。3.9.3 的子类必须自己 setByteTransform(new ReverseWordTransform())(ModbusTcpNet.java:28)。旧版是构造函数传 Class<TTransform> 并 newInstance() 兜底的——这是"去掉反射"带来的回归,忘了设就会在第一次 ReadInt16 时 NPE。
8.2 连接全生命周期
ConnectServer() 【建长连接】行 287-304
isPersistentConn=true → CloseSocket(旧) → CreateSocketAndInitialication()
└─ CreateSocketAndConnect(endPoint,timeout,local) 行 558
└─ InitializationOnConnect(socket) 行 561 ★钩子;失败则 CloseSocket + CopyError 行 563-566
成功 → pipeSocket.setSocket() + setIsSocketError(false) + WriteDebug(NetEngineStart)
GetAvailableSocket() 行 523-551
isPersistentConn ?
├─ 异形(isUseSpecifiedSocket) → 直接返回 pipeSocket.getSocket(),不健康检查 行 526-531
└─ 普通长连接 → IsConnectitonError() ? ConnectServer() 重连 : 复用 行 534-545
└─ 短连接 → CreateSocketAndInitialication(),每次新建 行 549
ConnectClose() 【手动断开】行 352-372
isPersistentConn=false → PipeLockEnter() 行 356
→ ExtraOnDisconnect(socket) 行 360 ★钩子
→ CloseSocket() + setSocket(null) 行 362-363
→ PipeLockLeave() 行 365
注意 ConnectClose() 行 356-368 是手写的 try-finally 等价物:正常路径和 catch 路径各留一份 unlock(行 365 / 367)。中间再插一行若忘了 unlock 就永久死锁。没有任何理由不写成 try { ... } finally { PipeLockLeave(); }。同样模式也出现在 ReadFromCoreServer(byte[],bool,bool)——那里有三处 unlock 调用(行 666 / 673 / 694)。
8.3 ReadFromCoreServer:帧收发闭环
外层容器(NetworkDoubleBase.java:662-697)负责取 socket、加锁、错误标记、短连接收尾:
public OperateResultExOne<byte[]> ReadFromCoreServer(byte[] send, boolean hasResponseData, boolean usePackAndUnpack ) {
OperateResultExOne<byte[]> result = new OperateResultExOne<byte[]>();
this.GetPipeSocket().PipeLockEnter(); // 行 666 ★ 加锁
OperateResultExOne<Socket> resultSocket = GetAvailableSocket(); // 行 669
if (!resultSocket.IsSuccess) { /* setIsSocketError(true) */ this.pipeSocket.PipeLockLeave(); // 行 670-673
result.CopyErrorFromOther(resultSocket); return result; }
OperateResultExOne<byte[]> read = ReadFromCoreServer(resultSocket.Content, send, hasResponseData, usePackAndUnpack); // 行 678
if (read.IsSuccess) { this.GetPipeSocket().setIsSocketError(false); // 行 681
result.IsSuccess = true; result.Content = read.Content;
result.Message = StringResources.Language.SuccessText();
} else { this.GetPipeSocket().setIsSocketError(true); // 行 687
result.CopyErrorFromOther(read); }
ExtraAfterReadFromCoreServer(read); // 行 692 ★ 钩子
this.GetPipeSocket().PipeLockLeave(); // 行 694
if (!isPersistentConn) CloseSocket(resultSocket.Content); // 行 695 ★ 短连接在此关闭
return result;
}
内层真身(NetworkDoubleBase.java:580-651)负责打包、发、收、校验、拆包(NetworkDoubleBase.java:582-596 节选):
byte[] sendValue = usePackAndUnpack ? PackCommandWithHeader(send) : send; // 行 582 ★ 钩子
ILogNet logNet = LogNet;
if (logNet != null) logNet.WriteDebug(this.toString(), StringResources.Language.Send() + " : " + ...); // 行 584-585
INetMessage netMessage = GetNewNetMessage(); // 行 586 ★ 钩子
if (netMessage != null) netMessage.setSendBytes(sendValue); // 行 587
OperateResult resultSend = Send(socket, sendValue); // 行 590
if (!resultSend.IsSuccess) return OperateResultExOne.CreateFailedResult(resultSend);
if (this.getReceiveTimeOut() < 0) return OperateResultExOne.CreateSuccessResult(new byte[0]); // 行 593 发完不管
if (!hasResponseData) return OperateResultExOne.CreateSuccessResult(new byte[0]); // 行 594 广播写
try { if (sleepTime > 0) Thread.sleep(sleepTime); } catch (Exception ignored) {} // 行 596
接收循环的核心是 CheckMessageMatch 的三态(NetworkDoubleBase.java:630-643):
if (netMessage == null) break; // 行 630
int check = netMessage.CheckMessageMatch(sendValue, resultReceive.Content); // 行 633
if (check == 1) break; // 匹配,收工
if (check == 0) return new OperateResultExOne<byte[]>("INetMessage.CheckMessageMatch failed" ...); // 行 636
times++; // -1:不是我的帧,继续收
if (getReceiveTimeOut() >= 0 && (new Date().getTime() - start.getTime()) > this.getReceiveTimeOut())
return new OperateResultExOne<byte[]>("Receive Message timeout: ...CheckMessageMatch times:" + times); // 行 642
最后两步(行 646-650):netMessage.CheckHeadBytesLegal(...) 失败则关 socket 返回 CommandHeadCodeCheckFailed,否则返回 UnpackResponseContent(sendValue, resultReceive.Content)。
关键设计点:
CheckMessageMatch的 -1 语义驱动了"再收一帧"循环。典型场景:Modbus TCP 事务 ID 必须匹配(ModbusTcpMessage.java:71-86),Omron FINS 的 SID 同理(FinsMessage.java:36-46比对第 25 字节)。这是解决流水线应答错配的关键机制,比固定偏移解码器更灵活。- 行 593 / 594 两个提前返回分别服务"发完不管"与"广播写"(
ModbusTcpNet.java:147-150用它处理广播站号)。 - 每次收发都会做一次报文 hex 串拼接(行 584-585 / 626-627)。虽然被
if (logNet != null)保护,但开启日志后在大批量读取时会产生大量临时字符串。
8.4 服务端主动推送模式(useServerActivePush)
后台线程在 InitializationOnConnect(NetworkDoubleBase.java:382-405)里起:内部是一个 while(true),每帧 ReceiveByMessage(socket, -1, null, GetNewNetMessage())(行 388,注意 -1 = 永不超时),收失败就 setIsSocketError(true) 并退出(行 390);若 DecideWhetherQAMessage 判定为应答帧(行 396),就把内容写进 bufferQA 并 autoResetEvent.set()(行 398-399)。业务侧则在 autoResetEvent.waitOne(receiveTimeOut) 上等(NetworkDoubleBase.java:608)。
这一段是本篇最想批评的地方:
| 问题 | 位置 | 说明 |
|---|---|---|
bufferQA 无 volatile |
行 44 声明 / 398-399 后台写 / 610-611 业务读 | 靠 AutoResetEvent 内部的 ReentrantLock(Core/Types/AutoResetEvent.java)提供 happens-before,勉强成立但代码里没有任何注释说明,极易在后续修改中被破坏 |
| 后台线程永不中断 | 行 387 while(true) |
ConnectClose()(行 352-372)没有 interrupt 或 stop |
threadBackReceive 被覆盖 |
行 384 / 404 | 多次 ConnectServer() 会丢失旧线程句柄,旧线程直到读到坏 socket 才退出 |
| 线程非 daemon | 行 404 | 未 setDaemon(true),JVM 无法优雅退出 |
9. PipeSocket:多端口冗余与共享连接
Core/Pipe/PipeSocket.java(248 行)+ Core/Pipe/PipeBase.java(44 行)。
9.1 多端口冗余
// Core/Pipe/PipeSocket.java:202-234
public void SetMultiPorts( int[] ports ) {
if (ports != null && ports.length > 0) { this._port = ports; this.indexPort = -1; } // 行 203-206
}
public SocketAddress GetConnectIPEndPoint( ) {
if (_port.length == 1) return new InetSocketAddress( getIpAddress(), _port[0] ); // 行 216
ChangePorts( ); // 行 217 ★ 每次调用轮转
return new InetSocketAddress( getIpAddress(), _port[this.indexPort] );
}
public void ChangePorts( ) { // 行 226-234,环形递增
if (_port.length == 1) return;
if (this.indexPort < this._port.length - 1) this.indexPort++; else this.indexPort = 0;
}
配 [5000, 5001] 后,每次 CreateSocketAndInitialication() 都切到下一个端口;配合失败时 setIsSocketError(true),下一次请求会走 GetAvailableSocket → ConnectServer → CreateSocketAndInitialication,自动换端口。20 行代码实现端口冗余,性价比极高。
indexPort 是非 volatile 的 int,只在持 pipe 锁的路径上被修改——和 isSocketError 一样属于"靠锁做内存可见性"的隐式依赖。
9.2 共享连接与一个 bug
// Core/Net/NetworkBase/NetworkDoubleBase.java:245-252
public void SetPipeSocket( PipeSocket pipeSocket ) {
if (this.pipeSocket != null) // 行 247 ← 判的是 this,而构造函数行 35 保证它恒非空
{
this.pipeSocket = pipeSocket;
SetPersistentConnection( );
}
}
把同一个 PipeSocket 实例赋给多个设备对象后,它们共享同一个 socket 和同一把锁——典型用途是一台串口服务器后面挂 N 台 Modbus RTU 从站,物理上只有一条 TCP。这个抽象是对的(锁在 pipe 上而非设备上,正是为此)。
但第 247 行的判空条件写反了:大概想写 if (pipeSocket != null)(判断入参)。当前写法下实参传 null 会直接把 this.pipeSocket 置 null,后续 NPE。
9.3 异形客户端
ConnectServer(AlienSession session)(NetworkDoubleBase.java:312-345)处理 DTU/透传:socket 由外部创建并塞进来,客户端不负责连接。此时 GetAvailableSocket(行 526-531)直接返回 pipeSocket.getSocket(),不做任何健康检查。
10. SimpleHybirdLock 与错误码体系
10.1 SimpleHybirdLock
Core/Thread/SimpleHybirdLock.java 全文 35 行,实质就是:
public SimpleHybirdLock(){ queueLock = new ReentrantLock(); } // 行 15 默认非公平 → 吞吐优先
public void Enter( ) { queueLock.lock( ); } // 行 23
public void Leave( ) { queueLock.unlock( ); } // 行 30
private Lock queueLock = null; // 行 34
上面再叠一层 PipeBase(Core/Pipe/PipeBase.java:21-39),顺手埋了排队深度计数:
public void PipeLockEnter( ){ lockingTick.getAndIncrement(); this.hybirdLock.Enter( ); } // 行 22-23
public void PipeLockLeave( ){ this.hybirdLock.Leave( ); lockingTick.decrementAndGet(); } // 行 30-31
public int getLockingTick(){ return this.lockingTick.get(); } // 行 38
几点观察:
- 名字名不副实。
SimpleHybirdLock直接沿用 C# 版(注意 "Hybird" 是原作者的拼写错误,全库一致),C# 版实现是"自旋 + Monitor 混合",Java 版退化成纯ReentrantLock,"混合"语义已不存在。 lockingTick计数器是加分项:可在监控线程里轮询getLockingTick()看多少请求在排队,成本只是一个AtomicInteger,排障时价值巨大。- 加锁粒度是"整次请求"而非"同一 socket"——从
GetAvailableSocket到收完一整帧全程持锁。好处是绝不应答串味;坏处是一条物理连接同时只能处理一个请求,吞吐被限制为 1/RTT,多设备共享PipeSocket时会形成队头阻塞。这是正确的工程取舍:绝大多数 PLC(尤其 S7-1200、FX5U)在单连接上本就不支持管线化,并发请求会直接导致应答错配。 NetworkXBase.SendBytesAsync用的是另一个世界的东西——session.getHybirdLockSend()返回裸java.util.concurrent.locks.Lock(Core/Net/StateOne/AppSession.java:128, 198),没走SimpleHybirdLock。同一概念两套实现。
10.2 三层错误码
层 1 · 框架级:默认 ErrorCode = 10000(OperateResult.java:52)
-10000 : 接收超时 / tryAcquire 异常 NetworkDoubleBase.java:615、618
其余沿用 10000,只靠 Message 区分
层 2 · 协议级:
Modbus : 功能码 >= 0x80 为异常,ErrorCode = response[2] ModbusInfo.java:476
0x01 不支持功能码 / 0x02 地址越界 / 0x03 长度超限 / 0x04 读写异常
三菱 MC : ErrorCode = Utilities.getUShort(content, 9) McBinaryHelper.java:50-51
0x0002 / 0x0051 / 0x0052 / 0x0054~0x0059 / 0xC04D …
西门子 : 在已解析数据区里扫描 0x05/0x06/0x0A 模式串 SiemensS7Helper.java:102-107
层 3 · 文案级(多语言):
StringResources.Language.XXX()
Language/DefaultLanguage.java (575 行,中文) / Language/English.java (562 行,英文)
三种协议的错误提取方式对比:
| 协议 | 错误标记方式 | 错误码来源 | 是否位置无关 |
|---|---|---|---|
| Modbus | 功能码高位置 1 | response[2] |
依赖固定偏移 |
| 三菱 MC Binary | 结束码非 0 | getUShort(content, 9) |
依赖固定偏移 |
| 西门子 S7 | payload 中出现 0x05 0x00 等模式 |
content[i] |
需在数据区里扫描才能辨别 |
西门子的做法最糟——不得不在已解析的数据区里做模式匹配来反向辨认"这是不是错误项"。这部分是 S7 协议本身的锅(错误项伪装成正常数据项),但 SiemensS7Helper.java:84 那个 content.length >= 21 魔数无从辩解。
10.3 一个手抄不一致
OperateResult.CreateSuccessResult() → Message = StringResources.Language.SuccessText(); // OperateResult.java:83 多语言
OperateResultExOne.CreateSuccessResult(T c) → Message = "success"; // OperateResultExOne.java:67 硬编码英文
无害(没人校验成功文案),但足以说明这套 OperateResult 家族是手抄 5 遍产生的。
11. 可覆写钩子汇总
| 钩子 | 声明位置 | 默认行为 | 典型覆写者 |
|---|---|---|---|
GetNewNetMessage() |
NetworkDoubleBase.java:87 |
返回 null |
SiemensS7Net.java:48 → new S7Message();MelsecMcNet.java:53 → new MelsecQnA3EBinaryMessage();ModbusTcpNet.java:60 → new ModbusTcpMessage() |
InitializationOnConnect(Socket) |
NetworkDoubleBase.java:381 |
push 模式起后台线程;否则成功 | SiemensS7Net.java:227 → ISO-on-TCP 两次握手 + 协商 PDU |
ExtraOnDisconnect(Socket) |
NetworkDoubleBase.java:416 |
成功 | 少数需发送关闭帧的协议 |
ExtraAfterReadFromCoreServer(OperateResult) |
NetworkDoubleBase.java:426 |
空 | 统计/后置处理 |
PackCommandWithHeader(byte[]) |
NetworkDoubleBase.java:503 |
原样返回 | ModbusTcpNet.java:123 → 加 MBAP;MelsecMcNet.java:108 → 加 11 字节 MC 头 |
UnpackResponseContent(byte[], byte[]) |
NetworkDoubleBase.java:513 |
原样返回 | ModbusTcpNet.java:127 → 剥 MBAP + 抽数据;MelsecMcNet.java:112 → 查错误码 + 剥 11 字节 |
DecideWhetherQAMessage(Socket, OperateResultExOne<byte[]>) |
NetworkDoubleBase.java:78 |
恒 true |
需在推送流里区分"应答帧/自发帧"的协议 |
IpAddressPing() |
NetworkDoubleBase.java:277 |
InetAddress.isReachable(3000) |
无 |
其中 InitializationOnConnect 的西门子实现是教科书式例子(Profinet/Siemens/SiemensS7Net.java:227-243):先用两次 ReadFromCoreServer(socket, plcHead1/plcHead2, true, true) 做 ISO-on-TCP 两层握手(行 229、233),再从第二帧最后两字节协商 PDU 长度并减去 28(行 237,下限兜底 200),最后初始化自增计数 new SoftIncrementCount(65535, 1)(行 240)。
注意它用的是 ReadFromCoreServer(socket, ...) 的带 socket 参重载——此时连接还没登记到 pipeSocket,若走自动获取 socket 的路径会递归创建连接。写 InitializationOnConnect 时必须传 socket 版本,这是本库只在注释里提了一句的隐式约定。
附录:实测编译结论
我在本机用 JDK 21.0.10 对全部 294 个文件做了全量编译:
javac -nowarn -encoding UTF-8 -d /tmp/hslout @filelist.txt
结果:只有 2 个错误,全部来自 JDK 模块化后的包移除:
BasicFramework/SoftBasic.java:6: error: package org.omg.CORBA does not exist
Core/Types/Encoding.java:3: error: package com.sun.org.apache.bcel.internal.generic is not visible
(declared in module java.xml, which does not export it)
2 errors
改成 --release 8 再编,只剩 1 个(com.sun.* 内部包不在 ct.sym 里)。这意味着:
org.omg.CORBA在 JDK 11 被移除,所以该库在 JDK 11+ 上无法直接编译,必须手动处理SoftBasic.java:6这个 import。该 import 是否被实际使用:未在本版本源码中逐一确认(SoftBasic有 1176 行),若确属误引则删掉即可——修复方式标注为"需实测确认"。- 除这两处外,全部 62169 行源码在 JDK 21 下类型检查通过,零语法错误、零未解析符号。
这个结论比任何主观评价都更有价值:它是一个"零第三方依赖但绑了已废弃 JDK 包"的库,落地前务必验证目标 JDK 版本。
设计优点
NetworkBase只管 IO、NetworkDoubleBase只管连接生命周期,职责边界清晰,没有模糊地带。- 两阶段读 +
INetMessage把"粘包拆包"从 4 个魔术数字变成一段可执行代码,能表达"长度域缺失、要靠发送报文反推"这类工业界真实存在的畸形协议。 CheckMessageMatch的三态返回值(-1 继续收 / 0 报错 / 1 匹配)优雅地解决了流水线应答错配,固定偏移解码器做不到。- 用一把锁把整次请求串行化,牺牲并发吞吐换来"永不串帧"的确定性。对绝大多数不支持管线化的 PLC 而言是正确取舍。
PipeBase.lockingTick提供了几乎零成本的可观测指标;SetMultiPorts用 20 行实现端口冗余;编译时不依赖任何第三方 jar。
设计缺点
- 完全没有设置
TCP_NODELAY:Modbus/S7 这类小包协议在默认 Nagle 下会引入数十毫秒级发送延迟,而偏偏目标场景就是对延迟敏感的产线采集。这是本篇最重要的实用性批评。 - 没有
SO_KEEPALIVE:长连接靠isSocketError布尔标志被动跟踪错误,长时间只读不写会滞留半开连接。 NetSupport.java:42注释掉了setSoTimeout,而类注释宣称"每收到新数据就更新超时"——实现与文档不符,大 payload 读取会在单次超时窗口内失败。ReceiveCommandLineFromSocket逐字节读(NetworkBase.java:197),每字节一次setSoTimeout系统调用 + 一次Byte装箱。SimpleHybirdLock名不副实,"混合锁"语义在 Java 版完全丢失;NetworkXBase又另起炉灶用裸Lock。- 多处手写的 try-catch-unlock 而非 finally(
NetworkDoubleBase.java:356-368、666/673/694),三份 unlock 拷贝,易漏。 - push 模式存在线程泄漏与非 volatile 跨线程字段(
NetworkDoubleBase.java:383-405)。 byteTransform在基类构造函数里不初始化(NetworkDoubleBase.java:34-37),去反射后引入 NPE 风险。PipeSocket.isSocketError/indexPort非 volatile,内存可见性完全隐式依赖ReentrantLock;SetPipeSocket判空写反(判断this.pipeSocket而非入参,行 247)。System.out.println硬编码中文串遗留在ThreadPoolCheckTimeOut(NetworkBase.java:93, 102);成功文案一处多语言一处硬编码"success"。- JDK 11+ 无法直接编译(
SoftBasic.java:6的org.omg.CORBAimport)。
可借鉴点
- "读头 → 让协议对象回答还要读多少 → 读体" 这个模式,是纯阻塞 IO 下最清晰的分帧写法。若要在资源受限环境(不能用 Netty)写自定义 TCP 协议,照抄这个骨架即可。
CheckMessageMatch的三态协议(1/0/-1)比 boolean 更有表达力,值得在"流水线应答"类场景复用。PipeSocket把连接参数对象化,从而支持多设备共享一条物理连接:把锁放在"共享资源"上,而不是放在"使用者"上。- 可观测钩子
getLockingTick():在锁里顺手加一个计数器,成本近乎为零,排障时价值巨大。 - "初始化握手必须走带 socket 参数的重载" 这条约定:任何连接后置握手型框架都需要类似约定,并应在类型或文档上明确表达(本库只在注释里提了一句)。
- 用
int负值编码多参数(SpecifiedCharacterMessage)可读性很差,是你自己代码里应避免的反例——优先用一个小配置类。
上一篇:HslCommunication 3.9.3 项目总览与架构 | 下一篇:INetMessage 与粘包解法:33 个帧解析器与 Netty 对比
本文是《工业通信与数据采集》栏目第 11/22 篇 · 上一篇:HslCommunication 3.9.3 项目总览与架构 | 栏目总览 | 下一篇:INetMessage 与粘包解法:33 个帧解析器与 Netty 对比