[13] Tomcat 架构设计&基本原理

发布时间 2023-11-29 08:03:07作者: tree6x7

1. Tomcat 引入

1.1 功能需求

浏览器发给服务端的是一个 HTTP 格式的请求,HTTP 服务器收到这个请求后,需要调用服务端程序来处理,所谓的服务端程序就是你写的 Java 类,一般来说不同的请求需要由不同的 Java 类来处理。

那么问题来了,HTTP 服务器怎么知道要调用哪个 Java 类的哪个方法呢?

左图方式 HTTP 服务器直接调用具体业务类,它们是紧耦合的。

解决方法如右图所示,HTTP 服务器不直接调用业务类,而是把请求交给容器来处理,容器通过 Servlet 接口调用业务类。因此 Servlet 接口和 Servlet 容器的出现,达到了 HTTP 服务器与业务类解耦的目的。

至此引出 Tomcat 两个非常重要的功能(身份):

  • Http 服务器功能:Socket 通信(TCP/IP)、解析 Http 报文;
  • Servlet 容器功能:有很多 Servlet(自带系统级 Servlet + 自定义 Servlet),Servlet 处理具体的业务逻辑。

1.2 设计实现

Tomcat 是一个开源的 Java Web 应用服务器,实现了 Java EE(Java Platform Enterprise Edition) 的部分技术规范,比如 Java Servlet、JavaServer Pages、Java Expression Language、Java WebSocket 等。

Tomcat 的需求是要实现 2 个核心功能:

  • 处理 Socket 连接,负责网络字节流与 Request 和 Response 对象的转化。
  • 加载和管理 Servlet,以及具体处理 Request 请求。

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

Tomcat 中一个 Container 可能对接多个 Connector,每一个 Connector 都对应某种协议某种 IO 模型,Tomcat 将多个 Connector 和单个 Container 组成一个 Service 组件,一个 Tomcat 中可能存在多个 Service 组件。

  • 【Connector】将不同协议/不同 IO 模型的请求转换为标准的标准的 ServletRequest 对象交给容器处理。
  • 【Container】Container 本质上是一个 Servlet 容器,负责 Servlet 的加载和管理,处理请求 ServletRequest,并返回标准的 ServletResponse 对象给连接器。

2. Connector 设计

2.1 前置基础

Tomcat 支持的多种 I/O 模型和应用层协议

Tomcat 支持的 I/O 模型:

  • BIO:阻塞式 I/O,性能低下,8.5 版本之后已经移除;
  • NIO:非阻塞 I/O,采用 Java NIO 类库实现,Tomcat 内部实现了 Reactor 线程模型,性能较高;
  • AIO(NIO2):异步 I/O,采用 JDK 7 最新的 NIO2 类库实现;
  • APR:采用 Apache 可移植运行库实现,是 C/C++ 编写的本地库。是从操作系统级别来解决异步的 IO 问题,大幅度提高了性能。

Tomcat 支持的应用层协议:

  • HTTP/1.1:这是大部分 Web 应用采用的访问协议
  • AJP:用于和 Web 服务器集成(如 Apache)
  • HTTP/2:HTTP 2.0 大幅度的提升了 Web 性能

2.2 架构分析

Tomcat 为了实现支持多种 I/O 模型和应用层协议,一个容器可能对接多个连接器,就好比一个房间有多个门,但是单独的连接器或者容器都不能对外提供服务,需要把它们组装起来才能工作,组装后这个整体叫作 Service 组件。

Service 本身没有做什么重要的事情,只是在连接器和容器外面多包了一层,把它们组装在一起。

Tomcat 内可能有多个 Service,这样的设计也是出于灵活性的考虑。通过在 Tomcat 中配置多个 Service,可以实现通过不同的端口号来访问同一台机器上部署的不同应用。

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

2.3 核心功能

Tomcat 与外部世界的连接器,监听固定端口接收外部请求,传递给 Container,并将 Container 处理的结果返回给外部。

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

Connector 需要实现的功能:

  • 监听网络端口。
  • 接受网络连接请求。
  • 读取请求网络字节流。
  • 根据具体应用层协议(HTTP/AJP)解析字节流,生成统一的 Tomcat Request 对象。
  • 将 Tomcat Request 对象转成标准的 ServletRequest。
  • 调用 Servlet 容器,得到 ServletResponse。
  • 将 ServletResponse 转成 Tomcat Response 对象。
  • 将 Tomcat Response 转成网络字节流。
  • 将响应字节流写回给浏览器。

2.4 通用架构设计

优秀的模块化设计应该考虑高内聚、低耦合。高内聚是指相关度比较高的功能要尽可能集中,不要分散;低耦合是指两个相关的模块要尽可能减少依赖的部分和降低依赖的程度,不要让两个模块产生强依赖。

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

  1. 网络通信
  2. 应用层协议解析
  3. Tomcat Request/Response 与 ServletRequest/ServletResponse 的转化

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

  1. EndPoint 负责提供字节流给 Processor
  2. Processor 负责提供 Tomcat Request 对象给 Adapter
  3. Adapter 负责提供 ServletRequest 对象给容器

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

