04 · 基础设施与工程特性

本文分析支撑三大协议实现的公共底座:common 包(字节缓冲 / 数据类型 / 注解序列化 / 分包算法)、异常体系、异步回调、线程安全模型、设计模式使用、src/test 测试与 demo 结构,以及 RTSP / RTP / RTCP / MP4 等"非 PLC"模块的用途。所有代码片段均标注文件与行号。

本文目录


1. 基础设施全景

common 包只有 16 个文件,却是全库的"词汇表"——所有协议模块的报文类都在调用它:

common/
├── IObjectByteArray.java     自描述报文契约(byteArrayLength + toByteArray)
├── IObjectString.java        文本化输出(RTSP/SDP 用)
├── buff/     ByteWriteBuff(561) / ByteReadBuff(509) / BitWriteBuff(102) / EByteBuffFormat(187)
├── enums/    EDataType.java(14 个数据类型 + 字节长度)
├── constant/ GeneralConst.java(LOCALHOST / S7_PORT=102 / MODBUS_PORT=502 / MC 端口)
├── algorithm/ LoopGroupAlg.java / LoopGroupItem.java(分包算法)
└── serializer/
    ├── IByteArraySerializable.java   字节数组 ↔ 对象
    ├── IPLCSerializable.java         PLC ↔ 对象(read/write)
    ├── ByteArrayVariable.java        @ByteArrayVariable 注解
    ├── ByteArrayParameter.java       注解参数的运行时载体
    ├── ByteArrayParseData.java       (注解, 字段) 二元组
    └── ByteArraySerializer.java      IByteArraySerializable 的唯一实现

配套的 utils 包 13 个纯静态工具类:ByteUtil / ShortUtil / IntegerUtil / LongUtil / FloatUtil / BooleanUtil / HexUtil / CRCUtil / LRCUtil / BCDUtil / MD5Util / StringSpUtil / TimesUtil。

CRCUtil(Modbus RTU)与 LRCUtil(Modbus ASCII)放在公共 utils,而不是 modbus 包内——略显随意,但确实只有一个协议用得到它们。

分层关系:

                 IByteArraySerializable        IPLCSerializable
                 (byte[] ↔ Object)             (PLC ↔ Object)
                        │                             │
              ByteArraySerializer                S7Serializer
                        │                             │
        ByteReadBuff / ByteWriteBuff  ◄────────────────┘
                        │
        ByteUtil / ShortUtil / IntegerUtil / FloatUtil / BooleanUtil

即:工具类 → 缓冲层 → 序列化层 → 协议层,四层单向依赖,无环。这是本库结构最干净的部分。


2. 字节缓冲层:ByteWriteBuff / ByteReadBuff / BitWriteBuff

2.1 ByteWriteBuff:定长数组 + 游标

