03 · INetMessage 与粘包解法:33 个帧解析器与 Netty 对比

本篇定位:回答一个问题——"这条 TCP 流,哪里是一帧的结尾?" HslCommunication 的答案是把这件事抽成 INetMessage 接口,然后为每种协议写一个 20~100 行的小类。本篇逐个讲解接口方法、给 33 个实现类做全景分类,并与 Netty 的 LengthFieldBasedFrameDecoder 做正面对比。

目录


1. 为什么需要 INetMessage

TCP 是字节流,没有消息边界。一次 InputStream.read() 可能返回:

Core/IMessage/INetMessage.java(109 行)抽象了"如何认定一帧"这件事。它的使用者只有一个——NetworkBase.ReceiveByMessage(NetworkBase.java:290,已在第 02 篇详解):

阶段一  Receive(socket, N = netMessage.ProtocolHeadBytesLength(), timeout)
        → netMessage.setHeadBytes(头字节)
阶段二  M = netMessage.GetContentLengthByHeadBytes()
        → Receive(socket, M, timeout)
        → SpliceTwoByteArray(head, body)

这个抽象的核心价值:把"每种协议的帧边界怎么定"从通信引擎里彻底剥离出去。ReceiveByMessage 这一份代码服务了 24 个 PLC 品牌 + Modbus + MQTT + Redis + WebSocket 的自定义帧。

类比一下就很清楚:

方案 谁负责定帧 新增协议成本
写死在 Socket 代码里 通信引擎 改核心代码,风险高
Netty LengthFieldBasedFrameDecoder 4 个 int 参数(offset/length/adjustment/strippedBytes) 配参,但遇到非标准帧就配不出来
Hsl INetMessage 一段 Java 代码 写一个 20~100 行的小类

2. 接口方法逐个解释

Core/IMessage/INetMessage.java:9-109,共 11 个方法。逐个解释:

方法 行号 返回值语义 调用时机 谁在调
ProtocolHeadBytesLength() 15 第一次读多少字节。-1 = 开启流式模式;<-1 = 结束符模式(见第 02 篇 §3.4) 每次收帧的第一阶段 NetworkBase.java:318
GetContentLengthByHeadBytes() 22 拿到头之后,算出还要读多少字节。返回 0 = 无体 两阶段读的中间 NetworkBase.java:322
CheckHeadBytesLegal(byte[] token) 30 头是否合法(含 magic 检查、token 校验) 收完整帧后 NetworkDoubleBase.java:646
PependedUselesByteLength(byte[] headByte) 38 头前面有几字节是垃圾(用于重新对齐) 定义了但未被调用 见下方说明
GetHeadBytesIdentity() 45 从帧头提取消息 ID 未被框架调用,由协议自己按需使用 —
setHeadBytes / getHeadBytes 52 / 59 头的读写 — NetworkBase.java:321
getContentBytes / setContentBytes 66 / 73 体的读写 — NetworkBase.java:328
getSendBytes / setSendBytes 80 / 87 保存本次请求报文,供"依靠请求推断响应长度"使用 发送前设置 NetworkDoubleBase.java:587
CheckReceiveDataComplete(byte[] send, MemoryStream ms) 96 流式模式下判断是否收全 每追加一段就问一次 NetworkBase.java:363
CheckMessageMatch(byte[] send, byte[] receive) 107 1 = 匹配 / 0 = 明确错误 / -1 = 不是我这帧,继续收 收完一帧后 NetworkDoubleBase.java:633

2.1 两个最有分量的方法

getSendBytes / setSendBytes:协议可以"回头看自己发了什么"

这是 INetMessage 最特别的设计。很多国产/日系老协议的响应帧根本不带长度字段,长度必须由请求推断。setSendBytes 就是为此存在的。典型使用者:

这是一种"有状态解码器"。它的代价是:一个 Message 实例必须对应一次请求-响应往返,不能复用。

CheckMessageMatch:三态返回值

// Modbus TCP 的实现:Core/IMessage/ModbusTcpMessage.java:71-86
public int CheckMessageMatch(byte[] send, byte[] receive) {
    if (send == null) return 1;
    if (receive == null) return 1;
    if (send.length < 8 || receive.length < 8) return 1;
    if (IsCheckMessageId) {
        if (send[0] != receive[0] || send[1] != receive[1]) return -1;   // 事务ID不一致 → 继续收
    }
    if (StationCheckMatch) {
        if (send[6] != receive[6]) return -1;                             // 站号不一致 → 继续收
    }
    return 1;
}

