0
点赞
收藏
分享

微信扫一扫

RK3568笔记二十:PP-YOLOE部署测试

青乌 03-25 23:30 阅读 3

Netty核心原理剖析与RPC实践

0、为什么要学习 Netty?

https://www.bilibili.com/video/BV1e24y1z7eJ/?p=2&spm_id_from=pageDriver&vd_source=255d320e92e8ca6db4e0b59bb9a56416

我们知道网络层是架构设计中至关重要的环节,但 Java 的网络编程框架有很多(比如 Java NIO、Mina、Grizzy),为什么我这里只推荐 Netty 呢?

因为 Netty 是目前最流行的一款高性能 Java 网络编程框架,它被广泛使用在中间件、直播、社交、游戏等领域。目前,许多知名的开源软件也都将 Netty 用作网络通信的底层框架,如 Dubbo、RocketMQ、Elasticsearch、HBase 等。

讲到这里,你可能要问了:如果我的工作中涉及网络编程的内容并不多,那我是否还有必要花精力学习 Netty 呢?

其实在互联网大厂(阿里、腾讯、美团等)的中高级 Java 开发面试中,经常会问到涉及 Netty 核心技术原理的问题,比如:

  1. Netty 的高性能表现在哪些方面?对你平时的项目开发有何启发?
  2. Netty 中有哪些重要组件,它们之间有什么联系?
  3. Netty 的内存池、对象池是如何设计的?
  4. 针对 Netty 你有哪些印象比较深刻的系统调优案例?

这些问题看似简单,但如果你对 Netty 掌握不够深入,回答时就很容易“翻车”。我面试过很多求职者,虽然他们都有一定的 Netty 使用经验,但当深入探讨技术细节及如何解决项目中的实际问题时,就会发现大部分人只是简单使用,并没有深入掌握 Netty 的技术原理。如果你可以学好 Netty,掌握底层原理,一定会成为你求职面试的加分项。

而且通过 Netty 的学习,还可以锻炼你的编程思维,对 Java 其他的知识体系起到融会贯通的作用。

当年我刚踏入工作,领到的第一个任务是数据采集和上报。我尝试了各种解决方案最后都被主管否掉了,他说“不用那么麻烦,直接使用 Netty 就好了”。于是我一边学习一边完成工作,工作之余还会挤出时间研究 Netty 源码。

回想起研究源码的那段日子,虽然很辛苦,但仿佛为我打开了一扇 Java 新世界的大门,当我理解领悟 Netty 的设计原理之后,对 I/O 模型 、内存管理、线程模型、数据结构等当时理解起来有一定难度的知识,仿佛一瞬间“顿悟”了。而且在我日后再去学习 RocketMQ、Nginx、Redis 等优秀框架时,也明显感觉更加便捷、高效了。

因此,如果你想提升自己的技术水平并找到一份满意的工作,学习掌握 Netty 就非常重要。事实上,在平时的开发工作中,Netty 的易用性和可靠性也极大程度上降低了开发者的心智负担。

我在学生时代,写过不少网络应用,现在看来,非常冗长。当我熟练掌握 Netty 后,一切问题迎刃而解。Netty 对 Java NIO 进行了高级封装,简化了网络应用的开发过程,我们不再需要花费大量精力关注 Selector、SocketChannel、ServerSocketChannel 等繁杂的 API。

当我自己写网络应用时,拆包/粘包、数据编解码、TCP 断线重连等一系列问题都需要考虑到,而现在 Netty 给我们提供了现成的解决方案。此外遇到问题还可以在社区讨论,Netty 的迭代周期短修复问题快,其可靠性和健壮性被越来越多的公司所认可和采纳。

不客气地说,正是因为有 Netty 的存在,网络编程领域 Java 才得以与 C++ 并肩而立

由以上几点出发,我想和你一起学习 Netty,希望在工作和求职的过程中能够为你提供帮助,也可以为你打开学习思路。

学习目标与困难

那么我们该如何学习 Netty 技术呢?作为初学者,你一定会有很多疑问或遇到一些问题:

  • 缺乏网络相关的基础知识,学习 Netty 往往理解不深刻,始终不得其法;
  • Netty 知识点非常多,网上资源比较零散,社区文档对初学者也不够友好,如何系统化学习 Netty;
  • 看了这么多 Netty 的基础理论,落到项目开发中却依然毫无头绪;
  • Netty 源码过于复杂,学习无从下手,抓不住重点,最终半途而废;
  • 工作中缺少实践,仅仅学习理论知识很容易就忘记了。

在学习的过程中我也遇到了同样的问题,但幸运的是美团的工作经历让我有了很多实践和解决问题的机会。在这期间,我在系统设计方面不断有新的认知。

这里我想分享一些我的学习经验,供你一同学习。学习方法不但适合 Netty,也适合其他技术。希望通过这些经验,可以一同进步。

  • 首先,兴趣是最好的老师,工作之余我一定会分配出至少 10% 的时间去思考和学习新的知识,像 Netty 如此优秀的学习资源当然不能放过。
  • 其次,如果你工作中缺乏项目实战,其实也不必过于担心,可以尝试实现一些 MVP 的原型系统,例如 RPC、IM 即时聊天,HTTP 服务器等。不要觉得这是在浪费时间,实践出真知,在学习 Netty 的同时你也会得到很多收获。
  • 再次,在学习源码之前,首先要让自己成为一个熟练工,掌握基本理论。事实上,不论是学习什么框架,我会先尝试挑战自己。我在心中问自己:“我会如何设计它的架构?”然后再去学习相关的博客、源码等资源,思考作者的设计为什么与自己完全不一样?两者设计的差别在哪里?
  • 最终,反复学习也很重要。有时在汲取新知识的时候会对之前的知识点理解产生新的想法,我会带着疑问去把相关的知识重新学习一遍,打破砂锅问到底,经常收获满满。

Netty 的学习路径

如果现阶段的你:

  1. 具备一定的 Java 基础,需要深入学习一款开源框架提升能力和开拓视野;
  2. 希望自己在求职面试中增加闪光点,成为精通 Netty 的硬核程序员;
  3. 想系统学习 Netty 服务端开发,并希望通过实战来加深理解;
  4. 正在从事网络、分布式服务框架等方向的工作,期望自己成为该领域的专家。

那么这个课程就是为你量身定做的,课程中我会结合高频的面试题,从源码出发剖析 Netty 的核心技术原理,同时将这么多年使我受益匪浅的一些编程思想实战经验分享给你,帮助你在工作中学以致用,避免踩坑。

在这里我也总结归纳出一份 Netty 核心知识点的思维导图,希望可以帮助你梳理本专栏的整体知识脉络。我会由浅入深地带你建立起完整的 Netty 知识体系,夯实你的 Netty 基础知识、Netty 进阶技能、实战开发经验。

image (2).png

  • 夯实 Netty 基础知识:第一、二部分介绍 Netty 的全貌,了解 Netty 的发展现状和技术架构,并且逐一讲解了 Netty 的核心组件原理和使用,以及网络通信必不可少的编解码技能,为后面的源码解析和实践环节打下基础。
  • Netty 进阶技能:第三部分讲解 Netty 的内存管理,并希望通过对比介绍 Nginx、Redis 两个著名的开源软件,帮你达到举一反三的能力。第四部分结合高频的面试问题,通过多解读剖析 Netty 的核心源码,帮助你快速准确地理解 Netty 高性能的技术原理,对其中的设计思想学以致用。
  • 实战开发经验:课程最后带你从 0 到 1 打造一个高性能分布式 RPC 框架,并针对 RPC 框架的核心要点,帮助你掌握网络编程的技巧,加深对 Netty 的理解。

除了上述内容,你还可以通过本专栏获得一些额外的福利。

  • 万丈高楼平地起,课程会穿插必备的 Linux 网络编程基础知识,助你理解 Netty 时事半功倍。
  • Netty 源码的调试经验和技巧,从源码中我们可以学习到优秀的设计思想和技巧。
  • Netty 在实际的项目实践中踩过哪些坑?最佳实践应该是什么?
  • 利用 Netty 如何快速搭建一套高性能的分布式 RPC 框架?我会一步步带你完成这个 MVP 原型。
  • 在技术道路上如何升级打怪?告诉你我是如何学习和打造自己的技术体系的。

讲到最后,相信你一定对学习 Netty 满怀激情,那么一起来解锁 Netty 这项技能吧,也欢迎你留言和我一起交流和讨论。希望你能够将 Netty 这门技术融会贯通,让你的开发实践与职业发展走得更加顺利、长远!

你好,我是若地。今天我们将正式开始学习本专栏,一同了解一下 Netty。

众所周知,Java 的生态非常完善,同一类型的需求可能会有几款产品供你选择。那为什么 Java 的网络编程框架大家都会向你推荐 Netty,而不是 Java NIO、Mina、Grizzy 呢?

本节课,我们就一起来看看 Netty 为什么这么流行,它到底解决了什么问题,以及目前它的发展现状,让你对 Netty 有一个全面的认识。

为什么选择 Netty?

Netty 是一款用于高效开发网络应用的 NIO 网络框架,它大大简化了网络应用的开发过程。我们所熟知的 TCP 和 UDP 的 Socket 服务器开发,就是一个有关 Netty 简化网络应用开发的典型案例。

既然 Netty 是网络应用框架,那我们永远绕不开以下几个核心关注点:

  • I/O 模型、线程模型和事件处理机制;
  • 易用性 API 接口;
  • 对数据协议、序列化的支持。

我们之所以会最终选择 Netty,是因为 Netty 围绕这些核心要点可以做到尽善尽美,其健壮性、性能、可扩展性在同领域的框架中都首屈一指。下面我们从以下三个方面一起来看看,Netty 到底有多厉害。

高性能,低延迟

经常听到这么一句话:“网络编程只要你使用了 Netty 框架,你的程序性能基本就不会差。”这句话虽然有些绝对,但是也从侧面上反映了人们对 Netty 高性能的肯定。

实现高性能的网络应用框架离不开 I/O 模型问题,在了解 Netty 高性能原理之前我们需要先储备 I/O 模型的基本知识。

I/O 请求可以分为两个阶段,分别为调用阶段和执行阶段。

  • 第一个阶段为I/O 调用阶段,即用户进程向内核发起系统调用。
  • 第二个阶段为I/O 执行阶段。此时,内核等待 I/O 请求处理完成返回。该阶段分为两个过程:首先等待数据就绪,并写入内核缓冲区;随后将内核缓冲区数据拷贝至用户态缓冲区。

