加群联系作者vx:xiaoda0423
仓库地址:https://webvueblog.github.io/JavaPlusDoc/
✅ 模拟报文生成器代码(支持带/不带子包)
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
变为-1
,80
变为-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
将你提供的代码加到你的 ChannelInboundHandlerAdapter
或 SimpleChannelInboundHandler
中,实现心跳超时的关闭处理。
增强点 | 描述 |
---|---|
✅ 心跳包上报 | 客户端可以周期性发送 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) {
}
});