likes
comments
collection
share

浅析tomcat一: 基本设计

作者站长头像
站长
· 阅读数 24

本章来自于对深入剖析Tomcat这本书的理解。

1. 一个简单的web服务器如何构成

简单理解就是3部分

  1. tcp部分, 一般用语言封装好的字节流socket。
  2. 协议解析部分, 可以用一个状态机解析http的请求头(比如Keep-alive这样的),根据请求头的要求执行相关的属性
  3. 响应构造部分,需要构造一个符合httpResponse规范的响应体。

由于http1.1是文本协议,所以解析和构造都是直接用文本拼接即可。

如何让服务器更高效呢?

  1. 使用epoll,io_uring这样的高性能底层网络库,用于处理请求
  2. 使用压缩等技术。

使用面向对象的思想来做

封装一个request和Response对象

public class Request {
    // 请求体的相关参数
    private []byte input;
    private ... uri;
    private ... body; 
    private ... header;
    private ... cookie; 
    
    public void parseReq([]byte input) {
        // 该方法是一个状态机,能够根据输入字节流(请求报文)进行解析,并填充到Request的相关成员上。
        
        // ...
    }
}
public class Response() {
    // 响应体的相关参数
    private ... outBody;
    private ... some_attr;
    
    // 一些常量
    private static final String CRLF = "\r\n";
    
    private static final String CONTENT_LENGTH = "Content-length"; 
    
    private static final String HTTP_VERSION = "HTTP/1.1";
    
    public void set...(...attr) {
        this.attr = attr;
    }
    
    public String build() {
        // 构造响应体
        return HTTP_VRESION + ... + CRLF + outBody + ...;
    }
}

不难发现,我们在Servlet经常见到的HttpRequest和HttpResponse其实原理上和上面两个类差不多。

接下来我们分析tomcat是如何让web服务器高效化的,相比一般的服务器又有什么优点。

2. Tomcat的设计

2.1 Servlet

我们常听闻Servlet这个东西,在经常性使用spring-mvc的情况下,似乎有点淡忘这个概念,特地学习下。

简单理解Servlet,就是一个附带生命周期管理的业务逻辑的容器

  1. 业务逻辑的容器: Servlet接收一个请求对象,进行处理后,根据业务逻辑返回相关响应。其中HttpRequest, HttpResponse是自动注入的(可以理解为Tomcat在把请求交给Servlet前会执行上面的简单封装,把请求报文解析为一个Request,然后传入一个Response对象,这里Response对象可以简单理解为一个StringBuilder)。

见图1,可以看到Request对象其实就是把请求报文的一些参数封装为对象,当然,解析请求报文的工作是由另外的组件做的

解析代码位于org.apache.catalina.connector.http.HttpProcessor,当然如果我们想实现一个websocket服务器,就可以弄个org.apache.catalina.connector.websocket.WebSocketProcessor。说到底,processor就是一个字节流解析器,和词法分析器做的差不多。

浅析tomcat一: 基本设计

图1

  1. 附带生命周期管理: 生命周期的管理和业务逻辑解耦,如果业务逻辑涉及到资源的使用,可以在init()方法里初始化资源,在destory()方法里释放资源。

当然还有一些细节问题

  1. tomcat在哪里知道哪个uri应该被哪个Servlet处理,如何维护这个映射,如果url比较多,这里会不会成为性能瓶颈

可以在业务逻辑中根据Request对象的uri进行分发,但这样将uri的分发和业务逻辑耦合,并不是很好的设计,使用框架可以避免这个问题,也就是将url和handler的映射交给框架维护

  1. 业务逻辑可能会依赖一些配置,我们在其他的web框架里经常能看到config和context,tomcat有没有这两个概念呢?

仅使用Servlet完成的web服务器

  1. 人工解析请求报文,生成响应报文

当然,这部分工作后面会交给tomcat来做,人工解析的复杂点在于

(1) http的头部比较复杂,每个头部都有特定含义,人工处理可能不能完全覆盖。tomcat会完成解析后,将封装好的Request对象注入Servlet

(2) 响应报文需要遵循http格式,有很多重复逻辑。tomcat会把基本格式指定好,让Servlet只专注于响应头和响应体的设计,而不是整个响应报文的设计。

  1. 人工解析uri,根据uri最后一部分反射定位到负责处理的servlet,然后执行业务逻辑

回收上文,uri和servlet的映射是反射做到的,毕竟uri是一个字符串,servlet是一个java class,对于静态语言java,必然是需要反射获取到的。当然可以Class.forName, 但是tomcat用了一套自己的ClassLoader逻辑,后面学习一下。

  1. 把业务逻辑封装在MyResponse对象,继承HttpServletResponse

这个设计有点抽象, 相当于Servlet容器在这里就是一个发响应报文的工具人(其service()方法单纯发送MyResponse构造好的报文),业务逻辑也不给他做了。

然后调用service()方法的时候,还得把MyResponse转成父类HttpServletResponse。