上面提到 I/O 模型和应用层协议可以自由组合,比如 NIO + HTTP 或者 NIO2 + AJP。

传输层 应用层
NIO HTTP
NIO2 AJP
APR HTTP2

Tomcat 的设计者将网络通信和应用层协议解析放在一起考虑,设计了一个叫 ProtocolHandler 的接口来封装这两种变化点。各种协议和通信模型的组合有相应的具体实现类。比如:Http11NioProtocol 和 AjpNioProtocol。

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

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

Coyote 是 Tomcat 的连接器框架的名称。

2.5 内部组件

a. ProtocolHandler

连接器用 ProtocolHandler 来处理「网络连接」「应用层协议」,包含了 2 个重要部件:EndPoint 和 Processor。

连接器用 ProtocolHandler 接口来封装通信协议和 I/O 模型的差异,ProtocolHandler 内部又分为 EndPoint 和 Processor 模块,EndPoint 负责底层 Socket 通信,Proccesor 负责应用层协议解析。连接器通过适配器 Adapter 调用 Container。

(1)EndPoint

Endpoint 翻译过来是“通信端点”,主要负责网络通信,这其中就包括,监听客户端连接创建于客户端连接的 Socket,并负责连接 Socket 接收和发送处理器。因此Endpoint 是对「传输层」的抽象,是用来实现 TCP/IP 协议的。

Tomcat 并没有 EndPoint 接口,而是提供了一个抽象类 AbstractEndpoint。对于不同的 Linux IO 模型通过使用不同子类来实现。

在 AbstractEndpoint 的具体子类,比如在 NioEndpoint 和 Nio2Endpoint 中,有两个重要的子组件:Acceptor 和 SocketProcessor。

  • Acceptor 用于监听 Socket 连接请求
  • SocketProcessor 用于处理接收到的 Socket 请求,它实现 Runnable 接口,在 run 方法里调用协议处理组件 Processor 进行处理。为了提高处理能力,SocketProcessor 被提交到线程池来执行。而这个线程池叫作执行器(Executor)。

(2)Processor

Processor 是 Coyote 的协议处理接口 ,如果说 EndPoint 是用来实现 TCP/IP 协议的,那么 Processor 用来实现 HTTP 协议。

Processor 接收来自 EndPoint 的 Socket,根据具体应用层协议(HTTP/AJP)读取字节流解析成 Tomcat Request 和 Response 对象,然后交给 Adapter 处理。

Processor 是对应用层协议的抽象。

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

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

补充

ProtocolHandler 是 Coyote 协议接口,通过 Endpoint 和 Processor,实现针对具体协议的处理能力。Tomcat 按照应用层协议和 I/O 模型提供了 6 个实现类。

我们在配置 tomcat/conf/server.xml 时 , 至少要指定具体的 ProtocolHandler,当然也可以指定协议名称, 如:HTTP/1.1,如果安装了 APR,那么将使用 Http11AprProtocol,否则使用 Http11NioProtocol。

b. Adapter

由于协议不同,客户端发过来的请求信息也不尽相同,Tomcat 定义了自己的 Request 类来“存放”这些请求信息。ProtocolHandler 接口负责解析请求并生成 Tomcat Request 类。但是这个 Request 对象不是标准的 ServletRequest,也就意味着,不能用 Tomcat Request 作为参数来调用容器。

Tomcat 设计者的解决方案是引入 CoyoteAdapter,这是「适配器模式」的经典运用,连接器调用 CoyoteAdapter#sevice 方法,其负责将 Tomcat Request/Response 与 ServletRequest/ServletResponse 的相互转化,实现连接器(Connector)和容器(Container)的解耦。

设计复杂系统的基本思路:首先要分析需求,根据高内聚低耦合的原则确定子模块,然后找出子模块中的变化点和不变点,用接口和抽象基类去封装不变点,在抽象基类中定义模板方法,让子类自行实现抽象方法,也就是具体子类去实现变化点。

2.6 处理流程

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

  • Endpoint 内部 Acceptor 组件用于监听 Socket 连接请求,当发送客户端连接到服务端 Acceptor 组件负责与客户端建立连接创建 Socket,每当连接客户端发起请求,Endpoint 会创建一个 SocketProcessor 对象。SocketProcessor 用于处理接收到的 Socket 请求,它实现 Runnable 接口,在 run 方法里调用协议处理组件 Processor 进行处理。为了提高处理能力,SocketProcessor 被提交到线程池来执行。而这个线程池叫作执行器(Executor)。
  • Processor 接收来自 Endpoint 的 Socket,读取字节流解析成 Tomcat Request 和 Response 对象,接着会调用 Adapter 的 service 方法,并通过 Adapter 将其提交到容器处理。
  • 连接器调用 CoyoteAdapter 的 sevice 方法,传入的是 Tomcat Request 对象,CoyoteAdapter 负责将 Tomcat Request 转成 ServletRequest,再调用容器的 service 方法。

