本章来自深入剖析Tomcat的阅读心得,供以后的面经和个人复习使用。
1. StandardWrapper
一、请求处理流程
关键组件
- Connector: 负责构造好Request和Response,然后连接到一个web容器,进行处理。
- Context: 经典的web容器,负责调用Wrapper,提供Mapper, Loader, Realm, Session, Logger等基础组件的能力
- Wrapper: Servlet的简单包装,对外提供了 Servlet的 加载(load()), 初始化(init()), 服务(service()), 卸载(unload()) 全流程。
逻辑执行的入口是 Pipeline 对象。容器一般都有一个配套的阀对象,在容器构造时 就会加入到Pipeline。
容器负责高层的处理,组件和子容器的管理,而Valve对象则是真正负责处理请求。
二、Servlet和多线程
经常能听到 "无状态Servlet" 是安全的,Tomcat是如何把多线程技术和Servlet应用的呢?
实际上,这取决于标记接口SingleThreadModel, 由于其名字有误解,被弃用了。
这个接口的真正含义是:Servlet保证 不会有多个线程,访问同一个Servlet实例的service()方法。
真正的实现有两种 1. 严格控制Servlet实例只能被一个线程访问 2. 初始化一个Servlet实例池,每个线程持有一个Servlet实例。
// 1. 严格同步的Servlet实现: 真正意义上的 SingleThreadModel
Servlet servlet = getContainer().allocate();
// 如果实现了标记接口 SingleThreadModel 则在执行service()时进行控制
if (servlet instanceof SingleThreadModel) {
synchronized(servlet) {
servlet.service();
}
}
// 2. 高性能的实现: 只保证 一个Servlet实例严格被一个线程调用
// 这个模型里,如果每个Servlet实例都访问同一个静态区域,可能导致线程不安全。
ServletPoll pool = xxx; // 实际实现上,是一个Stack<Servlet>
if (servlet instanceof SingleThreadModel) {
// 从对象池里取Servlet,如果取不到就等待
synchronized(pool) {
if (能取到对象) {
return pool.pop();
} else {
// 阻塞等待
pool.wait();
}
}
}
注意: 这里的线程不安全,指的是Servlet访问外部暴露的数据。如果Servlet本身是有状态的,该状态是线程安全的,因为 该模型下,每个线程都持有单独的Servlet实例,这个实例是单线程的。
总结下就是,SingleThreadModel只保证 Servlet自己的状态是线程安全的,如果Servlet访问外部的共享变量,是不安全的,除非这个共享变量本身支持线程安全的访问。
回顾: Connector会维护一个Processor实例池,每个Processor实例都在自己的线程中。
考虑 请求A和请求B都想获取 同一个Servlet,那么入口是 HttpProcessor#process(),这个方法会调用connector.getContainer().invoke(request, response);,将请求交给Context的pipeline处理。pipeline最终会执行到 StandardWrapperValve#invoke()
因此,肯定存在一个时刻,多个线程同时执行相同的Servlet的service方法。如果Servlet不实现SingleThreadModel接口,HttpServlet#service可能被多个线程访问,可想而知有线程安全问题。
所以,如果让Servlet是线程安全的,就得把Servlet设计为无状态的。
三、Wrapper是如何维护Servlet实例的
Wrapper负责 Servlet对象 从部署包里的字节码,到真正运行执行的全流程控制,那么具体是如何控制的呢?
- 如果Servlet没实现 SingleThreadModel 接口,直接从
loadServlet()方法获取。 - 否则 从 ServletInstancePool里获取,这个Pool被实现为一个Stack。
那么,Servlet的载入过程具体是什么?
- 如果是非 SingleThreadModel,并且已经载入过了,直接返回实例。
- 否则开始Servlet的载入逻辑
2.1 获取Servlet的类加载器
2.2 根据 (2.1) 获取到的classloader,结合Servlet的全限定名,进行加载,得到class对象
tomcat的设计中,如果需要运行时加载一些组件,一般都是传入全限定名
2.3 通过 (2.2) 得到的class对象,进行实例化,fire一个BEFORE_INIT_EVENT, 并调用初始化方法。
实例化之前会安全检查,只有通过了才能实例化成功。
Tomcat支持 Servlet感知其所在的容器环境,即Servlet可以调用Wrapper的能力。如果我们声明自己的Servlet是这样的Servlet,实例化期间,Tomcat会将Wrapper注入到Servlet实例中。
2.4 初始化的过程
如果是STM的 Servlet,初始化后会把实例放到 ServletPool里。这个Pool可以视为 多个Servlet实例的副本。这种多线程下访问对象实例副本的 "类似原型模式"的设计,是经典多线程编程设计。
四、解密Servlet的初始化流程
Servlet的初始化需要传入一个 ServletConfig
实际上,StandardWrapper自己就实现了 ServletConfig接口。但是我们观察,StandardWrapper给Servlet的init()方法传入的是 new StandardWrapperFacade(this);
这里又用Facade,原因不必多言。
主要是避免老六把servletConfig实例强转为 StandardWrapper, 直接访问容器细节
但是,前面我们提到一种Servlet,可以知道容器细节,Wrapper在初始化阶段会把this传给Servlet。
这两个是不一样的,前者是一种规范的做法,我们看这个方法的实现
private boolean isContainerProvidedServlet(String classname) {
if (classname.startsWith("org.apache.catalina.")) {
return (true);
}
try {
Class clazz =
this.getClass().getClassLoader().loadClass(classname);
return (ContainerServlet.class.isAssignableFrom(clazz));
} catch (Throwable t) {
return (false);
}
}
不难发现,tomcat只给内部的Servlet开后门,内部的Servlet可以感知容器细节(得到的就是StandardWrapper),但是对于程序员而言,得到的只是StandardWrapperFacade。
这个StandardWrapperFacade 只暴露了 ServletConfig接口的方法实现,当然这个实现就是委托给config对象完成。
该接口提供了四个方法,不难理解是为了让容器感知Context对象的存在、
- getServletContext
会调用其父容器(context)的getServletContext()。熟悉tomcat的应该已经猜到了,context会返回一个Facade对象。
-
getServletName
-
getInitParameter
tomcat可以为容器注入一些初始化参数,这些参数会保存在wrapper实例的一个hashmap里。
要注意,一个Wrapper里的servlet可能在多线程内访问,所以对这个hashmap的访问需要上锁。
- getInitParameterName
被保存在枚举中
五、Wrapper父子容器关系
Wrapper作为容器的最底层,不能再有子容器,所以其addChild方法会直接扔出一个异常
Wrapper的上层容器只能是Context,所以其setParent方法会校验传入的参数是不是Context的实例。
六、Wrapper的业务逻辑在请求中执行。
说了这么多,Wrapper给我们的印象仍然是一个容器,那么Wrapper是怎么在请求期间被访问的呢?
回顾: Wrapper在初始化时(构造函数), 会把StandardWrapperValve加入到其父容器的pipeline里。所以这个Valve的功能就代表了wrapper处理请求的逻辑。
学习tomcat时,我们知道JavaEE的三大组件是 Servlet, Filter和Listner。Servlet和Filter在 StandardWrapperValve实现。tomcat既然作为web容器,务必要对这些组件提供支持。
七、Tomcat对Filter的支持。
- Filter可以进行配置,配置后的Filter会被收集为
FilterDef对象。
这个对象就是Filter的配置对象,包括Filter的名字,配置的参数信息等。
- ApplicationFilterConfig
负责管理Filter的生命周期
Filter加载流程
2.1: 当前filter是不是org.apache.cataline包下的实例,如果是则调用加载 ApplicationFilterConfig的类的加载器来加载。
2.2: 如果是用户自定义的Filter,则直接用Wrapper容器的Loader加载。
2.3: 反射实例化出filter实例
2.4 调用filter的初始化方法进行初始化。
- ApplicationFilterChain
会根据createFilterChain传进来的所有过滤器,依次执行doFilter(),如果没有了,就执行servlet.service()
八、总结
StandardWrapper, StandardWrapperValve两个类,实现了Servlet容器的基本功能。
前者负责管理Servlet对象的全生命周期,后者则提供了 javaEE三大件中,Servlet和Filter的支持。
2. StandardContext类
Wrapper只负责其对应的Servlet的加载, 实例化, 初始化, 引用计数维护, 析构, 卸载维护。但是Context要考虑的就很多了。
这一章,我们从StandardContext的生命周期分析
一、构造StandardContext(instantiation)
我们简单区分下实例化和初始化,这里通过new或者反射获取一个对象,并调用对象的构造函数,这个过程我们叫做实例化。
尽管构造函数底层是"初始化器",我们仍然认为构造过程属于实例化阶段。而初始化则是通过额外配置的init-param属性等、
同样,在构造期间,StandardContext会把配套的阀对象(StandardContextValve)设置为pipeline的基础阀(BasicValve)
二、StandardContext: start
这个过程非常庞大,需要初始化所有的子容器和组件。
- 配置(交给其他组件完成,通过事件回调。)
这里留几个疑惑点
- 谁监听了BEFORE_START_EVENT事件,进行相关配置,配置了什么?
最后,我们也能看到事件回调的好处,配置工作和context的start工作同时进行。
三、invoke()
context#invoke() 就是真正做事的方法了。
如果有HostContainer,则由Host调用,否则由Connector调用。
调用链
- StandardContext#invoke()
- ContainerBase#invoke()
- pipeline.invoke()
- basicValve.invoke()
- StandardContextValve#invoke()
该方法检查 是否处于类重载阶段,如果在重载,则等一会。 是否重载由变量paused 决定。
invoke的真正实现位于 StandardContextValve
四、Tomcat的Mapper体系
- ContainerBase#addDefaultClass(className)
该方法负责把Mapper类加载进来, 并进行基础的配置,然后实例化之。
tomcat的很多方法,在加载类的时候,都是通过全类名。
好处在于,tomcat实际上覆盖了大多数加载点,这些类在加载时,可以用tomcat构造好的类加载器。这些加载器一般都是定制化的。
- StandardContext#start()在启动阶段,会调用 ContainerBase#addDefaultClass(className),此时Mapper被加载进来。
不难理解,这里className是一个常量,名字就是org.apache.catalina.core.StandardContextMapper。
所以,在容器启动时,StandardContext把加载委托给父类方法,父方法根据传入的默认Mapper类进行实例化。
- ContainerBase#addMapper
注意,这里会把mapper关联到this上,如果是StandardContextMapper对象,会检查传入进来的容器是不是Context,如果不是则报错。
- StandardContextMapper#map()
根据请求对象,找到对应的Servlet(Wrapper)
其实现就是解析请求行,然后找ServletName对不对上
public Container map(Request request, boolean update) {
// 1. 找到项目的根路径, 请求url和相对url
// 比如根路径是 /demo, 请求url是 /demo/hello, 那么relativeURI就是/hello
String contextPath =
((HttpServletRequest) request.getRequest()).getContextPath();
String requestURI = ((HttpRequest) request).getDecodedRequestURI();
String relativeURI = requestURI.substring(contextPath.length());
// Apply the standard request URI mapping rules from the specification
Wrapper wrapper = null;
String servletPath = relativeURI;
String pathInfo = null;
String name = null;
// 2. 开始匹配
// 2.1 精准匹配
if (wrapper == null) {
if (!(relativeURI.equals("/")))
// 根据uri模式 https://blog.csdn.net/chenwiehuang/article/details/52275396
// 这个模式就是 web.xml里配置的 <url-pattern> 标签
// 这个标签会关联到一个servlet-name,那么url-pattern和servlet-name的映射就存在这个ServletMapping里
name = context.findServletMapping(relativeURI);
if (name != null)
wrapper = (Wrapper) context.findChild(name);
if (wrapper != null) {
servletPath = relativeURI;
pathInfo = null;
}
}
// 2.2 前缀匹配, 这里如果路径信息在后面没找到,比如请求/demo/aaa, 这个aaa没有配置,就会路由到/*对应的servlet
if (wrapper == null) {
servletPath = relativeURI;
while (true) {
name = context.findServletMapping(servletPath + "/*");
if (name != null)
wrapper = (Wrapper) context.findChild(name);
if (wrapper != null) {
// 找到请求路径的具体路径
pathInfo = relativeURI.substring(servletPath.length());
if (pathInfo.length() == 0)
pathInfo = null;
break;
}
int slash = servletPath.lastIndexOf('/');
if (slash < 0)
break;
servletPath = servletPath.substring(0, slash);
}
}
// 2.3 精准匹配
if (wrapper == null) {
int slash = relativeURI.lastIndexOf('/');
if (slash >= 0) {
String last = relativeURI.substring(slash);
// 古早的tomcat风格的url里,存在 xxx.yyy这样的格式
int period = last.lastIndexOf('.');
if (period >= 0) {
String pattern = "*" + last.substring(period);
name = context.findServletMapping(pattern);
if (name != null)
wrapper = (Wrapper) context.findChild(name);
if (wrapper != null) {
servletPath = relativeURI;
pathInfo = null;
}
}
}
}
// 2.4 默认匹配
if (wrapper == null) {
name = context.findServletMapping("/");
if (name != null)
wrapper = (Wrapper) context.findChild(name);
if (wrapper != null) {
servletPath = relativeURI;
pathInfo = null;
}
}
if (update) {
request.setWrapper(wrapper);
((HttpRequest) request).setServletPath(servletPath);
((HttpRequest) request).setPathInfo(pathInfo);
}
return (wrapper);
}
五、Tomcat的重载机制
StandardContext 通过 loadable 变量,和WebAppLoader类进行双向绑定。如果loadable从true->false,则关闭reload线程,否则开启。
简单来说,WebAppClassLoader#modified() 根据时间戳和一个默认的 "认为更新时间" 判断该类有没有被修改。
WebAppLoader类是一个线程
public void run() {
// Loop until the termination semaphore is set
while (!threadDone) {
threadSleep();
if (!started)
break;
try {
if (!classLoader.modified())
continue;
} catch (Exception e) {
continue;
}
notifyContext();
break;
}
如果检查到类发生了修改,则通知给Context对象。
protected class WebappContextNotifier implements Runnable {
/**
* Perform the requested notification.
*/
public void run() {
((Context) container).reload();
}
}
当然,最终就是单独启一个线程,调用StandardContext#reload
这个方法的实现,就是把能停的组件都停了,然后再启动一遍。启动过程中自然会触发类的加载。
六、tomcat5对线程工作的优化
由于组件有创建线程的需求,tomcat5将这个过程封装成一个方法。
七、总结
由于tomcat组件化和父子容器的设计,通篇大量使用组合技术。因此一个上层容器的初始化,会导致大量的组件初始化。此时生命周期就起到了直观作用。
另外,tomcat的初始化和配置交给不同的类来做,配置对象完成配置后,异步设置context实例的configured变量,异步解耦。
最后,业务逻辑实现在配套的valve对象,整个入口是invoke()方法,我们也见识到了相比Wrapper在web处理上最重要的组件: mapper的简单实现。
当然,tomcat的多数组件都需要一个线程来干自己的工作,tomcat也对其进行了封装。
3. Host和Engine
首先带着疑问去读这章
- Context这个容器已经具备了请求分发,session管理,以及大多数web请求处理的能力,为什么需要Host
- 目前容器父子层级(理解tomcat用组合实现这样的层级关系)为: Engine->Host->Context->Wrapper,Engine,Host,Context的层级关系具体是什么,相比Context增加了哪些功能?
和分析Context一样,我们按照
- Host的继承体系
- Host的实例化
- Host的start()方法
- Host在invoke()的时候干了啥,来分析。
一、Tomcat的Host体系
ctrl + alt + u 展示类的diagram
换汤不换药的组件还是那几个
- Pipeline, 充当执行链的组件
- Lifecycle,提供统一的生命周期管理和事件传播机制
- ContainerBase, 其invoke()会调用pipeline.invoke(),这个在context也讨论过。同时集成了父子容器管理的功能。
Host接口:
主要是 别名(alias)的管理,部署(deploy)相关的管理,绑定默认Context,还有最重要的map()方法。根据请求对象找到一个处理器。
我们回顾下,之前不是有一个StandardContextMapper实例吗,为什么这里有需要一个新的Mapper,新Mapper做了什么?
alt + 7 查看类的所有方法
StandardHost类:
作为默认的Host实现
- 实例化
不用多想,肯定是把某个valve加到pipeline里了。
- start()方法
- invoke()方法
Host会直接用ContainerBase#invoke()执行逻辑,而其父类的invoke()只是调用pipeline.invoke()
- map()方法。
容器start()阶段,会根据指定的mapperClass 的类全限定名,注册一个Mapper实例。
private String mapperClass =
"org.apache.catalina.core.StandardHostMapper";
可以看到这个类是 StandardHostMapper,我们猜测这个Mapper只能绑定到Host容器上
那么,这个map的作用就是: 由于同一个Host 可能有多个Context, 每个context都有自己的StandardContextMapper组件,我们说这个组件就是根据请求行和配置文件进行Servlet的分发。所以StandardHostMapper实际上就是多加了一层分发到具体的context的逻辑。
但是map方法的实际入口是一个携带两个参数的map,这个map会把map方法委托给StandardHostMapper, 然后StandardHostMapper简单调用StandardHost的map方法。
这个设计在之前也比较常见,也就是组件作为调用的入口,真正的实现在容器对象上。可以理解,组件可能有很多实例,可以通过get/set进行插拔,但是一些通用逻辑放在哪里呢? tomcat的设计者把这些通用的逻辑放在了组件所在的容器实例上。
比如map的实现放在Host上,其Mapper组件会调用该方法。
回顾下StandardHost#start(), 最后一步调用了super.start(), 而ContainerBase#start()里调用了addDefaultMapper, 负责把mapperClass反射加载进来。
但是StandardContainer#start()却不是这个逻辑,它完全的重写了父类的start()。
所以,尽管父类提供了start的很多模板流程,但如果子类需要更大的定制空间,还是需要把start完全重写的。相反如果子类需要父类提供的能力就行,那么可以把重复的操作委托给父类实现。
二、StandardHostValve对象
- 通过map方法找到真正要访问的
context容器 - 进行前置处理,比如调用
session.access()刷新session对象的lastAccessTime - 调用context容器的invoke()方法处理。
三、Host的意义
实际上,一个Host就是一个虚拟主机。
这里不引入额外的复杂概念,简单理解就是。
虚拟主机,在tomcat的语义下,就是一台物理机,配置多个域名,不同域名会在http请求体里携带Host请求头,而tomcat正是根据该请求头分发到指定的Host对象。
好处就是一台主机可以配置多个域名,但现在其实也不太缺计算资源,而且现在推崇云原生,也别管是不是一个物理机了,反正只要是物理机的一部分,都可以视为整个物理机。
比如我们用8个1c2g的机器弄一个集群,可以得到最多8c16g的基础设施池。我们可以管这个池子要一个1c1g的资源,作为虚拟主机HostA的部署,再要一个2c2g的部署HostB。
云原生环境提供的资源隔离能力,肯定比tomcat原生提供的虚拟主机能力要强。简单概括,tomcat就是单纯的把请求分发给指定的context,通过请求体的Host头
Tomcat部署及虚拟主机配置与优化_tomcat安装及配置教程虚拟机-CSDN博客
所以,见上文。我们可以配置多个Host,可以让tomcat在同一个物理机上提供针对不同host的服务。
这里又有一个八股文: 我们知道tomcat把多个context都加载进来,如果contextA依赖某个第三方库的A版本,contextB依赖B版本,我们知道如果两个context容器都采取双亲委派机制,肯定是不行的,所以每个context都有自己的Loader。
四、Engine体系
对于Engine, StandardEngine, StandardEngineValve 这几个类型,换汤不换药的在Host上又包装了一层。
Engine的职责自然就是分发找到具体的Host来执行处理。不多分析。
可以简单看下Engine#start()更是大道至简,全交给ContainerBase#start()来做了。
/**
* Start this Engine component.
*
* @exception LifecycleException if a startup error occurs
*/
public void start() throws LifecycleException {
// Log our server identification information
System.out.println(ServerInfo.getServerInfo());
// Standard container startup
super.start();
}
Engine#invoke()更是没实现,直接交给ContainerBase#invoke()来做了。
StandardEngineValve#invoke()
可以看到,Engine就是加了一个map层,根据请求找Host, 我们看下具体的实现。
这个Mapper对象,也不难理解,肯定就是通过mapperClass反射加进来的。其类名肯定就是StandardEngineMapper
StandardEngineMapper#map
可以看到,Engine是根据 request.getServerName()分发具体的Host的。
我们之前说这个其实是请求头Host的值,怎么验证究竟是不是呢?
这个值其实是 HttpRequestBase的一个成员,只提供了set方法。没找到何时被调用的。
我们回顾下,这个字段肯定是在解析请求报文的时候被设置,那么我们之前提到,Connector有一个Processor组件负责处理请求报文。因此我们去Processor看下。
破案了,在HttpProcessor处理请求头的过程中,不难看到,的确是根据Host这个key的值,设置了serverName,这也证实了我们之前的猜想,
Engine可以根据 请求头的host值,分发到具体的Host对象,这里每个Host对象都是一个虚拟主机。
五、总结
简而言之,Host对应一个虚拟主机,可以认为Host是 域名 到 具体的Context对象的映射。
而Engine则是 物理机到虚拟主机的映射,可以认为Engine是 ip地址 到所有域名的映射。
但是在目前云原生的环境下,感觉也没人会用tomcat提供的虚拟主机能力。
六、扩展:
目前为止,我们都是根据http协议实现了一个主机多个域名,如果协议主体是https呢?
这个的实现叫做 SNI,可以先看下其定义,再看下其用法
一个用途是,
服务器名称指示 - 维基百科,自由的百科全书 (wikipedia.org)
说说SNI Proxy | 天天の記事簿 (ttionya.com)
4. 服务器组件
组件可以通过拼拼图的方式,组合成一个high-level的更大组件,而Server就是这样的组件。
- Server接口
- StandardServer默认实现
2.1 组件生命周期
实例化 -> 初始化(initialized?) -> 开始(start?) -> 结束(stop, !start)
组件的生命周期由 两个变量和三个方法控制。如果是可配置的组件,比如Context,其生命周期还有configured状态。
在开始和结束阶段,会通过Lifecycle的事件机制,对外发送事件。
2.2 关机机制
await()是一个阻塞调用,负责两件事
- 开始端口监听请求(默认在8005)端口
- 阻塞等待输入,如果输入为shutdown指定的字符串,则退出。
退出后,会进入组件生命周期的stop()阶段
- Service接口
- Service本身依托一个Container, 即一个Service关联一个Container
- Service本身依托一个Server, 即一个Service关联一个Server,但一个Server有多个Services
- Service下可以管理多个Connectors
- StandardService实现
4.1 学习组合代替继承的实现
Tomcat把所有对监听器(Listener)的管理 和 事件发布 都抽取成一个 lifecycleSupport 来做。
组件通过继承Lifecycle接口,通过new LifecycleSupport(this) 完成双向绑定,相关方法都委托给LifecycleSupport来做。
这个逻辑其实仔细想一下,tomcat的lifecycle其实是两部分
- 真正的生命周期,也就是下图红框的部分
- 事件监听回调相关,也就是其他部分。
tomcat设计的时候,把他俩混在一个接口里,其实硬要说的话,"生命周期阶段的状态转移产生的事件"也能算成是生命周期管理的一部分,但是感觉拆开更符合单一职责。
而tomcat确实是这么设计的,生命周期流转的方法,start(), stop() 确实通过继承实现,而事件的相关管理,则通过组合实现,事件通知的逻辑写在另一个类LifecycleSupport里。
这样,接口的部分方法继承实现,符合单一职责,而另一部分通过组合实现,也符合单一职责。尽管定义上看上去不是单一职责的,但是实现却是单一职责的,所以设计模式不是一成不变的东西。
Service本身生命周期流转的方法还是start, stop, initialize()
同样有两个变量started, initialized 表示当前的生命周期。
4.2 Service的绑定。
- 和Container进行绑定
这里就能看出生命周期接口的好处了
可以让一些操作和生命周期关联,比如 绑定容器 = 容器初始化,解绑容器 = 容器销毁。同时让整个业务逻辑变得很清晰。
- 和Server绑定
这里倒没啥说法,单纯把自己设置为server的一个属性,当server触发生命周期流转时,就会带着自己一起流转。
- 和Connector绑定
这里还是几点
- 由于tomcat每个线程处理请求,Service可能是并发访问的,需要进行加锁。
- 添加/移除 即 组件启动/销毁,这一点用生命周期接口可以清晰可见。
5. digester库
这一章主要讲了下xml是咋被解析的。
javaSE基本用不到的地方就是
- ClassLoader 加载类和资源文件
- 解析资源文件,一般资源文件就是一些配置,将配置转换为对象。
无论是tomcat,还是spring这些框架,大量用到了类加载,资源加载,反射,注解这些东西,建议花点功夫学习下
(1) ClassLoader的原理,简单实现一个能加载任意磁盘路径的,如果遇到自己的类就优先加载的ClassLoader
(2) 能加载任意路径下资源文件的 getResource, findResource, getResources, findResources, 四个方法
tomcat使用Digester库对xml进行解析,这个是apache维护的一个xml解析库。我们知道xml其实是一个DOM文档,而且是一个很强的描述语言,xml能描述的信息非常多,但是冗余性也比较高。
那么,xml如何将DOM文档解析为对象呢,主要有几个设计
- 元素定位: 如果是外层元素,就是该元素的标签名,如果存在标签嵌套,那么就是
parentTag/childTag,即分隔符是/ - 规则: 规则指的是,对于某个元素标识,当解析到该元素时,会触发哪些回调,比较常用的如
- 内部栈
由于xml存在嵌套关系,digester维护了一个内部栈,用于辅助对象的创建,好比我们用hashmap辅助建树。在创建一些复杂结构的时候,当然需要额外的数据结构辅助创建。
有了这三把利器,基于简单的XMLReader解析,可以把xml文件解析为一个个的java类。
- Tomcat的内部配置类ContextConfig
该类实现了 LifecycleListener 接口,既然如此,他肯定负责监听一类事件的回调,我们从两点分析
- 这个类何时把自己加入到EventContext, 也就是其所在容器的lifecycleSupport类里?
- 这个类对什么事件感兴趣,具体的行为是什么?
对于2, 我们可以猜测其行为,就是根据web.xml进行配置的初始化。
对于第一点,我们可以认为,在Context对象创建之初,就为其绑定一个用于处理配置的监听器对象
该方法位于 org.apache.catalina.startup.Embedded.java
然后是该组件的触发逻辑, 前面提到过,组件&容器的生命周期统一用Lifecycle接口控制。那么流程是
Server#start -> Service#start -> ContainerBase#start
回顾Context#start, 这个容器的start有一套自己的流程,在容器启动时,会发出一个START_EVENT,如果contextConfig 收到该事件,就会完成配置解析操作,并把容器的configured设置为true,container进一步检查该值是否为true,判断是否成功完成配置工作。
这里根据事件类型进行分发。由此可见,该监听器只对START_EVENT 和 STOP_EVENT感兴趣。
在ContextConfig#start()中,完成了大量配置,我们只关心defaultConfig() 和applicationConfig以及对阀的基本配置。配置后会将其所在容器的configured设置为true。
defaultConfig()查找的配置路径是conf/web.xml, 这里就涉及到java的资源定位解析机制。我们需要自己定义好哪里是存放资源的路径,以方便项目在配置阶段进行查找。
得到配置的InputStream对象后,就可以调用webDigester 进行解析,整个过程是加锁的。
至于解析了那些配置,就按照tomcat官方提供的内容,能配置的内容就能被解析。
applicationConfig 会在WEB_INF/web.xml 寻找,这里我们看到了经典的getResourceAsStream,这个方法基本上就是在类路径下进行资源查找。
也能看出两种区别,java的资源定位无外乎两种: 类路径下的,以及任意路径下的(一般都是 xxxHome下的,比如TOMCAT_HOME)。 所以,很多时候要我们配置环境变量,就是为了项目启动时可以找到相关资源(主要是配置)的路径
注意,这里说的是,针对Context容器进行的配置类ContextConfig,也就是web.xml 主要用于配置Context容器。
如果留意过tomcat的应用程序目录,可能会注意到还有conf/server.xml的类似配置
解析web请求中的"上下文"
6. 关闭钩子
- 触发时机
可以用于在
- 系统正常退出: 最后一个非守护进程退出
- 用户中止(收到SIGINT信号),或者前台进程退出(可能收到SIGHUP信号)。
总之,要么正常退出,要么收到某个信号。要是断电或者系统崩溃,就不会触发。这里理解关闭钩子本质上就是一个jvm层面的信号处理器比较关键。
- 用途
自然是清理一些资源,我们可能会疑惑,jvm进程都销毁了,进程空间都释放了,栈也清空了,还释放啥资源?
这里指的是进程和OS交互的资源,比如文件锁,我们希望程序退出后,要销毁创建的文件锁。就可以用shutdownHook
- Tomcat的shutdownHook
只是确保Server.stop一定会被调用。Server是一个大对象,里面的一些操作,比如SessionManager可能涉及到一些OS资源的分配,所以这里也是保证能安全释放。
当然,这里也是做了幂等处理,比如刚好stop结束,此时jvm进程还没退出,然后收到了用户的sigint信号,stop重复调用收到状态started的控制
7. 启动tomcat
这一章,将分析tomcat的入口,为什么可以通过某些脚本启动tomcat。这里我们不止要学习tomcat是如何启动的,也要分析其他的类似的启动脚本的实现,以后我们自己的服务也可以用脚本实现启动。
另外,很多流水线也可以构造 构建脚本,这些脚本一般都是shell脚本。
和启动相关的代码大多数位于 org.apache.catalina.startup 包下。
回顾
- tomcat所有组件的最高层组织结构就是 Server对象。
因此,Catalina.java 负责该配置的解析,以及Server对象的构建。
一、Catalina.java
Catalina负责启动的主要逻辑,好比我们的操作系统。然而,我们不直接通过main()启动,而是通过引导类Bootstrap 进行启动。好比在进入OS前的 BIOS做的工作。
好处在于,不同的Bootstrap可以提供不同的启动方式。
这里catalina.home 和 catalina.base 如果不设置,就是user.dir的别名。可以理解为用户会在哪里调用Java命令。
catalina.java一般通过脚本启动,因此会解析命令行参数
execute() 会根据指定的参数是-start 还是-stop 调用同名方法。
简单而言,start()分三步
- 解析配置,构造Server对象
- 初始化并启动Server对象,启动成功则阻塞等待关机命令
- 如果收到关机命令,就执行销毁。
回收上文,我们说tomcat的目录下存在conf/server.xml,那么这个是啥时候被解析的呢?
可以看到,digester正是对这个文件进行了解析。
简单而言,就是把文件的一堆东西塞到Server里。
这个配置也很繁杂,理清容器和组件之间的属性嵌套关系会好很多
比如,Service下存在多个Connector, 一个Container,一个Server。
每个Connector下又存在一个ConnectorFactory, 每个Host下存在HostConfig, Valve等,我们需要根据嵌套的规则进行对象创建,属性注入,组件关系设置等一系列行为。只要配置文件可以配置的,解析类都应该解析到。
二、Bootstrap.java
简单分两步
- 创建三个类加载器
- 执行Catalina#main
这里就涉及到 tomcat的类加载机制。
Tomcat 的类加载机制_tomcat类加载机制-CSDN博客
我们说,ClassLoader无非关心两点, 类在哪里(字节码文件的仓库),资源在哪里(资源和类本质上都是二进制字节流,只不过具体职能不同,我们有意区分开)。
所以,classLoader主要在意: 在哪里找字节码,在哪里找配置。同时还关心: 找不到的话是直接ClassNotFound还是委托给父亲找? 什么样的类是我先自己找,然后再让父亲找?
tomcat的类加载器类型为 StandardClassLoader。对于类加载器,我们主要关注
- findClass, findResource,先不管别人,这两个方法定义了我们自己如何找类找资源
- loadClass, getResource, 这两个类决定了在整个类加载器体系下怎么找。
这里,双亲委派体现在 loadClass 和 getResource层,tomcat的设计中,传入了delegate变量,如果这个变量传的是true,则先让父亲来,否则自己来
注意,java里的Class和Resource是同一个东西(字节流)的两个抽象,所以二者有类似的性质,都可以具备双亲委派。
对于找资源
简单来说,就是
- 如果开启了委派,则先让父亲找,父亲找不到就自己找
- 如果没开启委派,就先自己找,自己找不到才父亲找,所以,tomcat打破双亲委派,就是这么打破的。
- 如果找不到,返回null。不会扔异常
public URL getResource(String name) {
URL url = null;
// 1. 如果开启了委托,先让父亲查一下
if (delegate) {
ClassLoader loader = parent;
if (loader == null)
loader = system; // null,此时交给BootstrapClassLoader
url = loader.getResource(name);
if (url != null) {
return (url);
}
}
// 2. 如果父亲没找到,或者就不想让父亲参与资源查找(delegate=false), 按照自己的逻辑来查
url = findResource(name);
if (url != null) {
return (url);
}
// 3. 如果之前就没让父亲找(自己逞能),然后自己还没找到,还是得叫爹找
if( !delegate ) {
ClassLoader loader = parent;
if (loader == null)
loader = system;
url = loader.getResource(name);
if (url != null) {
return (url);
}
}
// (4) Resource was not found
if (debug >= 2)
log(" --> Resource not found, returning null");
return (null);
}
我们再来分析下loadClass,还是那句话,findClass 表示不管别人,只看自己怎么找,loadClass表示纵观整个类加载体系,要怎么找。
这里我们先了解下前置知识: java原生的classloader规范
- 无论如何先让父亲找,如果父亲找不到就自己找。
- 解析顺序是
2.1 loadClass: 类加载的入口,每个loadClass会先获取自己的parent,然后调用parent的loadClass
2.2 findClass: (当前类加载器) 根据自己的查找逻辑进行查找
2.3 defineClass: 如果findClass表示能在自己的查找范围内找到类,就可以把类的二进制流载入方法区,此方法返回堆上的class<?>对象
2.4 resolveClass: 对类进行符号解析,解析后resolved为true
2.5 findLoadedClass 在执行上面的真正加载操作前,会判断类是不是加载过了,是的话就直接返回。
2.6 如果加载器路径上的所有classloader都没找到,扔ClassNotFound异常
public Class loadClass(String name, boolean resolve)
throws ClassNotFoundException {
Class clazz = null;
// 1. 这一步和Java的classloader规范一样: 如果已经加载过了,就不执行加载逻辑
clazz = findLoadedClass(name);
if (clazz != null) {
// 如果类还没进行符号解析,则解析后返回
if (resolve)
resolveClass(clazz);
return (clazz);
}
// 2. 尽管说tomcat打破了双亲委派,但tomcat仍然不允许直接加载一些黑名单的类
// 比方说我们用tomcat的类加载器,加载指定路径下的java.lang.Object, 会直接交给BootstrapClassLoader
// 这里也没进行双亲委派,而是直接交给最顶级的ClassLoader,是因为java包下一般都是交给BootstrapClassLoader处理的,相当于我们提前知道了这些类该谁来加载。
if( name.startsWith("java.") ) {
ClassLoader loader = system;
clazz = loader.loadClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return (clazz);
}
throw new ClassNotFoundException(name);
}
// (.5) Permission to access this class when using a SecurityManager
if (securityManager != null) {
int i = name.lastIndexOf('.');
if (i >= 0) {
try {
securityManager.checkPackageAccess(name.substring(0,i));
} catch (SecurityException se) {
String error = "Security Violation, attempt to use " +
"Restricted Class: " + name;
System.out.println(error);
se.printStackTrace();
log(error);
throw new ClassNotFoundException(error);
}
}
}
// 下面就是经典的三段式
// 1. 如果允许委派,先让父亲查一下
if (delegate) {
ClassLoader loader = parent;
if (loader == null)
loader = system;
try {
clazz = loader.loadClass(name);
if (clazz != null) {
if (debug >= 3)
log(" Loading class from parent");
if (resolve)
resolveClass(clazz);
return (clazz);
}
} catch (ClassNotFoundException e) {
// 父亲没查到不能直接摆烂,我们自己还得查一下
;
}
}
// 2. 如果开启委托,但是父亲没查到。或者不开启委托。
try {
clazz = findClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return (clazz);
}
} catch (ClassNotFoundException e) {
// 同样还没完,不能直接摆烂
;
}
// 3. 能走到这里,需要 1. 没开启委派 2. 自己没查找
if (!delegate) {
if (debug >= 3)
log(" Delegating to parent classloader");
ClassLoader loader = parent;
if (loader == null)
loader = system;
try {
clazz = loader.loadClass(name);
if (clazz != null) {
if (debug >= 3)
log(" Loading class from parent");
if (resolve)
resolveClass(clazz);
return (clazz);
}
} catch (ClassNotFoundException e) {
;
}
}
// This class was not found
throw new ClassNotFoundException(name);
}
整个流程和getResource一模一样,我们可以仿照类似的设计。
if (delegate) { (1) 双亲委派代码; }
(2) 自己查一遍;
if (!delegate) { (3) 双亲委派代码; }
(4) 没找到时的逻辑
实现类似的逻辑。
而Tomcat打破双亲委派,也是不严谨的,应该是
- 针对非java包下的类,设置delegate为false时,才是不按java类加载器规范来的"打破双亲委派"。
回归正题,tomcat的类加载体系,就是在 BootStrap里初始化的。
org.apache.catalina.startup.Bootstrap
- commonClassLoader
我们可能疑惑,jar包在java里是什么样的抽象。实际上,jar包本质就是一组class文件,Java本身就可以处理jar包,同样,jar包也被抽象为一个URL,通过URL就可以找到jar包
- catalinaClassLoader
- sharedClassLoader
同时我们发现,catalinaClassLoader 和 sharedClassLoader 的父加载器就是 commonClassLoader
另外,通过ClassLoader工厂创建出的类加载器,默认开启双亲委派
启动脚本
这一章主要是windows下bat脚本的编写,当然如果我们后面也有类似的需求,可以写对应的bat脚本或者shell脚本来实现。
首先,要理解为什么需要先配置一些系统变量,才能正确运行脚本。原因很简单,脚本内使用了这些变量。必须配置的有
- CATALINA_HOME: tomcat的安装路径
- CATALINA_BASE: tomcat的一些动态内容存放的目录,如果不配就是 CATALINA_HOME
上面分析了,整个入口是Bootstrap.java。 而Bootstrap负责调用Catalina#main()。。
那么Catalina的相关库是在何时被加载的呢? 注意到bat脚本的目录下有一个bootstrap.jar 还有一些tomcat的工具类,这些类会在catalina.bat里被加入classpath。
然后就是一般的脚本都需要做的: 解析命令行,如果不认识命令则显示usage信息。
主要看下 当运行catalina.bat start 时,主要执行下面的操作
另外,我们提到了Bootstrap的设计,以及为什么不直接用Catalina#main启动。实际上tomcat支持不同的启动方式,如果启动脚本传入参数
embedded,则会调用Embbed的方式进行启动。
那么,Bootstrap的入口何时被指定?
整个执行流程,设置了
- _RUNJAVA
该变量通过call setclasspath.bat 设置,具体为_RUNJAVA="%JRE_HOME%\bin\java.exe"。
- MAINCLASS 两个变量。
2.1 如果不采取 embedded启动,则MAINCLASS为 org.apache.catalina.startup.Bootstrap
2.2 如果采取embedded启动,则MAINCLASS为 org.apache.catalina.stratup.Embedded
总而言之,最终执行的东西就是
start Tomcat
%_EXECJAVA% java.exe 以什么命令启动
%CATALINA_LOGGING_CONFIG% tomcat日志配置
%LOGGING_MANAGER%
%JAVA_OPTS% java变量信息
%CATALINA_OPTS% catalina 变量信息
%DEBUG_OPTS%
-D%ENDORSED_PROP%="%JAVA_ENDORSED_DIRS%" 指定endorsed目录,该目录下的内容由commonClassLoader加载
-classpath "%CLASSPATH%" 这里在setclasspath中,指定为 catalina核心库和tomcat的工具类
-Dcatalina.base="%CATALINA_BASE%"
-Dcatalina.home="%CATALINA_HOME%"
-Djava.io.tmpdir="%CATALINA_TMPDIR%"
%MAINCLASS% Bootstrap.java(MAINCLASS启动的话,就是调用该类的main方法)
%CMD_LINE_ARGS% %ACTION%
所以总结就是,tomcat设置了一些环境变量,jvm运行参数,指定了一些类的路径后,调用Bootstrap#main进行启动。
对于linux平台的启动,也差不多。
但多了一个对各OS版本的专门处理,以进行平台上的统一入口。同样会调用setenv.sh 和 setclasspath.sh。
要注意,在linxu中,如果一个脚本调用另一个脚本,而且另外的脚本可能会设置一些环境变量,那么要用source 或者 .的方式。 比如 . setenv.sh。
同样,会将tomcat的启动源码和工具类加入classpath。
8. 部署器
简单理解,部署器以 Context为单位,可以将一个ContextPath和具体的Context实现(目录, war包,资源)进行绑定,同时部署并启动对应的Context。
看完职能,应该也能理解部署器是 Host层面的。
回顾一下Lifecycle的设计。
其中,关于生命周期流转的方法,由Lifecycle实现类自己实现。而关于事件发布管理的方法,由内部对象LifecycleSupport组合实现。
部署器也采取类似的设计,Host实现Deployer接口,同时内部有一个 真正的实现类作为Host的成员。Host使用Deployer的方法,全部交给内部成员完成。
为什么Host不自己完成呢?原因有两点
- Deployer 可能会有拓展,用户也可以实现自己的deployer,Host组合deployer的实现,并将真正的接口实现委托给该实现,可以方便deployer进行更新和替换。这就是组件化
- 相比Lifecycle接口,Host也需要实现start()方法(或者直接用ContainerBase的方法),这是因为和生命周期流转的业务逻辑确实应该由容器完成,而"部署器"更多的应该是一种增强的逻辑,而不是容器本身应该具备的逻辑。简单来说,部署器是 只有Host才具备的一种增强性质能力。这种能力是不在 "容器体系"范畴内的。
1、 实现接口
2、内部组合具体的支持对象,通过双向绑定 (传入this)
插个题外话,这里可以注意下,我们发现 如果这么写,有把this暴露出去的风险。
如果外部系统用this访问了内部的私有方法,然后私有方法又能访问本类的属性,如果this传出的时候,本类还没构造好,可能存在风险。
因此,我们观察一下StandardHost的构造函数,没有进行额外的赋值操作,这是因为,StandardHostDeployer这个成员的初始化顺序,优先于构造函数。也就是deployer可以通过this访问到StandardHost还没构造好的成员,好在StandardHost在构造函数里没有进行构造,那么就不存在上述问题。
3、 接口具体实现委托给support类。
- 部署的触发时机。
我们知道,Host不关心其子组件的部署,所以通过事件机制发布相关事件。
回顾: tomcat的关键组件 都是通过解析配置完成的。
下图就一目了然了,解析某个配置(server.xml), 应用某个规则(ruleSet),得到一些对象。
经过如此处理,配置就可以转换为一个个复杂对象。
其中,只要遇到Host就会创建一个HostConfig对象。这里回收上文,为什么说必须要有Host的配置,明明只有Context也能完成基本的web功能。tomcat把部署工作放在这里了。
既然是通过监听实现,那么不难发现HostConfig实现了LifecycleListener接口,其处理方法就是下面的
那么这个监听器是什么时候加入到EventContext的呢?
begin()方法是digester的一个方法,规则触发前会调用。在解析过程中,栈顶元素是Host的实例,那么这里就是调用了host的addLifecycleListener()方法,所以就能监听来自host发布的事件。
这个方法在host里委托给host的成员support: LifecycleSupport 完成
- 部署器做了啥?
一句话: 先部署,再执行
同时开启一个守护线程,定期检查各个deployed的context里的web.xml是否有修改,有则重新部署。
这个守护线程可以支持动态部署,因此无需重启tomcat,可以对已有的部署进行修改,也可以运行时加入新的Context容器(运行时触发部署)
用程序描述一个部署可能需要耦合一些代码, tomcat同样提供了 一些 配置实现,以及无需配置的 "约定大于配置"
所谓约定大于配置,就是默认去CATALINA_HOME/webapps 下找需要部署的内容,有约定大于配置,就一定有自行配置,所以docBase 属性,可以指定deployer去哪里寻找要部署的内容。
那么,deployer知道如何寻找要部署的内容后,可以部署什么内容呢?
3.1 部署一个文件描述符
在appBase 目录下,找.xml 结尾的文件。 换句话说,该方法找的是, 部署的元数据信息,简单理解就是约定大于配置中的web.xml。
传统的部署就是 在webapp下找到web.xml 然后作为配置context容器的元数据。既然现在可以在任何地方部署,那deployDescriptors就充当这样的作用。
- 寻找是否有满足要求的.xml文件
- 将其转换为输入流。
- 调用host.install()方法
3.2 部署一个war包
和上面的类似
- 寻找是不是有满足要求的war包
- 将其转换为输入流 3.将war包 去掉.war的部分,作为ContextPath
- 调用host.install(contextPath, url)方法
((Deployer) host).install(contextPath, url);
3.3 部署一个目录
- 在appBase下找到所有符合要求的目录(目录下面需要有可读的WEB-INF目录)
- 将其转换为URL对象
- 将目录名作为contextPath
- 调用host.install(contextPath, url)方法
- Context管理
部署成功后,Host会通过addChild加入部署成功的Context对象,所以,实际上所有context被管理在 Host的父类型 ContainerBase的 children 成员变量里。
这个东西就是一个 hashmap,配置了相关的CRUD方法。
而如何寻找Context呢?每个Context的key就是ContextPath,就是本章概览图里的/demo1, /demo2。
如果没有ContextPath,也就是ContextPath="", Tomcat会将其处理为根/路径。同时其在hashmap的key为""
- Deployer接口
我们其实也发现了,无论是部署什么,最终都要调用host的install方法,而这个方法代理给了StandardHostDeployer。
所有我们看下Deployer接口和 StandardHostDeployer的实现。
简单来说,Deployer就
5.1 CRUD 可以添加(install),删除(remove),查找部署(findDeployedApp)。
不难发现,部署的单元是Context,所以findDeployedApp返回一个Context实现类。
而无论是install还是remove,都是根据ContextPath这个key进行新Context的部署或者旧Context的卸载。
5.1.1 install系列
安装描述符,这个名字太土了,简单理解,他要求我们第一个参数是URL(配置对象的引用),那么他只负责处理配置信息。
核心代码为
我们主要关注 接收String作为第一个参数的,也就是接受一个ContextPath作为首个参数。
其核心主要就是,将ContextPath和Context绑定,然后加入到Host
此时相当于注册一个Context到Host,只不过整体还没完全启动。
5.2 生命周期 Deployer作为一个组件,需要实现自己的生命周期管理方法
那么,start和stop就不多说了,install负责从配置中解析出context对象,然后加入到host的children里。
start就负责启动他们
9. Manager
这个东西和Deployer配合用,其实上面我们 StandardHostDeployer有一个传入ContextPath的方法,这个方法没有在启动阶段进行调用,那我们部署的context何时被调用呢?实际上Manager就是一个入口。
作为一个 组件,manager同样可以通过xml描述,可以通过appBase 人工指定manage.xml的路径,也可以约定大于配置,在默认的server/webapps/manage.xml 被读取。
这个Manager同样是Context,同样可以部署,同样需要配置Servlet,但和我们自己的Context有一些区别
- 其配置的Servlet类型为
我们可以给他一个 ContextPath为 /manage,那么该命名空间下的所有api都和内部操作相关。
同时,我们留意到这个Servlet实现了ContainerServlet,回顾一下如果Servlet想获取Wrapper,或者更高层次的容器,获取到的永远是Facade对象,除了tomcat给开后门的Servlet。tomcat会检查Servlet是不是org.apache.catalina 或者继承自这样的全限定名对应的类,如果是的话,那么Servlet可以直接拿到豪华版的Wrapper或者更高层次的容器。
对于用户而言,拿到Facade对象就够了,但是对于内部vip的Servlet,显然希望拿到更多的功能。
同时,回顾一下我们几乎不用的tomcat提供的realm组件,manage也需要额外的身份认证,类似网站的admin页。
通过manage配置的servlet,我们可以访问到上一张 Deployer的相关方法,通过http请求的方式对 Context进行运行时的管理。
小结
这一章主要是
- tomcat对管理接口的设计
- tomcat是如何给 内部Servlet开后门的。
10. JMX
简单了解即可。
简单总结
- JMX 是J2EE的一个接口,用于对象的管理。
- 基于对象的自省(控制粒度是 字段,方法)提供的权限(可读,可写)机制。
- JMX的实现本身也是JAvaBean,只不过是 管理javabean的javabean,也叫MBean
- tomcat内置了一些MBean,用于管理常见的组件和容器Bean。
其他的 J2EE这一套东西其实也不太了解,感觉以后也用不到。。