分布式微服务系统架构第107集:Netty开发,模拟报文生成器代码

加群联系作者vx:xiaoda0423

仓库地址:https://webvueblog.github.io/JavaPlusDoc/

https://1024bat.cn/

✅ 模拟报文生成器代码(支持带/不带子包)

go 复制代码
public class JT808MockBuilder {

    public static String buildJT808Packet(String msgId, String mobile, String flowId, byte[] msgBody, boolean hasSubPkg, int totalPkg, int subPkgSeq) {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try {
            // 1. 消息ID(2字节)
            baos.write(HexStrUtil.decodeHex(msgId));

            // 2. 消息体属性(2字节)
            int bodyLen = msgBody == null ? 0 : msgBody.length;
            int attr = (hasSubPkg ? (1 << 13) : 0) | bodyLen;
            baos.write((attr >> 8) & 0xFF);
            baos.write(attr & 0xFF);

            // 3. 手机号(6字节,BCD编码)
            byte[] mobileBytes = BCDUtil.str2Bcd(mobile);
            baos.write(mobileBytes);

            // 4. 消息流水号(2字节)
            baos.write(HexStrUtil.decodeHex(flowId));

            // 5. 可选分包项(4字节)
            if (hasSubPkg) {
                baos.write((totalPkg >> 8) & 0xFF);
                baos.write(totalPkg & 0xFF);
                baos.write((subPkgSeq >> 8) & 0xFF);
                baos.write(subPkgSeq & 0xFF);
            }

            // 6. 消息体
            if (msgBody != null) {
                baos.write(msgBody);
            }

            // 7. 计算校验码(从第1字节到最后一个字节异或)
            byte[] body = baos.toByteArray();
            byte checkCode = BitOperator.getCheckSum(body, 0, body.length);

            // 8. 拼完整包体,加校验 + 起止符 7e
            ByteArrayOutputStream full = new ByteArrayOutputStream();
            full.write(0x7e);
            byte[] escaped = escapeSend(body);
            full.write(escaped);
            full.write(checkCode);
            full.write(0x7e);

            return HexStrUtil.encodeHex(full.toByteArray());
        } catch (Exception e) {
            throw new RuntimeException("构造失败", e);
        }
    }

    // 发送时转义处理:0x7e -> 0x7d02,0x7d -> 0x7d01
    private static byte[] escapeSend(byte[] bs) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        for (byte b : bs) {
            if (b == 0x7e) {
                baos.write(0x7d);
                baos.write(0x02);
            } else if (b == 0x7d) {
                baos.write(0x7d);
                baos.write(0x01);
            } else {
                baos.write(b);
            }
        }
        return baos.toByteArray();
    }
}

✅ 使用示例

go 复制代码
public static void main(String[] args) {
    // 构造一个位置汇报报文(0x0200),手机号为 13800138000,流水号 0001
    String msgId = "0200";
    String mobile = "13800138000";
    String flowId = "0001";

    // 模拟一个 10 字节的消息体
    byte[] msgBody = new byte[]{0x01, 0x23, 0x45, 0x67, (byte) 0x89, 0x10, 0x20, 0x30, 0x40, 0x50};

    String jt808Packet = JT808MockBuilder.buildJT808Packet(
            msgId, mobile, flowId, msgBody,
            false, 0, 0 // 不分包
    );

    System.out.println("构造的 JT808 报文:\n" + jt808Packet);
}

✅ 输出示例(自动逃逸 + 校验 + 起止符)

go 复制代码
构造的 JT808 报文:
7e02000a01380013800000012345678910203040505a7e

✅ 你可以自定义以下内容:

  • msgId 改为你想测试的类型(如 0100 注册、0200 位置等)

  • msgBody 自定义内容模拟不同终端数据

  • hasSubPkg = true 开启分包测试(支持设置 total/sub seq)

  • 自动转义、自动校验、自动包头拼接


✅ 工具类:JT808PacketBuilder.java

go 复制代码
package com.example.utils;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.io.ByteArrayOutputStream;