这个设计可以让service()方法直接接收MyResponse类型,这样业务逻辑就可以下放到Servlet执行。但作者给出了一个更好的方案

blog.csdn.net/sunshinezx8…

门面模式:

实际的Servlet中的HttpRequest和HttpResponse对象的实例其实都是 RequestFacade和ResponseFacade。简单理解这个设计,Request这个对象是一个 对内,需要被Tomcat的多个组件使用,对外需要被 Servlet的编写者使用的对象。 Request对象封装了很多只应该被框架使用的成员。如果直接把HttpRequest暴露给Servlet的使用者, 可能会导致使用者将Request强制向下转型为框架内部版本,进而破坏封装性。

而且由于Request对象还要被内部使用,一些公用的方法还不能是私有的。

简单理解。

  1. ServletRequest: 通用接口
  2. RequestFacade: 对外展示的版本,只开放一部分功能给外部使用者。
  3. HttpServletRequest: 对外展示的接口类型,其运行时类型为RequestFacade
  4. HttpRequestBase, HttpRequst: 内部使用的Request对象,开放全部方法

这里,ServletRequest的运行类型并不是HttpRequest, 所以使用者不可以向下转型到这个类型,使用一些内部方法,破坏封装性,而是只能得到RequestFacade类,使用受限制的部分功能。

我们其实实现了servlet的容器。之所以叫容器,是因为servlet本身不具备main方法,需要一个调用环境,我们通过反射技术,调用了servlet的方法。

3. 连接器(Connector)

带着疑问阅读:

之前的webserver中,我们把监听套接字(socket.listen())的任务交给一个大的Server对象完成,把Request对象的解析放在了Request对象本身(Request.parse())。

  1. 如果我们想用epoll作为监听对象,或者底层改用io_uring来进行多路复用,那是不是得修改整个代码?

  2. 如果我们现在换用http3, 那么写死在Request里的parse逻辑也应该修改(实际上这个确实没啥办法,我们只能使用一个Http3Request来继承Servlet实现新协议的解析)。

考虑上面的问题,tomcat肯定希望以组件化的形式,每个组件负责一个功能,高层组件是底层组件的组合

连接器就是这样的组件,连接器涉及到大量 协议的实现 or 底层连接建立的优化,如果想在这个上面下功夫,可以移步webserver =.=。我们还是关注下tomcat的设计

Connector: 负责建立连接, 实现上就是一个while循环, 底层可以采取多线程server, 多路复用server等。

Processor: 负责解析协议, 简单理解就是协议支持层, 实现上就是一个文本解析器(比如解析请求uri, 解析请求头, 解析请求参数)。

更具体的,Processor可以把 请求报文和响应报文封装为对应的 RequestFacade 和 ResponseFacade对象,作为Servlet的参数传入.

现在,我们把负责连接建立和协议解析的组件抽离出来,我们把他俩看成两块积木,新的服务器就是搭积木的过程。通过一个 Bootstrap 对象完成组装。

所以,Bootstrap 方法 作为整个Server的入口,用于组装Connector,完成基本的tcp连接监听,Connector 再组装Processor 以实现对不同协议的支持。

Processor 封装好RequestFacadeResponseFacade 后,通过反射调用Servlet 的handle方法,处理业务逻辑。于是Servlet 就被调用了,即便它并不存在main方法。

当然,这一部分的难点在于,怎么把请求报文封装成一个Request对象,然后又转成RequestFacade,这几个Request之间的关系是什么?。可以参考书的UML类图和代码逻辑。做过webserver项目的应该不陌生了。

本章给出了一个 纯文本解析的Processor实现,和一个没有任何优化过的 Connector实现(就是一个最基本的单线程监听器),因此性能上比较差。

4. tomcat默认的连接器

目前我们应该了解几点

  1. Connector和Processor是为什么而存在的,简单的实现是什么
  2. 在简单实现中,有什么设计or性能上的优化

然后再看本章,学习下tomcat对连接器进行的优化

一、对http1.1的支持

做过webserver的应该不陌生,web服务器解析请求时,要根据请求头实现相关的功能。

当然,当初只实现了对持久连接(Keep-alive)的支持,分块传输和对状态码100的解析并未涉及。

二、容器思想

我们说Servlet没有main方法,所以想要运行都得每次根据uri反射出来,有没有更好的方法?

最简单的,有必要调用一次反射一次吗,提前存储到一个hashtable里不好吗?

三、高扩展性

如果我们在github仓库找到了一个绝绝子的网络库,想让它作为tomcat的connector,应该怎么办呢?

实际上,实现了Tomcat提供的Connector接口,就可以作为tomcat的连接器组件。

四、设计上的优化