为了方便大家理解,可以看一下这张图:

Drawing 0.png

1、初识Netty

接下来我们来回顾一下 Linux 的 5 种主要 I/O 模式,并看下各种 I/O 模式的优劣势都在哪里?

1. 同步阻塞 I/O(BIO)

1.png

如上图所表现的那样,应用进程向内核发起 I/O 请求,发起调用的线程一直等待内核返回结果。一次完整的 I/O 请求称为BIO(Blocking IO,阻塞 I/O),所以 BIO 在实现异步操作时,只能使用多线程模型,一个请求对应一个线程。但是,线程的资源是有限且宝贵的,创建过多的线程会增加线程切换的开销。

2. 同步非阻塞 I/O(NIO)

2.png

在刚介绍完 BIO 的网络模型之后,NIO 自然就很好理解了。

如上图所示,应用进程向内核发起 I/O 请求后不再会同步等待结果,而是会立即返回,通过轮询的方式获取请求结果。NIO 相比 BIO 虽然大幅提升了性能,但是轮询过程中大量的系统调用导致上下文切换开销很大。所以,单独使用非阻塞 I/O 时效率并不高,而且随着并发量的提升,非阻塞 I/O 会存在严重的性能浪费。

3. I/O 多路复用

3.png

多路复用实现了一个线程处理多个 I/O 句柄的操作。多路指的是多个数据通道,复用指的是使用一个或多个固定线程来处理每一个 Socket。select、poll、epoll 都是 I/O 多路复用的具体实现,线程一次 select 调用可以获取内核态中多个数据通道的数据状态。多路复用解决了同步阻塞 I/O 和同步非阻塞 I/O 的问题,是一种非常高效的 I/O 模型。

4. 信号驱动 I/O

4.png

信号驱动 I/O 并不常用,它是一种半异步的 I/O 模型。在使用信号驱动 I/O 时,当数据准备就绪后,内核通过发送一个 SIGIO 信号通知应用进程,应用进程就可以开始读取数据了。

5. 异步 I/O

5.png

异步 I/O 最重要的一点是从内核缓冲区拷贝数据到用户态缓冲区的过程也是由系统异步完成,应用进程只需要在指定的数组中引用数据即可。异步 I/O 与信号驱动 I/O 这种半异步模式的主要区别:信号驱动 I/O 由内核通知何时可以开始一个 I/O 操作,而异步 I/O 由内核通知 I/O 操作何时已经完成。

了解了上述五种 I/O,我们再来看 Netty 如何实现自己的 I/O 模型。Netty 的 I/O 模型是基于非阻塞 I/O 实现的,底层依赖的是 JDK NIO 框架的多路复用器 Selector。一个多路复用器 Selector 可以同时轮询多个 Channel,采用 epoll 模式后,只需要一个线程负责 Selector 的轮询,就可以接入成千上万的客户端。

在 I/O 多路复用的场景下,当有数据处于就绪状态后,需要一个事件分发器(Event Dispather),它负责将读写事件分发给对应的读写事件处理器(Event Handler)。事件分发器有两种设计模式:Reactor 和 Proactor,Reactor 采用同步 I/O, Proactor 采用异步 I/O

Reactor 实现相对简单,适合处理耗时短的场景,对于耗时长的 I/O 操作容易造成阻塞。Proactor 性能更高,但是实现逻辑非常复杂,目前主流的事件驱动模型还是依赖 select 或 epoll 来实现。

6.png

(摘自 Lea D. Scalable IO in Java )

上图所描述的便是 Netty 所采用的主从 Reactor 多线程模型,所有的 I/O 事件都注册到一个 I/O 多路复用器上,当有 I/O 事件准备就绪后,I/O 多路复用器会将该 I/O 事件通过事件分发器分发到对应的事件处理器中。该线程模型避免了同步问题以及多线程切换带来的资源开销,真正做到高性能、低延迟。

完美弥补 Java NIO 的缺陷

在 JDK 1.4 投入使用之前,只有 BIO 一种模式。开发过程相对简单。新来一个连接就会创建一个新的线程处理。随着请求并发度的提升,BIO 很快遇到了性能瓶颈。JDK 1.4 以后开始引入了 NIO 技术,支持 select 和 poll;JDK 1.5 支持了 epoll;JDK 1.7 发布了 NIO2,支持 AIO 模型。Java 在网络领域取得了长足的进步。

既然 JDK NIO 性能已经非常优秀,为什么还要选择 Netty?这是因为 Netty 做了 JDK 该做的事,但是做得更加完备。我们一起看下 Netty 相比 JDK NIO 有哪些突出的优势。

  • 易用性。 我们使用 JDK NIO 编程需要了解很多复杂的概念,比如 Channels、Selectors、Sockets、Buffers 等,编码复杂程度令人发指。相反,Netty 在 NIO 基础上进行了更高层次的封装,屏蔽了 NIO 的复杂性;Netty 封装了更加人性化的 API,统一的 API(阻塞/非阻塞) 大大降低了开发者的上手难度;与此同时,Netty 提供了很多开箱即用的工具,例如常用的行解码器、长度域解码器等,而这些在 JDK NIO 中都需要你自己实现。
  • 稳定性。 Netty 更加可靠稳定,修复和完善了 JDK NIO 较多已知问题,例如臭名昭著的 select 空转导致 CPU 消耗 100%,TCP 断线重连,keep-alive 检测等问题。
  • 可扩展性。 Netty 的可扩展性在很多地方都有体现,这里我主要列举其中的两点:一个是可定制化的线程模型,用户可以通过启动的配置参数选择 Reactor 线程模型;另一个是可扩展的事件驱动模型,将框架层和业务层的关注点分离。大部分情况下,开发者只需要关注 ChannelHandler 的业务逻辑实现。
更低的资源消耗

作为网络通信框架,需要处理海量的网络数据,那么必然面临有大量的网络对象需要创建和销毁的问题,对于 JVM GC 并不友好。为了降低 JVM 垃圾回收的压力,Netty 主要采用了两种优化手段:

  • 对象池复用技术。 Netty 通过复用对象,避免频繁创建和销毁带来的开销。
  • 零拷贝技术。 除了操作系统级别的零拷贝技术外,Netty 提供了更多面向用户态的零拷贝技术,例如 Netty 在 I/O 读写时直接使用 DirectBuffer,从而避免了数据在堆内存和堆外内存之间的拷贝。

因为 Netty 不仅做到了高性能、低延迟以及更低的资源消耗,还完美弥补了 Java NIO 的缺陷,所以在网络编程时越来越受到开发者们的青睐。

网络框架的选型

很多开发者都使用过 Tomcat,Tomcat 作为一款非常优秀的 Web 服务器看上去已经帮我们解决了类似问题,那么它与 Netty 到底有什么不同?

Netty 和 Tomcat 最大的区别在于对通信协议的支持,可以说 Tomcat 是一个 HTTP Server,它主要解决 HTTP 协议层的传输,而 Netty 不仅支持 HTTP 协议,还支持 SSH、TLS/SSL 等多种应用层的协议,而且能够自定义应用层协议。

Tomcat 需要遵循 Servlet 规范,在 Servlet 3.0 之前采用的是同步阻塞模型,Tomcat 6.x 版本之后已经支持 NIO,性能得到较大提升。然而 Netty 与 Tomcat 侧重点不同,所以不需要受到 Servlet 规范的约束,可以最大化发挥 NIO 特性。

如果你仅仅需要一个 HTTP 服务器,那么我推荐你使用 Tomcat。术业有专攻,Tomcat 在这方面的成熟度和稳定性更好。但如果你需要做面向 TCP 的网络应用开发,那么 Netty 才是你最佳的选择。

此外,比较出名的网络框架还有 Mina 和 Grizzly。Mina 是 Apache Directory 服务器底层的 NIO 框架,由于 Mina 和 Netty 都是 Trustin Lee 主导的作品,所以两者在设计理念上基本一致。Netty 出现的时间更晚,可以认为是 Mina 的升级版,解决了 Mina 一些设计上的问题。比如 Netty 提供了可扩展的编解码接口、优化了 ByteBuffer 的分配方式,让用户使用起来更为便捷、安全。Grizzly 出身 Sun 公司,从设计理念上看没有 Netty 优雅,几乎是对 Java NIO 比较初级的封装,目前业界使用的范围也很小。

综上所述,Netty 是我们一个较好的选择。

Netty 的发展现状

Netty 如此成功离不开社区的精心运营,迭代周期短且文档比较齐全,如果你遇到任何问题通过 issue 或者邮件都可以得到非常及时的答复。

你可以去官方社区学习相关资料,下面这些网站可以帮助你学习。

  • 官方社区。
  • GitHub。截止至 2020 年 7 月,2.4w+ star,一共被 4w+ 的项目所使用。

Netty 官方提供 3.x、4.x 的稳定版本,之前一直处于测试阶段的 5.x 版本已被作者放弃维护。此前,官方从未对外发布过任何 5.x 的稳定版本。我在工作中也会碰到一些业务方在开发新项目时直接使用 Netty 5.x 版本的情况,这是因为不少人信任 Netty 社区,并认为这样可以避免以后升级。可惜这一省事之举随着 5.x 版本废弃后全白费了。不过这也给我们带来了一个经验教训:尽可能不要在生产环境使用任何非稳定版本的组件。

如果没有项目历史包袱,目前主流推荐 Netty 4.x 的稳定版本,Netty 3.x 到 4.x 版本发生了较大变化,属于不兼容升级,下面我们初步了解下 4.x 版本有哪些值得你关注的变化和新特性。

  • 项目结构:模块化程度更高,包名从 org.jboss.netty 更新为 io.netty,不再属于 Jboss。
  • 常用 API:大多 API 都已经支持流式风格,更多新的 API 参考以下网址:https://netty.io/news/2013/06/18/4-0-0-CR5.html。
  • Buffer 相关优化:Buffer 相关功能调整了现在 5 点。
    1. ChannelBuffer 变更为 ByteBuf,Buffer 相关的工具类可以独立使用。由于人性化的 Buffer API 设计,它已经成为 Java ByteBuffer 的完美替代品。
    2. Buffer 统一为动态变化,可以更安全地更改 Buffer 的容量。
    3. 增加新的数据类型 CompositeByteBuf,可以用于减少数据拷贝。
    4. GC 更加友好,增加池化缓存,4.1 版本开始 jemalloc 成为默认内存分配方式。
    5. 内存泄漏检测功能。
  • 通用工具类:io.netty.util.concurrent 包中提供了较多异步编程的数据结构。
  • 更加严谨的线程模型控制,降低用户编写 ChannelHandler 的心智,不必过于担心线程安全问题。