对应驱动者在 NetworkDoubleBase.java:630-643:匹配就 break,返回 -1 就 times++ 继续收下一帧,直到超时。这个能力是 Netty 的固定偏移解码器不具备的——Netty 假定"先到先服务"的严格顺序。

PependedUselesByteLength:一个定义了却没接线的接口方法

我看遍了 ReceiveByMessage 的全部分支(NetworkBase.java:290-331),没有一处调用过 PependedUselesByteLength。全库只有 DLT645Message.java:18 实现了它:

// Core/IMessage/DLT645Message.java:18-22
public int PependedUselesByteLength(byte[] headByte) {
    int index = HslCommunication.Instrument.DLT.Helper.DLT645Helper.FindHeadCode68H(headByte);
    if (index < 0) return 10;
    return index;
}

这个方法本意是"帧前面有乱码时,告诉引擎跳过几字节重新对齐",但因为没被接线,DLT645 的前导垃圾字节实际上无法自动纠正。这是一个明确的半成品(未在本版本 ReceiveByMessage 中见到调用)。


3. 默认实现 NetMessageBase

Core/IMessage/NetMessageBase.java(56 行)是所有实现类的父类。它不是抽象类,也没有实现 INetMessage:

public class NetMessageBase {
    public boolean CheckHeadBytesLegal(byte[] token)  { return true; }          // 行 8-11  默认放行
    public int GetHeadBytesIdentity()                 { return 0; }             // 行 13-15
    public int PependedUselesByteLength(byte[] headByte) { return 0; }          // 行 17
    public byte[] getHeadBytes()    { return HeadBytes; }                       // 行 19-21
    public byte[] getContentBytes() { return ContentBytes; }                    // 行 23-25
    public byte[] getSendBytes()    { return SendBytes; }                       // 行 27-29
    public void setHeadBytes(byte[] headBytes)    { HeadBytes = headBytes; }    // 行 31-33
    public void setContentBytes(byte[] cb)        { ContentBytes = cb; }        // 行 35-37
    public void setSendBytes(byte[] sendBytes)    { SendBytes = sendBytes; }    // 行 39-41
    private byte[] HeadBytes = null;                                            // 行 43
    private byte[] ContentBytes = null;                                         // 行 45
    private byte[] SendBytes = null;                                            // 行 47
    public boolean CheckReceiveDataComplete(byte[] send, MemoryStream ms) { return true; }  // 行 49
    public int CheckMessageMatch(byte[] send, byte[] receive) { return 1; }     // 行 51
}

所有 33 个实现类的写法一致:extends NetMessageBase implements INetMessage。

设计评价:

另外注意这类里有两个完全没用到的局部变量模式:几乎每个实现方法开头都会写

byte[] HeadBytes = getHeadBytes();
byte[] SendBytes = getSendBytes();        // ← 即使这个方法里根本用不到 SendBytes

这是从 C# 版逐行翻译时留下的(C# 版里有 #region,作者习惯开局把两个 local 取好)。属于无害但刺眼的噪音——Core/IMessage/ 包内就有 36 处 byte[] SendBytes = getSendBytes();,其中相当一部分在该方法里完全没被用到。


4. 33 个帧解析器全景表

Core/IMessage/ 共 34 个文件:1 个接口(INetMessage.java)+ 1 个基类(NetMessageBase.java,不算实现)+ 32 个实现类;此外 CNC/Fanuc/CNCFanucSeriesMessage.java 也在别处实现了同一接口,故全库共 33 个帧解析器。按"帧怎么定界"分成五类:

4.1 类别总览

类别 定界方式 数量
A. 标准长度域 固定头 + 从头里拿长度 25
B. 推断长度 要靠 getSendBytes() 反推 3
C. 流式(-1) 收一段问一次 CheckReceiveDataComplete 2
D. 结束符 SpecifiedCharacterMessage 的负数编码 1
E. 长度域 + 流式兜底 两者结合 1
— 位于 CNC/Fanuc/ 包(结构不一致) 1
合计 33

4.2 完整清单