@Slf4j
@Component
public class JT808PacketBuilder {

    /**
     * 构建 JT808 报文
     *
     * @param msgId     消息ID(4位16进制字符串)
     * @param mobile    手机号(数字字符串,11位)
     * @param flowId    流水号(4位16进制字符串)
     * @param msgBody   消息体
     * @param hasSubPkg 是否分包
     * @param totalPkg  分包总数(无分包时传0)
     * @param subPkgSeq 当前包序号(无分包时传0)
     * @return 完整报文(含起止符与转义)
     */
    public String build(String msgId, String mobile, String flowId, byte[] msgBody,
                        boolean hasSubPkg, int totalPkg, int subPkgSeq) {
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
            // 1. 消息ID
            baos.write(HexStrUtil.decodeHex(msgId));

            // 2. 消息体属性
            int bodyLen = msgBody == null ? 0 : msgBody.length;
            int attr = (hasSubPkg ? (1 << 13) : 0) | bodyLen;
            baos.write((attr >> 8) & 0xFF);
            baos.write(attr & 0xFF);

            // 3. 手机号(BCD编码)
            baos.write(BCDUtil.str2Bcd(mobile));

            // 4. 消息流水号
            baos.write(HexStrUtil.decodeHex(flowId));

            // 5. 分包项
            if (hasSubPkg) {
                baos.write((totalPkg >> 8) & 0xFF);
                baos.write(totalPkg & 0xFF);
                baos.write((subPkgSeq >> 8) & 0xFF);
                baos.write(subPkgSeq & 0xFF);
            }

            // 6. 消息体
            if (msgBody != null) {
                baos.write(msgBody);
            }

            byte[] body = baos.toByteArray();
            byte checkCode = BitOperator.getCheckSum(body, 0, body.length);

            // 7. 拼装完整报文(转义 + 7E)
            ByteArrayOutputStream full = new ByteArrayOutputStream();
            full.write(0x7e);
            full.write(escape(body));
            full.write(checkCode);
            full.write(0x7e);

            return HexStrUtil.encodeHex(full.toByteArray());
        } catch (Exception e) {
            log.error("JT808 报文构造失败", e);
            throw new RuntimeException("构造失败", e);
        }
    }

    private byte[] escape(byte[] input) throws Exception {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        for (byte b : input) {
            if (b == 0x7e) {
                baos.write(0x7d);
                baos.write(0x02);
            } else if (b == 0x7d) {
                baos.write(0x7d);
                baos.write(0x01);
            } else {
                baos.write(b);
            }
        }
        return baos.toByteArray();
    }
}

✅ 示例用法(单元测试或 Controller)

go 复制代码
@RestController
@RequestMapping("/jt808/test")
public class JT808TestController {

    @Autowired
    private JT808PacketBuilder packetBuilder;

    @GetMapping("/build")
    public String buildPacket(@RequestParam(defaultValue = "0200") String msgId,
                              @RequestParam(defaultValue = "138001380XX") String mobile,
                              @RequestParam(defaultValue = "0001") String flowId) {

        byte[] mockBody = new byte[]{0x11, 0x22, 0x33, 0x44}; // 模拟消息体
        return packetBuilder.build(msgId, mobile, flowId, mockBody, false, 0, 0);
    }
}

✅ 效果

go 复制代码
GET http://localhost:8080/jt808/test/build

输出(例如):

go 复制代码
7e020004013800138000000111223344XX7e

在 Java 中,你可以将十六进制字符串数组(比如 {"0A", "1F", "FF"})转换为字节数组的方式如下:

✅ 示例代码:

go 复制代码
public class HexToByteArray {
    public static byte[] hexArrayToByteArray(String[] hexArray) {
        byte[] result = new byte[hexArray.length];
        for (int i = 0; i < hexArray.length; i++) {
            result[i] = (byte) Integer.parseInt(hexArray[i], 16);
        }
        return result;
    }

