简介
简而言之,Tomcat是一个免费的开放源代码的Web应用服务器,属于轻量级应用服务器。
Apache Tomcat
Tomcat是Apache 软件基金会(Apache Software Foundation)的Jakarta 项目中的一个核心项目,由Apache、Sun 和其他一些公司及个人共同开发而成。由于有了Sun 的参与和支持,最新的Servlet 和JSP 规范总是能在Tomcat 中得到体现,Tomcat 5支持最新的Servlet 2.4 和JSP 2.0 规范。因为Tomcat 技术先进、性能稳定,而且免费,因而深受Java 爱好者的喜爱并得到了部分软件开发商的认可,成为比较流行的Web 应用服务器。
官方网站:tomcat.apache.org
Tomcat经常在并发量不是很高的中小企业项目中使用。
其作用主要是:
- 1)管理servlet应用的生命周期;
- 2)把客户端请求的url映射到对应的servlet;
- 3)于servlet程序协同处理HTTP请求;
WEB服务器
现状:业界现有的web服务器有JBOSS、WebLogic、Websphere、Apache、Nginx、Jetty和Undertow。
痛点:
- Weblogic和Websphere都是收费软件且价格不菲;
- JBoss核心服务不包括servlet/JSP的WEB容器;
- Jetty使用场景一般是处理大量连接并且长时间保持这些连接;
- Undertow更适合用于IO密集型服务器或者文件服务器;
Apache和Nginx都是高性能的web服务器,在处理少数非常繁忙的连接上Tomcat的总体性能更高。
Tomcat架构
Tomcat总体结构如下图所示,
最外层到内层依次是服务器(Server)、服务(Service)、引擎(Engine)、虚拟主机(Host)、上下文容器/应用(Context/Application),其作用和含义下面一一解释,
- Server:代表Tomcat本身,用来接收客户端发发请求数据并解析,启动service并监听端口等;
- Service:负责管理一个Servlet,包括Servlet的装载、初始化、执行以及资源回收;
- Engine:表示可运行的Catalina的Servlet引擎实例,负责请求的处理;
- Host:WEB应用容器或者Tomcat中所说的上下文虚拟主机;
- Context:也可以看作是Web应用。Servlet的上下文,具备了Servlet运行的基本环境,它表示Web应用程序本身;
- Connector:Tomcat与外部世界的连接器,监听固定端口接收外部请求,传递给Container,并将Contrainer处理的结果返回给外部;
- Container:Catalina、Servlet容器,内部有多层容器组成,用于管理Servlet生命周期,调用Servlet相关方法;
- Wrapper:包装器,它是四大容器最底层的容器,是请求处理的真正容器。它与Servlet的区别是它本质并未处理请求,而是加载对应的Servlet来处理,在这之前也会调用filter。
目录结构
tomcat项目克隆地址:https://gitclone.com/github.com/apache/tomcat
其项目目录结构如下,
- bin:启动和关闭 tomcat 的脚本文件,运行点击 startup.bat 文件,关闭点击 shutdown.bat 文件(.bat针对windows系统,.sh针对linux系统)。
- conf:存放 tomcat 服务器的各种配置文件。
- lib:存放 Tomcat 服务器和所有 web 应用程序需要访问的 jar 文件。
- log:存放 tomcat 日志文件。
- webapps:当发布 web 应用程序时,通常吧 web 应用程序的目录及文件放到这个目录下。
- java:包含jakarta和org相关的java文件;
- modules:tomcat核心模块;
- test:tomcat测试文件;
核心功能
Tomcat有2个核心功能:
- 处理Socket连接,负责网络字节流与Request和Response对象的转化。
- 加载和管理Servlet,以及具体处理Request请求。
Tomcat支持多种I/O模型和应用层协议。
其中,Tomcat支持的I/O模型有:
- NIO(Non-Blocking I/O):非阻塞I/O,采用Java NIO类库实现。
- NIO.2(Non-Blocking I/O.2):异步I/O,采用JDK 7最新的NIO.2类库实现。
- APR(Address Resolution Protocol):采用Apache可移植运行库实现,是C/C++编写的本地库。
Tomcat支持的应用层协议有:
- HTTP(Hypertext Transfer Protocol)/1.1:这是大部分Web应用采用的访问协议。
- AJP(Apache JServ Protocol):定向包协议,用于与Web服务器集成(如Apache)。
- HTTP/2:HTTP 2.0大幅度的提升了Web性能。
Tomcat为了实现支持多种I/O模型和应用层协议,一个容器可能对接多个连接器,就好比一个房间有多个门。但是单独的连接器或者容器都不能对外提供服务,需要把它们组装起来才能工作,组装后这个整体叫作Service组件。这里请你注意,Service本身没有做什么重要的事情,只是在连接器和容器外面多包了一层,把它们组装在一起。Tomcat内可能有多个Service,这样的设计也是出于灵活性的考虑。通过在Tomcat中配置多个Service,可以实现通过不同的端口号来访问同一台机器上部署的不同应用。
从图上你可以看到,最顶层是Server,这里的Server指的就是一个Tomcat实例。一个Server中有一个或者多个Service,一个Service中有多个连接器和一个容器。连接器与容器之间通过标准的ServletRequest和ServletResponse通信。
Tomcat核心组件------连接器(Connector)
连接器对Servlet容器屏蔽了协议及I/O模型等的区别,无论是HTTP(Hypertext Transfer Protocol)还是AJP(Apache JServ Protocol),在容器中获取到的都是一个标准的ServletRequest对象。
我们可以把连接器的功能需求进一步细化,比如:
- 监听网络端口;
- 接受网络连接请求;
- 读取网络请求字节流;
- 根据具体应用层协议(HTTP/AJP)解析字节流,生成统一的Tomcat Request对象;
- 将Tomcat Request对象转成标准的ServletRequest;
- 调用Servlet容器,得到ServletResponse;
- 将ServletResponse转成Tomcat Response对象;
- 将Tomcat Response转成网络字节流;
- 将响应字节流写回给浏览器;
高内聚:是指相关度比较高的功能要尽可能集中,不要分散。
低耦合:是指两个相关的模块要尽可能减少依赖的部分和降低依赖的程度,不要让两个模块产生强依赖。
通过分析连接器的详细功能列表,我们发现连接器需要完成3个高内聚的功能:
- 网络通信,对应组件Endpoint。
- 应用层协议解析,对应组件Processor。
- Tomcat Request/Response与ServletRequest/ServletResponse的转化,对应组件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。Http11NioProtocol的无参构造函数中调用了父类的有参构造函数方法并传入了NioEndpoint实例。
java
public class Http11NioProtocol extends AbstractHttp11JsseProtocol<NioChannel> {
private static final Log log = LogFactory.getLog(Http11NioProtocol.class);
public Http11NioProtocol() {
super(new NioEndpoint());
}
@Override
protected Log getLog() { return log; }
// -------------------- Pool setup --------------------
public void setSelectorTimeout(long timeout) {
((NioEndpoint)getEndpoint()).setSelectorTimeout(timeout);
}
public long getSelectorTimeout() {
return ((NioEndpoint)getEndpoint()).getSelectorTimeout();
}
public void setPollerThreadPriority(int threadPriority) {
((NioEndpoint)getEndpoint()).setPollerThreadPriority(threadPriority);
}
public int getPollerThreadPriority() {
return ((NioEndpoint)getEndpoint()).getPollerThreadPriority();
}
// -------------------- JMX related methods
@Override
protected String getNamePrefix() {
if (isSSLEnabled()) {
return "https-" + getSslImplementationShortName()+ "-nio";
} else {
return "http-nio";
}
}
}
除了这些变化点,系统也存在一些相对稳定的部分,因此Tomcat设计了一系列抽象基类来 封装这些稳定的部分,抽象基类AbstractProtocol实现了ProtocolHandler接口。每一种应用层协议有自己的抽象基类,比如AbstractAjpProtocol和AbstractHttp11Protocol,具体协议的实现类扩展了协议层抽象基类。下面我整理一下它们的继承关系。
通过上面的图,可以清晰地看到它们的继承和层次关系,这样设计的目的是尽量将稳定的部分放到抽象基类,同时每一种I/O模型和协议的组合都有相应的具体实现类,我们在使用时可以自由选择。
小结一下,连接器模块用三个核心组件:Endpoint、Processor和Adapter来分别做三件事情,其中Endpoint和Processor放在一起抽象成了ProtocolHandler组件,它们的关系如下图所示。
下面详细介绍这两个顶层组件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)
Processor
如果说Endpoint是用来实现TCP/IP协议的,那么Processor用来实现HTTP协议,Processor接收来自Endpoint的Socket,读取字节流解析成Tomcat Request和Response对象,并通过Adapter将其提交到容器处理,Processor是对应用层协议的抽象。
Processor是一个接口,定义了请求的处理等方法。它的抽象实现类AbstractProcessor对一些协议共有的属性进行封装,没有对方法进行实现。具体的实现有AjpProcessor、Http11Processor等,这些具体实现类实现了特定协议的解析方法和请求处理方式。
我们再来看看连接器的组件图:
从图中我们看到,Endpoint接收到Socket连接后,生成一个SocketProcessor任务提交到线程池去处理,SocketProcessor的run方法会调用Processor组件去解析应用层协议,Processor通过解析生成Request对象后,会调用Adapter的Service方法。
Adapter组件
前面说过,由于协议不同,客户端发过来的请求信息也不尽相同,Tomcat定义了自己的Request类来"存放"这些请求信息。ProtocolHandler接口负责解析请求并生成Tomcat Request类。但是这个Request对象不是标准的ServletRequest,也就意味着,不能用Tomcat Request作为参数来调用容器。Tomcat设计者的解决方案是引入CoyoteAdapter,这是适配器模式的经典运用,连接器调用CoyoteAdapter的sevice方法,传入的是Tomcat Request对象,CoyoteAdapter负责将Tomcat Request转成ServletRequest,再调用容器的service方法。
Tomcat核心组件------容器(Container)
Tomcat设计了4种容器,分别是Engine、Host、Context和Wrapper。这4种容器不是平行关系,而是父子关系。比如下图,你就能看出他们的关系:
你可能会问,为什么要设计成这么多层次的容器,这不是增加了复杂度吗?其实这背后的考虑是, Tomcat通过一种分层的架构,使得Servlet容器具有很好的灵活性。
- Context表示一个Web应用程序;
- Wrapper表示一个Servlet,一个Web应用程序中可能会有多个Servlet;
- Host代表的是一个虚拟主机,或者说一个站点,可以给Tomcat配置多个虚拟主机地址,而一个虚拟主机下可以部署多个Web应用程序;
- Engine表示引擎,用来管理多个虚拟站点,一个Service最多只能有一个Engine。
你可以再通过Tomcat的 server.xml 配置文件来加深对Tomcat容器的理解。Tomcat采用了组件化的设计,它的构成组件都是可配置的,其中最外层的是Server,其他组件按照一定的格式要求配置在这个顶层容器中。配置层级关系如下所示,
xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- 顶层组件,可以包含多个Service,监听端口为8005(该端口负责监听关闭tomcat的请求)-->
<Server port="8005" shutdown="SHUTDOWN">
<!-- 监听器组件,输出一些运行日志,如操作系统、JDK、Tomcat版本信息以及catalina.base、catalina.home的定义等 -->
<Listener className="org.apache.catalina.startup.VersionLoggerListener" />
<!-- 在Tomcat初始化前,AprLifecycleListener尝试初始化APR库,如果初始化成功,则使用APR接收并处理客户端的请求。
在Tomcat销毁后,AprLifecycleListener会对APR做一些销毁终止操作。 -->
<Listener className="org.apache.catalina.core.AprLifecycleListener" SSLEngine="on" />
<!-- 在Tomcat初始化时使用系统类加载器预先加载一些JRE的类和设置URLConnection缓存禁用属性,以避免线程上下文类加载器
Tomcat自定义的WebAppClassLoader时,加载JRE导致的内存泄漏和URLConnection缓存导致的锁文件问题。 -->
<Listener className="org.apache.catalina.core.JreMemoryLeakPreventionListener" />
<!-- 在Tomcat启动时为JNDI创建MBean,停止时销毁MBean。 -->
<Listener className="org.apache.catalina.mbeans.GlobalResourcesLifecycleListener" />
<!-- 监听Context停止后,销毁连接器Connector中Executor的所有核心工作线程,并重新创建,以避免使用ThreadLocal带来的内存泄漏。 -->
<Listener className="org.apache.catalina.core.ThreadLocalLeakPreventionListener" />
<!-- GlobalNamingResources全局命名资源,通过JNDI(Java Naming and Directory Interface)提供统一的命名对象访问接口。 -->
<GlobalNamingResources>
<Resource name="UserDatabase" auth="Container"
type="org.apache.catalina.UserDatabase"
description="User database that can be updated and saved"
factory="org.apache.catalina.users.MemoryUserDatabaseFactory"
pathname="conf/tomcat-users.xml" />
</GlobalNamingResources>
<!-- 顶层组件,可以包含一个Engine和多个连接器Connector -->
<Service name="Catalina">
<!-- 连接器组件,连接器Connector代表通信端口,它负责接收客户请求,以及向客户返回响应结果。-->
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />
<!-- 容器组件,一个Engine组件处理Service中的所有请求,包括多个Host-->
<Engine name="Catalina" defaultHost="localhost">
<!-- Realm其实就是一个存放用户名,密码及角色的一个"数据库" -->
<Realm className="org.apache.catalina.realm.LockOutRealm">
<Realm className="org.apache.catalina.realm.UserDatabaseRealm"
resourceName="UserDatabase"/>
</Realm>
<!-- 容器组件,处理特定Host下客户请求,可包含多个Context -->
<Host name="localhost" appBase="webapps"
unpackWARs="true" autoDeploy="true">
<Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs"
prefix="localhost_access_log" suffix=".txt"
pattern="%h %l %u %t "%r" %s %b" />
</Host>
</Engine>
</Service>
</Server>
那么,Tomcat是怎么管理这些容器的呢?你会发现这些容器具有父子关系,形成一个树形结构,你可能马上就想到了设计模式中的组合模式。没错,Tomcat就是用组合模式来管理这些容器的。具体实现方法是,所有容器组件都实现了Container接口,因此组合模式可以使得用户对单容器对象和组合容器对象的使用具有一致性。这里单容器对象指的是最底层的Wrapper,组合容器对象指的是上面的Context、Host或者Engine。Container接口定义如下:
java
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子容器。每个容器都有对应的访问路径,可以通过下面这张图来帮助理解。
假如有用户访问一个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路径匹配规则:
1)精确匹配:标签里的配置与URL精确匹配;
2)扩展名匹配:标签里的配置以
*.
开头的字符串;3)路径匹配:标签里的配置以
/
字符开头,并以/*
结尾的字符串;4)缺省匹配:标签里的配置为
/
,默认匹配规则;
- 根据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接口中的关键方法。
java
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接口:
java
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:
java
// Calling the container
connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
Wrapper容器的最后一个Valve会创建一个Filter链,并调用doFilter方法,最终会调到Servlet的service方法。
你可能会问,Filter似乎也有相似的功能,那Valve和Filter有什么区别吗?它们的区别是:
Valve是Tomcat的私有机制,与Tomcat的基础架构/API是紧耦合的。Servlet API是公有的标准,所有的Web容器包括Jetty都支持Filter机制。
另一个重要的区别是Valve工作在Web容器级别,拦截所有应用的请求;而Servlet Filter工作在应用级别,只能拦截某个Web应用的所有请求。如果想做整个Web容器的拦截器,必须通过Valve来实现。
其他补充
Tomcat的整体架构包含了两个核心组件连接器和容器。连接器负责对外交流,容器负责内部处理。连接器用ProtocolHandler接口来封装通信协议和I/O模型的差异,ProtocolHandler内部又分为Endpoint和Processor模块,Endpoint负责底层Socket通信,Processor负责应用层协议解析。连接器通过适配器Adapter调用容器。
Tomcat设计了多层容器是为了灵活性的考虑,灵活性具体体现在一个Tomcat实例(Server)可以有多个Service,每个Service通过多个连接器监听不同的端口,而一个Service又可以支持多个虚拟主机。一个URL网址可以用不同的主机名、不同的端口和不同的路径来访问特定的Servlet实例。
请求的链式调用是基于Pipeline-Valve责任链来完成的,这样的设计使得系统具有良好的可扩展性,如果需要扩展容器本身的功能,只需要增加相应的Valve即可。
通过对Tomcat整体架构的学习,我们可以得到一些设计复杂系统的基本思路。首先要分析需求,根据高内聚低耦合的原则确定子模块,然后找出子模块中的变化点和不变点,用接口和抽象基类去封装不变点,在抽象基类中定义模板方法,让子类自行实现抽象方法,也就是具体子类去实现变化点。
参考链接: