本篇将讲述如何书写简单的Netty服务端和客户端。
1. 编写服务端
所有的Netty服务器都需要以下两部分:
- 至少一个ChannelHandler,该组件实现了服务器对从客户端接收的数据的处理,即它的业务逻辑。
- 引导,配置服务器的启动代码。至少,它会将服务器绑定到它要监听连接请求的端口上。
1.1 ChannelHandler和业务逻辑
ChannelHandler是一个接口族的父接口,它负责接收并响应事件通知。在netty应用程序中,所有的数据处理逻辑都包含在这些核心抽象的实现中。
服务器要响应传入的消息,需要实现ChannelInboundHandler接口,用来定义响应入站事件的方法。一般继承ChannelInboundHandlerAdapter类就足够了,它提供了ChannelInboundHandler的默认实现。
接口中比较重要的方法有:
- ChannelRead(),对于每个传入的消息都要调用。
- ChannelReadComplete(),通知 ChannelInboundHandler最后一次对ChannelRead()的调用是当前批量读取中的最后一条消息。
- exceptionCaught(),在读取操作期间,有异常抛出时会调用。
对应的服务端Handler代码如下:
// 标识一个ChannelHandler可以被多个Channel安全地共享
@ChannelHandler.Sharable
public class EasyNettyServerHandler extends ChannelInboundHandlerAdapter {@Overridepublic void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {ByteBuf input=(ByteBuf) msg;// 打印来自客户端的信息System.out.println("服务端接受请求:" +input.toString(StandardCharsets.UTF_8));// 将接收到的消息写给发送者,而不冲刷出站消息ctx.write(input);}@Overridepublic void channelReadComplete(ChannelHandlerContext ctx) throws Exception {// 将未决消息冲刷到远程节点,并关闭该Channelctx.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);}@Overridepublic void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {// 打印异常堆栈,正式项目建议用logger组件打印cause.printStackTrace();// 关闭该channel,关闭连接ctx.close();}
}
如果不捕获异常,会发生什么?
每个channel都拥有一个与之相邻的ChannelPipeline,其持有一个ChannelHandler的实例链。在默认情况下,ChannelHandler会把对它的方法的调用转发给链中的下一个ChannelHandler。如果exceptionCaught()方法没有被链中的某处实现,那么所接收的异常将被传递到ChannelPipeline的尾端并被记录。因此,我们的应用程序中应该提供至少一个实现了exceptionCaught()方法的ChannelHandler.
除了ChannelInboundHandlerAdapter外,还有很多需要学习的ChannelHandler的子类型和实现。当前我们需要记住以下关键点:
- 针对不同类型的事件来调用ChannelHandler;
- 应用程序通过实现或者扩展ChannelHandler来挂钩到事件的生命周期,并提供自定义的应用程序逻辑;
- 在架构上,ChannelHandler有助于保持业务逻辑与网络处理代码的分离,简化了开发过程。
1.2 引导服务器
EasyNettyServerHandler 实现核心业务逻辑后,就可以初始化服务器了,具体步骤如下:
- 绑定到服务器将在其监听并接受传入连接请求的端口;
- 配置Channel,以将有关的入站消息通知给EasyNettyServerHandler 实例。
传输
在网络协议中的标准多层视图中,传输层提供了端到端的或者主机到主机的通信服务。
因特网通信建立在TCP传输之上。除了一些由Java NIO实现提供的服务器端性能增强外,NIO传输大多数时候指的就是TCP传输。
服务端实例代码如下:
@Slf4j
public class EasyNettyServer {public static final int PORT = 4700;public static void main(String[] args) throws Exception {new EasyNettyServer().start();}public void start() throws Exception {final EasyNettyServerHandler serverHandler = new EasyNettyServerHandler();// 创建EventLoopEventLoopGroup group = new NioEventLoopGroup();try {// 创建ServerBootstarpServerBootstrap bootstrap = new ServerBootstrap();// 指定所使用的NIO传输Channel;使用指定的端口设置套接字地址;添加到一个Handler到子Channel的ChannelPipelinebootstrap.group(group).channel(NioSctpServerChannel.class).localAddress(new InetSocketAddress(PORT)).childHandler(new ChannelInitializer<SocketChannel>() {@Overrideprotected void initChannel(SocketChannel socketChannel) throws Exception {// EasyNettyServerHandler被标注为@Sharable,所以总能使用同样的实例socketChannel.pipeline().addLast(serverHandler);}});// 异步地绑定服务器;调用sync()方法阻塞等到直到绑定完成ChannelFuture future = bootstrap.bind().sync();// 获取Channel的CloseFuture,并阻塞当前线程直到它完成future.channel().closeFuture().sync();} catch (Exception e) {log.error("Init netty server fail ", e);} finally {// 关闭EventLoopGroup,释放所有的资源group.shutdownGracefully().sync();}}
}
下面是服务器的主要代码组件:
-
EasyNettyServerHandler实现了业务逻辑
- main()方法引导/初始化了服务器
引导过程所需要的步骤如下:
- 创建一个ServerBootstrap的实例以引导和绑定服务器
- 创建并分配一个NioEventLoopGroup实际以进行事件的处理,如接受新链接以及读、写数据
- 指定服务器绑定的本地InetSocketAddress
- 使用一个EasyNettyServerHandler的实例初始化一个新的Channel
- 调用ServerBootstrap.bind()方法绑定服务器
做完以上步骤,服务器已经初始化就绪,能被使用了。
2.编写客户端
客户端将会:
- 连接到服务器
- 发送一个或者多个消息
- 对于每个消息,等到并接受从服务器发回的相同的消息
- 关闭连接
编写客户端所涉及的两个主要代码部分也是业务逻辑和引导。
2.1 通过ChannelHandler实现客户端逻辑
在客户端将使用ChannelInboundHandler,扩展SimpleChannelInboundHandler类以处理所有必须得任务。一般需要重写以下方法:
- channelActive(),在到服务器的连接已经建立之后将调用
- channelRead0(),当从服务器接收到一条消息时被调用
- exceptionCaught(),在处理过程中引发异常时被调用
// 标记该类的实例可以被多个Channel共享
@ChannelHandler.Sharable
public class EasyNettyClientHandler extends SimpleChannelInboundHandler<ByteBuf> {@Overridepublic void channelActive(ChannelHandlerContext ctx) throws Exception {// 当被通知Channel是活跃的时候,发送一条消息ctx.writeAndFlush(Unpooled.copiedBuffer("Hello Netty!", CharsetUtil.UTF_8));}@Overrideprotected void channelRead0(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf) throws Exception {// 记录已接收消息的转储System.out.println("Client received: " + byteBuf.toString(CharsetUtil.UTF_8));}@Overridepublic void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {// 在发生异常时,记录错误并关闭channel连接cause.printStackTrace();ctx.close();}
}
重写channelRead0()方法,即每当接收数据时,都会调用该方法。需要注意的是,由服务器发送的消息可能会被分块接收。如服务器发送了5字节,那么不能保证这5字节会被一次性接收。即便是这么少的数据,channelRead0()方法也可能被调用两次,第一次使用一个持有3字节的BuyeBuf,第二次使用一个持有2字节的ByteBuf。作为一个面向流的协议,TCP保证了字节数组将会按照服务器发送顺序去接收。
SimpleChannelInboundHandler与ChannelInboundHandler
这里的客户端为啥使用SimpleChannelInboundHandler,而不是ChannelInboundHandler?
在客户端,当channelRead0()方法完成时,已经有了传入消息且处理完它了。当该方法返回时,SimpleChannelInboundHandler负责释放指向保存该消息的Bytebuf的内存引用。
在EasyNettyServerHandler中,还需要将传入消息回送给发送者,而write()操作是一部的,直到channelRead()方法返回后可能仍然没有完成。为此,EasyNettyServerHandler扩展了ChannelInboundHandlerAdapter,其在这个时间点上不会释放消息。
消息在EasyNettyServerHandler的channelReadComplete()方法中,当writeAndFluseh()方法被调用时释放。
2.2 引导客户端
相关代码如下:
public class EasyNettyClient {public static final String HOST = "127.0.0.1";public static final int PORT = 8009;public static void main(String[] args) throws Exception {new EasyNettyClient().start();}public void start() throws Exception {NioEventLoopGroup group = new NioEventLoopGroup();try {// 创建BootstrapBootstrap bootstrap = new Bootstrap();bootstrap.group(group) // 指定EventLoopGroup以处理客户端事件.channel(NioSocketChannel.class) // 需要适用于NIO的实现;适用于NIO传输的Channel类型.remoteAddress(new InetSocketAddress(HOST, PORT)) //设置服务器的InetSocketAddress.handler(new ChannelInitializer<SocketChannel>() { @Overrideprotected void initChannel(SocketChannel socketChannel) throws Exception {socketChannel.pipeline().addLast(new EasyNettyClientHandler());}});// 连接到远程节点,阻塞等待直到连接完成ChannelFuture future = bootstrap.connect().sync();// 阻塞直到Channel关闭future.channel().closeFuture().sync();} finally {// 关闭线程池并且释放所有资源group.shutdownGracefully().sync();}}
}
客户端和服务端同时使用了NIO传输,可也可以在服务端和客户端分别使用不同的传输。如服务端使用NIO传输,客户端使用OIO传输。如何选择用于特定用例的特定传输的各种因素和场景在后面的系列会讲。
总结引导客户端的步骤:
- 为初始化客户端,创建了一个Bootstrap实例
- 为进行事件处理分配了一个NioEventLoopGroup实例,其中事件处理包括创建新链接以及处理入站和出站数据。
- 为服务器连接创建了一个InetSocketAddress实例
- 当连接被创建是,一个EasyNettyClientHandler实例将被安装到该channel对应的ChannelPipeline中
- 在一切都设置完后,调用Bootstrap.connect()方法连接到远程节点。
以上步骤便完成客户端了。
3.运行程序
3.1 正常流程
先启动服务端,再启动客户端,服务端会收到“Hello Netty”的指令。接着服务器报告并接收到的消息,并将其传回客户端;客户端报告返回的消息并退出。
服务端
客户端
以上的行为是正常的行为。