实现类 文件:行数 帧头字节 长度怎么算 类别 服务于谁
HslMessage HslMessage.java:11-22 32 getInt(head, 28) A HSL 自有协议(NetSimplify 等)
AlienMessage AlienMessage.java:7-16 5 head[4] & 0xFFFF A DTU/异形透传会话
AllenBradleyMessage AllenBradleyMessage.java:7 24 getUShort(head, 2) A AB PLC CIP(EtherNet/IP)
AllenBradleySLCMessage AllenBradleySLCMessage.java 28 head[2]*256 + head[3] A AB SLC / PCCC
AdsNetMessage AdsNetMessage.java:7-22 6 getInt(head,2),钳到 [0, 1e8] A Beckhoff ADS
DLT645Message DLT645Message.java:10-22 10 head[9] & 0xff + 2 E 电能表 DLT645(含 PependedUselesByteLength 与 CheckReceiveDataComplete)
EFORTMessage EFORTMessage.java:7-18 18 getShort(head,16) - 18 A 埃夫特机器人
EFORTMessagePrevious EFORTMessagePrevious.java 17 同上减去 17 A 埃夫特旧版固件
FanucRobotMessage FanucRobotMessage.java:7 56 getUShort(head, 4) A FANUC 机器人
FetchWriteMessage FetchWriteMessage.java:4-25 16 见 §5.4(靠 head[5] + SendBytes[8] 分支) B 西门子 Fetch/Write
FinsMessage FinsMessage.java:6-34 8 getInt(倒序4字节, 0),钳到 [0, 10000] A 欧姆龙 FINS/TCP
FujiCommandSettingTypeMessage FujiCommandSettingTypeMessage.java 5 head[4] & 0xff A 富士
FujiSPBMessage FujiSPBMessage.java:12 5 parseInt(ASCII(head,3,2),16)*2+2 A 富士 SPB
FujiSPHMessage FujiSPHMessage.java 20 getUShort(head, 18) A 富士 SPH
GeSRTPMessage GeSRTPMessage.java:4-13 56 head[4] + head[5]*256 A GE SRTP
InovanceEasyNetMessage InovanceEasyNetMessage.java:11 2 ToUInt16(head,0) - 2 A 汇川 Easy
KukaVarProxyMessage KukaVarProxyMessage.java:7-28 4 head[2]*256 + head[3] A KUKA 机器人 VarProxy
LsisFastEnetMessage LsisFastEnetMessage.java:7-32 20 getShort(head, 16) A LS Electric FEnet
MelsecQnA3EBinaryMessage MelsecQnA3EBinaryMessage.java:4-24 9 head[7] + head[8]*256 A 三菱 MC 二进制
MelsecQnA3EAsciiMessage MelsecQnA3EAsciiMessage.java:8-33 18 parseInt(ASCII(head,14,4), 16) A 三菱 MC ASCII
MelsecA1EBinaryMessage MelsecA1EBinaryMessage.java:5-40 2 见 §5.3 B 三菱 A-1E 二进制
MelsecA1EAsciiMessage MelsecA1EAsciiMessage.java:9 4 见 §5.3 B 三菱 A-1E ASCII
MemobusMessage MemobusMessage.java 12 ToUInt16(head,6) - 12 A 安川 Memobus
ModbusRtuMessage ModbusRtuMessage.java:16-38 -1 — C Modbus RTU Over TCP
ModbusTcpMessage ModbusTcpMessage.java:10-55 8 见 §5.2 A(带 B 倾向) Modbus TCP
OpenProtocolMessage OpenProtocolMessage.java:10-28 4 ToInt32(ASCII(head,0,4)) - 4 + 1;异常时兜底 17 A 阿特拉斯拧紧枪 OpenProtocol
RkcTemperatureMessage RkcTemperatureMessage.java:8-26 -1 — C RKC 温控表(ASCII 控制符)
SAMMessage SAMMessage.java 7 head[5]*256 + head[6] A SAM 协议
S7Message S7Message.java:5-29 4 head[2]*256 + head[3] - 4 A 西门子 S7(ISO-on-TCP TPKT)
ToyoPucMessage ToyoPucMessage.java 4 ToUInt16(head,2),try-catch 兜 0 A 丰田 Toyopuc
YokogawaLinkBinaryMessage YokogawaLinkBinaryMessage.java:4 4 head[2]*256 + head[3] A 横河 Link
CNCFanucSeriesMessage* CNC/Fanuc/ 包下 — — — FANUC 0i 机床(与 FANUC 机器人不同)
SpecifiedCharacterMessage SpecifiedCharacterMessage.java:5-65 负数 见第 02 篇 §3.4 D 所有"以某字符结尾"的文本协议

CNCFanucSeriesMessage.java 位于 CNC/Fanuc/ 包而非 Core/IMessage/,但同样实现 INetMessage。也就是说 Core/IMessage/ 不是唯一的消息实现位置——这是包结构上的一个不一致。


5. 五类定帧模型逐类详解

5.1 类别 A:标准长度域(最干净的一类)

西门子 S7(Core/IMessage/S7Message.java:5-29)——ISO-on-TCP 的 TPKT 头:

public int ProtocolHeadBytesLength(){ return 4; }        // 行 5-7  0x03 0x00 Len_Hi Len_Lo