3. 容器 Container

3.1 容器的本质

Tomcat 是一个由一系列可配置的组件构成的 Web 容器,而 Catalina 是 Tomcat 的 servlet 容器。

容器,顾名思义就是用来装载东西的器具,在 Tomcat 里,容器就是用来装载 Servlet 的。Tomcat 通过一种分层的架构,使得 Servlet 容器具有很好的灵活性。Tomcat 设计了 4 种容器,分别是 Engine、Host、Context 和 Wrapper。这 4 种容器不是平行关系,而是父子关系。

Catalina 是 Servlet 容器实现,包含了之前讲到的所有的容器组件,以及后续章节涉及到的安全、会话、集群、管理等 Servlet 容器架构的各个方面。它通过松耦合的方式集成 Coyote,以完成按照请求协议进行数据读写。同时,它还包括我们的启动入口、Shell 程序等。

Tomcat 模块分层结构:

Tomcat 本质上就是一款 Servlet 容器,因此 Catalina 才是 Tomcat 的核心,其他模块都是为 Catalina 提供支撑的。比如:通过 Coyote 模块提供连接通信,Jasper 模块提供 JSP 引擎,Naming 提供 JNDI 服务,Juli 提供日志服务。

如下图所示,Catalina 负责管理 Server,而 Server 表示着整个服务器。Server 下面有多个服务 Service,每个服务都包含着多个连接器组件 Connector(Coyote 实现)和一个容器组件 Container。在 Tomcat 启动的时候, 会初始化一个 Catalina 的实例。

3.2 容器工作流程

当客户请求某个资源时,HTTP 服务器会用一个 ServletRequest 对象把客户的请求信息封装起来,然后调用 Servlet 容器的 service 方法,Servlet 容器拿到请求后,根据请求的 URL 和 Servlet 的映射关系,找到相应的 Servlet,如果 Servlet 还没有被加载,就用反射机制创建这个 Servlet,并调用 Servlet 的 init 方法来完成初始化,接着调用 Servlet 的 service 方法来处理请求,把 ServletResponse 对象返回给 HTTP 服务器,HTTP 服务器会把响应发送给客户端。

3.3 容器层次结构

Tomcat 设计了 4 种容器,分别是 Engine、Host、Context 和 Wrapper。Tomcat 通过一种分层的架构,使得 Servlet 容器具有很好的灵活性。

这 4 种容器不是平行关系,而是父子关系。

容器 描述
Engine 表示整个 Catalina 的 Servlet 引擎,用来管理多个虚拟站点,一个 Service 最多只能有一个 Engine,但是一个引擎可包含多个 Host。
Host 代表一个虚拟主机或者说一个站点,可以给 Tomcat 配置多个虚拟主机地址,而一个虚拟主机下可包含多个 Context(Web 应用程序)。
Context 表示一个 Web 应用程序, 一个 Web 应用可包含多个 Wrapper。
Wrapper 表示一个 Servlet,Wrapper 作为容器中的最底层,不能包含子容器。

(1)思考:Tomcat 是怎么管理这些容器组件的?

Tomcat 采用「组合模式」来管理这些容器。具体实现方法是,所有容器组件都实现了 Container 接口,因此组合模式可以使得用户对单容器对象和组合容器对象的使用具有一致性。

public interface Container extends Lifecycle {
    public void setName(String name);
    public Container getParent();
    public void setParent(Container container);
    public void addChild(Container child);
    public void removeChild(Container child);
    public Container findChild(String name);
}

可能还注意到 Container 接口扩展了 Lifecycle 接口,Lifecycle 接口用来统一管理各组件的生命周期。

可以再通过 Tomcat 的 server.xml 配置文件来加深对 Tomcat 容器的理解。Tomcat 采用了组件化的设计,它的构成组件都是可配置的,其中最外层的是 Server,其他组件按照一定的格式要求配置在这个顶层容器中。

<Server>                               <!-- 顶层组件,可以包含多个Service -->
    <Service>                          <!-- 顶层组件,可包含一个Engine,多个Connector -->
        <Connector/>                   <!-- 连接器组件,代表通信接口 -->
        <Connector/>
        <Engine>                       <!-- 容器组件,一个Engine组件处理Service中的所有请求,包含多个Host -->
            <Host>                     <!-- 容器组件,处理特定的Host下客户请求,可包含多个Context -->
                <Context></Context>    <!-- 容器组件,为特定的Web应用处理所有的客户请求 -->
            </Host>
        </Engine>
    </Service>