可见 Netty 4.x 带来了很多提升,性能、健壮性都变得更加强大了。Netty 精益求精的设计精神值得每个人学习。当然,其中还有更多细节变化,感兴趣的同学可以参考以下网址:https://netty.io/wiki/new-and-noteworthy-in-4.0.html。如果你现在对这些概念还不是很清晰,也不必担心,专栏后续的内容中我都会具体讲解。

谁在使用 Netty?

Netty 凭借其强大的社区影响力,越来越多的公司逐渐采用Netty 作为他们的底层通信框架,下图中我列举了一些正在使用 Netty 的公司,一起感受下它的热度吧。

Drawing 7.png

Netty 经过很多出名产品在线上的大规模验证,其健壮性和稳定性都被业界认可,其中典型的产品有一下几个。

  • 服务治理:Apache Dubbo、gRPC。
  • 大数据:Hbase、Spark、Flink、Storm。
  • 搜索引擎:Elasticsearch。
  • 消息队列:RocketMQ、ActiveMQ。

还有更多优秀的产品我就不一一列举了,感兴趣的小伙伴可以参考下面网址:https://netty.io/wiki/related-projects.html。

总结

作为正式学习专栏前的开胃餐,今天我主要向你介绍了 Netty 的优势与特色,同时提到了 I/O 多路复用、Reactor 设计模式、零拷贝等必备的知识点,帮助你对 Netty 有了基本的认识。相信你一定意犹未尽,在后续的章节中我们将逐步走进 Netty 的世界。

最后,我也想给你留一个思考题:Netty 的内部结构大概如何?为什么 Netty 能够成为如此优秀的工具?下节课我将为你解答这个问题。

2、纵览全局,把握Netty整体架构脉络

上次课程中我介绍了 Netty 的功能特性和优势,从今天开始我们正式进入 Netty 技术原理的学习。

学习任何一门技术都需要有全局观,在开始上手的时候,不宜陷入琐碎的技术细节,避免走进死胡同。这节课我们以 Netty 整体架构设计为切入点,来带你明确学习目标,建立起 Netty 的学习主线,这条主线将贯穿我们整个的学习过程。

本节课以 Netty 4.1.42 为基准版本,我将分别从 Netty 整体结构逻辑架构源码结构三个方面对其进行介绍。

Netty 整体结构

Netty 是一个设计非常用心的网络基础组件,Netty 官网给出了有关 Netty 的整体功能模块结构,却没有其他更多的解释。从图中,我们可以清晰地看出 Netty 结构一共分为三个模块:

Drawing 0.png

1. Core 核心层

Core 核心层是 Netty 最精华的内容,它提供了底层网络通信的通用抽象和实现,包括可扩展的事件模型、通用的通信 API、支持零拷贝的 ByteBuf 等。

2. Protocol Support 协议支持层

协议支持层基本上覆盖了主流协议的编解码实现,如 HTTP、SSL、Protobuf、压缩、大文件传输、WebSocket、文本、二进制等主流协议,此外 Netty 还支持自定义应用层协议。Netty 丰富的协议支持降低了用户的开发成本,基于 Netty 我们可以快速开发 HTTP、WebSocket 等服务。

3. Transport Service 传输服务层

传输服务层提供了网络传输能力的定义和实现方法。它支持 Socket、HTTP 隧道、虚拟机管道等传输方式。Netty 对 TCP、UDP 等数据传输做了抽象和封装,用户可以更聚焦在业务逻辑实现上,而不必关系底层数据传输的细节。

Netty 的模块设计具备较高的通用性和可扩展性,它不仅是一个优秀的网络框架,还可以作为网络编程的工具箱。Netty 的设计理念非常优雅,值得我们学习借鉴。

现在,我们对 Netty 的整体结构已经有了一个大概的印象,下面我们一起看下 Netty 的逻辑架构,学习下 Netty 是如何做功能分解的。

Netty 逻辑架构

下图是 Netty 的逻辑处理架构。Netty 的逻辑处理架构为典型网络分层架构设计,共分为网络通信层、事件调度层、服务编排层,每一层各司其职。图中包含了 Netty 每一层所用到的核心组件。我将为你介绍 Netty 的每个逻辑分层中的各个核心组件以及组件之间是如何协调运作的。

Drawing 1.png

网络通信层

网络通信层的职责是执行网络 I/O 的操作。它支持多种网络协议和 I/O 模型的连接操作。当网络数据读取到内核缓冲区后,会触发各种网络事件,这些网络事件会分发给事件调度层进行处理。

网络通信层的核心组件包含BootStrap、ServerBootStrap、Channel三个组件。

  • BootStrap & ServerBootStrap

Bootstrap 是“引导”的意思,它主要负责整个 Netty 程序的启动、初始化、服务器连接等过程,它相当于一条主线,串联了 Netty 的其他核心组件。

如下图所示,Netty 中的引导器共分为两种类型:一个为用于客户端引导的 Bootstrap,另一个为用于服务端引导的 ServerBootStrap,它们都继承自抽象类 AbstractBootstrap。

Drawing 2.png

Bootstrap 和 ServerBootStrap 十分相似,两者非常重要的区别在于 Bootstrap 可用于连接远端服务器,只绑定一个 EventLoopGroup。而 ServerBootStrap 则用于服务端启动绑定本地端口,会绑定两个 EventLoopGroup,这两个 EventLoopGroup 通常称为 Boss 和 Worker。

ServerBootStrap 中的 Boss 和 Worker 是什么角色呢?它们之间又是什么关系?这里的 Boss 和 Worker 可以理解为“老板”和“员工”的关系。每个服务器中都会有一个 Boss,也会有一群做事情的 Worker。Boss 会不停地接收新的连接,然后将连接分配给一个个 Worker 处理连接。

有了 Bootstrap 组件,我们可以更加方便地配置和启动 Netty 应用程序,它是整个 Netty 的入口,串接了 Netty 所有核心组件的初始化工作。

  • Channel

Channel 的字面意思是“通道”,它是网络通信的载体。Channel提供了基本的 API 用于网络 I/O 操作,如 register、bind、connect、read、write、flush 等。Netty 自己实现的 Channel 是以 JDK NIO Channel 为基础的,相比较于 JDK NIO,Netty 的 Channel 提供了更高层次的抽象,同时屏蔽了底层 Socket 的复杂性,赋予了 Channel 更加强大的功能,你在使用 Netty 时基本不需要再与 Java Socket 类直接打交道。

下图是 Channel 家族的图谱。AbstractChannel 是整个家族的基类,派生出 AbstractNioChannel、AbstractOioChannel、AbstractEpollChannel 等子类,每一种都代表了不同的 I/O 模型和协议类型。常用的 Channel 实现类有:

Drawing 3.png

  • NioServerSocketChannel 异步 TCP 服务端。
  • NioSocketChannel 异步 TCP 客户端。
  • OioServerSocketChannel 同步 TCP 服务端。
  • OioSocketChannel 同步 TCP 客户端。
  • NioDatagramChannel 异步 UDP 连接。
  • OioDatagramChannel 同步 UDP 连接。

当然 Channel 会有多种状态,如连接建立、连接注册、数据读写、连接销毁等。随着状态的变化,Channel 处于不同的生命周期,每一种状态都会绑定相应的事件回调,下面的表格我列举了 Channel 最常见的状态所对应的事件回调。

事件说明
channelRegisteredChannel 创建后被注册到 EventLoop 上
channelUnregisteredChannel 创建后未注册或者从 EventLoop 取消注册
channelActiveChannel 处于就绪状态,可以被读写
channelInactiveChannel 处于非就绪状态
channelReadChannel 可以从远端读取到数据
channelReadCompleteChannel 读取数据完成

有关网络通信层我就先介绍到这里,简单地总结一下。BootStrap 和 ServerBootStrap 分别负责客户端和服务端的启动,它们是非常强大的辅助工具类;Channel 是网络通信的载体,提供了与底层 Socket 交互的能力。那么 Channel 生命周期内的事件都是如何被处理的呢?那就是 Netty 事件调度层的工作职责了。

事件调度层

事件调度层的职责是通过 Reactor 线程模型对各类事件进行聚合处理,通过 Selector 主循环线程集成多种事件( I/O 事件、信号事件、定时事件等),实际的业务处理逻辑是交由服务编排层中相关的 Handler 完成。

事件调度层的核心组件包括 EventLoopGroup、EventLoop

  • EventLoopGroup & EventLoop

EventLoopGroup 本质是一个线程池,主要负责接收 I/O 请求,并分配线程执行处理请求。在下图中,我为你讲述了 EventLoopGroups、EventLoop 与 Channel 的关系。

Drawing 4.png

从上图中,我们可以总结出 EventLoopGroup、EventLoop、Channel 的几点关系。

  1. 一个 EventLoopGroup 往往包含一个或者多个 EventLoop。EventLoop 用于处理 Channel 生命周期内的所有 I/O 事件,如 accept、connect、read、write 等 I/O 事件。
  2. EventLoop 同一时间会与一个线程绑定,每个 EventLoop 负责处理多个 Channel。
  3. 每新建一个 Channel,EventLoopGroup 会选择一个 EventLoop 与其绑定。该 Channel 在生命周期内都可以对 EventLoop 进行多次绑定和解绑。

下图是 EventLoopGroup 的家族图谱。可以看出 Netty 提供了 EventLoopGroup 的多种实现,而且 EventLoop 则是 EventLoopGroup 的子接口,所以也可以把 EventLoop 理解为 EventLoopGroup,但是它只包含一个 EventLoop 。

Drawing 5.png

EventLoopGroup 的实现类是 NioEventLoopGroup,NioEventLoopGroup 也是 Netty 中最被推荐使用的线程模型。NioEventLoopGroup 继承于 MultithreadEventLoopGroup,是基于 NIO 模型开发的,可以把 NioEventLoopGroup 理解为一个线程池,每个线程负责处理多个 Channel,而同一个 Channel 只会对应一个线程。