public int GetContentLengthByHeadBytes(){
    byte[] HeadBytes = getHeadBytes();
    byte[] SendBytes = getSendBytes();                   // ← 未被使用
    if(HeadBytes == null) return 0;
    return (HeadBytes[2] & 0xff) * 256 + (HeadBytes[3] & 0xff) - 4;   // 行 14  注意减掉头自身 4 字节
}

public boolean CheckHeadBytesLegal(byte[] token)
{
    byte[] HeadBytes = getHeadBytes();
    byte[] SendBytes = getSendBytes();                   // ← 未被使用
    if(HeadBytes == null) return false;
    if(HeadBytes[0]==0x03 && HeadBytes[1] == 0x00){ return true; }   // 行 23  TPKT 版本号必须为 3
    else { return false; }
}

-4 很关键:TPKT 的 length 字段描述的是包含自身 4 字节头在内的总长度,而 Hsl 的两阶段模型里"内容长度"必须是头之后的部分,所以要减 4。这个 "lengthAdjustment" 概念在 Netty 里需要一个显式的 lengthAdjustment 参数,在这里只是一行减法。

三菱 MC 二进制(Core/IMessage/MelsecQnA3EBinaryMessage.java:4-24):

public int ProtocolHeadBytesLength() { return 9; }       // 行 4-6
public int GetContentLengthByHeadBytes() {
    byte[] HeadBytes = getHeadBytes();
    byte[] SendBytes = getSendBytes();                   // ← 未被使用
    if (HeadBytes == null) return 0;
    return (HeadBytes[7] & 0xff) + (HeadBytes[8] & 0xff) * 256;   // 行 12  小端
}
public boolean CheckHeadBytesLegal(byte[] token) {
    byte[] HeadBytes = getHeadBytes();
    byte[] SendBytes = getSendBytes();                   // ← 未被使用
    if (HeadBytes == null) return false;
    if ((HeadBytes[0] & 0xff) == 0xd0 && HeadBytes[1] == 0x00) { return true; } else { return false; }  // 行 19
}

注意:这里(HeadBytes[7] + HeadBytes[8]*256)是小端,而 S7 的(HeadBytes[2]*256 + HeadBytes[3])是大端。两种字节序在同一份代码里各自硬编码,没有任何工具方法辅助——于是每一处都要作者手工写 *256。仅在 Core/IMessage/ 包内,* 256 就出现了 16 次。这是 Low-level 代码的必然,但也意味着极易写错。

三菱 MC ASCII(Core/IMessage/MelsecQnA3EAsciiMessage.java:8-33)——长度是 ASCII 十六进制:

public int ProtocolHeadBytesLength() { return 18; }      // 行 8-10
public int GetContentLengthByHeadBytes() {
    byte[] HeadBytes = getHeadBytes();
    if (HeadBytes == null) return 0;
    byte[] buffer = new byte[4];
    buffer[0] = HeadBytes[14]; buffer[1] = HeadBytes[15];   // 行 17-20
    buffer[2] = HeadBytes[16]; buffer[3] = HeadBytes[17];
    return Integer.parseInt(new String(buffer, StandardCharsets.US_ASCII), 16);   // 行 22  ★ radix=16
}

这是 Netty 的 LengthFieldBasedFrameDecoder 完全做不到的场景:长度域是""0018" 这样的 ASCII 十六进制文本"。Netty 只有 lengthFieldLength 为 1/2/3/4/8 字节的二进制解码,且即便扩展也需要自定义解码器。而在 Hsl 的模型里,这只是一个 Integer.parseInt(s, 16)。

欧姆龙 FINS(Core/IMessage/FinsMessage.java:10-23)——长度字段字节序还得倒过来:

public int GetContentLengthByHeadBytes() {
    byte[] HeadBytes = getHeadBytes();
    byte[] SendBytes = getSendBytes();                   // ← 未被使用
    if (HeadBytes == null) return 0;
    byte[] buffer = new byte[4];
    buffer[0] = HeadBytes[7]; buffer[1] = HeadBytes[6];   // 行 15-18   ★ 倒序填充
    buffer[2] = HeadBytes[5]; buffer[3] = HeadBytes[4];
    int length = Utilities.getInt(buffer, 0);
    if (length > 10000) length = 10000;                  // 行 20  钳上限
    if (length < 0) length = 0;                          // 行 21  钳下限
    return length;
}

注意行 20-21 的钳位——这是一个重要的防御性实践:如果帧头是脏的、长度字段被解析成一个巨大数字,Receive(socket, 巨大值) 会挂死到超时。钳位能快速失败。全库只有 6 个类做了这类保护(FinsMessage 行 20-21、AdsNetMessage 行 17-18、MemobusMessage、OpenProtocolMessage 行 22、ModbusTcpMessage 行 27、ToyoPucMessage 的 try-catch 兜 0)。其余 27 个类没有任何防护——这是不一致的安全实践。

