Apache Tomcat 架构演进

Tomcat 作为一款知名的轻量级应用服务器,它的架构设计可以值得我们借鉴。因为 Tomcat 作为开源以久的 Web 服务器,它的架构还是挺复杂的。这篇博客主要是介绍 Tomcat 的总体架构,通过由浅到深的方案介绍 Tomcat 的架构演进。首先我们先抛开 Tomcat 的现有架构,自己来设计一个 Web 服务器。

本文主要截取于书籍 – 《Tomcat 架构解析》。

1、Server

作为一个 Web 服务器,它应该有下面的功能

它接口客户端发送过来的请求。首先对请求进行解析,然后完成相应的业务处理,最后把处理结果响应给客户端

这个属于网络编程,Java 提供了基于 Socket 的网络编程。所以我们最开始就可以定义一个最简单的 Web 服务器。

在这里插入图片描述
通过 start 方法启动服务,并且通过 Socket 绑定端口并接收并处理客户端发送过的请求。

2、Connector 和 Container

将请求监听与请求处理放在一起扩展性就会很差,并且不符面向对象的单一职责原则。这里我们就需要把对请求的监听与请求的处理分离开来。通过 Connector 连接请求处理解析不同的协议,然后处理业务逻辑处理交易容器来处理。容器这个概念是 Tomcat 里面非常重要的一个概念。

在这里插入图片描述

一个 Server 可以包含多个 Connector 和 Container。它们都拥有自己的 start 和 stop 方法来加载和释放自己维护的资源。

但是这个设计有个明显的缺陷。就是 Server 可以包含多个 Connect 和 Container ,如何把 Connector 请求与 Container 处理映射请求呢。可以维护一个复杂的映射规则,但是这样不够灵活。可以通过引入 Service 这个概念来解决。请求的解析和处理都在一个 Service 里,而一个 Server 可以包含多个 Service。

在这里插入图片描述
在 Tomcat 中 Container 是一个通用的概念。为了和 Tomcat 的组件一致,我们将 Container 重新命名为 Engine。用于处理 整个 Servlet 请求。

需要注意的是, Engine 表示整个 Servlet 引擎,而非 Servlet 容器。表示整个 Servlet 容器的是 Server。引擎只负责请求的处理,并不需要考虑请求链接、协议处理等。

在这里插入图片描述

3、Container 设计

在上面的架构中我们解耦了 Web 请求的接收与处理这两个动作。

但是 Web 服务器是用来部署并运行 Web 应用的,是一个运行环境,而不是一个独立的业务处理系统。在一个容器当中可能有多个 Web 服务。因为我们需要在 Engine 容器中支持多个 Web 应用,并且当接口到 Connector 的处理请求时, Engine 容器能够找到一个合适的 Web 应用来处理。所以这里就引入 Context 这个概念。

在这里插入图片描述
这里一个 Context 表示一个 Web 应用,并且一个 Engine 可以包含多个 Context。

Context 也拥有 start 与 stop 方法,用来启动时候加载资源以及停止时释放资源。采用这种设计方式,我们将加载与卸载资源的过程分解到每个组件当中,使组件充分解耦,提高服务器的可扩展性和可维护性。在后续的过程中,新增组件多数也人有相同的方法,就不在赘述了。(在开源框架 Spring 中也有同样的设计)

这是不是个合理的方案呢?

设想我们有一台主机,它承担了多个域名的服务,如 news.mycompany.com 和 article.mycompany.com 均由该主机处理,我们应该如果实现呢?当然,我们可以在该主机上运行多个服务器实例,但是如果我们希望运行一个服务器实例呢?因为作为 Web 服务器,我们应提供尽量灵活的部署方式。

既然需要提供多个域名的服务,那么就可以将每个域名视为一个虚拟的主机,在每个虚拟主机下包含多个 Web 应用。因为对于客户端来说,他们并不了解服务端使用几台主机来为他们提供服务,只知道每个域名提供了哪些服务。因此,应用服务器将每个域名抽象为一个虚拟主机从概念上是合理的。