设计要点(common/buff/ByteWriteBuff.java:41-89):构造即定长,不扩容。

  47→    private final EByteBuffFormat format;      // 4/8 字节的字序
  53→    private final byte[] data;                 // 定长数组,构造时一次性分配
  59→    private int offset = 0;                    // 写游标
  65→    private final boolean littleEndian;        // 字节序,默认 false = 大端
  73→    public ByteWriteBuff(int capacity) {
  74→        this(capacity, false, EByteBuffFormat.DC_BA);

三个正交维度:

维度 取值 影响范围
littleEndian false(默认)/ true 所有多字节类型(2/4/8 字节)
format AB_CD / BA_DC / CD_AB / DC_BA(默认) 仅 4 字节与 8 字节(2 字节不受影响)
offset 游标 顺序写;显式指定 desIndex 时不影响游标

写入不越界的唯一守卫是 checkCondition(ByteWriteBuff.java:128-133):

 128→    private void checkCondition(int desIndex, int targetLength) {
 129→        if (desIndex + targetLength > data.length) {
 131→            throw new IllegalArgumentException("Exceeds the maximum capacity of the byte array");

游标推进规则很微妙(ByteWriteBuff.java:153-160、206-216):只有 desIndex == this.offset 时才推进 offset。也就是说,用 putByte(x, 5) 回填第 5 字节不会破坏顺序写的游标——这正是 selfCheck() 能"先顺序写 body,再回填 header 长度"的原因。

⚠️ 但 putBytes(src, srcIndex, desIndex) 的越界判断用了 src.length - srcIndex(第 210 行),若 srcIndex > src.length 会得到负数而绕过检查,随后 System.arraycopy 才抛异常。属于边界不严谨但不致命。

2.2 ByteReadBuff:读游标与越界检查

ByteReadBuff.java:42-108 结构对称,多了 getRemainSize()(第 74 行)。读方法成对出现:无参版本推进游标,带 index 版本不推进(ByteReadBuff.java:170-174、356-359)。

 170→    public byte getByte() {              // 顺序读,offset++
 171→        byte res = this.getByte(this.offset);
 172→        this.offset++;
 173→        return res;
 356→    public byte getByte(int index) {      // 随机读,不动 offset
 357→        this.checkCondition(index);
 358→        return this.data[index];

checkCondition(第 140-149 行)只检查上界且不检查"读越界长度"——getBytes(index, length) 用 checkCondition(index + length - 1)(第 370 行)间接检查,当 length == 0 时会传 index - 1,若 index == 0 则抛 IndexOutOfBoundsException("Index less than 0")。读 0 字节是合法需求,这里会误报(需实测确认)。

2.3 API 对照表

语义 ByteWriteBuff ByteReadBuff 字节数 受 format 影响
字节 putByte(byte/int) putBytes(byte[]) getByte() getBytes(n) getByteToInt() 1 —
位 —(由 BitWriteBuff 负责) getBoolean(bit) getBitToInt(i,bit) getByteToInt(i,start,len) 1 位 —
16 位 putShort(short/int) getInt16() getUInt16() 2 ❌
32 位 putInteger(int/long) getInt32() getUInt32() 4 ✅
64 位 putLong(long) getInt64() 8 ✅
浮点 putFloat(float) putDouble(double) getFloat32() getFloat64() 4 / 8 ✅
字符串 putString(s) / putString(s, charset) getString(len) / getString(len, charset) n —

putString 默认 US_ASCII(ByteWriteBuff.java:387),而 S7Serializer 默认 GB2312——默认值不一致的问题在第一篇已提到,根因就在这里。

2.4 EByteBuffFormat:字序建模

这是本库最有辨识度的设计。工业现场经常遇到"西门子/三菱的 32 位数据与上位机字节序不一致",常见解决方案是手写 byte[0]^byte[3] 交换,本库把它抽象成 4 种字序(common/buff/EByteBuffFormat.java:36-59):

  41→    AB_CD("AB_CD"),   // 完全倒序(4 字节:D C B A)
  47→    BA_DC("BA_DC"),   // 单字节内反转(B A D C)
  53→    CD_AB("CD_AB"),   // 双字节反转(C D A B)
  59→    DC_BA("DC_BA");   // 原始顺序(默认)

实现就是一个 switch 重排(EByteBuffFormat.java:86-118):

  86→    public byte[] formatIn4Bytes(byte[] data, int index) {
  87→        byte[] res = new byte[4];
  88→        switch (this.code) {
  89→            case "AB_CD":
  90→                res[0] = data[index + 3];  res[1] = data[index + 2];
  92→                res[2] = data[index + 1];  res[3] = data[index + 0];
 113→            default:
 115→                throw new HexParseException("The data format is not implemented");

formatIn8Bytes(第 139 行起)同理,8 字节版本。

关键点:format 只在 4/8 字节路径生效(ByteWriteBuff.java:438、478、505、532),putShort / getInt16 完全不受影响。这符合物理事实——"字序(word swap)"只对 32/64 位数据有意义。

批判:switch (this.code) 用字符串匹配枚举分支,而不是 switch (this)。字符串 switch 有哈希碰撞的语义风险,且重命名枚举常量名时不会编译报错——应当改成 switch(this) 或把行为放进枚举构造器。这是典型的"用常量代替多态"。

2.5 BitWriteBuff:按位写入

全库最短的类之一(102 行),专供 Modbus 线圈/离散输入的位拼接(common/buff/BitWriteBuff.java:35-101):

  39→    public BitWriteBuff(int bitSize) {
  40→        this.buffer = new byte[(bitSize + 7) / 8];     // 按位申请,向上取整到字节
  49→    public void writeBits(byte[] data, int bitCount) {
  50→        for (int i = 0; i < bitCount; i++) {
  54→            int bit = (data[byteIndex] >> bitIndex) & 1;   // 源数据 LSB 优先
  56→            this.writeBit(bit);
  66→    private void writeBit(int bit) {
  70→        this.buffer[byteIndex] |= (byte) (bit << bitIndex);
  71→        this.bitPos++;

写入是"或运算"而非赋值(第 70 行),因此只能把 0 写成的位保持为 0,不能把已置 1 的位清 0——对新建缓冲区没问题,复用缓冲区则会出错。另有 writeByteBits(value, bitCount, lsbFirst)(第 81 行)支持按 MSB/LSB 优先展开一个字节。

使用场景见第三篇 §1.3:Modbus 分包读线圈时,每包结果用 buff.writeBits(...) 累加,最后 toByteArray() 一次性取回。


3. EDataType 枚举

common/enums/EDataType.java:37-83,14 个常量,每个带字节长度:

类型 字节 Java 侧类型 说明
BOOL 1 Boolean / List<Boolean> 位,配合 bitOffset
BYTE 1 Byte / List<Byte>
UINT16 / INT16 2 Integer / Short 不受 format 影响
UINT32 / INT32 4 Long / Integer 受 format 影响
INT64 8 Long
FLOAT32 / FLOAT64 4 / 8 Float / Double
STRING 1(每字符) String 长度由 count 决定
TIME 4 long 仅 S7(第 63 行)
DATE 2 LocalDate 仅 S7
TIME_OF_DAY 4 LocalTime 仅 S7
DTL 12 LocalDateTime 仅 S7

名称反查用了静态内部类 Holder 懒加载(EDataType.java:87-102):

  88→    private static class Holder {
  89→        private static final Map<String, EDataType> INSTANCE = createMap();
 100→    public static EDataType from(String data) {
 101→        return Holder.INSTANCE.get(data);

这是《Effective Java》式的懒加载单例写法,避免了 values() 的每次数组拷贝。不过 from() 在源码中调用点极少(主要用于序列化器的反序列化路径),收益有限。


4. 注解序列化机制(重点)

这是本库最有特色的部分:用注解描述"字段 ↔ 字节/地址"的映射,一次反射完成对象与字节数组的双向转换。有两种形态。

4.1 两套注解与两个序列化器

@ByteArrayVariable @S7Variable
文件 common/serializer/ByteArrayVariable.java:40 protocol/s7/serializer/S7Variable.java:39
序列化器 ByteArraySerializer S7Serializer
接口 IByteArraySerializable(toObject / toByteArray) IPLCSerializable(read / write)
定位维度 字节偏移 + 位偏移(byteOffset / bitOffset) PLC 地址(address = "DB1.0")
数据源 内存 byte[] 真实 PLC(走一次读请求)
属性 byteOffset bitOffset count type littleEndian format address type count
支持数组 count > 1 → 字段必须是 List 同(BYTE/STRING 除外)

@ByteArrayVariable 注解定义(ByteArrayVariable.java:40-88):

  40→public @interface ByteArrayVariable {
  48→    int byteOffset() default 0;                              // 字节偏移量
  56→    int bitOffset() default 0;                               // 位偏移量 [0,7]
  64→    int count() default 1;                                   // 数量>1 时字段必须是 List
  71→    EDataType type() default EDataType.BYTE;
  79→    boolean littleEndian() default false;
  87→    EByteBuffFormat format() default EByteBuffFormat.DC_BA;

@Target(FIELD) + @Retention(RUNTIME)(第 37-38 行)——只有字段级,不支持类级配置,也不支持嵌套对象。

4.2 toObject():字节数组 → 对象

ByteArraySerializer.java:64-83,三步:反射扫描 → 构造参数 → 抽值填充:

  65→    public <T> T toObject(final Class<T> targetClass, final byte[] src) {
  67→            final T bean = targetClass.newInstance();
  68→            for (final Field field : targetClass.getDeclaredFields()) {
  69→                final ByteArrayVariable variable = field.getAnnotation(ByteArrayVariable.class);
  70→                if (variable == null) { continue; }
  73→                ByteArrayParameter parameter = new ByteArrayParameter(variable.byteOffset(), variable.bitOffset(),
  74→                        variable.count(), variable.type(), variable.littleEndian(), variable.format());
  75→                this.checkByteArrayVariable(parameter);
  76→                this.extractData(src, bean, field, parameter);
  81→            throw new ByteArrayParseException("parsing to object error, cause:" + e.getMessage(), e);

四个可注意的点:

  1. targetClass.newInstance()(第 67 行)——Java 9 起已废弃,且要求 public 无参构造,否则抛异常被包成 ByteArrayParseException;
  2. getDeclaredFields()(第 68 行)——不含父类字段,继承的 Bean 会静默丢字段;
  3. 每个字段都 new ByteReadBuff(...)(见下节),大对象有额外分配开销;
  4. 异常被 catch (Exception e) 吞掉原始堆栈类型,只保留 message + cause。

4.3 extractData():按类型分发与位寻址

ByteArraySerializer.java:176-244,一个 switch (variable.getType()) 覆盖全部 14 个类型。先看 BOOL 分支——位寻址是怎么算的(第 180-188 行):

 177→        ByteReadBuff buff = new ByteReadBuff(src, 0, variable.isLittleEndian(), variable.getFormat());
 180→            case BOOL:
 181→                List<Boolean> booleans = IntStream.range(0, variable.getCount())
 182→                        .mapToObj(x -> {
 183→                            int byteAdd = variable.getByteOffset() + (variable.getBitOffset() + x) / 8;
 184→                            int bitAdd = (variable.getBitOffset() + x) % 8;
 185→                            return buff.getBoolean(byteAdd, bitAdd);
 187→                field.set(bean, variable.getCount() == 1 ? booleans.get(0) : booleans);

即:count 个 bool 从 byteOffset 字节的 bitOffset 位开始连续排布,跨字节自动进位。这是 Modbus/PLC 位数据的常见布局,实现正确。

其余分支都是同一模板(以 UINT16 为例,第 195-200 行):

 196→                List<Integer> uint16s = IntStream.range(0, variable.getCount())
 197→                        .mapToObj(x -> buff.getUInt16(variable.getByteOffset() + x * variable.getType().getByteLength()))
 199→                field.set(bean, variable.getCount() == 1 ? uint16s.get(0) : uint16s);

count == 1 时注入裸值,count > 1 时注入 List——字段声明必须匹配,否则 IllegalArgumentException。

STRING 是特例(第 237-239 行):buff.getString(byteOffset, count, charsets),count 表示字符数。

4.4 toByteArray():对象 → 字节数组

ByteArraySerializer.java:122-163,分两步:先扫描算总长,再分配并填充。

 126→            int buffSize = 0;
 128→            for (final Field field : targetBean.getClass().getDeclaredFields()) {
 133→                ByteArrayParameter parameter = new ByteArrayParameter(variable.byteOffset(), ...);
 136→                parseDataList.add(new ByteArrayParseData(variable, field));
 137→                int maxPos = variable.byteOffset() + variable.count() * variable.type().getByteLength();
 138→                if (maxPos > buffSize) { buffSize = maxPos; }      // 取所有字段的最大末位
 142→            if (buffSize == 0 || parseDataList.isEmpty()) { return new byte[0]; }
 146→            ByteWriteBuff buff = ByteWriteBuff.newInstance(buffSize);
 147→            for (ByteArrayParseData item : parseDataList) {
 149→                Object data = item.getField().get(targetBean);
 150→                if (data == null) { continue; }                   // null 字段跳过,留 0
 153→                if (item.getVariable().count() == 1) {
 154→                    this.fillOneData(item.getVariable(), data, buff, 0);
 155→                } else {
 156→                    this.fillListData(item.getVariable(), data, buff);

注意:长度是最大值而非累加值,说明注解模型是"偏移量寻址"而非"顺序拼接"——两个字段可以重叠(比如同一字节拆成高低 4 位),这是有意为之。

fillOneData(第 276-315 行)是 extractData 的镜像,BOOL 分支用读-改-写实现置位(第 279-282 行):

 279→                int byteAdd = variable.byteOffset() + (variable.getBitOffset() + index) / 8;
 280→                int bitAdd = (variable.getBitOffset() + index) % 8;
 281→                byte newByte = BooleanUtil.setBit(buff.getByte(byteAdd), bitAdd, (Boolean) data);
 282→                buff.putByte(newByte, byteAdd);

因为 putByte(x, index) 在 index != offset 时不推进游标(§2.1),这里的随机回填是安全的。

4.5 校验与限制

checkByteArrayVariable(ByteArraySerializer.java:252-265)只做 3 项检查:

检查 行 异常
byteOffset < 0 253 ByteArrayParseException
count < 0 257 同上
type == BOOL && bitOffset ∉ [0,7] 261 同上

没有检查"字段类型与 type/count 是否匹配"(如 count=3 但字段是 Integer)、没有检查 byteOffset + count*len 是否越界(写方向靠 ByteWriteBuff 的容量检查兜底,读方向靠 ByteReadBuff 抛 IndexOutOfBoundsException)。也就是说:错误配置在运行时才炸,且异常信息指向底层缓冲区而非注解字段。

4.6 评价

优点:

缺点:

S7 侧的对照:S7Serializer 的 read(Class) 把注解地址解析成 RequestItem 列表,合成一次读请求(含自动分包)再回填(详见第三篇 §2.6.2)——这是两个序列化器最本质的差别:ByteArraySerializer 是纯内存转换,S7Serializer 带有"批量合并 I/O"的优化。


5. LoopGroupAlg:分包算法

common/algorithm/LoopGroupAlg.java(112 行)只有两个静态方法,作用是把"超长/多块"的请求切成若干符合协议上限的小段,是 Modbus 自动分包与 MC 随机读的公共底座。

5.1 loopExecute():单组分片

  51→    public static void loopExecute(int actualLength, int maxLength, BiConsumer<Integer, Integer> biConsumer) {
  53→        int off = 0;
  54→        while (off < actualLength) {
  55→            int len = maxLength <= 0 ? actualLength - off : Math.min(maxLength, actualLength - off);
  56→            biConsumer.accept(off, len);
  57→            off += len;

语义:把 actualLength 切成若干段,每段最长 maxLength,回调 (offset, length)。maxLength <= 0 表示不分包(第 55 行)。

用途:Modbus 的自动分包。第三篇 §1.3/§1.5 里读线圈、读保持寄存器都用到了它——quantity=1000、单次上限 125 时,会回调 8 次 (0,125) (125,125) ...,每次发一个独立报文,结果用 ByteWriteBuff 或 BitWriteBuff 累加。

调用方只有两处(源码中确认):ModbusSkeletonAbstract、McNetwork。

5.2 biLoopExecute():双组循环

这是较难理解的一个方法(LoopGroupAlg.java:70-111),用于两个数据源交叉步进——典型场景是 MC 协议的"随机读:字软元件 + 位软元件各一组"。

  70→    public static void biLoopExecute(LoopGroupItem item1, LoopGroupItem item2,
  71→                                     BiPredicate<LoopGroupItem, LoopGroupItem> biPredicate,
  72→                                     BiConsumer<LoopGroupItem, LoopGroupItem> biConsumer) {
  74→        while (item1.inRange() || item2.inRange()) {
  75→            if (item1.getOff() < item1.getActualLength() && item1.inRange()) {
  77→                if (biPredicate.test(item1, item2)) {          // 条件满足 → 提交这一段
  78→                    biConsumer.accept(item1, item2);
  79→                    item1.setOff(item1.getOff() + item1.getLen());
  80→                    item1.setLen(0);
  81→                } else {
  82→                    item1.setLen(item1.getLen() + 1);          // 不满足 → 长度试探性 +1

四个分支(第 75 / 84 / 95 / 104 行)分别处理"全在第 1 组"、"跨 1、2 两组"、"全在第 2 组"、"兜底推进"。末尾第 108-110 行再把剩余的一并提交。

LoopGroupItem 只有三个 int 字段(LoopGroupItem.java:43/49/55):actualLength(总长)、off(当前偏移)、len(当前试探长度),inRange() 判定 off + len < actualLength(第 71 行)。

它解决什么问题:MC 随机读一次报文能带 N 个字软元件 + M 个位软元件,两类上限不同(如 QnA 系列随机读字 96、随机读位 94)。调用方用 biPredicate 表达"当前这一组 (len1, len2) 是否还装得下",用 biConsumer 执行"就按当前长度发一包"。本质是贪心试探 + 条件提交。

批判:1)while 循环里 len 每次只 +1,最坏情况时间复杂度是 O(n²)(n 为点数),点数上千时会有可感知开销;2)四个 if-else 分支条件重复度高、可读性差,缺少一句注释解释"双组"的业务含义;3)LoopGroupItem 用 @Data 暴露 setter,算法内部直接改状态,不是纯函数,无法单独单测(测试文件 LoopGroupAlgTest 存在,仅覆盖 loopExecute)。


6. 异常体系

exceptions/ 共 11 个类,全部直接 extends RuntimeException:

异常 抛出场景
SocketRuntimeException Socket 建立/读写失败(SocketUtils、客户端)
SocketTimeoutException 定义了但源码中未见抛出(与 java.net.SocketTimeoutException 同名,易混淆)
ModbusCommException Modbus 事务号不一致、功能码异常响应、长度不足
S7CommException TPKT 无效、PDU 引用不一致、返回码非 SUCCESS
McCommException MC 结束码非 0、长度不足
RtspCommException RTSP 状态码非 2xx、鉴权失败
RtpCommException RTP 包解析失败
RtcpCommException RTCP 包解析失败
AuthenticationException RTSP Basic/Digest 鉴权
ByteArrayParseException 注解解析/填充失败
HexParseException 十六进制字符串解析、未实现的 EByteBuffFormat

问题:

  1. 没有共同父类(如 IoTCommException)。调用方想"捕获本库所有通信异常"必须写 7 个 catch,或者退化成 catch (RuntimeException e)——后者会误捕 NullPointerException 等。这是本库异常设计最明显的缺陷。
  2. SocketTimeoutException 与 JDK 同名类撞名,且实际从未被抛出(超时靠 SocketTimeoutException 的 JDK 版本或 SocketRuntimeException 表达)。属于死代码 + 命名陷阱。
  3. 异常层级扁平,但每个异常都只带 message,没有错误码字段(MC 的结束码、S7 的 errorClass 都只拼进字符串)。不利于上层做程序化分支处理。

7. 异步与回调

7.1 AckCallback / IAckListener:定义了但没用

net/AckCallback.java(160 行)+ net/IAckListener.java 构成一套异步响应回调机制:

// net/IAckListener.java
    void ok(T ack);
    void fail(String message);

// net/AckCallback.java:75
  75→    protected final Object lock = new Object();
  91→        this.timeoutMs = 10_000;                      // 默认超时 10 秒
 101→    public void responseOk(T ackPackage) {
 102→        this.ackData = ackPackage;
 103→        this.completed = true;
 105→        synchronized (this.lock) { this.lock.notifyAll(); }
 108→        if (this.listener != null) {
 109→            CompletableFuture.runAsync(() -> this.listener.ok(ackPackage));   // 异步回调
 135→    public void waitData() {
 137→        synchronized (this.lock) { this.lock.wait(this.timeoutMs); }

设计意图清晰:waitData() 阻塞等待响应(最长 timeoutMs),responseOk/responseFail 由 I/O 线程唤醒等待者并触发 IAckListener。

⚠️ 结论:AckCallback 与 IAckListener 在 src/main 中零引用(源码中未见任何 new AckCallback 或 implements IAckListener)。这是一套为将来异步化预留、但从未落地的机制——本库所有 PLC 读写都是同步阻塞的。

附带问题:waitData() 用 lock.wait(timeout) 无法区分"超时返回"与"被唤醒",且没有 while (!completed) 的虚假唤醒保护(标准 wait 惯用法缺失)。

7.2 真正被使用的回调:comCallback

协议层有一套报文观测钩子(不是异步回调,而是同步的旁路通知):

类 字段 类型
ModbusSkeletonAbstract.java:75 comCallback BiConsumer<String, byte[]>
McNetwork.java:69 comCallback 同
PLCNetwork comCallback 同

第一个参数是方向标记(GeneralConst.PACKAGE_REQ = "REQ" / PACKAGE_ACK = "ACK",GeneralConst.java:86,92),第二个是原始报文字节。用法在 PLCNetwork.java:278-279:

 278→        if (this.comCallback != null) {
 279→            this.comCallback.accept(GeneralConst.PACKAGE_ACK, total);

这是调试利器——抓包级别的观测能力,且对用户免费。Modbus ASCII 模式额外提供 comStringCallback(BiConsumer<String,String>),能看到文本形态报文。

7.3 真正的异步:流媒体侧

全库唯一实质使用异步并发的是 RTSP/RTCP 模块(见 §11):RtspFMp4Proxy 用 ConcurrentLinkedQueue 缓冲 + CompletableFuture 分发 + ExecutorService 线程池。PLC 侧则完全没有异步 API(无 readAsync)。


8. 线程安全性分析

8.1 锁清单

位置 锁 保护对象
PLCNetwork.java:70 ReentrantLock locker S7 一次请求-响应往返
McNetwork.java:63 ReentrantLock locker MC 一次请求-响应往返
ModbusSkeletonAbstract.java:69 ReentrantLock locker(protected) Modbus 一次请求-响应往返
RtspNetwork.java:68 ReentrantLock locker RTSP 一次请求-响应往返
ModbusTcpServer.java:67 ReadWriteLock rwLock 服务端四大存储区
S7PLCServer.java:63 ReentrantLock locker dataMap 结构变更
S7PLCServer.java:69 ReadWriteLock rwLock 数据区读写
RtspFMp4Proxy.java:53 Object objLock 帧缓冲与状态

8.2 锁粒度:锁住整个往返

以 S7 为例(PLCNetwork.java:257-273):

 257→        try {
 258→            this.locker.lock();
 259→            this.write(sendData);                       // 发请求
 261→            byte[] data = new byte[TPKT.BYTE_LENGTH];
 262→            len = this.read(data);                      // 读 TPKT 头
 270→            len = this.read(total, TPKT.BYTE_LENGTH, tpkt.getLength() - TPKT.BYTE_LENGTH);  // 读剩余
 271→        } finally {
 272→            this.locker.unlock();

这是正确的——BIO 下单 socket 是独占资源,"写+读"必须原子,否则两个线程的报文与响应会错位(S7 的 pduReference 校验就是为了发现这种错位,但 lock 已经从根上避免了)。

推论:同一个 S7PLC / McPLC / ModbusTcp 实例可以被多线程共享(安全),但读写是严格串行的——10 个线程并发读,实际是 10 次串行往返,吞吐量不会随线程数提升。

8.3 没有连接池

源码中未见任何连接池(ObjectPool / GenericObjectPool / 自实现池化均未出现)。要提升并发吞吐,只能自己创建多个客户端实例,每个实例一条 TCP 连接。

8.4 其它并发隐患

问题 位置 说明
事务号归零非原子 MbapHeader.java:108-115 getAndIncrement() 后再 set(0),check-then-act
AckCallback.completed 非 volatile AckCallback.java:52 跨线程可见性无保证(该类当前未被使用,暂无实际影响)
服务端线程模型 TcpServerBasic thread-per-connection + 固定线程池(详见第二篇 §7)
BitWriteBuff 只置位不清位 BitWriteBuff.java:70 复用缓冲区会出错(当前每次新建,暂无影响)

9. 设计模式盘点

模式 落点 评价
模板方法 TcpClientBasic 的 doAfterConnected();TcpServerBasic 的 doClientConnected / readClientData / checkHandshake;ModbusSkeletonAbstract 的 readModbusData / checkResult 全库最核心的模式,把"变与不变"切得很干净
泛型骨架 ModbusSkeletonAbstract<T extends MbRequest, R extends MbResponse> 一份代码复用 TCP/RTU/ASCII 三种模式
静态工厂 MbPdu.fromBytes() 按功能码分发;McHeaderReq.createByFrameType();ByteWriteBuff.newInstance();S7PLC/McPLC 的 newInstance() 用得克制且一致
Builder HeaderBuilder(S7 头);MultiAddressRead.addData() 链式;McReqBuilder 有,但未形成统一风格(有的叫 Builder,有的叫 addXxx)
策略/回调 comCallback / comStringCallback / fmp4DataHandle(Consumer<byte[]>) 用户扩展点,简单有效
自描述对象 IObjectByteArray(byteArrayLength() + toByteArray() + selfCheck()) 见第一篇 §4,selfCheck() 统一回填长度/校验码
懒加载 Holder EDataType.Holder 教科书写法
注解驱动映射 @ByteArrayVariable / @S7Variable 见 §4
门面 S7PLC / McPLC / ModbusTcp / RtspClient 网络层与协议层之上再包一层业务 API

缺失的模式:没有装饰器(如"带重试的客户端")、没有责任链(报文拦截)、没有观察者(数据点订阅)——这些是工业采集场景的常见需求,本库都没有提供,只能用户自己在外层实现。


10. src/test 测试与 Demo 结构

131 个测试文件,与 main 的 332 个文件相比测试密度约 0.39,对协议库来说偏高(因为大量是报文级单测)。

类别 数量 说明
含 @Test 的 JUnit 类 99 真断言测试
Demo* 开头的类 22 main() 方法,不是 JUnit,需连真机
model 包下单测 53 报文序列化/反序列化
被 @Ignore 标记 ≥5(网络与并发类) 需真实设备

分布:

src/test/java/com/github/xingshuangs/iot/
├── common/      buff 3 个 + algorithm 1 + serializer 6(1 个测试 + 5 个 Bean 样例)
├── net/         client 2 / server 2(全部 @Ignore)
├── protocol/
│   ├── modbus/  model 17(16 = 8 类功能码 × Request/Response,另 1 个 MbRtuCommTest) + service 10(含 5 个 Demo)
│   ├── s7/      model 16 + serializer 7 + service 13(含 7 个 Demo)+ algorithm 1 + utils 1 + enums 1
│   ├── melsec/  model 7 + service 6(含 4 个 Demo)
│   ├── rtsp/    15(含 5 个 Demo)   rtp 5   rtcp 2   mp4 4
└── utils/       11 个工具类单测

值得肯定的部分:modbus 的 16 个 model 单测覆盖了每个功能码的 toByteArray() 与 fromBytes(),这是协议库最该有的测试——报文级往返测试能挡住 90% 的低级错误。

Demo 的形态(protocol/s7/service/DemoS7Read.java:39-45):

  39→    public static void main(String[] args) {
  40→        S7PLC s7PLC = new S7PLC(EPlcType.S1200, "127.0.0.1");
  42→        boolean boolData = s7PLC.readBoolean("DB1.2.0");
  43→        List<Boolean> boolDatas = s7PLC.readBoolean("DB1.2.0", "DB1.2.1", "DB1.2.7");
  45→        List<Boolean> iDatas = s7PLC.readBoolean("I0.0", "I0.1", ...);

即:Demo 是 main 方法而非 @Test,mvn test 不会执行它们,也不会因为没有真机而失败——这是个务实的选择(唯一代价是 Demo 不会被 CI 验证,API 变更时可能过时)。

问题:

  1. 需要真实 PLC 的测试类只加了 @Ignore(如 McPLCFrame1ETest.java:46、S7SerializerConcurrentTest),没有用 @Tag("integration") 或 profile 隔离,跑全量测试时无法区分"跳过是因为没硬件"还是"这个测试本来就不重要";
  2. AppTest.java 是 Maven archetype 残留的空测试;
  3. 网络层四个基础测试全部 @Ignore,意味着 TcpClientBasic / SocketUtils 的粘包逻辑没有自动化回归测试——这是全库最关键、最易出错的部分。

11. RTSP / RTP / RTCP / MP4 模块

这四个包与 PLC 毫无关系,是作者给"工业视觉/摄像头接入"做的补充能力,合计 144 个文件(占 main 的 43%)——规模上其实比任何一个 PLC 协议都大。

包 文件数 职责
rtsp 62 RTSP 信令:OPTIONS/DESCRIBE/SETUP/PLAY/PAUSE/TEARDOWN、SDP 解析、Basic/Digest 鉴权、interleaved 模式
rtp 26 RTP 包解析、H.264 NALU(Single/STAP-A/STAP-B/FU-A/FU-B/MTAP16/MTAP24)、SPS 指数哥伦布解码
rtcp 16 RTCP SR/RR/SDES/BYE/APP 包构造与解析、收发统计(RtcpDataStatistics)
mp4 40 fMP4 封装:ftyp/moov/moof/mdat/traf/trun 等 30+ box 的读写

核心用例是 RtspFMp4Proxy(protocol/rtsp/service/RtspFMp4Proxy.java):把 RTSP 拉来的 H.264 流转封装成 fMP4 分片,供前端 MSE 播放。

RtspClient(TCP/UDP 收流)
    │  RTP 包
    ▼
H264VideoParser → H264VideoFrame(FU-A 重组 NALU)
    │
    ▼
RtspFMp4Proxy.buffers(ConcurrentLinkedQueue<IObjectByteArray>,第 76 行)
    │  组装 Mp4Header + Moof + Mdat
    ▼
fmp4DataHandle(Consumer<byte[]>,第 82 行)→ 业务侧(WebSocket 推流 / 落盘)

关键字段(RtspFMp4Proxy.java:53-112):objLock(第 53 行)保护状态、sequenceNumber(第 70 行)fMP4 分片序号、codecHandle(第 88 行,Consumer<String>)输出 codec 字符串、destroyHandle(第 94 行)销毁回调。

评价:这部分与"工业数据采集"的主线(PLC 寄存器读写)是两个完全不同的领域放在一个 jar 里。好处是做视觉+PLC 联动项目时一个依赖搞定(比如"相机拍照 → 结果写入 PLC DB 块");坏处是 jar 体积、维护面与关注点都被稀释。是否值得,取决于使用者的场景,但从库定位看,这部分更像是作者自身项目的沉淀。


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

✅ 设计优点

  1. 缓冲层的"三维度"设计:littleEndian(字节序)+ format(字序)+ offset(游标)三个正交维度,把工业现场最头疼的字节序问题用一套 API 收敛掉了,比同类库只给一个 isLittleEndian 强。
  2. EByteBuffFormat 把字序具象化为 4 种命名模型(AB_CD / BA_DC / CD_AB / DC_BA),可直接对照设备手册配置,可读性极高。
  3. 注解序列化零依赖、双向对称:@ByteArrayVariable 的 byteOffset + bitOffset + count + type + littleEndian + format 六元组覆盖了绝大多数报文布局,count=1 → 裸值 / count>1 → List 的约定也很自然。
  4. selfCheck() 统一回填:长度、CRC、LRC 的回填时机一致(详见第三篇),避免"改 body 忘改 header"。
  5. 锁粒度正确:ReentrantLock 锁住完整的"写+读"往返,从根上杜绝 BIO 单连接下的响应错位。
  6. comCallback 报文观测钩子:抓包级调试能力,对现场排错极有价值。
  7. 测试以报文级单测为主:modbus 16 个 model 单测覆盖全部功能码的序列化往返,是协议库最该写的一类测试。

❌ 设计缺点

  1. 异常体系无共同父类:11 个异常全部直接继承 RuntimeException,调用方无法统一捕获;且都只带 message,无错误码字段。
  2. AckCallback / IAckListener / SocketTimeoutException 是死代码:定义了完整的异步回调机制却零引用,会误导使用者以为库支持异步。
  3. 反射无缓存:ByteArraySerializer 每次调用都重扫 getDeclaredFields() + setAccessible(),不适合高频解析。
  4. EByteBuffFormat 用 switch (code) 而非 switch (this):字符串分支,重命名枚举常量不会编译报错。
  5. 不支持继承字段与嵌套对象:getDeclaredFields() 漏掉父类字段,Bean 继承会静默丢数据。
  6. targetClass.newInstance() 已废弃(Java 9+),且强依赖 public 无参构造。
  7. 无连接池:多实例 = 多连接,需使用者自行管理。
  8. 网络层关键逻辑无自动化回归测试:TcpClientBasicTest / UdpClientBasicTest / TcpServerBasicTest 全部 @Ignore。
  9. biLoopExecute 复杂度与可读性:O(n²) 试探、四分支 if-else、非纯函数(内部改 LoopGroupItem 状态)。
  10. 模块边界模糊:RTSP/RTP/RTCP/MP4 共 144 个文件(占 main 43%)与 PLC 采集主线无关,却打包在同一个 jar。

💡 可借鉴点

做法 借鉴到
littleEndian + format 双维度字节序建模 任何需要对接多品牌 PLC/设备的采集程序
EByteBuffFormat 四种字序命名(AB_CD 等) 让配置能直接对照设备手册
"定长缓冲 + 显式偏移回填(不改游标)" header 里有 body 长度/校验码的报文构造
selfCheck() 统一回填长度与校验码 同上
锁住完整"写+读"往返(而非只锁写) BIO 单连接下的请求-响应串行化
comCallback 旁路报文观测钩子 协议库的调试/抓包能力,成本极低
Holder 静态内部类懒加载枚举反查表 枚举的 from(String) 反查
报文级序列化往返单测(每功能码一对) 协议类库的测试编写范式
Demo 用 main() 而非 @Test 需要真机才能跑的示例代码,避免污染 CI
LoopGroupAlg.loopExecute(actual, max, consumer) 任何"按协议上限自动分片"的场景

本系列完。四篇文档分别覆盖:iot-communication 项目总览与架构、网络层与粘包处理、协议实现详解(上):Modbus 与 S7、协议实现详解(下):三菱 MC 与横向对比、基础设施与工程特性。


本文是《工业通信与数据采集》栏目第 18/22 篇 · 上一篇:协议实现详解(下):三菱 MC 与横向对比 | 栏目总览 | 下一篇:HslCommunication 与 iot-communication 横向对比与选型