5.2 类别 A 的一个特殊变体:Modbus TCP( ModbusTcpMessage)

Core/IMessage/ModbusTcpMessage.java 是 33 个类里最长、最有故事的一个(95 行)。它的 GetContentLengthByHeadBytes(行 14-55)做了三层递进:

int length = (HeadBytes[4] & 0xff) * 256 + (HeadBytes[5] & 0xff);       // 行 19  MBAP 里的 length 字段
int remainLength = 0;
if (length == 0) {
    HeadBytes = SoftBasic.BytesArrayRemoveBegin(HeadBytes, 1);          // 行 23  ★ 丢掉头一个字节,重对齐
    setHeadBytes(HeadBytes);                                            // 行 24  ★ 写回自己的 head!
    remainLength = (HeadBytes[4] & 0xff) * 256 + (HeadBytes[5] & 0xff) - 1;
} else {
    remainLength = Math.min(length - 2, 300);                           // 行 27  ★ 上限 300
}

if (SendBytes != null && SendBytes.length == 12) {                       // 行 30
    if (SendBytes[7] == ModbusInfo.ReadRegister || SendBytes[7] == ModbusInfo.ReadInputRegister) {
        int readLength = (SendBytes[10] & 0xff) * 256 + (SendBytes[11] & 0xff);
        if (readLength > 0 && readLength <= 127) {
            int expectLength = readLength * 2 + 1;                       // 行 34  字节计数+长度字节
            if (expectLength != remainLength && length == 6) {
                // 2025年7月23日 00:15:45  此处以读取的长度为准,剩余长度需要重新计算
                remainLength = expectLength;                             // 行 38
            }
        }
    } else if (SendBytes[7] == ModbusInfo.ReadCoil || SendBytes[7] == ModbusInfo.ReadDiscrete) {
        int readLength = (SendBytes[10] & 0xff) * 256 + (SendBytes[11] & 0xff);
        if (readLength > 0 && readLength <= 2040) {
            int expectLength = (readLength - 1) / 8 + 1 + 1;             // 行 44
            if (expectLength != remainLength && length == 6) {
                remainLength = expectLength;
            }
        }
    }
}
return remainLength;

这段代码讲的是血泪史:

评价:

另外注意 CheckHeadBytesLegal(行 58-67)只检查 head[2] == 0 && head[3] == 0(MBAP 协议标识必须为 0),而没有检查 magic 是否含有未被 strip 的字节。

5.3 类别 B:靠请求推断长度(难度最高)

三菱 A-1E 二进制(Core/IMessage/MelsecA1EBinaryMessage.java:5-40)——它的帧头只有 2 字节,响应长度必须由请求的命令码决定:

public int ProtocolHeadBytesLength(){ return 2; }        // 行 5-7  只有副标题 + 结束码

public int GetContentLengthByHeadBytes(){
    byte[] HeadBytes = getHeadBytes();
    byte[] SendBytes = getSendBytes();
    if (HeadBytes[1] == 0x5B) return 2;                  // 行 12  CPU 异常:异常码 + 0x00
    else if (HeadBytes[1] == 0x00)
    {
        switch (HeadBytes[0])
        {
            case (byte)0x80: return SendBytes[10] != 0x00 ? ((SendBytes[10] & 0xff) + 1) / 2 : 128;   // 行 17  位批量读;请求未填点数时兜底 128
            case (byte)0x81: return (SendBytes[10] & 0xff ) * 2;                                       // 行 18  字批量读
            case (byte)0x82:                                                                            // 行 19  位批量写
            case (byte)0x83: return 0;                                                                 // 行 20  字批量写:无体
            default: return 0;
        }
    }
    else return 0;
    // 注释:在A兼容1E协议中,写入值后,若不发生异常,只返回副标题 + 结束代码(0x00)
}

注意行 17 的 (SendBytes[10] + 1) / 2 与那个 : 128 兜底:请求里的"读取点数"决定响应字节数;若点是 0(请求里没写),就按最大 128 字节读。这是一处猜测。

CheckHeadBytesLegal(行 31-40)也很特别——它拿 HeadBytes[0] 和 SendBytes[0] 做减法验证:

if (((HeadBytes[0] & 0xff) - (SendBytes[0] & 0xff)) == 0x80) { return true; }

即响应副标题必须 = 请求副标题 + 0x80。这是唯一一个 CheckHeadBytesLegal 依赖 SendBytes 的实现。

