简介:面向环保数据通信开发者的HJ212协议Java解析器示例,聚焦环境监测数据采集传输协议(HJ212)的报文解析与数据处理,适合需要对接环保数采仪、搭建数据接入平台或学习自定义协议解析的程序员。压缩包共112个文件,其中以100个Java源码为主,辅以8个XML配置、项目说明与许可证文件,整体仅92KB,代码结构紧凑且模块划分清晰。解析器覆盖T212报文帧拆解、数据段映射、监测因子CpData转换、污染指标解析等核心环节,涉及SegmentParser、T212Mapper、T212Factory等关键类,并提供一个可直接运行的解析Demo,便于对照报文观察每一步解析结果。目前已有1309人学习,通过研读源码可快速掌握HJ212帧格式、CRC校验及Java实现思路,既可用于教学参考,也能直接复用或扩展至实际环保数据采集项目。
1. HJ212 解析器:环保数采接入的第一个技术门槛
HJ212 解析器是所有环保数采平台绕不开的起点。数采仪按 HJ/T 212 标准把污染物浓度、流量、设备状态打包成 ASCII 文本帧,通过 TCP 或 4G DTU 推送到服务端;帧本身就是一串由##、4 位十进制长度、分号分隔字段和&&嵌套数据区组成的文本。第一次接触的人很容易按;直接 split,结果在 CRC 校验和 CP 内层字段上反复踩坑。用 Java 写一个可复用的 HJ212 解析器,核心不是正则,而是先把帧边界、CRC 范围和嵌套结构讲清楚,再写拆包与解析代码。我会直接给出一套能用于生产项目的解析器骨架,以及一个能自建帧、验证粘包的 demo,适合正在做数据接入的后端工程师和维护者参考。
2. HJ212 报文帧结构:先看懂 ## 与 && 的边界
HJ212 协议本身是纯文本协议,每一帧有明确的物理布局:固定包头开头,4 位长度给出数据段长度,之后是数据段、CRC 和 CRLF 结束符。理解这个布局是写解析器的前提,因为粘包、半包和 CRC 校验全部依赖这个边界。
2.1 帧结构的四个组成部分
实际线上最常见的完整帧长这样(长度和 CRC 用占位符表示):
##xxxxQN=20240101120000000;ST=22;CN=2011;PW=123456;MN=010000A9300001;CP=&&a34001-Rtd=12.5,a34002-Rtd=0.34&&xxxx\r\n拆开看就是四个部分:
| 组成 | 占用字符数 | 说明 |
|---|---|---|
| 包头 | 2 | 固定为##,用于寻找帧起始 |
| 数据段长度 | 4 | ASCII 编码的十进制数字,例如0147 |
| 数据段 | N | 从QN=或ST=开始,到最后一个&&结束 |
| CRC 校验 | 4 | 对数据段整个字符串计算 CRC-16 后转大写十六进制 |
| 结束符 | 2 | \r\n,部分设备可能只发\n,解析时要兼容 |
包头##很容易找,但不要看到##就认为是一个新帧,因为字符串里a34001-Rtd等字段中也可能出现#。正确做法是先锁定##,再读后面的 4 位长度,然后用长度去截帧。如果长度不够,说明帧还没到齐,继续等下一次 TCP 读取。
2.2 数据段字段与 CP 嵌套规则
数据段由若干固定字段组成,字段之间用分号;分隔。常见字段包括:
QN:请求时间戳,常见为 17 位,格式类似yyyyMMddHHmmssSSS,用于标识一条唯一的命令或数据包。ST:系统编码,例如21代表水环境监测,22代表大气环境监测。CN:命令编码,2011一般表示实时数据上报,2061是应答包,9011是设置类的请求或应答。PW:设备访问密码,普通场景下是 6 位数字。MN:设备唯一标识,例如010000A9300001。CP:数据区,是整个协议最需要小心的部分。
CP的值固定以&&开始、以&&结束。内部用分号分隔一组组数据,每组用等号连接字段名和值。比如CP=&&a34001-Rtd=12.5,a34002-Rtd=0.34&&,其中a34001-Rtd是污染物浓度字段,12.5是数值。
难点在于:数据段的多个字段是分号分隔的,而CP内部也有分号。如果直接对整个数据段split(";"),外部字段和内部字段会被一起拆开,CP本身也很难还原。因此解析器必须先找到CP=,把CP值整体抠出来,再对剩余部分做外部字段拆分。
2.3 CRC 计算边界:不算包头,算整个数据段
协议文档对 CRC 的定义是:对数据段所有字符按 ASCII 码做 CRC-16/CCITT 校验,多项式为0x1021,初值0xFFFF,计算结果用 4 位大写十六进制表示。也就是说,计算范围从QN=(或ST=)开始,一直到最后一个&&结束,不包含前面的长度字段,也不包含后面的 CRC 和\r\n。
写解析器前可以先在命令行验证一次长度逻辑:
data='QN=20240101120000000;ST=22;CN=2011;PW=123456;MN=010000A9300001;CP=&&a34001-Rtd=12.5&&' echo -n "$data" | wc -c printf '%04d\n' "${#data}"echo -n不输出换行,wc -c统计字节数;因为这一段全部是 ASCII 字符,字节数等于字符数。printf '%04d'把字符数补成 4 位十进制,正好对应包头里的长度字段。这里要特别提醒:实际设备数据如果包含中文,长度必须按协议字符集计算,通常协议要求 ASCII,遇到非 ASCII 字符先转义或按设备手册处理,否则长度对不上,CRC 也会一起失败。
3. 用 Java 实现 HJ212 解析器核心类:CRC 与字段拆解
这一章给出一个不依赖 Spring、可以直接拷进工具类的 Java 实现。类名用Hj212Parser,包含三个能力:算 CRC、校验并解析一帧、从 CP 中提取内部字段。代码在hj212-master这类骨架工程里通常会拆成Crc16和Hj212Parser两个类,这里为方便演示合并成一个。
3.1 CRC-16/CCITT 的 Java 实现与参数说明
CRC 计算用位循环方式实现,不引入查表法,代码短且容易读懂:
import java.nio.charset.StandardCharsets; public class Hj212Parser { private static final int CRC_POLY = 0x1021; private static final int CRC_INIT = 0xFFFF; public static String crc16(String data) { int crc = CRC_INIT; byte[] bytes = data.getBytes(StandardCharsets.ISO_8859_1); for (byte b : bytes) { crc ^= (b & 0xFF) << 8; for (int i = 0; i < 8; i++) { if ((crc & 0x8000) != 0) { crc = (crc << 1) ^ CRC_POLY; } else { crc <<= 1; } crc &= 0xFFFF; } } return String.format("%04X", crc); } }这段代码里有两个关键参数:CRC_POLY是多项式0x1021,对应协议里的 CCITT 多项式;CRC_INIT是初值0xFFFF。每一字节先异或到 crc 高 8 位,再做 8 次移位;位 15 为 1 时与多项式异或,最终保留低 16 位。ISO_8859_1保证每个字符按一个字节处理,不会引入 UTF-8 的多字节干扰。如果设备帧里出现了协议外的字符,这里得到的 byte 流仍能按单字节计算,不会抛异常,但 CRC 很可能因字符集不一致而对不上。
3.2 解析主流程:先用长度截帧,再拆字段
解析一帧的标准步骤是:校验##开头,读第 3 到 6 个字符得到数据段长度,截取数据段,取出后面 4 位 CRC,用crc16校验,校验通过后进入字段解析。
import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; public class Hj212Parser { public static class Hj212Frame { public final Map<String, String> header = new LinkedHashMap<>(); public final Map<String, String> cp = new LinkedHashMap<>(); } public static Hj212Frame parseFrame(String frame) { if (frame == null || !frame.startsWith("##")) { throw new IllegalArgumentException("帧必须以 ## 开头"); } int dataLen = Integer.parseInt(frame.substring(2, 6)); int frameLen = 6 + dataLen + 4 + 2; if (frame.length() < frameLen) { throw new IllegalArgumentException("帧长度不足,期望 " + frameLen); } String dataSection = frame.substring(6, 6 + dataLen); String crcCode = frame.substring(6 + dataLen, 6 + dataLen + 4); if (!crcCode.equalsIgnoreCase(crc16(dataSection))) { throw new IllegalArgumentException("CRC 校验失败: " + crcCode); } Hj212Frame result = new Hj212Frame(); int cpIdx = dataSection.indexOf("CP="); if (cpIdx < 0) { throw new IllegalArgumentException("缺少 CP 字段"); } String outerFields = dataSection.substring(0, cpIdx); String cpRaw = dataSection.substring(cpIdx + 3, dataSection.length()); String cpBody = cpRaw.substring(1, cpRaw.lastIndexOf("&&")); for (String field : outerFields.split(";")) { putField(result.header, field); } for (String field : cpBody.split(";")) { putField(result.cp, field); } return result; } private static void putField(Map<String, String> map, String field) { int eq = field.indexOf('='); if (eq > 0) { map.put(field.substring(0, eq).trim(), field.substring(eq + 1).trim()); } } }流程中的几个关键参数分别是:frameLen中6是包头和长度字段的占用长度,4是 CRC 位数,2是 CRLF。这里先按长度截出数据段,再做 CRC 校验,而不是先按\r\n找结束符,这样对设备只发\n或丢失\r的情况更宽容。
字段拆分之前先找CP=,把外层的QN、ST、CN、PW、MN等字段和 CP 内部字段分开。outerFields.split(";")安全的前提是这些外部字段的值不会包含分号;如果未来出现字段值带分号的扩展命令,需要改用可配置的分隔符扫描逻辑。cpRaw.substring(1, cpRaw.lastIndexOf("&&"))用于剥离 CP 最外层的一对&&,内部字段再用分号拆分。
3.3 返回结果与 CP 内部字段的存放方式
解析结果放在Hj212Frame里,header保存外部公共字段,cp保存 CP 内部字段。这样写的好处是:后续业务代码可以直接取frame.cp.get("a34001-Rtd")拿到污染物浓度,而不用关心CP=&&的嵌套干扰。
public static String buildFrame(String dataSection) { String crc = crc16(dataSection); return String.format("##%04d%s%s\r\n", dataSection.length(), dataSection, crc); }buildFrame是给 demo 和测试用的,它先用dataSection.length()得到字符数,再用%04d补成 4 位,随后拼上 CRC 和\r\n。这里没有做字符集适配,如果将来需要发送包含中文的字段,得先用协议要求的编码转换并确认长度。
这一章的实现重点是边界:长度字段告诉解析器从哪里截断,CRC 告诉解析器这段数据是否完整可信,CP=的定位告诉解析器内外层字段如何分区。三件事按顺序做,解析器就站稳了一半。
4. HJ212 解析器对接 TCP:粘包、半包与重发帧的处理
真正生产环境里,数采仪和服务端的连接通常是 TCP。TCP 是流协议,一次read返回的字节可能包含完整一帧、半帧,也可能是多帧粘在一起。如果直接把read结果丢给parseFrame,大概率会出现substring越界或 CRC 乱报。这一章给一个适合 HJ212 的拆包器,把字节流变成完整帧列表。
4.1 为什么必须自己做粘包拆包
HJ212 不依赖\r\n作为帧结束的唯一依据,因为数据段内可能出现\r或转义后的换行。可靠的边界是包头##加 4 位长度。拆包器应该维护一个缓冲区,每次收到新字节先追加,再循环尝试从缓冲区头部解析一帧:
- 若缓冲区不足 6 字节,等待下一批数据。
- 若找不到
##,丢弃开头无效字节,继续找。 - 若长度字段合法且缓冲区已够一帧,截出来做完整帧校验。
- 若 CRC 校验不通过,说明这一帧损坏,跳过当前
##继续找下一个包头。
4.2 基于缓冲区的粘包解码器实现
用ByteArrayOutputStream做追加缓冲,drain 逻辑放在每次写入后统一执行:
import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; public class Hj212Decoder { private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); public List<String> accept(byte[] chunk) throws IOException { buffer.write(chunk); List<String> frames = new ArrayList<>(); byte[] all = buffer.toByteArray(); int offset = 0; while (offset + 6 <= all.length) { if (all[offset] != '#' || all[offset + 1] != '#') { offset++; continue; } String lenText = new String(all, offset + 2, 4, StandardCharsets.US_ASCII); int dataLen; try { dataLen = Integer.parseInt(lenText); } catch (NumberFormatException e) { offset += 2; continue; } int frameLen = 6 + dataLen + 4 + 2; if (offset + frameLen > all.length) { break; } String frame = new String(all, offset, frameLen, StandardCharsets.ISO_8859_1); try { Hj212Parser.parseFrame(frame); frames.add(frame); offset += frameLen; } catch (IllegalArgumentException e) { offset += 2; } } buffer.reset(); if (offset < all.length) { buffer.write(Arrays.copyOfRange(all, offset, all.length)); } return frames; } }这段代码的核心是while循环里的三次判断:第一次用all[offset] != '#'跳过无效字符;第二次在长度字段非数字时跳过 2 个字节,避免死循环;第三次在缓冲区长度不足一帧时break,保留剩余数据。buffer.reset()后写入未消费的部分,保证下一批 TCP 数据到达时继续处理。
值得注意的细节是长度字段解析用US_ASCII,帧内容用ISO_8859_1解码。原因很简单:协议字段都是 ASCII,长度必须按 ASCII 读成数字;内容在 Java 内存里只做字节搬运,用 ISO-8859-1 可以无损地保留每个字节,后续再用协议字符集解读。
4.3 帧校验失败与重发:收到坏帧怎么办
CRC 校验失败的帧不一定需要中断连接。一种常见处理是跳过当前##,继续找下一个##。上面代码已经这样做了。但如果错误是长度字段本身损坏,4 位数字可能解析出一个很大的dataLen,导致offset + frameLen超过缓冲区长度,结果永远是break,缓冲区越积越大。
我一般会给缓冲区设一个上限,比如 8KB,超过后直接从最后一个##位置重置:
| 异常场景 | 行为 | 防止的问题 |
|---|---|---|
找不到## | 前进一个字节 | 无效垃圾数据长期占用缓冲区 |
| 长度非数字 | 前进两个字节 | 避免重解析同一区域 |
| 长度合理但数据不足 | 等待下一包 | 保留半包 |
| CRC 失败 | 丢弃当前开头 | 坏帧不污染正常帧 |
对于重发,核心是QN。数采仪可能因为网络抖动重发同一包数据,服务端可以在接入层用MN + QN做去重。简单做法是维护一个ConcurrentHashMap<String, Long>,key 是MN_QN,value 是接收时间,超过 5 分钟淘汰;收到重复 key 时直接丢弃,不进入业务队列。这个逻辑放在解码器之后、消息队列之前,成本很低。
5. 给 HJ212 demo 加自检:生成合法帧并断言解析结果
解析器写完不能只靠设备来验证。我会在工程里放一个main方法,用buildFrame生成合法帧,再模拟半包和粘包送入Hj212Decoder,最后打印和断言结果。这样每次改完协议逻辑都能在本地或 CI 跑一遍自检。
public static void main(String[] args) throws Exception { String section = "QN=20240101120000000;ST=22;CN=2011;PW=123456;MN=010000A9300001;CP=&&a34001-Rtd=12.5,a34002-Rtd=0.34&&"; String frame = Hj212Parser.buildFrame(section); Hj212Decoder decoder = new Hj212Decoder(); List<String> first = decoder.accept(frame.substring(0, 10).getBytes(StandardCharsets.ISO_8859_1)); List<String> second = decoder.accept((frame.substring(10) + frame).getBytes(StandardCharsets.ISO_8859_1)); if (!first.isEmpty()) { throw new AssertionError("半包阶段不应输出完整帧"); } if (second.size() != 2) { throw new AssertionError("粘包后应拆出 2 帧,实际 " + second.size()); } Hj212Parser.Hj212Frame parsed = Hj212Parser.parseFrame(second.get(0)); if (!"12.5".equals(parsed.cp.get("a34001-Rtd"))) { throw new AssertionError("CP 字段解析结果不正确"); } System.out.printf("pass: %s%n", parsed.header.get("MN")); }这里的first阶段只发了帧前 10 字节,缓冲区里肯定不是一个完整帧,正常输出空列表;second阶段把剩余半包和整帧一起送入,拆包器应当恢复两帧。最后一个断言检查 CP 内部字段,确保CP=&&边界没有被split(";")破坏。
自检通过后,还有三个验证技巧可以沉淀到测试环境:
| 验证内容 | 方法 | 目的 |
|---|---|---|
| CRC 算法 | 用在线 CRC-16/CCITT 工具比对一帧 | 确认多项式、初值、输出大小写 |
| 半包边界 | 每 1、2、3 字节切分帧做循环测试 | 找到拆包器对边界长度的误判 |
| 坏帧跳过 | 在合法帧前插入随机字符 | 确认丢坏帧后仍能恢复后续合法帧 |
最后提醒一个容易忽略的地方:接口文档里的CN、ST、PW在不同行业版本里含义可能不同,解析器只负责把字段拆出来,不要在里面写死业务码判断。把协议解析和业务编码解耦,后续升级成 HJ212-2017 或带加密扩展的版本时才不会牵一发动全身。
本文还有配套的精品资源,点击获取