</Server>

Tomcat 启动期间会通过解析 server.xml,利用反射创建相应的组件,所以 xml 中的标签和源码一一对应。

(2)假如有用户访问一个 URL,如下图的 http://user.shopping.com:8080/order/buy,Tomcat 如何将这个 URL 定位到一个 Servlet 呢?

首先,根据协议和端口号选定 Service 和 Engine。

我们知道 Tomcat 的每个连接器都监听不同的端口,比如 Tomcat 默认的 HTTP 连接器监听 8080 端口、默认的 AJP 连接器监听 8009 端口。上面例子中的 URL 访问的是 8080 端口,因此这个请求会被 HTTP 连接器接收,而一个连接器是属于一个 Service 组件的,这样 Service 组件就确定了。我们还知道一个 Service 组件里除了有多个连接器,还有一个容器组件,具体来说就是一个 Engine 容器,因此 Service 确定了也就意味着 Engine 也确定了。

然后,根据域名选定 Host。

Service 和 Engine 确定后,Mapper 组件通过 URL 中的域名去查找相应的 Host 容器,比如例子中的 URL 访问的域名是 user.shopping.com,因此 Mapper 会找到 Host2 这个容器。

之后,根据 URL 路径找到 Context 组件。

Host 确定以后,Mapper 根据 URL 的路径来匹配相应的 Web 应用的路径,比如例子中访问的是 /order,因此找到了 Context4 这个 Context 容器。

最后,根据 URL 路径找到 Wrapper(Servlet)。

Context 确定后,Mapper 再根据 web.xml 中配置的 Servlet 映射路径来找到具体的 Wrapper 和 Servlet。

4. Tomcat 一键式启停

4.1 问题说明

Tomcat 里面有各种各样的组件,每个组件各司其职,组件之间又相互协作共同完成 Web 服务器这样的工程。

上面这张图描述了组件之间的静态关系,如果想让一个系统能够对外提供服务,我们需要创建、组装并启动这些组件;在服务停止的时候,我们还需要释放资源,销毁这些组件,因此这是一个动态的过程。也就是说,Tomcat 需要动态地管理这些组件的生命周期。

组件关系:

先来看看组件之间的关系。如果你仔细分析过这些组件,可以发现它们具有两层关系。

  • 第一层关系是组件有大有小,大组件管理小组件,比如 Server 管理 Service,Service 又管理连接器和容器。
  • 第二层关系是组件有外有内,外层组件控制内层组件,比如连接器是外层组件,负责对外交流,外层组件调用内层组件完成业务功能。也就是说,请求的处理过程是由外层组件来驱动的。

这两层关系决定了系统在创建组件时应该遵循一定的顺序。

  • 第一个原则是先创建子组件,再创建父组件,子组件需要被“注入”到父组件中。
  • 第二个原则是先创建内层组件,再创建外层组件,内层组件需要被“注入”到外层组件

因此,最直观的做法就是将图上所有的组件按照先小后大、先内后外的顺序创建出来,然后组装在一起。不知道注意到没有,这个思路其实很有问题!因为这样不仅会造成代码逻辑混乱和组件遗漏,而且也不利于后期的功能扩展。为了解决这个问题,我们希望找到一种通用的、统一的方法来管理组件的生命周期,就像汽车“一键启动”那样的效果。

思考:如何统一管理组件的创建、初始化、启动、停止和销毁?

4.2 Lifecycle

由于所有的组件均存在初始化、启动、停止等生命周期方法,拥有生命周期管理的特性, 所以 Tomcat 在设计的时候, 基于生命周期管理抽象成了一个接口 Lifecycle ,而组件 Server、Service、Container、Executor、Connector 组件 , 都实现了一个生命周期的接口,从而具有了以下生命周期中的核心方法:

在这样的设计中,在父组件的 init 方法里需要创建子组件并调用子组件的 init 方法。同样,在父组件的 start 方法里也需要调用子组件的 start 方法,因此调用者可以无差别的调用各组件的 init 方法和 start 方法,这就是组合模式的使用,并且只要调用最顶层组件,也就是 Server 组件的 init 和 start 方法,整个 Tomcat 就被启动起来了。

4.3 LifecycleBase

有了接口,我们就要用类去实现接口。一般来说实现类不止一个,不同的类在实现接口时往往会有一些相同的逻辑,如果让各个子类都去实现一遍,就会有重复代码。那子类如何重用这部分逻辑呢?其实就是定义一个基类来实现共同的逻辑,然后让各个子类去继承它,就达到了重用的目的。

而基类中往往会定义一些抽象方法,所谓的抽象方法就是说基类不会去实现这些方法,而是调用这些方法来实现骨架逻辑。抽象方法是留给各个子类去实现的,并且子类必须实现,否则无法实例化。