5.4 再多一个 B 类例子:Siemens Fetch/Write

Core/IMessage/FetchWriteMessage.java:8-25 的逻辑更绕:不仅要看响应的 HeadBytes[5](命令类型),还要回头看请求的 SendBytes[8]:

public int GetContentLengthByHeadBytes() {
    byte[] HeadBytes = getHeadBytes();
    byte[] SendBytes = getSendBytes();
    if (HeadBytes[5] == 0x05 || HeadBytes[5] == 0x04) return 0;         // 行 12
    if (HeadBytes[5] == 0x06) {                                          // 行 13
        if (SendBytes == null) return 0;
        if (HeadBytes[8] != 0x00) return 0;                              // 行 15  发生了错误
        if (SendBytes[8] == 0x01 || SendBytes[8] == 0x06 || SendBytes[8] == 0x07)
            return ((SendBytes[12] & 0xff) * 256 + (SendBytes[13] & 0xff)) * 2;   // 行 17  按字:×2
        return (SendBytes[12] & 0xff) * 256 + (SendBytes[13] & 0xff);    // 行 18  按字节
    } else if (HeadBytes[5] == 0x03) {
        if (HeadBytes[8] == 0x01 || HeadBytes[8] == 0x06 || HeadBytes[8] == 0x07)
            return ((HeadBytes[12] & 0xff) * 256 + (HeadBytes[13] & 0xff)) * 2;   // 行 21
        return (HeadBytes[12] & 0xff) * 256 + (HeadBytes[13] & 0xff);    // 行 22
    }
    return 0;
}

注意同一个任务在两处会切换数据源:HeadBytes[5] == 0x06 时看请求的 SendBytes,而 == 0x03 时看响应的 HeadBytes。这是把协议文档的手wave写成了代码。难读,但它确实在服务款奇形怪状的西门子 Fetch/Write。

5.5 类别 C:流式(-1)模式

ModbusRtuMessage(Core/IMessage/ModbusRtuMessage.java:16-38)是纯粹的流式:

public int ProtocolHeadBytesLength() { return -1; }              // 行 16-18
public int GetContentLengthByHeadBytes() { return 0; }           // 行 21-23
public boolean CheckReceiveDataComplete( byte[] send, MemoryStream ms ) {
    return HslCommunication.ModBus.ModbusInfo.CheckRtuReceiveDataComplete(send, ms.ToArray());   // 行 27
}
public int CheckMessageMatch( byte[] send, byte[] receive ) {
    if (!StationCheckMatch) return 1;                            // 行 33
    return HslCommunication.ModBus.ModbusInfo.CheckRtuMessageMatch( send, receive );             // 行 34
}

注意行 27:Core/IMessage 反向依赖了 HslCommunication.ModBus。这是全库唯一一处 Core → 应用协议包的反向依赖,破坏了分层。

RkcTemperatureMessage(Core/IMessage/RkcTemperatureMessage.java:14-26)则是通过 ASCII 控制符判断:

public boolean CheckReceiveDataComplete( byte[] send, MemoryStream ms )
{
    byte[] buffer = ms.ToArray( );
    if (buffer.length == 1)
        if (buffer[0] == AsciiControl.ACK || buffer[0] == AsciiControl.NAK) return true;   // 行 19
    else if (buffer.length == 6 && buffer[0] == AsciiControl.EOT
             && buffer[buffer.length - 1] == AsciiControl.ENQ) return true;                // 行 21
    if (buffer.length > 3 && buffer[0] == AsciiControl.STX
        && buffer[buffer.length - 2] == AsciiControl.ETX) return true;                     // 行 23
    if (buffer.length > 6 && buffer[0] == AsciiControl.EOT && buffer[3] == AsciiControl.STX
        && buffer[buffer.length - 2] == AsciiControl.ETX) return true;                     // 行 24
    return false;
}

温控表的 RESP 协议用 STX/ETX/EOT/ENQ/ACK/NAK 这些 1960 年代的电传 control code 做分界。这种"看开头是 STX、倒数第二位是 ETX"的判断逻辑,用参数化解码器表达不出来。

5.6 类别 E:长度域 + 流式兜底(DLT645Message)

// Core/IMessage/DLT645Message.java:10-26
public int ProtocolHeadBytesLength() { return 10; }                        // 行 10-12
public int GetContentLengthByHeadBytes() { return (getHeadBytes()[9] & 0xff) + 2; }   // 行 14-16  数据域长度 + 2 字节校验+结束符
public int PependedUselesByteLength(byte[] headByte) { ... }               // 行 18-22  (未被调用)
public boolean CheckReceiveDataComplete(byte[] send, MemoryStream ms) {
    return DLT645Helper.CheckReceiveDataComplete(ms);                      // 行 24-26  再兜一层
}

