Netty 简易指南
本文的两个示例来自 Netty 官方示例,由笔者用 Maven 实现。
回写服务器
Netty 封装了 NIO 的大多数处理步骤,我们只需要将精力集中在业务代码编写上,比如,如果要创建一个最简单的服务端,只打印并丢弃客户端发送来的内容。
java
public class DiscardServerHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) { // (2)
// 丢弃接收到的消息
((ByteBuf) msg).release(); // (3)
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { // (4)
// 当异常出现时关闭 channel
cause.printStackTrace();
ctx.close();
}
}
这里创建了一个 Netty 的处理器类DiscardServerHandler来承载业务代码,它继承自ChannelInboundHandlerAdapter,这里重写了两个方法:
channelRead,服务端收到消息时调用exceptionCaught,处理器产生异常时调用
这里方法签名中收到的消息类型是Object,其实质上是一个ByteBuf,这也是 Netty 消息传递时使用的最基本类型。
要使用这个处理器并创建服务端,可以:
java
@Slf4j
public class SimpleServer {
private int port;
public SimpleServer(int port) {
this.port = port;
}
public void run() throws Exception {
log.info("开始启动服务端");
EventLoopGroup bossGroup = new NioEventLoopGroup(); // (1)
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
log.info("配置服务端并加载处理器");
ServerBootstrap b = new ServerBootstrap(); // (2)
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class) // (3)
.childHandler(new ChannelInitializer<SocketChannel>() { // (4)
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline()
.addLast(new StringEncoder())
.addLast(new DiscardServerHandler());
}
})
.option(ChannelOption.SO_BACKLOG, 128) // (5)
.childOption(ChannelOption.SO_KEEPALIVE, true); // (6)
// Bind and start to accept incoming connections.
log.info("开始监听[{}]端口,并等待建立连接", port);
ChannelFuture f = b.bind(port).sync(); // (7)
// Wait until the server socket is closed.
// In this example, this does not happen, but you can do that to gracefully
// shut down your server.
f.channel().closeFuture().sync();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}
public static void main(String[] args) throws Exception {
int port = 8080;
if (args.length > 0) {
port = Integer.parseInt(args[0]);
}
new SimpleServer(port).run();
}
}
服务端主要的工作由bossGroup和workerGroup完成,它们与通常协程中的时间循环(Event Loop)的概念是类似的,都是不断循环遍历通道(Channel)的事件,一旦通道有数据写入或者输出,就触发相应的事件,将相应的数据从缓冲(ByteBuf)写入或读出。
不同的是,通常只有一个bossGroup线程,它负责主循环的遍历和事件处理,有多个workGroup线程,它们负责捕获事件后的具体处理(读写缓存并调用处理器等)。
ServerBootstrap用于配置 Netty 服务端,childHandler方法用于设置workGroup的通道,最关键的用于处理业务的处理器也在这里设置,具体是以ch.pipeline().addLast(...)的方式进行添加。
Pipline 中的多个处理器是一个双向链表,发动到服务端的消息会被这些处理器依次处理,所以用于处理业务的处理器通常放在最后(addLast)。
这里使用了一个额外的处理器(
StringEncoder),置于业务处理器之前,其用途是对服务器返回的字节进行编码,以让客户端能正常显示,否则 Telnet 客户端可能无法正常显示服务端返回的内容。
单纯的丢弃消息并不有趣,我们可以让处理器丢弃后打印消息:
java
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf in = (ByteBuf) msg;
try {
String strMsg = in.toString(CharsetUtil.UTF_8);
log.info("收到消息{}", strMsg);
System.out.println(strMsg);
System.out.flush();
} finally {
// 释放缓冲(引用对象)
ReferenceCountUtil.release(msg);
}
}
或者更进一步,将接收的消息原样返回:
java
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
String strMsg = ((ByteBuf) msg).toString(StandardCharsets.UTF_8);
log.info("收到消息{}", strMsg);
ctx.writeAndFlush(strMsg);
log.info("返回消息{}", strMsg);
}
以上示例都可以通过 Telnet 客户端进行测试和验证。
时间服务器
时间服务器同样简单,它不需要接收任何消息,只要在任意客户端连接后,将当前时间作为消息返回给客户端即可。
java
@Slf4j
public class TimeServerHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(final ChannelHandlerContext ctx) { // (1)
log.info("连接已建立");
final ByteBuf time = ctx.alloc().buffer(4); // (2)
time.writeInt((int) (System.currentTimeMillis() / 1000L + 2208988800L));
log.info("发送当前时间给客户端");
final ChannelFuture f = ctx.writeAndFlush(time); // (3)
f.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) {
assert f == future;
log.info("消息已发送完,尝试关闭连接");
ctx.close();
}
}); // (4)
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
时间可以用无符号整形表示,这里使用一个4字节长度的缓存(ByteBuf)来存储时间。
客户端用于接收的处理器类:
java
@Slf4j
public class TimeClientHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
log.info("接收到服务端消息");
ByteBuf m = (ByteBuf) msg; // (1)
try {
long currentTimeMillis = (m.readUnsignedInt() - 2208988800L) * 1000L;
System.out.println(new Date(currentTimeMillis));
ctx.close();
} finally {
m.release();
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
一个简单的客户端类:
java
@Slf4j
public class SimpleClient {
// 服务端 ip
private String host;
// 服务端端口
private int port;
// 客户端处理器链
private List<ChannelHandler> handlers = new ArrayList<>();
public SimpleClient(String host, int port) {
this.host = host;
this.port = port;
}
public void setHandlers(@NonNull List<ChannelHandler> handlers) {
this.handlers = handlers;
}
public void run(){
log.info("启动客户端");
EventLoopGroup workerGroup = new NioEventLoopGroup();
log.info("配置客户端");
try {
Bootstrap b = new Bootstrap(); // (1)
b.group(workerGroup); // (2)
b.channel(NioSocketChannel.class); // (3)
b.option(ChannelOption.SO_KEEPALIVE, true); // (4)
b.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
for (ChannelHandler handler : handlers) {
ch.pipeline().addLast(handler);
}
}
});
// Start the client.
log.info("尝试连接服务端[{}:{}]",host,port);
ChannelFuture f = b.connect(host, port).sync(); // (5)
// Wait until the connection is closed.
f.channel().closeFuture().sync();
log.info("客户端已关闭");
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
workerGroup.shutdownGracefully();
}
}
}
用 Netty 构建客户端与服务端的方式是类似的, 不同的是客户端不需要 Work Group,只需要一个 Boss Group 即可,这也很好理解,客户端并不需要多个线程处理并发请求。
现在分别启动服务端和客户端,就能看到客户端收到当前时间。
消息在底层(网络层)转发时可能拆分成多个包,因此即使只有4个字节,也可能在接收时分多个包接收,比如先接收 2 个字节,再接收 2 个字节,此时我们的客户端接收并打印的时间可能出错。
处理这种分包问题的一种方式是在处理器中使用缓冲区,只有缓冲区中的数据积攒足够后才进行处理:
java
@Slf4j
public class TimeClientHandler2 extends ChannelInboundHandlerAdapter {
// 处理器缓冲区
private ByteBuf byteBuf;
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
// 执行处理器的初始化任务
// 为处理器初始化一个 4 字节的缓冲区
byteBuf = ctx.alloc().buffer(4);
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
// 执行处理器的清理任务
ReferenceCountUtil.release(byteBuf);
byteBuf = null;
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
log.info("接收到消息");
// 将接收到的消息写入缓冲区
ByteBuf m = (ByteBuf) msg;
byteBuf.writeBytes(m);
m.release();
if (byteBuf.readableBytes() >= 4) {
// 如果缓冲区中数据够了(4 字节),转换为时间并打印
long currentTimeMillis = (byteBuf.readUnsignedInt() - 2208988800L) * 1000L;
System.out.println(new Date(currentTimeMillis));
ctx.close();
}
}
}
这样的做法缺点在于在业务代码中混杂了其它用途的代码,不够简洁,不符合一事一例原则。因此,可以将持续接收并合并消息的内容用单独的处理器进行处理:
java
public class TimeDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
if (in.readableBytes() < 4) {
// 如果缓冲区字节数小于4,不处理
return;
}
// 缓冲区字节数大于等于4,作为消息读取,并清空缓冲区
out.add(in.readBytes(4));
}
}
这样处理业务代码的处理器就不需要再考虑消息因为分包不完整的问题了。
ByteToMessageDecoder是ChannelInboundHandlerAdapter的一个子类,专门用于消息转换。
在业务处理器中直接处理 ByteBuf 类型的消息并不是很方便,可以更近一步,直接使用自定义的 POJO 类型的消息。
先定义一个表示时间的 POJO 类:
java
public class UnixTime {
private final long value;
public UnixTime() {
this(System.currentTimeMillis() / 1000L + 2208988800L);
}
public UnixTime(long value) {
this.value = value;
}
public long value() {
return value;
}
@Override
public String toString() {
return new Date((value() - 2208988800L) * 1000L).toString();
}
}
在消息转换时将消息转换为自定义类型,并传递给后边的处理器:
java
public class TimeDecoder2 extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
if (in.readableBytes() < 4) {
// 如果缓冲区字节数小于4,不处理
return;
}
// 缓冲区字节数大于等于4,作为消息读取,并清空缓冲区
out.add(new UnixTime(in.readUnsignedInt()));
}
}
处理业务的处理器只需要处理 POJO 类型的消息即可:
java
@Slf4j
public class TimeClientHandler3 extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
log.info("接收到服务端消息");
UnixTime currentTime = (UnixTime) msg;
System.out.println(currentTime);
ctx.close();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
类似的,在服务端也可以做同样的事情。在业务处理器中直接将自定义类型写入并传递给前边的处理器:
java
@Slf4j
public class TimeServerHandler2 extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(final ChannelHandlerContext ctx) { // (1)
log.info("连接已建立");
UnixTime time = new UnixTime();
log.info("发送当前时间[{}]给客户端", time);
final ChannelFuture f = ctx.writeAndFlush(time); // (3)
f.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) {
assert f == future;
log.info("消息已发送完,尝试关闭连接");
ctx.close();
}
}); // (4)
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
其前边的处理器将自定义类型转换为 4 字节整形返回给客户端:
java
public class TimeEncoder2 extends MessageToByteEncoder<UnixTime> {
@Override
protected void encode(ChannelHandlerContext ctx, UnixTime msg, ByteBuf out) throws Exception {
out.writeInt((int) msg.value());
}
}
本文的完整示例可以从这里获取。