05 | Tomcat系统架构(上): 连接器是如何设计的?

在面试时我们可能经常被问到:你做的XX项目的架构是如何设计的,请讲一下实现的思路。对于面试官来说,可以通过你对复杂系统设计的理解,了解你的技术水平以及处理复杂问题的思路。

今天咱们就来一步一步分析Tomcat的设计思路,看看Tomcat的设计者们当时是怎么回答这个问题的。一方面我们可以学到Tomcat的总体架构,学会从宏观上怎么去设计一个复杂系统,怎么设计顶层模块,以及模块之间的关系;另一方面也为我们深入学习Tomcat的工作原理打下基础。

Tomcat总体架构

我们知道如果要设计一个系统,首先是要了解需求。通过专栏前面的文章,我们已经了解了Tomcat要实现2个核心功能:

  • 处理Socket连接,负责网络字节流与Request和Response对象的转化。

  • 加载和管理Servlet,以及具体处理Request请求。

因此Tomcat设计了两个核心组件连接器(Connector)和容器(Container)来分别做这两件事情。连接器负责对外交流,容器负责内部处理。

所以连接器和容器可以说是Tomcat架构里最重要的两部分,需要你花些精力理解清楚。这两部分内容我会分成两期,今天我来分析连接器是如何设计的,下一期我会介绍容器的设计。

在开始讲连接器前,我先铺垫一下Tomcat支持的多种I/O模型和应用层协议。

Tomcat支持的I/O模型有:

  • NIO:非阻塞I/O,采用Java NIO类库实现。

  • NIO.2:异步I/O,采用JDK 7最新的NIO.2类库实现。

  • APR:采用Apache可移植运行库实现,是C/C++编写的本地库。

Tomcat支持的应用层协议有:

  • HTTP/1.1:这是大部分Web应用采用的访问协议。

  • AJP:用于和Web服务器集成(如Apache)。

  • HTTP/2:HTTP 2.0大幅度的提升了Web性能。

Tomcat为了实现支持多种I/O模型和应用层协议,一个容器可能对接多个连接器,就好比一个房间有多个门。但是单独的连接器或者容器都不能对外提供服务,需要把它们组装起来才能工作,组装后这个整体叫作Service组件。这里请你注意,Service本身没有做什么重要的事情,只是在连接器和容器外面多包了一层,把它们组装在一起。Tomcat内可能有多个Service,这样的设计也是出于灵活性的考虑。通过在Tomcat中配置多个Service,可以实现通过不同的端口号来访问同一台机器上部署的不同应用。

到此我们得到这样一张关系图:

从图上你可以看到,最顶层是Server,这里的Server指的就是一个Tomcat实例。一个Server中有一个或者多个Service,一个Service中有多个连接器和一个容器。连接器与容器之间通过标准的ServletRequest和ServletResponse通信。

连接器

连接器对Servlet容器屏蔽了协议及I/O模型等的区别,无论是HTTP还是AJP,在容器中获取到的都是一个标准的ServletRequest对象。

我们可以把连接器的功能需求进一步细化,比如:

  • 监听网络端口。

  • 接受网络连接请求。

  • 读取网络请求字节流。

  • 根据具体应用层协议(HTTP/AJP)解析字节流,生成统一的Tomcat Request对象。

  • 将Tomcat Request对象转成标准的ServletRequest。

  • 调用Servlet容器,得到ServletResponse。

  • 将ServletResponse转成Tomcat Response对象。

  • 将Tomcat Response转成网络字节流。

  • 将响应字节流写回给浏览器。

需求列清楚后,我们要考虑的下一个问题是,连接器应该有哪些子模块?优秀的模块化设计应该考虑高内聚、低耦合

  • 高内聚是指相关度比较高的功能要尽可能集中,不要分散。

  • 低耦合是指两个相关的模块要尽可能减少依赖的部分和降低依赖的程度,不要让两个模块产生强依赖。

通过分析连接器的详细功能列表,我们发现连接器需要完成3个高内聚的功能:

  • 网络通信。

  • 应用层协议解析。

  • Tomcat Request/Response与ServletRequest/ServletResponse的转化。

因此Tomcat的设计者设计了3个组件来实现这3个功能,分别是Endpoint、Processor和Adapter。

组件之间通过抽象接口交互。这样做还有一个好处是封装变化。这是面向对象设计的精髓,将系统中经常变化的部分和稳定的部分隔离,有助于增加复用性,并降低系统耦合度。