在这里插入图片描述
Host 表示虚拟主机的概念,一个 Host 可以包含多个 Context。在这里插入图片描述
在 Servlet 规范中,在 Web 应用中,可以包含多个 Servlet 实例来处理不同的链接。所以我们还需要一个组件概念来表示 Servlet 定义。在 Tomcat 当中,Servlet 定义被称为 Wrappe。

在这里插入图片描述
在之前多次提到容器这个概念,有时候指 Engine,有时候指 Context,其实它代表了一类组件。这类组件的具体作用就是处理接收客户端请求并且返回响应数据。尽管具体操作可能会委派到子组件完成,但是从行为定义上,它们是一致的。基于这个概念,再次修正我们的设计。

我们使用 Container 来表示容器, Container 可以添加并维护子容器,因引 Engine、Host、Context、Wrapper 均继承自 Container。我们将它们之前的组合关系改为虚线,表示它们之间是弱依赖的关系,它们之前的关系是通过 Container 的父子容器的概念体现的。不过 Service 持有的是 Engine 接口(8.5.6 版本之前为 Container 接口,更加通用)。

既然 Tomcat 的 Container 可以表示不同的概念级别:Servlet 引擎、虚拟主机、Web 应用和 Servlet,那么我们就可以将不同级别的容器作为处理客户端请求的组件。这具体由我们提供的服务器的复杂度决定。假如我们需要以嵌入式的方式启动 Tomat,且运行极其简单的请求处理,不必支持多 Web 应用场景。那么我们完全可以只在 Service 中维护一个简化版的 Engine(8.5.6 之前甚至可以直接由 Service 维护一个 Context)。当然,Tomcat 的默认实现采用了下图这种是灵活的方式。只是,我们要了解 Tomcat 的模型设计理论上的可伸缩性,这也是一个中间件产品架构设计所需要重点关注的。

在这里插入图片描述
Tomcat 的 Container 还有一个很重要的功能,就是后台处理。在很多情况下, Container 需要执行一些异步处理,而且是定期执行,如每隔 30 秒执行一次, Tomcat 对于文件变更的扫描就是通过这种机制来实现的。Tomcat 针对后台处理,在 Container 上定义了 backgroudProcess() 方法,它的基础抽象类 (ContainerBase) 确保在启动组件的同时,异步后台处理。所以各个容器组件仅需要实现 Container 的 backgroudProcess() 方法就可以了,不必考虑创建异步线程。

4、Lifecycle

通过上面的架构设计,所有的组件都存在启动、停止等生命周期方法,拥有生命周期管理的特性。因此,我们可以基于生命周期管理进行一次接口抽象。

在 Tomcat 中抽象了一个 Lifecycle 通用生命周期接口,这个接口定义了生命周期管理的核心方法:

  • init():初始化组件
  • start():启动组件
  • stop():停止组件
  • destroy():销毁组件

在这里插入图片描述
同时,该接口支持组件状态以及状态之间的转换,支持添加事件监听器(LifecycleListner) 用于监听组件的状态变化。如此,我们可以采用一致的机制来初始化、启动、停止以及销毁各个组件。如 Tomcat 核心组件的默念实现均继承自 LifecycleMBeanBase 抽象类,该类不但负责组件各个状态的转换和事件处理,还将组件自身注册为 MBean,以便通过 Tomcat 的管理工具进行动态维护。

Tomcat 中的 Lifecycle 接口状态图:
在这里插入图片描述
每个生命周期方法可以对应数个状态的转换,以 start() 为例,分为启动前、启动中、已启动,这 3 个状态之间自动转换(所有标识为 auto 的转换路径都是在生命周期方法中自动转换的,不再需要额外的方法调用)。

其次,并不是每个状态都会触发生命周期事件,也不是所有生命周期事件均存在对应状态。状态与应用生命周期事件的对应如下所示:

在这里插入图片描述

5、Pipeline 和 Valve

从架构设计的角度来考虑,应用服务器设计主要完成了我们对核心概念的分解,确保了整体架构的可伸缩性和可扩展性。除此之外,我们还要考虑如何提高每个组件的灵活性,让它易于扩展。