对Connector的优化

  1. 引入生命周期: 作为一个组件,我们希望上层应用可以以生命周期的方式组合组件,比如上层应用决定什么时候初始化组件(init),什么时候启动组件(start),什么时候暂停组件(stop),什么时候销毁组件(destory),组件需要实现对应生命周期状态转移的代码。当然,在一些框架的设计上,组件还会实现消息回调的接口,用于监听来自上层应用发来的事件。
  2. 使用工厂建立ServerSocket对象。可以通过工厂继承快速获得 比较常见的Socket对象。
  3. HttpProcessor继承了Runnable,Connector维护HttpProcessor的线程池。这里就是经典的多线程处理模式了,一个listener负责分发请求,然后processor负责处理请求。
  4. connector作为processor的"前台",可以根据参数设置,适应性调整processor线程池的数量,如果请求超载可以选择拒绝请求。

对Processor的优化

  1. 每个Processor都是一个可运行对象,单独放在一个线程处理,而不是和Connector位于一个线程

五、Connector和Processor的交互

由于连接器线程和处理线程脱离开,所以引入了线程交互的成本。

二者通过一个available 变量进行线程间通信。Connector在接受请求后,会弹出一个Proceessor对象,将acceptSocket交给Processor的成员变量,并把available 设置为true,然后调用notifyAll()唤醒所有阻塞的Processor

这块就是 wait + notifyAll的用法,其实就是条件变量,这个条件变量就是available,使用while (available)来避免虚假唤醒。

简单理解这里就是,如果没有请求,Processor就阻塞,直到Connector将请求套接字分发给某个Processor。

六、Request对象的设计

看着头疼,简单理解就是

对内提供的是 HttpRequestImpl类,这个类实现了所有方法,为了避免Servlet程序员直接把ServletRequest向下转型为HttpRequestImpl,所以对外提供的是RequestFacade类,这个类只对外暴露了一部分Request的功能。

七、最终版的解析流程

不变的还是对请求行,请求头的解析。增加了对http1.1的支持,以及对代理的解析。

代理服务器的部分可以看csapp有简单实现。http1.1 感觉也没必要手搓,懂啥意思就行。

八、Connector如何用Container接口完成Servlet的绑定

这里终于看到 "容器"的字眼了。老说servlet容器,这个容器到底是啥呢?

简单理解,容器就是Servlet的房子,能住一平米厕所房,也能住100平米大别墅。

对于之前的Processor,单纯的把Serlvet反射出来,然后给他传个Request和Response,就属于厕所房,Tomcat不允许Servlet住的太差,给了他一个Container接口,简单理解这个容器接口,就是运行Servlet的房子,具备了一切的生活设施。

这里重点关注下 继承Container接口后,如何绑定到对应的Servlet

方法入口位于Container.invoke(Request, Response), 依旧是使用tomcat的工具类,获取一个ClassLoader,反射加载Servlet。

九、拼图Bootstrap

Bootstrap作为组件的粘合剂,既然引入了生命周期,那么在启动整个web容器时,就可以先初始化Connection,再启动Connection,程序流程可以用时序图流转,比较易懂。

5. Servlet容器

需要理解的几点

一、tomcat的容器体系

tomcat有四类容器: Engine, Host, Context, Wrapper,都继承Container接口。

这四类容器通过组合的方式,达成父子关系。

为什么这么设计?

  1. 父容器和子容器是0对多的关系,父容器通过一套CRUD API可以比较灵活的对子容器进行管理
  2. 容器有一些通用逻辑,比如获取类加载器(getLoader),如果子容器没有,可以管父容器获取,减少成本。
  3. 容器可以插入很多组件,比如Loader, Logger, Manager等,对外的扩展性很好。

二、tomcat的执行体系

  1. 执行的单元为 pipeline (管道)
  2. 执行的最小单元为Valve (阀门)
  3. 每个容器最终执行的操作封装为BasicValve,换句话说,管道的其他valve作为前置增强

这是一个职责链模式。执行顺序为

  1. pipeline.invove(req, resp)
  2. ValveContext.invoke(req, resp)
  3. Valve.invoke(ctx, req, resp)

ValveContext 负责管理一次pipeline执行的上下文,该ctx作为参数传入具体Valve的invoke方法里,写go的可能很熟悉这个"ctx",简单理解,ctx可以提供同属于一个pipeline下valve的共享数据,同时当前valve执行完毕后,会调用ctx.invokeNext更新上下文对象的下标,上下文随之执行下一个valve。

为什么这么设计?

  1. 一些通用的功能,比如日志记录请求内容,对参数进行预校验等,不希望和servlet耦合住

三、Wrapper接口

Wrapper负责管理 servlet对象的生命周期

注意: 不要和Connector管理的生命周期搞混,我们这里Servlet的生命周期指的是

  1. Servlet仍然是文件系统里的字节码,还没被加载到JVM方法区
  2. Servlet已经被加载到方法区,但是没有对应的实例
  3. Servlet的实例销毁
  4. ...

为什么要这么设计?

  1. 回顾之前的版本,我们把加载URLClassLoader的工作耦合在Processor的逻辑里,实际上这个工作可以专门交给一个人来做。
  2. Wrapper对上层提供load和assign方法,上层可以在合适的时候获取servlet的实例。在合适的时候调用servlet对象的service()方法。