网络通信的I/O模型是变化的,可能是非阻塞I/O、异步I/O或者APR。应用层协议也是变化的,可能是HTTP、HTTPS、AJP。浏览器端发送的请求信息也是变化的。

但是整体的处理逻辑是不变的,Endpoint负责提供字节流给Processor,Processor负责提供Tomcat Request对象给Adapter,Adapter负责提供ServletRequest对象给容器。

如果要支持新的I/O方案、新的应用层协议,只需要实现相关的具体子类,上层通用的处理逻辑是不变的。

由于I/O模型和应用层协议可以自由组合,比如NIO + HTTP或者NIO.2 + AJP。Tomcat的设计者将网络通信和应用层协议解析放在一起考虑,设计了一个叫ProtocolHandler的接口来封装这两种变化点。各种协议和通信模型的组合有相应的具体实现类。比如:Http11NioProtocol和AjpNioProtocol。

除了这些变化点,系统也存在一些相对稳定的部分,因此Tomcat设计了一系列抽象基类来封装这些稳定的部分,抽象基类AbstractProtocol实现了ProtocolHandler接口。每一种应用层协议有自己的抽象基类,比如AbstractAjpProtocol和AbstractHttp11Protocol,具体协议的实现类扩展了协议层抽象基类。下面我整理一下它们的继承关系。

通过上面的图,你可以清晰地看到它们的继承和层次关系,这样设计的目的是尽量将稳定的部分放到抽象基类,同时每一种I/O模型和协议的组合都有相应的具体实现类,我们在使用时可以自由选择。

小结一下,连接器模块用三个核心组件:Endpoint、Processor和Adapter来分别做三件事情,其中Endpoint和Processor放在一起抽象成了ProtocolHandler组件,它们的关系如下图所示。

下面我来详细介绍这两个顶层组件ProtocolHandler和Adapter。

ProtocolHandler组件

由上文我们知道,连接器用ProtocolHandler来处理网络连接和应用层协议,包含了2个重要部件:Endpoint和Processor,下面我来详细介绍它们的工作原理。

  • Endpoint

Endpoint是通信端点,即通信监听的接口,是具体的Socket接收和发送处理器,是对传输层的抽象,因此Endpoint是用来实现TCP/IP协议的。

Endpoint是一个接口,对应的抽象实现类是AbstractEndpoint,而AbstractEndpoint的具体子类,比如在NioEndpoint和Nio2Endpoint中,有两个重要的子组件:Acceptor和SocketProcessor。

其中Acceptor用于监听Socket连接请求。SocketProcessor用于处理接收到的Socket请求,它实现Runnable接口,在run方法里调用协议处理组件Processor进行处理。为了提高处理能力,SocketProcessor被提交到线程池来执行。而这个线程池叫作执行器(Executor),我在后面的专栏会详细介绍Tomcat如何扩展原生的Java线程池。

  • Processor

如果说Endpoint是用来实现TCP/IP协议的,那么Processor用来实现HTTP协议,Processor接收来自Endpoint的Socket,读取字节流解析成Tomcat Request和Response对象,并通过Adapter将其提交到容器处理,Processor是对应用层协议的抽象。

Processor是一个接口,定义了请求的处理等方法。它的抽象实现类AbstractProcessor对一些协议共有的属性进行封装,没有对方法进行实现。具体的实现有AjpProcessor、Http11Processor等,这些具体实现类实现了特定协议的解析方法和请求处理方式。

我们再来看看连接器的组件图:

从图中我们看到,Endpoint接收到Socket连接后,生成一个SocketProcessor任务提交到线程池去处理,SocketProcessor的run方法会调用Processor组件去解析应用层协议,Processor通过解析生成Request对象后,会调用Adapter的Service方法。

到这里我们学习了ProtocolHandler的总体架构和工作原理,关于Endpoint的详细设计,后面我还会专门介绍Endpoint是如何最大限度地利用Java NIO的非阻塞以及NIO.2的异步特性,来实现高并发。

Adapter组件

我在前面说过,由于协议不同,客户端发过来的请求信息也不尽相同,Tomcat定义了自己的Request类来“存放”这些请求信息。ProtocolHandler接口负责解析请求并生成Tomcat Request类。但是这个Request对象不是标准的ServletRequest,也就意味着,不能用Tomcat Request作为参数来调用容器。Tomcat设计者的解决方案是引入CoyoteAdapter,这是适配器模式的经典运用,连接器调用CoyoteAdapter的sevice方法,传入的是Tomcat Request对象,CoyoteAdapter负责将Tomcat Request转成ServletRequest,再调用容器的service方法。