在增强组件的灵活性和可扩展性方面,职责链模式是一种比较好的选择。 Tomcat 就采用了这种模式来实现客户端请求的处理 – 请求处理也是职责链模式典型的应用场景之一。换句话说,在 Tomcat 中每个 Container 组件通过执行一个职责链来完成具体的请求处理。Tomcat 中每个 Container 组件都是通过执行职责链来完成具体的请求处理。

Tomcat 定义了 Pipeline(管道) 和 Valve (阀) 两个接口。前者用于构造职责链,后者代表职责链上的每个处理器。其设计如下图:

在这里插入图片描述
Pipeline 中维护了一个基础的 Valve,它始终位于 Pipeline 的末端(即最后执行),封装了具体的请求处理和输出响应的处理。然后,通过 addValu() 方法,我们可以为 Pipeline 添加其它的 Valve。后添加的 Valve 位于基础 Valve 之前,并按照添加顺序执行。Pipeline 通过获取首个 Value 来启动整个链条的执行。

Tomcat 每个层级的容器(Engine、Host、Context、Wrapper) 都有对应 Valve 的实现,同时维护了一个 Pipeline 实例。也就是说,我们可以在任何层级的容器上针对请求处理进行扩展。

在这里插入图片描述

6、Connector 设计

在前面主要讨论了容器组件的设计,集中于如何设计才能确保容器的灵活性和可扩展性,并做到合理的解耦。下面我们就来细化一下服务器设计中的另一个重要组件 – Connnector。

要想与 Container 配置实现一个完整的服务器功能,Connector 至少需要包含以下几项功能:

  • 监听服务器端口,读取来自客户端的请求
  • 将请求数据按照指定协议进行解析
  • 根据请求地址匹配正确的容器进行处理
  • 将响应返回客户端

只有这样才能保证将接收到的客户端请求交由与请求地址匹配的容器处理。

Tomcat 支持多种协议,默认支持 HTTP 和 AJP。同时,Tomcat 还支持多种 I/O 方式,包括 BIO、NIO、APR。并且在 Tomcat 8 之后还新增了对 NIO2 和 HTTP2 协议的支持。所以对协议和 I/O 进行抽象和建模需要重点关注:

在这里插入图片描述
在 Tomcat 中,ProtocolHandler 表示一个协议处理器,针对不同协议和 I/O 方式,提供了不同的实现。如 Http11NioProtocol 表示基于 NIO 的 HTTP 1.1 协议的处理器。ProtocolHandler 包含一个 Endpoint 用于启动 Socket 监听,这个接口按照 I/O 方式进行分类实现,如 Nio2Endpoint 表示非阻塞式 Socket I/O。来包含一个 Processor 用于按照指定协议读取数据,并将请求交给容器处理,如 Http11NioProcessor 表示在 NIO 的方式下 HTTP 请求的处理类。

Tomcat 并没有 Endpoint 接口,仅有 AbstractEndpoint 抽象类,这里只是作为概念讨论。

在 Connector 启动时,Endpoint 会启动线程来监听服务器端口,并在接收到请求后调用 Processor 进行数据读取。

当 Processor 读取客户端请求后,需要按照请求地址映射到具体的容器进行处理。这个过程就是请求映射。由于 Tomcat 各个组件采用能用的生命周期管理,而且可以通过管理工具进行状态变更,所以请求映射除了考虑映射规则的时候外,还需要考虑容器组件ener,用于的注册与销毁。

Tomcat 通过 Mapper 和 MapperListerner 两个类实现上述功能。前者用于维护容器映射信息,同时按照映射规则(Servlet 规范定义) 查询容器。后者实现了 ContainerLintener 和 LifecycleListener 用于在容器组件状态发生变更时,注册或者取消对应容器映射信息。为了实现上述功能, MapperListener 实现了 Lifecycle 接口,当其启动时(在 Service 启动时启动),会自动作为监听器注册到容器组件上,同时将已创建的容器注册到 Mapper。

在 Tomcat 7 及之前的版本中, Mapper 由 Connnector 维护,而在 Tomcat 8 中,改由 Service 维护,国为 Service 本来就是用于维护 Connector 和 Container 的组合,两者从概念上讲更密切一些

