Netty 学习(六)实现自定义协议通信

前言

为了满足自己业务场景的需要, 应用层之间通信需要实现各种各样的网络协议。本文记录如何设计一个高效、可扩展、易维护的自定义通信协议,以及如何使用 Netty 实现自定义的通信协议。


一、通信协议设计

所谓的协议,就是通信双方事先商量好的接口“暗语”, 在 TCP 网络编程中,发送方和接收方的数据包格式都是二进制,发送方将对象转化成二进制流发送给接收方,接收方获得二进制数据后需要知道如何解析对象,所以协议是双方能够正常通行的基础。

通用协议

市面上已经有不小通用的协议,例如 HTTP、 HTTPS、JSON-RPC、FTP、IMAP、Protobuf等。通用协议兼容性好,易于维护,各种异构系统间可以实现无缝对接等。如果满足业务场景及性能需求的前提下,推荐采用通用协议的方案。

自定义协议

在特定的场景下,需要自定义自有协议。自定义协议有以下的优点:

  • 极致性能:通用协议考虑很多兼容性的因素,必然在性能有所损失。
  • 扩展性:自定义的协议相比通用协议更好扩展,可以更好地满足自己的业务需求。
  • 安全性:通用协议是公开的,可能存在很多漏洞。自定义协议通常是私有的,黑客需要先破解协议内容,才能攻破漏洞。

网络协议需要具备的要素

一个较为通用的协议示例:

/*
+---------------------------------------------------------------+
| 魔数 2byte | 协议版本号 1byte | 序列化算法 1byte | 报文类型 1byte  |
+---------------------------------------------------------------+
| 状态 1byte |        保留字段 4byte     |      数据长度 4byte     | 
+---------------------------------------------------------------+
|                   数据内容 (长度不定)          | 校验字段 2byte |
+---------------------------------------------------------------+
*/

1. 魔数

魔数是通信双方协商的一个暗号,通常采用固定的几个字节表示。魔数的作用是用于服务端在接收数据时先解析出前几个固定字节做正确性对比。如果和协议中的魔数不匹配,则认为是非法数据,可以直接关闭连接或采取其他措施增强系统安全性。魔数的思想在很多场景中都有体现,如 Java Class 文件开头就存储了魔数 OxCAFEBABE,在 JVM 加载 Class 文件时首先就会验证魔数对的正确性。

2. 协议版本号

为了应对业务需求的变化,可能需要对自定义协议的结构或字段进行改动。不同版本的协议对应的解析方法也是不同的。所以在生产级项目中强烈建议预留协议版本这个字段。

3. 序列化算法

序列化算法字段表示发送方将对象转换成二进制流,以及接收方将接收的二进制流转换成对象的方法,如 JSON、 Hessian、Java 自带序列化等。

4. 报文类型

报文类型用于描述业务场景中存在的不同报文类型。如 RPC 框架中有请求、响应、心跳类型。IM 通讯场景中有登陆、创建群聊、发送消息、接收消息、退出群聊等类型。

5. 长度域字段

长度域字段代表请求数据的长度,可以定义整个报文的长度,也可以是请求数据部分的长度。

6. 请求数据

请求数据通常为的业务对象信息序列化后的二进制流。是整个报文的主体。

7. 状态

状态字段用于标识请求是否正常,一般由被调用方设置。例如一次 RPC 调用失败,状态字段可被服务提供方设置为异常状态。

8. 校验字段

校验字段存放某种校验算法计算报文校验码,校验码用于验证报文的正确性。

9. 保留字段

保留字段是可选项,为了应对协议升级的可能性,可以预留若干字节的保留字段,以备不时之需。


二、Netty 实现自定义通信协议

Netty 作为一个非常优秀的网络通信框架,提供了非常丰富的编解码抽象基类来实现自定义协议。

Netty 中编解码器分类

  • 编码解码分类:
类型 编解码基类 说明
常用编码器类型 MessageToByteEncoder 对象编码成字节流
MessageToMessageEncoder 一种消息类型编码成另外一种消息类型
常用解码器类型 ByteToMessageDecoder/ReplayingDecoder 将字节流解码为消息对象
MessageToMessageDecoder 将一种消息类型解码为另外一种消息类型
  • 分层解码分类:
    一次解码:一次解码用于解决 TCP 拆包/粘包问题,按协议解析得到的字节数据。常用一次编解码器:MessageToByteEncoder / ByteToMessageDecoder。
    二次解码:对一次解析后的字节数据做对象模型的转换,这时候需要二次解码器,同理编码器的过程是反过来的。常用二次编解码器:MessageToMessageEncoder / MessageToMessageDecoder。