简单来说,Wrapper负责提供 Servlet类的加载,实例化,调用一条龙服务。

四、一个Wrapper就已经能够成一个webserver了

  1. Servlet: 封装业务逻辑,给出Response对象的输出
  2. Wrapper: 对Servlet的生命周期控制
  3. Pipeline: 封装好一个执行流程
  4. Valves&ValveContext: 真正的执行逻辑。Servlet本身的工作位于BasicValve中,我们可以有前置增强的Valve(比如HttpHeaderLoggerValve)
  5. Connector: 负责封装好req和resp,并把Wrapper设置为自己的容器。
  6. Bootstrap: 肉眼可见的,组件多了起来,所以Bootstrap负责 (1) 容器关系的设置,谁是父容器,谁是子容器都设置好 (2) 组件的组装, 把连接器和容器绑定好。

最后,Connector对象的init方法,负责初始化所有组件,Connector的start方法则会执行到Pipeline上。

我们这个是啥webserver呢? 其实就是,只要请求建立,就会调用日志Valve和Servlet逻辑。

我们假设Servlet就返回一个hello, world,那么此时就是一个简单的 httpServer。

还有最后一个问题,路由在哪里处理?,总不能所有请求都让一个Servlet来做吧(DispatcherServlet: 你礼貌吗?),为了解决这个问题,我们应该用Context容器,而不是Wrapper容器。

五、一个Context就是目前主流的httpServer实现

实际上,Host还有虚拟主机这玩意,但一般都没听说。。简单理解就是一个ip多个域名,根据请求头的Host字段选择要去哪个Context处理。这里一个主机部署多个Context也有tomcat打破双亲委派机制的八股文,遇到了再说。

基本流程差不多,主要区别在于,Context对象的BasicValve多了一个请求分发的过程

当然,Tomcat高度的抽象化,肯定会把 负责请求分发的功能写成一个接口,而不和BasicValve耦合住。那么这个接口就是Mapper。

整个流程相比 (四) 多了两部

  1. Bootstrap 要给不同的Wrapper起名,然后把 uri作为key,Wrapper作为value注册给Mapper
  2. Context在执行BasicValve时,需要根据请求行里的uri,分发具体的Wrapper处理。

六、总结

我们应该学习到

  1. 高度抽象化, 单一职责: 我们发现,负责请求分发交给了Mapper,同时Servlet加载实例化这些操作交给了Wrapper, Servlet则只负责处理请求,生成响应。
  2. 善用职责链模式,保证使用者的定制化。

七、题外话

DispatcherServlet执行流程_dispatcherservlet流程-CSDN博客

我们可以看下DispatcherServlet的逻辑。似乎也不遵循我们上面的总结...

我们说,路由分发这些功能,应该交给一个组件(上文Mapper)来做,Servlet应该专注于业务逻辑,而不关心请求应该分发到哪个业务逻辑上。

这里,

6. 生命周期

一、为什么需要生命周期?

  1. 引入组件化后,比如 Context容器可能有很多Wrapper子容器,以及Mapper组件。我们有一系列操作, 比如初始化,启动。希望容器和它的所有子容器&所有组件能一起启动
  2. 组件之间的通信,我们希望一个组件可以在特定时期完成一定操作,而这个特定时期是由其他组件触发的。所以就可以有一个状态机,描述每个状态,每个状态的转移,每个转移需要做什么。

二、事件&状态机

目前我们引入了6个事件,分别是 "启动前中后" 和 "关闭前中后"

三、java原生的事件监听机制

  1. Event: 事件本身,需要组合一个EventSource,可以理解为事件的上下文,Event通过Source对象,把一类对象交给对应的监听器处理。
  2. EventSource: 事件上下文,负责管理一类事件对应的监听器
  3. 监听器: 负责处理事件

下面是一个简单例子, 不难理解,如果两个组件想要合作,相比组件A直接组合组件B,这意味着B如果有什么改动,那么A也会随之变化。

取而代之的,我们让A耦合一个EventContext,在这里A只关心把事件传给B,业务逻辑在B里完成,不需要在A的主场里完成对B的操作。

import java.util.ArrayList;
import java.util.EventListener;
import java.util.EventObject;
import java.util.List;


public class test_event {
    public static void main(String[] args) {
        ComponentA a = new ComponentA();
        a.handle();
    }
}


class MyEventListener implements EventListener {
    public void handle(EventObject e) {
        if (e instanceof SomeEvent) {
            SomeEvent se = (SomeEvent) e;
            System.out.println("我是组件B的监听器, 回调事件, 事件细节:" + se.details);
        }
    }
}

class EventContext {
    List<MyEventListener> listeners = new ArrayList<>();

    // 由于可能存在多线程访问,需要线程安全,这里简单实现一下
    public void addListener(MyEventListener listener) {
        listeners.add(listener);
    }
    // 当然也可以删除,这里不实现了
    public void removeListener() {
        throw new UnsupportedOperationException();
    }
    public void onEvent(EventObject e) {
        for (MyEventListener listener : listeners) {
            listener.handle(e);
        }
    }
}