EventLoopGroup 是 Netty 的核心处理引擎,那么 EventLoopGroup 和之前课程所提到的 Reactor 线程模型到底是什么关系呢?其实 EventLoopGroup 是 Netty Reactor 线程模型的具体实现方式,Netty 通过创建不同的 EventLoopGroup 参数配置,就可以支持 Reactor 的三种线程模型:

  1. 单线程模型:EventLoopGroup 只包含一个 EventLoop,Boss 和 Worker 使用同一个EventLoopGroup;
  2. 多线程模型:EventLoopGroup 包含多个 EventLoop,Boss 和 Worker 使用同一个EventLoopGroup;
  3. 主从多线程模型:EventLoopGroup 包含多个 EventLoop,Boss 是主 Reactor,Worker 是从 Reactor,它们分别使用不同的 EventLoopGroup,主 Reactor 负责新的网络连接 Channel 创建,然后把 Channel 注册到从 Reactor。

在介绍完事件调度层之后,可以说 Netty 的发动机已经转起来了,事件调度层负责监听网络连接和读写操作,然后触发各种类型的网络事件,需要一种机制管理这些错综复杂的事件,并有序地执行,接下来我们便一起学习 Netty 服务编排层中核心组件的职责。

服务编排层

服务编排层的职责是负责组装各类服务,它是 Netty 的核心处理链,用以实现网络事件的动态编排和有序传播。

服务编排层的核心组件包括 ChannelPipelineChannelHandler、ChannelHandlerContext

  • ChannelPipeline

ChannelPipeline 是 Netty 的核心编排组件,负责组装各种 ChannelHandler,实际数据的编解码以及加工处理操作都是由 ChannelHandler 完成的。ChannelPipeline 可以理解为ChannelHandler 的实例列表——内部通过双向链表将不同的 ChannelHandler 链接在一起。当 I/O 读写事件触发时,ChannelPipeline 会依次调用 ChannelHandler 列表对 Channel 的数据进行拦截和处理。

ChannelPipeline 是线程安全的,因为每一个新的 Channel 都会对应绑定一个新的 ChannelPipeline。一个 ChannelPipeline 关联一个 EventLoop,一个 EventLoop 仅会绑定一个线程。

ChannelPipeline、ChannelHandler 都是高度可定制的组件。开发者可以通过这两个核心组件掌握对 Channel 数据操作的控制权。下面我们看一下 ChannelPipeline 的结构图:

Drawing 6.png

从上图可以看出,ChannelPipeline 中包含入站 ChannelInboundHandler 和出站 ChannelOutboundHandler 两种处理器,我们结合客户端和服务端的数据收发流程来理解 Netty 的这两个概念。

Drawing 7.png

客户端和服务端都有各自的 ChannelPipeline。以客户端为例,数据从客户端发向服务端,该过程称为出站,反之则称为入站。数据入站会由一系列 InBoundHandler 处理,然后再以相反方向的 OutBoundHandler 处理后完成出站。我们经常使用的编码 Encoder 是出站操作,解码 Decoder 是入站操作。服务端接收到客户端数据后,需要先经过 Decoder 入站处理后,再通过 Encoder 出站通知客户端。所以客户端和服务端一次完整的请求应答过程可以分为三个步骤:客户端出站(请求数据)、服务端入站(解析数据并执行业务逻辑)、服务端出站(响应结果)。

  • ChannelHandler & ChannelHandlerContext

在介绍 ChannelPipeline 的过程中,想必你已经对 ChannelHandler 有了基本的概念,数据的编解码工作以及其他转换工作实际都是通过 ChannelHandler 处理的。站在开发者的角度,最需要关注的就是 ChannelHandler,我们很少会直接操作 Channel,都是通过 ChannelHandler 间接完成。

下图描述了 Channel 与 ChannelPipeline 的关系,从图中可以看出,每创建一个 Channel 都会绑定一个新的 ChannelPipeline,ChannelPipeline 中每加入一个 ChannelHandler 都会绑定一个 ChannelHandlerContext。由此可见,ChannelPipeline、ChannelHandlerContext、ChannelHandler 三个组件的关系是密切相关的,那么你一定会有疑问,每个 ChannelHandler 绑定ChannelHandlerContext 的作用是什么呢?

Drawing 8.png

ChannelHandlerContext 用于保存 ChannelHandler 上下文,通过 ChannelHandlerContext 我们可以知道 ChannelPipeline 和 ChannelHandler 的关联关系。ChannelHandlerContext 可以实现 ChannelHandler 之间的交互,ChannelHandlerContext 包含了 ChannelHandler 生命周期的所有事件,如 connect、bind、read、flush、write、close 等。此外,你可以试想这样一个场景,如果每个 ChannelHandler 都有一些通用的逻辑需要实现,没有 ChannelHandlerContext 这层模型抽象,你是不是需要写很多相同的代码呢?

以上便是 Netty 的逻辑处理架构,可以看出 Netty 的架构分层设计得非常合理,屏蔽了底层 NIO 以及框架层的实现细节,对于业务开发者来说,只需要关注业务逻辑的编排和实现即可。

组件关系梳理

当你了解每个 Netty 核心组件的概念后。你会好奇这些组件之间如何协作?结合客户端和服务端的交互流程,我画了一张图,为你完整地梳理一遍 Netty 内部逻辑的流转。

Drawing 9.png

  • 服务端启动初始化时有 Boss EventLoopGroup 和 Worker EventLoopGroup 两个组件,其中 Boss 负责监听网络连接事件。当有新的网络连接事件到达时,则将 Channel 注册到 Worker EventLoopGroup。
  • Worker EventLoopGroup 会被分配一个 EventLoop 负责处理该 Channel 的读写事件。每个 EventLoop 都是单线程的,通过 Selector 进行事件循环。
  • 当客户端发起 I/O 读写事件时,服务端 EventLoop 会进行数据的读取,然后通过 Pipeline 触发各种监听器进行数据的加工处理。
  • 客户端数据会被传递到 ChannelPipeline 的第一个 ChannelInboundHandler 中,数据处理完成后,将加工完成的数据传递给下一个 ChannelInboundHandler。
  • 当数据写回客户端时,会将处理结果在 ChannelPipeline 的 ChannelOutboundHandler 中传播,最后到达客户端。

以上便是 Netty 各个组件的整体交互流程,你只需要对每个组件的工作职责有所了解,心中可以串成一条流水线即可,具体每个组件的实现原理后续课程我们会深入介绍。

Netty 源码结构

Netty 源码分为多个模块,模块之间职责划分非常清楚。如同上文整体功能模块一样,Netty 源码模块的划分也是基本契合的。

Drawing 10.png

我们不仅可以使用 Netty all-in-one 的 Jar 包,也可以单独使用其中某些工具包。下面我根据 Netty 的分层结构以及实际的业务场景具体介绍 Netty 中常用的工具包。

Core 核心层模块

netty-common模块是 Netty 的核心基础包,提供了丰富的工具类,其他模块都需要依赖它。在 common 模块中,常用的包括通用工具类自定义并发包

  • 通用工具类:比如定时器工具 TimerTask、时间轮 HashedWheelTimer 等。
  • 自定义并发包:比如异步模型****Future & Promise、相比 JDK 增强的 FastThreadLocal 等。

netty-buffer 模块中Netty自己实现了的一个更加完备的ByteBuf 工具类,用于网络通信中的数据载体。由于人性化的 Buffer API 设计,它已经成为 Java ByteBuffer 的完美替代品。ByteBuf 的动态性设计不仅解决了 ByteBuffer 长度固定造成的内存浪费问题,而且更安全地更改了 Buffer 的容量。此外 Netty 针对 ByteBuf 做了很多优化,例如缓存池化、减少数据拷贝的 CompositeByteBuf 等。

netty-resover模块主要提供了一些有关基础设施的解析工具,包括 IP Address、Hostname、DNS 等。

Protocol Support 协议支持层模块

netty-codec模块主要负责编解码工作,通过编解码实现原始字节数据与业务实体对象之间的相互转化。如下图所示,Netty 支持了大多数业界主流协议的编解码器,如 HTTP、HTTP2、Redis、XML 等,为开发者节省了大量的精力。此外该模块提供了抽象的编解码类 ByteToMessageDecoder 和 MessageToByteEncoder,通过继承这两个类我们可以轻松实现自定义的编解码逻辑。

Lark20201021-150506.png

netty-handler模块主要负责数据处理工作。Netty 中关于数据处理的部分,本质上是一串有序 handler 的集合。netty-handler 模块提供了开箱即用的 ChannelHandler 实现类,例如日志、IP 过滤、流量整形等,如果你需要这些功能,仅需在 pipeline 中加入相应的 ChannelHandler 即可。

Transport Service 传输服务层模块

netty-transport 模块可以说是 Netty 提供数据处理和传输的核心模块。该模块提供了很多非常重要的接口,如 Bootstrap、Channel、ChannelHandler、EventLoop、EventLoopGroup、ChannelPipeline 等。其中 Bootstrap 负责客户端或服务端的启动工作,包括创建、初始化 Channel 等;EventLoop 负责向注册的 Channel 发起 I/O 读写操作;ChannelPipeline 负责 ChannelHandler 的有序编排,这些组件在介绍 Netty 逻辑架构的时候都有所涉及。

