mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-19 15:43:44 +08:00
mod
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
<audio id="audio" title="05 | Tomcat系统架构(上): 连接器是如何设计的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/bf/d1e496ba509e03ccb784ec2b940929bf.mp3"></audio>
|
||||
|
||||
在面试时我们可能经常被问到:你做的XX项目的架构是如何设计的,请讲一下实现的思路。对于面试官来说,可以通过你对复杂系统设计的理解,了解你的技术水平以及处理复杂问题的思路。
|
||||
|
||||
今天咱们就来一步一步分析Tomcat的设计思路,看看Tomcat的设计者们当时是怎么回答这个问题的。一方面我们可以学到Tomcat的总体架构,学会从宏观上怎么去设计一个复杂系统,怎么设计顶层模块,以及模块之间的关系;另一方面也为我们深入学习Tomcat的工作原理打下基础。
|
||||
|
||||
## Tomcat总体架构
|
||||
|
||||
我们知道如果要设计一个系统,首先是要了解需求。通过专栏前面的文章,我们已经了解了Tomcat要实现2个核心功能:
|
||||
|
||||
<li>
|
||||
处理Socket连接,负责网络字节流与Request和Response对象的转化。
|
||||
</li>
|
||||
<li>
|
||||
加载和管理Servlet,以及具体处理Request请求。
|
||||
</li>
|
||||
|
||||
**因此Tomcat设计了两个核心组件连接器(Connector)和容器(Container)来分别做这两件事情。连接器负责对外交流,容器负责内部处理。**
|
||||
|
||||
所以连接器和容器可以说是Tomcat架构里最重要的两部分,需要你花些精力理解清楚。这两部分内容我会分成两期,今天我来分析连接器是如何设计的,下一期我会介绍容器的设计。
|
||||
|
||||
在开始讲连接器前,我先铺垫一下Tomcat支持的多种I/O模型和应用层协议。
|
||||
|
||||
Tomcat支持的I/O模型有:
|
||||
|
||||
<li>
|
||||
NIO:非阻塞I/O,采用Java NIO类库实现。
|
||||
</li>
|
||||
<li>
|
||||
NIO.2:异步I/O,采用JDK 7最新的NIO.2类库实现。
|
||||
</li>
|
||||
<li>
|
||||
APR:采用Apache可移植运行库实现,是C/C++编写的本地库。
|
||||
</li>
|
||||
|
||||
Tomcat支持的应用层协议有:
|
||||
|
||||
<li>
|
||||
HTTP/1.1:这是大部分Web应用采用的访问协议。
|
||||
</li>
|
||||
<li>
|
||||
AJP:用于和Web服务器集成(如Apache)。
|
||||
</li>
|
||||
<li>
|
||||
HTTP/2:HTTP 2.0大幅度的提升了Web性能。
|
||||
</li>
|
||||
|
||||
Tomcat为了实现支持多种I/O模型和应用层协议,一个容器可能对接多个连接器,就好比一个房间有多个门。但是单独的连接器或者容器都不能对外提供服务,需要把它们组装起来才能工作,组装后这个整体叫作Service组件。这里请你注意,Service本身没有做什么重要的事情,只是在连接器和容器外面多包了一层,把它们组装在一起。Tomcat内可能有多个Service,这样的设计也是出于灵活性的考虑。通过在Tomcat中配置多个Service,可以实现通过不同的端口号来访问同一台机器上部署的不同应用。
|
||||
|
||||
到此我们得到这样一张关系图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/d6/ee880033c5ae38125fa91fb3c4f8cad6.jpg" alt="">
|
||||
|
||||
从图上你可以看到,最顶层是Server,这里的Server指的就是一个Tomcat实例。一个Server中有一个或者多个Service,一个Service中有多个连接器和一个容器。连接器与容器之间通过标准的ServletRequest和ServletResponse通信。
|
||||
|
||||
## 连接器
|
||||
|
||||
连接器对Servlet容器屏蔽了协议及I/O模型等的区别,无论是HTTP还是AJP,在容器中获取到的都是一个标准的ServletRequest对象。
|
||||
|
||||
我们可以把连接器的功能需求进一步细化,比如:
|
||||
|
||||
<li>
|
||||
监听网络端口。
|
||||
</li>
|
||||
<li>
|
||||
接受网络连接请求。
|
||||
</li>
|
||||
<li>
|
||||
读取网络请求字节流。
|
||||
</li>
|
||||
<li>
|
||||
根据具体应用层协议(HTTP/AJP)解析字节流,生成统一的Tomcat Request对象。
|
||||
</li>
|
||||
<li>
|
||||
将Tomcat Request对象转成标准的ServletRequest。
|
||||
</li>
|
||||
<li>
|
||||
调用Servlet容器,得到ServletResponse。
|
||||
</li>
|
||||
<li>
|
||||
将ServletResponse转成Tomcat Response对象。
|
||||
</li>
|
||||
<li>
|
||||
将Tomcat Response转成网络字节流。
|
||||
</li>
|
||||
<li>
|
||||
将响应字节流写回给浏览器。
|
||||
</li>
|
||||
|
||||
需求列清楚后,我们要考虑的下一个问题是,连接器应该有哪些子模块?优秀的模块化设计应该考虑**高内聚、低耦合**。
|
||||
|
||||
<li>
|
||||
**高内聚**是指相关度比较高的功能要尽可能集中,不要分散。
|
||||
</li>
|
||||
<li>
|
||||
**低耦合**是指两个相关的模块要尽可能减少依赖的部分和降低依赖的程度,不要让两个模块产生强依赖。
|
||||
</li>
|
||||
|
||||
通过分析连接器的详细功能列表,我们发现连接器需要完成3个**高内聚**的功能:
|
||||
|
||||
<li>
|
||||
网络通信。
|
||||
</li>
|
||||
<li>
|
||||
应用层协议解析。
|
||||
</li>
|
||||
<li>
|
||||
Tomcat Request/Response与ServletRequest/ServletResponse的转化。
|
||||
</li>
|
||||
|
||||
因此Tomcat的设计者设计了3个组件来实现这3个功能,分别是Endpoint、Processor和Adapter。
|
||||
|
||||
组件之间通过抽象接口交互。这样做还有一个好处是**封装变化。**这是面向对象设计的精髓,将系统中经常变化的部分和稳定的部分隔离,有助于增加复用性,并降低系统耦合度。
|
||||
|
||||
网络通信的I/O模型是变化的,可能是非阻塞I/O、异步I/O或者APR。应用层协议也是变化的,可能是HTTP、HTTPS、AJP。浏览器端发送的请求信息也是变化的。
|
||||
|
||||
但是整体的处理逻辑是不变的,Endpoint负责提供字节流给Processor,Processor负责提供Tomcat Request对象给Adapter,Adapter负责提供ServletRequest对象给容器。
|
||||
|
||||
如果要支持新的I/O方案、新的应用层协议,只需要实现相关的具体子类,上层通用的处理逻辑是不变的。
|
||||
|
||||
由于I/O模型和应用层协议可以自由组合,比如NIO + HTTP或者NIO.2 + AJP。Tomcat的设计者将网络通信和应用层协议解析放在一起考虑,设计了一个叫ProtocolHandler的接口来封装这两种变化点。各种协议和通信模型的组合有相应的具体实现类。比如:Http11NioProtocol和AjpNioProtocol。
|
||||
|
||||
除了这些变化点,系统也存在一些相对稳定的部分,因此Tomcat设计了一系列抽象基类来**封装这些稳定的部分**,抽象基类AbstractProtocol实现了ProtocolHandler接口。每一种应用层协议有自己的抽象基类,比如AbstractAjpProtocol和AbstractHttp11Protocol,具体协议的实现类扩展了协议层抽象基类。下面我整理一下它们的继承关系。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/55/13850ee56c3f09cbabe9892e84502155.jpg" alt="">
|
||||
|
||||
通过上面的图,你可以清晰地看到它们的继承和层次关系,这样设计的目的是尽量将稳定的部分放到抽象基类,同时每一种I/O模型和协议的组合都有相应的具体实现类,我们在使用时可以自由选择。
|
||||
|
||||
小结一下,连接器模块用三个核心组件:Endpoint、Processor和Adapter来分别做三件事情,其中Endpoint和Processor放在一起抽象成了ProtocolHandler组件,它们的关系如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/ce/6eeaeb93839adcb4e76c15ee93f545ce.jpg" alt="">
|
||||
|
||||
下面我来详细介绍这两个顶层组件ProtocolHandler和Adapter。
|
||||
|
||||
**ProtocolHandler组件**
|
||||
|
||||
由上文我们知道,连接器用ProtocolHandler来处理网络连接和应用层协议,包含了2个重要部件:Endpoint和Processor,下面我来详细介绍它们的工作原理。
|
||||
|
||||
- Endpoint
|
||||
|
||||
Endpoint是通信端点,即通信监听的接口,是具体的Socket接收和发送处理器,是对传输层的抽象,因此Endpoint是用来实现TCP/IP协议的。
|
||||
|
||||
Endpoint是一个接口,对应的抽象实现类是AbstractEndpoint,而AbstractEndpoint的具体子类,比如在NioEndpoint和Nio2Endpoint中,有两个重要的子组件:Acceptor和SocketProcessor。
|
||||
|
||||
其中Acceptor用于监听Socket连接请求。SocketProcessor用于处理接收到的Socket请求,它实现Runnable接口,在run方法里调用协议处理组件Processor进行处理。为了提高处理能力,SocketProcessor被提交到线程池来执行。而这个线程池叫作执行器(Executor),我在后面的专栏会详细介绍Tomcat如何扩展原生的Java线程池。
|
||||
|
||||
- Processor
|
||||
|
||||
如果说Endpoint是用来实现TCP/IP协议的,那么Processor用来实现HTTP协议,Processor接收来自Endpoint的Socket,读取字节流解析成Tomcat Request和Response对象,并通过Adapter将其提交到容器处理,Processor是对应用层协议的抽象。
|
||||
|
||||
Processor是一个接口,定义了请求的处理等方法。它的抽象实现类AbstractProcessor对一些协议共有的属性进行封装,没有对方法进行实现。具体的实现有AjpProcessor、Http11Processor等,这些具体实现类实现了特定协议的解析方法和请求处理方式。
|
||||
|
||||
我们再来看看连接器的组件图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/30/cf/309cae2e132210489d327cf55b284dcf.jpg" alt="">
|
||||
|
||||
从图中我们看到,Endpoint接收到Socket连接后,生成一个SocketProcessor任务提交到线程池去处理,SocketProcessor的run方法会调用Processor组件去解析应用层协议,Processor通过解析生成Request对象后,会调用Adapter的Service方法。
|
||||
|
||||
到这里我们学习了ProtocolHandler的总体架构和工作原理,关于Endpoint的详细设计,后面我还会专门介绍Endpoint是如何最大限度地利用Java NIO的非阻塞以及NIO.2的异步特性,来实现高并发。
|
||||
|
||||
**Adapter组件**
|
||||
|
||||
我在前面说过,由于协议不同,客户端发过来的请求信息也不尽相同,Tomcat定义了自己的Request类来“存放”这些请求信息。ProtocolHandler接口负责解析请求并生成Tomcat Request类。但是这个Request对象不是标准的ServletRequest,也就意味着,不能用Tomcat Request作为参数来调用容器。Tomcat设计者的解决方案是引入CoyoteAdapter,这是适配器模式的经典运用,连接器调用CoyoteAdapter的sevice方法,传入的是Tomcat Request对象,CoyoteAdapter负责将Tomcat Request转成ServletRequest,再调用容器的service方法。
|
||||
|
||||
## 本期精华
|
||||
|
||||
Tomcat的整体架构包含了两个核心组件连接器和容器。连接器负责对外交流,容器负责内部处理。连接器用ProtocolHandler接口来封装通信协议和I/O模型的差异,ProtocolHandler内部又分为Endpoint和Processor模块,Endpoint负责底层Socket通信,Processor负责应用层协议解析。连接器通过适配器Adapter调用容器。
|
||||
|
||||
通过对Tomcat整体架构的学习,我们可以得到一些设计复杂系统的基本思路。首先要分析需求,根据高内聚低耦合的原则确定子模块,然后找出子模块中的变化点和不变点,用接口和抽象基类去封装不变点,在抽象基类中定义模板方法,让子类自行实现抽象方法,也就是具体子类去实现变化点。
|
||||
|
||||
## 课后思考
|
||||
|
||||
回忆一下你在工作中曾经独立设计过的系统,或者你碰到过的设计类面试题,结合今天专栏的内容,你有没有一些新的思路?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<audio id="audio" title="06 | Tomcat系统架构(下):聊聊多层容器的设计" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/a8/932fcee9871bd36583c78261ee0832a8.mp3"></audio>
|
||||
|
||||
专栏上一期我们学完了连接器的设计,今天我们一起来看一下Tomcat的容器设计。先复习一下,上期我讲到了Tomcat有两个核心组件:连接器和容器,其中连接器负责外部交流,容器负责内部处理。具体来说就是,连接器处理Socket通信和应用层协议的解析,得到Servlet请求;而容器则负责处理Servlet请求。我们通过下面这张图来回忆一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/d6/ee880033c5ae38125fa91fb3c4f8cad6.jpg" alt="">
|
||||
|
||||
容器,顾名思义就是用来装载东西的器具,在Tomcat里,容器就是用来装载Servlet的。那Tomcat的Servlet容器是如何设计的呢?
|
||||
|
||||
## 容器的层次结构
|
||||
|
||||
Tomcat设计了4种容器,分别是Engine、Host、Context和Wrapper。这4种容器不是平行关系,而是父子关系。下面我画了一张图帮你理解它们的关系。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cc/ed/cc968a11925591df558da0e7393f06ed.jpg" alt="">
|
||||
|
||||
你可能会问,为什么要设计成这么多层次的容器,这不是增加了复杂度吗?其实这背后的考虑是,**Tomcat通过一种分层的架构,使得Servlet容器具有很好的灵活性。**
|
||||
|
||||
Context表示一个Web应用程序;Wrapper表示一个Servlet,一个Web应用程序中可能会有多个Servlet;Host代表的是一个虚拟主机,或者说一个站点,可以给Tomcat配置多个虚拟主机地址,而一个虚拟主机下可以部署多个Web应用程序;Engine表示引擎,用来管理多个虚拟站点,一个Service最多只能有一个Engine。
|
||||
|
||||
你可以再通过Tomcat的`server.xml`配置文件来加深对Tomcat容器的理解。Tomcat采用了组件化的设计,它的构成组件都是可配置的,其中最外层的是Server,其他组件按照一定的格式要求配置在这个顶层容器中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/66/82b3f97aab5152dd5fe74e947db2a266.jpg" alt="">
|
||||
|
||||
那么,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接口用来统一管理各组件的生命周期,后面我也用专门的篇幅去详细介绍。
|
||||
|
||||
## 请求定位Servlet的过程
|
||||
|
||||
你可能好奇,设计了这么多层次的容器,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子容器。每个容器都有对应的访问路径,你可以通过下面这张图来帮助你理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/be/96/be22494588ca4f79358347468cd62496.jpg" alt="">
|
||||
|
||||
假如有用户访问一个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。
|
||||
|
||||
看到这里,我想你应该已经了解了什么是容器,以及Tomcat如何通过一层一层的父子容器找到某个Servlet来处理请求。需要注意的是,并不是说只有Servlet才会去处理请求,实际上这个查找路径上的父子容器都会对请求做一些处理。我在上一期说过,连接器中的Adapter会调用容器的Service方法来执行Servlet,最先拿到请求的是Engine容器,Engine容器对请求做一些处理后,会把请求传给自己子容器Host继续处理,依次类推,最后这个请求会传给Wrapper容器,Wrapper会调用最终的Servlet来处理。那么这个调用过程具体是怎么实现的呢?答案是使用Pipeline-Valve管道。
|
||||
|
||||
Pipeline-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。我还是通过一张图来解释。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/ca/b014ecce1f64b771bd58da62c05162ca.jpg" alt="">
|
||||
|
||||
整个调用过程由连接器中的Adapter触发的,它会调用Engine的第一个Valve:
|
||||
|
||||
```
|
||||
// Calling the container
|
||||
connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
|
||||
|
||||
```
|
||||
|
||||
Wrapper容器的最后一个Valve会创建一个Filter链,并调用doFilter方法,最终会调到Servlet的service方法。
|
||||
|
||||
你可能会问,前面我们不是讲到了Filter,似乎也有相似的功能,那Valve和Filter有什么区别吗?它们的区别是:
|
||||
|
||||
<li>
|
||||
Valve是Tomcat的私有机制,与Tomcat的基础架构/API是紧耦合的。Servlet API是公有的标准,所有的Web容器包括Jetty都支持Filter机制。
|
||||
</li>
|
||||
<li>
|
||||
另一个重要的区别是Valve工作在Web容器级别,拦截所有应用的请求;而Servlet Filter工作在应用级别,只能拦截某个Web应用的所有请求。如果想做整个Web容器的拦截器,必须通过Valve来实现。
|
||||
</li>
|
||||
|
||||
## 本期精华
|
||||
|
||||
今天我们学习了Tomcat容器的层次结构、根据请求定位Servlet的过程,以及请求在容器中的调用过程。Tomcat设计了多层容器是为了灵活性的考虑,灵活性具体体现在一个Tomcat实例(Server)可以有多个Service,每个Service通过多个连接器监听不同的端口,而一个Service又可以支持多个虚拟主机。一个URL网址可以用不同的主机名、不同的端口和不同的路径来访问特定的Servlet实例。
|
||||
|
||||
请求的链式调用是基于Pipeline-Valve责任链来完成的,这样的设计使得系统具有良好的可扩展性,如果需要扩展容器本身的功能,只需要增加相应的Valve即可。
|
||||
|
||||
## 课后思考
|
||||
|
||||
Tomcat内的Context组件跟Servlet规范中的ServletContext接口有什么区别?跟Spring中的ApplicationContext又有什么关系?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
152
极客时间专栏/深入拆解Tomcat & Jetty /模块二 整体架构/07 | Tomcat如何实现一键式启停?.md
Normal file
152
极客时间专栏/深入拆解Tomcat & Jetty /模块二 整体架构/07 | Tomcat如何实现一键式启停?.md
Normal file
@@ -0,0 +1,152 @@
|
||||
<audio id="audio" title="07 | Tomcat如何实现一键式启停?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/54/dd/54966edd8fee48887fcf1015a61dffdd.mp3"></audio>
|
||||
|
||||
通过前面的学习,相信你对Tomcat的架构已经有所了解,知道了Tomcat都有哪些组件,组件之间是什么样的关系,以及Tomcat是怎么处理一个HTTP请求的。下面我们通过一张简化的类图来回顾一下,从图上你可以看到各种组件的层次关系,图中的虚线表示一个请求在Tomcat中流转的过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/9b/12ad9ddc3ff73e0aacf2276bcfafae9b.png" alt="">
|
||||
|
||||
上面这张图描述了组件之间的静态关系,如果想让一个系统能够对外提供服务,我们需要创建、组装并启动这些组件;在服务停止的时候,我们还需要释放资源,销毁这些组件,因此这是一个动态的过程。也就是说,Tomcat需要动态地管理这些组件的生命周期。
|
||||
|
||||
在我们实际的工作中,如果你需要设计一个比较大的系统或者框架时,你同样也需要考虑这几个问题:如何统一管理组件的创建、初始化、启动、停止和销毁?如何做到代码逻辑清晰?如何方便地添加或者删除组件?如何做到组件启动和停止不遗漏、不重复?
|
||||
|
||||
今天我们就来解决上面的问题,在这之前,先来看看组件之间的关系。如果你仔细分析过这些组件,可以发现它们具有两层关系。
|
||||
|
||||
<li>
|
||||
第一层关系是组件有大有小,大组件管理小组件,比如Server管理Service,Service又管理连接器和容器。
|
||||
</li>
|
||||
<li>
|
||||
第二层关系是组件有外有内,外层组件控制内层组件,比如连接器是外层组件,负责对外交流,外层组件调用内层组件完成业务功能。也就是说,**请求的处理过程是由外层组件来驱动的。**
|
||||
</li>
|
||||
|
||||
这两层关系决定了系统在创建组件时应该遵循一定的顺序。
|
||||
|
||||
<li>
|
||||
第一个原则是先创建子组件,再创建父组件,子组件需要被“注入”到父组件中。
|
||||
</li>
|
||||
<li>
|
||||
第二个原则是先创建内层组件,再创建外层组件,内层组件需要被“注入”到外层组件。
|
||||
</li>
|
||||
|
||||
因此,最直观的做法就是将图上所有的组件按照先小后大、先内后外的顺序创建出来,然后组装在一起。不知道你注意到没有,这个思路其实很有问题!因为这样不仅会造成代码逻辑混乱和组件遗漏,而且也不利于后期的功能扩展。
|
||||
|
||||
为了解决这个问题,我们希望找到一种通用的、统一的方法来管理组件的生命周期,就像汽车“一键启动”那样的效果。
|
||||
|
||||
## 一键式启停:Lifecycle接口
|
||||
|
||||
我在前面说到过,设计就是要找到系统的变化点和不变点。这里的不变点就是每个组件都要经历创建、初始化、启动这几个过程,这些状态以及状态的转化是不变的。而变化点是每个具体组件的初始化方法,也就是启动方法是不一样的。
|
||||
|
||||
因此,我们把不变点抽象出来成为一个接口,这个接口跟生命周期有关,叫作Lifecycle。Lifecycle接口里应该定义这么几个方法:init、start、stop和destroy,每个具体的组件去实现这些方法。
|
||||
|
||||
理所当然,在父组件的init方法里需要创建子组件并调用子组件的init方法。同样,在父组件的start方法里也需要调用子组件的start方法,因此调用者可以无差别的调用各组件的init方法和start方法,这就是**组合模式**的使用,并且只要调用最顶层组件,也就是Server组件的init和start方法,整个Tomcat就被启动起来了。下面是Lifecycle接口的定义。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/5c/a1fcba6105f4235486bdba350d58bb5c.png" alt="">
|
||||
|
||||
## 可扩展性:Lifecycle事件
|
||||
|
||||
我们再来考虑另一个问题,那就是系统的可扩展性。因为各个组件init和start方法的具体实现是复杂多变的,比如在Host容器的启动方法里需要扫描webapps目录下的Web应用,创建相应的Context容器,如果将来需要增加新的逻辑,直接修改start方法?这样会违反开闭原则,那如何解决这个问题呢?开闭原则说的是为了扩展系统的功能,你不能直接修改系统中已有的类,但是你可以定义新的类。
|
||||
|
||||
我们注意到,组件的init和start调用是由它的父组件的状态变化触发的,上层组件的初始化会触发子组件的初始化,上层组件的启动会触发子组件的启动,因此我们把组件的生命周期定义成一个个状态,把状态的转变看作是一个事件。而事件是有监听器的,在监听器里可以实现一些逻辑,并且监听器也可以方便的添加和删除,这就是典型的**观察者模式**。
|
||||
|
||||
具体来说就是在Lifecycle接口里加入两个方法:添加监听器和删除监听器。除此之外,我们还需要定义一个Enum来表示组件有哪些状态,以及处在什么状态会触发什么样的事件。因此Lifecycle接口和LifecycleState就定义成了下面这样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dd/c0/dd0ce38fdff06dcc6d40714f39fc4ec0.png" alt="">
|
||||
|
||||
从图上你可以看到,组件的生命周期有NEW、INITIALIZING、INITIALIZED、STARTING_PREP、STARTING、STARTED等,而一旦组件到达相应的状态就触发相应的事件,比如NEW状态表示组件刚刚被实例化;而当init方法被调用时,状态就变成INITIALIZING状态,这个时候,就会触发BEFORE_INIT_EVENT事件,如果有监听器在监听这个事件,它的方法就会被调用。
|
||||
|
||||
## 重用性:LifecycleBase抽象基类
|
||||
|
||||
有了接口,我们就要用类去实现接口。一般来说实现类不止一个,不同的类在实现接口时往往会有一些相同的逻辑,如果让各个子类都去实现一遍,就会有重复代码。那子类如何重用这部分逻辑呢?其实就是定义一个基类来实现共同的逻辑,然后让各个子类去继承它,就达到了重用的目的。
|
||||
|
||||
而基类中往往会定义一些抽象方法,所谓的抽象方法就是说基类不会去实现这些方法,而是调用这些方法来实现骨架逻辑。抽象方法是留给各个子类去实现的,并且子类必须实现,否则无法实例化。
|
||||
|
||||
比如宝马和荣威的底盘和骨架其实是一样的,只是发动机和内饰等配套是不一样的。底盘和骨架就是基类,宝马和荣威就是子类。仅仅有底盘和骨架还不是一辆真正意义上的车,只能算是半成品,因此在底盘和骨架上会留出一些安装接口,比如安装发动机的接口、安装座椅的接口,这些就是抽象方法。宝马或者荣威上安装的发动机和座椅是不一样的,也就是具体子类对抽象方法有不同的实现。
|
||||
|
||||
回到Lifecycle接口,Tomcat定义一个基类LifecycleBase来实现Lifecycle接口,把一些公共的逻辑放到基类中去,比如生命状态的转变与维护、生命事件的触发以及监听器的添加和删除等,而子类就负责实现自己的初始化、启动和停止等方法。为了避免跟基类中的方法同名,我们把具体子类的实现方法改个名字,在后面加上Internal,叫initInternal、startInternal等。我们再来看引入了基类LifecycleBase后的类图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/d9/6704bf8a3e10e1d4cfb35ba11e6de5d9.png" alt="">
|
||||
|
||||
从图上可以看到,LifecycleBase实现了Lifecycle接口中所有的方法,还定义了相应的抽象方法交给具体子类去实现,这是典型的**模板设计模式**。
|
||||
|
||||
我们还是看一看代码,可以帮你加深理解,下面是LifecycleBase的init方法实现。
|
||||
|
||||
```
|
||||
@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) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个方法逻辑比较清楚,主要完成了四步:
|
||||
|
||||
第一步,检查状态的合法性,比如当前状态必须是NEW然后才能进行初始化。
|
||||
|
||||
第二步,触发INITIALIZING事件的监听器:
|
||||
|
||||
```
|
||||
setStateInternal(LifecycleState.INITIALIZING, null, false);
|
||||
|
||||
```
|
||||
|
||||
在这个setStateInternal方法里,会调用监听器的业务方法。
|
||||
|
||||
第三步,调用具体子类实现的抽象方法initInternal方法。我在前面提到过,为了实现一键式启动,具体组件在实现initInternal方法时,又会调用它的子组件的init方法。
|
||||
|
||||
第四步,子组件初始化后,触发INITIALIZED事件的监听器,相应监听器的业务方法就会被调用。
|
||||
|
||||
```
|
||||
setStateInternal(LifecycleState.INITIALIZED, null, false);
|
||||
|
||||
```
|
||||
|
||||
总之,LifecycleBase调用了抽象方法来实现骨架逻辑。讲到这里, 你可能好奇,LifecycleBase负责触发事件,并调用监听器的方法,那是什么时候、谁把监听器注册进来的呢?
|
||||
|
||||
分为两种情况:
|
||||
|
||||
<li>
|
||||
Tomcat自定义了一些监听器,这些监听器是父组件在创建子组件的过程中注册到子组件的。比如MemoryLeakTrackingListener监听器,用来检测Context容器中的内存泄漏,这个监听器是Host容器在创建Context容器时注册到Context中的。
|
||||
</li>
|
||||
<li>
|
||||
我们还可以在`server.xml`中定义自己的监听器,Tomcat在启动时会解析`server.xml`,创建监听器并注册到容器组件。
|
||||
</li>
|
||||
|
||||
## 生周期管理总体类图
|
||||
|
||||
通过上面的学习,我相信你对Tomcat组件的生命周期的管理有了深入的理解,我们再来看一张总体类图继续加深印象。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/90/de55ad3475e714acbf883713ee077690.png" alt="">
|
||||
|
||||
这里请你注意,图中的StandardServer、StandardService等是Server和Service组件的具体实现类,它们都继承了LifecycleBase。
|
||||
|
||||
StandardEngine、StandardHost、StandardContext和StandardWrapper是相应容器组件的具体实现类,因为它们都是容器,所以继承了ContainerBase抽象基类,而ContainerBase实现了Container接口,也继承了LifecycleBase类,它们的生命周期管理接口和功能接口是分开的,这也符合设计中**接口分离的原则**。
|
||||
|
||||
## 本期精华
|
||||
|
||||
Tomcat为了实现一键式启停以及优雅的生命周期管理,并考虑到了可扩展性和可重用性,将面向对象思想和设计模式发挥到了极致,分别运用了**组合模式、观察者模式、骨架抽象类和模板方法**。
|
||||
|
||||
如果你需要维护一堆具有父子关系的实体,可以考虑使用组合模式。
|
||||
|
||||
观察者模式听起来“高大上”,其实就是当一个事件发生后,需要执行一连串更新操作。传统的实现方式是在事件响应代码里直接加更新逻辑,当更新逻辑加多了之后,代码会变得臃肿,并且这种方式是紧耦合的、侵入式的。而观察者模式实现了低耦合、非侵入式的通知与更新机制。
|
||||
|
||||
而模板方法在抽象基类中经常用到,用来实现通用逻辑。
|
||||
|
||||
## 课后思考
|
||||
|
||||
从文中最后的类图上你会看到所有的容器组件都扩展了ContainerBase,跟LifecycleBase一样,ContainerBase也是一个骨架抽象类,请你思考一下,各容器组件有哪些“共同的逻辑”需要ContainerBase由来实现呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
267
极客时间专栏/深入拆解Tomcat & Jetty /模块二 整体架构/08 | Tomcat的“高层们”都负责做什么?.md
Normal file
267
极客时间专栏/深入拆解Tomcat & Jetty /模块二 整体架构/08 | Tomcat的“高层们”都负责做什么?.md
Normal file
@@ -0,0 +1,267 @@
|
||||
<audio id="audio" title="08 | Tomcat的“高层们”都负责做什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/50/66/50a5b1e7769fe9d4e37d84dc695b0666.mp3"></audio>
|
||||
|
||||
使用过Tomcat的同学都知道,我们可以通过Tomcat的`/bin`目录下的脚本`startup.sh`来启动Tomcat,那你是否知道我们执行了这个脚本后发生了什么呢?你可以通过下面这张流程图来了解一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/57/4d/578edfe9c06856324084ee193243694d.png" alt="">
|
||||
|
||||
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的“高层”。
|
||||
|
||||
今天我们就来看看这些“高层”的实现细节,目的是让我们逐步理解Tomcat的工作原理。另一方面,软件系统中往往都有一些起管理作用的组件,你可以学习和借鉴Tomcat是如何实现这些组件的。
|
||||
|
||||
## Catalina
|
||||
|
||||
Catalina的主要任务就是创建Server,它不是直接new一个Server实例就完事了,而是需要解析`server.xml`,把在`server.xml`里配置的各种组件一一创建出来,接着调用Server组件的init方法和start方法,这样整个Tomcat就启动起来了。作为“管理者”,Catalina还需要处理各种“异常”情况,比如当我们通过“Ctrl + C”关闭Tomcat时,Tomcat将如何优雅的停止并且清理资源呢?因此Catalina在JVM中注册一个“关闭钩子”。
|
||||
|
||||
```
|
||||
public void start() {
|
||||
//1. 如果持有的Server实例为空,就解析server.xml创建出来
|
||||
if (getServer() == null) {
|
||||
load();
|
||||
}
|
||||
//2. 如果创建失败,报错退出
|
||||
if (getServer() == null) {
|
||||
log.fatal(sm.getString("catalina.noServer"));
|
||||
return;
|
||||
}
|
||||
|
||||
//3.启动Server
|
||||
try {
|
||||
getServer().start();
|
||||
} catch (LifecycleException e) {
|
||||
return;
|
||||
}
|
||||
|
||||
//创建并注册关闭钩子
|
||||
if (useShutdownHook) {
|
||||
if (shutdownHook == null) {
|
||||
shutdownHook = new CatalinaShutdownHook();
|
||||
}
|
||||
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
||||
}
|
||||
|
||||
//用await方法监听停止请求
|
||||
if (await) {
|
||||
await();
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那什么是“关闭钩子”,它又是做什么的呢?如果我们需要在JVM关闭时做一些清理工作,比如将缓存数据刷到磁盘上,或者清理一些临时文件,可以向JVM注册一个“关闭钩子”。“关闭钩子”其实就是一个线程,JVM在停止之前会尝试执行这个线程的run方法。下面我们来看看Tomcat的“关闭钩子”CatalinaShutdownHook做了些什么。
|
||||
|
||||
```
|
||||
protected class CatalinaShutdownHook extends Thread {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
if (getServer() != null) {
|
||||
Catalina.this.stop();
|
||||
}
|
||||
} catch (Throwable ex) {
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从这段代码中你可以看到,Tomcat的“关闭钩子”实际上就执行了Server的stop方法,Server的stop方法会释放和清理所有的资源。
|
||||
|
||||
## Server组件
|
||||
|
||||
Server组件的具体实现类是StandardServer,我们来看下StandardServer具体实现了哪些功能。Server继承了LifecycleBase,它的生命周期被统一管理,并且它的子组件是Service,因此它还需要管理Service的生命周期,也就是说在启动时调用Service组件的启动方法,在停止时调用它们的停止方法。Server在内部维护了若干Service组件,它是以数组来保存的,那Server是如何添加一个Service到数组中的呢?
|
||||
|
||||
```
|
||||
@Override
|
||||
public void addService(Service service) {
|
||||
|
||||
service.setServer(this);
|
||||
|
||||
synchronized (servicesLock) {
|
||||
//创建一个长度+1的新数组
|
||||
Service results[] = new Service[services.length + 1];
|
||||
|
||||
//将老的数据复制过去
|
||||
System.arraycopy(services, 0, results, 0, services.length);
|
||||
results[services.length] = service;
|
||||
services = results;
|
||||
|
||||
//启动Service组件
|
||||
if (getState().isAvailable()) {
|
||||
try {
|
||||
service.start();
|
||||
} catch (LifecycleException e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
//触发监听事件
|
||||
support.firePropertyChange("service", null, service);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从上面的代码你能看到,它并没有一开始就分配一个很长的数组,而是在添加的过程中动态地扩展数组长度,当添加一个新的Service实例时,会创建一个新数组并把原来数组内容复制到新数组,这样做的目的其实是为了节省内存空间。
|
||||
|
||||
除此之外,Server组件还有一个重要的任务是启动一个Socket来监听停止端口,这就是为什么你能通过shutdown命令来关闭Tomcat。不知道你留意到没有,上面Catalina的启动方法的最后一行代码就是调用了Server的await方法。
|
||||
|
||||
在await方法里会创建一个Socket监听8005端口,并在一个死循环里接收Socket上的连接请求,如果有新的连接到来就建立连接,然后从Socket中读取数据;如果读到的数据是停止命令“SHUTDOWN”,就退出循环,进入stop流程。
|
||||
|
||||
## Service组件
|
||||
|
||||
Service组件的具体实现类是StandardService,我们先来看看它的定义以及关键的成员变量。
|
||||
|
||||
```
|
||||
public class StandardService extends LifecycleBase implements Service {
|
||||
//名字
|
||||
private String name = null;
|
||||
|
||||
//Server实例
|
||||
private Server server = null;
|
||||
|
||||
//连接器数组
|
||||
protected Connector connectors[] = new Connector[0];
|
||||
private final Object connectorsLock = new Object();
|
||||
|
||||
//对应的Engine容器
|
||||
private Engine engine = null;
|
||||
|
||||
//映射器及其监听器
|
||||
protected final Mapper mapper = new Mapper();
|
||||
protected final MapperListener mapperListener = new MapperListener(this);
|
||||
|
||||
```
|
||||
|
||||
StandardService继承了LifecycleBase抽象类,此外StandardService中还有一些我们熟悉的组件,比如Server、Connector、Engine和Mapper。
|
||||
|
||||
那为什么还有一个MapperListener?这是因为Tomcat支持热部署,当Web应用的部署发生变化时,Mapper中的映射信息也要跟着变化,MapperListener就是一个监听器,它监听容器的变化,并把信息更新到Mapper中,这是典型的观察者模式。
|
||||
|
||||
作为“管理”角色的组件,最重要的是维护其他组件的生命周期。此外在启动各种组件时,要注意它们的依赖关系,也就是说,要注意启动的顺序。我们来看看Service启动方法:
|
||||
|
||||
```
|
||||
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具体是如何实现的。Engine本质是一个容器,因此它继承了ContainerBase基类,并且实现了Engine接口。
|
||||
|
||||
```
|
||||
public class StandardEngine extends ContainerBase implements Engine {
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们知道,Engine的子容器是Host,所以它持有了一个Host容器的数组,这些功能都被抽象到了ContainerBase中,ContainerBase中有这样一个数据结构:
|
||||
|
||||
```
|
||||
protected final HashMap<String, Container> children = new HashMap<>();
|
||||
|
||||
```
|
||||
|
||||
ContainerBase用HashMap保存了它的子容器,并且ContainerBase还实现了子容器的“增删改查”,甚至连子组件的启动和停止都提供了默认实现,比如ContainerBase会用专门的线程池来启动子容器。
|
||||
|
||||
```
|
||||
for (int i = 0; i < children.length; i++) {
|
||||
results.add(startStopExecutor.submit(new StartChild(children[i])));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
所以Engine在启动Host子容器时就直接重用了这个方法。
|
||||
|
||||
那Engine自己做了什么呢?我们知道容器组件最重要的功能是处理请求,而Engine容器对请求的“处理”,其实就是把请求转发给某一个Host子容器来处理,具体是通过Valve来实现的。
|
||||
|
||||
通过专栏前面的学习,我们知道每一个容器组件都有一个Pipeline,而Pipeline中有一个基础阀(Basic Valve),而Engine容器的基础阀定义如下:
|
||||
|
||||
```
|
||||
final class StandardEngineValve extends ValveBase {
|
||||
|
||||
public final void invoke(Request request, Response response)
|
||||
throws IOException, ServletException {
|
||||
|
||||
//拿到请求中的Host容器
|
||||
Host host = request.getHost();
|
||||
if (host == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用Host容器中的Pipeline中的第一个Valve
|
||||
host.getPipeline().getFirst().invoke(request, response);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个基础阀实现非常简单,就是把请求转发到Host容器。你可能好奇,从代码中可以看到,处理请求的Host容器对象是从请求中拿到的,请求对象中怎么会有Host容器呢?这是因为请求到达Engine容器中之前,Mapper组件已经对请求进行了路由处理,Mapper组件通过请求的URL定位了相应的容器,并且把容器对象保存到了请求对象中。
|
||||
|
||||
## 本期精华
|
||||
|
||||
今天我们学习了Tomcat启动过程,具体是由启动类和“高层”组件来完成的,它们都承担着“管理”的角色,负责将子组件创建出来,并把它们拼装在一起,同时也掌握子组件的“生杀大权”。
|
||||
|
||||
所以当我们在设计这样的组件时,需要考虑两个方面:
|
||||
|
||||
首先要选用合适的数据结构来保存子组件,比如Server用数组来保存Service组件,并且采取动态扩容的方式,这是因为数组结构简单,占用内存小;再比如ContainerBase用HashMap来保存子容器,虽然Map占用内存会多一点,但是可以通过Map来快速的查找子容器。因此在实际的工作中,我们也需要根据具体的场景和需求来选用合适的数据结构。
|
||||
|
||||
其次还需要根据子组件依赖关系来决定它们的启动和停止顺序,以及如何优雅的停止,防止异常情况下的资源泄漏。这正是“管理者”应该考虑的事情。
|
||||
|
||||
## 课后思考
|
||||
|
||||
Server组件的在启动连接器和容器时,都分别加了锁,这是为什么呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<audio id="audio" title="09 | 比较:Jetty架构特点之Connector组件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/86/aa/86daf3559544dcc83b1de4e4e4c650aa.mp3"></audio>
|
||||
|
||||
经过专栏前面几期的学习,相信你对Tomcat的整体架构和工作原理有了基本了解。但是Servlet容器并非只有Tomcat一家,还有别的架构设计思路吗?今天我们就来看看Jetty的设计特点。
|
||||
|
||||
Jetty是Eclipse基金会的一个开源项目,和Tomcat一样,Jetty也是一个“HTTP服务器 + Servlet容器”,并且Jetty和Tomcat在架构设计上有不少相似的地方。但同时Jetty也有自己的特点,主要是更加小巧,更易于定制化。Jetty作为一名后起之秀,应用范围也越来越广,比如Google App Engine就采用了Jetty来作为Web容器。Jetty和Tomcat各有特点,所以今天我会和你重点聊聊Jetty在哪些地方跟Tomcat不同。通过比较它们的差异,一方面希望可以继续加深你对Web容器架构设计的理解,另一方面也让你更清楚它们的设计区别,并根据它们的特点来选用这两款Web容器。
|
||||
|
||||
## 鸟瞰Jetty整体架构
|
||||
|
||||
简单来说,Jetty Server就是由多个Connector(连接器)、多个Handler(处理器),以及一个线程池组成。整体结构请看下面这张图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/b6/95b908af86695af107fd3877a02190b6.jpg" alt="">
|
||||
|
||||
跟Tomcat一样,Jetty也有HTTP服务器和Servlet容器的功能,因此Jetty中的Connector组件和Handler组件分别来实现这两个功能,而这两个组件工作时所需要的线程资源都直接从一个全局线程池ThreadPool中获取。
|
||||
|
||||
Jetty Server可以有多个Connector在不同的端口上监听客户请求,而对于请求处理的Handler组件,也可以根据具体场景使用不同的Handler。这样的设计提高了Jetty的灵活性,需要支持Servlet,则可以使用ServletHandler;需要支持Session,则再增加一个SessionHandler。也就是说我们可以不使用Servlet或者Session,只要不配置这个Handler就行了。
|
||||
|
||||
为了启动和协调上面的核心组件工作,Jetty提供了一个Server类来做这个事情,它负责创建并初始化Connector、Handler、ThreadPool组件,然后调用start方法启动它们。
|
||||
|
||||
我们对比一下Tomcat的整体架构图,你会发现Tomcat在整体上跟Jetty很相似,它们的第一个区别是Jetty中没有Service的概念,Tomcat中的Service包装了多个连接器和一个容器组件,一个Tomcat实例可以配置多个Service,不同的Service通过不同的连接器监听不同的端口;而Jetty中Connector是被所有Handler共享的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/f2/7b100cb1c7399a366157b97b77f042f2.jpg" alt="">
|
||||
|
||||
它们的第二个区别是,在Tomcat中每个连接器都有自己的线程池,而在Jetty中所有的Connector共享一个全局的线程池。
|
||||
|
||||
讲完了Jetty的整体架构,接下来我来详细分析Jetty的Connector组件的设计,下一期我将分析Handler组件的设计。
|
||||
|
||||
## Connector组件
|
||||
|
||||
跟Tomcat一样,Connector的主要功能是对I/O模型和应用层协议的封装。I/O模型方面,最新的Jetty 9版本只支持NIO,因此Jetty的Connector设计有明显的Java NIO通信模型的痕迹。至于应用层协议方面,跟Tomcat的Processor一样,Jetty抽象出了Connection组件来封装应用层协议的差异。
|
||||
|
||||
Java NIO早已成为程序员的必备技能,并且也经常出现在面试题中。接下来我们一起来看看Jetty是如何实现NIO模型的,以及它是怎么**用**Java NIO的。
|
||||
|
||||
**Java NIO回顾**
|
||||
|
||||
关于Java NIO编程,如果你还不太熟悉,可以先学习这一[系列文章](http://ifeve.com/java-nio-all/)。Java NIO的核心组件是Channel、Buffer和Selector。Channel表示一个连接,可以理解为一个Socket,通过它可以读取和写入数据,但是并不能直接操作数据,需要通过Buffer来中转。
|
||||
|
||||
Selector可以用来检测Channel上的I/O事件,比如读就绪、写就绪、连接就绪,一个Selector可以同时处理多个Channel,因此单个线程可以监听多个Channel,这样会大量减少线程上下文切换的开销。下面我们通过一个典型的服务端NIO程序来回顾一下如何使用这些组件。
|
||||
|
||||
首先,创建服务端Channel,绑定监听端口并把Channel设置为非阻塞方式。
|
||||
|
||||
```
|
||||
ServerSocketChannel server = ServerSocketChannel.open();
|
||||
server.socket().bind(new InetSocketAddress(port));
|
||||
server.configureBlocking(false);
|
||||
|
||||
```
|
||||
|
||||
然后,创建Selector,并在Selector中注册Channel感兴趣的事件OP_ACCEPT,告诉Selector如果客户端有新的连接请求到这个端口就通知我。
|
||||
|
||||
```
|
||||
Selector selector = Selector.open();
|
||||
server.register(selector, SelectionKey.OP_ACCEPT);
|
||||
|
||||
```
|
||||
|
||||
接下来,Selector会在一个死循环里不断地调用select去查询I/O状态,select会返回一个SelectionKey列表,Selector会遍历这个列表,看看是否有“客户”感兴趣的事件,如果有,就采取相应的动作。
|
||||
|
||||
比如下面这个例子,如果有新的连接请求,就会建立一个新的连接。连接建立后,再注册Channel的可读事件到Selector中,告诉Selector我对这个Channel上是否有新的数据到达感兴趣。
|
||||
|
||||
```
|
||||
while (true) {
|
||||
selector.select();//查询I/O事件
|
||||
for (Iterator<SelectionKey> i = selector.selectedKeys().iterator(); i.hasNext();) {
|
||||
SelectionKey key = i.next();
|
||||
i.remove();
|
||||
|
||||
if (key.isAcceptable()) {
|
||||
// 建立一个新连接
|
||||
SocketChannel client = server.accept();
|
||||
client.configureBlocking(false);
|
||||
|
||||
//连接建立后,告诉Selector,我现在对I/O可读事件感兴趣
|
||||
client.register(selector, SelectionKey.OP_READ);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
简单回顾完服务端NIO编程之后,你会发现服务端在I/O通信上主要完成了三件事情:**监听连接、I/O事件查询以及数据读写**。因此Jetty设计了**Acceptor、SelectorManager和Connection来分别做这三件事情**,下面我分别来说说这三个组件。
|
||||
|
||||
**Acceptor**
|
||||
|
||||
顾名思义,Acceptor用于接受请求,跟Tomcat一样,Jetty也有独立的Acceptor线程组用于处理连接请求。在Connector的实现类ServerConnector中,有一个`_acceptors`的数组,在Connector启动的时候, 会根据`_acceptors`数组的长度创建对应数量的Acceptor,而Acceptor的个数可以配置。
|
||||
|
||||
```
|
||||
for (int i = 0; i < _acceptors.length; i++)
|
||||
{
|
||||
Acceptor a = new Acceptor(i);
|
||||
getExecutor().execute(a);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Acceptor是ServerConnector中的一个内部类,同时也是一个Runnable,Acceptor线程是通过getExecutor得到的线程池来执行的,前面提到这是一个全局的线程池。
|
||||
|
||||
Acceptor通过阻塞的方式来接受连接,这一点跟Tomcat也是一样的。
|
||||
|
||||
```
|
||||
public void accept(int acceptorID) throws IOException
|
||||
{
|
||||
ServerSocketChannel serverChannel = _acceptChannel;
|
||||
if (serverChannel != null && serverChannel.isOpen())
|
||||
{
|
||||
// 这里是阻塞的
|
||||
SocketChannel channel = serverChannel.accept();
|
||||
// 执行到这里时说明有请求进来了
|
||||
accepted(channel);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接受连接成功后会调用accepted函数,accepted函数中会将SocketChannel设置为非阻塞模式,然后交给Selector去处理,因此这也就到了Selector的地界了。
|
||||
|
||||
```
|
||||
private void accepted(SocketChannel channel) throws IOException
|
||||
{
|
||||
channel.configureBlocking(false);
|
||||
Socket socket = channel.socket();
|
||||
configure(socket);
|
||||
// _manager是SelectorManager实例,里面管理了所有的Selector实例
|
||||
_manager.accept(channel);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**SelectorManager**
|
||||
|
||||
Jetty的Selector由SelectorManager类管理,而被管理的Selector叫作ManagedSelector。SelectorManager内部有一个ManagedSelector数组,真正干活的是ManagedSelector。咱们接着上面分析,看看在SelectorManager在accept方法里做了什么。
|
||||
|
||||
```
|
||||
public void accept(SelectableChannel channel, Object attachment)
|
||||
{
|
||||
//选择一个ManagedSelector来处理Channel
|
||||
final ManagedSelector selector = chooseSelector();
|
||||
//提交一个任务Accept给ManagedSelector
|
||||
selector.submit(selector.new Accept(channel, attachment));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
SelectorManager从本身的Selector数组中选择一个Selector来处理这个Channel,并创建一个任务Accept交给ManagedSelector,ManagedSelector在处理这个任务主要做了两步:
|
||||
|
||||
第一步,调用Selector的register方法把Channel注册到Selector上,拿到一个SelectionKey。
|
||||
|
||||
```
|
||||
_key = _channel.register(selector, SelectionKey.OP_ACCEPT, this);
|
||||
|
||||
```
|
||||
|
||||
第二步,创建一个EndPoint和Connection,并跟这个SelectionKey(Channel)绑在一起:
|
||||
|
||||
```
|
||||
private void createEndPoint(SelectableChannel channel, SelectionKey selectionKey) throws IOException
|
||||
{
|
||||
//1. 创建EndPoint
|
||||
EndPoint endPoint = _selectorManager.newEndPoint(channel, this, selectionKey);
|
||||
|
||||
//2. 创建Connection
|
||||
Connection connection = _selectorManager.newConnection(channel, endPoint, selectionKey.attachment());
|
||||
|
||||
//3. 把EndPoint、Connection和SelectionKey绑在一起
|
||||
endPoint.setConnection(connection);
|
||||
selectionKey.attach(endPoint);
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面这两个过程是什么意思呢?打个比方,你到餐厅吃饭,先点菜(注册I/O事件),服务员(ManagedSelector)给你一个单子(SelectionKey),等菜做好了(I/O事件到了),服务员根据单子就知道是哪桌点了这个菜,于是喊一嗓子某某桌的菜做好了(调用了绑定在SelectionKey上的EndPoint的方法)。
|
||||
|
||||
这里需要你特别注意的是,ManagedSelector并没有调用直接EndPoint的方法去处理数据,而是通过调用EndPoint的方法**返回一个Runnable,然后把这个Runnable扔给线程池执行**,所以你能猜到,这个Runnable才会去真正读数据和处理请求。
|
||||
|
||||
**Connection**
|
||||
|
||||
这个Runnable是EndPoint的一个内部类,它会调用Connection的回调方法来处理请求。Jetty的Connection组件类比就是Tomcat的Processor,负责具体协议的解析,得到Request对象,并调用Handler容器进行处理。下面我简单介绍一下它的具体实现类HttpConnection对请求和响应的处理过程。
|
||||
|
||||
**请求处理**:HttpConnection并不会主动向EndPoint读取数据,而是向在EndPoint中注册一堆回调方法:
|
||||
|
||||
```
|
||||
getEndPoint().fillInterested(_readCallback);
|
||||
|
||||
```
|
||||
|
||||
这段代码就是告诉EndPoint,数据到了你就调我这些回调方法`_readCallback`吧,有点异步I/O的感觉,也就是说Jetty在应用层面模拟了异步I/O模型。
|
||||
|
||||
而在回调方法`_readCallback`里,会调用EndPoint的接口去读数据,读完后让HTTP解析器去解析字节流,HTTP解析器会将解析后的数据,包括请求行、请求头相关信息存到Request对象里。
|
||||
|
||||
**响应处理**:Connection调用Handler进行业务处理,Handler会通过Response对象来操作响应流,向流里面写入数据,HttpConnection再通过EndPoint把数据写到Channel,这样一次响应就完成了。
|
||||
|
||||
到此你应该了解了Connector的工作原理,下面我画张图再来回顾一下Connector的工作流程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b5/83/b526a90be6ee4c3e45c94e122c9c1e83.jpg" alt="">
|
||||
|
||||
1.Acceptor监听连接请求,当有连接请求到达时就接受连接,一个连接对应一个Channel,Acceptor将Channel交给ManagedSelector来处理。
|
||||
|
||||
2.ManagedSelector把Channel注册到Selector上,并创建一个EndPoint和Connection跟这个Channel绑定,接着就不断地检测I/O事件。
|
||||
|
||||
3.I/O事件到了就调用EndPoint的方法拿到一个Runnable,并扔给线程池执行。
|
||||
|
||||
4.线程池中调度某个线程执行Runnable。
|
||||
|
||||
5.Runnable执行时,调用回调函数,这个回调函数是Connection注册到EndPoint中的。
|
||||
|
||||
6.回调函数内部实现,其实就是调用EndPoint的接口方法来读数据。
|
||||
|
||||
7.Connection解析读到的数据,生成请求对象并交给Handler组件去处理。
|
||||
|
||||
## 本期精华
|
||||
|
||||
Jetty Server就是由多个Connector、多个Handler,以及一个线程池组成,在设计上简洁明了。
|
||||
|
||||
Jetty的Connector只支持NIO模型,跟Tomcat的NioEndpoint组件一样,它也是通过Java的NIO API实现的。我们知道,Java NIO编程有三个关键组件:Channel、Buffer和Selector,而核心是Selector。为了方便使用,Jetty在原生Selector组件的基础上做了一些封装,实现了ManagedSelector组件。
|
||||
|
||||
在线程模型设计上Tomcat的NioEndpoint跟Jetty的Connector是相似的,都是用一个Acceptor数组监听连接,用一个Selector数组侦测I/O事件,用一个线程池执行请求。它们的不同点在于,Jetty使用了一个全局的线程池,所有的线程资源都是从线程池来分配。
|
||||
|
||||
Jetty Connector设计中的一大特点是,使用了回调函数来模拟异步I/O,比如Connection向EndPoint注册了一堆回调函数。它的本质**将函数当作一个参数来传递**,告诉对方,你准备好了就调这个回调函数。
|
||||
|
||||
## 课后思考
|
||||
|
||||
Jetty的Connector主要完成了三件事件:接收连接、I/O事件查询以及数据读写。因此Jetty设计了Acceptor、SelectorManager和Connection来做这三件事情。今天的思考题是,为什么要把这些组件跑在不同的线程里呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<audio id="audio" title="10 | 比较:Jetty架构特点之Handler组件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/72/88/7247d03fdc73836019655db81219ba88.mp3"></audio>
|
||||
|
||||
在专栏上一期,我们学习了Jetty的整体架构。先来回顾一下,Jetty 就是由多个Connector(连接器)、多个Handler(处理器),以及一个线程池组成,整体结构图如下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/66/6a/66e55e89fd621c0eba6321471da2016a.png" alt="">
|
||||
|
||||
上一期我们分析了Jetty Connector组件的设计,Connector会将Servlet请求交给Handler去处理,那Handler又是如何处理请求的呢?
|
||||
|
||||
Jetty的Handler在设计上非常有意思,可以说是Jetty的灵魂,Jetty通过Handler实现了高度可定制化,那具体是如何实现的呢?我们能从中学到怎样的设计方法呢?接下来,我就来聊聊这些问题。
|
||||
|
||||
## Handler是什么
|
||||
|
||||
**Handler就是一个接口,它有一堆实现类**,Jetty的Connector组件调用这些接口来处理Servlet请求,我们先来看看这个接口定义成什么样子。
|
||||
|
||||
```
|
||||
public interface Handler extends LifeCycle, Destroyable
|
||||
{
|
||||
//处理请求的方法
|
||||
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
|
||||
throws IOException, ServletException;
|
||||
|
||||
//每个Handler都关联一个Server组件,被Server管理
|
||||
public void setServer(Server server);
|
||||
public Server getServer();
|
||||
|
||||
//销毁方法相关的资源
|
||||
public void destroy();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你会看到Handler接口的定义非常简洁,主要就是用handle方法用来处理请求,跟Tomcat容器组件的service方法一样,它有ServletRequest和ServletResponse两个参数。除此之外,这个接口中还有setServer和getServer方法,因为任何一个Handler都需要关联一个Server组件,也就是说Handler需要被Server组件来管理。一般来说Handler会加载一些资源到内存,因此通过设置destroy方法来销毁。
|
||||
|
||||
**Handler继承关系**
|
||||
|
||||
Handler只是一个接口,完成具体功能的还是它的子类。那么Handler有哪些子类呢?它们的继承关系又是怎样的?这些子类是如何实现Servlet容器功能的呢?
|
||||
|
||||
Jetty中定义了一些默认Handler类,并且这些Handler类之间的继承关系比较复杂,我们先通过一个全景图来了解一下。为了避免让你感到不适,我对类图进行了简化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/64/3a7b3fbf16bb79594ec23620507c5c64.png" alt="">
|
||||
|
||||
从图上你可以看到,Handler的种类和层次关系还是比较复杂的:
|
||||
|
||||
Handler接口之下有抽象类AbstractHandler,这一点并不意外,因为有接口一般就有抽象实现类。
|
||||
|
||||
在AbstractHandler之下有AbstractHandlerContainer,为什么需要这个类呢?这其实是个过渡,为了实现链式调用,一个Handler内部必然要有其他Handler的引用,所以这个类的名字里才有Container,意思就是这样的Handler里包含了其他Handler的引用。
|
||||
|
||||
理解了上面的AbstractHandlerContainer,我们就能理解它的两个子类了:HandlerWrapper和HandlerCollection。简单来说就是,HandlerWrapper和HandlerCollection都是Handler,但是这些Handler里还包括其他Handler的引用。不同的是,HandlerWrapper只包含一个其他Handler的引用,而HandlerCollection中有一个Handler数组的引用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/c1/f89a1e88a78a7e6860d69af3572467c1.png" alt="">
|
||||
|
||||
接着来看左边的HandlerWrapper,它有两个子类:Server和ScopedHandler。Server比较好理解,它本身是Handler模块的入口,必然要将请求传递给其他Handler来处理,为了触发其他Handler的调用,所以它是一个HandlerWrapper。
|
||||
|
||||
再看ScopedHandler,它也是一个比较重要的Handler,实现了“具有上下文信息”的责任链调用。为什么我要强调“具有上下文信息”呢?那是因为Servlet规范规定Servlet在执行过程中是有上下文的。那么这些Handler在执行过程中如何访问这个上下文呢?这个上下文又存在什么地方呢?答案就是通过ScopedHandler来实现的。
|
||||
|
||||
而ScopedHandler有一堆的子类,这些子类就是用来实现Servlet规范的,比如ServletHandler、ContextHandler、SessionHandler、ServletContextHandler和WebAppContext。接下来我会详细介绍它们,但我们先把总体类图看完。
|
||||
|
||||
请看类图的右边,跟HandlerWrapper对等的还有HandlerCollection,HandlerCollection其实维护了一个Handler数组。你可能会问,为什么要发明一个这样的Handler?这是因为Jetty可能需要同时支持多个Web应用,如果每个Web应用有一个Handler入口,那么多个Web应用的Handler就成了一个数组,比如Server中就有一个HandlerCollection,Server会根据用户请求的URL从数组中选取相应的Handler来处理,就是选择特定的Web应用来处理请求。
|
||||
|
||||
**Handler的类型**
|
||||
|
||||
虽然从类图上看Handler有很多,但是本质上这些Handler分成三种类型:
|
||||
|
||||
- 第一种是**协调Handler**,这种Handler负责将请求路由到一组Handler中去,比如上图中的HandlerCollection,它内部持有一个Handler数组,当请求到来时,它负责将请求转发到数组中的某一个Handler。
|
||||
- 第二种是**过滤器Handler**,这种Handler自己会处理请求,处理完了后再把请求转发到下一个Handler,比如图上的HandlerWrapper,它内部持有下一个Handler的引用。需要注意的是,所有继承了HandlerWrapper的Handler都具有了过滤器Handler的特征,比如ContextHandler、SessionHandler和WebAppContext等。
|
||||
- 第三种是**内容Handler**,说白了就是这些Handler会真正调用Servlet来处理请求,生成响应的内容,比如ServletHandler。如果浏览器请求的是一个静态资源,也有相应的ResourceHandler来处理这个请求,返回静态页面。
|
||||
|
||||
## 如何实现Servlet规范
|
||||
|
||||
上文提到,ServletHandler、ContextHandler以及WebAppContext等,它们实现了Servlet规范,那具体是怎么实现的呢?为了帮助你理解,在这之前,我们还是来看看如何使用Jetty来启动一个Web应用。
|
||||
|
||||
```
|
||||
//新建一个WebAppContext,WebAppContext是一个Handler
|
||||
WebAppContext webapp = new WebAppContext();
|
||||
webapp.setContextPath("/mywebapp");
|
||||
webapp.setWar("mywebapp.war");
|
||||
|
||||
//将Handler添加到Server中去
|
||||
server.setHandler(webapp);
|
||||
|
||||
//启动Server
|
||||
server.start();
|
||||
server.join();
|
||||
|
||||
```
|
||||
|
||||
上面的过程主要分为两步:
|
||||
|
||||
第一步创建一个WebAppContext,接着设置一些参数到这个Handler中,就是告诉WebAppContext你的WAR包放在哪,Web应用的访问路径是什么。
|
||||
|
||||
第二步就是把新创建的WebAppContext添加到Server中,然后启动Server。
|
||||
|
||||
WebAppContext对应一个Web应用。我们回忆一下Servlet规范中有Context、Servlet、Filter、Listener和Session等,Jetty要支持Servlet规范,就需要有相应的Handler来分别实现这些功能。因此,Jetty设计了3个组件:ContextHandler、ServletHandler和SessionHandler来实现Servlet规范中规定的功能,而**WebAppContext本身就是一个ContextHandler**,另外它还负责管理ServletHandler和SessionHandler。
|
||||
|
||||
我们再来看一下什么是ContextHandler。ContextHandler会创建并初始化Servlet规范里的ServletContext对象,同时ContextHandler还包含了一组能够让你的Web应用运行起来的Handler,可以这样理解,Context本身也是一种Handler,它里面包含了其他的Handler,这些Handler能处理某个特定URL下的请求。比如,ContextHandler包含了一个或者多个ServletHandler。
|
||||
|
||||
再来看ServletHandler,它实现了Servlet规范中的Servlet、Filter和Listener的功能。ServletHandler依赖FilterHolder、ServletHolder、ServletMapping、FilterMapping这四大组件。FilterHolder和ServletHolder分别是Filter和Servlet的包装类,每一个Servlet与路径的映射会被封装成ServletMapping,而Filter与拦截URL的映射会被封装成FilterMapping。
|
||||
|
||||
SessionHandler从名字就知道它的功能,用来管理Session。除此之外WebAppContext还有一些通用功能的Handler,比如SecurityHandler和GzipHandler,同样从名字可以知道这些Handler的功能分别是安全控制和压缩/解压缩。
|
||||
|
||||
WebAppContext会将这些Handler构建成一个执行链,通过这个链会最终调用到我们的业务Servlet。我们通过一张图来理解一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/c1/5f1404567deec36ac68c36e44bb06cc1.jpg" alt="">
|
||||
|
||||
通过对比Tomcat的架构图,你可以看到,Jetty的Handler组件和Tomcat中的容器组件是大致是对等的概念,Jetty中的WebAppContext相当于Tomcat的Context组件,都是对应一个Web应用;而Jetty中的ServletHandler对应Tomcat中的Wrapper组件,它负责初始化和调用Servlet,并实现了Filter的功能。
|
||||
|
||||
对于一些通用组件,比如安全和解压缩,在Jetty中都被做成了Handler,这是Jetty Handler架构的特点。
|
||||
|
||||
因此对于Jetty来说,请求处理模块就被抽象成Handler,不管是实现了Servlet规范的Handler,还是实现通用功能的Handler,比如安全、解压缩等,我们可以任意添加或者裁剪这些“功能模块”,从而实现高度的可定制化。
|
||||
|
||||
## 本期精华
|
||||
|
||||
Jetty Server就是由多个Connector、多个Handler,以及一个线程池组成。
|
||||
|
||||
Jetty的Handler设计是它的一大特色,Jetty本质就是一个Handler管理器,Jetty本身就提供了一些默认Handler来实现Servlet容器的功能,你也可以定义自己的Handler来添加到Jetty中,这体现了“**微内核 + 插件**”的设计思想。
|
||||
|
||||
## 课后思考
|
||||
|
||||
通过今天的学习,我们知道各种Handler都会对请求做一些处理,再将请求传给下一个Handler,而Servlet也是用来处理请求的,那Handler跟Servlet有什么区别呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<audio id="audio" title="11 | 总结:从Tomcat和Jetty中提炼组件化设计规范" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6e/42/6ea42a9860fc77edb204200e5fd49742.mp3"></audio>
|
||||
|
||||
在当今的互联网时代,我们每个人获取信息的机会基本上都是平等的,但是为什么有些人对信息理解得更深,并且有自己独到的见解呢?我认为是因为他们养成了思考和总结的好习惯。当我们学习一门技术的时候,如果可以勤于思考、善于总结,可以帮助我们看到现象背后更本质的东西,让我们在成长之路上更快“脱颖而出”。
|
||||
|
||||
我们经常谈敏捷、快速迭代和重构,这些都是为了应对需求的快速变化,也因此我们在开始设计一个系统时就要考虑可扩展性。那究竟该怎样设计才能适应变化呢?或者要设计成什么样后面才能以最小的成本进行重构呢?今天我来总结一些Tomcat和Jetty组件化的设计思想,或许从中我们可以得到一些启发。
|
||||
|
||||
## 组件化及可配置
|
||||
|
||||
Tomcat和Jetty的整体架构都是基于组件的,你可以通过XML文件或者代码的方式来配置这些组件,比如我们可以在server.xml配置Tomcat的连接器以及容器组件。相应的,你也可以在jetty.xml文件里组装Jetty的Connector组件,以及各种Handler组件。也就是说,**Tomcat和Jetty提供了一堆积木,怎么搭建这些积木由你来决定**,你可以根据自己的需要灵活选择组件来搭建你的Web容器,并且也可以自定义组件,这样的设计为Web容器提供了深度可定制化。
|
||||
|
||||
那Web容器如何实现这种组件化设计呢?我认为有两个要点:
|
||||
|
||||
- 第一个是面向接口编程。我们需要对系统的功能按照“高内聚、低耦合”的原则进行拆分,每个组件都有相应的接口,组件之间通过接口通信,这样就可以方便地替换组件了。比如我们可以选择不同连接器类型,只要这些连接器组件实现同一个接口就行。
|
||||
- 第二个是Web容器提供一个载体把组件组装在一起工作。组件的工作无非就是处理请求,因此容器通过责任链模式把请求依次交给组件去处理。对于用户来说,我只需要告诉Web容器由哪些组件来处理请求。把组件组织起来需要一个“管理者”,这就是为什么Tomcat和Jetty都有一个Server的概念,Server就是组件的载体,Server里包含了连接器组件和容器组件;容器还需要把请求交给各个子容器组件去处理,Tomcat和Jetty都是责任链模式来实现的。
|
||||
|
||||
用户通过配置来组装组件,跟Spring中Bean的依赖注入相似。Spring的用户可以通过配置文件或者注解的方式来组装Bean,Bean与Bean的依赖关系完全由用户自己来定义。这一点与Web容器**不同**,Web容器中组件与组件之间的关系是固定的,比如Tomcat中Engine组件下有Host组件、Host组件下有Context组件等,但你不能在Host组件里“注入”一个Wrapper组件,这是由于Web容器本身的功能来决定的。
|
||||
|
||||
## 组件的创建
|
||||
|
||||
由于组件是可以配置的,Web容器在启动之前并不知道要创建哪些组件,也就是说,不能通过硬编码的方式来实例化这些组件,而是需要通过反射机制来动态地创建。具体来说,Web容器不是通过new方法来实例化组件对象的,而是通过Class.forName来创建组件。无论哪种方式,在实例化一个类之前,Web容器需要把组件类加载到JVM,这就涉及一个类加载的问题,Web容器设计了自己类加载器,我会在专栏后面的文章详细介绍Tomcat的类加载器。
|
||||
|
||||
Spring也是通过反射机制来动态地实例化Bean,那么它用到的类加载器是从哪里来的呢?Web容器给每个Web应用创建了一个类加载器,Spring用到的类加载器是Web容器传给它的。
|
||||
|
||||
## 组件的生命周期管理
|
||||
|
||||
不同类型的组件具有父子层次关系,父组件处理请求后再把请求传递给某个子组件。你可能会感到疑惑,Jetty的中Handler不是一条链吗,看上去像是平行关系?其实不然,Jetty中的Handler也是分层次的,比如WebAppContext中包含ServletHandler和SessionHandler。因此你也可以把ContextHandler和它所包含的Handler看作是父子关系。
|
||||
|
||||
而Tomcat通过容器的概念,把小容器放到大容器来实现父子关系,其实它们的本质都是一样的。这其实涉及如何统一管理这些组件,如何做到一键式启停。
|
||||
|
||||
Tomcat和Jetty都采用了类似的办法来管理组件的生命周期,主要有两个要点,一是父组件负责子组件的创建、启停和销毁。这样只要启动最上层组件,整个Web容器就被启动起来了,也就实现了一键式启停;二是Tomcat和Jetty都定义了组件的生命周期状态,并且把组件状态的转变定义成一个事件,一个组件的状态变化会触发子组件的变化,比如Host容器的启动事件里会触发Web应用的扫描和加载,最终会在Host容器下创建相应的Context容器,而Context组件的启动事件又会触发Servlet的扫描,进而创建Wrapper组件。那么如何实现这种联动呢?答案是观察者模式。具体来说就是创建监听器去监听容器的状态变化,在监听器的方法里去实现相应的动作,这些监听器其实是组件生命周期过程中的“扩展点”。
|
||||
|
||||
Spring也采用了类似的设计,Spring给Bean生命周期状态提供了很多的“扩展点”。这些扩展点被定义成一个个接口,只要你的Bean实现了这些接口,Spring就会负责调用这些接口,这样做的目的就是,当Bean的创建、初始化和销毁这些控制权交给Spring后,Spring让你有机会在Bean的整个生命周期中执行你的逻辑。下面我通过一张图帮你理解Spring Bean的生命周期过程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7f/3d/7f87b5f06cef33af6266ae7f6dcf203d.png" alt="">
|
||||
|
||||
## 组件的骨架抽象类和模板模式
|
||||
|
||||
具体到组件的设计的与实现,Tomcat和Jetty都大量采用了骨架抽象类和模板模式。比如说Tomcat中ProtocolHandler接口,ProtocolHandler有抽象基类AbstractProtocol,它实现了协议处理层的骨架和通用逻辑,而具体协议也有抽象基类,比如HttpProtocol和AjpProtocol。对于Jetty来说,Handler接口之下有AbstractHandler,Connector接口之下有AbstractConnector,这些抽象骨架类实现了一些通用逻辑,并且会定义一些抽象方法,这些抽象方法由子类实现,抽象骨架类调用抽象方法来实现骨架逻辑。
|
||||
|
||||
这是一个通用的设计规范,不管是Web容器还是Spring,甚至JDK本身都到处使用这种设计,比如Java集合中的AbstractSet、AbstractMap等。 值得一提的是,从Java 8开始允许接口有default方法,这样我们可以把抽象骨架类的通用逻辑放到接口中去。
|
||||
|
||||
## 本期精华
|
||||
|
||||
今天我总结了Tomcat和Jetty的组件化设计,我们可以通过搭积木的方式来定制化自己的Web容器。Web容器为了支持这种组件化设计,遵循了一些规范,比如面向接口编程,用“管理者”去组装这些组件,用反射的方式动态的创建组件、统一管理组件的生命周期,并且给组件生命状态的变化提供了扩展点,组件的具体实现一般遵循骨架抽象类和模板模式。
|
||||
|
||||
通过今天的学习,你会发现Tomcat和Jetty有很多共同点,并且Spring框架的设计也有不少相似的的地方,这正好说明了Web开发中有一些本质的东西是相通的,只要你深入理解了一个技术,也就是在一个点上突破了深度,再扩展广度就不是难事。并且我建议在学习一门技术的时候,可以回想一下之前学过的东西,是不是有相似的地方,有什么不同的地方,通过对比理解它们的本质,这样我们才能真正掌握这些技术背后的精髓。
|
||||
|
||||
## 课后思考
|
||||
|
||||
在我们的实际项目中,可能经常遇到改变需求,那如果采用组件化设计,当需求更改时是不是会有一些帮助呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
124
极客时间专栏/深入拆解Tomcat & Jetty /模块二 整体架构/12 | 实战:优化并提高Tomcat启动速度.md
Normal file
124
极客时间专栏/深入拆解Tomcat & Jetty /模块二 整体架构/12 | 实战:优化并提高Tomcat启动速度.md
Normal file
@@ -0,0 +1,124 @@
|
||||
<audio id="audio" title="12 | 实战:优化并提高Tomcat启动速度" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1c/28/1cd234a8fa1e16e291ed04a55a944928.mp3"></audio>
|
||||
|
||||
到目前为止,我们学习了Tomcat和Jetty的整体架构,还知道了Tomcat是如何启动起来的,今天我们来聊一个比较轻松的话题:如何优化并提高Tomcat的启动速度。
|
||||
|
||||
我们在使用Tomcat时可能会碰到启动比较慢的问题,比如我们的系统发布新版本上线时,可能需要重启服务,这个时候我们希望Tomcat能快速启动起来提供服务。其实关于如何让Tomcat启动变快,官方网站有专门的[文章](https://wiki.apache.org/tomcat/HowTo/FasterStartUp)来介绍这个话题。下面我也针对Tomcat 8.5和9.0版本,给出几条非常明确的建议,可以现学现用。
|
||||
|
||||
## 清理你的Tomcat
|
||||
|
||||
**1. 清理不必要的Web应用**
|
||||
|
||||
首先我们要做的是删除掉webapps文件夹下不需要的工程,一般是host-manager、example、doc等这些默认的工程,可能还有以前添加的但现在用不着的工程,最好把这些全都删除掉。如果你看过Tomcat的启动日志,可以发现每次启动Tomcat,都会重新布署这些工程。
|
||||
|
||||
**2. 清理XML配置文件**
|
||||
|
||||
我们知道Tomcat在启动的时候会解析所有的XML配置文件,但XML解析的代价可不小,因此我们要尽量保持配置文件的简洁,需要解析的东西越少,速度自然就会越快。
|
||||
|
||||
**3. 清理JAR文件**
|
||||
|
||||
我们还可以删除所有不需要的JAR文件。JVM的类加载器在加载类时,需要查找每一个JAR文件,去找到所需要的类。如果删除了不需要的JAR文件,查找的速度就会快一些。这里请注意:**Web应用中的lib目录下不应该出现Servlet API或者Tomcat自身的JAR**,这些JAR由Tomcat负责提供。如果你是使用Maven来构建你的应用,对Servlet API的依赖应该指定为`<scope>provided</scope>`。
|
||||
|
||||
**4. 清理其他文件**
|
||||
|
||||
及时清理日志,删除logs文件夹下不需要的日志文件。同样还有work文件夹下的catalina文件夹,它其实是Tomcat把JSP转换为Class文件的工作目录。有时候我们也许会遇到修改了代码,重启了Tomcat,但是仍没效果,这时候便可以删除掉这个文件夹,Tomcat下次启动的时候会重新生成。
|
||||
|
||||
## 禁止Tomcat TLD扫描
|
||||
|
||||
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包呢,这里分两种情况:
|
||||
|
||||
- 如果你的项目没有使用JSP作为Web页面模板,而是使用Velocity之类的模板引擎,你完全可以把TLD扫描禁止掉。方法是,找到Tomcat的`conf/`目录下的`context.xml`文件,在这个文件里Context标签下,加上**JarScanner**和**JarScanFilter**子标签,像下面这样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/6e/b9c09507c546c6ff349270cd992ff66e.jpg" alt="">
|
||||
|
||||
- 如果你的项目使用了JSP作为Web页面模块,意味着TLD扫描无法避免,但是我们可以通过配置来告诉Tomcat,只扫描那些包含TLD文件的JAR包。方法是,找到Tomcat的`conf/`目录下的`catalina.properties`文件,在这个文件里的jarsToSkip配置项中,加上你的JAR包。
|
||||
|
||||
```
|
||||
tomcat.util.scan.StandardJarScanFilter.jarsToSkip=xxx.jar
|
||||
|
||||
```
|
||||
|
||||
## 关闭WebSocket支持
|
||||
|
||||
Tomcat会扫描WebSocket注解的API实现,比如`@ServerEndpoint`注解的类。我们知道,注解扫描一般是比较慢的,如果不需要使用WebSocket就可以关闭它。具体方法是,找到Tomcat的`conf/`目录下的`context.xml`文件,给Context标签加一个**containerSciFilter**的属性,像下面这样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/fc/fb25c3f5e44521ec47046fafec11e0fc.jpg" alt="">
|
||||
|
||||
更进一步,如果你不需要WebSocket这个功能,你可以把Tomcat lib目录下的`websocket-api.jar`和`tomcat-websocket.jar`这两个JAR文件删除掉,进一步提高性能。
|
||||
|
||||
## 关闭JSP支持
|
||||
|
||||
跟关闭WebSocket一样,如果你不需要使用JSP,可以通过类似方法关闭JSP功能,像下面这样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/c4/0ef2d6f508babe62960909c6c881c4c4.jpg" alt="">
|
||||
|
||||
我们发现关闭JSP用的也是**containerSciFilter**属性,如果你想把WebSocket和JSP都关闭,那就这样配置:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/eb/5d/ebd41a1156f28e92257f4c0130a8125d.jpg" alt="">
|
||||
|
||||
## 禁止Servlet注解扫描
|
||||
|
||||
Servlet 3.0引入了注解Servlet,Tomcat为了支持这个特性,会在Web应用启动时扫描你的类文件,因此如果你没有使用Servlet注解这个功能,可以告诉Tomcat不要去扫描Servlet注解。具体配置方法是,在你的Web应用的`web.xml`文件中,设置`<web-app>`元素的属性`metadata-complete="true"`,像下面这样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/60/9b54b6eebfe23017f4e90e7a16c97760.jpg" alt="">
|
||||
|
||||
`metadata-complete`的意思是,`web.xml`里配置的Servlet是完整的,不需要再去库类中找Servlet的定义。
|
||||
|
||||
## 配置Web-Fragment扫描
|
||||
|
||||
Servlet 3.0还引入了“Web模块部署描述符片段”的`web-fragment.xml`,这是一个部署描述文件,可以完成`web.xml`的配置功能。而这个`web-fragment.xml`文件必须存放在JAR文件的`META-INF`目录下,而JAR包通常放在`WEB-INF/lib`目录下,因此Tomcat需要对JAR文件进行扫描才能支持这个功能。
|
||||
|
||||
你可以通过配置`web.xml`里面的`<absolute-ordering>`元素直接指定了哪些JAR包需要扫描`web fragment`,如果`<absolute-ordering/>`元素是空的, 则表示不需要扫描,像下面这样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ff/8f/ff715ef5e61959bb8a17abd15681fc8f.jpg" alt="">
|
||||
|
||||
## 随机数熵源优化
|
||||
|
||||
这是一个比较有名的问题。Tomcat 7以上的版本依赖Java的SecureRandom类来生成随机数,比如Session ID。而JVM 默认使用阻塞式熵源(`/dev/random`), 在某些情况下就会导致Tomcat启动变慢。当阻塞时间较长时, 你会看到这样一条警告日志:
|
||||
|
||||
>
|
||||
<p>`<DATE>` org.apache.catalina.util.SessionIdGenerator createSecureRandom<br>
|
||||
INFO: Creation of SecureRandom instance for session ID generation using [SHA1PRNG] took [8152] milliseconds.</p>
|
||||
|
||||
|
||||
这其中的原理我就不展开了,你可以阅读[资料](https://stackoverflow.com/questions/28201794/slow-startup-on-tomcat-7-0-57-because-of-securerandom)获得更多信息。解决方案是通过设置,让JVM使用非阻塞式的熵源。
|
||||
|
||||
我们可以设置JVM的参数:
|
||||
|
||||
```
|
||||
-Djava.security.egd=file:/dev/./urandom
|
||||
|
||||
```
|
||||
|
||||
或者是设置`java.security`文件,位于`$JAVA_HOME/jre/lib/security`目录之下: `securerandom.source=file:/dev/./urandom`
|
||||
|
||||
这里请你注意,`/dev/./urandom`中间有个`./`的原因是Oracle JRE中的Bug,Java 8里面的 SecureRandom类已经修正这个Bug。 阻塞式的熵源(`/dev/random`)安全性较高, 非阻塞式的熵源(`/dev/./urandom`)安全性会低一些,因为如果你对随机数的要求比较高, 可以考虑使用硬件方式生成熵源。
|
||||
|
||||
## 并行启动多个Web应用
|
||||
|
||||
Tomcat启动的时候,默认情况下Web应用都是一个一个启动的,等所有Web应用全部启动完成,Tomcat才算启动完毕。如果在一个Tomcat下你有多个Web应用,为了优化启动速度,你可以配置多个应用程序并行启动,可以通过修改`server.xml`中Host元素的startStopThreads属性来完成。startStopThreads的值表示你想用多少个线程来启动你的Web应用,如果设成0表示你要并行启动Web应用,像下面这样的配置。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/ed/50e0c10a38c5e4a5e7a28ca1885698ed.jpg" alt="">
|
||||
|
||||
这里需要注意的是,Engine元素里也配置了这个参数,这意味着如果你的Tomcat配置了多个Host(虚拟主机),Tomcat会以并行的方式启动多个Host。
|
||||
|
||||
## 本期精华
|
||||
|
||||
今天我讲了不少提高优化Tomcat启动速度的小贴士,现在你就可以把它们用在项目中了。不管是在开发环境还是生产环境,你都可以打开Tomcat的启动日志,看看目前你们的应用启动需要多长时间,然后尝试去调优,再看看Tomcat的启动速度快了多少。
|
||||
|
||||
如果你是用嵌入式的方式运行Tomcat,比如Spring Boot,你也可以通过Spring Boot的方式去修改Tomcat的参数,调优的原理都是一样的。
|
||||
|
||||
## 课后思考
|
||||
|
||||
在Tomcat启动速度优化上,你都遇到了哪些问题,或者你还有自己的“独门秘籍”,欢迎把它们分享给我和其他同学。
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<audio id="audio" title="13 | 热点问题答疑(1):如何学习源码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/be/a2/beea7e78875c4dd8e30fc1ee337e72a2.mp3"></audio>
|
||||
|
||||
不知道你有没有留意到,不少高端开发岗位在招聘要求里往往会写这么一条:研究过框架和中间件源码的优先考虑。这是因为一切秘密都藏在源码之中,阅读源码会让我们对框架或者中间件的理解更加深刻。有时候即使你阅读了大量原理性的文档,但如果不看源码,可能仍然会觉得还没有理解透。另外如果你能深入源码,招聘者从侧面也能感觉到你的学习热情和探索精神。
|
||||
|
||||
今天我们就来聊聊源码学习这个话题。对于Java后端开发来说,有不少经典的开源框架和中间件,下面我帮你按照后端的分层架构整理出来供你参考。
|
||||
|
||||
- **服务接入层**:反向代理Nginx;API网关Node.js。
|
||||
- **业务逻辑层**:Web容器Tomcat、Jetty;应用层框架Spring、Spring MVC和Spring Boot;ORM框架MyBatis;
|
||||
- **数据缓存层**:内存数据库Redis;消息中间件Kafka。
|
||||
- **数据存储层**:关系型数据库MySQL;非关系型数据库MongoDB;文件存储HDFS;搜索分析引擎Elasticsearch。
|
||||
|
||||
这其中每一层都要支持水平扩展和高可用,比如业务层普遍采用微服务架构,微服务之间需要互相调用,于是就出现了RPC框架:Spring Cloud和Dubbo。
|
||||
|
||||
除此之外,还有两个非常重要的基础组件:Netty和ZooKeeper,其中Netty用于网络通信,ZooKeeper用于分布式协调。其实很多中间件都用到了这两个基础组件,并且ZooKeeper的网络通信模块也是通过Netty来实现的。
|
||||
|
||||
而这些框架或者中间件并不是凭空产生的,它们是在互联网的演化过程中,为了解决各种具体业务的痛点,一点一点积累进化而来的。很多时候我们把这些“零件”按照成熟的模式组装在一起,就能搭建出一个互联网后台系统。一般来说大厂都会对这些框架或者中间件进行改造,或者完全靠自己来实现。这就对后台程序员提出了更高的要求。
|
||||
|
||||
那这么多中间件和框架,从哪里入手呢?先学哪个后学哪个呢?我觉得可以先学一些你熟悉的,或者相对来说比较简单的,树立起信心后再学复杂的。比如可以先学Tomcat、Jetty和Spring核心容器,弄懂了这些以后再扩展到Spring的其他组件。
|
||||
|
||||
在这个过程中,我们就会积累一些通用的技术,比如网络编程、多线程、反射和类加载技术等,这些通用的技术在不少中间件和框架中会用到。
|
||||
|
||||
先说**网络通信**,在分布式环境下,信息要在各个实体之间流动,到处都是网络通信的场景,比如浏览器要将HTTP请求发给Web容器,一个微服务要调用另一个微服务,Web应用读写缓存服务器、消息队列或者数据库等,都需要网络通信。
|
||||
|
||||
尽管网络通信的场景很多,但无外乎都要考虑这么几个问题:
|
||||
|
||||
- I/O模型同步还是异步,是阻塞还是非阻塞?
|
||||
- 通信协议是二进制(gRPC)还是文本(HTTP)?
|
||||
- 数据怎么序列化,是JSON还是Protocol Buffer?
|
||||
|
||||
此外服务端的线程模型也是一个重点。我们知道多线程可以把要做的事情“并行化”,提高并发度和吞吐量,但是线程可能会阻塞,一旦阻塞线程资源就闲置了,并且会有线程上下文切换的开销,浪费CPU资源。而有些任务执行会发生阻塞,有些则不会阻塞,因此线程模型就是要决定哪几件事情放到一个线程来做,哪几件事情放到另一个线程来做,并设置合理的线程数量,目的就是要让CPU忙起来,并且不是白忙活,也就是不做无用功。
|
||||
|
||||
我们知道服务端处理一个网络连接的过程是:
|
||||
|
||||
accept、select、read、decode、process、encode、send。
|
||||
|
||||
一般来说服务端程序有几个角色:Acceptor、Selector和Processor。
|
||||
|
||||
- Acceptor负责接收新连接,也就是accept;
|
||||
- Selector负责检测连接上的I/O事件,也就是select;
|
||||
- Processor负责数据读写、编解码和业务处理,也就是read、decode、process、encode、send。
|
||||
|
||||
Acceptor在接收连接时,可能会阻塞,为了不耽误其他工作,一般跑在单独的线程里;而Selector在侦测I/O事件时也可能阻塞,但是它一次可以检测多个Channel(连接),其实就是用阻塞它一个来换取大量业务线程的不阻塞,那Selector检测I/O事件到了,是用同一个线程来执行Processor,还是另一个线程来执行呢?不同的场景又有相应的策略。
|
||||
|
||||
比如Netty通过EventLoop将Selector和Processor跑在同一个线程。一个EventLoop绑定了一个线程,并且持有一个Selector。而Processor的处理过程被封装成一个个任务,一个EventLoop负责处理多个Channel上的所有任务,而一个Channel只能由一个EventLoop来处理,这就保证了任务执行的线程安全,并且用同一个线程来侦测I/O事件和读写数据,可以充分利用CPU缓存。我们通过一张图来理解一下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/f4/f6741dc09985d5e08934ad77cd8c96f4.png" alt="">
|
||||
|
||||
请你注意,这要求Processor中的任务能在短时间完成,否则会阻塞这个EventLoop上其他Channel的处理。因此在Netty中,可以设置业务处理和I/O处理的时间比率,超过这个比率则将任务扔到专门的业务线程池来执行,这一点跟Jetty的EatWhatYouKill线程策略有异曲同工之妙。
|
||||
|
||||
而Kafka把Selector和Processor跑在不同的线程里,因为Kafka的业务逻辑大多涉及与磁盘读写,处理时间不确定,所以Kafka有专门的业务处理线程池来运行Processor。与此类似,Tomcat也采用了这样的策略,同样我们还是通过一张图来理解一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/e4/74f7742142740ae55bcda997a73a37e4.png" alt="">
|
||||
|
||||
我们再来看看**Java反射机制**,几乎所有的框架都用到了反射和类加载技术,这是为了保证框架的通用性,需要根据配置文件在运行时加载不同的类,并调用其方法。比如Web容器Tomcat和Jetty,通过反射来加载Servlet、Filter和Listener;而Spring的两大核心功能IOC和AOP,都用到了反射技术;再比如MyBatis将数据从数据库读出后,也是通过反射机制来创建Java对象并设置对象的值。
|
||||
|
||||
因此你会发现,通过学习一个中间件,熟悉了这些通用的技术以后,再学习其他的中间件或者框架就容易多了。比如学透了Tomcat的I/O线程模型以及高并发高性能设计思路,再学Netty的源码就轻车熟路了;Tomcat的组件化设计和类加载机制理解透彻了,再学Spring容器的源码就会轻松很多。
|
||||
|
||||
接下来我再来聊聊具体如何学习源码,有很多同学在专栏里问这个问题,我在专栏的留言中也提到过,但我觉得有必要展开详细讲讲我是如何学习源码的。
|
||||
|
||||
学习的第一步,首先我们要弄清楚中间件的核心功能是什么,我以专栏所讲的Tomcat为例。Tomcat的核心功能是HTTP服务器和Servlet容器,因此就抓住请求处理这条线:通过什么样的方式接收连接,接收到连接后以什么样的方式来读取数据,读到数据后怎么解析数据(HTTP协议),请求数据解析出来后怎么调用Servlet容器,Servlet容器又怎么调到Spring中的业务代码。
|
||||
|
||||
为了完成这些功能,Tomcat中有一些起骨架作用的核心类,其他类都是在这个骨架上进行扩展或补充细节来实现。因此在学习前期就要紧紧抓住这些类,先不要深入到其他细节,你可以先画出一张骨架类图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/9b/12ad9ddc3ff73e0aacf2276bcfafae9b.png" alt="">
|
||||
|
||||
在此之后,我们还需要将源码跑起来,打打断点,看看变量的值和调用栈。我建议用内嵌式的方式来启动和调试Tomcat,体会一下Spring Boot是如何使用Tomcat的,这里有[示例源码](https://github.com/heroku/devcenter-embedded-tomcat)。在源码阅读过程中要充分利用IDE的功能,比如通过快捷键查找某个接口的所有实现类、查找某个类或者函数在哪些地方被用到。
|
||||
|
||||
我们还要带着问题去学习源码,比如你想弄清楚Tomcat如何启停、类加载器是如何设计的、Spring Boot是如何启动Tomcat的、Jetty是如何通过Handler链实现高度定制化的,如果要你来设计这些功能会怎么做呢?带着这些问题去分析相关的源码效率会更高,同时你在寻找答案的过程中,也会碰到更多问题,等你把这些问题都弄清楚了,你获得的不仅仅是知识,更重要的是你会树立起攻克难关的信心。同时我还建议,在你弄清楚一些细节后要及时记录下来,画画流程图或者类图,再加上一些关键备注以防遗忘。
|
||||
|
||||
当然在这个过程中,你还可以看看产品的官方文档,熟悉一下大概的设计思路。在遇到难题时,你还可以看看网上的博客,参考一下别人的分析。但最终还是需要你自己去实践和摸索,因为网上的分析也不一定对,只有你自己看了源码后才能真正理解它,印象才更加深刻。
|
||||
|
||||
今天说了这么多,就是想告诉你如果理解透彻一两个中间件,有了一定的积累,这时再来学一个新的系统,往往你只需要瞧上几眼,就能明白它所用的架构,而且你会自然联想到系统存在哪些角色,以及角色之间的关系,包括静态的依赖关系和动态的协作关系,甚至你会不由自主带着审视的眼光,来发现一些可以改进的地方。如果你现在就是这样的状态,那么恭喜你,你的技术水平已经成长到一个新的层面了。
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user