整篇文章分为两大部分,Tomcat 系统架构设计和 Tomcat 源码剖析。
Tomcat系统架构设计
1.前言
很多人谈到架构感觉是一个非常高大尚的东西,觉得自己目前不太可能接触到或者没有实力接触和学习它。这其实是一个非常错误的认识,事实上我们作为开发人员每天都在和架构打交道。比如当你接到一个功能模块的需求时,你首先要做的就是分析和设计,例如技术选型、功能拆分、设计合理的开发流程、类结构关系梳理、是否需要应用设计模式等等。
所以其实架构的本质就是通过合理的内部编排,保证整个系统高度有序、能够不断扩展,满足业务和技术的不断变化。
架构是属于设计层面,而源码则是对设计的实现,在源码分析前如果能够对整体框架有一个全面的认知,那么后续解读源码将会事半功倍。当然了由于 Tomcat 代码不像 Spring 那么庞大,因此我们是有这个实力去分析 Tomcat 的整体架构设计的,并且它的架构设计比较独特,属于俄罗斯套娃式架构设计,这种设计方式也非常值的我们去探索和学习它。
接下来我们以 Tomcat 的设计者身份来揭开 Tomcat 架构设计的神秘面纱。
2.Tomcat 功能分析
既然要设计一个系统,我们自然是先要了解需求。也就是我们希望 Tomcat 能干什么?
HTTP 服务器
首先想到的肯定是希望 Tomcat 能够接受并且处理 http 请求,也就是作为一个 HTTP 服务器。
http 请求意味着使用的是 HTTP 协议进行数据传送,HTTP 协议是应用层协议,其本质就是一种浏览器与服务器之间约定好的通信格式。
那么浏览器和服务器针对一次 http 请求的处理过程大致是怎样的呢?如下图所示
从图中你可以看到整个处理过程分为了11步:
- 用户通过浏览器进行了一个操作,这个操作可以是输入url地址并回车,或者是点击超链接,或者是在搜索框中输入关键字进行搜索,接着浏览器就捕获到了这个事件
- 由于 HTTP 协议底层具体的数据传输使用的是 TCP/IP 协议,因此浏览器需要向服务端发出 TCP 连接请求
- 服务器接受浏览器的连接请求,并经过 TCP 三次握手建立连接
- 浏览器将请求数据打包成一个 HTTP 协议格式的数据包
- 浏览器将打包好的数据包推入网络,经过网络传输最终到达服务器指定程序
- 服务端程序拿到数据包后,根据 HTTP 协议格式进行解包,获取到客户端的意图
- 得知客户端意图后进行处理,比如提供静态文件或者调用服务端程序获得动态结果
- 服务器将响应结果按照 HTTP 协议格式打包
- 服务器将响应数据包推入网络,数据包经过网络传输最终达到到浏览器
- 浏览器拿到数据包后,按照 HTTP 协议的格式解包,然后对数据进行解析
- 浏览器将解析后的静态数据(如html、图片)展示给用户
那么在这里 Tomcat 作为一个 HTTP 服务器,主要需要完成的功能是接受连接、解析请求数据、处理请求和发送响应这几个步骤。
我们重点关注处理请求部分,当我们使用浏览器向某一个网站发起一个 HTTP 格式的请求,那么作为 HTTP 服务器接收到这个请求之后,会调用具体的程序(Java类)进行处理,往往不同的请求由不同的 Java 类完成处理。那么问题来了,HTTP 服务器怎么知道要调用哪个 Java 类的哪个方法呢。当然我们可以在 HTTP 服务器代码里直接进行处理,也就是根据请求路径等信息进行一堆的 if else 逻辑判断,但是这样做缺陷很明显,因为 HTTP 服务器的代码跟后端业务处理逻辑耦合在一起了,不符合我们软件开发设计的原则,并且也非常不利于系统的扩展性。
Servlet 容器
针对这种耦合问题最好的解决办法自然是面向接口编程,于是我们可以定义一个 Servlet 接口,所有的业务类都必须实现这个接口。到这里就完了吗?当然没有,因为我们还是没能解决 Servlet 定位问题,即一个请求到来时 HTTP 服务器如何知道该由哪个 Servlet 来处理呢,并且这些自定义的 Servlet 也需要进行加载和管理。于是 Servlet 容器就被发明出来了,Servlet 容器作为 HTTP 服务器和具体业务类进行交互的桥梁,HTTP 服务器将请求交由 Servlet 容器去处理,而 Servlet 容器则负责将请求转发到具体的 Servlet,并且调用 Servlet 的方法进行业务处理,它们之间的调用通过 Servlet 接口进行解耦。
其实 Servlet 接口和 Servlet 容器并不是 Tomcat 发明的,而是在 JAVAEE API 中定义的,我们也把这一整套内容叫做 Servlet 规范。有了这套规范之后,如果我们要实现新的业务功能,只需要实现一个 Servlet,并把它注册到 Servlet 容器中,剩下的事情就需要由 Tomcat 帮我们处理了。因此对于 Tomcat 而言就需要按照 Serlvet 规范的要求去实现一个 Servlet 容器。
Tomcat Servlet 容器工作流程
当用户请求某个URL资源时:
- HTTP 服务器会把请求信息使用 ServletRequest 对象封装起来
- 进一步去调用 Servlet 容器中某个具体的 Servlet
- 在第二步中当 Servlet 容器拿到请求后,会根据 URL 和 Servlet 的映射关系,找到相应的 Servlet
- 如果 Servlet 还没有被加载,就使用反射机制创建这个 Servlet,并调用 Servlet 的 init 方法来完成初始化
- 接着调用这个具体 Servlet 的 service 方法来处理请求,请求处理结果使用 ServletResponse 对象封装
- 把 ServletResponse 对象返回给 HTTP 服务器,HTTP 服务器会把响应发送给客户端
Web 服务器
根据上述分析,我们知道了 Tomcat 要实现成 “HTTP 服务器 + Servlet 容器”,也就是所谓的 Web 服务器。
而作为一个 Web 服务器,Tomcat 要实现两个非常核心的功能:
- Http 服务器功能:进行 Socket 通信(基于 TCP/IP),解析 HTTP 报文
- Servlet 容器功能:加载和管理 Servlet,由 Servlet 具体负责处理 Request 请求
3.Tomcat组件设计
连接器和容器
为了完成上述两个功能,我们设计了两个核心组件连接器(Connector)和容器(Container)来分别做这两件事情。连接器负责对外交流(完成 Http 服务器功能),容器负责内部处理(完成 Servlet 容器功能)。
连接器既然负责对外交流那就免不了进行 socket 通信,说到 socket 通信,就涉及到了网络IO模型,那么网络IO模型是可变的、多种多样的,因此一个容器可能对接多个连接器,这就好比一个房间有多个门。但是单独的连接器或者容器都不能对外提供服务,需要把它们组装起来才能工作,组装后这个整体我们将其命名为 Service 组件。
Service 设计一个还是多个?考虑一下这种场景,当我们有2个或以上网站都需要能够部署和管理自己的应用并且彼此不会相互影响(端口隔离),而此时又不想安装多个tomcat避免造成资源的浪费,那么就需要多个 Service 了。因此我们在一个 Tomcat 中让它可以配置多个 Service,这种设计也是出于系统的灵活性考虑,虽然现在大多数情况下我们不会用到。
至此我们画出了如下的架构图:
对图中的组件分别进行说明:
- Server
这里的 Server 就代表了一个 Tomcat 实例,包含了 Servlet 容器以及其他组件,负责组装并启动 Servlet 引擎、Tomcat 连接器 - Service
服务是 Server 内部的组件,一个Server包括多个Service。它将若干个 Connector 组件绑定到一个 Container - Container
容器,负责处理用户的 servlet 请求,并返回对象给 web 用户的模块
这里还需注意的是连接器和容器两者之间是通过标准的 ServletRequest 和 ServletResponse 通信,这样连接器对 Servlet 容器就屏蔽了网络协议以及 I/O 模型等的区别。
有了总体架构后,我们就进一步看看连接器和容器分别应该怎么设计。
连接器的设计
首先我们分析出连接器主要需要完成以下三个核心功能:
- socket 通信,也就是网络编程
- 解析处理应用层协议,封装成一个 Request 对象
- 将 Request 转换为 ServletRequest,将 Response 转换为 ServletResponse
我们设计了三个组件 EndPoint、Processor、Adapter 来对应完成上述三项功能。这三个组件之间通过抽象接口进行交互。从一个请求的正向流程来看, Endpoint 负责提供请求字节流给 Processor,Processor 负责提供 Tomcat 定义的 Request 对象给 Adapter,Adapter 负责提供标准的 ServletRequest 对象给 Servlet 容器。
首先来说一下 Adapter 组件,连接器需要对接的是标准的 Servlet 容器,既然是 Servlet 容器,那就应该遵循 Servlet 规范,也就是说在 Servlet 的 service方法中只能接收标准的 ServletRequest 对象和 ServletResponse对象,而连接器负责对外交流,只能将基础的请求信息封装成一个 Request 对象,这时候我们就需要一个转换器,遇到这种需求我们通常会采用适配器模式。因此我们设计了一个 CoyoteAdapter 类,并提供一个 service 方法供连接器调用,内部则调用容器的 service 方法。
然后是 EndPoint 组件 和 Processor 组件,这两者一个负责对接 I/O 模型,一个负责对接应用层协议,都是会变化的,并且可以自由组合。因此在这里我们规定一下 Tomcat 能够支持的 I/O 模型和应用层协议,如下图所示。
针对这样一个组合的场景我们可以这么来设计,首先这两者其实是可以作为一个整体的,最终目的就是将请求信息转为一个统一的 Request 对象,当然了还要负责响应信息的输出。因此我们设计一个 ProtocolHandler 的接口来封装这两种变化点。
除了这些变化点,这两个组件也存在一些相对稳定的部分或者是一些通用的处理逻辑,这些稳定的部分我们通常使用抽象类来封装,我们可以定义一个抽象基类 AbstractProtocol 让它实现 ProtocolHandler 接口,然后针对每一种应用层协议也定义一个自己的抽象类,它们继承自 AbstractProtocol 抽象基类,扩展了具体协议相关的内容。
最后我们针对具体的应用层协议和 I/O 模型组合定义具体的实现类,例如:Http11NioProtocol 就是对 HTTP/1.1 协议 和 NIO 模型的实现。它们的类关系图如下:
在这里可能大家会有一个疑问,不是说 ProtocolHandler 是对 EndPoint 组件 和 Processor 组件的封装吗?为什么从源码中完全看不出来,很好的一个问题,有关于 EndPoint 组件和 Processor 组件的设计细节以及它们的交互过程我们会在源码部分给出答案。
连接器组件总体设计如下:
容器的设计
容器部分我们设计了4种容器,分别是Engine、Host、Context、Wrapper。这四种容器是父子关系,整体形成一个分层结构,如下图所示。
首先说明一下这四种容器的作用:
- Engine
表示整个 Catalina 的 Servlet 引擎,用来管理多个虚拟站点,一个 Service 最多只能有一个 Engine,但是一个引擎可包含多个 Host - Host
代表一个虚拟主机,或者说一个站点,可以给 Tomcat 配置多个虚拟主机地址,而一个虚拟主机下可包含多个 Context - Context
表示一个 Web 应用程序,一个Web应用可包含多个 Wrapper - Wrapper
表示一个Servlet,负责管理整个 Servlet 的生命周期,包括装载、初始化、资源回收等
通过这种分层的架构设计,使得 Servlet 容器具有很好的灵活性,同时功能也更加强大。
4.Tomcat架构汇总
至此 Tomcat 的核心组件就分析的差不多了,可以看出为了实现两项功能,Tomcat 进行了很多的封装设计,封装出了很多的组件,而这些组件之间呈现出了明显的层级关系,一层套着一层,这就是经典的套娃式架构设计。
配置文件与 Catalina 组件
其实这些组件的设计更多是为了使用者能够灵活的进行 web 项目部署配置,因此我们将其抽取成一个配置文件,名为 server.xml,如下图所示,在配置文件中你也能很清晰的对应上这些层级关系。
在这里需要做一个补充:不知道你在前面有没有注意到在讲解 Engine 组件时提到了 Catalina。其实 Catalina 也是 Tomcat 中的一个组件,它负责的是解析 Tomcat 的配置文件(server.xml),以此来创建服务器 Server 组件并进行管理。
因此也可以认为整个 Tomcat 就是一个 Catalina 实例,Tomcat 启动的时候会初始化这个实例,Catalina 实例通过加载server.xml 完成其他实例的创建,创建并管理一个 Server,Server 创建并管理多个服务, 每个服务又可以有多个Connector 和一个 Container。
套娃式架构设计的好处:
- 一层套一层的方式,组件关系清晰明了,也便于后期组件生命周期管理
- 与 xml 这种层级的数据格式非常吻合,整体的配置也非常灵活
- 便于子组件继承父组件的一些配置
Tomcat 模块分层结构
Tomcat 是一个由一系列可配置的组件构成的 Web 容器,在实现时根据不同的功能 Tomcat 内部进行了模块分层,其中 Catalina 模块作为 Tomcat 的 servlet 容器实现,它是 Tomcat 的核心模块。因为从另一个角度来说,Tomcat 本质上就是一款 Servlet 容器。而其他模块的设计都是为 Catalina 提供支撑的。
相关模块的功能说明如下:
整体模块分层结构图如下:
Tomcat总体架构
Tomcat 核心组件架构图如下所示:
这里有部分组件并没有分析到,我们对其进行简单的说明:
- Listener 组件
可以在 Tomcat 生命周期中完成某些容器相关的监听器 - JNDI
JNDI是 Java 命名与目录接口,是属于 J2EE 规范的,Tomcat 对其进行了实现。JNDI 在 J2EE 中的角色就是“交换机”,即 J2EE 组件在运行时间接地查找其他组件、资源或服务的通用机制(你可以简单理解为给资源取个名字,再根据名字来找资源) - Cluster 组件
提供了集群功能,可以将对应容器需要共享的数据同步到集群中的其他 Tomcat 实例中 - Realm 组件
提供了容器级别的用户-密码-权限的数据对象,配合资源认证模块使用 - Loader 组件
Web 应用加载器,用于加载 Web 应用的资源,它要保证不同 Web 应用之间的资源隔离 - Manager 组件
Servlet 映射器,它属于 Context 内部的路由映射器,只负责该 Context 容器的路由导航
Tomcat源码剖析
1.Tomcat 源码构建
下载源码(8.5.50)
网址:https://archive.apache.org/dist/tomcat/tomcat-8/v8.5.50/src/
如果需要下载其他版本的源码可以在tomcat官网:http://tomcat.apache.org/ 点击左侧相应的大版本,然后在右侧点击 Archives (存档)找到所有的子版本源码。
源码导入IDE之前准备工作
- 解压源码包,进入 apache-tomcat-8.5.50-src 目录
- 在当前目录中创建 source 文件夹,然后将 conf、webapps 目录移动到 source 文件夹中
- 在当前目录下创建 pom.xml,文件内容如下:
4.0.0
org.apache.tomcat
apache-tomcat-8.5.50-src
Tomcat8.5
8.5
Tomcat8.5
java
java
org.apache.maven.plugins
maven-compiler-plugin
3.1
UTF-8
11
org.easymock
easymock
3.4
ant
ant
1.7.0
wsdl4j
wsdl4j
1.6.2
javax.xml
jaxrpc
1.1
org.eclipse.jdt.core.compiler
ecj
4.5.1
javax.xml.soap
javax.xml.soap-api
1.4.0
导入源码工程到IDE并进行配置
打开IDEA,新建一个空的工程如下图所示,命名为tomcatsource。
然后选择【File】—>【New】—>【Module from Existing Sources】,选择我们解压好的tomcat源码目录。
下一步选择Maven,点击Finish即可。
项目构建完成后,在当前项目中搜索 Bootstrap 类,找到 main 方法,然后运行,这时候发现控制台报了一些错。
我们双击错误定位到错误具体位置,根据提示进行修复,如下图所示。
然后给 tomcat 的源码程序启动类 Bootstrap 配置VM参数(注意路径需要修改为自己的项目位置),因为 tomcat 源码运行也需要加载配置文件等。
-Dcatalina.home=D:/IdeaProjects/apache-tomcat-8.5.50-src/source
-Dcatalina.base=D:/IdeaProjects/apache-tomcat-8.5.50-src/source
-Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager
-Djava.util.logging.config.file=D:/IdeaProjects/apache-tomcat-8.5.50-src/source/conf/logging.properties
打开运行设置界面,在如下位置添加
最后还需要在 ContextConfig 类中的 configureStart 方法中增加一行代码将 Jsp 引擎初始化,如下
重启 tomcat,看到如下界面就说明访问正常了,至此 Tomcat 源码构建完毕。
2.Tomcat 启动流程分析
启动入口
首先我们需要定位启动入口在哪里。由于 Tomcat 是 Java 编写的,因此启动方法肯定是在某个类的 main 方法中,我们第一步就先来找到这个类。
通常启动 tomcat 都是直接双击运行 startup.sh 或者 startup.bat 脚本,以 startup.sh 脚本为例,文件核心内容如下:
可以看出在该脚本中执行了 catalina.sh,我们打开 catalina.sh 文件,找到核心内容如下:
在 catalina.sh 中,执行了 Bootstrap 类,当然在前面附加了很多参数比如 JVM 相关参数等。
接下来就来到了 Bootstrap 类的 main 方法,也就是 tomcat 的启动入口处。
逐级初始化
首先在 main 方法中调用了自身的 init 方法
进入 init 方法,主要逻辑如下:
然后回到 main 方法,往下调用了自身的 load 方法和 start 方法,我们先来看 load 方法的执行过程。
在 load 方法中实际是使用反射调用了 Catalina.load() 方法
进入 Catalina 类的 load 方法,里面代码有点多,前面其实是读取和解析配置文件的过程,我们后面再来分析,直接来到方法的末尾处,在这里 getServer() 返回的是一个 StandardServer 对象,分别对该对象设置了Catalina 对象、catalinaHome 和 catalinaBase 属性,catalinahome 是咱们 tomcat 的安装目录,catalinabase 是工作目录,其实这两个都是我们在构建源码时配置的 tomcat 源码下的 source 目录,最后关键的一步是调用了 server 的 init 方法,即 server 组件的初始化。
进入 init 方法,实际调用的是 LifecycleBase 的 init 方法,该类是一个抽象基类,实现了 Lifecycle 接口,方法主要逻辑如下:
下一步进入到了 LifecycleBase 的子类,也就是 StandardServer 的 initInternal() 方法,该方法除了对自身的一些初始化操作之外,最后进行了 service 组件的初始化。
当我们进入 service 的 init 方法发现又来到了 LifecycleBase 的 init 方法,因此我们直接跳过,下一步来到 StandardService 的 initInternal() 方法,该方法的重点依然是对各个子组件的初始化,其实到这你会发现这些组件都有一个共性,就是都继承了 LifecycleBase 抽象类并实现了其中的抽象方法,因此我们不在一一 debug了,最后看一个 connector 组件的初始化。
来到 Connector 类的 initInternal() 方法,该方法执行了 protocolHandler 组件的初始化。
下一步来到了 AbstractHttp11Protocol 类的 init 方法,该方法又调用了父类的初始化方法。
进入父类即 AbstractProtocol 的 init 方法,该方法最后完成了 endpoint 组件的初始化。
看一下 endpoint 的初始化过程,进入 AbstractEndpoint 的 init 方法,核心逻辑是这个 bind 方法。
进入 NioEndpoint 的 bind 方法,最后其实是使用了 java nio 的 API 创建了相关的对象及对象初始化工作。
到此各个组件的初始化过程就分析完毕了。
逐级启动
回到 Bootstrap 类的 main 方法,分析一下 start 方法的执行流程。该方法最终也是调用的 Catalina 类 的 start 方法。
进入 Catalina 的 start 方法,该方法调用了 server 的 start 方法,即启动 server 组件。
首先还是来到 LifecycleBase 的 start 方法,设置组件的状态,然后调用子类的 startInternal 方法。
来到 StandardServer 的 startInternal() 方法,该方法最后启动了 service 组件。
相信分析到这后面的步骤大家都能知道,和 load 方法的执行流程一样,也是一个逐级启动的过程,我们最后
看一下 endpoint 组件启动时完成的工作。来到 NioEndpoint 的 startInternal 方法。主要逻辑如下:
其中 Acceptor 线程是负责服务监听端口的请求接入的,我们可以看下在 NioEndPoint 中的内部类 Acceptor 的 run 方法。
Tomcat 启动流程总结
我们用一张时序图来描述上述的调用过程,整体分为两大部分,组件的逐级初始化和逐级启动。
3.优雅的 Lifecycle 接口
通过对 tomcat 启动流程的分析,你会发现这些组件都有 init 方法和 start方法,而这两个方法其实都是在 Lifecycle 接口中定义的。那么Lifecycle 接口是怎么被设计出来的?
生命周期机制
设计其实就是要找到系统的变化点和不变点,然后遵循设计原则,选择合适的设计模式进行具体的设计。
对于这些组件来说不变点就是每个组件的生命周期是一致的,即它们都要经历创建、初始化、启动、停止和销毁这几个过程,在这个过程中组件的状态和状态之间的转化也是不变的。而其中的变化点则是某个具体的组件在执行某个过程时是有所差异的。
因此针对这些组件的生命周期,我们抽取出一个接口,这个接口就是 Lifecycle。在该接口中,我们需要定义这么几个方法:init、start、stop 和 destroy,让每个组件去实现这些方法。
在 tomcat 中组件是有层级关系的,因此父组件需要在自己的生命周期方法比如 init 方法里创建子组件并且调用子组件的 init 方法,最终像一个链条一样层层调用下去,这其实就是设计模式中组合模式的经典实用。最终实现的效果就是在启动入口我们只需要调用最顶层组件的 init 方法 和 start 方法,整个 tomcat 就被启动起来了。
事件监听机制
tomcat 作为一个框架,尤其是作为一个 Web 容器框架,监听机制和过滤机制是我们设计时必须要考虑的一个点,也就是我们通常说的监听器和过滤器。由于在这里并没有涉及到请求的处理,因此我们只需要考虑监听器的设计。
在启动阶段各个组件会涉及到生命周期方法的组合调用,因此我们可以把组件的生命周期定义成相应的状态,把状态的转变看作是一个事件。有了事件就需要有监听器,在监听器里可以实现一些内部逻辑,并且监听器也可以方便的添加和删除,这就是典型的观察者模式的应用。
对于组件的状态我们可以定义一个枚举类 LifecycleState 来表示,针对监听器的添加和删除我们在 Lifecycle 接口中新增两个方法。类图关系如下:
抽象基类设计
在设计了 Lifecycle 接口之后,我们通常需要做这么一个考虑,在接口的这些众多方法中,不同实现类实现它们是否包含一些通用的逻辑或者流程,如果有我们就需要定义一个抽象基类来实现这部分共同的逻辑,而其中有差异的地方我们将其抽取成抽象方法,供子类实现。
因此我们可以定义一个抽象基类 LifecycleBase 让它实现 Lifecycle 接口并重写所有的方法,针对方法中的通用逻辑我们可以定义私有的方法自行实现,例如状态的转换和事件监听的处理我们可以定义了 setStateInternal 方法,而非通用逻辑定义相应的抽象方法交给具体子类去实现,例如子类的初始化逻辑定义抽象方法 initInternal,这就是典型的模板设计模式的使用,其中的抽象方法也称为模版方法。具体类图关系如下:
未完待续...
暂时先写到这,原本打算只写一万字左右,经过大量删减发现还是超出很多,如果真要写完整,估计不下于10万字。后续专门写个专栏吧,这篇就当作练手了,随便看看就好。
目前内容还有xml解析过程以及tomcat请求处理流程后续会更新,最近研究源码有点走火入魔了,头发掉了不少,最后给大家一个忠告,源码慎入,注意身体。