以上只介绍了 Netty 常用的功能模块,还有很多模块就不一一列举了,有兴趣的同学可以在 GitHub(https://github.com/netty/netty)查询 Netty 的源码。

总结

本节课我们分别从整体结构、逻辑架构以及源码结构对 Netty 的整体架构进行了初步介绍,可见 Netty 的分层架构设计非常合理,实现了各层之间的逻辑解耦,对于开发者来说,只需要扩展业务逻辑即可。

在我刚开始接触 Netty 时,面对太多的核心组件刚开始是无从下手的,所以在 Netty 的逻辑架构中我梳理了 Netty 中各个核心组件的关系,希望能够帮助你快速入门。从下节课开始我们会对 Netty 逻辑架构中的核心组件做详细的介绍。

3、引导器作用:客户端和服务端启动都要做些什么

你好,我是若地。上节课我们介绍了 Netty 中核心组件的作用以及组件协作的方式方法。从这节课开始,我们将对 Netty 的每个核心组件依次进行深入剖析解读。我会结合相应的代码示例讲解,帮助你快速上手 Netty。

我们在使用 Netty 编写网络应用程序的时候,一定会从引导器 Bootstrap开始入手。Bootstrap 作为整个 Netty 客户端和服务端的程序入口,可以把 Netty 的核心组件像搭积木一样组装在一起。本节课我会从 Netty 的引导器Bootstrap出发,带你学习如何使用 Netty 进行最基本的程序开发。

从一个简单的 HTTP 服务器开始

HTTP 服务器是我们平时最常用的工具之一。同传统 Web 容器 Tomcat、Jetty 一样,Netty 也可以方便地开发一个 HTTP 服务器。我从一个简单的 HTTP 服务器开始,通过程序示例为你展现 Netty 程序如何配置启动,以及引导器如何与核心组件产生联系。

完整地实现一个高性能、功能完备、健壮性强的 HTTP 服务器非常复杂,本文仅为了方便理解 Netty 网络应用开发的基本过程,所以只实现最基本的请求-响应的流程:

  1. 搭建 HTTP 服务器,配置相关参数并启动。
  2. 从浏览器或者终端发起 HTTP 请求。
  3. 成功得到服务端的响应结果。

Netty 的模块化设计非常优雅,客户端或者服务端的启动方式基本是固定的。作为开发者来说,只要照葫芦画瓢即可轻松上手。大多数场景下,你只需要实现与业务逻辑相关的一系列 ChannelHandler,再加上 Netty 已经预置了 HTTP 相关的编解码器就可以快速完成服务端框架的搭建。所以,我们只需要两个类就可以完成一个最简单的 HTTP 服务器,它们分别为服务器启动类业务逻辑处理类,结合完整的代码实现我将对它们分别进行讲解。

服务端启动类

所有 Netty 服务端的启动类都可以采用如下代码结构进行开发。简单梳理一下流程:首先创建引导器;然后配置线程模型,通过引导器绑定业务逻辑处理器,并配置一些网络参数;最后绑定端口,就可以完成服务器的启动了。

public class HttpServer {
    public void start(int port) throws Exception {
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .localAddress(new InetSocketAddress(port))
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        public void initChannel(SocketChannel ch) {
                            ch.pipeline()
                                    .addLast("codec", new HttpServerCodec())                  // HTTP 编解码
                                    .addLast("compressor", new HttpContentCompressor())       // HttpContent 压缩
                                    .addLast("aggregator", new HttpObjectAggregator(65536))   // HTTP 消息聚合
                                    .addLast("handler", new HttpServerHandler());             // 自定义业务逻辑处理器
                        }
                    })
                    .childOption(ChannelOption.SO_KEEPALIVE, true);
            ChannelFuture f = b.bind().sync();
            System.out.println("Http Server started, Listening on " + port);
            f.channel().closeFuture().sync();
        } finally {
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }
    public static void main(String[] args) throws Exception {
        new HttpServer().start(8088);
    }
}
服务端业务逻辑处理类

如下代码所示,HttpServerHandler 是业务自定义的逻辑处理类。它是入站 ChannelInboundHandler 类型的处理器,负责接收解码后的 HTTP 请求数据,并将请求处理结果写回客户端。

public class HttpServerHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest msg) {
        String content = String.format("Receive http request, uri: %s, method: %s, content: %s%n", msg.uri(), msg.method(), msg.content().toString(CharsetUtil.UTF_8));
        FullHttpResponse response = new DefaultFullHttpResponse(
                HttpVersion.HTTP_1_1,
                HttpResponseStatus.OK,
                Unpooled.wrappedBuffer(content.getBytes()));
        ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
    }
}

通过上面两个类,我们可以完成 HTTP 服务器最基本的请求-响应流程,测试步骤如下:

  1. 启动 HttpServer 的 main 函数。
  2. 终端或浏览器发起 HTTP 请求。

测试结果输出如下:

$ curl http://localhost:8088/abc
$ Receive http request, uri: /abc, method: GET, content:

当然,你也可以使用 Netty 自行实现 HTTP Client,客户端和服务端的启动类代码十分相似,我在附录部分提供了一份 HTTPClient 的实现代码仅供大家参考。

通过上述一个简单的 HTTP 服务示例,我们基本熟悉了 Netty 的编程模式。下面我将结合这个例子对 Netty 的引导器展开详细的介绍。

引导器实践指南

Netty 服务端的启动过程大致分为三个步骤:

  1. 配置线程池;
  2. Channel 初始化;
  3. 端口绑定。

下面,我将逐一为大家介绍每一步具体需要做哪些工作。

配置线程池

Netty 是采用 Reactor 模型进行开发的,可以非常容易切换三种 Reactor 模式:单线程模式多线程模式主从多线程模式

单线程模式

Reactor 单线程模型所有 I/O 操作都由一个线程完成,所以只需要启动一个 EventLoopGroup 即可。

EventLoopGroup group = new NioEventLoopGroup(1);
ServerBootstrap b = new ServerBootstrap();
b.group(group)
多线程模式

Reactor 单线程模型有非常严重的性能瓶颈,因此 Reactor 多线程模型出现了。在 Netty 中使用 Reactor 多线程模型与单线程模型非常相似,区别是 NioEventLoopGroup 可以不需要任何参数,它默认会启动 2 倍 CPU 核数的线程。当然,你也可以自己手动设置固定的线程数。

EventLoopGroup group = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(group)
主从多线程模式

在大多数场景下,我们采用的都是主从多线程 Reactor 模型。Boss 是主 Reactor,Worker 是从 Reactor。它们分别使用不同的 NioEventLoopGroup,主 Reactor 负责处理 Accept,然后把 Channel 注册到从 Reactor 上,从 Reactor 主要负责 Channel 生命周期内的所有 I/O 事件。

EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)

从上述三种 Reactor 线程模型的配置方法可以看出:Netty 线程模型的可定制化程度很高。它只需要简单配置不同的参数,便可启用不同的 Reactor 线程模型,而且无需变更其他的代码,很大程度上降低了用户开发和调试的成本。

Channel 初始化

设置 Channel 类型

NIO 模型是 Netty 中最成熟且被广泛使用的模型。因此,推荐 Netty 服务端采用 NioServerSocketChannel 作为 Channel 的类型,客户端采用 NioSocketChannel。设置方式如下:

 b.channel(NioServerSocketChannel.class);

当然,Netty 提供了多种类型的 Channel 实现类,你可以按需切换,例如 OioServerSocketChannel、EpollServerSocketChannel 等。

注册 ChannelHandler

在 Netty 中可以通过 ChannelPipeline 去注册多个 ChannelHandler,每个 ChannelHandler 各司其职,这样就可以实现最大化的代码复用,充分体现了 Netty 设计的优雅之处。那么如何通过引导器添加多个 ChannelHandler 呢?其实很简单,我们看下 HTTP 服务器代码示例:

b.childHandler(new ChannelInitializer<SocketChannel>() {
    @Override
    public void initChannel(SocketChannel ch) {
        ch.pipeline()
                .addLast("codec", new HttpServerCodec())
                .addLast("compressor", new HttpContentCompressor())
                .addLast("aggregator", new HttpObjectAggregator(65536)) 
                .addLast("handler", new HttpServerHandler());
    }
})

ServerBootstrap 的 childHandler() 方法需要注册一个 ChannelHandler。ChannelInitializer是实现了 ChannelHandler接口的匿名类,通过实例化 ChannelInitializer 作为 ServerBootstrap 的参数。

Channel 初始化时都会绑定一个 Pipeline,它主要用于服务编排。Pipeline 管理了多个 ChannelHandler。I/O 事件依次在 ChannelHandler 中传播,ChannelHandler 负责业务逻辑处理。上述 HTTP 服务器示例中使用链式的方式加载了多个 ChannelHandler,包含HTTP 编解码处理器、HTTPContent 压缩处理器、HTTP 消息聚合处理器、自定义业务逻辑处理器

在以前的章节中,我们介绍了 ChannelPipeline 中入站 ChannelInboundHandler出站 ChannelOutboundHandler的概念,在这里结合 HTTP 请求-响应的场景,分析下数据在 ChannelPipeline 中的流向。当服务端收到 HTTP 请求后,会依次经过 HTTP 编解码处理器、HTTPContent 压缩处理器、HTTP 消息聚合处理器、自定义业务逻辑处理器分别处理后,再将最终结果通过 HTTPContent 压缩处理器、HTTP 编解码处理器写回客户端。

设置 Channel 参数

Netty 提供了十分便捷的方法,用于设置 Channel 参数。关于 Channel 的参数数量非常多,如果每个参数都需要自己设置,那会非常繁琐。幸运的是 Netty 提供了默认参数设置,实际场景下默认参数已经满足我们的需求,我们仅需要修改自己关系的参数即可。

b.option(ChannelOption.SO_KEEPALIVE, true);

ServerBootstrap 设置 Channel 属性有optionchildOption两个方法,option 主要负责设置 Boss 线程组,而 childOption 对应的是 Worker 线程组。

这里我列举了经常使用的参数含义,你可以结合业务场景,按需设置。

参数含义
SO_KEEPALIVE设置为 true 代表启用了 TCP SO_KEEPALIVE 属性,TCP 会主动探测连接状态,即连接保活
SO_BACKLOG已完成三次握手的请求队列最大长度,同一时刻服务端可能会处理多个连接,在高并发海量连接的场景下,该参数应适当调大
TCP_NODELAYNetty 默认是 true,表示立即发送数据。如果设置为 false 表示启用 Nagle 算法,该算法会将 TCP 网络数据包累积到一定量才会发送,虽然可以减少报文发送的数量,但是会造成一定的数据延迟。Netty 为了最小化数据传输的延迟,默认禁用了 Nagle 算法
SO_SNDBUFTCP 数据发送缓冲区大小
SO_RCVBUFTCP数据接收缓冲区大小,TCP数据接收缓冲区大小
SO_LINGER设置延迟关闭的时间,等待缓冲区中的数据发送完成
CONNECT_TIMEOUT_MILLIS建立连接的超时时间
端口绑定

在完成上述 Netty 的配置之后,bind() 方法会真正触发启动,sync() 方法则会阻塞,直至整个启动过程完成,具体使用方式如下:

ChannelFuture f = b.bind().sync();

bind() 方法涉及的细节比较多,我们将在《源码篇:从 Linux 出发深入剖析服务端启动流程》课程中做详细地解析,在这里就先不做展开了。

关于如何使用引导器开发一个 Netty 网络应用我们就介绍完了,服务端的启动过程一定离不开配置线程池、Channel 初始化、端口绑定三个步骤,在 Channel 初始化的过程中最重要的就是绑定用户实现的自定义业务逻辑。是不是特别简单?你可以参考本节课的示例,自己尝试开发一个简单的程序练练手。

