Netty入门

简介

在Netty官方网站中,有这么一段对Netty的概述:

Netty is an asynchronous event-driven network application framework
for rapid development of maintainable high performance protocol servers & clients.

Netty is a NIO client server framework which enables quick and easy development of network applications such as protocol servers and clients. It greatly simplifies and streamlines network programming such as TCP and UDP socket server.

'Quick and easy' doesn't mean that a resulting application will suffer from a maintainability or a performance issue. Netty has been designed carefully with the experiences earned from the implementation of a lot of protocols such as FTP, SMTP, HTTP, and various binary and text-based legacy protocols. As a result, Netty has succeeded to find a way to achieve ease of development, performance, stability, and flexibility without a compromise.

简言之,Netty是一款异步的,事件驱动的,网络应用程序框架,用于快速开发高可用的,高性能的网络协议服务器和客户端。Netty吸取了许多网络协议的开发经验,并基于Java NIO,经过精心设计,成功找到了一种方式保证易于开发的同时还确保了其应用的性能,稳定性和伸缩性。

传统BIO简介

在学习Netty的过程中,我认为复习一下传统BIO编程可以更深刻地体会NIO和Netty框架的优越性。

传统BIO模式图
图1 传统BIO模式

以下是示例代码:

/**
 * 示例:传统网络io —— 服务端接收客户端数据并输出
 *
 * @author binbing
 * @description 服务端
 */