class SomeEvent extends EventObject {

    public String details;
    public SomeEvent(Object source, String details) {
        super(source);
        this.details = details;
    }
}

// 组件A想有发送事件的能力,我们可以让组件A继承EventSource,但是组合可能会更好一点
class ComponentA {

    EventContext context = new EventContext();

    public void fireEvent(EventObject e) {
        context.onEvent(e);
    }
    public void initListener() {
    
        // 这里看上去比较奇怪,可以认为组件A和B共享这个ctx,是组件B进行注册的。这里简单把注册代码也写在组件A了。
        context.addListener(new MyEventListener());
        context.addListener(new MyEventListener());
        context.addListener(new MyEventListener());
    }
    public void handle() {
        initListener();

        // 组件A希望自己在执行前,通知组件B完成一些工作
        System.out.println("组件A执行前");
        
        // 组件A不需要操作组件B的代码。
        fireEvent(new SomeEvent(context, "BEFORE_START"));
        System.out.println("组件A执行...");
        System.out.println("组件A执行完了");
        fireEvent(new SomeEvent(context, "AFTER_START"));
    }
}

浅析tomcat一: 基本设计

四、tomcat的lifecycle体系

  1. LifeCycleEvent: 事件本身,由于事件可能携带一些数据,所以tomcat将该类设计成{data, type}成员
  2. LifeCycleListener: 只有一个方法,handleEvent
  3. LifeCycleSupport: 就是EventContext,或者叫EventSource也好,负责Listener对象的管控,必要时调用Listener对象的处理方法,将Event传给Listener进行处理。

注意: 实际使用上, 可能是多个组件共享同一个LifeCycleSupport, 这些组件可能位于不同线程。相当于这个玩意是一个共享对象,因此该对象对Listerner的操作是上锁的。

具体而言,组件A在进行fireEvent发送事件时,可以指定另外一个LifecycleSupport, 而这个support可能被另一个线程下的组件持有

这里fireEvent的实现也很tricky: 采取了 类似COW的方式,为了避免fireEvent的同时有人注册Listener,fire执行时 会对当前的listeners进行一个deepcopy,相当于执行时拷贝一下,如果执行时我看到了,就有份,看不到的就当没看见。这个在并发编程的设计很常见,如果我们希望操作一个并发对象,又不想耽误该对象的写入,可以在读取的时候读副本,达成读写分离。

浅析tomcat一: 基本设计

浅析tomcat一: 基本设计

五、生命周期应用

继承了生命周期之后,组件可以方便的调用相关状态,比如

start(), init()。

当然,如果调用start(),则负责同时调用该组件的所有 组合组件,子容器的start(),在start的必要阶段,通过LifecycleSupport对象将该阶段对应的事件发送出去(其他组件会监听相同的licecycleSupport对象)

六、总结

为什么要引入生命周期呢?

组件化,父子容器化使得每个组件的流转变得复杂,而且有明显的依赖关系: 我们希望父容器创建成功后,子容器也成功。对于这样的需求,我们希望对外提供统一的接口。我们不希望父容器的启动方法叫startParent,而子容器叫另一个名字。

另外,生命周期接口也 兼顾了 事件回调的功能,能在生命周期流转的必要阶段完成组件间的通信协作。

熟悉Linux hook的可能知道,tomcat可以在 必要的生命周期留出一个hook点,让我们进行定制化。

7. 日志记录器

接下来的就是 组件化的东西,简单看看,懂啥意思,里面的关键设计看会了就行。

我们说,容器给了Servlet一个温暖的家,在前面,我们已经给容器安排了 父子关系,全套的生命周期流转方法,以及简单的Mapper组件,让外界请求找到合适的Servlet,Loader组件,把Servlet从字节码到最终被执行管理的明明白白,等等。

接下来,我们希望让这个房子更结实一点,那么Logger就作为房子的基石之一存在(主要是为了方便开发人员)。

一、Logger怎么优雅的和现有的内容整合到一起。

Logger接口提供了 关于container的getter和setter,自己本身实现了Lifecycle。这意味着

  1. Logger可以绑定到一个Servlet容器中。通过setContainer
  2. Logger收到其所在的Servlet容器的生命周期管理,对于一些依赖外部资源的Logger,可以更方便统一的进行管理。Logger还可以监听自己感兴趣的事件,同样是Lifecycle接口提供的功能。

二、Tomcat提供了哪些Logger

  1. stdout Logger
  2. stderr Logger
  3. FileLogger

这里就是Logger的具体实现了,业界有比较成熟的。

tomcat的FileLogger就是一个简单的把日志打到文件的logger,可以定义格式等(当然更复杂的还可以diy成 定期轮换日志,等等。)

三、总结

我们接触了组件之后,可以看到,组件和容器的关联,以及生命周期的灵活性。组件和容器关联后,可以方便的受到容器的管理。

好处不言而喻,一个Container肯定不止Logger一个组件,我们应该关注组件和容器的接入方式,而不是组件具体的实现。

