Tomcat剖析之架构篇(-)

前言

早在之前写过一些http玩具服务器,总感觉无法继续前进了,期间花了比较多的时间在基础知识上,前段时间想着直接从用的比较多的服务器开始,对于Java开发者来说,自然Tomcat是首选,但有一个比较大的问题是经过了近20年的发展,它已经成为了一个十分系统、复杂的框架了,读起来肯定容易陷入泥潭,回想起之前学其它原理一样,一开始就看了比较复杂的一张架构图,然后就没有然后了…,不过还好有《how tomcat works》这种神书,虽说是讲的Tomcat4,5版本的,但关键在于从实际问题出发描述,寻求解决方式,一步步的将其从百来行代码的玩具构建成了一个功能完整的、有着较强扩展性的框架。虽然到现在的Tomcat9版本有较大的变化,但核心还是没变,了解了早期版本的源码之后再看现在的版本就不会像无头苍蝇一样乱撞,从一条线出发,清楚整个流程,学习设计,先不管太多细节性的问题,这也是我看了一些技术书籍和文章之后的体会,发现很多都是只谈概念,不讲这样做的原因,就很难让读者带入自己的思考、融入书本去读,自然难以阅读下去,也容易理解不够深,忘记得也就更加快。用这篇博客主要来解析一下Tomcat的架构,因为Tomcat涉及到的功能模块比较多,这里只从它的核心功能出发,附加的组件只简单介绍一下。本来打算直接写一篇从源码开始的,最后写一个简单的Tomcat的,写的过程中发现很多东西都要去解释,而且难以将前后串起来,所以打算把它拆解成架构篇,源码篇,实践篇一共三篇,基于Tomcat9,这里第一篇主要介绍Tomcat的核心组件,以及整体的架构和运作方式,不会涉及过多的源码,现在开始正文。

Tomcat总体架构

Tomcat本质是一个Web服务器 + Servlet容器, 首先借用一张图看看它的的整体架构

整体架构
可以看到 顶层是一个Server,它是运行着的Tomcat服务器的具体表示,一个Tomcat只能有一个Server,而一个Server可以有多个Service,Service表示完整的服务,用来管理tomcat核心的组件,后面再进行讲述。
所以总的来说,Tomcat需要实现一下两个核心功能,(SpringMVC本质也是对Servlet的封装,将DispatcherServlet加载到tomcat中,将最终请求的处理,使用反射进行相应参数的获取和绑定,然后调用对应的方法,最后还是由tomcat建立的TCP连接通道的包装对象将数据发送出去.)

1
2
1. 处理Socket连接, 负责网络字节流与Resquest和Response对象的转换.
2. 加载和管理Servlet,以及请求的具体处理.

因此tomcat设计了两个核心组件连接器(connector) 和 容器(container), 连接器负责对外交流,容器负责内部处理,对应着上述两步。接下来就从连接器开始

连接器

连接器(connector) 内部持有一个实现了ProtocolHandler接口的对象,来看看这个接口具体的实现类的类图