Tomcat 定义一个基类 LifecycleBase 来实现 Lifecycle 接口,把一些公共的逻辑放到基类中去,比如生命状态的转变与维护、生命事件的触发以及监听器的添加和删除等,而子类就负责实现自己的初始化、启动和停止等方法。

@Override
public final synchronized void init() throws LifecycleException {
    // 1. 状态检查
    if (!state.equals(LifecycleState.NEW)) {
        invalidTransition(Lifecycle.BEFORE_INIT_EVENT);
    }

    try {
        // 2.触发INITIALIZING事件的监听器
        setStateInternal(LifecycleState.INITIALIZING, null, false);
        
        // 3.调用具体子类的初始化方法
        initInternal();
        
        // 4. 触发INITIALIZED事件的监听器
        setStateInternal(LifecycleState.INITIALIZED, null, false);
    } catch (Throwable t) {
      ...
    }
}

5. Tomcat 源码启动

Tomcat 源码目录结构:

  • 【bin】bin 目录主要是用来存放 Tomcat 的脚本,如 startup.sh , shutdown.sh
  • 【conf】
    • catalina.policy:Tomcat 安全策略文件,控制 JVM 相关权限,具体可以参考 java. security.Permission;
    • catalina.properties:Tomcat Catalina 行为控制配置文件,如 Common ClassLoader;
    • logging.properties:Tomcat 日志配置文件;
    • server.xml:Tomcat Server配置文件;
    • GlobalNamingResources:全局 JNDI 资源;
    • context.xml:全局 Context 配置文件;
    • tomcat-users.xml:Tomcat 角色配置文件;
    • web.xml:Servlet 标准的 web.xml 部署文件,Tomcat 默认实现部分配置入内:org.apache.catalina.servlets.DefaultServlet、org.apache.jasper.servlet.JspServlet;
  • 【lib】公共类库;
  • 【logs】Tomcat 在运行过程中产生的日志文件;
  • 【webapps】用来存放应用程序,当 Tomcat 启动时会去加载 webapps 目录下的应用程序;
  • 【work】用来存放 Tomcat 在运行时的编译后文件,例如 JSP 编译后的文件。

(1)解压源码包后,进入解压目录并创建一个子目录,命名为 home,并将 conf、webapps 目录移动该目录中;

(2)在当前目录下创建一个 pom.xml,引入 tomcat 的依赖;

<?xml version="1.0" encoding="UTF-8"?>
<project ...>
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.apache.tomcat</groupId>
    <artifactId>apache-tomcat-8.5.42-src</artifactId>
    <name>tomcat</name>
    <version>8.5.42</version>
 
    <build>
        <finalName>Tomcat8.5</finalName>
        <sourceDirectory>java</sourceDirectory>
        <resources>
            <resource>
                <directory>java</directory>
            </resource>
        </resources>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>2.3</version>
                <configuration>
                    <encoding>UTF-8</encoding>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
 
    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.easymock</groupId>
            <artifactId>easymock</artifactId>
            <version>3.4</version>
        </dependency>
        <dependency>
            <groupId>ant</groupId>
            <artifactId>ant</artifactId>
            <version>1.7.0</version>
        </dependency>
        <dependency>
            <groupId>wsdl4j</groupId>
            <artifactId>wsdl4j</artifactId>
            <version>1.6.2</version>
        </dependency>
        <dependency>
            <groupId>javax.xml</groupId>
            <artifactId>jaxrpc</artifactId>
            <version>1.1</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.jdt.core.compiler</groupId>
            <artifactId>ecj</artifactId>
            <version>4.5.1</version>
        </dependency>
    </dependencies>
</project>

(3)添加启动类 org.apache.catalina.startup.Bootstrap

(4)添加 VM 启动参数

-Dcatalina.home=U:/.../apache-tomcat-8.5.42-src/home
-Dcatalina.base=U:/.../apache-tomcat-8.5.42-src/home
-Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager
-Djava.util.logging.config.file=U:/.../apache-tomcat-8.5.42-src/home/conf/logging.properties

(5)运行起来后,访问 localhost:8080,发现报 500 ...

出现上述异常的原因是我们直接启动 Bootstrap 的时候没有加载 JasperInitializer,从而无法编译 JSP。解决办法是在 tomcat 的 ContextConfig#configureStart 中手动将 JSP 解析器初始化:context.addServletContainerInitializer(new JasperInitializer(), null);

重启后,访问正常了。

6. JSP 核心引擎 Jasper

对于基于 JSP 的 web 应用来说,我们可以直接在 JSP 页面中编写 Java 代码,添加第三方的标签库,以及使用 EL 表达式。但是无论经过何种形式的处理,最终输出到客户端的都是标准的 HTML 页面(包含 js、css ...),并不包含任何的 Java 相关的语法。 也就是说,我们可以把 JSP 看做是一种运行在服务端的脚本。 那么服务器是如何将 JSP 页面转换为 HTML 页面的呢?