本期精华

Tomcat的整体架构包含了两个核心组件连接器和容器。连接器负责对外交流,容器负责内部处理。连接器用ProtocolHandler接口来封装通信协议和I/O模型的差异,ProtocolHandler内部又分为Endpoint和Processor模块,Endpoint负责底层Socket通信,Processor负责应用层协议解析。连接器通过适配器Adapter调用容器。

通过对Tomcat整体架构的学习,我们可以得到一些设计复杂系统的基本思路。首先要分析需求,根据高内聚低耦合的原则确定子模块,然后找出子模块中的变化点和不变点,用接口和抽象基类去封装不变点,在抽象基类中定义模板方法,让子类自行实现抽象方法,也就是具体子类去实现变化点。

课后思考

回忆一下你在工作中曾经独立设计过的系统,或者你碰到过的设计类面试题,结合今天专栏的内容,你有没有一些新的思路?

不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

精选留言

  • 电光火石

    2019-05-21 00:46:32

    对Tomcat的结构有个清晰的了解,其中有两个问题:
    1. PorotocolHandler的继承关系是不是太重了,看起来像典型的多维度扩展,nio2在apj和1HTTP11都要做一遍,用组合会不会更好
    2. 为什么要多一层adapter,在processor直接转换为容器的servletrequest和servletresponse不是更好,为什么要先转化Tomcat的request和response,再用adapter做一层转换消耗性能?
    谢谢了!
    作者回复

    1,说的对,能用组合就不用继承,这里我感觉Tomcat设计者考虑的是通过多层继承来尽量重用一些通用的逻辑。另外I/O模型和应用层协议的个数也是可控的,用户可以在server.xml中直接指定想要的连接器类型:比如Http11NioProtocol和Http11Nio2Protocol。

    2,这里的考虑是,如果连接器直接创建ServletRequest和ServletResponse对象的话,就和Servlet协议耦合了,设计者认为连接器尽量保持独立性,它不一定要跟Servlet容器工作的。另外对象转化的性能消耗还是比较少的,Tomcat对HTTP请求体采取了延迟解析的策略,也就是说,TomcatRequest对象转化成ServletRequest的时候,请求体的内容都还没读取呢,直到容器处理这个请求的时候才读取的。

    2019-05-21 12:00:04

  • 2019-05-21 22:05:00

    两个问题请教一下老师
    第一,如何debug源码呢?
    第二,tomcat和netty有什么区别呢?为什么netty常常用做底层通讯模块,而tomcat作为web容器呢?
    作者回复

    1)软件系统本质是对信息的处理,要跟踪信息在流动过程中的经过的关键环节,并在这些地方下断点,看看变量的值是什么。比如你可以在业务代码中下个断点,看看调用栈,看Tomcat和Spring是怎么调到你的代码的,然后在这个调用栈中的关键函数里上下都看看,先熟悉个大概,然后带着问题去深入调试。
    2)你可以把Netty理解成Tomcat中的连接器,它们都负责网络通信,都利用了Java NIO非阻塞特性。但Netty素以高性能高并发著称,为什么Tomcat不把连接器替换成Netty呢?第一个原因是Tomcat的连接器性能已经足够好了,同样是Java NIO编程,套路都差不多。第二个原因是Tomcat做为Web容器,需要考虑到Servlet规范,Servlet规范规定了对HTTP Body的读写是阻塞的,因此即使用到了Netty,也不能充分发挥它的优势。所以Netty一般用在非HTTP协议和Servlet的场景下。

    2019-05-22 00:12:40

  • 2019-05-22 15:41:20

    “EndPoint 是通信端点,即通信监听的接口,是具体的 Socket 接收和发送处理器,是对传输层的抽象,因此 EndPoint 是用来实现 TCP/IP 协议的。”,【EndPoint是用来实现TCP/IP协议的】这个没有太明白,据我有限的知识所知,TCP/IP协议是【由操作系统实现】的,而socket只是在TCP/IP之上展现给用户层的一个接口,而EndPoint又用到了socket接口(我瞎猜的)。所以,我是否可以把这句话理解为,EndPoint利用Socket接口来将底层传来的数据转化成为HTTP格式的数据,这种行为就可以看作是对TCP/IP协议的一种间接实现。
    作者回复

    理解正确👍

    2019-05-22 16:09:03

  • ty_young

    2019-06-07 21:39:25

    老师,我看您说socket = endpoint.serverSocketAccept()这个是阻塞式accept;但是连接器使用的IO模型是NIO或者AIO啊,都是非阻塞的吧,只是同步或者非同步的区别吧
    作者回复

    阻塞和非阻塞说的是线程发起IO操作时,如果数据没有就绪,线程是否挂起。
    同步异步说的是,应用程序与内核交互时,数据从内核空间到用户空间的拷贝,是内核主动发起还是由应用程序来触发,14篇会详细介绍。

    这里的accept调用,当连接请求未到时,应用线程会挂起,因此是阻塞的,但为什么又说NIO是非阻塞呢?这是因为读取数据的线程没有挂起,因为之前已经通过Selector侦测到数据已经准备好了,到了内核空间,数据读取线程不需要等待,不需要挂起去等待数据。

    2019-06-07 23:12:08

  • 郑晨Cc

    2019-05-31 13:19:16

    老师有个问题想请教: tomcat既然已经使用了java的nio模型 而nio模型在linx上是基于epoll 实现的 那为什么和同样的使用epoll的nginx相比 他对于http请求处理的性能远不如nginx呢
    作者回复

    Nginx/Apche一般做反向代理和处理静态HTML资源,做的事情相对来说简单,KPI就是要快,因此用C语言实现,直接调用操作系统API,充分利用操作系统的高级特性。

    而Tomcat用来处理动态请求,还需要跑Java应用,因此用Java实现,因此”快“不是它主要的KPI。Java调用操作系统API要通过JNI,无形中有性能损耗。另外Tomcat通过使用Apache APR本地库来做I/O通信,性能已经跟Apache、Nginx接近了。

    2019-06-01 11:00:13

  • zhycareer

    2019-05-21 20:28:36

    老师,源码如何阅读效果好啊?现在源码一大堆,不知从何下手。谢谢
    作者回复

    抓主线,抓主干,每个系统中都有一个关键的核心类,紧紧抓住这些类,先不要分散,在逐步看旁枝,等你学习弄明白一个经典的系统,很多套路你就明白了。

    2019-05-21 21:04:16

  • 易儿易

    2019-06-29 20:05:37

    这个专栏期待已久,一出立马就订阅了,但是因为其他课程没结束,导致这个课程没有跟上老师的节奏,目前正在努力追赶,我订阅了不少课程,李号双老师是回答问题最详细最用心的一个没有之一,虽然我还没来得及提一个问题,但是已经从老师给其他学生的回复中学习了不少……给老师点赞!!!
    作者回复

    谢谢😄

    2019-06-29 23:40:28

  • 欠债太多

    2019-05-21 20:21:35

    io是盲区啊,老师有什么建议呢
    作者回复

    unix环境高级编程里相关章节有详细介绍,这个专栏也会图文并貌分析,你有没有发现这个专栏的内容其实蛮容易懂的😊

    2019-05-21 21:06:25

  • 鱼乐

    2019-06-27 09:48:00

    老师,有个问题,根据网络协议分层模型,请求不应该是先经过Http协议,然后才经过TCP协议处理的吗,上面的图处理顺序感觉反了
    作者回复

    发送方是先http数据生成后tcp发送,接收方先tcp接收,再http解析

    2019-06-27 14:42:06

  • 学无涯

    2019-05-21 08:39:45

    一个service对应tomcat中部署的一个项目,一个连接器对应一个请求,这样理解对吗
    作者回复

    一个Service中可以部署多个项目呢,一个连接器对应一个监听端口,不是一个请求,一个端口上可以接收多个请求。

    2019-05-21 12:07:28

  • 面白i小黄毛

    2019-05-27 15:50:38

    老师您好,"通过在 Tomcat 中配置多个 Service,可以实现通过不同的端口号来访问同一台机器上部署的不同应用。"这句话应该怎样理解?如果仅仅针对http来说的话,同一个tomcat可以设置多个端口号来启动多个应用吗?
    作者回复

    可以的,在server.xml配置多个service,或者同一个service里配置多个connector

    2019-05-28 20:36:54

  • Geek_ebda96

    2019-05-23 10:59:51

    老师请教两个问题
    1.应用层的i/o模型和http1,ajp等协议是指在endpoint接受网络请求后,对请求内容解析才会用到吧,就是在processor里面,这里面就是根据请求的协议类型,采用指定i/o读取网络流,是不是这样?
    2.ajp也是指一种网络协议么,类似于http这种,processor里面是根据什么来判定请求的协议类型,比如浏览器里面请求的header里面的内容吗
    3.endpoint里面的aceptor本身是监听和获取网络请求没有用多线程,这里会成为高并发的瓶颈点不
    作者回复

    1. 对的
    2.AJP可以理解为应用层协议,它是用二进制的方式来传输文本,比如HTTP的请求头“accept-language”有15个字符,如果用二进制0xA004表示只有2个字节,效率大大提升。
    3. Endpoint中的Acceptor有多个,每个Acceptor跑在单独的线程里,后面会详细分析为什么要这样做。

    2019-05-23 14:53:36

  • 永光

    2019-06-05 15:23:58

    老师,你看这样理解对不,
    采用何种I/O模式(NIO、NIO2、ARP),以及采用何种应用协议(HTTP1.1、AJP、HTTP/2)都是在processor这一层决定的。EndPoint只负责接收连接,并读取网络字节流但是不对字节流本身就进行任何解析。
    作者回复

    对的

    2019-06-05 16:12:32

  • Monday

    2019-05-21 22:40:16

    从上一节突然跳转到本节,感觉跳跃性很大。突然进入整体架构后,即使我花了大量时间多次阅读本节,也很难消化。真的捉急!
    不知道老师上面提到的类名,是基于Tomcat的哪个版本。
    今天我刻意花时间把tomcat7.0.94的源码下载下来,导入IDEA。发现org.apache.tomcat.util.net.AbstractEndpoint是一个抽象类,既没有实现EndPoint,也没有声明内部类SocketProcessor。和老师讲上面提到的有出入,难道我下了一个假的Tomato源码。>大哭<
    作者回复

    刚开始是需要适应一下^_^,其实不难的,我用的最新版的代码:https://github.com/apache/tomcat,AbstractEndpoint本身确实没有内部类,是它的子类Nio2Endpoint中包含了两个内部类:Nio2Acceptor 和 SocketProcessor。已经在下面的回复中已经纠正了。

    2019-05-22 01:12:55

  • 新世界

    2019-05-21 07:56:41

    对tomcat的结构的连接器部分收获不少,有一问题,tomcat的endpoint的功能和netty的实现功能很多方面一样,tomcat为什么没有用netty作为底层通讯框架?
    作者回复

    Tomcat在I/O模型和线程模型方面跟Netty很相似,后面会详细分析。

    2019-05-21 12:03:47

  • chp

    2019-05-21 09:43:32

    老师,请求来的时候,源码入口在哪里?
    作者回复

    在Acceptor的run方法里:

    socket = endpoint.serverSocketAccept();

    这句话用来接收一个新的连接

    2019-05-21 12:05:53

  • KL3

    2019-05-21 08:29:29

    您好,我不太理解io模型,是指若干个请求的网络字节流经过网络适配器,由连接器通过一个什么样的方式读到吗?
    我对io理解的不深,表达会有问题。
    作者回复

    I/O是外部设备和主存之间拷贝数据的过程,I/O模型是实现这个过程的方式,后面会详细介绍各种I/O模型的区别:同步阻塞,同步非阻塞、异步等等。

    2019-05-21 12:14:41

  • 王盛武

    2019-06-09 13:35:42

    醍醐灌顶 以前学tomcat源码,关注的是类 方法,从来没这么清晰过
  • 发条橙子 。

    2019-05-25 07:36:51

    老师 ,我有个疑问 :
    层次结构是 一个server表示一个应用实例(java进程)。 一个server中可以有多个service , service中又有多个连接器和容器 。 并且每个service可以监听一个不同的端口号 。

    但是我们的一个应用一般不都是一个端口号么 , url地址:端口号 。 为什么会有多个端口号。是我哪个点理解的有偏差么老师
    作者回复

    可以给同一个Web应用配置多个端口号啊,比如同时支持HTTP(8080)和HTTPS(8443)。

    2019-05-25 10:21:09

  • Standly

    2019-05-21 23:38:18

    “如果说 EndPoint 是用来实现 TCP/IP 协议的,那么 Processor 用来实现 HTTP 协议”,这句话不太理解,TCP/IP协议不是由linux系统内核实现的么?
    作者回复

    这里可以理解为Endpoint负责Socket网络通信,跟TCP/IP协议紧密相关。“实现”这个词确实有点误导。:)

    2019-05-22 00:27:20