赞
踩
通过对Tomcat整体架构的学习,我们可以得到一些设计复杂系统的基本思路。首先要分析需求,根据高内聚低耦合的原则确定子模块,然后找出子模块中的变化点和不变点,用接口和抽象基类去封装不变点,在抽象基类中定义模板方法,让子类自行实现抽象方法,也就是具体子类去实现变化点。
server.xml的整体结构如下:
- <Server>
- <Service>
- <Connector />
- <Connector />
- <Engine>
- <Host>
- <Context /><!-- 现在常常使用自动部署,不推荐配置Context元素,Context小节有详细说明 -->
- </Host>
- </Engine>
- </Service>
- </Server>
而对应的结构体系可以参考下图
Server
Server是Tomcat中最顶层的组件,它可以包含多个Service组件。
在Tomcat源代码中Server组件对应源码中的 org.apache.catalina.core.StandardServer 类。
Service
接下来咋们来看看Service组件,Service组件相当于Connetor和Engine组件的包装器,它将一个或者多个Connector组件和一个Engine建立关联。
Connector
既然Tomcat需要提供http服务,而我们知道http应用层协议最终都是需要通过TCP层的协议进行传递的,而Connector正是Tomcat中监听TCP网络连接的组件,一个Connector会监听一个独立的端口来处理来自客户端的连接。
Connector的主要功能,是接收连接请求,创建Request和Response对象用于和请求端交换数据;
然后分配线程让Engine来处理这个请求,并把产生的Request和Response对象传给Engine,当Engine处理完请求后,也会通过Connector将响应返回给客户端。
而关于Connector内部的组件,我们会在下面的连接器章节中会细讲。
Engine
Tomcat中有一个容器的概念,而Engine,Host,Context都属于Contanier,我们先来说说最顶层的容器Engine.
一个Engine可以包含一个或者多个Host,也就是说我们一个Tomcat的实例可以配置多个虚拟主机。
Engine组件在Service组件中有且只有一个;Engine是Service组件中的请求处理组件,Engine组件从一个或多个Connector中接收请求并处理,并将完成的响应返回给Connector,最终传递给客户端。
前面已经提到过,Engine、Host和Context都是容器,但它们不是平行的关系,而是父子关系:Engine包含Host,Host包含Context。
而关于Engine内部的组件,我们会在下面的容器章节中会细讲。
我们知道如果要设计一个系统,首先是要了解需求。
我们已经了解了Tomcat要实现2个核心功能:
处理Socket连接,负责网络字节流与Request和Response对象的转化。
加载和管理Servlet,以及具体处理Request请求。
因此Tomcat设计了两个核心组件连接器(Connector)和容器(Container)来分别做这两件事情。
连接器负责对外交流,容器负责内部处理,具体来说就是,连接器处理Socket通信和应用层协议的解析,得到Servlet请求;而容器则负责处理Servlet请求。
所以连接器和容器可以说是Tomcat架构里最重要的两部分,需要你花些精力理解清楚。
Tomcat为了实现支持多种I/O模型和应用层协议,一个容器可能对接多个连接器,就好比一个房间有多个门。
但是单独的连接器或者容器都不能对外提供服务,需要把它们组装起来才能工作,组装后这个整体叫作Service组件。这里请你注意,Service本身没有做什么重要的事情,只是在连接器和容器外面多包了一层,把它们组装在一起。
Tomcat内可能有多个Service,这样的设计也是出于灵活性的考虑。通过在Tomcat中配置多个Service,可以实现通过不同的端口号来访问同一台机器上部署的不同应用。
到此我们得到这样一张关系图:
从图上你可以看到,最顶层是Server,这里的Server指的就是一个Tomcat实例。
一个Server中有一个或者多个Service,一个Service中有多个连接器和一个容器,连接器与容器之间通过标准的ServletRequest和ServletResponse通信。
Tomcat支持的应用层协议有:
HTTP/1.1:这是大部分Web应用采用的访问协议。
AJP:用于和Web服务器集成(如Apache)。
HTTP/2:HTTP 2.0大幅度的提升了Web性能。
Tomcat支持的I/O模型有:
NIO:非阻塞I/O,采用Java NIO类库实现。
NIO2:异步I/O,采用JDK 7最新的NIO2类库实现。
APR:采用Apache可移植运行库实现,是C/C++编写的本地库。
在Tomcat 8及更高版本中,Tomcat的默认IO模型是NIO,即非阻塞IO。
在Tomcat7及更早版本默认使用的BIO模型。
在Tomcat的server.xml配置文件中,你可以通过Connector元素的protocol属性来指定IO模型,例如:
<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol"
connectionTimeout="20000"
redirectPort="8443" />
连接器对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和Adaptor。
组件之间通过抽象接口交互。这样做还有一个好处是封装变化。这是面向对象设计的精髓,将系统中经常变化的部分和稳定的部分隔离,有助于增加复用性,并降低系统耦合度。
网络通信的I/O模型是变化的,可能是非阻塞I/O、异步I/O或者APR。应用层协议也是变化的,可能是HTTP、HTTPS、AJP。浏览器端发送的请求信息也是变化的。
但是整体的处理逻辑是不变的,EndPoint负责提供字节流给Processor,Processor负责提供Tomcat Request对象给Adaptor,Adaptor负责提供ServletRequest对象给容器。
如果要支持新的I/O方案、新的应用层协议,只需要实现相关的具体子类,上层通用的处理逻辑是不变的。
由于I/O模型和应用层协议可以自由组合,比如NIO + HTTP或者NIO2 + AJP。Tomcat的设计者将网络通信和应用层协议解析放在一起考虑,设计了一个叫ProtocolHandler的接口来封装这两种变化点。各种协议和通信模型的组合有相应的具体实现类。比如:Http11NioProtocol和AjpNioProtocol。
除了这些变化点,系统也存在一些相对稳定的部分,因此Tomcat设计了一系列抽象基类来封装这些稳定的部分,抽象基类AbstractProtocol实现了ProtocolHandler接口。每一种应用层协议有自己的抽象基类,比如AbstractAjpProtocol和AbstractHttp11Protocol,具体协议的实现类扩展了协议层抽象基类。
下面我整理一下它们的继承关系。
通过上面的图,你可以清晰地看到它们的继承和层次关系,这样设计的目的是尽量将稳定的部分放到抽象基类,同时每一种I/O模型和协议的组合都有相应的具体实现类,我们在使用时可以自由选择。
小结一下,连接器模块用三个核心组件:Endpoint、Processor和Adaptor来分别做三件事情,其中Endpoint和Processor放在一起抽象成了ProtocolHandler组件,它们的关系如下图所示。
顾名思义,连接器用ProtocolHandler来处理网络连接和应用层协议,包含了2个重要部件:EndPoint和Processor,下面我来详细介绍它们的工作原理。
这里我们在细化一下ProtocolHandler组件的内部功能结构
从图中我们看到,EndPoint接收到Socket连接后,生成一个SocketProcessor任务提交到线程池去处理,SocketProcessor的Run方法会调用Processor组件去解析应用层协议,Processor通过解析生成Request对象后,会调用Adapter的Service方法。
EndPoint是通信端点,即通信监听的接口,是具体的Socket接收和发送处理器,是对传输层的抽象,因此EndPoint是用来实现TCP/IP协议的。
EndPoint是一个接口,它的抽象实现类AbstractEndpoint里面定义了两个内部类:Acceptor和SocketProcessor。
其中Acceptor用于监听Socket连接请求。
所以当请求来的时候入口调用就是在Acceptor的run方法里,下面这句话用来接收一个新的连接
socket = endpoint.serverSocketAccept();
SocketProcessor用于处理接收到的Socket请求,它实现Runnable接口,在Run方法里调用协议处理组件Processor进行处理。为了提高处理能力,SocketProcessor被提交到线程池来执行。
而这个线程池叫作执行器(Executor),我在后面的专栏会详细介绍Tomcat如何扩展原生的Java线程池。关于EndPoint的详细设计,后面我还会专门介绍EndPoint是如何最大限度地利用Java NIO的非阻塞以及NIO2的异步特性,来实现高并发。
如果说EndPoint是用来实现TCP/IP协议的,那么Processor用来实现HTTP协议,Processor接收来自EndPoint的Socket,读取字节流解析成Tomcat Request和Response对象,并通过Adapter将其提交到容器处理,Processor是对应用层协议的抽象。
Processor是一个接口,定义了请求的处理等方法。它的抽象实现类AbstractProcessor对一些协议共有的属性进行封装,没有对方法进行实现。具体的实现有AJPProcessor、HTTP11Processor等,这些具体实现类实现了特定协议的解析方法和请求处理方式。
我在前面说过,由于协议不同,客户端发过来的请求信息也不尽相同,Tomcat定义了自己的Request类来“存放”这些请求信息。
ProtocolHandler接口负责解析请求并生成Tomcat Request类。但是这个Request对象不是标准的ServletRequest,也就意味着,不能用Tomcat Request作为参数来调用容器。
Tomcat设计者的解决方案是引入CoyoteAdapter,这是适配器模式的经典运用,连接器调用CoyoteAdapter的Sevice方法,传入的是Tomcat Request对象,CoyoteAdapter负责将Tomcat Request转成ServletRequest,再调用容器的Service方法
容器,顾名思义就是用来装载东西的器具,在Tomcat里,容器就是用来装载Servlet的。
那Tomcat的Servlet容器是如何设计的呢?
你可能会问,为什么要设计成这么多层次的容器,这不是增加了复杂度吗?
其实这背后的考虑是,Tomcat通过一种分层的架构,使得Servlet容器具有很好的灵活性。
Engine
Engine表示引擎,用来管理多个虚拟站点,一个Service最多只能有一个Engine。
Host
Host代表的是一个虚拟主机,或者说一个站点,可以给Tomcat配置多个虚拟主机地址,而一个虚拟主机下可以部署多个Web应用程序,即可以有多个Context。
缺省的配置如下:
<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true">….</Host>
其中appBase为webapps,也就是<CATALINA_HOME>\webapps目录,unpackingWARS属性指定在appBase指定的目录中的war包都自动的解压,缺省配置为true,autoDeploy属性指定是否对加入到appBase目录的war包进行自动的部署,缺省为true.
Context
Context表示一个Web应用程序;
在Tomcat中,每一个运行的webapp其实最终都是以Context的形成存在,每个Context都有一个根路径和请求URL路径,即每个context就对应一个web应用
Context对应源代码中的org.apache.catalina.core.StandardContext
Wrapper
Wrapper表示一个Servlet,一个Web应用程序中可能会有多个Servlet;
你可以再通过Tomcat的server.xml配置文件来加深对Tomcat容器的理解。
Tomcat采用了组件化的设计,它的构成组件都是可配置的,其中最外层的是Server,其他组件按照一定的格式要求配置在这个顶层容器中。
那么,Tomcat是怎么管理这些容器的呢?
你会发现这些容器具有父子关系,形成一个树形结构,你可能马上就想到了设计模式中的组合模式。没错,Tomcat就是用组合模式来管理这些容器的。
具体实现方法是,所有容器组件都实现了Container接口,因此组合模式可以使得用户对单容器对象和组合容器对象的使用具有一致性。这里单容器对象指的是最底层的Wrapper,组合容器对象指的是上面的Context、Host或者Engine。
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);
- }
正如我们期望的那样,我们在上面的接口看到了getParent、SetParent、addChild和removeChild等方法。
你可能还注意到Container接口扩展了LifeCycle接口,LifeCycle接口用来统一管理各组件的生命周期,后面我也用专门的篇幅去详细介绍。
我们思考一个问题:
Tomcat内的Context组件跟Servlet规范中的ServletContext接口有什么区别?跟Spring中的
ApplicationContext又有什么关系?
1.Servlet规范中ServletContext表示web应用的上下文环境,而web应用对应在tomcat的概念是Context。
所以从设计上,ServletContext自然会成为tomcat的Context具体实现的一个成员变量。
2.tomcat内部实现也是这样完成的,ServletContext对应在tomcat实现是org.apache.catalina.core.ApplicationContext,Context容器对应tomcat实现是org.apache.catalina.core.StandardContext。ApplicationContext是StandardContext的一个成员变量。
3.Spring的ApplicationContext之前已经介绍过,tomcat启动过程中ContextLoaderListener会监听到容 器初始化事件,它的contextInitialized方法中,Spring会初始化全局的Spring根容器ApplicationContext, 初始化完毕后,Spring将其存储到ServletContext中。
总而言之,Servlet规范中ServletContext是tomcat的Context实现的一个成员变量,而Spring的ApplicationContext是Servlet规范中ServletContext的一个属性。
你可能好奇,设计了这么多层次的容器,Tomcat是怎么确定请求是由哪个Wrapper容器里的Servlet来处理的呢?
答案是,Tomcat是用Mapper组件来完成这个任务的。
Mapper组件的功能就是将用户请求的URL定位到一个Servlet,它的工作原理是:Mapper组件里保存了Web 应用的配置信息,其实就是容器组件与访问路径的映射关系,比如Host容器里配置的域名、Context容器里的Web应用路径,以及Wrapper容器里Servlet映射的路径,你可以想象这些配置信息就是一个多层次的 Map。
当一个请求到来时,Mapper组件通过解析请求URL里的域名和路径,再到自己保存的Map里去查找,就能 定位到一个Servlet。请你注意,一个请求URL最后只会定位到一个Wrapper容器,也就是一个Servlet。
假如有一个网购系统,有面向网站管理人员的后台管理系统,还有面向终端客户的在线购物系统。
这两个系 统跑在同一个Tomcat上,为了隔离它们的访问域名,配置了两个虚拟域名:manage.shopping.com和 user.shopping.com,网站管理人员通过manage.shopping.com域名访问Tomcat去管理用户和商品, 而用户管理和商品管理是两个单独的Web应用。终端客户通过user.shopping.com域名去搜索商品和下 订单,搜索功能和订单管理也是两个独立的Web应用。
针对这样的部署,Tomcat会创建一个Service组件和一个Engine容器组件,在Engine容器下创建两个Host子容器,在每个Host容器下创建两个Context子容器。由于一个Web应用通常有多个Servlet,Tomcat还会在每 个Context容器里创建多个Wrapper子容器。每个容器都有对应的访问路径,你可以通过下面这张图来帮助你理解。
假如有用户访问一个URL,比如图中的http://user.shopping.com:8080/order/buy,Tomcat如何将这个URL定位到一个Servlet呢?
1.首先,根据协议和端口号选定Service和Engine。
我们知道Tomcat的每个连接器都监听不同的端口,比如Tomcat默认的HTTP连接器监听8080端口、默认的 AJP连接器监听8009端口。
上面例子中的URL访问的是8080端口,因此这个请求会被HTTP连接器接收,而一个连接器是属于一个Service组件的,这样Service组件就确定了。我们还知道一个Service组件里除了有多个 连接器,还有一个容器组件,具体来说就是一个Engine容器,因此Service确定了也就意味着Engine也确定了。
2.然后,根据域名选定Host。
Service和Engine确定后,Mapper组件通过URL中的域名去查找相应的Host容器,比如例子中的URL访问的 域名是user.shopping.com,因此Mapper会找到Host2这个容器。
3.之后,根据URL路径找到Context组件。
Host确定以后,Mapper根据URL的路径来匹配相应的Web应用的路径,比如例子中访问的是/order,因此 找到了Context4这个Context容器。
4.最后,根据URL路径找到Wrapper(Servlet)。
Context确定后,Mapper再根据web.xml中配置的Servlet映射路径来找到具体的Wrapper和Servlet。
那么找到了对应的Wrapper后,之后的处理流程又是怎样的?
其处理流程如下:Wrapper -> Filter -> DispatcherServlet -> Controller
看到这里,我想你应该已经了解了什么是容器,以及Tomcat如何通过一层一层的父子容器找到某个Servlet 来处理请求。需要注意的是,并不是说只有Servlet才会去处理请求,实际上这个查找路径上的父子容器都会对请求做一些处理。
我在上一期说过,连接器中的Adapter会调用容器的Service方法来执行Servlet,最先拿到请求的是Engine容器,Engine容器对请求做一些处理后,会把请求传给自己子容器Host继续处理,依次类推,最后这个请求会传给Wrapper容器,Wrapper会调用最终的Servlet来处理。
那么这个调用过程具体 是怎么实现的呢?
答案是使用Pipeline-Valve管道。
Pipeline-Valve是责任链模式,责任链模式是指在一个请求处理的过程中有很多处理者依次对请求进行处 理,每个处理者负责做自己相应的处理,处理完之后将再调用下一个处理者继续处理。
这样的设计使得系统具有良好的可扩展性,如果需要扩展容器本身的功能,只需要增加相应的Valve即可。
Valve表示一个处理点,比如权限认证和记录日志。如果你还不太理解的话,可以来看看Valve和Pipeline接 口中的关键方法。
- public interface Valve {
- public Valve getNext();
- public void setNext(Valve valve);
- public void invoke(Request request, Response response)
- }
由于Valve是一个处理点,因此invoke方法就是来处理请求的。注意到Valve中有getNext和setNext方法,因此我们大概可以猜到有一个链表将Valve链起来了。
请你继续看Pipeline接口:
- public interface Pipeline extends Contained {
- public void addValve(Valve valve);
- public Valve getBasic();
- public void setBasic(Valve valve);
- public Valve getFirst();
- }
没错,Pipeline中有addValve方法。Pipeline中维护了Valve链表,Valve可以插入到Pipeline中,对请求做某些处理。
我们还发现Pipeline中没有invoke方法,因为整个调用链的触发是Valve来完成的,Valve完成自己的处理后,调用getNext.invoke()来触发下一个Valve调用。
每一个容器都有一个Pipeline对象,只要触发这个Pipeline的第一个Valve,这个容器里Pipeline中的Valve就都会被依次调用到。
但是,不同容器的Pipeline是怎么链式触发的呢,比如Engine中Pipeline需要调用下层容器Host中的Pipeline。
这是因为Pipeline中还有个getBasic方法。这个BasicValve处于Valve链表的末端,它是Pipeline中必不可少的 一个Valve,负责调用下层容器的Pipeline里的第一个Valve。
我还是通过一张图来解释。
整个调用过程由连接器中的Adapter触发的,它会调用Engine的第一个Valve:
- // Calling the container
- connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
Wrapper容器的最后一个Valve会创建一个Filter链,并调用doFilter()方法,最终会调到Servlet的service方法。
你可能会问,前面我们不是讲到了Filter,似乎也有相似的功能,那Valve和Filter有什么区别吗?
它们的区别如下
tomcat启停设计,顾名思义就是tomcat启动和停止的设计,里面不可避免的就是了解各个组件的生命周期
我们通过一张简化的类图来回顾一下,从图上你可以看到各种组件的层次关系,图中的虚线表示一个请求在Tomcat中流转的过程。
上面这张图描述了组件之间的静态关系,如果想让一个系统能够对外提供服务,我们需要创建、组装并启动 这些组件;在服务停止的时候,我们还需要释放资源,销毁这些组件,因此这是一个动态的过程。也就是 说,Tomcat需要动态地管理这些组件的生命周期。
在我们实际的工作中,如果你需要设计一个比较大的系统或者框架时,你同样也需要考虑这几个问题:如何 统一管理组件的创建、初始化、启动、停止和销毁?如何做到代码逻辑清晰?如何方便地添加或者删除组 件?如何做到组件启动和停止不遗漏、不重复?
今天我们就来解决上面的问题,在这之前,先来看看组件之间的关系。如果你仔细分析过这些组件,可以发 现它们具有两层关系。
第一层关系是组件有大有小,大组件管理小组件,比如Server管理Service,Service又管理连接器和容 器。
第二层关系是组件有外有内,外层组件控制内层组件,比如连接器是外层组件,负责对外交流,外层组件 调用内层组件完成业务功能。也就是说,请求的处理过程是由外层组件来驱动的。
这两层关系决定了系统在创建组件时应该遵循一定的顺序。
第一个原则是先创建子组件,再创建父组件,子组件需要被“注入”到父组件中。
第二个原则是先创建内层组件,再创建外层组件,内层组建需要被“注入”到外层组件。
因此,最直观的做法就是将图上所有的组件按照先小后大、先内后外的顺序创建出来,然后组装在一起。
知道你注意到没有,这个思路其实很有问题!因为这样不仅会造成代码逻辑混乱和组件遗漏,而且也不利于 后期的功能扩展。
为了解决这个问题,我们希望找到一种通用的、统一的方法来管理组件的生命周期,就像汽车“一键启 动”那样的效果。
Tomcat来管理组件的生命周期,主要有两个要点,
一是父组件负责子组件的创 建、启停和销毁。这样只要启动最上层组件,整个Web容器就被启动起来了,也就实现了一键式启停;
二是 Tomcat和Jetty都定义了组件的生命周期状态,并且把组件状态的转变定义成一个事件,一个组件的状态变 化会触发子组件的变化,比如Host容器的启动事件里会触发Web应用的扫描和加载,最终会在Host容器下创建相应的Context容器,而Context组件的启动事件又会触发Servlet的扫描,进而创建Wrapper组件。
那么 如何实现这种联动呢?答案是观察者模式。具体来说就是创建监听器去监听容器的状态变化,在监听器的方 法里去实现相应的动作,这些监听器其实是组件生命周期过程中的“扩展点”。
设计就是要找到系统的变化点和不变点。
这里的不变点就是每个组件都要经历创建、初始 化、启动这几个过程,这些状态以及状态的转化是不变的。
而变化点是每个具体组件的初始化方法,也就是 启动方法是不一样的。
因此,我们把不变点抽象出来成为一个接口,这个接口跟生命周期有关,叫作LifeCycle。
LifeCycle接口里应 该定义这么几个方法:init()、start()、stop()和destroy(),每个具体的组件去实现这些方法。 理所当然,在父组件的init()方法里需要创建子组件并调用子组件的init()方法。同样,在父组件的start()方法 里也需要调用子组件的start()方法,因此调用者可以无差别的调用各组件的init()方法和start()方法,这就是 组合模式的使用,并且只要调用最顶层组件,也就是Server组件的init()和start()方法,整个Tomcat就被启动 起来了。
下面是LifeCycle接口的定义。
我们再来考虑另一个问题,那就是系统的可扩展性。
因为各个组件init()和start()方法的具体实现是复杂多变 的,比如在Host容器的启动方法里需要扫描webapps目录下的Web应用,创建相应的Context容器,如果将来需要增加新的逻辑,直接修改start()方法?
这样会违反开闭原则,那如何解决这个问题呢?
开闭原则说的 是为了扩展系统的功能,你不能直接修改系统中已有的类,但是你可以定义新的类。
我们注意到,组件的init()和start()调用是由它的父组件的状态变化触发的,上层组件的初始化会触发子组件 的初始化,上层组件的启动会触发子组件的启动,因此我们把组件的生命周期定义成一个个状态,把状态的 转变看作是一个事件。而事件是有监听器的,在监听器里可以实现一些逻辑,并且监听器也可以方便的添加 和删除,这就是典型的观察者模式。
具体来说就是在LifeCycle接口里加入两个方法:添加监听器和删除监听器。除此之外,我们还需要定义一个 Enum来表示组件有哪些状态,以及处在什么状态会触发什么样的事件。因此LifeCycle接口和LifeCycleState 就定义成了下面这样。
从图上你可以看到,组件的生命周期有NEW、INITIALIZING、INITIALIZED、STARTING_PREP、 STARTING、STARTED等,而一旦组件到达相应的状态就触发相应的事件,比如NEW状态表示组件刚刚被实 例化;而当init()方法被调用时,状态就变成INITIALIZING状态,这个时候,就会触发BEFORE_INIT_EVENT 事件,如果有监听器在监听这个事件,它的方法就会被调用。
有了接口,我们就要用类去实现接口。一般来说实现类不止一个,不同的类在实现接口时往往会有一些相同 的逻辑,如果让各个子类都去实现一遍,就会有重复代码。那子类如何重用这部分逻辑呢?其实就是定义一 个基类来实现共同的逻辑,然后让各个子类去继承它,就达到了重用的目的。
而基类中往往会定义一些抽象方法,所谓的抽象方法就是说基类不会去实现这些方法,而是调用这些方法来 实现骨架逻辑。抽象方法是留给各个子类去实现的,并且子类必须实现,否则无法实例化。
回到LifeCycle接口,Tomcat定义一个基类LifeCycleBase来实现LifeCycle接口,把一些公共的逻辑放到基类 中去,比如生命状态的转变与维护、生命事件的触发以及监听器的添加和删除等,而子类就负责实现自己的 初始化、启动和停止等方法。为了避免跟基类中的方法同名,我们把具体子类的实现方法改个名字,在后面 加上Internal,叫initInternal()、startInternal()等。
我们再来看引入了基类LifeCycleBase后的类图:
从图上可以看到,LifeCycleBase实现了LifeCycle接口中所有的方法,还定义了相应的抽象方法交给具体子类 去实现,这是典型的模板设计模式。
我们还是看一看代码,可以帮你加深理解,下面是LifeCycleBase的init()方法实现。
这个方法逻辑比较清楚,主要完成了四步:
第一步,检查状态的合法性,比如当前状态必须是NEW然后才能进行初始化。
第二步,触发INITIALIZING事件的监听器:
第三步,调用具体子类实现的抽象方法initInternal()方法。我在前面提到过,为了实现一键式启动,具体组 件在实现initInternal()方法时,又会调用它的子组件的init()方法。
第四步,子组件初始化后,触发INITIALIZED事件的监听器,相应监听器的业务方法就会被调用。
总之,LifeCycleBase调用了抽象方法来实现骨架逻辑。
讲到这里, 你可能好奇,LifeCycleBase负责触发事 件,并调用监听器的方法,那是什么时候、谁把监听器注册进来的呢?
分为两种情况:
1.Tomcat自定义了一些监听器,这些监听器是父组件在创建子组件的过程中注册到子组件的。比如 MemoryLeakTrackingListener监听器,用来检测Context容器中的内存泄漏,这个监听器是Host容器在创 建Context容器时注册到Context中的。
2.我们还可以在server.xml中定义自己的监听器,Tomcat在启动时会解析server.xml,创建监听器并注册到 容器组件。
有了上面的基础,我们就可以看看整个生命周期的架构设计
StandardEngine、StandardHost、StandardContext和StandardWrapper是相应容器组件的具体实现类,因 为它们都是容器,所以继承了ContainerBase抽象基类。
而ContainerBase实现了Container接口,也继承了 LifeCycleBase类,它们的生命周期管理接口和功能接口是分开的,这也符合设计中接口分离的原则。
Tomcat为了实现一键式启停以及优雅的生命周期管理,并考虑到了可扩展性和可重用性,将面向对象思想 和设计模式发挥到了极致,分别运用了组合模式、观察者模式、模板方法模式。
在使用设计模式时,同时考虑了一些权威的设计原则,比如运用观察者模式遵守了开闭原则,防止动态添加生命周期组件而更改基类,同时生命周期接口和容器接口拆分也体现了接口分离原则
使用过Tomcat的同学都知道,我们可以通过Tomcat的/bin目录下的脚本startup.sh来启动Tomcat,那你是 否知道我们执行了这个脚本后发生了什么呢?
你可以通过下面这张流程图来了解一下。
1.Tomcat本质上是一个Java程序,因此startup.sh脚本会启动一个JVM来运行Tomcat的启动类Bootstrap。
2.Bootstrap的主要任务是初始化Tomcat的类加载器,并且创建Catalina。关于Tomcat为什么需要自己的类 加载器,我会在专栏后面详细介绍。
3.Catalina是一个启动类,它通过解析server.xml、创建相应的组件,并调用Server的start方法。
4.Server组件的职责就是管理Service组件,它会负责调用Service的start方法。
5.Service组件的职责就是管理连接器和顶层容器Engine,因此它会调用连接器和Engine的start方法。
这样Tomcat的启动就算完成了。
下面我来详细介绍一下上面这个启动过程中提到的几个非常关键的启动类 和组件。
你可以把Bootstrap看作是上帝,它初始化了类加载器,也就是创造万物的工具。 如果我们把Tomcat比作是一家公司,那么Catalina应该是公司创始人,因为Catalina负责组建团队,也就是创建Server以及它的子组件。
Server是公司的CEO,负责管理多个事业群,每个事业群就是一个Service。
Service是事业群总经理,它管理两个职能部门:一个是对外的市场部,也就是连接器组件;另一个是对内 的研发部,也就是容器组件。
Engine则是研发部经理,因为Engine是最顶层的容器组件。
你可以看到这些启动类或者组件不处理具体请求,它们的任务主要是“管理”,管理下层组件的生命周期, 并且给下层组件分配任务,也就是把请求路由到负责“干活儿”的组件。
因此我把它们比作Tomcat的“高 层”。
当我们在设计这样的组件时,需要考虑两个方面:
首先要选用合适的数据结构来保存子组件,比如Server用数组来保存Service组件,并且采取动态扩容的方 式,这是因为数组结构简单,占用内存小;再比如ContainerBase用HashMap来保存子容器,虽然Map占用 内存会多一点,但是可以通过Map来快速的查找子容器。因此在实际的工作中,我们也需要根据具体的场景 和需求来选用合适的数据结构。
其次还需要根据子组件依赖关系来决定它们的启动和停止顺序,以及如何优雅的停止,防止异常情况下的资 源泄漏。
Catalina的主要任务就是创建Server,它不是直接new一个Server实例就完事了,而是需要解析server.xml, 把在server.xml里配置的各种组件一一创建出来,接着调用Server组件的init方法和start方法,这样整个 Tomcat就启动起来了。
作为“管理者”,Catalina还需要处理各种“异常”情况,比如当我们通过“Ctrl + C”关闭Tomcat时,Tomcat将如何优雅的停止并且清理资源呢?因此Catalina在JVM中注册一个“关闭钩 子”。
源码位置:org.apache.catalina.startup.Catalina#start
- /**
- * Start a new server instance.
- */
- public void start() {
-
- if (getServer() == null) {
- load();
- }
-
- if (getServer() == null) {
- log.fatal("Cannot start server. Server instance is not configured.");
- return;
- }
-
- long t1 = System.nanoTime();
-
- // Start the new server
- try {
- getServer().start();
- } catch (LifecycleException e) {
- log.fatal(sm.getString("catalina.serverStartFail"), e);
- try {
- getServer().destroy();
- } catch (LifecycleException e1) {
- log.debug("destroy() failed for failed Server ", e1);
- }
- return;
- }
-
- long t2 = System.nanoTime();
- if(log.isInfoEnabled()) {
- log.info("Server startup in " + ((t2 - t1) / 1000000) + " ms");
- }
-
- // Register shutdown hook
- if (useShutdownHook) {
- if (shutdownHook == null) {
- shutdownHook = new CatalinaShutdownHook();
- }
- Runtime.getRuntime().addShutdownHook(shutdownHook);
-
- // If JULI is being used, disable JULI's shutdown hook since
- // shutdown hooks run in parallel and log messages may be lost
- // if JULI's hook completes before the CatalinaShutdownHook()
- LogManager logManager = LogManager.getLogManager();
- if (logManager instanceof ClassLoaderLogManager) {
- ((ClassLoaderLogManager) logManager).setUseShutdownHook(
- false);
- }
- }
-
- if (await) {
- await();
- stop();
- }
- }
代码的实现逻辑如下
1. 如果持有的Server实例为空,就解析server.xml创建出来
2. 如果创建失败,报错退出
3.启动Server
4.创建并注册关闭钩⼦
5.⽤await⽅法监听停⽌请求
那什么是“关闭钩子”,它又是做什么的呢?
如果我们需要在JVM关闭时做一些清理工作,比如将缓存数据 刷到磁盘上,或者清理一些临时文件,可以向JVM注册一个“关闭钩子”。“关闭钩子”其实就是一个线 程,JVM在停止之前会尝试执行这个线程的run方法。
下面我们来看看Tomcat的“关闭钩 子”CatalinaShutdownHook做了些什么。
- // --------------------------------------- CatalinaShutdownHook Inner Class
-
- // XXX Should be moved to embedded !
- /**
- * Shutdown hook which will perform a clean shutdown of Catalina if needed.
- */
- protected class CatalinaShutdownHook extends Thread {
-
- @Override
- public void run() {
- try {
- if (getServer() != null) {
- Catalina.this.stop();
- }
- } catch (Throwable ex) {
- ExceptionUtils.handleThrowable(ex);
- log.error(sm.getString("catalina.shutdownHookFail"), ex);
- } finally {
- // If JULI is used, shut JULI down *after* the server shuts down
- // so log messages aren't lost
- LogManager logManager = LogManager.getLogManager();
- if (logManager instanceof ClassLoaderLogManager) {
- ((ClassLoaderLogManager) logManager).shutdown();
- }
- }
- }
- }
从这段代码中你可以看到,Tomcat的“关闭钩子”实际上就执行了Server的stop方法,Server的stop方法会 释放和清理所有的资源。
Server组件的具体实现类是StandardServer,我们来看下StandardServer具体实现了哪些功能。Server继承 了LifeCycleBase,它的生命周期被统一管理,并且它的子组件是Service,因此它还需要管理Service的生命 周期,也就是说在启动时调用Service组件的启动方法,在停止时调用它们的停止方法。Server在内部维护了 若干Service组件,它是以数组来保存的。
那Server是如何添加一个Service到数组中的呢?
从上面的代码你能看到,它并没有一开始就分配一个很长的数组,而是在添加的过程中动态地扩展数组长 度,当添加一个新的Service实例时,会创建一个新数组并把原来数组内容复制到新数组,这样做的目的其 实是为了节省内存空间。
除此之外,Server组件还有一个重要的任务是启动一个Socket来监听停止端口,这就是为什么你能通过 shutdown命令来关闭Tomcat。
不知道你留意到没有,上面Caralina的启动方法的最后一行代码就是调用了 Server的await方法。 在await方法里会创建一个Socket监听8005端口,并在一个死循环里接收Socket上的连接请求,如果有新的 连接到来就建立连接,然后从Socket中读取数据;如果读到的数据是停止命令“SHUTDOWN”,就退出循 环,进入stop流程。
Service组件的具体实现类是StandardService
我们先来看看它的定义以及关键的成员变量。
StandardService继承了LifecycleBase抽象类,此外StandardService中还有一些我们熟悉的组件,比如 Server、Connector、Engine和Mapper。
那为什么还有一个MapperListener?
这是因为Tomcat支持热部署,当Web应用的部署发生变化时,Mapper 中的映射信息也要跟着变化,MapperListener就是一个监听器,它监听容器的变化,并把信息更新到 Mapper中,这是典型的观察者模式。
作为“管理”角色的组件,最重要的是维护其他组件的生命周期。此外在启动各种组件时,要注意它们的依 赖关系,也就是说,要注意启动的顺序。
我们来看看Service启动方法:
源码位置:org.apache.catalina.core.StandardService#startInternal
- protected void startInternal() throws LifecycleException {
- //1. 触发启动监听器
- setState(LifecycleState.STARTING);
- //2. 先启动Engine,Engine会启动它⼦容器
- if (engine != null) {
- synchronized (engine) {
- engine.start();
- }
- }
-
- //3. 再启动Mapper监听器
- mapperListener.start();
- //4.最后启动连接器,连接器会启动它⼦组件,⽐如Endpoint
- synchronized (connectorsLock) {
- for (Connector connector: connectors) {
- if (connector.getState() != LifecycleState.FAILED) {
- connector.start();
- }
- }
- }
- }
从启动方法可以看到,Service先启动了Engine组件,再启动Mapper监听器,最后才是启动连接器。
这很好理解,因为内层组件启动好了才能对外提供服务,才能启动外层的连接器组件。而Mapper也依赖容器组件,容器组件启动好了才能监听它们的变化,因此Mapper和MapperListener在容器组件之后启动。
组件停止的顺序跟启动顺序正好相反的,也是基于它们的依赖关系。
最后我们再来看看顶层的容器组件Engine具体是如何实现的。
Engine本质是一个容器,因此它继承了ContainerBase基类,并且实现了Engine接口。
我们知道,Engine的子容器是Host,所以它持有了一个Host容器的数组,这些功能都被抽象到了
ContainerBase中,ContainerBase中有这样一个数据结构:
ContainerBase用HashMap保存了它的子容器,并且ContainerBase还实现了子容器的“增删改查”,甚至连子组件的启动和停止都提供了默认实现,比如ContainerBase会用专门的线程池来启动子容器。
所以Engine在启动Host子容器时就直接重用了这个方法。
那Engine自己做了什么呢?
我们知道容器组件最重要的功能是处理请求,而Engine容器对请求的“处理”,其实就是把请求转发给某一个Host子容器来处理,具体是通过Valve来实现的。
通过专栏前面的学习,我们知道每一个容器组件都有一个Pipeline,而Pipeline中有一个基础阀(Basic Valve),而Engine容器的基础阀定义如下:
这个基础阀实现非常简单,就是把请求转发到Host容器。
你可能好奇,从代码中可以看到,处理请求的 Host容器对象是从请求中拿到的,请求对象中怎么会有Host容器呢?
这是因为请求到达Engine容器中之 前,Mapper组件已经对请求进行了路由处理,Mapper组件通过请求的URL定位了相应的容器,并且把容器 对象保存到了请求对象中。
我们在使用Tomcat时可能会碰到启动比较慢的问题,比如我们的系统发布新版本上线时,可能需要重启服 务,这个时候我们希望Tomcat能快速启动起来提供服务。
其实关于如何让Tomcat启动变快,官方网站有专 门的文章来介绍这个话题。
下面我也针对Tomcat 8.5和9.0版本,给出几条非常明确的建议,可以现学现 用。
Tomcat为了支持JSP,在应用启动的时候会扫描JAR包里面的TLD文件,加载里面定义的标签库,所以在 Tomcat的启动日志里,你可能会碰到这种提示:
At least one JAR was scanned for TLDs yet contained no TLDs. Enable debug logging for this logger for a complete list of JARs that were scanned but no TLDs were found in them. Skipping unneeded JARs during scanning can improve startup time and JSP compilation time
Tomcat的意思是,我扫描了你Web应用下的JAR包,发现JAR包里没有TLD文件。我建议配置一下Tomcat不要去扫描这些JAR包,这样可以提高Tomcat的启动速度,并节省JSP编译时间。
那如何配置不去扫描这些JAR包呢,这里分两种情况:
1.如果你的项目没有使用JSP作为Web页面模板,而是使用Velocity之类的模板引擎,你完全可以把TLD扫描 禁止掉。
方法是,找到Tomcat的conf/目录下的context.xml文件,在这个文件里Context标签下,加 上JarScanner和JarScanFilter子标签,像下面这样。
2.如果你的项目使用了JSP作为Web页面模块,意味着TLD扫描无法避免,但是我们可以通过配置来告诉 Tomcat,只扫描那些包含TLD文件的JAR包。方法是,找到Tomcat的conf/目录下的 catalina.properties文件,在这个文件里的jarsToSkip配置项中,加上你的JAR包
tomcat.util.scan.StandardJarScanFilter.jarsToSkip=xxx.jar
Tomcat会扫描WebSocket注解的API实现,比如@ServerEndpoint注解的类。我们知道,注解扫描一般是 比较慢的,如果不需要使用WebSockets就可以关闭它。具体方法是,找到Tomcat的conf/目录下的 context.xml文件,给Context标签加一个containerSciFilter的属性,像下面这样。
更进一步,如果你不需要WebSockets这个功能,你可以把Tomcat lib目录下的websocket-api.jar和 tomcat-websocket.jar这两个JAR文件删除掉,进一步提高性能。
跟关闭WebSocket一样,如果你不需要使用JSP,可以通过类似方法关闭JSP功能,像下面这样。
我们发现关闭JSP用的也是containerSciFilter属性,如果你想把WebSocket和JSP都关闭,那就这样配置:
Servlet 3.0引入了注解Servlet,Tomcat为了支持这个特性,会在Web应用启动时扫描你的类文件,因此如果 你没有使用Servlet注解这个功能,可以告诉Tomcat不要去扫描Servlet注解。具体配置方法是,在你的Web 应用的web.xml文件中,设置元素的属性metadata-complete="true",像下面这样。
metadata-complete的意思是,web.xml里配置的Servlet是完整的,不需要再去库类中找Servlet的定 义。
1、整体关系
Server元素在最顶层,代表整个Tomcat容器;一个Server元素中可以有一个或多个Service元素。
Service在Connector和Engine外面包了一层,把它们组装在一起,对外提供服务。一个Service可以包含多个Connector,但是只能包含一个Engine;Connector接收请求,Engine处理请求。
Engine、Host和Context都是容器,且 Engine包含Host,Host包含Context。每个Host组件代表Engine中的一个虚拟主机;每个Context组件代表在特定Host上运行的一个Web应用。
2、如何确定请求由谁处理?
当请求被发送到Tomcat所在的主机时,如何确定最终哪个Web应用来处理该请求呢?
(1)根据协议和端口号选定Service和Engine
Service中的Connector组件可以接收特定端口的请求,因此,当Tomcat启动时,Service组件就会监听特定的端口。在第一部分的例子中,Catalina这个Service监听了8080端口(基于HTTP协议)和8009端口(基于AJP协议)。当请求进来时,Tomcat便可以根据协议和端口号选定处理请求的Service;Service一旦选定,Engine也就确定。
通过在Server中配置多个Service,可以实现通过不同的端口号来访问同一台机器上部署的不同应用。
(2)根据域名或IP地址选定Host
Service确定后,Tomcat在Service中寻找名称与域名/IP地址匹配的Host处理该请求。如果没有找到,则使用Engine中指定的defaultHost来处理该请求。在第一部分的例子中,由于只有一个Host(name属性为localhost),因此该Service/Engine的所有请求都交给该Host处理。
(3)根据URI选定Context/Web应用
这一点在Context一节有详细的说明:Tomcat根据应用的 path属性与URI的匹配程度来选择Web应用处理相应请求,这里不再赘述。
(4)举例
以请求http://localhost:8080/app1/index.html为例,首先通过协议和端口号(http和8080)选定Service;然后通过主机名(localhost)选定Host;然后通过uri(/app1/index.html)选定Web应用。
Tomcat Server处理一个HTTP请求的过程
Tomcat Server处理一个HTTP请求的过程
1、用户点击网页内容,请求被发送到本机端口8080,被在那里监听的Coyote HTTP/1.1 Connector获得。
2、Connector把该请求交给它所在的Service的Engine来处理,并等待Engine的回应。
3、Engine获得请求localhost/test/index.jsp,匹配所有的虚拟主机Host。
4、Engine匹配到名为localhost的Host(即使匹配不到也把请求交给该Host处理,因为该Host被定义为该Engine的默认主机),名为localhost的Host获得请求/test/index.jsp,匹配它所拥有的所有的Context。Host匹配到路径为/test的Context(如果匹配不到就把该请求交给路径名为“ ”的Context去处理)。
5、path=“/test”的Context获得请求/index.jsp,在它的mapping table中寻找出对应的Servlet。Context匹配到URL PATTERN为*.jsp的Servlet,对应于JspServlet类。
6、构造HttpServletRequest对象和HttpServletResponse对象,作为参数调用JspServlet的doGet()或doPost().执行业务逻辑、数据存储等程序。
7、Context把执行完之后的HttpServletResponse对象返回给Host。
8、Host把HttpServletResponse对象返回给Engine。
9、Engine把HttpServletResponse对象返回Connector。
10、Connector把HttpServletResponse对象返回给客户Browser。
无论是BIO,还是NIO,Connector处理请求的大致流程是一样的:
1.在accept队列中接收连接(当客户端向服务器发送请求时,如果客户端与OS完成三次握手建立了连接,则OS将该连接放入accept队列);
2.在accept队列连接中获取请求的数据,生成request;
3.调用servlet容器处理请求;
4.返回response。
为了便于后面的说明,首先明确一下连接与请求的关系:连接是TCP层面的(传输层),对应socket;请求是HTTP层面的(应用层),必须依赖于TCP的连接实现;一个TCP连接中可能传输多个HTTP请求。
BIO方式
在BIO实现的Connector中,处理请求的主要实体是JIoEndpoint对象。JIoEndpoint维护了Acceptor和Worker:Acceptor接收socket,然后从Worker线程池中找出空闲的线程处理socket,如果worker线程池没有空闲线程,则Acceptor将阻塞。其中Worker是Tomcat自带的线程池,如果通过<Executor>配置了其他线程池,原理与Worker类似。其模型可以参考下图:
NIO方式
在NIO实现的Connector中,处理请求的主要实体是NIoEndpoint对象。NIoEndpoint中除了包含Acceptor和Worker外,还是用了Poller,处理流程如下图
Acceptor接收socket后,不是直接使用Worker中的线程处理请求,而是先将请求发送给了Poller,而Poller是实现NIO的关键。Acceptor向Poller发送请求通过队列实现,使用了典型的生产者-消费者模式。在Poller中,维护了一个Selector对象;当Poller从队列中取出socket后,注册到该Selector中;然后通过遍历Selector,找出其中可读的socket,并使用Worker中的线程处理相应请求。与BIO类似,Worker也可以被自定义的线程池代替。
其模型可参考下图:
通过上述过程可以看出,在NIoEndpoint处理请求的过程中,无论是Acceptor接收socket,还是线程处理请求,使用的仍然是阻塞方式;但在“读取socket并交给Worker中的线程”的这个过程中,使用非阻塞的NIO实现,这是NIO模式与BIO模式的最主要区别(其他区别对性能影响较小,暂时略去不提)。而这个区别,在并发量较大的情形下可以带来Tomcat效率的显著提升。
博主理解:
nio方式在读取socket并交给Worker中的线程中主要有两大区别:
1.中间引入了队列实现异步效果
2.使用多路复用IO模型,即Reactor模式
目前大多数HTTP请求使用的是长连接(HTTP/1.1默认keep-alive为true),而长连接意味着,一个TCP的socket在当前请求结束后,如果没有新的请求到来,socket不会立马释放,而是等timeout后再释放。如果使用BIO,“读取socket并交给Worker中的线程”这个过程是阻塞的,也就意味着在socket等待下一个请求或等待释放的过程中,处理这个socket的工作线程会一直被占用,无法释放;因此Tomcat可以同时处理的socket数目不能超过最大线程数,性能受到了极大限制。而使用NIO,“读取socket并交给Worker中的线程”这个过程是非阻塞的,当socket在等待下一个请求或等待释放时,并不会占用工作线程,因此Tomcat可以同时处理的socket数目远大于最大线程数,并发性能大大提高。
可以参考我的gitee地址
你可以通过内嵌的jar包或者直接下载压缩包进行源码阅读,两者之间的对应关系如下图所示
1.详解Tomcat 配置文件server.xml http://www.cnblogs.com/kismetv/p/7228274.html
2.详解tomcat的连接数与线程池 http://www.cnblogs.com/kismetv/p/7806063.html
3.解析Tomcat内部结构和请求过程https://www.cnblogs.com/zhouyuqin/p/5143121.html
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。