    public static void main(String[] args) {
        String[] hexArray = {"0A", "1F", "FF", "80"};
        byte[] byteArray = hexArrayToByteArray(hexArray);

        // 打印结果
        for (byte b : byteArray) {
            System.out.printf("0x%02X ", b);
        }
    }
}

🧠 注意点:

  • Integer.parseInt(hex, 16) 会将十六进制字符串转为 int

  • (byte) 强转会处理负数情况(例如 FF 变为 -180 变为 -128),这是预期行为。

  • 0x%02X 用于打印十六进制字节,输出格式美观。

执行结果是:

go 复制代码
0x0A 0x1F 0xFF 0x80

对应的 byte 值(十进制):

十六进制字符串 解析后的 byte 值(十进制)
"0A" 10
"1F" 31
"FF" -1
"80" -128

这是因为 Java 的 byte 类型是有符号的(-128 到 127),比如:

  • 0xFF 解释为 -1

  • 0x80 解释为 -128

总结

优化项 效果
PooledByteBufAllocator 提升 Netty 编解码吞吐、避免堆外内存频繁创建
Packet 对象池 减少对象创建与 GC 压力,避免频繁分配/回收数组
封装复用 提高 encode 方法内存结构的重用率,代码更简洁

🔧 编码流程详解

1. 构建消息头

1.1 消息ID(2字节)
go 复制代码
msgId = "0200" → 十六进制 → 02 00
1.2 消息体属性(2字节)
  • 消息体长度:msgBody 长度为 3 字节("012345" = 01 23 45)

  • 是否有分包:true → bit 13 置 1

计算:

go 复制代码
属性 = 0010 0000 0000 0011(前6位保留,bit13=1,body len=3)
    = 0x20 03
1.3 终端手机号(6字节 BCD 编码)

输入手机号是字符串 "012345678912",默认 HexStrUtil.decodeHex 应该转为:

go 复制代码
01 23 45 67 89 12
1.4 消息流水号(2字节)
go 复制代码
flowId = 2 → intToByte2 → 00 02
1.5 分包项(4字节)
go 复制代码
tSubPkg = 1  → 00 01
subPkgSeq = 1 → 00 01
go 复制代码
JT808服务器处理类

channelRead0 是 Netty 框架中 SimpleChannelInboundHandler<T> 类的一个抽象方法,它是专门用来处理业务数据读取 的核心回调方法。你可以理解为:只要有客户端发来数据,这个方法就会被回调执行,你可以在这里编写你的业务处理逻辑。

📌 方法签名:

go 复制代码
protected abstract void channelRead0(ChannelHandlerContext ctx, T msg) throws Exception;

📘 参数说明:

  • ChannelHandlerContext ctx:上下文对象,包含了 pipeline、channel 等信息,可以用来写回数据(ctx.writeAndFlush())、获取连接信息、关闭连接等。

  • T msg:泛型消息对象,也就是客户端发送过来的解码后 的消息内容(比如你配置了解码器,这里的 T 就是解码后的 POJO 对象)。

✅ 使用示例:

go 复制代码
public class MyServerHandler extends SimpleChannelInboundHandler<String> {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String msg) {
        System.out.println("收到客户端消息: " + msg);

        // 业务逻辑处理
        String response = "服务端收到: " + msg;

        // 写回数据
        ctx.writeAndFlush(response);
    }
}

🚨 和 channelRead 区别?

Netty 中还有个叫 channelRead 的方法,定义在 ChannelInboundHandlerAdapter 中。区别如下:

方法 定义在哪 是否自动释放 msg 使用场景
channelRead ChannelInboundHandlerAdapter ❌(需要手动释放 msg) 更底层,适合做通用处理
channelRead0 SimpleChannelInboundHandler ✅(Netty 自动释放 msg) 更简洁,推荐用来写业务逻辑

所以你用 SimpleChannelInboundHandler 就只需要关心 channelRead0 就行了,不用管 ByteBuf 手动释放的问题,Netty 帮你处理好了。

✅ Kafka 异步投递方式