总结

本节课我们围绕 Netty 的引导器,学习了如何开发最基本的网络应用程序。引导器串接了 Netty 的所有核心组件,通过引导器作为学习 Netty 的切入点有助于我们快速上手。Netty 的引导器作为一个非常方便的工具,避免我们再去手动完成繁琐的 Channel 的创建和配置等过程,其中有很多知识点可以深挖,在后续源码章节中我们再一起探索它的实现原理。

附录

HTTP 客户端类
public class HttpClient {
    public void connect(String host, int port) throws Exception {
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap b = new Bootstrap();
            b.group(group);
            b.channel(NioSocketChannel.class);
            b.option(ChannelOption.SO_KEEPALIVE, true);
            b.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                public void initChannel(SocketChannel ch) {
                    ch.pipeline().addLast(new HttpResponseDecoder());
                    ch.pipeline().addLast(new HttpRequestEncoder());
                    ch.pipeline().addLast(new HttpClientHandler());
                }
            });
            ChannelFuture f = b.connect(host, port).sync();
            URI uri = new URI("http://127.0.0.1:8088");
            String content = "hello world";
            DefaultFullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET,
                    uri.toASCIIString(), Unpooled.wrappedBuffer(content.getBytes(StandardCharsets.UTF_8)));
            request.headers().set(HttpHeaderNames.HOST, host);
            request.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
            request.headers().set(HttpHeaderNames.CONTENT_LENGTH, request.content().readableBytes());
            f.channel().write(request);
            f.channel().flush();
            f.channel().closeFuture().sync();
        } finally {
            group.shutdownGracefully();
        }
    }
    public static void main(String[] args) throws Exception {
        HttpClient client = new HttpClient();
        client.connect("127.0.0.1", 8088);
    }
}
客户端业务处理类
public class HttpClientHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (msg instanceof HttpContent) {
            HttpContent content = (HttpContent) msg;
            ByteBuf buf = content.content();
            System.out.println(buf.toString(io.netty.util.CharsetUtil.UTF_8));
            buf.release();
        }
    }
}

4、事件调度层:为什么EventLoop是Netty的精髓

你好,我是若地。通过前面课程的学习,我们已经知道 Netty 高性能的奥秘在于其 Reactor 线程模型。 EventLoop 是 Netty Reactor 线程模型的核心处理引擎,那么它是如何高效地实现事件循环和任务处理机制的呢?本节课我们就一起学习 EventLoop 的实现原理和最佳实践。

再谈 Reactor 线程模型

网络框架的设计离不开 I/O 线程模型,线程模型的优劣直接决定了系统的吞吐量、可扩展性、安全性等。目前主流的网络框架几乎都采用了 I/O 多路复用的方案。Reactor 模式作为其中的事件分发器,负责将读写事件分发给对应的读写事件处理者。大名鼎鼎的 Java 并发包作者 Doug Lea,在 Scalable I/O in Java 一文中阐述了服务端开发中 I/O 模型的演进过程。Netty 中三种 Reactor 线程模型也来源于这篇经典文章。下面我们对这三种 Reactor 线程模型做一个详细的分析。

单线程模型

1.png
(摘自 Lea D. Scalable IO in Java)

上图描述了 Reactor 的单线程模型结构,在 Reactor 单线程模型中,所有 I/O 操作(包括连接建立、数据读写、事件分发等),都是由一个线程完成的。单线程模型逻辑简单,缺陷也十分明显:

  • 一个线程支持处理的连接数非常有限,CPU 很容易打满,性能方面有明显瓶颈;
  • 当多个事件被同时触发时,只要有一个事件没有处理完,其他后面的事件就无法执行,这就会造成消息积压及请求超时;
  • 线程在处理 I/O 事件时,Select 无法同时处理连接建立、事件分发等操作;
  • 如果 I/O 线程一直处于满负荷状态,很可能造成服务端节点不可用。
多线程模型

2.png
(摘自 Lea D. Scalable IO in Java)

由于单线程模型有性能方面的瓶颈,多线程模型作为解决方案就应运而生了。Reactor 多线程模型将业务逻辑交给多个线程进行处理。除此之外,多线程模型其他的操作与单线程模型是类似的,例如读取数据依然保留了串行化的设计。当客户端有数据发送至服务端时,Select 会监听到可读事件,数据读取完毕后提交到业务线程池中并发处理。

主从多线程模型

3.png
(摘自 Lea D. Scalable IO in Java)

主从多线程模型由多个 Reactor 线程组成,每个 Reactor 线程都有独立的 Selector 对象。MainReactor 仅负责处理客户端连接的 Accept 事件,连接建立成功后将新创建的连接对象注册至 SubReactor。再由 SubReactor 分配线程池中的 I/O 线程与其连接绑定,它将负责连接生命周期内所有的 I/O 事件。

Netty 推荐使用主从多线程模型,这样就可以轻松达到成千上万规模的客户端连接。在海量客户端并发请求的场景下,主从多线程模式甚至可以适当增加 SubReactor 线程的数量,从而利用多核能力提升系统的吞吐量。

介绍了上述三种 Reactor 线程模型,再结合它们各自的架构图,我们能大致总结出 Reactor 线程模型运行机制的四个步骤,分别为连接注册事件轮询事件分发任务处理,如下图所示。

4.png

  • 连接注册:Channel 建立后,注册至 Reactor 线程中的 Selector 选择器。
  • 事件轮询:轮询 Selector 选择器中已注册的所有 Channel 的 I/O 事件。
  • 事件分发:为准备就绪的 I/O 事件分配相应的处理线程。
  • 任务处理:Reactor 线程还负责任务队列中的非 I/O 任务,每个 Worker 线程从各自维护的任务队列中取出任务异步执行。

以上介绍了 Reactor 线程模型的演进过程和基本原理,Netty 也同样遵循 Reactor 线程模型的运行机制,下面我们来了解一下 Netty 是如何实现 Reactor 线程模型的。

Netty EventLoop 实现原理

EventLoop 是什么

EventLoop 这个概念其实并不是 Netty 独有的,它是一种事件等待和处理的程序模型,可以解决多线程资源消耗高的问题。例如 Node.js 就采用了 EventLoop 的运行机制,不仅占用资源低,而且能够支撑了大规模的流量访问。

下图展示了 EventLoop 通用的运行模式。每当事件发生时,应用程序都会将产生的事件放入事件队列当中,然后 EventLoop 会轮询从队列中取出事件执行或者将事件分发给相应的事件监听者执行。事件执行的方式通常分为立即执行、延后执行、定期执行几种。

5.png

Netty 如何实现 EventLoop

在 Netty 中 EventLoop 可以理解为 Reactor 线程模型的事件处理引擎,每个 EventLoop 线程都维护一个 Selector 选择器和任务队列 taskQueue。它主要负责处理 I/O 事件、普通任务和定时任务。

Netty 中推荐使用 NioEventLoop 作为实现类,那么 Netty 是如何实现 NioEventLoop 的呢?首先我们来看 NioEventLoop 最核心的 run() 方法源码,本节课我们不会对源码做深入的分析,只是先了解 NioEventLoop 的实现结构。

protected void run() {
    for (;;) {
        try {
            try {
                switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) {
                case SelectStrategy.CONTINUE:
                    continue;
                case SelectStrategy.BUSY_WAIT:
                case SelectStrategy.SELECT:
                    select(wakenUp.getAndSet(false)); // 轮询 I/O 事件
                    if (wakenUp.get()) {
                        selector.wakeup();
                    }
                default:
                }
            } catch (IOException e) {
                rebuildSelector0();
                handleLoopException(e);
                continue;
            }            cancelledKeys = 0;
            needsToSelectAgain = false;
            final int ioRatio = this.ioRatio;
            if (ioRatio == 100) {
                try {
                    processSelectedKeys(); // 处理 I/O 事件
                } finally {
                    runAllTasks(); // 处理所有任务
                }
            } else {
                final long ioStartTime = System.nanoTime();
                try {
                    processSelectedKeys(); // 处理 I/O 事件
                } finally {
                    final long ioTime = System.nanoTime() - ioStartTime;
                    runAllTasks(ioTime * (100 - ioRatio) / ioRatio); // 处理完 I/O 事件,再处理异步任务队列
                }
            }
        } catch (Throwable t) {
            handleLoopException(t);
        }
        try {
            if (isShuttingDown()) {
                closeAll();
                if (confirmShutdown()) {
                    return;
                }
            }
        } catch (Throwable t) {
            handleLoopException(t);
        }
    }
}

上述源码的结构比较清晰,NioEventLoop 每次循环的处理流程都包含事件轮询 select、事件处理 processSelectedKeys、任务处理 runAllTasks 几个步骤,是典型的 Reactor 线程模型的运行机制。而且 Netty 提供了一个参数 ioRatio,可以调整 I/O 事件处理和任务处理的时间比例。下面我们将着重从事件处理任务处理两个核心部分出发,详细介绍 Netty EventLoop 的实现原理。

事件处理机制

6.png

结合 Netty 的整体架构,我们一起看下 EventLoop 的事件流转图,以便更好地理解 Netty EventLoop 的设计原理。NioEventLoop 的事件处理机制采用的是无锁串行化的设计思路

  • BossEventLoopGroupWorkerEventLoopGroup 包含一个或者多个 NioEventLoop。BossEventLoopGroup 负责监听客户端的 Accept 事件,当事件触发时,将事件注册至 WorkerEventLoopGroup 中的一个 NioEventLoop 上。每新建一个 Channel, 只选择一个 NioEventLoop 与其绑定。所以说 Channel 生命周期的所有事件处理都是线程独立的,不同的 NioEventLoop 线程之间不会发生任何交集。
  • NioEventLoop 完成数据读取后,会调用绑定的 ChannelPipeline 进行事件传播,ChannelPipeline 也是线程安全的,数据会被传递到 ChannelPipeline 的第一个 ChannelHandler 中。数据处理完成后,将加工完成的数据再传递给下一个 ChannelHandler,整个过程是串行化执行,不会发生线程上下文切换的问题。