Jasper 模块是 Tomcat 的 JSP 核心引擎,我们知道 JSP 本质上是一个 Servlet。Tomcat 使用 Jasper 对 JSP 语法进行解析,生成 Servlet 及 Class 字节码,用户在进行访问 JSP 时会访问 Servlet,最终将访问的结果直接响应在浏览器端 。另外,在运行的时候,Jasper 还会检测 JSP 文件是否修改,如果修改,则会重新编译 JSP 文件。

6.1 编译方式

(1)运行时编译

Tomcat 并不会在启动 Web 应用的时候自动编译 JSP 文件,而是在客户端第一次请求时才编译需要访问的 JSP 文件。创建一个 web 项目并编写 JSP 代码:

Tomcat 在默认的 web.xml 中配置了一个 org.apache.jasper.servlet.JspServlet 用于处理所有的 .jsp.jspx 结尾的请求,该 Servlet 实现即是运行时编译的入口。

(2)预编译

除了运行时编译,我们还可以直接在 Web 应用启动时, 一次性将 Web 应用中的所有的 JSP 页面一次性编译完成。在这种情况下,Web 应用运行过程中,便可以不必再进行实时编译,而是直接调用 JSP 页面对应的 Servlet 完成请求处理, 从而提升系统性能。

Tomcat 提供了一个 Shell 程序 JspC,用于支持 JSP 预编译,而且在 Tomcat 的安装目录下提供了一个 catalina-tasks.xml 文件声明了 Tomcat 支持的 Ant 任务, 因此我们很容易使用 Ant 来执行 JSP 预编译(要想使用这种方式,必须得确保在此之前已经下载并安装了 Apache Ant)。

6.2 编译原理

由编译后的源码解读, 可以分析出以下几点:

  1. 其类名为 index_jsp, 继承自 org.apache.jasper.runtime.HttpJspBase,该类是HttpServlet 的子类 , 所以 JSP 本质就是一个 Servlet;
  2. 通过属性 _jspx_dependants 保存了当前 JSP 页面依赖的资源,包含引入的外部的 JSP 页面、导入的标签、标签所在的 jar 包等,便于后续处理过程中使用(如重新编译检测,因此它以 Map 形式保存了每个资源的上次修改时间);
  3. 通过属性 _jspx_imports_packages 存放导入的 java 包, 默认导入 javax.servlet、javax.servlet.http、javax.servlet.jsp;
  4. 通过属性 _jspx_imports_classes 存放导入的类, 通过 import 指令导入的 DateFormat 、SimpleDateFormat 、Date 都会包含在该集合中;_jspx_imports_packages_jspx_imports_classes 属性主要用于配置 EL 引擎上下文;
  5. 请求处理由方法 _jspService 完成,而在父类 HttpJspBase 中的 service 方法通过模板方法模式,调用了子类的 _jspService 方法;
    @Override
    public final void service(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        _jspService(request, response);
    }
    
  6. _jspService 方法中定义了几个重要的局部变量:pageContext 、Session、application、config、out、page。由于整个页面的输出有 _jspService 方法完成,因此这些变量和参数会对整个 JSP 页面生效。 这也是我们为什么可以在 JSP 页面使用这些变量的原因;
  7. 指定文档类型的指令(page)最终转换为 response.setContentType() 方法调用;
  8. 对于每一行的静态内容(HTML),调用 out.write 输出;
  9. 对于 <% ... %> 中的 Java 代码,将直接转换为 Servlet 类中的代码。 如果在 Java 代码中嵌入了静态文件, 则同样调用 out.write 输出。

Compiler 编译工作主要包含“代码生成”和“编译”两部分:

代码生成:

  1. Compiler 通过一个 PageInfo 对象保存 JSP 页面编译过程中的各种配置,这些配置可能来源于 Web 应用初始化参数,也可能来源于 JSP 页面的指令配置(如 page、include);
  2. 调用 ParserController 解析指令节点,验证其是否合法,同时将配置信息保存到 PageInfo 中,用于控制代码生成;
  3. 调用 ParserController 解析整个页面,由于 JSP 是逐行解析,所以对于每一行会创建一个具体的 Node 对象。如静态文本(TemplateText)、Java 代码(Scriptlet)、定制标签(CustomTag)、Include 指令(IncludeDirective);
  4. 验证除指令外其他所有节点的合法性, 如脚本、定制标签、EL 表达式等;
  5. 收集除指令外其他节点的页面配置信息;
  6. 编译并加载当前 JSP 页面依赖的标签;
  7. 对于 JSP 页面的 EL 表达式,生成对应的映射函数;
  8. 生成 JSP 页面对应的 Servlet 类源代码。