8. 载入器

从字节码的层面理解一个请求到达的处理。

  1. web服务以 war包的形式被部署上去,此时 Servlet还是一个字节码文件,也就是xxxServlet.class,其封装的业务逻辑还不存在于tomcat这个服务器的内存里,自然无从访问。
  2. 通过Mapper组件,可以找到需要载入的Servlet字节码,那么,此时Loader组件负责把字节码以硬盘中字节码文件的格式,加入到内存里。到了内存的字节码,就是真正可运行的Java代码,于是乎业务逻辑被执行,response对象被填充,响应得到展示
  3. 如果我们是jsp页面,还有一部从jsp编译到servlet的过程。

一、为什么需要载入器

回顾之前,我们是如何载入一个Servlet的? 通过URLClassLoader, 直接从classes目录下加入,可能有一些问题

  1. 安全性
  2. 类的重新装载(Servlet热更新)
  3. 字节码文件的缓存

这一点,如果我们有什么对Servlet的变动,难道每次都需要 重新上传war包,然后重启tomcat吗? 尽管tomcat可能支持优雅停机,但这样无疑会对线上业务存在一定的影响,我们希望tomcat能根据servlet字节码的变动,自行在变动时完成重载。

仓库or资源: 仓库简单理解就是类路径classpath,也就是字节码文件的目录。资源简单理解为,每个字节码即一个资源。

二、java的类加载体系

八股盛宴,不多说了。

代理模型(双亲委派)的作用: 优先让父加载器进行加载。父加载器的仓库一般都是系统路径,可以让系统类优先加载,而我们自己写的恶意同名类不会被轻易加载。

三、tomcat为什么要自己加载类

Servlet的加载是一个很频繁的事,tomcat希望在自己的加载中,完成一定的定制化

四、tomcat的Loader规范

tomcat提供了Loader的接口,和一个默认的WebAppClassLoader作为默认加载器的实现。

一般而言,Loader这个组件都依附于Context容器。原因不言而喻了,这样设计,所有的Wrapper都可以用Context的Loader来加载Servlet。

和class的重新加载相关的两个接口

  • modified()一般单独启一个线程调用,轮询监控字节码文件的 md5码,时间戳等有没有修改
  • reload() 真正的重新装载类(这个后面会讲实现)

五、默认实现 WebAppClassLoader

主要功能

  1. 创建类加载器,这个创建方式给了我们定制化的空间

具体实现是,WebAppClassLoader会维护一个要实例化的类加载器的 全类名。运行时通过反射把这个类加载进来。

我们可以hack这个全类名,让他反射另一个类,但是加载的时候,会把反射出来的类强转为WebAppClassLoader,所以隐含要求是,如果我们想自己实现ClassLoader,就得继承WebAppClassLoader

  1. 设置仓库, 类路径,访问权限

简单理解就是,安全的从 WEB-INF/classes, WEB-INF/libs下加载字节码文件(所以,终于知道这俩东西原来是在这里起作用的)。

  1. 开启重载

这里会启动两个线程 1. 检查线程, 检查servlet类路径是否有变化,会调用context的modified()方法。2. 重载线程,会调用context的reload()方法。如果检查线程发现有变动,会通过线程间通信(比如条件变量)通知重载线程重载。

  1. 加载流程

会先看缓存是否存在,检查是否存在代理等等。

主要特性

  1. 安全性

WebAppClassLoader 内置了一些类的黑名单: 不允许我们通过类加载器加载这些类。

  1. 类缓存

该ClassLoader把一个字节码文件缓存到内存里,包括其URI, 字节码的二进制流(byte[]),以及上次修改时间,存放在hashmap里,每次请求可以先查缓存再真正的去仓库里找。

同时,对找不到的类也缓存起来,如果发现请求该类,直接抛异常(感觉可以bitmap优化)

六、总结

忘了提一嘴,作为组件的Loader自然也可以set到一个container里,也实现了Lifecycle接口,能够随着其容器的生命周期流转而流转,也能监听一些事件。

Loader的职责比较重要,负责所有Servlet生命周期的管理,同时Loader也和底层字节码技术相关,值得学习。

9. Session管理

神秘的jsessinid究竟是什么,又是什么时候发挥作用的?

一、Session对象在哪里被获取

熟悉Servlet开发的应该知道,只需要从HttpRequest对象中,调用getSession() 就可以拿到Session了。我们可以思考下这里的实现

  1. 我们之前说,封装Request的工作是由Connector完成的,那么Connector要不要负责Session管理呢?
  2. Session管理作为一个单独的组件,getSession()方法实际上是通过这个组件获取的。

tomcat自然选择第二种。

二、Session类家族

  1. Session: Session对象的接口
  2. StandardSession: Session对象的默认实现
  3. SessionFacade: request.getSession()方法获取到的实例,避免Servlet编写者把Session对象强转为StandardSession类型,获取一些内部使用的方法。

