02 · 通信内核 NetworkBase:Socket 层、两阶段读与四级继承

本篇定位:扒到最底下的那层。上半篇逐段拆解 Core/Net/NetworkBase/NetworkBase.java(1115 行),下半篇讲它上面叠的三层(NetworkDoubleBase / NetworkDeviceBase / NetworkXBase)各加了什么,最后汇到 SimpleHybirdLock、多端口冗余与错误码体系。本篇是四篇里信息密度最高的一篇。

目录


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

三个可以批评的点:

  1. 行 42 注释掉了 setSoTimeout(10_000)。类注释(NetSupport.java:24-26)宣称"为了防止数据太大导致接收失败,所以此处接收到新的数据之后就更新时间",但代码里根本没实现这个更新时间的逻辑——SoTimeout 在进入 Receive 时一次性设定,整个 receive 字节必须在同一窗口内读完。文档承诺与实现不符,传一个大 payload + 5 秒超时就会失败。
  2. 行 43 每次循环都调 socket.getInputStream(),内含状态检查,属于无谓开销。
  3. 行 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
        ╚══════════════════════════════════════════════════════╝

为什么这能解决粘包拆包:

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)。

关键设计点:

  1. CheckMessageMatch 的 -1 语义驱动了"再收一帧"循环。典型场景:Modbus TCP 事务 ID 必须匹配(ModbusTcpMessage.java:71-86),Omron FINS 的 SID 同理(FinsMessage.java:36-46 比对第 25 字节)。这是解决流水线应答错配的关键机制,比固定偏移解码器更灵活。
  2. 行 593 / 594 两个提前返回分别服务"发完不管"与"广播写"(ModbusTcpNet.java:147-150 用它处理广播站号)。
  3. 每次收发都会做一次报文 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

几点观察:

  1. 名字名不副实。SimpleHybirdLock 直接沿用 C# 版(注意 "Hybird" 是原作者的拼写错误,全库一致),C# 版实现是"自旋 + Monitor 混合",Java 版退化成纯 ReentrantLock,"混合"语义已不存在。
  2. lockingTick 计数器是加分项:可在监控线程里轮询 getLockingTick() 看多少请求在排队,成本只是一个 AtomicInteger,排障时价值巨大。
  3. 加锁粒度是"整次请求"而非"同一 socket"——从 GetAvailableSocket 到收完一整帧全程持锁。好处是绝不应答串味;坏处是一条物理连接同时只能处理一个请求,吞吐被限制为 1/RTT,多设备共享 PipeSocket 时会形成队头阻塞。这是正确的工程取舍:绝大多数 PLC(尤其 S7-1200、FX5U)在单连接上本就不支持管线化,并发请求会直接导致应答错配。
  4. 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 里)。这意味着:

这个结论比任何主观评价都更有价值:它是一个"零第三方依赖但绑了已废弃 JDK 包"的库,落地前务必验证目标 JDK 版本。


设计优点

设计缺点

可借鉴点


上一篇:HslCommunication 3.9.3 项目总览与架构 | 下一篇:INetMessage 与粘包解法:33 个帧解析器与 Netty 对比


本文是《工业通信与数据采集》栏目第 11/22 篇 · 上一篇:HslCommunication 3.9.3 项目总览与架构 | 栏目总览 | 下一篇:INetMessage 与粘包解法:33 个帧解析器与 Netty 对比