作为一款知名的轻量级应用服务器,Tomcat的架构设设计(如生命周期管理、可扩展的容器组件设计、类加载方式)可以为我们的服务器中间设计,甚至是应用系统组件设计提供非常好的借鉴意义。本章概要的介绍了Tomcat的总体架构,通过本章的学习,可以理解Tomcat各个组件的基本概念,并为进一步了解后续章节讲述的各个组件打下良好基础。
本章包含以下几个部分:
- Tomcat总体架构设计及Tomcat各个组件的概念;
- Tomcat启动及请求处理过程;
- Tomcat的类加载器。
为了使读者能更深刻的理解Tomcat的相关组件概念,我们将采用一种启发式的讲解方式来介绍Tomcat的总体架构。从如何设计一个应用服务器开始,逐步完善,直至最终推导出Tomcat的整个架构。
从基本的功能来讲,我们可以将服务器描述为这样一个应用:
它接收其他计算机(客户端)发来的请求数据并进行解析,完成相关业务处理,然后把处理结果响应返回给请求计算机(客户端)。
通常情况下,我们通过使用Socket监听服务器制定端口来实现该功能。按照该描述,一个最简单的服务器设计如图:
我们通过start方法启动服务器,打开Socket链接,监听服务器端口,并负责在接收到客户端请求时进行处理并返回响应。同时提供一个stop方法来停止服务器并释放网络资源。
如果我们设计的不是一款服务器,仅仅是作为嵌入在应用系统中的一个远程请求处理方案,切我们的应用系统访问量很低,那么这也许是一个可行方案。
很快我们就会发现,将请求监听与处理请求放在一起扩展性很差,比如当我们想适配多种网络协议,但是请求处理却相同的时候。要知道自从Tomcat诞生起,它始终支持与Apache集成,无论是通过AJP协议还是通过HTTP协议。当WEB应用通过Tomcat独立部署时,我们选择使用HTTP协议为客户端提供服务;当通过Apache进行集群部署时,我们使用AJP协议与Web服务器(Apche)进行链接。应用服务器(Tomcat)在两种部署架构下切换时,应确保Web应用不需要做任何改变。
那么我们如何通过面向对象的方式来解决这个问题呢?自然的想法就是将网络协议和请求处理从概念上分离。
于是,我们做了如下改进:
一个Server可以包含多个Connector和Container。其中Connector负责开启Socket并监听客户端请求、返回响应数据;Container负责具体的请求处理。Connector和Container分别拥有自己的start()和stop()方法来加载和释放自己维护的资源。
但是,这个设计有个冥想的缺陷。既然Server可以包含多个Connector和Container,那么如何知晓来自某个Connector的请求有哪个Container处理呢?当然,我们可以维护一个复杂的映射规则来解决这个问题。但这并不是必需的,后续的章节你会发现Container的设计已经足够灵活,并不需要一个Connector链接多个Container。更合理的方式如图:
一个Server包含多个Service(它们相互独立,只是共享一个JVM以及系统类库),一个Service负责维护多个Connector和Container,这样来自Connector的请求只能由它所属Service维护的Container处理。
在Tomcat中,Container是一个更加通用的概念。为了与Tomcat中的组件命名一致,我们将Container重新命名为Engine,用于表示整个Servlet引擎,需要注意的是此处的描述,Engine表示整个Servlet引擎,而非Servlet容器。表示整个Servlet容器的是Sever。引擎只负责请求的处理,并不需要考虑请求链接、协议等的处理。
上一节的设计已经解决了网络协议和容器的解耦,但是应用服务器是用来部署并运行Web应用的,是一个运行环境,而不是一个独立的业务处理系统。因此,我们需要在Engine容器中支持管理Web应用,当接收到Connector的处理请求时,Engine容器能够找到一个合适的Web应用来处理。
那么在原来的设计方案基础上,一种比较朴素的实现方案如图所示:
我们使用Context来表示Web应用,并且一个Engine可以包含多个Context。Context也拥有start()和stop()方法,用以在启动时加载资源以及停止时释放资源。采用这种方式设计,我们将加载和写在资源的过程分解到每个组件当中,使组件充分解耦,提高服务服务器的可扩展性和可维护性。在后续的讲解中,新增组件多数也会有相同的方法,我们不在赘述。
这是不是个合理的方案呢?
设想我们有一台主机,它承担了多个域名的服务,如news.mycompany.com和article.myconpany.com均由该主机处理,我们应该如何实现呢?当然,我们可以在该主机上运行多个服务器实例,但是如果我们希望运行一个服务器实例呢?因为,作物应用服务器,我们应该提供尽量灵活的部署方式。
既然我们要提供多个域名的服务,那么就可以将每个域名视为一个虚拟的主机,每个虚拟主机下包含多个Web应用,因为对于客户端用户来说,他们并不了解服务端使用几台主机来为他们提供服务,只知道每个域名提供了哪些服务,因此,应用服务器将为每个域名抽象一个虚拟主机从概念上是合理的。
依照这个想法修改后的设计如图:
我们用Host表示虚拟主机的概念,一个Host包含多个Context。在Tomcat设计中,Engine既可以包含Host,又可以包含Context。这是有具体的Engine实现确定的,而且Tomcat采用一种通用的概念解决此问题,我们在后续部分会详细讲解。Tomcat提供默认实现StanderEngine中能包含Host。
如果阅读Servlet规范,我们就知道,在一个Web应用中,可以包含多个Servlet实例以处理来自不同链接的请求。因此,我们还需要设计一个组件概念来表示Servlet定义,在Tomcat中,Servlet定义为Wrapper,基于此修改后的设计如图:
截止目前,我们多次提到“容器”这个概念,尽管在具体的小结中,容器的含义并不相同,有时候指Engine,有时候指Context,但他却代表一类组件,该类组件的作用就是处理接收自客户端的请求并返回相应数据。尽管具体操作可能会委派到子组件完成,但是从行为定义上,它们是一致的。基于这个概念,我们再次修改我们的设计。
此外,Tomcat的Container还有一个重要的功能,就是后台处理。在很多情况下,我们的Container需要执行一些异步处理,而且是定期执行,如没隔30秒执行一次,Tomcat对于Web应用文件变更的扫描就是通过该机制实现的。Tomcat针对后台处理,在Container上定义了backgroundProcess()方法,并且其基础抽象类(ContainerBase)确保启动组件的同时,异步启动后台处理。因此,在绝大多数情况下,各个容器组件仅需要实现Container的background-Process()方法即可,不需要考虑创建异步线程。
注意:既然Tomcat的Container可以表示不同的概念级别:Servlet引擎、虚拟主机、Web应用和Servlet,那么我们就可以将不同级别的容器作为处理客户端请求的组件,这具体由我们提供的服务器的复杂度决定。假使我们以嵌入式的方式启动Tomcat,且运行极其简单的请求处理,不必支持多Web应用的场景,那么我们完成可以只在Service中维护一个简化版的Engine(8.5.6之前升职可以直接由Service维护一个Context)。当然,Tomcat的默认实现采用了上图中这种最灵活的方式,只是,我们要了解Tomcat模型设计理论上的可伸缩性,这也是一个中间件产品架构设计所需要重点关注的。
在进一步深入细化应用服务设计之前,我们希望从抽象和复用层面在审视一下当前的设计成果,使概念更加清晰,提供通用性定义用于应用服务器的统一管理。
我们很容易发现,所有的组件均存在启动、停止等生命周期方法,拥有生命周期管理的特性。因此,我们可以基于生命周期管理进行一次接口抽象,如图:
同时,该接口支持组件状态以及状态之间的转换,支持添加事件监听器(LifecycleListener)用于监听组件的状态变化。如此,我们可以采用一致的机制来初始化、启动、停止以及销毁各个组件。如Tomcat核心组件的默认实现均继承自LifecycleMBeanBase抽象类,该类不但负责组件各个转台的转换和事件处理,还将组件自身注册为MBean,以便通过Tomcat的管理工具进行动态维护。
首先,每个生命周期方法可能对应数个状态的装换,以start()方法为例,即分为启动前、启动中、已启动,这三个状态之间的自动转换(所有标识为auto的转换路径都是在生命周期方法中自动转换的,不再需要额外的方法调用)。
其次,并不是每个状态都会出发生命周期事件,也不是所有声明周期均存在对应状态。状态与应用生命周期时间的对应表:
从表中可以详细地看到每个生命周期方法影响的组件状态以及每个状态出发的事件。此外,Tomcat默认还提供了3个与状态无关的事件类型,其中PERIODIC_EVENT主要用于Container的后台定时处理,每次调用后出发该事件。CONFIGURE_START_EVENT和CONFIGURE_STOP_EVENTDE的使用在后续章节中将会讲到。
从架构设计的角度来考虑,至此的应用服务器设计主要完成了我们对核心概念的分解,确保了整体架构的可伸缩性和可扩展性,除此之外,我们还需要考虑如何提高每个组件的灵活性,使其同样易于扩展。
在增强组件的灵活性和可扩展性方面,职责链模式是一种比较好的选择。Tomcat采用该模式来实现请求端的处理——请求处理也是职责链模式的典型应用场景之一。换句话说,在Tomcat中每个Container组件通过执行一个职责链来完成具体的请求处理。
Tomcat定义了Pipeline(管道)和Valve(阀)连个接口。前者用于构造职责链,后者代表职责链上的每个处理器。当然,我们还可以从字面意思来了解两个接口扮演的角色——来自客户端的请求就行是流经管道的水一般,经过每个阀进行处理。其接口设计如图:
Pipeline中维护了一个基础的Valve,它始终位于Pipeline的末端(即最后执行),封装了具体请求处理和输出响应的过程。然后,通过addValve()方法,我们可以为Pipeline添加其他的Valve。后添加的Valve位于基础Valve之前,并按照添加顺序执行。Pipeline通过首个Valve来启动整个链条的执行。
Tomcat容器组件的灵活之处在于,每个层级的容器(Engine、Host、Context、Wrapper)均有对应的基础Valve实现,同时维护了一个Pipeline实例。也就是说,我们可以在任何层级的容器上针对请求进行扩展。
由于Tomcat每个层级的容器均通过Pipeline和Valve进行请求处理,那么,我们很容易将一些通过Valve实现根据需要添加在任何层级的容器上面。
前面我们重点通论了容器组件的设计,集中于如何设计才能确保容器的灵活性和可扩展性,并且做到合理的解耦。接下来,我们在细化一下服务器设计中的另一个重要组件——Connector。
要想与Container配合实现一个完整的服务器功能,Connector至少要完成如下几个功能。
- 监听服务器端口,读取来自客户端的请求。
- 将请求按照指定协议进行解析。
- 根据请求地址匹配正确的容器进行处理。
- 将响应返回客户端。
只有这样侧能保证将接收到的客户端请求交由与请求地址匹配的容器处理。
我们知道Tomcat支持多协议,默认支持HTTP和AJP。同时,Tomcat还支持多种I/O,包括BIO(8.5版本后移除)、NIO、APR。而且在Tomcat 8之后增加对NIO2和HTTP/2协议的支持。因此,对协议和I/O进行抽象和建模是需要重点关注的。
在Tomcat中,ProtocolHandler表示一个协议处理器,针对不同的I/O方式,提供了不同的实现,如Http11NioProtocol表示基于NIO基于HTTP协议处理器。ProtocolHandler包含一个Endpoint用于启动Socket监听,该接口按照I/O方式进行分类实现,如Nio2Endpoint表示非阻塞式Socket I/O。还包含一个Processor用于按照指示协议读取数据,并将请求交由容器处理,如Http11NioProcessor表示在NIO的方式下HTTP请求的处理类。
在Connector启动时,Endpoint会启动线程来监听服务武器端口,并在接收到请求后调用Processor进行数据读取。
当Processor读取客户端请求后,需要按照请求地址映射到具体的容器进行处理,这个过程即为请求映射。由于Tomcat各个组件采用通用的生命中期管理,而且可以通过管理工具进行状态变更,因此请求映射除了考虑映射规则实现外,还需要考虑组件的注册和销毁。
Tomcat通过Mapper和MapperListener两个类实现上述功能。前者维护容器映射信息,同时按照映射规则(Servlet定义)查找容器。后者实现了ContainerListener和LifecycleListener,用于在组件状态发生变更时,注册或者取消对应的容器映射信息。为了实现上述功能MapperListener实现了Liftcycle接口,当其启动是(Service启动时启动),会自动作为监听器注册到各个组件上,同时将已创建的容器注册到Mapper。
在Tomcat7及之前的版本中,Mapper由Connector维护,而在Tomcat 8中,该有Service维护,因为Service本来就是用于维护Connector和Container的组合,两者从概念上讲更密切一些。
Tomcat通过是适配器模式(Adapter)实现了Connector和Mapper、Container的解耦。Tomcat默认的Connector实现(Coyote)对应的适配器为CoyoteAdapter。也就是说,如果你希望使用Tomcat的链接器方案,但是又想脱离Servlet容器(虽然这种情况几乎是不可能出现,但是从架构可扩展性的角度,还是值得讨论一下),此时只需要实现我们自己的Adapter即可。当然,我们还需要按照Container的定义开发我们自己的容器实现(不一定遵从Servlet)。
按照上述描述,Connector设计如图:
完成了Connector的设计之后,我们进一步审视一下当前的应用服务器方案,很明显,我们忽略了一个问题——并发。这对应用服务器而言是尤其需要考虑的,我们不可能让所有来自客户端的请求均以串行的方式执行。那么,我们应该如何设计应用服务器方案的并发方案呢?
首先,回顾已经讲解的Tomcat设计方案,既然Tomcat提供了一致的可插拔的组件环境,那么我们自然也希望线程池作为一个组件进行统一管理,因此,Tomcat提供了Executor接口来表示可以在组件间共享线程池(默认使用了JDK5提供的线程池技术),该接口同样继承了Lifecycle,可按照通用的组件进行管理。
其次,线程池的共享范围如何确定?在TOmcat中Executor由Service维护,因此同一个Service中组件可以共享一个线程池。
当然如果没有定义任何线程池,相关组件(如Endpoint)会自动创建线程池,此时,线程池不在共享。
在Tomcat中,Endpoint会启动一组线程来监听Socket,当接收到客户端请求后,会创建请求对象,并交由线程池处理,由此支持并发处理客户端请求。
添加Executor后,总体设计:
我们在前面几个小姐中讲解了Tomcat总体架构中的核心组件,它们代表了应用服务器程序本身,就像楼房的主体。但是,除了主体建筑外,楼房还需要外墙等装饰,Tomcat也一样,我们还需要提供一套配置环境来支持系统的可配置性,便于我们通过修改相关配置来优化应用服务器。
当然。我们没有涉及集群、安全等组件,尽管它们也非常重要,但是,我们还是希望更多的关注与一些通用概念。虽然集群、安全等作为一个完备的应用服务器必不可少,但是同门的缺失并影响我们去理解应用服务器的基本概念和设计方式。
在第1章中,我们列举了Tomcat的几个重要的配置文件,其中核心的文件为server.xml。通过这个文件,我们可以修改Tomcat组件的配置参数甚至添加相关组件,这也是后续性能能调优阶段重要涉及的文件。
Tomcat通过类Catalina提供了一个Shell程序,用于解析server.xml创建各个组件,同时,负责启动、停止应用服务器(只需要启动Tomcat顶层组件Server即可)。
Tomcat使用Digester解析XML文件,包括server.xml以及web.xml。
最后Tomcat提供了Bootstrap作为应用服务器启动入口。Bootstrap负责创建Ctalina实例,根据执行参数调用Catalina相关方法完成针对应用服务器的操作(启动、停止)。
也许你会有疑问,为什么Tomcat不直接Catalina启动,而是又提供了Bootstrap呢?你可以查一下Tocmat的发布包目录,Bootstrap并不位于Tomcat的依赖库目录下($ CATALINA_HOME/lib),而是直接在$ CATALINA_HOME/bin目录下。Bootstrap与Tomcat应用服务器完全松耦合(通过反射调用Catalina实例),它直接依赖JRE运行并为Tomcat应用服务器创建共享类加载器,用于构造Catalina实例及这个Tomcat服务器。
至此,我们应用服务器的完成设计如图:
上述是Tomcat标准的启动方式。但是正如我们所说,既然Server及其子组件代表了应用服务器本身,那么我们就可以不通过Bootstrap和Catalina来启动服务器。
Tomcat提供了一个同名类org.apache.catalina.startup.Tomcat,使用它我们可以将Tomcat服务器嵌入到我们的应用系统中并进行启动。当然,你可以自己编写代码来启动Server,也可以自定义其他配置方式启动,如YAML。这就是Tomcat的架构设计带给我们的便利,也是我们设计中间件产品的架构关注点之一。
最后,我们再整体回顾一下上述讲解设计的Tomcat服务器中的概念,
- Server:表示整个Servlet容器,因此Tomcat运行环境中只有唯一一个Server实例。
- Service:Service表示一个或者多个Connector共享一个Container来处理其请求。在同一个Tomcat实例中可以包含任意多个Service实例,它们彼此独立。
- Connector:即Tomcat链接器,用于监听并转化Socket请求,同时将读取的Socket请求交由Container处理,支持不同协议以及不同的I/O方式。
- Container:Container表示能够执行客户端请求并返回响应的一类对象,在Tomcat中存在不同级别的容器:Engine、Host、Context、Wrapper。
- Engine::Engine表示整个Servlet引擎。在Tomcat中,Engine为最该层级的容器对象。尽管Engine不是直接处理器请求的容器,缺失获取目标容器的入口。
- Host:Host作为一类容器,表示Servlet引擎(即Engine)中的虚拟机,与一个服务器的网络名有关,如域名等。客户端可以使用这个网络名连接服务器,这个名称必须有要在你DNS服务器上注册。
- Context:Context作为一类容器,用于表示ServletContext,在Servlet规范中,一个ServletContext即表示一个独立的Web应用。
- Wrapper:Wrapper作为一类容器,用于表示Web应用中定义的Servlet。
- Executor:表示Tomcat组件间可以共享的线程池。
至此,我们循序渐进的介绍了Tomcat总体架构的静态设计,接下来我们将从两个方面介绍Tomcat的动态设计:应用服务器启动和客户端请求处理。这两个方面也是应用服务器基本的处理过程。
在总体架构静态设计讲解中,我们已经讲到Tomcat的启动入口Bootstrap、Shell程序以及各个组件之间的关系,尤其是统一生命周期管理工具Lifecycle。在应用服务器启动过程中,我们会充分体会它的便利性。
基于上面的的静态设计,简化启动过程如图:
从图中我们可以看出,Tomcat的启动过程非常标准化,统一按照生命周期管理接口Lifecycle的定义进行启动。首先,调用init()方法进行组件的逐级初始化,然后调用start()方法进行启动。当然,每次均伴随着生命周期状态变更事件和触发。
每一级组件除了完成自身的处理外,还要负责调用子组件相应的生命周期管理方法,组件与组件之间是松耦合的设计,因此我们很容易通过配置进行修改和替换。
从本质上讲,应用服务器的请求处理开始于监听Socket端口接收到数据,结束于将服务器处理结果写入到Socket输出流。
在这个处理过程中,应用服务器需要将请求按照既定协议进行读取,并封装为与具体通信方案无关的请求对象。然后根据请求映射规则定位到具体的处理单元(在Java应用服务器中,对数是某个Web应用下的一个Servlet)进行处理。当然,如果我们的应用不是局域简单的Servlet API,而是基于当前成熟的MVC框架(如Apcahe Struts、Spring MVC),那么在多数情况下请求将进一步匹配到Servlet下的一个控制器——这一部分已经不属于应用服务器的处理范畴,而是由具体的MVC框架进行匹配。当Servlet或者控制器的业务处理结束后,处理结果将被写入一个与通信方案无关的响应对象。最后,该响应对象将按照既定协议写入输出流。
结合该处理过程以及前面讲解的主要概念,Tomcat的请求处理如图:
这张图仅是局域本章节讲述的概念做的简单示意,实际上Tomcat的请求处理过程要复杂得多,针对容器和链接器的请求处理过程,或许继续讲解。
本节主要介绍Tomcat的类加载机制,包括Tomcat的类加载器层级设计以及Web应用的类加载过程。类加载是一切Java应用运行的基础,了解一款应用的类加载机制便于我们掌握它的运行边界,也有助于其运行时异常的快速定位。
我们都知道JVM默认提供了3个类加载器,它们以一种父子树的方式创建,同时使用委派模型确保应用程序可以通过自身的类加载器(System)加载所有可见的Java类。结构如图:
- Bootstrap:用于加载JVM提供的基础运行类,即位于%JAVA_HOME%/jre/lib目录下的核心类库。
- Extension:Java提供的一个标准的扩展机制用于加载除核心类库外的Jar包,即只要复制到指定的扩展目录(可以多个)下的Jar,JVM就会自动加载(不需要通过-classpath指定)。默认的扩展目录是%JAVA_HOME%/jre/lib/ext。典型的应用场景就是,Java使用该类加载器加载JVM默认提供的但是不属于核心类库的Jar,如JCE等。不推荐将应用程序依赖的类库放置到扩展目录下,因为该目录下的类库对所有基于该JVM运行的应用程序可见。
- System:用于加载环境变量CLASSPATH(不推荐使用)指定目录下的或者-classpath运行参数指定的Jar包。System类加载器通常用于加载应用程序Jar包及其启动入口类(Tomcat的Bootstrap类即由System类加载器加载)。
应用程序在不自己构造类加载器的情况下,使用System作为默认的类加载器。如果应用程序自己构造类加载器,基本也是以System作为父类加载器。
除了支持类加载器按照层级创建外,JVM还提供了一套Endorsed Standards Override Mechanism的机制用于允许替换JCP之外生成的API。通过这套机制,应用程序可以提供新版本的API覆盖JVM的默认实现。
之所以存在这套机制是因为随着版本的不断更新,J2SE包含越来越多的扩展,这些扩展由JVM加载供所有应用程序使用(如JAXP),甚至作为核心类库(位于rt.jar)由Bootstrap类加载器加载。因此,即便应用程序提供了新版本的JAXP包,该版本也不会被使用。此时,我们可以通过Endorsed Standands机制解决该问题。
JVM默认的Endorsed的目录为%JAVA_HOME%/lib/endorsed,当然,我们可以通过指定启动参数java.endorsed.dir来修改。只要是复制到该目录下的Jar包,将优先于JVM的类加载器。
我们之所以提到Endorsed Standands机制,是因为很多应用服务器使用了该机制来提供新版本的Jar包,如JBoss,它默认Endorsed目录为%JBOSS_HOME%/lib/endorsed。虽然Tomcat没有相关目录,但是在启动参数中是包含相关配置的,默认为$ CATALINA_HOME/endorsed。
当然,如上面所说,并不是所有的Java核心类库均可以被覆盖,只有部分类库被允许。
应用服务器通常会自行创建类加载器以实现灵活的控制,这以方便对规范的实现(Servlet规范要求每个Web应用都有一个独立的类加载器实例),另一方面也有架构层面的考虑。
- 隔离性:Web应用类库相互隔离,避免依赖库或者应用包相互影响,设想一下,如果我们有两个Web应用,一个采用Spring 2.5,一个采用Spring 4.0,而应用服务器使用一个类加载器加载,那么Web应用将会由于Jar包覆盖导致无法启动成功。
- 灵活性:既然Web应用之间的类加载相互独立,那么我们就能只针对一个Web应用进行重新部署,此时该Web应用的类加载器将会重新创建,而且不会影响其他Web应用。如果采用一个类加载器,显然无法实现,因为只有一个类加载器的时候,类之间的依赖是杂乱无章的,无法完整的移除某个Web应用的类。
- 性能:由于每个Web应用程序都有一个类加载器,因此Web应用在加载类时,不会搜索其他Web应用包含的Jar包,性能自然高于应用服务器只有一个类加载器的情况。
当然,Tomcat的类加载器设计也体现了几点架构的要素,我们来看一下Tomcat的类加载方案:
我们可以看到,除了每个Web应用的类加载器外,Tomcat也提供了3个基础的类加载器和Web应用类加载器,而且这3个类加载器指向的路径和包列表均可以由catalina.properties配置。
- Common:以System为父类加载器,是位于Tomcat应用服务器顶层的公共类加载器。其路径为common.loader,默认指向$ CATALINA/lib。
- Catalina:以Common为父类加载器,是用于加载Tomcat应用服务器的类加载器,其路径为server.loader,默认为空。此时Tomcat使用Common类加载器加载应用服务器。
- Shared:以Common为父类加载器,是所有Web应用的父类加载器,其路径为shared.loader,默认为空,此时Tomcat使用Common类加载器作为Web应用的父类加载器。
- Web应用:以Shared为父类加载器,加载/WEB-INF/classes目录下未解压的Class和资源文件以及/WEB-INF/lib目录下的Jar包。如前所述,该类加载器只对当前Web应用可见,对其他Web应用均不可见。
尽管默认情况下,这3个基础类加载器是同一个,但是我们可以通过配置创建3个不同的类加载器,使它们各司其职。
首先,Common类加载器负责加载Tomcat应用程序服务器内部和Web应用均可见的类,例如Servlet规范相关包和一些通用的工具包。
其次,Catalina类加载器负责加载只有Tomcat应用服务器内部可见的类,这些类对Web应用不可见,如Tomcat的具体实现类,因为我们的Web应用最好与服务器松耦合,故不应该依赖应用服务器的内部类。
再次,Shared类加载器负责加载Web应用共享的类,这些类Tomcat服务器不会依赖。
既然Tomcat提供了这个特性,那么我们什么时候可以考虑使用呢?举个例子,如果我们想实现自己的回话存储方案,而且该方案依赖一些第三方包,我们不希望这些包对Web应用可见(因为可能会存在包版本冲突之类的问题,也可能我们的Web应用个根本不需要这些包)。此时,我们可以配置server.loader,创建独立的Catalina类加载器。
最后,Tomcat服务器$ CATALINA_HOME/bin目录下的包作为启动入口由System类加载器加载。通过将这几个启动包剥离,Tomcat简化了应用服务器的启动,同时增加了灵活性。
接下来,我们从架构层面讨论一下Tomcat的类加载方案。下面几点是对上述架构分析的补充。
- 共享:Tomcat通过Common类加载器实现了Jar包在应用服务器以及Web应用之间共享,通过Shared类加载器实现了Jar包在Web应用之间的共享,通过Catalina类加载器加载服务器依赖的类。这样最大限度实现了Jar包的共享,而且又确保了不会引入过多无用的包。
- 隔离性:这里的隔离性区别于前者,指服务器与Web应用隔离。理论上,除了Servlet规范定义的接口外,我们的Web应用不应该依赖服务器的任何实现类,这样才有助于Web应用的可移植性。正因为如此,Tomcat支持通过Catalina类加载器的加载服务器依赖的包(尽管Tomcat默认并没有这么做),以便应用服务器与Web应用更好的隔离。
既然在默认情况下,Tomcat的Common、Catalina、Shared为同一个类加载器,那么它是如何禁止Web应用使用服务器相关实现类的呢?这是通过JVM的安全策略许可实现的。
我们都知道Java默认的类加载机制是委派模型,委派的过程如下:
- 从缓存中加载;
- 如果缓存中没有,则从父类加载器中加载;
- 如果父类加载器没有,则从当前类加载器加载;
- 如果没有,则抛出异常。
Tomcat提供的Web应用类加载器与默认的委派模型稍有不同。当进行类加载时,除JVM基础类库外,它会首先尝试通过当前类加载器加载,然后才进行委派。Servlet规范相关API禁止通过Web应用类加载器加载,因此,不要在Web应用中包含这些包。
所有,Web应用类加载器默认加载顺序如下:
- 从缓存中加载;
- 如果没有,则从JVM的Bootstrap类加载器加载;
- 如果没有,则从当前类加载器加载(按照WEB-INF/classes、WEB-INF/lib的顺序);
- 如果没有,则从父类加载器加载,由于父类加载器默认采用委派模式,所有加载顺序为System、Common、Shared。
Tomcat提供了delegate属性用于控制是否启用Java委派模型,默认为false(不启用)。当配置为true时,Tomcat将使用Java默认的委派模型,即按如下顺序加载。
- 从缓存中加载;
- 如果没有,从JVM的Bootstrap类加载器加载;
- 如果没有,则从父类加载器(System、Common、Shared);
- 如果没有,则从当前类加载器加载。
除了可以通过delegate属性控制是否启用Java的委派模式外,Tomcat还可以通过packageTriggersDeny属性只让某些包路径采用Java的委派模式,Web应用类加载对于符合packageTriggersDeny指定包路径的类强制采用Java的委派模式。
Tomcat通过该机制实现了Web应用中Jar包覆盖服务器提供包的目的。如上所述,Java核心类库、Servlet规范相关类库是无法覆盖的,此外Java默认提供的注入XML工具包,由于位于JVM的Bootstrap类加载器也是无法覆盖,只能通过endorsed的方式。