04 · 基础设施与工程特性
本文分析支撑三大协议实现的公共底座:
common包(字节缓冲 / 数据类型 / 注解序列化 / 分包算法)、异常体系、异步回调、线程安全模型、设计模式使用、src/test测试与 demo 结构,以及 RTSP / RTP / RTCP / MP4 等"非 PLC"模块的用途。所有代码片段均标注文件与行号。
本文目录
- 1. 基础设施全景
- 2. 字节缓冲层:ByteWriteBuff / ByteReadBuff / BitWriteBuff
- 3.
EDataType枚举 - 4. 注解序列化机制(重点)
- 5.
LoopGroupAlg:分包算法 - 6. 异常体系
- 7. 异步与回调
- 8. 线程安全性分析
- 9. 设计模式盘点
- 10.
src/test测试与 Demo 结构 - 11. RTSP / RTP / RTCP / MP4 模块
- 设计优点 / 缺点 / 可借鉴点
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);
四个可注意的点:
targetClass.newInstance()(第 67 行)——Java 9 起已废弃,且要求 public 无参构造,否则抛异常被包成ByteArrayParseException;getDeclaredFields()(第 68 行)——不含父类字段,继承的 Bean 会静默丢字段;- 每个字段都
new ByteReadBuff(...)(见下节),大对象有额外分配开销; - 异常被
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 评价
优点:
- 声明式、零依赖、双向对称,比手写
byte[3] << 8 | byte[2]可读性高一个量级; littleEndian+format双维度字节序,是同类库少见的处理(大多数库只给一个isLittleEndian);- 偏移量寻址模型支持字段重叠与空洞,能直接映射"非连续布局"的报文(如自定义 TCP 帧)。
缺点:
- 无反射缓存:
getDeclaredFields()+getAnnotation()每次调用都重扫,且setAccessible(true)每个字段都调用一次(ByteArraySerializer.java:148、178)。高频解析(如 RTSP 每帧)会有明显开销——对比 Jackson / Fastjson 都做了Field缓存。 - 不支持继承与嵌套:只有
getDeclaredFields(),没有父类字段,也没有"对象里套对象"。 targetClass.newInstance()已废弃(Java 9+),且要求 public 无参构造。- 库内部零使用:
ByteArraySerializer在src/main中没有任何调用方(源码中未见),只出现在src/test的ByteArraySerializerTest。也就是说它是"给用户用的工具",自身未经过生产路径验证。 - 异常包装过重:
catch (Exception e)后统一包成ByteArrayParseException,原始异常类型信息丢失(只保留 message 与 cause)。
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 |
问题:
- 没有共同父类(如
IoTCommException)。调用方想"捕获本库所有通信异常"必须写 7 个catch,或者退化成catch (RuntimeException e)——后者会误捕NullPointerException等。这是本库异常设计最明显的缺陷。 SocketTimeoutException与 JDK 同名类撞名,且实际从未被抛出(超时靠SocketTimeoutException的 JDK 版本或SocketRuntimeException表达)。属于死代码 + 命名陷阱。- 异常层级扁平,但每个异常都只带 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 变更时可能过时)。
问题:
- 需要真实 PLC 的测试类只加了
@Ignore(如McPLCFrame1ETest.java:46、S7SerializerConcurrentTest),没有用@Tag("integration")或 profile 隔离,跑全量测试时无法区分"跳过是因为没硬件"还是"这个测试本来就不重要"; AppTest.java是 Maven archetype 残留的空测试;- 网络层四个基础测试全部
@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 体积、维护面与关注点都被稀释。是否值得,取决于使用者的场景,但从库定位看,这部分更像是作者自身项目的沉淀。
设计优点 / 缺点 / 可借鉴点
✅ 设计优点
- 缓冲层的"三维度"设计:
littleEndian(字节序)+format(字序)+offset(游标)三个正交维度,把工业现场最头疼的字节序问题用一套 API 收敛掉了,比同类库只给一个isLittleEndian强。 EByteBuffFormat把字序具象化为 4 种命名模型(AB_CD / BA_DC / CD_AB / DC_BA),可直接对照设备手册配置,可读性极高。- 注解序列化零依赖、双向对称:
@ByteArrayVariable的byteOffset + bitOffset + count + type + littleEndian + format六元组覆盖了绝大多数报文布局,count=1 → 裸值 / count>1 → List的约定也很自然。 selfCheck()统一回填:长度、CRC、LRC 的回填时机一致(详见第三篇),避免"改 body 忘改 header"。- 锁粒度正确:
ReentrantLock锁住完整的"写+读"往返,从根上杜绝 BIO 单连接下的响应错位。 comCallback报文观测钩子:抓包级调试能力,对现场排错极有价值。- 测试以报文级单测为主:modbus 16 个 model 单测覆盖全部功能码的序列化往返,是协议库最该写的一类测试。
❌ 设计缺点
- 异常体系无共同父类:11 个异常全部直接继承
RuntimeException,调用方无法统一捕获;且都只带 message,无错误码字段。 AckCallback/IAckListener/SocketTimeoutException是死代码:定义了完整的异步回调机制却零引用,会误导使用者以为库支持异步。- 反射无缓存:
ByteArraySerializer每次调用都重扫getDeclaredFields()+setAccessible(),不适合高频解析。 EByteBuffFormat用switch (code)而非switch (this):字符串分支,重命名枚举常量不会编译报错。- 不支持继承字段与嵌套对象:
getDeclaredFields()漏掉父类字段,Bean 继承会静默丢数据。 targetClass.newInstance()已废弃(Java 9+),且强依赖 public 无参构造。- 无连接池:多实例 = 多连接,需使用者自行管理。
- 网络层关键逻辑无自动化回归测试:
TcpClientBasicTest/UdpClientBasicTest/TcpServerBasicTest全部@Ignore。 biLoopExecute复杂度与可读性:O(n²) 试探、四分支 if-else、非纯函数(内部改LoopGroupItem状态)。- 模块边界模糊: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 横向对比与选型