三、抽象编码类

在这里插入图片描述通过抽象编码类的继承图可以看出,编码类是 ChanneOutboundHandler 的抽象类实现,具体操作的是 Outbound 出站数据。

MessageToByteEncoder

MessageToByteEncoder 用于将对象编码成字节流,只需要实现其 encode 方法即可完成自定义编码。

MessageToByteEncoder 的核心源码片段,如下所示。

	@Override
	public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
	
	    ByteBuf buf = null;
	
	    try {
	        if (acceptOutboundMessage(msg)) { // 1. 消息类型是否匹配
	            @SuppressWarnings("unchecked")
	            I cast = (I) msg;
	
	            buf = allocateBuffer(ctx, cast, preferDirect); // 2. 分配 ByteBuf 资源
	
	            try {
	                encode(ctx, cast, buf); // 3. 执行 encode 方法完成数据编码
	            } finally {
	                ReferenceCountUtil.release(cast);
	            }
	
	            if (buf.isReadable()) {
	                ctx.write(buf, promise); // 4. 向后传递写事件
	            } else {
	                buf.release();
	                ctx.write(Unpooled.EMPTY_BUFFER, promise);
	            }
	            buf = null;
	        } else {
	            ctx.write(msg, promise);
	        }
	    } catch (EncoderException e) {
	        throw e;
	    } catch (Throwable e) {
	        throw new EncoderException(e);
	    } finally {
	        if (buf != null) {
	            buf.release();
	        }
	    }
	}

MessageToByteEncoder 重写了 ChanneOutboundHandler 的 write() 方法,其主要逻辑分为以下几个步骤:

  • acceptOutboundMessage 判断是否有匹配的消息类型,如果匹配需要执行编码流程,如果不匹配直接继续传递给下一个 ChannelOutboundHandler;

  • 分配 ByteBuf 资源,默认使用堆外内存;

  • 调用子类实现的 encode 方法完成数据编码,一旦消息被成功编码,会通过调用 ReferenceCountUtil.release(cast) 自动释放;

  • 如果 ByteBuf 可读,说明已经成功编码得到数据,然后写入 ChannelHandlerContext 交到下一个节点;如果 ByteBuf 不可读,则释放 ByteBuf 资源,向下传递空的 ByteBuf 对象。

编码器实现非常简单,不需要关注拆包/粘包问题。如下例子,展示了如何将字符串类型的数据写入到 ByteBuf 实例,ByteBuf 实例将传递给 ChannelPipeline 链表中的下一个 ChannelOutboundHandler。

    public class StringToByteEncoder extends MessageToByteEncoder<String> {
            @Override
            protected void encode(ChannelHandlerContext channelHandlerContext, String data, ByteBuf byteBuf) throws Exception {
                byteBuf.writeBytes(data.getBytes());
            }
    }

MessageToMessageEncoder

MessageToMessageEncoder 是将一种格式的消息转换为另一种格式的消息,它的子类同样只需要实现 encode 方法。MessageToMessageEncoder 常用的实现子类有 StringEncoder、LineEncoder、Base64Encoder 等。
StringEncoder 可以直接实现 String 类型数据的编码。源码示例如下:

    @Override
    protected void encode(ChannelHandlerContext ctx, CharSequence msg, List<Object> out) throws Exception {
        if (msg.length() == 0) {
            return;
        }
        out.add(ByteBufUtil.encodeString(ctx.alloc(), CharBuffer.wrap(msg), charset));
    }

四、抽象解码类

在这里插入图片描述解码类是 ChanneInboundHandler 的抽象类实现,操作的是 Inbound 入站数据。解码器的主要难度在于拆包和粘包问题,由于接收方可能没有接受到完整的消息,所以编码框架还要对入站数据做缓冲处理,直到获取到完整的消息。

ByteToMessageDecoder

ByteToMessageDecoder 类将字节流转换成对象,其定义的抽象 decode 方法:

    public abstract class ByteToMessageDecoder extends ChannelInboundHandlerAdapter {
        protected abstract void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception;
        protected void decodeLast(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
            if (in.isReadable()) {
                decodeRemovalReentryProtection(ctx, in, out);
            }
        }
    }

encode 方法在调用时需要传入接收的数据 ByteBuf,及用来添加编码后对象的 List。处理过程如下:

  • 由于 TCP 粘包问题,ByteBuf 中可能包含多个有效的报文,或者不够一个完整的报文,所以 Netty 会重复回调 decode 方法
  • 将解码后的对象添加到 List,直到没有更多可以读取的数据为止。
  • List 的内容会传递给 ChannelPipeline 中的下一个 ChannelInboundHandler。
    decodeLast 方法在 Channel 关闭后会被调用一次,用于处理 ByteBuf 最后剩余的字节数据。Netty 中 decodeLast 的默认实现只是简单的调用了 decode 方法,如果有特殊的需求,可以通过重写 decodeLast 方法来扩展自定义逻辑。