Session类的重要方法

  1. Set/GetManager: Session最终是要被一个Manager管理起来的。这种组件的注册绑定在tomcat随处可见。不难猜测,Manager最终也要把自己setContainer到一个Context容器。
  2. expire: 检验session是否过期,主要取决于 lastAccessTimemaxInactiveInterval两个成员,前者表示上一次刷新的时间,后者表示多久没刷新就当过期。
  3. 生命周期相关: 用于以统一的方法对外代理给上层对象控制。还可以必要时发送相关事件。

StandardSession类

  1. 实现了Serializable接口,要理解,Session由持久化保存的需求。当然其listener,manager这样的成员不需要序列化,就直接transient了。值得序列化的只有session内容本身,和过期时间这样的信息

expire流程

  1. 将内部变量expired 设置为true,
  2. 从Manager移除当前Session
  3. Session类对外提供的监听器实现为 HttpSessionListener, 如果某个组件希望在Session生命周期期间进行一些处理,可以实现HttpSessionListener并进行注册。在session的必要时期,会遍历所有HttpSessionListener的子类型监听器调用fireEvent发送事件。

三、Manager类体系

  1. Manager: 接口
  2. ManagerBase: 作为整个Manager的通用逻辑,抽象类。
  3. StandardManager: 基于内存的Session管理器, 支持持久化Session到文件
  4. PersistentManagerBase: 支持Session的换出(in-out)和备份(backup)
  5. PersistentManager: 单纯继承PersistentManagerBase
  6. DistributedManager: 给出了tomcat集群化session的解决方案

Manager的重要方法:

  1. set/getContainer() 将自己和某个容器绑定
  2. maxInActiveInterval() 和Session何时过期有关
  3. load()/unload() 和Session如何和外部系统(jdbc, jndi, 文件系统...)交互有关
  4. add/remove/findSession(), 对Session的CRUD

ManagerBase的重要方法

  1. sessions在这里被组织成一个HashMap
  2. generateSessionID: session对象的id生成方法在这里实现,所以jsessionid就是这东西生成的
  3. add/remove/findSession()的真正实现,其实就是对hashmap的方法简单封装。

StandardManager

StandardManager 会启动一个后台线程,定期检查session是否过期(lastAccessTime < now - maxInactiveInterval), 如果过期了就调用session的expire()方法

PersistentManagerBase

你tomcat还是tomcat,一看到这个Persistent, 就能想到多个外部源,所以tomcat贴心的把外部源封装成一个Store对象。

PersistentManagerBase 会启动一个后台线程,动态检查 (1) Session是否过期 (2) Session要不要换出 (3) Session要不要备份。

换出的参数是 minIdleSwapmaxIdleSwap,简单理解如果内存里的Session对象太多了,就换出一点到磁盘里,类似OS的交换空间。同理,查找的时候如果内存里没找到,就要查存储系统。

DistributedManager

集群部署时的Session存储算是一个经典问题了,可以存储在外部系统里,比如Redis。当然Tomcat原生可以支持这个处理。

简单理解,这里有一个Cluster对象,如何服务发现、注册配置先不管,在创建Session时,会发送给其他节点,同时有一个后台线程负责接收其他结点发来的Session创建请求。

四、Store设计

Store同样是

  1. Store接口
  2. StoreBase类
  3. FileStore, JDBCStore具体实现

Store

  1. Store 关键方法就是save和load, 一个将Session从内存保存在磁盘,另一个反过来

StoreBase

实现了一个线程,负责对刷到磁盘的Session进行recycle和expire。

FileStore, JDBCStore

存储介质可以有很多。

五、总结

实际应用,需要让Valve对象能够访问到Context,这样才能让Request对象先获取context,然后从里面的Manager拿Session。

另外,我们在用tomcat的时候,其实也没有每次都初始化Bootstrap类,实际上这个Bootstrap类可以以xml文件的方式进行配置。

而且我们似乎没配置过关于Session的细节,不难理解都交给StandardManager和StandardSession做了,这就是约定大于配置。能用默认实现就用默认实现。当然,如何在启动时指定Session接口的实现类就是这个StandardSession呢,实际上可以通过SPI的机制。这下就串起来了。

另外,我们发现Servlet容器真的好用到翘jiojio,想要Request,Connector负责注入Request,想要Session,Manager负责注入Session。

实际上,这是一种反转控制的设计模式,我们想要的,可能需要的东西,直接管容器要就行,而不需要自己费劲巴拉的去创建。那么谁为我们创建了这些内容呢?自然是tomcat,tomcat把关键细节都封装到了容器里,对外直接暴露容器。

所以,程序入口(Bootstrap)交给配置文件实现,可能需要的组件都交给StandardXXX作为默认实现,能不配置就不配置。我们需要的实例都直接通过容器来拿,不需要自己创建。这就是tomcat的强大之处,能少写一点代码就少写一点代码(官话就是: 专注于servlet本身开发,而不关注其他细节。),后续的spring,springboot也提供了类似的功能。