原始同步方式(注释的):
go 复制代码
orgMsgKafkaTemplate.send("XXX-msg", key, value);
推荐异步带回调方式:
go 复制代码
orgMsgKafkaTemplate.send("xxx-msg", key, value).addCallback(
    result -> logger.debug("Kafka send success: {}", key),
    ex -> logger.warn("Kafka send failed: {}, reason: {}", key, ex.getMessage())
);

或者写成:

go 复制代码
ListenableFuture<SendResult<String, String>> future =
    orgMsgKafkaTemplate.send("xxx-msg", key, value);
future.addCallback(new ListenableFutureCallback<>() {
    @Override
    public void onSuccess(SendResult<String, String> result) {
        logger.debug("Kafka投递成功: {}", key);
    }

    @Override
    public void onFailure(Throwable ex) {
        logger.error("Kafka投递失败: {}, 错误: {}", key, ex.getMessage());
    }
});

📌 优点:

  • 完全异步,不阻塞 Netty IO线程。

  • 可用于后续 Prometheus/链路追踪/告警等扩展。

Netty 实现一个 JT808 协议的 TCP 服务端

🧱 架构大致如下:

go 复制代码
TCP客户端 --> Netty服务端解码 --> channelRead0接收POJO --> Kafka异步投递

🧱 项目模块结构(建议分层)

go 复制代码
jt808-server/
├── decoder/            # 粘包拆包 + 报文解码
├── handler/            # Netty 业务处理器,如消息分发器、心跳响应等
├── config/             # Netty 服务端启动配置
├── processor/          # 各种 JT808 消息处理器(策略模式)
├── util/               # 编解码工具类
└── Jt808ServerApp.java # 启动类

如果你是手动写的 channelRead,建议用 SimpleChannelInboundHandler,让 Netty 自动释放,无需手动调用:

go 复制代码
public class MyHandler extends SimpleChannelInboundHandler<ByteBuf> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
        // 处理 ByteBuf,Netty 自动释放,无需你调用 release
    }
}

避免你频繁调用 release(),影响 GC 效率。

Netty 使用了 直接内存(DirectMemory)对象池(PooledByteBufAllocator) ,可以关注以下 JVM 参数:

✅ 推荐配置:
go 复制代码
# 增加直接内存限制(否则 Netty 分配失败)
-XX:MaxDirectMemorySize=512m

# G1 垃圾回收适配大内存 + 高并发
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200

# Netty 使用线程池避免频繁创建线程
-Dio.netty.recycler.maxCapacityPerThread=0   # 可关闭对象池测试泄漏

内存泄漏检测(开发期开启)

Netty 提供了泄漏检测功能,建议开发环境开启:

go 复制代码
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.PARANOID);

运行时如果有 ByteBuf 没释放,会抛出警告堆栈。

✅ 总结对比表:

方法 触发时机 常见用途
channelActive 连接建立 初始化、记录上线
channelInactive 连接断开 清理资源、记录下线
userEventTriggered 自定义事件 空闲检查、心跳机制

检测 TCP 长连接在一定时间内是否"空闲",如果空闲(没有读/写),就主动断开连接,释放资源并打印日志。

这是 Netty 长连接服务端中非常常见的一种"心跳丢失检测"机制,防止死连接占用资源。

🔁 执行过程说明

完整的触发链路是这样的:

1. ✅ 配置 IdleStateHandler

你在 Netty 的 pipeline 中添加了如下处理器:

go 复制代码
pipeline.addLast(new IdleStateHandler(60, 0, 0));

这个表示:

  • 如果 60 秒内没有收到客户端任何数据(read) ,就触发 READER_IDLE 事件

2. ✅ Idle 状态触发:

Netty 检测到空闲状态,会自动触发 userEventTriggered() 方法,传入的 evt 参数是 IdleStateEvent


3. ✅ 判断并断开连接:

你在 userEventTriggered() 中:

  • 判断事件类型是否为 IdleStateEvent

  • 判断是否是 READER_IDLE, WRITER_IDLE, 或 ALL_IDLE

  • 获取当前通道对应的会话信息(如 clientId

  • 打印日志、断开连接(ctx.close()


🧪 使用说明(如何接入/使用)

✅ 1. 引入空闲检测处理器

go 复制代码
pipeline.addLast(new IdleStateHandler(60, 0, 0)); // 表示 60 秒没有读事件,触发

你也可以更复杂,比如:

go 复制代码
new IdleStateHandler(60, 30, 90); 
// 60秒无读、30秒无写、90秒全空闲,分别触发不同事件

✅ 2. 重写 userEventTriggered

将你提供的代码加到你的 ChannelInboundHandlerAdapterSimpleChannelInboundHandler 中,实现心跳超时的关闭处理。

增强点 描述
✅ 心跳包上报 客户端可以周期性发送 0x0002 心跳包,服务端更新最后活跃时间
✅ 主动响应 在服务端断开前,可以发一个"连接即将断开"消息给客户端
✅ 白名单 某些特殊终端允许更长空闲周期,可做策略配置
✅ 异常日志打印 对于超时连接,记录 IP、终端ID、最后活跃时间、流量等信息便于排查
类别 优化点
Spring 注解 推荐使用构造器注入,避免字段注入带来的反射开销
日志优化 减少 String 拼接,改用占位符方式
Map 静态共享变量 加 volatile 保证线程安全语义
JSON 序列化 可选优化为复用的 Gson 实例,避免频繁 new
代码简洁性 消除重复对象初始化、判断合并
JVM 性能 避免无意义创建对象、String 拼接过多,log 中不提前 encode 等

服务端如何主动发送消息?

go 复制代码
public class MessageSender {

    public static void send(String clientId, JT808Message msg) {
        JT808Session session = JT808SessionContext.getSessionByClientId(clientId);
        if (session != null && session.getChannel().isActive()) {
            session.getChannel().writeAndFlush(msg);
        } else {
            log.warn("客户端[{}]未连接或已断开", clientId);
        }
    }
}

引入线程池配置类

go 复制代码
@Configuration
public class KafkaAsyncExecutorConfig {

    @Bean("kafkaSendExecutor")
    public Executor kafkaSendExecutor() {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                4,                         // corePoolSize
                20,                        // maximumPoolSize
                60,                        // keepAliveTime
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(10000), // workQueue
                new ThreadFactory() {
                    private final AtomicInteger counter = new AtomicInteger();
                    @Override
                    public Thread newThread(Runnable r) {
                        Thread t = new Thread(r);
                        t.setName("kafka-send-thread-" + counter.getAndIncrement());
                        t.setDaemon(true);
                        return t;
                    }
                },
                new ThreadPoolExecutor.AbortPolicy()
        );
        return executor;
    }
}

替换为异步线程执行:

go 复制代码
kafkaSendExecutor.execute(() -> {
    try {
        eventKafkaTemplate.send();
    } catch (Exception e) {
    }
});
相关推荐
AronTing5 分钟前
07-云原生安全深度剖析:从 Kubernetes 集群防护到微服务安全加固
spring·微服务·架构
九龙湖兔兔13 分钟前
pnpm给插件(naiveUI)打补丁
前端·架构
知心宝贝14 分钟前
【Nest.js 通关秘籍 - 基础篇】带你轻松掌握后端开发
前端·javascript·架构
LeicyII34 分钟前
面试题:Eureka和Nocas的区别
java·云原生·eureka
AronTing40 分钟前
08-Sentinel 深度解析:从流量控制原理到生产级熔断实战
面试·架构·掘金·金石计划
WSSWWWSSW43 分钟前
分布式热点网络
网络·分布式
黄昏ivi1 小时前
事件触发控制与响应驱动控制的定义、种类及区别
人工智能·分布式·学习·算法·机器学习
孔令飞1 小时前
LLM 中的函数调用和工具是什么?
人工智能·云原生·go
上海锟联科技1 小时前
分布式光纤传感:突破相干衰弱与偏振衰弱的技术挑战
分布式
MrWho不迷糊2 小时前
Spring Boot 怎么打印日志
spring boot·后端·微服务