MessageToMessageDecoder

MessageToMessageDecoder 是将一种消息类型的编码成另外一种消息类型。MessageToMessageDecoder 不对数据报文继续缓存,其主要用作转换消息模型。

解码过程

在这里插入图片描述

  • 使用 ByteToMessageDecoder 解析 TCP 协议,解决拆包/粘包问题。解析得到有效 ByteBuf 数据
  • 使用 MessageToMessageDecoder 做数据对象的转换。

五、通信协议实战

	/*
	+---------------------------------------------------------------+
	| 魔数 2byte | 协议版本号 1byte | 序列化算法 1byte | 报文类型 1byte  |
	+---------------------------------------------------------------+
	| 状态 1byte |        保留字段 4byte     |      数据长度 4byte     | 
	+---------------------------------------------------------------+
	|                   数据内容 (长度不定)                          |
	+---------------------------------------------------------------+
	 */

对以上的自定义报文,协议头部包含了魔数、协议版本号、数据长度等固定字段。而 ByteBuf 是否完整,需要通过消息长度 dataLength 字段来判断。自定义编码器需要重写 ByteToMessageDecoder 的 encode 方法,具体代码如下所示:

	@Override
	public final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
	    // 判断 ByteBuf 可读取字节
	    if (in.readableBytes() < 14) { 
	        return;
	    }
	    in.markReaderIndex(); // 标记 ByteBuf 读指针位置
	    in.skipBytes(2); // 跳过魔数
	    in.skipBytes(1); // 跳过协议版本号
	    byte serializeType = in.readByte();
	    in.skipBytes(1); // 跳过报文类型
	    in.skipBytes(1); // 跳过状态字段
	    in.skipBytes(4); // 跳过保留字段
	    int dataLength = in.readInt();
	    if (in.readableBytes() < dataLength) {
	        in.resetReaderIndex(); // 重置 ByteBuf 读指针位置
	        return;
	    }
	    byte[] data = new byte[dataLength];
	    in.readBytes(data);
	    SerializeService serializeService = getSerializeServiceByType(serializeType);
	    Object obj = serializeService.deserialize(data);
	    if (obj != null) {
	        out.add(obj);
	    }
	}

总结

本文学习了协议设计的基本要素,以及如何使用 Netty 编解码器实现自定义协议。最后通过基于 Netty 抽象类实现自定义的编解码器,实战具体示例协议,加深对编解码器的理解。

Netty Quic是一种基于QUIC(Quick UDP Internet Connections)协议的高性能、低延迟的网络通信框架,它是由Netty项目集成支持的。QUIC是一种由Google设计并维护的下一代传输层协议,旨在改进TCP/IP协议栈的性能,特别是在移动网络环境下。 在Netty实现Quic服务器和客户端通信,可以按照以下步骤操作: 1. **添加依赖**:在你的项目中引入Netty的Quic模块,例如如果你使用Maven,需要添加`io.netty:netty-quic`依赖。 2. **创建QuicServer**:服务器端需要创建一个`QuicServerBootstrap`实例,并配置监听地址和处理器链路。 ```java QuicServerBootstrap bootstrap = new QuicServerBootstrap(); bootstrap.group(...); // 设置事件组 bootstrap.channel(NioQuicServerChannel.class); bootstrap.childOption(ChannelOption.AUTO_READ, false); // 配置选项 bootstrap.childHandler(new MyQuicServerHandler()); // 自定义处理程序 bootstrap.bind(...); // 绑定端口 ``` 3. **创建QuicClient**:客户端则需要创建一个`QuicClientBootstrap`实例,设置目标地址,并连接到服务器。 ```java QuicClientBootstrap clientBootstrap = new QuicClientBootstrap(); clientBootstrap.group(...); // 设置事件组 clientBootstrap.channel(NioQuicClientChannel.class); clientBootstrap.handler(new MyQuicClientHandler()); QuicOptions options = new QuicOptions(); // 设置连接选项 options.maxIdleTimeMillis(5000); // 设置最大空闲时间 clientBootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, options.connectTimeoutMillis); clientBootstrap.connect(...); // 连接服务器地址 ``` 4. **自定义Handler**:`MyQuicServerHandler`和`MyQuicClientHandler`是你自己实现的,用于处理数据收发、连接管理和错误处理等任务。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值