分布式微服务系统架构第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) {
    }
});
相关推荐
qq_2642208928 分钟前
k8s-Pod详解
云原生·容器·kubernetes
小诸葛的博客33 分钟前
k8s localpath csi原理
云原生·容器·kubernetes
小猿姐5 小时前
闲谈KubeBlocks For MongoDB设计实现
mongodb·云原生·kubernetes
勤源科技7 小时前
全链路智能运维中的实时流处理架构与状态管理技术
运维·架构
JanelSirry7 小时前
SOA和微服务之间的主要区别是什么
微服务·soa
大数据008 小时前
CLICKHOUSE分布式表初体验
分布式·clickhouse
失散138 小时前
分布式专题——43 ElasticSearch概述
java·分布式·elasticsearch·架构
早睡冠军候选人9 小时前
Ansible学习----Ansible Playbook
运维·服务器·学习·云原生·容器·ansible
mit6.8249 小时前
[Backstage] 后端插件 | 包架构 | 独立微服务 | by HTTP路由
架构
xrkhy9 小时前
微服务之hystrix熔断降级和负载均衡
hystrix·微服务·负载均衡