Netty实战 IM即时通讯系统(十二)构建客户端与服务端pipeline
零、 目录
- IM系统简介
- Netty 简介
- Netty 环境配置
- 服务端启动流程
- 客户端启动流程
- 实战: 客户端和服务端双向通信
- 数据传输载体ByteBuf介绍
- 客户端与服务端通信协议编解码
- 实现客户端登录
- 实现客户端与服务端收发消息
- pipeline与channelHandler
- 构建客户端与服务端pipeline
- 拆包粘包理论与解决方案
- channelHandler的生命周期
- 使用channelHandler的热插拔实现客户端身份校验
- 客户端互聊原理与实现
- 群聊的发起与通知
- 群聊的成员管理(加入与退出,获取成员列表)
- 群聊消息的收发及Netty性能优化
- 心跳与空闲检测
- 总结
- 扩展
一、 ChannelInboundHandlerAdapter 与 ChannelOutboundHandlerAdapter
-
首先是ChannelInboundHandlerAdapter , 这个适配器只用用于实现其接口ChannelInboundHandler 的所有方法,这样我们在编写自己的handler时就不需要实现handler里的每一个方法,而只需要实现我们关心的方法 , 默认情况下 , 对于ChannelInboundHandlerAdapter , 我们比较关心的是他的channelRead()
ChannelInboundHandlerAdapter.java @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ctx.fireChannelRead(msg); }
- 他的作用就是接受上一个handler的输出 , 这里的msg 就是上一个handler的输出 。 大家也可以看出 , 默认情况下adapter会通过fireChannelRead() 方法直接把本handler的处理结果传递给下一个handler 。
-
与ChannelinboundHandlerAdapter相似的是ChannelOutboundHandlerAdapter , 他的核心方法是 write() 方法
ChannelOutboundHandlerAdapter.java @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { ctx.write(msg, promise); }
- 默认情况下 , 这个adapter也会把对象传递给下一个outbound节点 , 需要注意的是他的传播顺序与inbound相反 。
-
我们往pipeline中添加的第一个handler中的channelRead方法中 , msg对象其实就是ByteBuf , 服务端在接收数据之后 , 应该首先把这个ByteBuf解码 , 然后把解码之后的结果传递给下一个handler :
@Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf requestByteBuf = (ByteBuf) msg; // 解码 Packet packet = PacketCodeC.INSTANCE.decode(requestByteBuf); // 解码后的对象传递到下一个 handler 处理 ctx.fireChannelRead(packet) }
-
在开始解码之前我们来了解另外一个特殊的handler
二 、 ByteToMessageDecoder
-
通常情况下 , 无论是我们在客户端还是在服务端 , 当我们接收到数据之后 , 首先要做的就是事情就是把二进制数据转换成我们所需要的java 对象 , 所以Netty 很贴心的帮我们写了一个父类 ,来专门做这个事情 , 我们来看一下如何使用这个类来实现服务端二进制数据解码:
public class PacketDecoder extends ByteToMessageDecoder { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { out.add(PacketCodeC.INSTANCE.decode(in)); } }
- 我们继承了 ByteToMessageDecoder 这个类之后 , 我们只需要实现decode()方法 , 这里的in在传递进来的时候就已经是ByteBuf 对象了 , 所以不再需要我们进行强转 , 第三个参数是List类型 , 我们通过往这个List里添加解码之后的结果对象 , 就可以自动实现结果往下一个handler进行传递 , 这样我们就实现了解码逻辑的handler
- 值的注意的一点是 , 对于Netty 里面的ByteBuf , 我们使用的4.1.6Final版本 , 默认情况下用的是堆外内存 , 在ByteBuf这一小节中 , 我们提到 , 堆外内存需要我们手动释放 , 在我们前面的小节的解码例子中 , 其实我们已经漏掉了这个操作 , 这一点是非常致命的 , 随着程序运行越来越久 , 内存泄露的问题就慢慢暴露出来了 , 而这里我们使用 ByteToMessageDecoder , Netty会自动进行内存释放 , 我们不用操心太多内存管理方面的逻辑 , 关于如何自动释放 , 可以参考 ByteToMessageDecoder的实现原理(8-2)。
-
当我们通过解码器把二进制数据转换到java 对象即指令数据包之后 , 就可以针对每一种指令数据包编写逻辑了 。
三 、 SimpleChannelInboundHandler
-
回顾一下我们之前处理java 对象的逻辑
if (packet instanceof LoginRequestPacket) { // ... } else if (packet instanceof MessageRequestPacket) { // ... } else if ...
-
我们通过if - else 来进行逻辑处理 , 当我们需要处理的指令越来越多的时候 , 代码就会显得越来越臃肿 ,这个时候我们可以通过给pipeline 添加多个handler(集成 ChannelInboundHandlerAdapter) 来解决多if-else 的问题
XXXHandler.java if (packet instanceof XXXPacket) { // ...处理 } else { ctx.fireChannelRead(packet); }
- 这样写的好处是 每次添加一个指令处理器 , 逻辑处理的框架都是一致的
- 但是大家也注意到了 , 这里我们编写指令处理器handler的时候 , 依然编写了一段我们其实不用关心的if-else 判断 , 然后还需要手动传递无法处理的指令对象至下一个指令处理器 , 这也是一段重复度极高的代码 , 因此Netty基于这种考虑抽象出了一个SimpleChannelInboundHandler对象 , 类型判断和对象传递都自动帮我们实现了 , 我们可以只关注我们需要处理的对象即可
-
接下来我们看一看 , 如何使用 SimpleChannelInboundHandler 简化我们的指令处理逻辑
LoginRequestHandler.java public class LoginRequestHandler extends SimpleChannelInboundHandler<LoginRequestPacket> { @Override protected void channelRead0(ChannelHandlerContext ctx, LoginRequestPacket loginRequestPacket) { // 登录逻辑 } }
- SimpleChannleInboundHandler 从字面意思可以看出 , 使用它非常简单 , 我们在集成这个类的时候 , 给他传递一个泛型 , 然后在 ChannelRead0()方法里面 , 我们不在用通过if-else来判断当前对象是否是本handler可以处理的对象 , 也不用强转 , 不用往下传递本handler处理不了的对象 , 这一切都已经交给父类SimpleChannelHandler来实现了 , 我们只需要专注于我们要处理的业务逻辑即可。
四 、 MessageToByteEncoder
-
在前面的几个小节 , 我们已经实现了登录和消息处理逻辑 , 处理完请求之后 , 我们都会给客户端一个响应 , 在写响应之前 , 我们需要把响应对象编码成ByteBuf , 结合本小节的内容 , 最后的逻辑框架如下:
public class LoginRequestHandler extends SimpleChannelInboundHandler<LoginRequestPacket> { @Override protected void channelRead0(ChannelHandlerContext ctx, LoginRequestPacket loginRequestPacket) { LoginResponsePacket loginResponsePacket = login(loginRequestPacket); ByteBuf responseByteBuf = PacketCodeC.INSTANCE.encode(ctx.alloc(), loginResponsePacket); ctx.channel().writeAndFlush(responseByteBuf); } } public class MessageRequestHandler extends SimpleChannelInboundHandler<MessageRequestPacket> { @Override protected void channelRead0(ChannelHandlerContext ctx, MessageRequestPacket messageRequestPacket) { MessageResponsePacket messageResponsePacket = receiveMessage(messageRequestPacket); ByteBuf responseByteBuf = PacketCodeC.INSTANCE.encode(ctx.alloc(), messageRequestPacket); ctx.channel().writeAndFlush(responseByteBuf); } }
-
我们注意到 , 我们处理完每一种指令之后的逻辑都是相似的 , 都需要进行解码 , 然后调用 writeAndFlush() 将数据写到对端 , 这个编码的过程其实也是重复的逻辑 , 而且在编码的过程中国 , 我们还需要手动去创建一个ByteBuf :
PacketCodeC.java public ByteBuf encode(ByteBufAllocator byteBufAllocator, Packet packet) { // 1. 创建 ByteBuf 对象 ByteBuf byteBuf = byteBufAllocator.ioBuffer(); // 2. 序列化 java 对象 // 3. 实际编码过程 return byteBuf; }
-
而Netty 提供了一个特殊的channelHandler 来专门处理这种逻辑 , 我们不需要每一次将响应写到对端的时候调用一次编码逻辑进行编码 , 也不需要自行创建ByteBuf , 这个类叫做 MessageToByteEncoder , 从字面意思可以看出 , 他的功能就是将对象转化到二进制数据 。
-
我们来看一下如何使用 MessageToByteEncoder 来实现编码逻辑
public class PacketEncoder extends MessageToByteEncoder<Packet> { @Override protected void encode(ChannelHandlerContext ctx, Packet packet, ByteBuf out) { PacketCodeC.INSTANCE.encode(out, packet); } }
-
PacketEncoder 集成自 MessageToByteEncoder , 泛型参数 Packet 表示这个类的作用是 将Packet 类型对象到二进制的转化 。
-
这里我们只需要实现encode() 方法 , 我们注意到 , 这个方法的第二个参数是java对象 , 而第三个参数是ByteBuf 对象 , 我们在这个方法里面要做的事情就是把java对象里面的字段写到ByteBuf , 我们不在需要自行去分配ByteBuf , 因此大家注意到 , PacketCodeC 的 encode() 方法 的定义也改了 , 下面是更改前后的对比:
PacketCodeC.java // 更改前的定义 public ByteBuf encode(ByteBufAllocator byteBufAllocator, Packet packet) { // 1. 创建 ByteBuf 对象 ByteBuf byteBuf = byteBufAllocator.ioBuffer(); // 2. 序列化 java 对象 // 3. 实际编码过程 return byteBuf; } // 更改后的定义 public void encode(ByteBuf byteBuf, Packet packet) { // 1. 序列化 java 对象 // 2. 实际编码过程 }
-
-
我们可以看到 , PacketCodeC 不在需要手动创建ByteBuf对象 , 不在需要把创建完ByteBuf 的进行返回 , 当我们向pipeline 中添加了这个编码器之后 , 我们在指令处理完毕之后就只需要writeAndFlush java 对象即可
public class LoginRequestHandler extends SimpleChannelInboundHandler<LoginRequestPacket> { @Override protected void channelRead0(ChannelHandlerContext ctx, LoginRequestPacket loginRequestPacket) { ctx.channel().writeAndFlush(login(loginRequestPacket)); } } public class MessageRequestHandler extends SimpleChannelInboundHandler<MessageResponsePacket> { @Override protected void channelRead0(ChannelHandlerContext ctx, MessageResponsePacket messageRequestPacket) { ctx.channel().writeAndFlush(receiveMessage(messageRequestPacket)); } }
-
通过我们前面的分析 , 可以看到 , Netty 为了让我们逻辑更加清晰简洁 , 帮我们做了很多工作 吗能直接用Netty 自带的handler 来解决问题 , 不要重复制造轮子 , 在下面的小节中 , 我们会继续探讨Netty还有哪些开箱即用的handler
-
分析完服务端的pipeline 与 handler 组成结构 , 相信你们也不难自行分析出客户端handler 的结构了 , 最后我们来看一下服务端和客户端完整的pipeline 与handler结构
五、 构建服务端和客户端 pipeline 与handler
-
对应我们的代码
服务端 serverBootstrap .childHandler(new ChannelInitializer<NioSocketChannel>() { protected void initChannel(NioSocketChannel ch) { ch.pipeline().addLast(new PacketDecoder()); ch.pipeline().addLast(new LoginRequestHandler()); ch.pipeline().addLast(new MessageRequestHandler()); ch.pipeline().addLast(new PacketEncoder()); } }); 客户端 bootstrap .handler(new ChannelInitializer<SocketChannel>() { @Override public void initChannel(SocketChannel ch) { ch.pipeline().addLast(new PacketDecoder()); ch.pipeline().addLast(new LoginResponseHandler()); ch.pipeline().addLast(new MessageResponseHandler()); ch.pipeline().addLast(new PacketEncoder()); } });
六 、 完整代码
七 、 总结
- 在本小节中我们通过学习Netty 内置的channelHandler 来逐步构建我们服务端的pipeline , 通过内置的channelHandler 可以减少很多重复的逻辑
- 基于 ByteToMessageDecoder , 我们可以实现自定义解码 , 而不用关心ByteBuf 的强转和解码结果的传递。
- 基于 SimpleChannelInboundHandler , 我们可以实现每一种指令的处理 , 不在需要强转 , 不再需要冗长的if-else , 不在需要手动传递对象
- 基于 MessageToByteEncoder , 我们可以实现自定义编码 , 而不用关心ByteBuf 的创建 , 不用每次向对端写数据时都调用编码逻辑
八 、 思考
- 在 LoginRequestHandler 以及 MessageRequestHandler 的 channelRead0() 方法中,第二个参数对象(XXXRequestPacket)是从哪里传递过来的?
-
答: channelRead0(ChannelHandlerContext ctx, MessageRequestPacket msg) 的msg是由父类SimpleChannelInboundHandler的channelRead() 方法判断是需要类型后, 强转类型后传递进来的
I imsg = (I) msg; channelRead0(ctx, imsg);
-
- 如何判断 ByteBuf 是使用堆外内存?
- 答: ByteBuf.isDirect()
- pipeline添加decode和encode的时候,有严格的顺序限制吧?
- 答: ouBound类和inBound类之间的顺序一般没有限制,通常情况下,同一种类型的 handler 的添加顺序需要注意