为什么说tomcat是一个 servlet容器,而不是简单的webserver,答案就显而易见了。webserver的部分其实就是tomcat的Connector组件,而tomcat不止Connector。

10. 安全性

这里主要是Tomcat对 认证鉴权的支持,而不是那些web安全(比如XSS, CSRF)的防护。

一、权限验证体系

不必多说,熟悉Tomcat的都知道,这个功能肯定以组件的方式,要么作为某个组件的子组件,要么作为某个容器的组件。

作为Web应用的龙头组件Context,权限验证功能就是作为其组件存在的。

  1. Realm对象

Realm这里指领域, 也就是 "认证鉴权域" 对应的领域对象,在java层面就是一个bean。

提供了不同的存储媒介实现,比如基于内存的MemoryRealm, 基于jndi的 JNDIRealm

关键方法

  1. set/getContainer(): 将自己绑定到某个容器,主要是Context

  2. authenticate(): 对用户进行鉴权, Context对象最终会委托到这个方法上执行。

  3. Pricinpal对象

Realm对象进行认证的方法,需要依赖principal对象进行鉴权,Tomcat提供了基于role的权限体系。

这里,认证和鉴权两个概念要区分开

  1. 认证: 代表我是我,我通过用户名和密码登录,就是认证

  2. 鉴权: 我能干啥,鉴权体系有很多,比如基于role的鉴权(当然这种鉴权算是比较粗粒度的,还有更细的鉴权方案)

  3. LoginConfig

如果想开启鉴权,就需要在tomcat的配置上配置相关内容。

tomcat会将配置文件里的login-config 解析为该对象。主要决定了认证的手段(比如表单,或者BASIC,就是请求头里加Authorization)

  1. Authenticator接口

该接口是一个标记接口,用法就是,如果一个Valve实现该接口,后面可以根据 Valve isinstanceof Authenticator来判断这个Valve是不是处理认证的阀。

实际上,tomcat已经内置了实现了Authenticator接口的Valve对象。

AuthenticatorBase通过invoke方法,将authenticate()方法的执行,委托给具体的子类完成。这个设计模式叫模板方法,也就是 抽象类完成一定准备工作后,关键的,可能变化的逻辑交给子类实现。

二、AuthenticatorValve如何加入到pipeline

我们知道,容器组合一个Realm作为组件,只是获得了认证的方法,我们希望每个Servlet执行,都调用到这个方法,那不就是pipeline吗。

  1. ContextConfig 对象继承LifecycleListener

我们引入一个 ContextConfig 对象,用于完成 容器生命周期阶段的一些自动配置。

该对象会把自己注册到 Context.lifecycle上。 Context在start()阶段,会在真正启动之前fire一个BEFORE_START事件,此时ContextConfig执行下面的逻辑

1.1 根据context是否存在 安全约束,判断要不要安装验证阀,如果没有则退出

1.2 如果容器存在LoginConfig对象则退出,否则创建一个LoginConfig对象

1.3 检查容器是否存在一个实现了Authenticator接口的Valve,如果已经有则退出,否则加入这个Valve

1.4 反射出系统指定的AuthenticatorValve,将其注册到pipeline里。

AuthenticatorValve的逻辑就是,调用所在容器的Realm对象的 authenicate()方法。

简单理解就是,ContextConfig 完成了 AuthenticatorValve装配到pipeline,该Valve会调用其所在context的authenicate()方法,而context会把这个方法真正的调用委托给 其Realm组件的authenicate()。

Realm组件的authenicate() 会完成认证,也就是简单的验证username和password,如果失败返回null,那么如果AuthenticatorValve发现返回null了,直接拒绝执行,比如返回 401。如果成功返回一个Principal对象,这个对象可以理解为 一个授权(Authorization),当然它是个java对象,我们就理解成token就行,表示当前用户能做什么。

三、总结

我们简单理解下认证体系

首先,context通过realm组件,支持执行认证功能。认证失败则返回null,否则返回一个代表"当前用户具备的role"的Principal对象

然后,ContextConfig在容器启动前,会完成 我们指定的Authenticator实现类自动装配到pipeline的过程。这里不是0配置的,需要我们指定采取鉴权的方式,当然tomcat也可以"约定大于配置",也就是默认采取Basic鉴权,此时就是0配置的。

最后,AuthenticatorValve可以调用 其所在容器的authenticate()方法完成鉴权,当然我们知道,这个方法委托给了Realm组件。

Tomcat容器就是一个 桥梁,在这里,Valve对象作为组件绑定到容器,它就享受了容器的强大功能,包括使用容器的组件,当然这里不是直接使用组件,而是直接和容器交互,容器随后将请求委托到具体的组件。容器作为组件之间互访的桥梁,总体就是一个星形架构

AuthenticatorValve -> getContainer().authenticate()[中转站] -> realm.authenticate()

另外,我们也看到了 容器Lifecycle提供 的事件机制的强大性。可以在容器启动的必要阶段,完成一些配置的注入,这个注入是和容器启动解耦的。

转载自:https://juejin.cn/post/7377191678862049317
评论
请登录