Tomcat 通过配置器模式(Adapter) 实现了 Connector 与 Mapper、 Container 的解耦。 Tomcat 默认的 Connector 实现(Coyote) 对应的配置器为 CoyoteAdapter。也就是说,如果你希望使用 Tomcat 的链接器方案,但是又想脱离 Sevlet 容器(虽然这种情况几乎不可能出现,但是从架构可扩展性的角度来讲,还是值得讨论一下),此时只需要实现我们自己的 Adapter 即可。当前,我们还需要按照 Container 的定义开发我们自己的容器实现(不一定遵从 Servlet 规范)。

在这里插入图片描述

7、Executor

在完成 Connector 设计之后,很明显我们忽略了一个问题 – 并发。我们不可能让所有来自客户端请求均以串行的方式执行。

既然 Tomcat 提供了一致的可插拔的组件环境,那么我们自然希望线程池作为一个组件进行统一管理。因此,Tomcat 提供了 Executor 接口来表示一个可以在组件间共享的线程池,这个接口同样继承自Lifecycle,可按照通用的组件进行管理。

其次,线程池的共享范围如何确定?在 Tomcat 中 Executor 由 Service 维护,因为同一个 Servcie 中的组件可以共享一个线程池。

在 Tomcat 中,Endpoint 会启动一组线程来监听 Socket 端口,当接收到客户端请求后,会创建请求处理对象,并交给线程池处理,由引支持并发处理客户端请求。

在这里插入图片描述

8、Bootstrap 和 Catalina

前面几个小节分析了 Tomcat 总体架构中的主要核心组件,它们代表应用服务器程序本身,就偈楼房的主体。除了主体建筑之外,楼房还需要外墙等装饰。 Tomcat 还需要提供一套配置环境来支持系统的可配置性,便于我们通过修改相关配置来优化应用服务器。

Tomcat 通过 Catalina 提供了一个 Shell 程序,用于解析 service 创建各个组件,同时,负责启动、停止应用服务器。Tomcat 使用 Digester 解析 XML 文件,包括 server.xml及 web.xml。

最后,Tomcat 提供了 Boottrap 作为应用服务器启动入口。 Bootstrap 负责创建 Catalina 实例,根据执行参数调用 Catalina 相关方法完成针对应用服务器的操作(启动、停止)。

也许你会有疑问,为什么 Tomcat 不直接通过 Catalina 启动,而是又提供 Bootstrap 呢?你可以查看一下 Tomcat 的发布包目录,Boostrap 并不位置 Tomcat 的依赖目录下 ($CATALINA_HOME/lib),而是直接在 $CATALINA_HOME/lib 目录下。 Boostrap 和 Tomcat 应用服务器完成松耦合(通过反射调用 Catalina 实例),它可以直接依赖 JRE 运行并为 Tomcat 应用服务器创建共享类加载器,用于构造 Catalina 实例以及整个 Tomcat 服务器。

在这里插入图片描述

Tomcat 的启动方式可以作为非常好的示范来指导中间件产品设计。它实现了启动入口与核心环境的解耦,这样不权简化了启动(不必配置各种依赖库,因为只有独立的几个 API),而且便于我们更灵活的组件中间件产品的结构,尤其是类加载器的方案。否则,我们所有的依赖库将统一放置到一个类加载器中,而无法做到灵活定制。

上述是 Tomcat 标准的启动方式。既然 Server 及其子组件代表了应用服务器本身,那么我们就可能不通过 Bootstrap 和 Catalina 来启动服务器。

Tomcat 提供了一个同名类 org.apache.catalina.stratup.Tomcat,使用它我们可以将 Tomcat 服务器嵌入到我们的应用系统中并进行启动。当然,你可以自己编写代码来启动 Server,也可以自定义其他配置方式启动,如 YAML。这就是 Tomcat 灵活的架构设计带给我们的便利,也是我们设计中间件产品的架构关注点之一。

引用地址:

  • 书籍 – 《Tomcat 架构解析》
发布了195 篇原创文章 · 获赞 248 · 访问量 74万+

猜你喜欢

转载自blog.csdn.net/u012410733/article/details/105593342