简介电力电表376.1协议的Java版实现旨在帮助Java开发者快速接入智能电表通信。376.1协议ANSI C12.18-2004是电力行业自动抄表AMR和高级计量基础设施AMI中的重要接口标准资源围绕报文解析、命令集、安全认证等核心机制展开提供了完整的类库设计与编码解码方案。包体共107个文件含92个Java源文件和15个XML配置文件Java文件负责协议编解码、报文收发及错误检测XML文件用于通信参数与设备适配配置压缩包大小85KB结构清晰。目前已有3290人学习/下载适合电力行业软件开发人员、系统集成工程师以及相关专业学生参考。通过研读源码可系统掌握协议报文头、控制字段、校验和等细节理解数据加密与身份认证实现并复用其中的报文处理框架与工具类同时覆盖数据采集、故障检测、能效分析等典型应用场景具有良好的扩展性有助于降低实际项目的开发与调试成本。1. 电力电表376.1协议Java版一份拿来就能跑的采集终端接入工程我第一次接触电力电表376.1协议java版是在某用电信息采集平台的后端联调现场。终端那边一直回链路不通项目组又拿不出一份能直接看懂报文格式的样例整个人被 376.1 的帧结构卡了一周。后来把这套源码包跑起来才发现复杂的协议被拆成了链路解析、业务分发、数据库落地三个模块我只需要关心业务逻辑。这份工程不是一个只做报文解析的工具包而是能监听端口、维持长连接、组装应答帧、写入数据库的完整采集服务。适合正在做 376.1 协议接入、给采集平台写后端或者想从零理解电力规约栈的 Java 开发者。2. 工程架构与目录拆解四层结构把协议栈和业务隔离干净2.1 Maven多模块结构每个模块只负责一件事拿到源码包后第一件事是看 pom.xml。整个工程不是单模块堆代码而是拆成了五个 Maven module从上到下依赖关系非常清楚。这个拆分方式跟大多数“一个类解析到底”的 demo 相比最大的好处是协议解析不依赖 Spring可以单独写单元测试网络接入层换了也不影响协议层和业务层。3761-java ├─ pom.xml ├─ 3761-protocol │ └─ src/main/java/xxx/frame/ │ ├─ Frame.java │ ├─ FrameParser.java │ └─ FrameEncoder.java ├─ 3761-transport │ ├─ pom.xml │ └─ src/main/java/xxx/netty/ │ ├─ NettyServer.java │ ├─ FrameDecoder.java │ └─ IdleHandler.java ├─ 3761-service │ ├─ pom.xml │ └─ src/main/java/xxx/ │ ├─ dispatch/MessageDispatcher.java │ ├─ session/SessionManager.java │ └─ task/AcquireTask.java ├─ 3761-dao │ ├─ pom.xml │ └─ src/main/java/xxx/mapper/ │ ├─ MeterDataMapper.java │ └─ src/main/resources/mapper/ │ └─ MeterDataMapper.xml └─ 3761-bootstrap ├─ pom.xml └─ src/main/resources/ │ └─ application.yml └─ src/main/java/xxx/BootstrapApp.javaprotocol 模块是纯 Java不引入任何框架依赖只有 Frame、Parser、Encoder 三个核心类。transport 模块依赖 Netty 4.1做 TCP 长连接和拆包粘包处理。service 模块依赖 protocol 和 transport负责业务分发与会话状态维护。dao 模块用 MyBatis只做持久化。bootstrap 是启动模块统一加载配置文件。这样分层的直接收益是如果以后不想用 Netty可以只重写 transport 模块Parser 和业务层完全不用动。常见做法是连 transport 一起外包给第三方协议网关但在这个工程里协议解析和网络传输被刻意拆开方便二次开发时定位问题。2.2 一帧数据的旅程从 Netty 读事件到 DAO 写入先把整体链路理清楚再看单个类会容易很多。终端设备通过 TCP 连到 2404 端口Netty 的 FrameDecoder 负责把字节流累积成完整帧然后交给 MessageDispatcher。Dispatcher 根据 AFN 和 FN 找到对应的处理器处理器解析负载数据往 SessionManager 里刷新心跳时间再组装一个应答帧写回 channel。如果这条数据是电表上报量处理器会同时把数据丢给 MeterDataService最终落到 MySQL。public class ServerHandler extends SimpleChannelInboundHandlerFrame { private final SessionManager sessionManager; private final MessageDispatcher dispatcher; Override protected void channelRead0(ChannelHandlerContext ctx, Frame frame) { String terminal frame.terminalKey(); sessionManager.refresh(terminal); MessageResult result dispatcher.dispatch(frame); if (result ! null result.needReply()) { ctx.writeAndFlush(result.buildReplyFrame()); } } }这段代码是整个接入层的主干。terminalKey()我用的是地址域里 5 个字节的十六进制字符串比如00015A060000这样同一终端的所有帧都能路由到同一个会话上。refresh会把当前时间更新到 SessionManager 的 map 里后续心跳超时判断就依赖这个时间戳。dispatcher.dispatch(frame)返回 null 表示这个帧不需要应答比如终端主动上报的数据主站回一个确认帧即可。注意确认帧的控制字必须带上启动标志位否则终端不认。2.3 依赖与配置少了这些 jar 包项目起不来bootstrap 模块里的 application.yml 是运行时的唯一配置入口。我习惯把 Netty 的监听端口和数据库配置分开方便在联调环境直接替换。server: port: 8080 netty: listen-port: 2404 boss-threads: 1 worker-threads: 8 idle-timeout-seconds: 60 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/collect username: col_user password: change-me driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath:/mapper/*.xmllistener-port就是终端 TCP 接入端口默认 2404很多采集终端出厂时也默认连接这个端口。idle-timeout-seconds是我重点关注参数它直接决定长连接稳定性。常见做法是设 60 秒超过 60 秒没有收到任何帧就发送测试帧探活而不是立刻断开连接。这个时间必须小于中间网络设备的 NAT 老化时间否则连接会被运营商或路由器悄悄切断终端那边看还是在线主站已经收不到后续报文。这个模块选型上Netty 负责网络MyBatis-Plus 负责数据库Fastjson 负责把负载里的 JSON 字段转成对象。如果你们项目里禁止用 Fastjson可以换成 Jackson需要改动的地方集中在 service 模块的 result 解析不到十个类。3. 核心模块拆解帧解析和会话管理的完整链路3.1 字节流中的帧边界FrameParser 与半包累加376.1 的帧格式大体上可以简化成下面的排列起始字符 0x68、两字节长度、起始字符 0x68、控制字、地址域 5 字节、AFN、FN、链路用户数据、帧序号、校验和、结束字符 0x16。理解长度字段的边界是解析的关键L 长度包含控制字、地址域、AFN、FN、数据、帧序号和校验和但不包含两个起始字符和结束字符。public class FrameParser { public Frame parse(byte[] frameBytes) throws FrameParseException { int p 0; if (frameBytes[p] ! 0x68) { throw new FrameParseException(起始字符不是68H); } int len (frameBytes[p] 0xFF) | ((frameBytes[p] 0xFF) 8); if (frameBytes[p] ! 0x68) { throw new FrameParseException(第二个起始字符缺失); } if (frameBytes.length ! 1 2 1 len 1) { throw new FrameParseException(长度字段与实际帧长不匹配); } Frame frame new Frame(); frame.setControl(frameBytes[p] 0xFF); byte[] address new byte[5]; System.arraycopy(frameBytes, p, address, 0, 5); p 5; frame.setAddress(address); frame.setAfn(frameBytes[p] 0xFF); frame.setFn((frameBytes[p] 0xFF) | ((frameBytes[p] 0xFF) 8)); int payloadLen len - 11; byte[] payload new byte[payloadLen]; System.arraycopy(frameBytes, p, payload, 0, payloadLen); p payloadLen; frame.setPayload(payload); frame.setSerialNo(frameBytes[p] 0xFF); int cs frameBytes[p] 0xFF; int expectCs 0; for (int i 1; i p - 1; i) { expectCs (expectCs (frameBytes[i] 0xFF)) 0xFF; } if (expectCs ! cs) { throw new FrameParseException(校验和不匹配); } if (frameBytes[p] ! 0x16) { throw new FrameParseException(结束字符不是16H); } return frame; } }这里的payloadLen len - 11是从总长度里减去控制字 1 字节、地址域 5 字节、AFN 1 字节、FN 2 字节、帧序号和校验和各 1 字节。最后一个校验和循环从偏移 1 开始因为 376.1 的校验范围从长度字段的第一个字节开始算一直加到帧序号不包含第一个起始字符和校验和本身。最容易翻车的地方是 Java 的 byte 是有符号类型直接强转 int 会把高位变成符号位。比如0xE1读完变成负数长度算出来就是错的。所有与数值有关的字节都先 0xFF再计算这是解析 376.1 的第一条铁律。有了 FrameParser 以后还需要解决 TCP 半包和粘包。Netty 里用ByteToMessageDecoder最方便它自带累积缓冲来一段数据就尝试解一帧解不出来就等下一段很多自己写 socket 的工程就是没有这一步导致时不时丢帧。public class FrameDecoder extends ByteToMessageDecoder { private final FrameParser parser new FrameParser(); Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { while (in.readableBytes() 4) { in.markReaderIndex(); if (in.readUnsignedByte() ! 0x68) { in.skipBytes(1); continue; } int len (in.readUnsignedByte() 0xFF) | ((in.readUnsignedByte() 0xFF) 8); if (in.readUnsignedByte() ! 0x68) { in.resetReaderIndex(); in.skipBytes(1); continue; } if (in.readableBytes() len 1) { in.resetReaderIndex(); return; } byte[] frameBytes new byte[4 len 1]; frameBytes[0] (byte) 0x68; frameBytes[1] (byte) (len 0xFF); frameBytes[2] (byte) ((len 8) 0xFF); frameBytes[3] (byte) 0x68; in.readBytes(frameBytes, 4, len 1); out.add(parser.parse(frameBytes)); } } }这个解码器稳不稳关键在两点。第一找不到 0x68 时要逐字节跳过而不是跳过整包否则很容易在错位后永远对不齐。第二readableBytes() len 1时直接 resetReaderIndex 并 returnNetty 会保留当前累积数据等下一次 TCP 数据到来继续解。我见过有人在长度不足时强行解析尾部残缺包结果每 10 条数据里就有 1 条解析异常这种问题很隐蔽。3.2 控制字和地址域路由逻辑的第一道分水岭控制字和地址域决定了这帧数据是“发给谁、从哪来、要不要回”。控制字里最常用的是方向位和启动标志位。方向位最高位为 0 表示主站到终端为 1 表示终端到主站。启动标志位表示这个帧是不是一次主动请求。很多终端在收到心跳请求后如果发现启动标志位不对会直接丢弃表现在外部就是“终端在线但主站发什么它都不回”。public class ControlField { private final int raw; public ControlField(int raw) { this.raw raw; } public int raw() { return raw; } public boolean isTerminalToMaster() { return (raw 0x80) ! 0; } public boolean isStartBit() { return (raw 0x40) ! 0; } public int frameType() { return (raw 4) 0x03; } }地址域是 5 个字节前两个字节是行政区码后三个字节是终端地址。行政区码一般是固定的终端地址在项目里往往是设备编号。我处理的终端会把三个字节做小端存储也就是低位在前所以真正读终端地址时我会这样处理。public String terminalKey(byte[] address) { int code (address[0] 0xFF) | ((address[1] 0xFF) 8); int addr (address[2] 0xFF) | ((address[3] 0xFF) 8) | ((address[4] 0xFF) 16); return String.format(%04X%06X, code, addr); }这里有个很关键的坑不能直接用Arrays.toString(address)当 key因为地址域前两字节是行政区码后三字节才是终端地址如果用全部 5 字节做 key同一个终端换一个行政区码就会被认为是两个设备。我一般把后三字节转成整数再格式化保证设备唯一。3.3 会话管理心跳、超时和断线重连会话管理在 376.1 接入服务里经常被忽略但实际联调时它比解析器更容易出问题。终端通过 TCP 长连接连上来之后主站和终端之间的请求是异步的主站下发一条读数据命令终端可能在 1 秒后或者 5 秒后才回。如果找不到之前的 Channel应答帧就写不出去。Component public class SessionManager { private final ConcurrentHashMapString, Channel channelMap new ConcurrentHashMap(); private final ConcurrentHashMapString, Long lastActiveMap new ConcurrentHashMap(); public void register(String terminal, Channel channel) { channelMap.put(terminal, channel); lastActiveMap.put(terminal, System.currentTimeMillis()); } public void refresh(String terminal) { lastActiveMap.put(terminal, System.currentTimeMillis()); } public Channel get(String terminal) { return channelMap.get(terminal); } public boolean isAlive(String terminal) { Channel channel channelMap.get(terminal); Long last lastActiveMap.get(terminal); if (channel null || last null) { return false; } return channel.isActive() System.currentTimeMillis() - last 90_000; } public void close(String terminal) { channelMap.remove(terminal); lastActiveMap.remove(terminal); } }我习惯用 ConcurrentHashMap 而不是给整个 SessionManager 加锁因为终端数量上来以后加锁会变成并发瓶颈。register 在连接建立时调用close 在 channelInactive 时调用。心跳定时任务每 30 秒扫一次 lastActiveMap发现超过 90 秒没有活跃的终端就通过 channel 发一帧测试帧探活。探活逻辑不放在 frame 解析里而是放在一个独立的 ScheduledExecutorService 里。原因是 376.1 的会话状态可能和 TCP 状态不一致终端一侧网络断开以后TCP 可能不会立刻触发 close如果不主动探活Channel 会一直存在 map 里变成僵尸连接。这个探活任务我一般用 10 秒作为间隔避免频繁发送测试帧。Scheduled(fixedDelay 30_000) public void probe() { long now System.currentTimeMillis(); lastActiveMap.forEach((terminal, last) - { if (now - last 90_000) { Channel channel channelMap.get(terminal); if (channel ! null channel.isActive()) { channel.writeAndFlush(FrameFactory.buildProbeFrame(terminal)); } else { close(terminal); } } }); }探活帧必须是 376.1 的测试帧而不是自定义的字节包。终端在收到测试帧后会在控制字里带上应答标志回一帧这样 lastActiveMap 会被刷新。如果连续三次探活都没有回包就可以认定终端已经离线主动 close 之后再从 channelMap 里移除避免内存泄漏。4. 应答编码与数据落地反向报文和存储逻辑怎么配合4.1 应答帧编码从 Frame 对象拼回字节数组终端收到主站命令后会回应用帧但有些场景主站也要主动给终端确认比如终端上报数据后主站回一个确认帧或者定时采集任务下发读数据命令。这些都需要把 Frame 对象重新编码成字节数组。编码是解析的逆过程长度字段、校验和、字节序都要保持一致。public class FrameEncoder { public byte[] encode(Frame frame) { byte[] payload frame.getPayload() null ? new byte[0] : frame.getPayload(); int len 1 5 1 2 payload.length 1 1; ByteBuffer body ByteBuffer.allocate(len); body.order(ByteOrder.LITTLE_ENDIAN); body.put((byte) frame.getControl()); body.put(frame.getAddress()); body.put((byte) frame.getAfn()); body.putShort((short) frame.getFn()); if (payload.length 0) { body.put(payload); } body.put((byte) frame.getSerialNo()); int cs 0; for (byte b : body.array()) { cs b 0xFF; } cs 0xFF; ByteBuffer buffer ByteBuffer.allocate(4 len 1); buffer.order(ByteOrder.LITTLE_ENDIAN); buffer.put((byte) 0x68); buffer.putShort((short) len); buffer.put((byte) 0x68); buffer.put(body.array()); buffer.put((byte) cs); buffer.put((byte) 0x16); return buffer.array(); } }编码时最容易错的是少算一个字节。len 字段里已经包含校验和但结束字符 0x16 不在 len 里所以最终数组长度是4 len 1。ByteBuffer.putShort默认是大端这里必须显式设置order(ByteOrder.LITTLE_ENDIAN)否则终端那边解析长度和 FN 时会把高低字节读反。应答帧的帧序号必须沿用请求帧的帧序号这是很多终端判断是否匹配请求的依据。如果主站自己随意生成新序号终端可能把应答当成一个新请求或者直接丢弃。Frame 对象里保存 serialNo就是为了在buildReplyFrame时原样回填。4.2 电表数据入库一张表加一个异步队列解析了一帧数据之后下一步是把它变成可查询的记录。我建议建一张尽量简单的表把 376.1 帧里的核心字段拆成列后续做报表或者异常分析都能直接用。CREATE TABLE meter_data ( id bigint(20) NOT NULL AUTO_INCREMENT, terminal_addr varchar(20) NOT NULL COMMENT 终端地址十六进制不带空格, afn tinyint(4) NOT NULL COMMENT 应用功能码, fn int(11) NOT NULL COMMENT 功能序号, meter_no varchar(32) DEFAULT NULL COMMENT 电表编号, point varchar(32) DEFAULT NULL COMMENT 数据项标识, value decimal(14,4) DEFAULT NULL COMMENT 测量值, report_time datetime DEFAULT NULL COMMENT 终端上报时间, create_time datetime NOT NULL COMMENT 入库时间, PRIMARY KEY (id), KEY idx_terminal_time (terminal_addr, report_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;写入不要直接在 Netty 的 IO 线程里执行否则数据库慢查询会拖垮所有连接。我习惯用一个有界队列做缓冲Netty 线程只负责把解析结果丢进队列然后由独立任务批量入库。Component public class MeterDataService { private final ArrayBlockingQueueMeterData queue new ArrayBlockingQueue(20_000); public void save(Frame frame, MapString, String pointValues) { pointValues.forEach((point, value) - { MeterData entity new MeterData(); entity.setTerminalAddr(JsonUtil.frameTerminalKey(frame.getAddress())); entity.setAfn(frame.getAfn()); entity.setFn(frame.getFn()); entity.setMeterNo(pointValues.get(meter_no)); entity.setPoint(point); entity.setValue(new BigDecimal(value)); entity.setReportTime(new Date()); entity.setCreateTime(new Date()); if (!queue.offer(entity)) { log.warn(采集数据队列已满丢弃 terminal{}, point{}, frame.terminalKey(), point); } }); } Scheduled(fixedDelay 1000) public void flushBatch() { if (queue.isEmpty()) { return; } ListMeterData batch new ArrayList(); MeterData item; while ((item queue.poll()) ! null batch.size() 500) { batch.add(item); } meterDataMapper.insertBatch(batch); } }队列容量和批量大小要按终端规模调整。我这边大概 3000 台终端每小时会产生几万条数据20_000 的队列容量完全够。如果出现队列满的警告优先检查是不是某张表没有索引导致批量插入太慢而不是盲目调大队列。4.3 定时采集任务定时下发命令并等待回包除了被动接收终端上报主站还需要定时下发采集命令比如每 15 分钟采集一次当前功率。这个任务不能直接发完命令就结束必须关联 SessionManager 和应答等待机制。Component public class AcquireTask { private final SessionManager sessionManager; private final TerminalConfigService terminalConfigService; Scheduled(cron 0 0/15 * * * ?) public void collect() { ListTerminalConfig terminals terminalConfigService.listAll(); for (TerminalConfig terminal : terminals) { Frame request FrameFactory.buildReadPowerFrame( terminal.getAddress(), terminal.getSerialNo()); Channel channel sessionManager.get(terminal.getTerminalKey()); if (channel ! null channel.isActive()) { channel.writeAndFlush(request).addListener(future - { if (!future.isSuccess()) { log.error(下发采集命令失败terminal{}, terminal.getTerminalKey(), future.cause()); } }); } else { log.warn(终端不在线跳过本轮采集terminal{}, terminal.getTerminalKey()); } } } }buildReadPowerFrame会根据终端类型设置不同的 FN因为不同厂商对 FN 的定义可能不一样。下发之后终端不一定立刻回包所以我在 MessageDispatcher 里维护一个请求和应答的关联表记录serialNo - CompletableFuture。终端回包时通过帧序号找到对应 future再把结果 set 回去。这样就避免了每发一条命令就 sleep 等待的笨办法。Scheduled(cron 0 0/15 * * * ?)的写法是每小时的 0、15、30、45 分钟执行。如果采集任务和终端上报同时发生数据库写入压力会聚集到同一时间段所以我习惯在任务里加一个随机延时把同一批命令分散到 1 到 5 秒内下发避免瞬时并发过高。5. 运行调试与避坑指南本地跑通和几个难忘的坑5.1 三步跑起来编译、配置、启动这套工程我本机用的是 JDK 8、Maven 3.6 和 MySQL 8.0推荐你也保持一致。先把 MySQL 建好库然后改 application.yml 里的数据库账号密码最后一条命令打 jar 包。mvn clean package -DskipTests编译完成后启动类在 3761-bootstrap 模块。java -jar 3761-bootstrap/target/3761-bootstrap.jar启动日志里如果出现Netty started on port 2404说明服务已经起来了。这时候可以用一个最简单的 Python 脚本模拟终端发帧验证整个链路是通的。import socket frame bytes.fromhex( 68 00 00 68 01 00 01 00 5A 06 00 0C 01 00 01 00 00 16 ) s socket.create_connection((127.0.0.1, 2404), timeout5) s.sendall(frame) resp s.recv(1024) print(resp.hex()) s.close()这个脚本只做链路验证不一定要能解析完整业务。只要服务端没有抛异常并且返回了一个以 68 开头、16 结尾的应答帧就说明 FrameDecoder 和 FrameEncoder 基本工作正常。注意脚本里的帧内容要跟实际终端的报文结构一致我这里的示例是为了跑通流程正式联调时要以厂商提供的报文为准。5.2 常见问题排查三个真实踩坑记录先说第一个坑也是最经典的类型符号扩展问题。现象是解析出来的报文长度特别大比如明明是 20 字节读出来却成了 65535。原因是 Java 的 byte 是有符号的0xFF这种字节直接转 int 就变成-1再跟其他字节做位运算结果完全错掉。解决方法是所有读出的字节都做 0xFF再参与计算。我在 FrameParser 里已经统一处理但如果你基于这套代码做二次开发新写的解析代码也要记住这个规则。第二个坑是心跳超时设得太长或者太短导致终端批量掉线。现象是终端过一段时间后集体断开重新连接后又能正常采集过一会又断。原因是主站的心跳间隔大于中间网络设备的 NAT 老化时间。表现在日志里就是终端没有任何异常channelInactive 突然被触发。解决方法是把 idle-timeout-seconds 从默认值调小同时让探活帧在空闲超过 40 秒时发送。376.1 的测试帧要带启动标志位否则终端不回应。我遇到过一次终端厂商要求 30 秒必须收到主站帧否则主动断开那个项目里就把探活间隔直接调到了 20 秒。第三个坑是地址域字节序不清导致数据串户。现象是终端 A 上报的数据最终入库时记到了终端 B 名下。原因是终端地址 3 字节是小端存储我没有反转解析出来的地址是反的更要命的是解析虽然错了但值还在合法范围内日志和数据库都不会报错。解决方法是把所有终端的地址域打出来和采集终端后台的设备编号表做一次对照确认terminalKey()的格式与设备出厂编号一致。从那以后我每次新接一个厂商的设备都先抓一条真实报文手工拆一遍地址域再写代码。5.3 抓包和日志把 376.1 报文变成可读文本排查线上问题时最常用的手段还是抓包。本机或者服务器上用 tcpdump 直接抓 2404 端口的流量。tcpdump -i any tcp port 2404 -w 3761.pcap抓下来的 pcap 文件用 Wireshark 打开。Wireshark 默认没有内置 376.1 解析器所以看到的是一堆 TCP 负载。我的习惯是在 Wireshark 里右键 TCP 流选择 “Follow Stream”把 Hex 数据复制出来再喂给项目里写好的一个 Hex 工具类解析。项目里HexUtils提供两个方法toHexString(byte[])和fromHexString(String)调试时把报文打出来跟抓包结果逐字节对。日志配置上建议把 frame 包的日志级别调到 DEBUG。我在 FrameDecoder 的decode方法里加了一行日志输出十六进制原始帧和解析后的 Frame 摘要这样每次收到帧都有完整记录。缺点是生产环境日志量会比较大所以我加了一个开关只有配置logging.level.xxx.frameDEBUG时才输出。查询问题的时候临时打开排查完再关掉。6. 二次开发如何往 376.1 工程里加新业务6.1 新增AFN/FN处理分支现在要加一个“读取电表当前电压”的新业务只需要在 service 模块里新增一个 Processor然后在 MessageDispatcher 里注册 AFN 和 FN。我习惯用AFNxx_FNxxxx作为 Bean 名Dispatcher 根据帧里的 AFN 和 FN 直接找 Bean这样加新业务不用改路由代码。Component(AFN0C_FN0150) public class VoltageProcessor implements ResultProcessor { Override public void process(Frame frame, ChannelHandlerContext ctx) { String voltage VoltagePayloadParser.parse(frame.getPayload()); meterDataService.save(frame, Map.of(voltage, voltage)); ctx.writeAndFlush(ReplyFrameFactory.buildConfirm(frame)); } }6.2 改造为Netty长连接服务如果原来是用纯 Socket 加线程池做的想迁移到 Netty只换 transport 模块。要注意 Netty 的 ChannelHandler 默认不是共享的如果你把 ServerHandler 定义成 Spring 单例必须加Sharable否则多个连接同时进来会触发共享状态问题。SessionManager 本身是线程安全的 map所以可以安全地在 handler 里注入使用。6.3 改造后的验证方法我最后一步永远是跑模拟帧矩阵而不是直接连真机。把每一条新增业务的手工帧、异常帧、错校验帧、半包帧组合起来同一个脚本循环发确认服务端不会因为一帧错包就挂掉某个连接。第一次接新终端时我会把厂商给的报文样例逐字节拆开写进单元测试里保证 FrameParser 回归稳定。从那以后我每次做新终端协议适配都强制先跑一遍模拟帧矩阵再上真机这个习惯帮我挡掉了至少三次由地址域看错引发的数据串户。希望帮到你。本文还有配套的精品资源点击获取