DLT645 的帧头是 10 字节(68H addr[6] 68H ctrl len),第 10 字节(index 9)是数据域长度,+2 是 CS 校验和 16H 结束符。同时它还实现了 CheckReceiveDataComplete 做二次确认。


6. 一个私有协议接入示例

假设你要接一台国产温控器,协议是:0xAA 0x55 | [len_lo] [len_hi] | payload... | 0x0D。怎么接?只需要写这一个类(约 25 行):

public class MyTcMessage extends NetMessageBase implements INetMessage {
    public int ProtocolHeadBytesLength()      { return 4; }                     // AA 55 len_lo len_hi
    public int GetContentLengthByHeadBytes()  {
        byte[] h = getHeadBytes();
        if (h == null || h.length < 4) return 0;
        if ((h[0] & 0xff) != 0xAA || (h[1] & 0xff) != 0x55) return 0;           // magic 不对 ← 返回 0 意为"没有体"
        return (h[2] & 0xff) + (h[3] & 0xff) * 256;
    }
    public boolean CheckHeadBytesLegal(byte[] token) {
        byte[] h = getHeadBytes();
        return h != null && (h[0] & 0xff) == 0xAA && (h[1] & 0xff) == 0x55;
    }
}

然后在设备类里:

@Override protected INetMessage GetNewNetMessage() { return new MyTcMessage(); }

就这样。 不要再碰 NetworkBase.ReceiveByMessage 一个字。这就是这套抽象最大的价值——新增协议的边际成本被压到 25 行。


7. 与 Netty LengthFieldBasedFrameDecoder 对比

7.1 Netty 的做法(简述)

// new LengthFieldBasedFrameDecoder(maxFrameLength, lengthFieldOffset, lengthFieldLength,
//                                  lengthAdjustment, initialBytesToStrip)
new LengthFieldBasedFrameDecoder(1024, 0, 2, 0, 0)

四个参数:长度域在哪、几个字节、长度值要不要修正、要不要剥掉头部字节。它在一个累积缓冲区上反复尝试解码,状态机是 Netty 管的。

7.2 异同对照表

维度 Hsl INetMessage Netty LengthFieldBasedFrameDecoder
定界表达力 任意 Java 代码 4 个 int 参数(二进制长度域)
能否处理 ASCII 十六进制长度域 ✅ Integer.parseInt(s, 16) ❌ 需自定义解码器
能否处理"长度由请求推断" ✅ getSendBytes() ❌ 完全不行(解码器是单向的)
能否处理"靠结束符" ✅ CheckReceiveDataComplete 任意逻辑 ❌ 需换 LineBasedFrameDecoder / DelimiterBasedFrameDecoder
能否处理"多帧乱序应答" ✅ CheckMessageMatch 三态 + 重试循环 ❌ 假定严格 FIFO
能否在解析过程中"修正头" ✅ ModbusTcpMessage.java:24 重写 head ❌
能否在失败前做特殊兼容 ✅ 任意 if-else(ModbusTcpMessage 的脏数据分支) ❌ 抛 TooLongFrameException 或等数据
是否需要处理半包 不需要(Receive 里有 while 循环,必然读满) 需要(累积缓冲区 + 状态机)
是否零拷贝 / 缓冲复用 ❌ 每次 new byte[length] + SpliceTwoByteArray 再拷一遍 ✅ ByteBuf 池化 + slice 零拷贝
是否异步 / 高并发 ❌ 阻塞 BIO,一个连接同时只能处理一个请求 ✅ NIO + EventLoop,一条连接可管线化
内存上限保护 靠每个 Message 类自己 Math.min(只有 5 个类做了) maxFrameLength 强制统一保护
新增协议成本 一个类,形态自由 配参数(能表达的场合很省),不能表达就得写 ByteToMessageDecoder
依赖 零(只吃 JDK) Netty(jar 体积 MB 级)
学习曲线 低(写两段 int 返回方法) 中(要理解 ByteBuf 生命周期与引用计数)

7.3 Netty 解码器能表达、而 Hsl 需要多写几行的例子

标准帧 | HDR(2) | LEN(4) | BODY(LEN) |:

// Netty
new LengthFieldBasedFrameDecoder(65535, 2, 4, 0, 0)

// Hsl
public int ProtocolHeadBytesLength()     { return 6; }
public int GetContentLengthByHeadBytes() { return Utilities.getInt(getHeadBytes(), 2); }