编译:

  • 代码生成完成后, Compiler 还会生成 SMAP 信息。 如果配置生成 SMAP 信息,Compiler 则会在编译阶段将 SMAP 信息写到 class 文件中;
  • 在编译阶段,Compiler 的两个实现 AntCompiler 和 JDTCompiler 分别调用先关框架的 API 进行源代码编译;
  • 对于 AntCompiler 来说,构造一个 Ant 的 javac 的任务完成编译;
  • 对于 JDTCompiler 来说, 调用 org.eclipse.jdt.internal.compiler.Compiler 完成编译。

7. Tomcat 类加载机制

回顾 JVM 的类加载机制:

JVM 的类加载机制中有一个非常重要的角色叫做类加载器(ClassLoader),类加载器有自己的体系,JVM 内置了几种类加载器,包括:引导类加载器、扩展类加载器、系统类加载器,他们之间形成父子关系,通过 Parent 属性来定义这种关系,最终可以形成树形结构。

Tomcat 的类加载机制相对于 JVM 的类加载机制做了一些改变。没有严格的遵从双亲委派机制,也可以说打破了双亲委派机制。

比如,有一个 tomcat 的 webapps下部署了两个应用:

  • app1/lib/a-1.0.jar com.lagou.edu.Abc
  • app2/lib/a-2.0.jar com.lagou.edu.Abc

不同版本中 Abc 类的内容是不一样的。若遵从默认的 JVM 类加载机制,则在加载 app2#com.lagou.edu.Abc 类的时候,会因为发现内存空间中已经存在了 app1#com.lagou.edu.Abc 而放弃加载。

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个 Java 虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

所以,Tomcat 对类加载逻辑做了一些改变:

  • 引导类加载器和扩展类加载器的作用不变;
  • 系统类加载器正常情况下加载的是 CLASSPATH 下的类,但是 Tomcat 的启动脚本并未使用该变量,而是加载 tomcat 启动的类,如 bootstrap.jar 是在 catalina.bat/catalina.sh 中指定:CLASSPATH="$CLASSPATH""$CATALINA_HOME"/bin/bootstrap.jar
  • Common ClassLoader 用于加载 Tomcat 使用以及应用通用的一些类,位于 CATALINA_HOME/lib 下,如 servlet-api.jar;
  • Catalina ClassLoader 用于加载服务器内部可见类,这些类应用程序不能访问;
  • Shared ClassLoader 用于加载应用程序共享类,这些类服务器不会依赖;
  • Webapp ClassLoader 是每个应用程序都会有的一个独一无二的类加载器,它用来加载本应用程序 /WEB-INF/classes 和 /WEB-INF/lib 下的类。

8. Tomcat&WebSocket

8.1 WebSocket 引入

WebSocket 是 HTML5 新增的协议,它的目的是在浏览器和服务器之间建立一个不受限的双向通信的通道,比如说,服务器可以在任意时刻发送消息给浏览器。

为什么传统的 HTTP 协议不能做到 WebSocket 实现的功能?这是因为 HTTP 协议是一个“请求-响应”协议,请求必须先由浏览器发给服务器,服务器才能响应这个请求,再把数据发送给浏览器。换句话说,浏览器不主动请求,服务器是没法主动发数据给浏览器的。

这样一来,要在浏览器中搞一个实时聊天、在线炒股(不鼓励),或者在线多人游戏的话就没法实现了,只能借助 Flash 这些插件。

也有人说,HTTP 协议其实也能实现啊,比如用 Ajax 轮询或者 Comet。轮询是指浏览器通过 JavaScript 启动一个定时器,然后以固定的间隔给服务器发请求,询问服务器有没有新消息。这个机制的缺点一是实时性不够,二是频繁的请求会给服务器带来极大的压力。

Comet 本质上也是轮询,但是在没有消息的情况下,服务器先拖一段时间,等到有消息了再回复。这个机制暂时地解决了实时性问题,但是它带来了新的问题:以多线程模式运行的服务器会让大部分线程大部分时间都处于挂起状态,极大地浪费服务器资源。另外,一个 HTTP 连接在长时间没有数据传输的情况下,链路上的任何一个网关都可能关闭这个连接,而网关是我们不可控的,这就要求 Comet 连接必须定期发一些 ping 数据表示连接“正常工作”。

以上两种机制都治标不治本,所以 HTML5 推出了 WebSocket 标准,让浏览器和服务器之间可以建立无限制的全双工通信,任何一方都可以主动发消息给对方。WebSocket 并不是全新的协议,而是利用了 HTTP 协议来建立连接。我们来看看 WebSocket 连接是如何创建的。

为什么 WebSocket 连接可以实现全双工通信而 HTTP 连接不行呢?

实际上 HTTP 协议是建立在 TCP 协议之上的,TCP 协议本身就实现了全双工通信,但是 HTTP 协议的请求-应答机制限制了全双工通信。WebSocket 连接建立以后,其实只是简单规定了一下:“接下来,咱们通信就不使用 HTTP 协议了,直接互相发数据吧”。

