简介一份基于Java的698报文数据项解析示例代码面向协议开发、报文调试以及有一定Java基础的工程师帮助理解698报文中数据项的组织方式与逐项解析实现思路适合作为入门参考或二次开发底稿。压缩包共94个文件以81个Java源文件为主配合7个XML环境配置、3张结构示意图、1份MD说明文档及少量工程辅助文件整体仅245KB体量轻巧目录结构包含源码、资源配置和文档说明便于快速定位。目前已有376人学习浏览属实战型示例而非完整商业项目。读者可从中获得解析类主体实现、数据项提取流程、工程配置与运行说明、README使用指引以及IDEA相关设置配合示意图能更直观理解报文结构并可在现有框架上继续扩展其他数据项解析逻辑。1. 为什么需要Java解析698报文数据项在电力用采系统的联调现场最常见的“翻车点”不是业务逻辑而是上报报文里那串十六进制不能被正确还原。698协议Q/GDW 1376.2 或 DL/T 698.45把电能表数据、费控命令、事件记录都压缩在一串字节里数据项标识、TLV 长度、数据类型任何一个字节看错后面全部偏移。我自己维护过一套用 Java 做采集主站前置机的系统每天要处理几十万帧这样的报文最初的思路是找现成解析库但实际项目中既有旧版 1376.2 终端又有新版 698.45 对象模型还会出现厂商自定义扩展最终只能基于 Java 的 ByteBuffer 和位运算手写数据项解析层。这篇文章不是贴官方文档而是给出一套能跑通、能加长尾参数的方法先讲清楚数据项在报文里的位置和编码规则再给可运行的解析骨架与递归示例最后补几个排错技巧。适合正在做电力采集项目、需要快速解析 698 报文数据项的 Java 开发者。2. 698报文数据项在帧结构里的编码位置与字节语义2.1 帧结构先找到数据项所在的APDU区域808 报文整体帧格式如下我按 Q/GDW 1376.2 常用帧结构展示域长度字节说明起始字符1固定 0x68长度域2从控制域到校验和的字节数控制域1传输方向、帧类型地址域5终端地址A1~A5应用层标志1AFN 标识数据单元可变序列域、数据项集合校验和1从长度域到数据单元的算术和结束字符1固定 0x16这里长度域是两字节不同协议版本对长度的计数起始位置有细微差别但整体逻辑一样数据项不会出现在帧头和地址域而是藏在“数据单元”里的应用层协议数据单元APDU中。在 1376.2 里应用层标志 AFN 后面跟的是序列域和数据项集合数据项集合由“数据项标识 DI0~DI3 数据长度 L 数据内容 DATA”组成。在 DL/T 698.45 里则采用对象寻址数据项位于 APDU 的数据值字段内通过 OAD对象地址描述符和属性 ID 定位。两种格式都逃不开一个核心动作——在字节流里准确切出每个数据项的标识和数据边界。2.2 数据项标识DI0到DI3的位语义Q/GDW 1376.2 的数据项标识由 4 个字节 DI0~DI3 组成每个字节都有固定的位定义我整理了最常用的映射关系字节含义典型例子DI0数据类型及是否测量点0x01 表示正向有功电能数据格式为 BCDDI1数据属性及分类0x04 表示电能量数据DI2测量点/费率号0x01 表示测量点 1DI3分相/总加/类别0x00 表示合计/总加实际解析时我会把 DI0~DI3 组合成一个 int 键再丢进预定义字典去查含义。注意DI0 的低二位决定数据类型是 BIN、BCD 还是 ASCII所以很多人只拿它比对工具里的“方案 ID”其实不对。方案 ID 是要结合 DI0~DI3 整体映射的单独一个 DI 字节定位不到完整数据项。另外DI 的连续标识位也不可忽略当 DI0 高位置 1 时表示后面还有连续的数据项标识这时不能直接把 4 字节当成一个独立键要先判断这个标志位。2.3 手工拆一个真实报文字节串假设从数据单元区截取出一个数据项区共 10 个字节01 04 00 01 04 00 00 00 00从第 1 个字节开始解析DI0 0x01BCD 编码正向有功电能DI1 0x04电能量分类DI2 0x00测量点 0DI3 0x01总额L 0x04数据长度 4 字节DATA 00 00 00 00当前组合有功总电能读数按 0.01 kWh 换算为 0.00这个例子里每个数据项都是“4 字节标识 1 字节长度 数据体”。但实际协议并不保证长度域总是 1 字节在 698.45 里长度域占 1~2 字节当数据体长度超过 127 时长度域会扩展。所以在开始写 Java 代码之前我建议先用一个十六进制编辑器把报文里每一段都标出来确认长度域的处理方式再动手写游标类。3. 用Java搭建解析引擎字节流处理与TLV解析3.1 最小字节缓冲类统一游标与无符号读取Java 自带的 ByteBuffer 在处理无符号数时需要手动掩码而且 BufferUnderflowException 不够携带上下文。我通常会封装一个ByteCursor把所有读取动作收敛到同一个位置指针上这样排错时只需要打印 position 就能知道解析到哪一步。import java.util.Arrays; public class ByteCursor { private final byte[] data; private int pos; public ByteCursor(byte[] data) { this.data data; this.pos 0; } public int readUInt8() { return data[pos] 0xFF; } public int readUInt16() { int value ((data[pos] 0xFF) 8) | (data[pos 1] 0xFF); pos 2; return value; } public long readUInt32() { long value 0; for (int i 0; i 4; i) { value (value 8) | (data[pos i] 0xFF); } pos 4; return value; } public byte[] readBytes(int len) { byte[] out Arrays.copyOfRange(data, pos, pos len); pos len; return out; } public int remaining() { return data.length - pos; } public int position() { return pos; } }这里readUInt8用 0xFF把byte变成 0~255 的int避免 Java 的byte有符号问题。readUInt16按大端拼接符合 698 协议的网络字节序。readBytes复制时用Arrays.copyOfRange防止像System.arraycopy那样需要手动管理目标数组长度。设计游标最大的好处是越界时能清晰地记录 position错误日志里直接看到“readUInt16 at position 23”这类信息比 ByteBuffer 默认的异常信息有用得多。3.2 数据类型映射先定义TLV的Tag与长度语义DL/T 698.45 的数据值采用 TLVTag-Length-Value编码常见 Tag 映射如下Tag 值数据类型长度语义0x00octet-string字节串长度是字节数0x01array长度是数组元素个数0x02structure长度是内部字节数不含自身0x03long-unsigned定长 4 字节0x04long定长 8 字节这个表不是所有场景都完全一致厂商实现可能使用私有 Tag所以解析器不能写死 switch而应该先把 Tag 和长度语义抽象成一个枚举再根据现场协议版本加载映射关系。public enum TlvType { OCTET_STRING(0x00), ARRAY(0x01), STRUCTURE(0x02), LONG_UNSIGNED(0x03), LONG(0x04); private final int tag; TlvType(int tag) { this.tag tag; } public static TlvType fromTag(int tag) { for (TlvType type : values()) { if (type.tag tag) { return type; } } return OCTET_STRING; } }请注意LONG的长度并不总等于 Tag 自身的含义因为 698.45 里的“ long-unsigned”是 4 字节的 uint32而“long”是 8 字节的 int64。解析时如果只看 Tag 不看字段定义会把数据值长度算错。所以枚举里要带上 fixedLength 字段初始化为 -1 表示不定长再按 Tag 约定赋值。3.3 固定长度数据项的最小解析方法接下来直接写一个解析单数据项的方法定位 DI 和长度public MapString, Object parseDataItem(ByteCursor cursor) { int di0 cursor.readUInt8(); int di1 cursor.readUInt8(); int di2 cursor.readUInt8(); int di3 cursor.readUInt8(); int length cursor.readUInt8(); if (length cursor.remaining()) { throw new IllegalArgumentException(data item length length exceeds remaining bytes cursor.remaining()); } byte[] value cursor.readBytes(length); MapString, Object item new HashMap(); item.put(di, String.format(%02X %02X %02X %02X, di0, di1, di2, di3)); item.put(length, length); item.put(value, formatValueByDi(di0, value)); return item; }这段代码的核心逻辑是“先切 DI再切长度最后切数据”。formatValueByDi需要根据 DI0 的类型位决定 BCD 还是 ASCII 字符串这里先返回十六进制字符串private static String formatValueByDi(int di0, byte[] value) { boolean bcd (di0 0x03) 0x01; if (bcd) { StringBuilder sb new StringBuilder(); for (byte b : value) { sb.append(String.format(%02X, b)); } return sb.toString(); } return Arrays.toString(value); }这一小段很适合作最小示例因为它覆盖了位运算、字节读取、越界保护三个关键动作。后面在真实项目里还会遇到“连续数据标识”和“数据项总数”那就需要在这个基础上加循环和标志位判断。4. 数据项解析的完整实现对象标识、嵌套结构与异常4.1 对象标识与名称映射如果要支持 DL/T 698.45就不能只解析 DI 四字节还需要把 OAD对象地址中的 class_id 和 attribute_id 映射成语文名称。我一般建一个静态字典public class ObisMapper { private static final MapString, String MAP new HashMap(); static { MAP.put(1.1.1.0.255, 当前组合有功总电能); MAP.put(1.1.1.1.255, 当前A相有功总电能); MAP.put(1.1.1.2.255, 当前B相有功总电能); MAP.put(1.1.1.3.255, 当前C相有功总电能); } public static String lookup(int classId, int attributeId, int[] index) { String key classId . attributeId . index[0] . index[1] . index[2]; return MAP.getOrDefault(key, 未知对象); } }这里 index 是对 OAD 中“对象实例”部分三个字节的抽象。实际 698.45 的 OAD 定义是 class_id2 个字节、attribute_id1 个字节、index3 个字节解析时要读够 6 字节才能定位到对象。很多新手拿到 698.45 报文发现没有 DI0~DI3 就不会解了其实只是把标识从固定四字节换成了 OAD 六字节后面跟的依然是 TLV。4.2 可变长域与嵌套结构的递归解析数据项里最常见的是“数组”和“结构体”两种嵌套类型。比如多个测量点的分组数据就是一个 array每个元素又是一个 structure。解析这类数据必须递归否则长度没法算。public Object parseTlv(ByteCursor cursor) { int tag cursor.readUInt8(); int len cursor.readUInt8(); TlvType type TlvType.fromTag(tag); switch (type) { case ARRAY: ListObject array new ArrayList(); for (int i 0; i len; i) { array.add(parseTlv(cursor)); } return array; case STRUCTURE: int endPos cursor.position() len; ListObject struct new ArrayList(); while (cursor.position() endPos) { struct.add(parseTlv(cursor)); } if (cursor.position() ! endPos) { throw new IllegalStateException(structure length mismatch); } return struct; case LONG_UNSIGNED: return cursor.readUInt32(); case LONG: // 实际要分高低位两个 int这里简化为 long byte[] longBytes cursor.readBytes(8); return bytesToLong(longBytes); default: return cursor.readBytes(len); } }注意 array 的 len 是元素个数不是字节长度structure 的 len 是内部字节总长度。这一点最容易踩坑。在 array 循环里如果某个元素内部长度极长可能导致递归深度增加所以生产代码里应加最大深度限制例如private Object parseTlv(ByteCursor cursor, int depth) { if (depth 8) { throw new IllegalArgumentException(tlv nested depth too large); } // 后续递归 depth 1 }4.3 校验和与长度越界的防御性编程解析落库前一定要做帧级校验。我提供的方法会在数据完全解析前先验证起始字符和校验和避免脏数据进入业务层public static byte[] verifyFrame(byte[] frame) { if (frame null || frame.length 10 || frame[0] ! 0x68) { throw new IllegalArgumentException(frame invalid: first byte or length); } int len ((frame[1] 0xFF) 8) | (frame[2] 0xFF); if (len frame.length - 4) { throw new IllegalArgumentException(length field exceeds actual frame); } int sum 0; for (int i 1; i len; i) { sum (sum (frame[i] 0xFF)) 0xFF; } if (sum ! frame[frame.length - 2]) { throw new IllegalStateException(checksum mismatch); } return frame; }这段代码的边界条件是长度域从控制域开始计所以校验和计算也要从下标 1 开始到 1 len 结束。如果上电后终端上报的数据总是校验失败先检查长度域是不是取了低位在前不同终端厂商有按高字节在前的也有按低字节在前的统一在这里集中处理即可。校验通过后再创建ByteCursor去解析这样业务代码里不需要重复判断。5. 解析器写完后值得做的三个验证与排错技巧5.1 反推测试向量先手工构造一个数据项区把期望值和字节流写死字节流: 01 04 00 01 04 00 00 00 00 期望: DI01 04 00 01, length4, value0.00用 JUnit 把ByteCursor喂给解析方法断言输出值等于预期。测试向量不要只给正常数据一定要包含 length 越界、数组嵌套、校验和错误三条异常路径。5.2 在日志里保留游标 position当解析失败时异常信息必须带上cursor.position()。我试过排查一个连续标识位问题日志里只显示“数组长度不符”花了两小时后来改成throw new IllegalArgumentException(parse error at position cursor.position())十分钟就锁定了字节偏移。这个习惯比任何解析库都重要。5.3 与成熟解析工具做交叉比对现场可以先用电力698协议及测试工具把同一份报文的树形解析结果导出然后把 Java 解析器的逐字段输出和它对照。两边不一致的地方高亮出来优先怀疑协议类型映射表其次怀疑长度域字节序。如果两边都解析失败就要回到“手工拆字节”的方法把 TLV 的每个 tag 和 length 打印出来逐一比对。把 position 和 tag 打出来再和工具的十六进制视图对照通常十分钟内能定位到问题。本文还有配套的精品资源点击获取