3
0
0

Netty:自定义 TCP 通信协议设计与编解码实现

2026-10-31
2026-10-31
文章摘要
|

一、为什么需要自定义 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 6F
  • CA 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 尾部开始向前传播。

八、生产环境注意事项

  1. 限制消息长度:校验正文长度,避免恶意长度导致内存占用过大。

  2. 校验魔数:魔数不匹配时,不能继续按当前协议解析。

  3. 版本兼容:协议升级时,需要明确新旧版本的兼容策略。

  4. 消息类型校验:未知类型不能直接进入业务逻辑。

  5. 统一字节序:客户端和服务端必须统一使用大端或小端。

  6. 异常处理:在 exceptionCaught()
    中记录协议异常,并根据策略关闭连接。

  7. 身份认证:魔数不是安全认证,不能代替设备身份验证;敏感通信应考虑
    TLS。

  8. 对象生命周期:本例使用 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。

十、重点总结

  1. 自定义 TCP 协议用于统一约定通信消息格式。

  2. 魔数用于识别协议,版本号用于协议升级,消息类型用于区分业务,长度字段用于确定正文边界。

  3. ProtocolMessage 用于封装解码后的业务消息。

  4. ByteToMessageDecoder 负责入站解码:ByteBuf → Java 对象。

  5. MessageToByteEncoder 负责出站编码:Java 对象 → ByteBuf。

  6. 入站传播方向是 Head → Tail,出站传播方向是 Tail → Head。

  7. 必须校验消息长度、版本号和消息类型,并统一字节序。

  8. 简单长度协议优先使用 Netty 内置解码器;复杂协议可以自定义 Decoder。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论