8.2 WebSocket 协议

WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。

WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

在 WebSocket API 中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。

在 JavaScript 中创建了 WebSocket 后,会有一个 HTTP 请求发送到服务器以发起连接。取得服务器响应后,建立的连接使用 HTTP 升级,从 HTTP 协议交换为 WebSocket 协议。即,使用标准的 HTTP 服务器无法实现 WebSocket,只有支持这种协议的专门服务器才能正常工作。

WebSocket 使用了自定义的协议,未加密的连接不再是 http://,而是 ws://,默认端口为 80,加密的连接也不是 https://,而是 wss://,默认端口为 443。

GET ws://localhost:3000/ws/chat HTTP/1.1
Host: localhost
Connection: Upgrade
Upgrade: websocket
Origin: http://localhost:3000
Sec-WebSocket-Key: client-random-string
Sec-WebSocket-Version: 13

该请求和普通的 HTTP 请求有几点不同:

  • GET 请求的地址不是类似 /path/,而是以 ws:// 开头的地址;
  • 请求头 Connection: Upgrade 和 Upgrade: websocket 表示这个连接将要被转换为 WebSocket 连接;
  • Sec-WebSocket-Key 是用于标识这个连接,并非用于加密数据;
  • Sec-WebSocket-Version 指定了 WebSocket 的协议版本;

服务器如果接受该请求,就会返回如下响应:

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: server-random-string

该响应代码 101 表示本次连接的 HTTP 协议即将被更改,更改后的协议就是 Upgrade: websocket 指定的 WebSocket 协议。

版本号和子协议规定了双方能理解的数据格式,以及是否支持压缩等等。如果仅使用 WebSocket 的 API,就不需要关心这些。

现在,一个 WebSocket 连接就建立成功,浏览器和服务器就可以随时主动发送消息给对方。消息有两种,一种是文本,一种是二进制数据。通常,我们可以发送 JSON 格式的文本,这样,在浏览器处理起来就十分容易。

当你获取 WebSocket 连接后,你可以通过 send() 方法来向服务器发送数据,并通过 onmessage 事件来接收服务器返回的数据。

8.3 与 Tomcat 结合

Tomcat 自 7.0.5 版本开始支持 WebSocket,并且实现了 Java WebSocket 规范(JSR356),而在 7.0.5 版本之前(7.0.2 版本之后)则采用自定义 API,即 WebSocketServlet。本节我们仅介绍 Tomcat 针对规范的实现。

根据 JSR356 的规定,Java WebSocket 应用由一系列的 WebSocket Endpoint 组成。Endpoint 是一个 Java 对象,代表 WebSocket 链接的一端,对于服务端,我们可以视为处理具体 WebSocket 消息的接口,就像 Servlet 之于 HTTP 请求一样(不同之处在于 Endpoint 每个链接一个实例)。

我们可以通过两种方式定义 Endpoint:

  1. 编程式,即继承类 javax.websocket.Endpoint 并实现其方法;
  2. 注解式,即定义一个 POJO 对象,为其添加 Endpoint 相关的注解。

Endpoint 实例在 WebSocket 握手时创建,并在客户端与服务端链接过程中有效,最后在链接关闭时结束。Endpoint 接口明确定义了与其生命周期相关的方法,规范实现者确保在生命周期的各个阶段调用实例的相关方法。Endpoint 的生命周期方法如下:

方法 含义描述 注解
onOpen 当开启一个新的会话时调用,这是客户端与服务器握手成功后调用的方法 @OnOpen
onClose 当会话关闭时调用 @OnClose
onError 当链接过程中异常时调用 @OnError

当客户端链接到一个 Endpoint 时,服务器端会为其创建一个唯一的会话 javax.websocket.Session。会话在 WebSocket 握手之后创建,并在链接关闭时结束。当生命周期中触发各个事件时,都会将当前会话传给 Endpoint。

我们通过为 Session 添加 MessageHandler 消息处理器来接收消息;当采用注解方式定义 Endpoint 时,我们还可以通过 @OnMessage 指定接收消息的方法。发送消息则由 RemoteEndpoint 完成,其实例由 Session 维护,根据使用情况,我们可以通过 Session.getBasicRemote() 获取同步消息发送的实例或者通过 Session.getAsyncRemote() 获取异步消息发送的实例。

WebSocket 通过 javax.websocket.WebSocketContainer<I> 维护应用中定义的所有 Endpoint。它在每个 Web 应用中只有一个实例,类似于传统 Web 应用中的 ServletContext。

最后,WebSocket 规范提供了一个接口 javax.websocket.server.ServerApplicationConfig,通过它我们可以为编程式的 Endpoint 创建配置(如指定请求地址),还可以过滤只有符合条件的 Endpoint 提供服务。该接口的实现同样通过 SCI 机制加载。