NioEventLoop 无锁串行化的设计不仅使系统吞吐量达到最大化,而且降低了用户开发业务逻辑的难度,不需要花太多精力关心线程安全问题。虽然单线程执行避免了线程切换,但是它的缺陷就是不能执行时间过长的 I/O 操作,一旦某个 I/O 事件发生阻塞,那么后续的所有 I/O 事件都无法执行,甚至造成事件积压。在使用 Netty 进行程序开发时,我们一定要对 ChannelHandler 的实现逻辑有充分的风险意识。

NioEventLoop 线程的可靠性至关重要,一旦 NioEventLoop 发生阻塞或者陷入空轮询,就会导致整个系统不可用。在 JDK 中, Epoll 的实现是存在漏洞的,即使 Selector 轮询的事件列表为空,NIO 线程一样可以被唤醒,导致 CPU 100% 占用。这就是臭名昭著的 JDK epoll 空轮询的 Bug。Netty 作为一个高性能、高可靠的网络框架,需要保证 I/O 线程的安全性。那么它是如何解决 JDK epoll 空轮询的 Bug 呢?实际上 Netty 并没有从根源上解决该问题,而是巧妙地规避了这个问题。

我们抛开其他细枝末节,直接定位到事件轮询 select() 方法中的最后一部分代码,一起看下 Netty 是如何解决 epoll 空轮询的 Bug。

long time = System.nanoTime();
if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {
    selectCnt = 1;
} else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
        selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
    selector = selectRebuildSelector(selectCnt);
    selectCnt = 1;
    break;
}

Netty 提供了一种检测机制判断线程是否可能陷入空轮询,具体的实现方式如下:

  1. 每次执行 Select 操作之前记录当前时间 currentTimeNanos。
  2. time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos,如果事件轮询的持续时间大于等于 timeoutMillis,那么说明是正常的,否则表明阻塞时间并未达到预期,可能触发了空轮询的 Bug。
  3. Netty 引入了计数变量 selectCnt。在正常情况下,selectCnt 会重置,否则会对 selectCnt 自增计数。当 selectCnt 达到 SELECTOR_AUTO_REBUILD_THRESHOLD(默认512) 阈值时,会触发重建 Selector 对象。

Netty 采用这种方法巧妙地规避了 JDK Bug。异常的 Selector 中所有的 SelectionKey 会重新注册到新建的 Selector 上,重建完成之后异常的 Selector 就可以废弃了。

任务处理机制

NioEventLoop 不仅负责处理 I/O 事件,还要兼顾执行任务队列中的任务。任务队列遵循 FIFO 规则,可以保证任务执行的公平性。NioEventLoop 处理的任务类型基本可以分为三类。

  1. 普通任务:通过 NioEventLoop 的 execute() 方法向任务队列 taskQueue 中添加任务。例如 Netty 在写数据时会封装 WriteAndFlushTask 提交给 taskQueue。taskQueue 的实现类是多生产者单消费者队列 MpscChunkedArrayQueue,在多线程并发添加任务时,可以保证线程安全。
  2. 定时任务:通过调用 NioEventLoop 的 schedule() 方法向定时任务队列 scheduledTaskQueue 添加一个定时任务,用于周期性执行该任务。例如,心跳消息发送等。定时任务队列 scheduledTaskQueue 采用优先队列 PriorityQueue 实现。
  3. 尾部队列:tailTasks 相比于普通任务队列优先级较低,在每次执行完 taskQueue 中任务后会去获取尾部队列中任务执行。尾部任务并不常用,主要用于做一些收尾工作,例如统计事件循环的执行时间、监控信息上报等。

下面结合任务处理 runAllTasks 的源码结构,分析下 NioEventLoop 处理任务的逻辑,源码实现如下:

protected boolean runAllTasks(long timeoutNanos) {
    // 1. 合并定时任务到普通任务队列
    fetchFromScheduledTaskQueue();
    // 2. 从普通任务队列中取出任务
    Runnable task = pollTask();
    if (task == null) {
        afterRunningAllTasks();
        return false;
    }
    // 3. 计算任务处理的超时时间
    final long deadline = ScheduledFutureTask.nanoTime() + timeoutNanos;
    long runTasks = 0;
    long lastExecutionTime;
    for (;;) {
        // 4. 安全执行任务
        safeExecute(task);
        runTasks ++;
        // 5. 每执行 64 个任务检查一下是否超时
        if ((runTasks & 0x3F) == 0) {
            lastExecutionTime = ScheduledFutureTask.nanoTime();
            if (lastExecutionTime >= deadline) {
                break;
            }
        }
        task = pollTask();
        if (task == null) {
            lastExecutionTime = ScheduledFutureTask.nanoTime();
            break;
        }
    }
    // 6. 收尾工作
    afterRunningAllTasks();
    this.lastExecutionTime = lastExecutionTime;
    return true;
}

我在代码中以注释的方式标注了具体的实现步骤,可以分为 6 个步骤。

  1. fetchFromScheduledTaskQueue 函数:将定时任务从 scheduledTaskQueue 中取出,聚合放入普通任务队列 taskQueue 中,只有定时任务的截止时间小于当前时间才可以被合并。
  2. 从普通任务队列 taskQueue 中取出任务。
  3. 计算任务执行的最大超时时间。
  4. safeExecute 函数:安全执行任务,实际直接调用的 Runnable 的 run() 方法。
  5. 每执行 64 个任务进行超时时间的检查,如果执行时间大于最大超时时间,则立即停止执行任务,避免影响下一轮的 I/O 事件的处理。
  6. 最后获取尾部队列中的任务执行。

EventLoop 最佳实践

在日常开发中用好 EventLoop 至关重要,这里结合实际工作中的经验给出一些 EventLoop 的最佳实践方案。

  1. 网络连接建立过程中三次握手、安全认证的过程会消耗不少时间。这里建议采用 Boss 和 Worker 两个 EventLoopGroup,有助于分担 Reactor 线程的压力。
  2. 由于 Reactor 线程模式适合处理耗时短的任务场景,对于耗时较长的 ChannelHandler 可以考虑维护一个业务线程池,将编解码后的数据封装成 Task 进行异步处理,避免 ChannelHandler 阻塞而造成 EventLoop 不可用。
  3. 如果业务逻辑执行时间较短,建议直接在 ChannelHandler 中执行。例如编解码操作,这样可以避免过度设计而造成架构的复杂性。
  4. 不宜设计过多的 ChannelHandler。对于系统性能和可维护性都会存在问题,在设计业务架构的时候,需要明确业务分层和 Netty 分层之间的界限。不要一味地将业务逻辑都添加到 ChannelHandler 中。

总结

本节课我们一起学习了 Netty Reactor 线程模型的核心处理引擎 EventLoop,熟悉了 EventLoop 的来龙去脉。结合 Reactor 主从多线程模型,我们对 Netty EventLoop 的功能用处做一个简单的归纳总结。

  • MainReactor 线程:处理客户端请求接入。
  • SubReactor 线程:数据读取、I/O 事件的分发与执行。
  • 任务处理线程:用于执行普通任务或者定时任务,如空闲连接检测、心跳上报等。

EventLoop 的设计思想被运用于较多的高性能框架中,如 Redis、Nginx、Node.js 等,它的设计原理是否对你有所启发呢?在后续源码篇的章节中我们将进一步介绍 EventLoop 的源码实现,吃透 EventLoop 这个死循环,可以说你就是一个 Netty 专家了。


5、服务编排层:Pipeline 如何协调各类 Handler ?

通过上节课的学习,我们知道 EventLoop 可以说是 Netty 的调度中心,负责监听多种事件类型:I/O 事件、信号事件、定时事件等,然而实际的业务处理逻辑则是由 ChannelPipeline 中所定义的 ChannelHandler 完成的,ChannelPipeline 和 ChannelHandler 也是我们在平时应用开发的过程中打交道最多的组件。Netty 服务编排层的核心组件 ChannelPipeline 和 ChannelHandler 为用户提供了 I/O 事件的全部控制权。今天这节课我们便一起深入学习 Netty 是如何利用这两个组件,将数据玩转起来。

在学习这节课之前,我先抛出几个问题。

  • ChannelPipeline 与 ChannelHandler 的关系是什么?它们之间是如何协同工作的?
  • ChannelHandler 的类型有哪些?有什么区别?
  • Netty 中 I/O 事件是如何传播的?

希望你在学习完本课时后,可以找到问题的答案。

ChannelPipeline 概述

Pipeline 的字面意思是管道、流水线。它在 Netty 中起到的作用,和一个工厂的流水线类似。原始的网络字节流经过 Pipeline ,被一步步加工包装,最后得到加工后的成品。经过前面课程核心组件的初步学习,我们已经对 ChannelPipeline 有了初步的印象:它是 Netty 的核心处理链,用以实现网络事件的动态编排和有序传播。

今天我们将从以下几个方面一起探讨 ChannelPipeline 的实现原理:

  • ChannelPipeline 内部结构;
  • ChannelHandler 接口设计;
  • ChannelPipeline 事件传播机制;
  • ChannelPipeline 异常传播机制。

ChannelPipeline 内部结构

首先我们要理清楚 ChannelPipeline 的内部结构是什么样子,这样才能理解 ChannelPipeline 的处理流程。ChannelPipeline 作为 Netty 的核心编排组件,负责调度各种类型的 ChannelHandler,实际数据的加工处理操作则是由 ChannelHandler 完成的。

ChannelPipeline 可以看作是 ChannelHandler 的容器载体,它是由一组 ChannelHandler 实例组成的,内部通过双向链表将不同的 ChannelHandler 链接在一起,如下图所示。当有 I/O 读写事件触发时,ChannelPipeline 会依次调用 ChannelHandler 列表对 Channel 的数据进行拦截和处理。

image.png

由上图可知,每个 Channel 会绑定一个 ChannelPipeline,每一个 ChannelPipeline 都包含多个 ChannelHandlerContext,所有 ChannelHandlerContext 之间组成了双向链表。又因为每个 ChannelHandler 都对应一个 ChannelHandlerContext,所以实际上 ChannelPipeline 维护的是它与 ChannelHandlerContext 的关系。那么你可能会有疑问,为什么这里会多一层 ChannelHandlerContext 的封装呢?

其实这是一种比较常用的编程思想。ChannelHandlerContext 用于保存 ChannelHandler 上下文;ChannelHandlerContext 则包含了 ChannelHandler 生命周期的所有事件,如 connect、bind、read、flush、write、close 等。可以试想一下,如果没有 ChannelHandlerContext 的这层封装,那么我们在做 ChannelHandler 之间传递的时候,前置后置的通用逻辑就要在每个 ChannelHandler 里都实现一份。这样虽然能解决问题,但是代码结构的耦合,会非常不优雅。

