Hello Netty World

发布时间 2023-04-04 14:53:00作者: 風栖祈鸢

Hello Netty World

Netty 介绍

(官网)Netty is an asynchronous event-driven network application framework,
for rapid development of maintainable high performance protocol servers & clients.
(翻译)Netty是一个异步事件驱动的网络应用程序框架,用于快速开发可维护的高性能协议服务器和客户端。

先说结论,Netty 可以干什么:通常,我们使用 SpringBoot 搭建服务器,可以在网页上通过 URL 发出请求,服务器处理请求后返回响应,此时客户端和服务器是通过 HTTP 协议交换数据的;而使用 Netty,我们可以基于客户端和服务器之间的字节流,自定义编码解码规则(网络协议),从而实现自定义网络协议的应用程序。

再来说说 Netty 是什么:官网的介绍是,Netty 是一个网络应用程序框架(类似 SpringBoot 是 Web 应用程序的框架),即用于网络编程的一个框架。涉及到网络编程,就少不了网络编程底层的套接字(Socket),原生的 Socket 编程就不说了,Java 中已经提供了 NIO(Non-Blocking IO,非阻塞 IO)用于高性能的网络编程,那 NIO 和 Netty 有什么关系呢?再说一下结论:Netty 也是一种 NIO 框架,但对比 Java 原生的 NIO 和其他 NIO 框架,它更好使。

接着对比一下 NIO 和 Netty 的区别:

先看 NIO 的缺点:

  1. API 复杂,作为 Java 直接提供的 API,NIO 的 API 封装度较低,使用起来比较繁杂,学习成本高,需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等对象;

  2. 使用 NIO 需要对多线程和网络编程较为了解,因为 NIO 涉及到 Reactor 模式(也叫 Dispatcher 模式,即 I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程),没有良好的基础很难使用 NIO 写出健康的程序。

  3. NIO 还会有一些自带的 BUG,如 epoll bug,会导致 Selector 空轮询造成 CPU 占用 100%(最终是修复了,但影响力太大,臭名昭著)。

与之相比,Netty 就具有相对的优点:

  1. 底层封装了 NIO,API 简单,非常容易上手;

  2. 内置多种编码器解码器,支持多种协议,功能强大;

  3. 对比原生 NIO 和其他 NIO 框架,Netty 的性能是最好的;

  4. 社区活跃,使用者多,出现 BUG 的修复速度也非常快,质量有保证。

总之,对于构建应用来说,即使没有任何 NIO 相关知识,直接使用 Netty 也不会有任何负担。当然,最好的还是能了解底层的原理。下面通过构建一个 Netty 的 Hello World 程序,就可以感受到 Netty 的强大。

Hello Netty

服务端

从0开始,创建一个 Maven 的 Java 项目,引入 Netty 的依赖:

    <dependencies>
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
            <version>4.1.36.Final</version>
        </dependency>
    </dependencies>

然后,就可以直接开始构建服务器了,创建 MyServer 类,代码如下:

public class MyServer {
    public static void main(String[] args) throws Exception {
        // 创建两个线程组 boosGroup、workerGroup(步骤1)
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            // 创建服务端的启动对象 ServerBootstrap,设置启动参数(步骤2)
            ServerBootstrap bootstrap = new ServerBootstrap();
            // 设置上面创建的线程组 boosGroup 和 workerGroup
            bootstrap.group(bossGroup, workerGroup)
                    // 设置服务端的通道实现类型
                    .channel(NioServerSocketChannel.class)
                    // 设置线程队列的连接个数
                    .option(ChannelOption.SO_BACKLOG, 128)
                    // 设置保持活动连接状态
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    // 使用匿名内部类的形式初始化通道对象 并设置处理器(步骤3,处理器当然是自己写,自定义业务流程)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            // 为服务端 添加一个处理器
                            socketChannel.pipeline().addLast(new MyServerHandler());
                        }
                    });
            System.out.println("Netty 服务端启动-Hello Server!");
            // 绑定端口号 服务端启动(步骤4)
            ChannelFuture channelFuture = bootstrap.bind(7777).sync();
            // 监听关闭通道
            channelFuture.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

简单归纳一下创建服务器的步骤:

  1. 创建两个线程组对象 bossGroup 和 workerGroup;

  2. 创建服务端的启动对象 ServerBootstrap,设置线程组、启动参数;

  3. 为服务端的通道设置处理器(顾名思义,此处就是自定义处理数据的地方了);

  4. 为服务端绑定端口,启动服务。

可见服务端的启动非常简单,步骤1、2、4可以说都是固定步骤,只有在步骤3中,需要添加处理数据的通道处理器,此处的 MyServerHandler 就是自定义的,代码如下:

public class MyServerHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        // Read时触发
        ByteBuf byteBuf = (ByteBuf) msg;
        System.out.println("收到客户端" + ctx.channel().remoteAddress() + "发送的消息:" + byteBuf.toString(CharsetUtil.UTF_8));
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
        // ReadComplete后触发,发送消息给客户端
        ctx.writeAndFlush(Unpooled.copiedBuffer("服务端收到,你是" + ctx.channel().remoteAddress(), CharsetUtil.UTF_8));
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        // 发生异常关闭通道
        ctx.close();
    }
}

MyServerHandler 类继承了 ChannelInboundHandlerAdapter,它就是一个处理器了。其中,重写了 channelRead 等方法,这些方法都会在对应的场合被调用,如 channelRead,对于接收到的数据 MyServerHandler 会调用 channelRead 方法处理数据,处理完成后又会调用 channelReadComplete 方法。

此处可以看到,channelRead 中读取了数据,在服务端打印了收到的数据(将数据转化为字节流后再转 Sting 读取),读取完后,又由 channelReadComplete 方法给客户端发送回复。

其中用到了 ChannelHandlerContext 对象,GPT 给出的介绍如下:

ChannelHandlerContext(通道处理上下文)是 Netty 中处理通道事件的核心接口,它封装了 Channel 和 ChannelHandler 之间的关联关系,以及 ChannelHandler 之间的交互操作。每当 Netty 中的通道(Channel)被激活时,会生成一个 ChannelHandlerContext 对象,并通过 ChannelPipeline 向通道处理器 ChannelHandler 传递事件和数据,让它们能够对通道进行处理。
简单来说,ChannelHandlerContext 提供了一种 Channel 和 ChannelHandler 之间进行通信和交互的方式,可以通过它来访问 Channel、调用 ChannelPipeline 中的其他处理器等。

客户端

完成了服务端,就到客户端了,与服务端类似,代码如下:

public class MyClient {

    public static void main(String[] args) throws Exception {
        // 创建 NIO 线程组(步骤1)
        NioEventLoopGroup eventExecutors = new NioEventLoopGroup();
        try {
            // 创建客户端的启动对象 Bootstrap,设置启动参数(步骤2)
            Bootstrap bootstrap = new Bootstrap();
            // 设置线程组
            bootstrap.group(eventExecutors)
                    // 设置客户端的通道实现类型
                    .channel(NioSocketChannel.class)
                    // 使用匿名内部类初始化通道,并设置处理器(步骤3,处理器也是自定义的)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            // 为客户端 添加一个处理器
                            ch.pipeline().addLast(new MyClientHandler());
                        }
                    });
            System.out.println("Netty 客户端到位-Hello Client!");
            // 设置 IP 和 端口 连接服务端,操作系统会给客户端分配一个空闲端口号
            ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 7777).sync();
            // 对通道关闭进行监听
            channelFuture.channel().closeFuture().sync();
        } finally {
            //关闭线程组
            eventExecutors.shutdownGracefully();
        }
    }
}

简单归纳一下创建客户端的步骤:

  1. 创建 NIO 线程组对象 eventExecutors;

  2. 创建客户端的启动对象 Bootstrap,设置线程组、启动参数;

  3. 为客户端的通道设置处理器(与客户端系统,自定义的数据处理);

  4. 为客户端设置连接 IP 和端口,启动连接。

可以看到,创建客户端的流程与服务端差不多,只是参数不太一样。自定义的 MyClientHandler 代码如下:

public class MyClientHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        // Active时触发(连接时),发送消息到服务端
        ctx.writeAndFlush(Unpooled.copiedBuffer("こんにちは、世界", CharsetUtil.UTF_8));
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        // Read时触发
        ByteBuf byteBuf = (ByteBuf) msg;
        System.out.println("收到服务端" + ctx.channel().remoteAddress() + "的消息:" + byteBuf.toString(CharsetUtil.UTF_8));
    }
}

其中,channelActive 方法在连接时触发,向服务器发送消息;收到数据时调用 channelRead 处理数据,在客户端打印数据内容(字节流数据转 String)。

启动

完成了服务端和客户端后,就可以启动它们尝试一下了。首先启动服务端,直接运行 MyServer:

再运行 MyClient:

可以看到 MyClient 直接有输出内容了,这是因为启动连接成功后 MyClient 调用 channelActive 向 MyServer 发送了数据,MyServer 调用 channelRead 读取了数据,完成后又调用 channelReadComplete 向 MyClient 发送了数据,最后由 MyClient 的 channelRead 打印到了控制台上。

回到 MyServer 的控制台查看输出:

Hello Netty 程序完成,就是这么简单了。