在这个最标准、最常见的场合,Netty 一行胜出,Hsl 需要写一个类。 这是 Netty 的优势区。

7.4 Hsl 能表达、Netty 需要换解码器或自写的例子

场景 Netty Hsl
长度域是 ASCII 十六进制(三菱 MC ASCII 帧) 写自定义 ByteToMessageDecoder Integer.parseInt(s,16),一行(已实现)
响应长度由请求的命令码决定(三菱 A-1E) 做不到,得在上层 Hack(记录最后一次请求) getSendBytes() 天然支持(接口内置)
协议靠 STX...ETX 分界(RKC 温控) 换 DelimiterBasedFrameDecoder;多个结束符还需多实例 CheckReceiveDataComplete 随意组合判断
收到脏字节要重对齐(Modbus TCP 网关) 无此概念 BytesArrayRemoveBegin(HeadBytes,1) + setHeadBytes
多主站共用链路、应答可能错序 无此概念 CheckMessageMatch 返回 -1,继续收

7.5 一个本质差异:同步 vs 异步模型

这是最重要的对比,甚至比"表达力"更重要:

Netty (异步/事件驱动)                      Hsl (同步阻塞)
─────────────────────────────────         ─────────────────────────────────
channel.read → 可能只有半帧               Receive(socket, N) → 必然返回 N 字节
  ↓ 累积到 ByteBuf                          ↓ 不到就死等(或 SoTimeout)
再 read → 拼出完整帧 → 触发 channelRead   Receive(socket, M) → 必然返回 M 字节
  ↓                                         ↓
pipeline 继续处理下个 handler             拼接返回,调用方直接拿到完整帧

Netty 的模型适合"高并发、不确定何时到、可能同时有几十条连接";Hsl 的模型适合"我发一条命令,然后死等这一条应答"——后者恰恰是 PLC 主站采集的真实模型:严格的一问一答,重试由上层控制。

所以这两者不是"谁替代谁",而是解决不同规模的问题。 Hsl 用同步 IO 换取了:

  1. 极低的认知负担(调用方拿到的返回值就是一帧,不存在"还没收全"的状态)
  2. 零 ByteBuf 生命周期管理(不用 release(),没有内存泄漏风险)
  3. 极低延迟抖动(没有 EventLoop 队列延迟)

换取这些的代价是:单连接吞吐被限制为 1/RTT(已在第 02 篇说明,这个取舍对 PLC 是正确的)。

7.6 一个双方都存在的缺陷

两者都不能优雅处理"长度字段本身损坏"。

全库只有 6 个 Message 类做了长度防护(FinsMessage 行 20-21、AdsNetMessage 行 17-18、MemobusMessage、OpenProtocolMessage 行 22、ModbusTcpMessage 行 27、ToyoPucMessage 的 try-catch)。这是一个应该做成框架级、而不是让每个协议各自考虑的能力:在 ReceiveByMessage 里加一个统一的 maxFrameLength 参数会是更安全的做法。


8. 综合评价:什么时候该用哪套

你的场景 推荐
高并发网关(几百条连接同时收发) Netty
标准 Modbus TCP / S7 单线采集 两者都行;Hsl 上手快(零依赖)
国产/日系非标准私有协议(长度域奇怪、要靠请求推断) Hsl INetMessage——写一个类,比自定义 Netty 解码器省一半代码
串口透传服务器(一 TCP 挂 N 台 RTU 从站) Hsl(共享 PipeSocket + 请求串行化锁,已在第 02 篇讲过)
需要 ByteBuf 零拷贝、大吞吐传输 Netty
目标环境是 Java 6 / 内存 64MB 的工控机 Hsl(零第三方依赖,且没有 Netty 的堆外内存管理)

一个务实的组合:如果你的系统同时有"高并发南向接入"和"各种奇怪北向设备",完全可以在 Netty 的 pipeline 里复用 INetMessage 的逻辑——把 ProtocolHeadBytesLength() 和 GetContentLengthByHeadBytes() 当成 ByteToMessageDecoder 的两个回调来调用。这两者的抽象其实很接近,只是 Netty 把状态机摆在了框架侧、Hsl 摆在了协议侧。


设计优点

设计缺点

可借鉴点


上一篇:通信内核 NetworkBase:Socket 层、两阶段读与四级继承 | 下一篇:协议实现与字节转换:ModBus / Siemens / Melsec 与 Core.Transfer


本文是《工业通信与数据采集》栏目第 12/22 篇 · 上一篇:通信内核 NetworkBase:Socket 层、两阶段读与四级继承 | 栏目总览 | 下一篇:协议实现与字节转换:ModBus / Siemens / Melsec 与 Core.Transfer