public class Server {
    public static void main(String[] args) {
        // 端口号,缓存大小
        final int PORT = 6666, BUFFER_SIZE = 2048;
        // 哨兵
        int read;
        ServerSocket serverSocket = null;
        try {
            serverSocket = new ServerSocket(PORT);
            Socket socket = serverSocket.accept();
            InputStream inputStream = socket.getInputStream();
            byte[] buffer = new byte[BUFFER_SIZE];
            while (-1 != (read = inputStream.read(buffer))) {
                System.out.println(new String(buffer, 0, read));
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (Objects.nonNull(serverSocket)) {
                    serverSocket.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

在BIO中,服务端启动后会进入阻塞状态,一直等到有客户端连接,通过accept()方法获取到socket及其输入流,程序才继续往下执行。在此期间,会浪费大量的系统资源。

/**
 * 示例:传统网络io —— 客户端发送数据到服务端
 *
 * @author binbing
 * @description 客户端
 */
public class Client {
    public static void main(String[] args) {
        // 服务器
        final String HOST = "localhost";
        // 端口
        final int PORT = 6666;
        final String CONTENT = "Hello Server!";
        Socket socket = null;
        try {
            socket = new Socket(HOST, PORT);
            OutputStream outputStream = socket.getOutputStream();
            outputStream.write(CONTENT.getBytes(StandardCharsets.UTF_8));
            // 不能调用flush(),否则服务端接收不到数据。
            // outputStream.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (Objects.nonNull(socket)) {
                    socket.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

在这个BIO示例中,客户端主要功能是通过输出流发送数据,功能简单,便不做过多解释。其中需要注意的是,我在测试的过程中发现,如注释所示,不能调用flush()方法,否则服务端接收不到数据。原因很简单:这里没有使用缓冲数据流。如果使用BufferedOutputStream包装一层,则调用flush()方法没问题。flush()方法的作用是确保缓冲区数据完整。

NIO简介

NIO的出现,正是为了解决BIO的阻塞问题。

图2 NIO工作原理

以下是示例代码:

/**
 * 示例:nio —— 复制文件
 *
 * @author binbing
 * @description 服务端
 * @date 2021/1/31 11:54 AM
 */
public class Server {
    public static void main(String[] args) {
        ServerSocketChannel serverSocketChannel = null;
        try {
            serverSocketChannel = ServerSocketChannel.open();
            serverSocketChannel.socket().bind(new InetSocketAddress(6666));
            serverSocketChannel.configureBlocking(false);
            // 创建selector并注册channel
            Selector selector = Selector.open();
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
            // 一定要迭代器。因为增强for循环会报错,for-i循环会删不干净
            while (true) {
                if (selector.select(2000) == 0) {
                    System.out.println("无事件发生!");
                    continue;
                }
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while (iterator.hasNext()) {
                    SelectionKey selectionKey = iterator.next();
                    iterator.remove();
                    if (selectionKey.isAcceptable()) {
                        SocketChannel accept = serverSocketChannel.accept();
                        accept.configureBlocking(false);
                        accept.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
                    }
                    if (selectionKey.isReadable()) {
                        SocketChannel channel = (SocketChannel) selectionKey.channel();
                        ByteBuffer byteBuffer = (ByteBuffer) selectionKey.attachment();
                        channel.read(byteBuffer);
                        System.out.println(new String(byteBuffer.array()));
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (Objects.nonNull(serverSocketChannel)) {
                    serverSocketChannel.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

NIO的核心组件包括三个:Channel(通道),Buffer(缓冲区),Selector(选择器)。我的理解和记忆方法是:Channel面向用户,用户使用Channel输入/获取数据;Buffer面向内存,数据通过Buffer输入/输出;Selector负责调度,与Channel交互,哪个Channel有数据变化就调度那个Channel。

此处注意:一个线程对应一个Selector,一个Selector对应多个Channel,一个Channel对应一个Buffer。另外,当selector处理完一个Channel事件后,需要及时地将事件从selectionKey集合中移除,在Java中,此处必须使用迭代器遍历。

/**
 * 示例:nio —— 复制文件
 *
 * @author binbing
 * @description 客户端
 * @date 2021/1/31 11:54 AM
 */
public class Client {
    public static void main(String[] args) {
        SocketChannel socketChannel = null;
        try {
            socketChannel = SocketChannel.open();
            socketChannel.connect(new InetSocketAddress("localhost", 6666));
            socketChannel.configureBlocking(false);
            ByteBuffer byteBuffer = ByteBuffer.wrap("Hello Server!".getBytes());
            socketChannel.write(byteBuffer);
            System.in.read();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (Objects.nonNull(socketChannel)) {
                    socketChannel.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

同BIO客户端一样,此NIO客户端示例也是负责包装发送数据,较简单,不做过多解释。需要关注的是NIO客户端涉及开启SocketChannel,创建ByteBuffer,并使用SocketChannel发送ByteBuffer数据。

注意点,客户端需要调用System.in.read()保持开启状态,否则...会出现神奇的现象,请读者自行探索。

BIO和NIO的差别

BIO和NIO的区别

BIONIO
面向对象面向流面向缓冲区
阻塞方式同步阻塞同步非阻塞
线程数1:11:N
核心组件Stream(流)Channel(通道),Buffer(缓冲区),Selector(选择器)
复杂度简单较复杂
吞吐量
优点模型简单
编码简单
并发度高
缺点性能瓶颈低模型复杂
编码复杂
需处理半包问题

Reactor模式简介

Netty的核心除了以NIO为基础,还有另外一点十分重要,那就是Reactor模式。

Reactor模式还划分了多种类型,包括单线程Reactor模式,多线程Reactor模式,以及主从多线程Reactor模式。Netty使用的是主从多线程Reactor模式。

图3 单线程Reactor模式

图3是单线程Reactor模式,在这个模式中,各组件的功能如下:

  • Reactor:负责响应事件,将事件分发给绑定了该事件的Handler处理;
  • Handler:事件处理器,绑定了某类事件,负责执行对应事件的Task对事件进行处理;
  • Acceptor:Handler的一种,绑定了connect事件。当客户端发起connect请求时,Reactor会将accept事件分发给Acceptor处理。
图4 多线程Reactor模式

图4是多线程Reactor模式。如图所示,有专门一个reactor线程用于监听服务端ServerSocketChannel,接收客户端的TCP连接请求。网络IO的读/写等操作由一个worker reactor线程池负责。线程池中的NIO线程负责监听SocketChannel事件,并进行消息的读取、解码、编码和发送等工作。一个NIO线程可以同时处理多条链路,但是一个链路只注册在一个NIO线程上处理,防止并发操作问题。

图5 主从多线程Reactor模式

图5是主从多线程Reactor模式。在绝大多数场景,多线程Reactor模型可以满足性能需求,但是在极个别特殊场景中,一个NIO线程负责监听和处理所有的客户端连接会存在性能问题。比如,建立连接时需要进行复杂的验证和授权工作。

在主从多线程Reactor模式中,服务端用于接收客户端连接的不再是个1个单独的reactor线程,而是一个boss reactor线程池。服务端启用多个ServerSocketChannel监听不同端口时,每个ServerSocketChannel的监听工作可以由线程池中的一个NIO线程完成。

Netty简介

图6 Netty Reactor架构图

首先,图6是Netty Reactor架构图。此图涉及内容及细节较多,作为入门教学文章不做太多解释,还请读者查阅相关文章,或者参考本文末尾的参考资料。

Netty的优点有很多:

  • API使用简单,学习成本低。
  • 功能强大,内置了多种解码编码器,支持多种协议。
  • 性能高,对比其他主流的NIO框架,Netty的性能最优。
  • 社区活跃,发现BUG会及时修复,迭代版本周期短,不断加入新的功能。
  • Dubbo、Elasticsearch都采用了Netty,质量得到验证。

当然,Netty也存在一些缺点,例如以下三个:

  • NIO的类库和API繁杂,学习成本高,你需要熟练掌握Selector、ServerSocketChannel、SocketChannel、ByteBuffer等。
  • 需要熟悉Java多线程编程。这是因为NIO编程涉及到Reactor模式,你必须对多线程和网络编程非常熟悉,才能写出高质量的NIO程序。
  • 臭名昭著的epoll bug。它会导致Selector空轮询,最终导致CPU 100%。直到JDK1.7版本依然没得到根本性的解决。

Netty编程可谓万变不离其宗,我是通过以下两点进行学习记忆。

  • 开发套路不变:
  1. 创建工作组(父类EventLoopGroup,子类NioEventLoopGroup)。服务端需要两个,一个是的boss group,另一个是worker group。客户端创建一个即可。
  2. 创建启动类。服务端是ServerBootstrap,客户端是Bootstrap。
  3. 设置启动项。通过启动类设置相关的启动项,例如:group、channel、handler、childHandler等。
  4. 服务端绑定端口,客户端创建连接,并调用sync()方法转为同步,最终得到一个ChannelFuture对象。
  5. 然后,通过步骤4得到的ChannelFuture对象对关闭通道进行监听。
  6. 最终,优雅地关闭(调用shutdownGracefully()方法)工作组。
  • 补充具体的handler处理器。

可以继承ChannelInboundHandlerAdapter或者实现其他相关的类,然后编写业务相关的事件代码。事件包括(不等于全部)通道注册(channelRegistered),通道注销(channelUnregistered),通道活动(channelActive),通道空闲(channelInactive),读取数据(channelRead),读取完毕(channelReadComplete),异常处理(exceptionCaught)等等。此处翻译可能不准确,请读者酌情理解。

总结

Netty看似开发简单,实则涉及的知识面极广。相信在未来,读者经过更深入的学习和研究可以体会到为何Netty是Java中级程序员,或者游戏开发者必须掌握的一门技术。我认为,我们不仅需要学会如何使用Netty,在怎样的场景下使用它,更需要深入研究Netty的源码。研究它的架构原理及工作流程,学习其中的设计思路,这才是所有软件工程师应该吸收的营养。

 

参考资料

  1. Netty百度百科
  2. JavaNIO-Reactor模型
  3. 超详细Netty入门,看这篇就够了!
 
本文内容转自冰部落,仅供学习交流,版权归原作者所有,如涉及侵权,请联系删除。

声明:
本平台/个人所提供的关于股票的信息、分析和讨论,仅供投资者进行研究和参考之用。
我们不对任何股票进行明确的买入或卖出推荐。
投资者在做出投资决策时,应自行进行充分的研究和分析,并谨慎评估自己的风险承受能力和投资目标。
投资有风险,入市需谨慎。请投资者根据自身的判断和风险承受能力,自主决策,理性投资。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注