根据网络数据的流向,ChannelPipeline 分为入站 ChannelInboundHandler 和出站 ChannelOutboundHandler 两种处理器。在客户端与服务端通信的过程中,数据从客户端发向服务端的过程叫出站,反之称为入站。数据先由一系列 InboundHandler 处理后入站,然后再由相反方向的 OutboundHandler 处理完成后出站,如下图所示。我们经常使用的解码器 Decoder 就是入站操作,编码器 Encoder 就是出站操作。服务端接收到客户端数据需要先经过 Decoder 入站处理后,再通过 Encoder 出站通知客户端。

image.png

接下来我们详细分析下 ChannelPipeline 双向链表的构造,ChannelPipeline 的双向链表分别维护了 HeadContext 和 TailContext 的头尾节点。我们自定义的 ChannelHandler 会插入到 Head 和 Tail 之间,这两个节点在 Netty 中已经默认实现了,它们在 ChannelPipeline 中起到了至关重要的作用。首先我们看下 HeadContext 和 TailContext 的继承关系,如下图所示。

image.png

HeadContext 既是 Inbound 处理器,也是 Outbound 处理器。它分别实现了 ChannelInboundHandler 和 ChannelOutboundHandler。网络数据写入操作的入口就是由 HeadContext 节点完成的。HeadContext 作为 Pipeline 的头结点负责读取数据并开始传递 InBound 事件,当数据处理完成后,数据会反方向经过 Outbound 处理器,最终传递到 HeadContext,所以 HeadContext 又是处理 Outbound 事件的最后一站。此外 HeadContext 在传递事件之前,还会执行一些前置操作。

TailContext 只实现了 ChannelInboundHandler 接口。它会在 ChannelInboundHandler 调用链路的最后一步执行,主要用于终止 Inbound 事件传播,例如释放 Message 数据资源等。TailContext 节点作为 OutBound 事件传播的第一站,仅仅是将 OutBound 事件传递给上一个节点。

从整个 ChannelPipeline 调用链路来看,如果由 Channel 直接触发事件传播,那么调用链路将贯穿整个 ChannelPipeline。然而也可以在其中某一个 ChannelHandlerContext 触发同样的方法,这样只会从当前的 ChannelHandler 开始执行事件传播,该过程不会从头贯穿到尾,在一定场景下,可以提高程序性能。

ChannelHandler 接口设计

在学习 ChannelPipeline 事件传播机制之前,我们需要了解 I/O 事件的生命周期。整个 ChannelHandler 是围绕 I/O 事件的生命周期所设计的,例如建立连接、读数据、写数据、连接销毁等。ChannelHandler 有两个重要的子接口ChannelInboundHandlerChannelOutboundHandler,分别拦截入站和出站的各种 I/O 事件

1. ChannelInboundHandler 的事件回调方法与触发时机。

事件回调方法触发时机
channelRegisteredChannel 被注册到 EventLoop
channelUnregisteredChannel 从 EventLoop 中取消注册
channelActiveChannel 处于就绪状态,可以被读写
channelInactiveChannel 处于非就绪状态Channel 可以从远端读取到数据
channelReadChannel 可以从远端读取到数据
channelReadCompleteChannel 读取数据完成
userEventTriggered用户事件触发时
channelWritabilityChangedChannel 的写状态发生变化

2. ChannelOutboundHandler 的事件回调方法与触发时机。

ChannelOutboundHandler 的事件回调方法非常清晰,直接通过 ChannelOutboundHandler 的接口列表可以看到每种操作所对应的回调方法,如下图所示。这里每个回调方法都是在相应操作执行之前触发,在此就不多做赘述了。此外 ChannelOutboundHandler 中绝大部分接口都包含ChannelPromise 参数,以便于在操作完成时能够及时获得通知。

image (2).png

事件传播机制

在上文中我们介绍了 ChannelPipeline 可分为入站 ChannelInboundHandler 和出站 ChannelOutboundHandler 两种处理器,与此对应传输的事件类型可以分为Inbound 事件Outbound 事件

我们通过一个代码示例,一起体验下 ChannelPipeline 的事件传播机制。

serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
    @Override
    public void initChannel(SocketChannel ch) {
        ch.pipeline()
                .addLast(new SampleInBoundHandler("SampleInBoundHandlerA", false))
                .addLast(new SampleInBoundHandler("SampleInBoundHandlerB", false))
                .addLast(new SampleInBoundHandler("SampleInBoundHandlerC", true));
        ch.pipeline()
                .addLast(new SampleOutBoundHandler("SampleOutBoundHandlerA"))
                .addLast(new SampleOutBoundHandler("SampleOutBoundHandlerB"))
                .addLast(new SampleOutBoundHandler("SampleOutBoundHandlerC"));    }
}
public class SampleInBoundHandler extends ChannelInboundHandlerAdapter {
    private final String name;
    private final boolean flush;
    public SampleInBoundHandler(String name, boolean flush) {
        this.name = name;
        this.flush = flush;
    }
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        System.out.println("InBoundHandler: " + name);
        if (flush) {
            ctx.channel().writeAndFlush(msg);
        } else {
            super.channelRead(ctx, msg);
        }
    }
}public class SampleOutBoundHandler extends ChannelOutboundHandlerAdapter {
    private final String name;
    public SampleOutBoundHandler(String name) {
        this.name = name;
    }
    @Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
        System.out.println("OutBoundHandler: " + name);
        super.write(ctx, msg, promise);
    }
}

通过 Pipeline 的 addLast 方法分别添加了三个 InboundHandler 和 OutboundHandler,添加顺序都是 A -> B -> C,下图可以表示初始化后 ChannelPipeline 的内部结构。

image.png

当客户端向服务端发送请求时,会触发 SampleInBoundHandler 调用链的 channelRead 事件。经过 SampleInBoundHandler 调用链处理完成后,在 SampleInBoundHandlerC 中会调用 writeAndFlush 方法向客户端写回数据,此时会触发 SampleOutBoundHandler 调用链的 write 事件。最后我们看下代码示例的控制台输出:

image (3).png

由此可见,Inbound 事件和 Outbound 事件的传播方向是不一样的。Inbound 事件的传播方向为 Head -> Tail,而 Outbound 事件传播方向是 Tail -> Head,两者恰恰相反。在 Netty 应用编程中一定要理清楚事件传播的顺序。推荐你在系统设计时模拟客户端和服务端的场景画出 ChannelPipeline 的内部结构图,以避免搞混调用关系。

异常传播机制

ChannelPipeline 事件传播的实现采用了经典的责任链模式,调用链路环环相扣。那么如果有一个节点处理逻辑异常会出现什么现象呢?我们通过修改 SampleInBoundHandler 的实现来模拟业务逻辑异常:

public class SampleInBoundHandler extends ChannelInboundHandlerAdapter {
    private final String name;
    private final boolean flush;
    public SampleInBoundHandler(String name, boolean flush) {
        this.name = name;
        this.flush = flush;
    }
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        System.out.println("InBoundHandler: " + name);
        if (flush) {
            ctx.channel().writeAndFlush(msg);
        } else {
            throw new RuntimeException("InBoundHandler: " + name);
        }
    }
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        System.out.println("InBoundHandlerException: " + name);
        ctx.fireExceptionCaught(cause);
    }
}

在 channelRead 事件处理中,第一个 A 节点就会抛出 RuntimeException。同时我们重写了 ChannelInboundHandlerAdapter 中的 exceptionCaught 方法,只是在开头加上了控制台输出,方便观察异常传播的行为。下面看一下代码运行的控制台输出结果:

image (4).png

由输出结果可以看出 ctx.fireExceptionCaugh 会将异常按顺序从 Head 节点传播到 Tail 节点。如果用户没有对异常进行拦截处理,最后将由 Tail 节点统一处理,在 TailContext 源码中可以找到具体实现:

protected void onUnhandledInboundException(Throwable cause) {
    try {
        logger.warn(
                "An exceptionCaught() event was fired, and it reached at the tail of the pipeline. " +
                        "It usually means the last handler in the pipeline did not handle the exception.",
                cause);
    } finally {
        ReferenceCountUtil.release(cause);
    }
}

虽然 Netty 中 TailContext 提供了兜底的异常处理逻辑,但是在很多场景下,并不能满足我们的需求。假如你需要拦截指定的异常类型,并做出相应的异常处理,应该如何实现呢?我们接着往下看。

异常处理的最佳实践

在 Netty 应用开发的过程中,良好的异常处理机制会让排查问题的过程事半功倍。所以推荐用户对异常进行统一拦截,然后根据实际业务场景实现更加完善的异常处理机制。通过异常传播机制的学习,我们应该可以想到最好的方法是在 ChannelPipeline 自定义处理器的末端添加统一的异常处理器,此时 ChannelPipeline 的内部结构如下图所示。

image.png

用户自定义的异常处理器代码示例如下:

public class ExceptionHandler extends ChannelDuplexHandler {
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        if (cause instanceof RuntimeException) {
            System.out.println("Handle Business Exception Success.");
        }
    }
}

加入统一的异常处理器后,可以看到异常已经被优雅地拦截并处理掉了。这也是 Netty 推荐的最佳异常处理实践。

image (5).png

总结

本节课我们深入分析了 Pipeline 的设计原理与事件传播机制。那么课程最初我提出的几个问题你是否已经都找到答案了?我来做个简单的总结:

  • ChannelPipeline 是双向链表结构,包含 ChannelInboundHandler 和 ChannelOutboundHandler 两种处理器。
  • ChannelHandlerContext 是对 ChannelHandler 的封装,每个 ChannelHandler 都对应一个 ChannelHandlerContext,实际上 ChannelPipeline 维护的是与 ChannelHandlerContext 的关系。
  • Inbound 事件和 Outbound 事件的传播方向相反,Inbound 事件的传播方向为 Head -> Tail,而 Outbound 事件传播方向是 Tail -> Head。
  • 异常事件的处理顺序与 ChannelHandler 的添加顺序相同,会依次向后传播,与 Inbound 事件和 Outbound 事件无关。

ChannelPipeline 精妙的设计思想值得我们学以致用,建议有兴趣的同学可以深入学习下这个组件的核心源码。在未来源码篇的课程中我们将会继续深入了解 ChannelPipeline 这个组件。


举报

相关推荐

0 条评论