Netty:自定义 TCP 通信协议设计与编解码实现
一、为什么需要自定义 TCP 协议?
TCP
是面向字节流的协议,不保留应用层消息边界。客户端和服务端需要约定统一的消息格式,才能正确识别和解析数据。
自定义协议主要解决: 1. 如何判断一条消息从哪里开始、在哪里结束。 2.
如何区分登录、心跳、业务数据等消息类型。 3. 如何识别协议版本。 4.
如何校验非法数据。 5. 如何为后续协议升级预留空间。
二、设计协议结构
本例设计一个简单的二进制协议:
| 魔数 | 版本号 | 消息类型 | 正文长度 | 正文 |
| 2B | 1B | 1B | 4B | N字节 |消息头固定 8 字节,正文长度不固定。
字段 长度 作用
Magic 2 字节 识别当前协议
Version 1 字节 标识协议版本
Type 1 字节 区分消息类型
Length 4 字节 表示正文的字节长度
Body 不固定 存放业务数据
本例约定: - 魔数:0xCAFE - 版本号:1 -
消息类型:1=登录、2=心跳、3=业务数据 -
长度字段只表示正文长度,不包含 8 字节消息头。 -
多字节数字统一使用大端字节序(Big Endian)。
三、完整消息示例
假设发送一条消息:版本号 1、消息类型 3、正文 hello。
CA FE | 01 | 03 | 00 00 00 05 | 68 65 6C 6C 6FCA FE:魔数。01:协议版本 1。03:业务数据消息。00 00 00 05:正文长度为 5 字节。68 65 6C 6C 6F:正文hello。
整条消息长度:2 + 1 + 1 + 4 + 5 = 13 字节。
注意:协议中的长度表示字节数,不是 Java 字符串的字符数。
四、定义消息对象 ProtocolMessage
解码后,将协议数据封装成 Java 对象,交给业务 Handler 使用。
public class ProtocolMessage {
private final int version;
private final int type;
private final byte[] body;
public ProtocolMessage(int version, int type, byte[] body) {
this.version = version;
this.type = type;
this.body = body;
}
public int getVersion() {
return version;
}
public int getType() {
return type;
}
public byte[] getBody() {
return body;
}
}这个对象表示一条已经解析好的消息,不再关心底层字节排列。
五、自定义 Decoder:ByteBuf 转 Java 对象
继承 ByteToMessageDecoder,实现协议解析。
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import io.netty.handler.codec.CorruptedFrameException;
import java.util.List;
public class ProtocolDecoder extends ByteToMessageDecoder {
private static final int MAGIC = 0xCAFE;
private static final int VERSION = 1;
private static final int HEADER_LENGTH = 8;
private static final int MAX_BODY_LENGTH = 1024 * 1024;
@Override
protected void decode(
ChannelHandlerContext ctx,
ByteBuf in,
List<Object> out) {
// 1. 消息头不足 8 字节,等待后续数据
if (in.readableBytes() < HEADER_LENGTH) {
return;
}
// 2. 获取当前读取位置,下面使用绝对索引读取
int readerIndex = in.readerIndex();
// 3. 校验魔数:读取 2 字节,不移动 readerIndex
int magic = in.getUnsignedShort(readerIndex);
if (magic != MAGIC) {
throw new CorruptedFrameException("非法魔数");
}
// 4. 读取版本号和消息类型
int version = in.getUnsignedByte(readerIndex + 2);
int type = in.getUnsignedByte(readerIndex + 3);
if (version != VERSION) {
throw new CorruptedFrameException("不支持的协议版本");
}
if (type < 1 || type > 3) {
throw new CorruptedFrameException("非法消息类型");
}
// 5. 读取正文长度:4 字节,不移动 readerIndex
int bodyLength = in.getInt(readerIndex + 4);
// 6. 校验正文长度,避免异常长度导致内存问题
if (bodyLength < 0 || bodyLength > MAX_BODY_LENGTH) {
throw new CorruptedFrameException("非法正文长度:" + bodyLength);
}
// 7. 判断整条消息是否收完整
if (in.readableBytes() < HEADER_LENGTH + bodyLength) {
return;
}
// 8. 消息完整,跳过 8 字节消息头
in.skipBytes(HEADER_LENGTH);
// 9. 将正文复制到 byte[]
byte[] body = new byte[bodyLength];
in.readBytes(body);
// 10. 输出 Java 消息对象,传递给下一个入站 Handler
out.add(new ProtocolMessage(version, type, body));
}
}Decoder 中的关键方法
readerIndex():获取当前读取指针的位置。getUnsignedShort(index):从指定索引读取 2
字节无符号整数,不移动读取指针。getUnsignedByte(index):从指定索引读取 1
字节无符号整数,不移动读取指针。getInt(index):从指定索引读取 4 字节整数,不移动读取指针。readableBytes():获取当前可读取的字节数。skipBytes(n):跳过 n 个字节,并移动读取指针。readBytes(byte[]):读取指定数量的字节到数组中,并移动读取指针。out.add(message):将解码后的对象交给 Pipeline 中下一个入站
Handler。
Decoder 的处理顺序
收到 ByteBuf
↓
判断消息头是否完整
↓
校验魔数、版本号、消息类型
↓
读取正文长度并校验
↓
判断正文是否完整
↓
跳过消息头,读取正文
↓
封装 ProtocolMessage
↓
out.add() 传给下一个 Handler如果消息不完整,decode() 返回即可。ByteToMessageDecoder
会保留未处理的数据,等待后续数据到达后继续解码。
本例将正文复制到 byte[],因此输出的 ProtocolMessage 不持有引用计数
ByteBuf,不需要额外释放正文对象。
六、自定义 Encoder:Java 对象转 ByteBuf
继承 MessageToByteEncoder,将 ProtocolMessage 按照协议格式写入ByteBuf。
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;
public class ProtocolEncoder extends MessageToByteEncoder<ProtocolMessage> {
private static final int MAGIC = 0xCAFE;
private static final int MAX_BODY_LENGTH = 1024 * 1024;
@Override
protected void encode(
ChannelHandlerContext ctx,
ProtocolMessage msg,
ByteBuf out) {
byte[] body = msg.getBody();
if (body == null || body.length > MAX_BODY_LENGTH) {
throw new IllegalArgumentException("非法消息正文长度");
}
if (msg.getVersion() != 1 || msg.getType() < 1 || msg.getType() > 3) {
throw new IllegalArgumentException("非法协议版本或消息类型");
}
// 1. 写入 2 字节魔数
out.writeShort(MAGIC);
// 2. 写入 1 字节版本号
out.writeByte(msg.getVersion());
// 3. 写入 1 字节消息类型
out.writeByte(msg.getType());
// 4. 写入 4 字节正文长度
out.writeInt(body.length);
// 5. 写入正文
out.writeBytes(body);
}
}Encoder 中的关键方法
writeShort(value):写入 2 字节整数。writeByte(value):写入 1 字节整数。writeInt(value):写入 4 字节整数。writeBytes(array):将字节数组写入 ByteBuf。
Netty 的 ByteBuf 默认使用大端字节序。本例 Decoder 和 Encoder
都按大端格式读写,通信双方必须保持一致。
七、Decoder 与 Encoder 放入 Pipeline
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.SimpleChannelInboundHandler;
import java.nio.charset.StandardCharsets;
public class TcpServerInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// 入站:ByteBuf 解码为 ProtocolMessage
pipeline.addLast(new ProtocolDecoder());
// 出站:ProtocolMessage 编码为 ByteBuf
pipeline.addLast(new ProtocolEncoder());
// 业务处理
pipeline.addLast(new SimpleChannelInboundHandler<ProtocolMessage>() {
@Override
protected void channelRead0(
ChannelHandlerContext ctx,
ProtocolMessage msg) {
String body = new String(
msg.getBody(),
StandardCharsets.UTF_8
);
System.out.println("版本号:" + msg.getVersion());
System.out.println("消息类型:" + msg.getType());
System.out.println("消息正文:" + body);
String responseText = "服务端收到:" + body;
ProtocolMessage response = new ProtocolMessage(
1,
3,
responseText.getBytes(StandardCharsets.UTF_8)
);
// 触发出站编码并发送
ctx.writeAndFlush(response);
}
});
}
}Pipeline 的传播方向
入站方向:Head → Tail。
客户端字节流
↓
ProtocolDecoder
↓
ProtocolMessage
↓
业务 Handler出站方向:Tail → Head。
业务 Handler 调用 writeAndFlush()
↓
ProtocolEncoder
↓
ByteBuf
↓
发送给客户端说明:
Decoder 负责入站解码:
ByteBuf → ProtocolMessage。Encoder 负责出站编码:
ProtocolMessage → ByteBuf。ctx.writeAndFlush()从当前 Handler 所在位置向前寻找出站 Handler。channel.writeAndFlush()通常从 Pipeline 尾部开始向前传播。
八、生产环境注意事项
限制消息长度:校验正文长度,避免恶意长度导致内存占用过大。
校验魔数:魔数不匹配时,不能继续按当前协议解析。
版本兼容:协议升级时,需要明确新旧版本的兼容策略。
消息类型校验:未知类型不能直接进入业务逻辑。
统一字节序:客户端和服务端必须统一使用大端或小端。
异常处理:在
exceptionCaught()
中记录协议异常,并根据策略关闭连接。身份认证:魔数不是安全认证,不能代替设备身份验证;敏感通信应考虑
TLS。对象生命周期:本例使用
byte[]保存正文;如果改为引用计数
ByteBuf,需要明确由谁负责释放。
异常处理示例:
@Override
public void exceptionCaught(
ChannelHandlerContext ctx,
Throwable cause) {
log.error("TCP 协议解析异常", cause);
ctx.close();
}九、常见问题与答案
1. 为什么需要魔数?
用于识别当前协议,避免把其他格式的数据误认为合法消息。它不是身份认证机制。
2. 为什么需要版本号?
用于标识协议版本,方便后续升级和兼容不同版本的客户端。
3. 为什么需要消息类型?
用于区分登录、心跳、业务数据等消息,让服务端选择不同的业务处理逻辑。
4. 为什么消息头需要长度字段?
TCP
不保留应用层消息边界。长度字段可以帮助解码器判断一条完整消息的结束位置。
5. 为什么自定义 Decoder 继承 ByteToMessageDecoder?
它已经提供数据累积和循环解码机制,开发者只需要实现具体协议的解析逻辑。
6. MessageToByteEncoder 有什么作用?
它是出站编码 Handler,负责将 Java 消息对象编码成 ByteBuf。
7. Decoder 和 Encoder 有什么区别?
Decoder:字节数据转换为 Java 消息对象。
Encoder:Java 消息对象转换为字节数据。
8. 什么时候使用 LengthFieldBasedFrameDecoder,什么时候自定义 Decoder?
只需要按长度字段拆分消息时,优先使用LengthFieldBasedFrameDecoder。如果还需要校验魔数、版本、消息类型,并转换为业务对象,可以使用自定义ByteToMessageDecoder。
十、重点总结
自定义 TCP 协议用于统一约定通信消息格式。
魔数用于识别协议,版本号用于协议升级,消息类型用于区分业务,长度字段用于确定正文边界。
ProtocolMessage用于封装解码后的业务消息。ByteToMessageDecoder负责入站解码:ByteBuf → Java 对象。MessageToByteEncoder负责出站编码:Java 对象 → ByteBuf。入站传播方向是 Head → Tail,出站传播方向是 Tail → Head。
必须校验消息长度、版本号和消息类型,并统一字节序。
简单长度协议优先使用 Netty 内置解码器;复杂协议可以自定义 Decoder。