ProtocolHandler
根据名称就可以看出ProtocolHandler其实就是对协议的抽象,先用一个实现了ProtocolHandler接口的抽象类AbstarctProtocol, 然后有两类协议,分别是Ajp和Http1.1协议,这里就用了两个不同的抽象类来分别表示AbstractAjpProtocol和AbstractHttp11Protocol。对于AbstractAjpProtocol类,只有三个子类,刚好分别是使用Nio,Apr,Nio2三种不同IO模型实现的Ajp协议;对于AbstractHttp11Protocol类,也同样是使用了三种不同的IO模型来实现的,不同地方在于对于Nio和Nio2,不是直接是继承了AbstractHttp11Protocol,而是通过一个继承了该类的抽象父类AbstractHttp11JsseProtocol,这实际上就是为了支持传输安全的Socket,也就是我们常见的Https协议(传输的加密与解密实际是在应用层来做的,具体使用了HTTP+ TLS协议来实现)。Http协议应该都比较熟悉了,这里简单介绍一下Ajp协议,众所周知,HTTP协议是基于TCP协议实现的纯文本的一个协议,而Ajp协议是一个基于TCP实现的二进制协议,内部做了较多的优化,我们平时使用的基本都是Http协议,因为浏览器或者操作系统,以及各种网络编程相关的库内部都实现了Http协议,所以我们使用起来都是无感知的。Tomcat内部虽然实现了Ajp协议,但我们的浏览器等基础软件并没有实现,所以肯定是无法直接使用该协议进行数据交互的,因此一个办法就是在服务器端做一个反向代理, 做反向代理的服务器帮我们实现了从Http协议到Ajp的双向转换即可(实际情况实现了Ajp协议的服务器较少,所以Ajp相关的端口默认是关闭的),使用较多的自然就是Apache和Nginx服务器,Apache是直接支持Ajp协议的,而Nginx我看了下官网,没找到相关的,不过看到了第三方实现了Ajp协议的Nginx反向代理的模块(关于Ajp协议的更多信息可以参考Tomcat官方文档https://tomcat.apache.org/connectors-doc/ajp/ajpv13a.html)。


ProtocolHandler接口的实现类里面持有一个AbstractEndPoint,这就是真正建立,管理连接的地方,每个EndPoint内部使用了多个Acceptor(每个都是一个新启动的线程)来监听新到来连接请求,建立连接后,会把连接对应的通道注册到一个Poller(轮询器)中,EndPoint里面也是持有了多个Poller(每个也都是一个新启动的线程),当有读写事件就绪时Poller会把数据通道(Channel)交给Processor处理真正的读写,先大概有个了解,具体的实现在源码分析篇里面再进行解析。对与AbstractEndPoint的实现对应了上述的几种IO模型,包括Nio, Nio2,Apr,看下类图,

EndPoint
基本是与上面对应的,然后来简单介绍一下Tomcat中的几种IO模型,要了解IO模型首先要搞清楚网络IO的过程分为两步, 用户线程发起IO操作的请求后

1
2
1.用户线程等待内核数据从网卡缓冲区拷贝到内核空间
2.内核将数据从内核空间拷贝到用户空间

来说说为什么需要这两个过程,因为网络传输是基本上都是基于TCP/UDP协议的,特别是对于TCP而言,接收到数据后还需要发送相对应的ack包,表示接收方已经接收到数据了,包的传输过程不稳定,可能会受到各种因素的影响,所以要提高数据传输的效率的话,就尽量减少网络数据包的往返的次数,就尽可能的多接收一点数据后再进行应答;同样,写数据也是,尽量要让缓冲区有较多的数据后再真正让网卡进行发送。

接收数据到应用层的过程

网卡接收数据时使用DMA(直接内存访问)的方式先接收到它的缓冲区队列(这里实际也是网卡内部的存储空间映射到的物理内存,所以完成数据的拷贝后需要解除这个映射),等网卡的缓冲区队列满后再通过发起一个硬件中断,CPU收到该中断后就会通知操作系统的内核,接着,内核会根据会根据中断信号在中断信号表里面查找对应的中断处理程序,接下来网卡中断处理程序会为网络帧分配内核数据结构(sk_buff),并将其拷贝到 sk_buff 缓冲区中;然后再通过软中断(注意软中断的发起很可能不是即刻的),通知内核收到了新的网络帧。接下来中断处理程序就开始从下层到上层开始依次拆包解析,一直到传输层再根据包的TCP/UDP头部信息找到对应的Socket,然后将数据拷贝到Socket的接收缓冲区,此时表示数据已经接收好了,此时应用层就可以使用对应的Socket通道进行数据读取了。由于用户空间是不能直接访问操作系统的内核空间的,所以内核空间的数据必须要拷贝到用户空间才能进行读取,也就是上述的拷贝到Socket缓冲区的地方才是将数据拷贝到了用户空间。至于写数据刚好是相反的过程,这里就不多说了。

Nio

同步非阻塞IO,具体读数据时还是同步的方式,也就是指数据从内核空间拷贝用户空间的这段时间一直是阻塞的,等数据到了用户空间再将用户线程唤醒。对于传统的BIO(同步阻塞)而言,不管是连接的建立,数据读写的就绪以及数据从网卡到内核空间,从内核空间拷贝到用户空间都是阻塞的,所以必须用新的线程管理着具体的Socket连接,而对于Java来说,线程是直接映射到操作系统内核的线程,所以资源是比较重量级并且是十分有限的的,而大部分时间线程又在等待,并且线程数过多会造成大量的线程上下文切换,为了解决这个问题才产生的新的IO模型;Nio是使用了一个Selector(多路复用器)来管理多个连接,去轮询通道(连接)是否有事件就绪,也就是内核已经接收到了数据并完成了包的拆解,具体的实现是依赖操作系统底层的epoll(Linux)或iocp(Windows)机制,这种IO模型能只用少量的线程管理就能大量的数据通道(虽然是同步,但由于CPU将数据进行拷贝时的速度太快了,而大多数情况下数据包都比较小,所以在应用层也基本无感知),也是目前使用较多的IO模型。

Nio2

AIO,异步非阻塞IO,就连数据的读写也无需等待,只要设置一个实现回调的接口的对象,就能在数据收发完成后主动进行通知需要回调的对象,AIO是在后面出来的,一般场景下同步非阻塞IO已经完全够用了,在数据包量较大时使用AIO就能拥有更好的性能。

Apr

同步非阻塞IO,前面两者都是JDK自带的,而Apr(Apache Portable Runtime Libraries)是使用的Apache可移植运行时库,内部是采用C语言实现的,具体的实现也是用了操作系统epoll机制,因为是用C语言实现,Java层的调用就是使用JNI的方式进行。那么同样是同步非阻塞IO,为什么Tomcat要多搞这么一个连接器呢?肯定是在性能上面有了较多的优化,不然没必要吧,接下来就阐述一下具体做了哪些的优化

1.TCP协议层的优化

AprEndPoint类里面有一个参数名为deferAccept,它对应了TCP协议里面的TCP_DEFER_ACCEPT,表示开启延迟接收,设置这个参数后当客户端有新的连接请求时服务器端先不建立连接,而是直到客户端有数据时再接受连接,这样的好处是在传输层减少了包的往返次数,在应用层减少了Selector查询的连接数量,减少了CPU的消耗。

2.JVM堆内存与本地内存

首先从JVM谈起,Java对象的实例化、数组等,都是JVM给我们在Java堆里面分配的空间,而JVM本身其实也只是一个进程,所以JVM内存也只是进程空间的一部分,整个进程空间内,JVM之外的部分叫本地内存,看看下面的图

JVM进程
Tomcat的EndPoint组件在接收网络数据时需要提前分配一个字节数组,Java通过JNI调用将字节数组的内存地址传给C代码,C代码通过操作系统的API读取Socket,并把数据填充到这个字节数组。Java NIO提供了两种方式来分配字节数组: HeapByteBuffer 和 DirectedByteBuffer,对应下面的代码

1
2
3
4
5
//分配HeapByteBuffer
ByteBuffer buf = ByteBuffer.allocate(1024);
//分配DirectByteBuffer
ByteBuffer buf = ByteBuffer.allocateDirect(1024);


使用HeapByteBuffer的方式,字节数组所需的空间是直接在Java堆上面进分配的,由虚拟机所管理,使用这种方式,数据到内核空间后,具体拷贝数据时,得先把数据拷贝到临时的本地内存, 然后再从本地内存拷贝到Java堆,使用本地内存来进行中转的目的是为了防止直接从内核拷贝到JVM堆时发生GC,分配的字节数组可能会进行移动,这样之前的地址空间就会失效,而从本地内存拷贝时不满足JVM可安全的进行垃圾回收的条件,所以不会触发GC;使用DirectByteBuffer的方式,它持有的接收数据的字节数组所需的内存是在本地内存进行分配的,而这部分内存不是由JVM进行管理的,在Java堆里面的该对象实例,仅仅保存了该对象所持有的字节数组的地址,在真正进行数据收发时是把这个内存地址传递给C代码,然后进行后续处理,用这种方式减少了JVM堆与本地内存之间的数据拷贝。但由于这部分内存不是被JVM所管理,发生内存泄漏时难以定位,所以Tomcat的NioEndPoint和Nio2EndPoint都使用了第一种方式,而AprEndPoint使用了第二种方式,反正具体的管理是交给Apr的C代码去做的。实际上很多的网络编程框架都使用了第二种方式,比如Netty,由于本地内存不方便JVM进行内存管理,它就使用了本地内存池的方式。

3.sendfile

除了前面所说的使用DirectByteBuffer来进行优化外,APR在文件发送的场景也做了比较好的优化,使用传统的方式,在发送文件时,如果使用HeapByteBuffer的方式,首先通过系统调用需要先将文件读到内核缓冲区,然后再将数据拷贝到Java应用程序的本地内存缓冲区,最后再拷贝到JVM堆,此时才可以调用Socket真正进行数据的写,写的时候同样也要先写到本地内存缓冲区,然后再拷贝到内核的缓冲区,最后再使用网卡发送具体的数据;而使用APR的方式,数据不需要拷贝到JVM进程相关的缓冲区,只需要把记录数据位置和长度等相关的信息填充到Socket缓冲区中,接着数据直接从内核缓冲区传递给网卡,这两种方式可以看看下面的图

两种方式比较
很显然,APR方式文件的数据是直接从内核区域进行发送的,一共减少了4次多余的数据拷贝,自然大大节省了CPU以及内存的资源。


容器

如下图所示,容器主要由Engine、Host、Context、Wrapper四部分组成

容器组成结构
Tomcat采用了这种分层架构的方式,使得其具有了极大的灵活性,因为这些组件完全可以根据我们自己的需求去进行添加实现,又可以直接复用已有的组件。Engine表示引擎,可以用来管理多个虚拟主机,Host表示虚拟主机,可以管理多个WEB应用, Context表示WEB应用,可以管理多个Wrapper,Wrapper实际上是对Servlet的封装。Container整个组件的通信,采用了责任链模式,每个组件里面都有一个pipeline用来存放Valve,Valve就是实际请求通过时需要经过的节点, 每层组件pipeline的尾节点是一个BasicValve, 这也是必须要拥有的一个节点,该节点是直接在当前层级的组件对象实例化时在构造方法里面进行添加的,作用是用来与下一层组件通信,当下一层的子组件有多个时,就需要在BasicValve节点建立映射,然后就找到下层组件持有的pipeline的第一个valve,以此类推,直到请求正确的找到最终需要处理它的Servlet。直接按名字来进行理解也是非常形象的,pipeline表示管道,(这里直接把它当成入口是开着的,出口是用一个Basic阀门关着的),valve表示阀门,每个管道是隶属于每个单独的组件的,要让这些组件连接起来,就直接把出口的阀门对接到下层组件的管道的入口,数据流通时才去开启阀门。也就是采用pipeline和valve这么一种方式,可以在任何我们感兴趣的地方进行拦截,也就是往管道的任何地方都可以插入一道新的阀门,Tomcat内部的很多扩展的插件就是这样实现的,比如配置不同的访问认证方式、session管理、访问日志、错误记录、SSL/TLS认证等。

JSP文件的解析与处理

还有需要清楚的一点是,对于JSP文件的处理,其实就是使用了上图的Jasper模块,不过这个模块不是Tomcat本身自带的。 Tomcat有多种启动方式,为了方便说明,以内嵌式(SpringBoot就是使用的这种方式)启动为例,对应的是org.apache.catalina.startup.Tomcat类,SpringBoot会首先实例化这个对象,默认的web.xml文件没有找到的情况下,会去调用这么一个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void initWebappDefaults(Context ctx) {
// Default servlet
Wrapper servlet = addServlet(
ctx, "default", "org.apache.catalina.servlets.DefaultServlet");
servlet.setLoadOnStartup(1);
servlet.setOverridable(true);
// JSP servlet (by class name - to avoid loading all deps)
servlet = addServlet(
ctx, "jsp", "org.apache.jasper.servlet.JspServlet");
servlet.addInitParameter("fork", "false");
servlet.setLoadOnStartup(3);
servlet.setOverridable(true);
// Servlet mappings
ctx.addServletMappingDecoded("/", "default");
ctx.addServletMappingDecoded("*.jsp", "jsp");
ctx.addServletMappingDecoded("*.jspx", "jsp");
}
.....

逻辑很清楚,它会个Context添加两个默认的Servlet,也就是DefaultServlet和JspServlet,然后通过addServlet()方法,这个方法会把Servlet包装成一个Wrapper,然后添加到Context的子容器中,并进行相应的映射,这样当有JSP页面的请求到来时,在Context的pipeline的BasicValve里面会直接拿到请求对象request对应的Wrapper,现在关键需要知道的是request是怎么和wrapper对应起来的,request内部有一个MappingData对象实例,该实例里面持有一个Wrapper对象,其实这个对象是连接器(connector)把请求给容器(container)处理之前,也就是把request对象交给Adapter处理时,如果是jsp则根据初始化时建立的映射使用的子容器Wrapper就是包装了JspServlet的这个,把这个直接赋值给MappingData的Wrapper即可,这个JspServlet会在内部把jsp文件进行解析处理,编译成继承了HttpJspBase的类,而HttpJspBase又是继承了HttpServlet类,最后实例化这个类进行最终的处理。

两大核心组件总览

如果上述连接器(connector)和容器(container)的作用还没有说明白的话再看看下面的这张图,描述了一个请求从连接器到容器运转的流程

tomcat核心

由上图可知Connector 和 Container组件是被一个Service来进行管理的,之前不是已经有一个Server对象了吗,那么为什么还要搞Service这么一个对象来进行管理呢?这里回到Tomcat的设计,它是把处理Socket连接相关的组(Connector)和处理Servlet相关的组件分开(Container),也就意味着对Container来说,是不关心Connector内部的处理的,不管是它内部的IO模型,还是使用的应用层协议都不关心,只要你最后给我包装好的Request和Response对象即可,这个适配的操作就是通过Adapter接口的实例对象CoyoteAdapter来做的,正因为这两个核心组件是不同的运作方式,所以需要一个Service对象进行它们生命周期的管理,而Server前面说过是对Tomcat运行着的服务器的表示,主要作用就是加载一些外部的配置,控制服务器运行的状态,也能方便的控制多个Service。Service可以配置多个连接器,反正最后进行了请求和响应对象的的适配操作,直接交给同一个容器进行处理即可,这样的方式也方便了开发人员进行协议的扩展。

生命周期

从上面的结构可知,tomcat实际运行中的辅助组件和容器可能是较多的,那么用什么方式能统一的管理这些辅助组件、容器的生命周期呢?Tomcat内部提供了一种优雅的方式,
每个容器或者组件都实现了Lifecycle接口, 它提供了init()、start()、stop()等方法,只要容器相关的组件和其子容器都同样实现了该方法,就可以方便一键式启动/关闭组件和子容器。在这样的规则下,父容器也不需要知道子容器是什么,只要子容器只需要实现LifeCycle接口即可,也就是说子容器的生命周期完全地由父容器来管理。并且该接口还提供了添加、删除LifecycleListener的方法,用来给当前的容器注册和解除监听器,这样在生命周期的某个时刻,可以发送事件,外部就能监听到发送的事件,并进行相应的处理,各层容器的一些配置文件实际上就是实现了LifecycleListener接口,将容器与它的配置文件的处理进行了解耦,其实这就是使用了观察者模式, 某个具体的辅助组件或容器是被观察者, 而实现了事件监听器接口的对象是观察者,只要将观察者注册到被观察者管理的观察者列表,这样当有事件到达时,被观察者就可以通知到所有观察者。接下来看看类图

Lifecycle
可以清楚的看到,Tomcat的这些组件都是实现了Lifecycle接口,这些Standardxxx都是Tomcat内这些组件的标准实现,不仅仅是核心组件,几乎所有的辅助组件,也是实现了这个接口,这样的做法极大的方便了全局的资源的统一管理。

辅助模块

除了前文提到的,Tomcat辅助模块还有一些,这里简单的列举一下。

基于Realm的权限认证

Realm是用户名和密码的“数据库”,用于标示Web应用程序的有效用户,以及与每个有效用户相关的角色列表,角色类似于Unix操作的系统中的组,因此对具有特定角色的所有用户授予对特定Web应用程序资源的访问权限,使用该组件可以将Servlet容器对接到某些生产环境中已经存在的认证数据库和机制。具体查看org.apache.catalina.Realm接口。

基于JMX Bean的管理

JMX(Java Management Extensions) MBean是Java SE定义的技术规范,是一个为设备、应用程序植入可管理功能的框架,通过JMX可以远程监控Tomcat各个组件的运行状态。前面说了Lifecycle接口,其实Tomcat的各个组件其实都是通过继承LifecycleMBeanBase,也就是实现了LifecycleJmxEnabled接口的抽象类。

Session管理

负责创建和管理Session,以及Session的持久化处理,支持Session集群。

Cluster

Tomcat的集群,提供了Session,上下文attribute的复制和集群范围内的WAR文件部署,提供了较多的可配置选项,可以对集群相关的问题进行较细粒度的控制。

Logging

使用Tomcat时内部的日志记录,这是一个打包重命名的Apache Commons Logging的分支,使用了java.util.loggin框架进行硬编码实现的,确保了Tomcat内部日志记录和其它任何Web应用程序日志记录框架保持独立。

Naming

命名服务,Tomcat提供了对Java中JNDI(Java Naming and Directory Interface)的支持,Java应用程序使用此API来访问各种命名和目录服务,可以使用名字访问对象以及对象的资源。Tomcat中使用JNDI定义数据源、配置信息,用来实现开发与部署的分离。

结尾

由于篇幅有限,很多东西没有涉及,虽说是讲Tomcat,其实也还涉及到一些计算机网络与操作系统相关的知识,确实不管是这些基础知识,还是源码的设计思想,才是真正需要花大量时间去学习的东西,毕竟编程语言,应用层的东西只是方便我们用来干事情的工具,切记不要本末倒置,被工具所主导,对这些通用的知识的反复思考,实践,总结才是正确的道路,那些优秀的开源项目也是如此,没有扎实的地基是不可能建立起高楼的,这篇博客就先到这里了。

参考

https://docs.oracle.com/javase/tutorial/jndi/index.html
http://tomcat.apache.org/tomcat-9.0-doc/index.html
https://time.geekbang.org/column/intro/180
https://juejin.im/post/58eb5fdda0bb9f00692a78fc
http://www.voidcn.com/article/p-cnfwakoo-bma.html

×

纯属好玩

扫码支持
扫码打赏,你说多少就多少

打开支付宝扫一扫,即可进行扫码打赏哦

文章目录
  1. 1. 前言
  2. 2. Tomcat总体架构
    1. 2.1. 连接器
      1. 2.1.1. 接收数据到应用层的过程
    2. 2.2. Nio
    3. 2.3. Nio2
    4. 2.4. Apr
      1. 2.4.1. 1.TCP协议层的优化
      2. 2.4.2. 2.JVM堆内存与本地内存
      3. 2.4.3. 3.sendfile
    5. 2.5. 容器
      1. 2.5.1. JSP文件的解析与处理
    6. 2.6. 两大核心组件总览
  3. 3. 生命周期
  4. 4. 辅助模块
    1. 4.0.1. 基于Realm的权限认证
    2. 4.0.2. 基于JMX Bean的管理
    3. 4.0.3. Session管理
    4. 4.0.4. Cluster
    5. 4.0.5. Logging
    6. 4.0.6. Naming
  • 5. 结尾
    1. 5.0.1. 参考
  • ,