本系列文章转自极客时间大佬李号双的专栏《深入拆解Tomcat & Jetty》

链接:https://time.geekbang.org/column/intro/100027701

仅供自己学习,联系我,侵删。

23 | Host容器:Tomcat如何实现热部署和热加载?

从这一期我们开始学习 Tomcat 的容器模块,来聊一聊各容器组件实现的功能,主要有热部署热加载、类加载机制以及 Servlet 规范的实现。最后还会谈到 Spring Boot 是如何与 Web 容器进行交互的。

今天我们首先来看热部署和热加载。要在运行的过程中升级 Web 应用,如果你不想重启系统,实现的方式有两种:热加载和热部署。

那如何实现热部署和热加载呢?它们跟类加载机制有关,具体来说就是:

  • 热加载的实现方式是 Web 容器启动一个后台线程,定期检测类文件的变化,如果有变化,就重新加载类,在这个过程中不会清空 Session ,一般用在开发环境。
  • 热部署原理类似,也是由后台线程定时检测 Web 应用的变化,但它会重新加载整个 Web 应用。这种方式会清空 Session,比热加载更加干净、彻底,一般用在生产环境。

今天我们来学习一下 Tomcat 是如何用后台线程来实现热加载和热部署的。Tomcat 通过开启后台线程,使得各个层次的容器组件都有机会完成一些周期性任务。我们在实际工作中,往往也需要执行一些周期性的任务,比如监控程序周期性拉取系统的健康状态,就可以借鉴这种设计。

Tomcat 的后台线程

要说开启后台线程做周期性的任务,有经验的同学马上会想到线程池中的 ScheduledThreadPoolExecutor,它除了具有线程池的功能,还能够执行周期性的任务。Tomcat 就是通过它来开启后台线程的:

bgFuture = exec.scheduleWithFixedDelay(
              new ContainerBackgroundProcessor(),// 要执行的 Runnable
              backgroundProcessorDelay, // 第一次执行延迟多久
              backgroundProcessorDelay, // 之后每次执行间隔多久
              TimeUnit.SECONDS);        // 时间单位

上面的代码调用了 scheduleWithFixedDelay 方法,传入了四个参数,第一个参数就是要周期性执行的任务类 ContainerBackgroundProcessor,它是一个 Runnable,同时也是 ContainerBase 的内部类,ContainerBase 是所有容器组件的基类,我们来回忆一下容器组件有哪些,有 Engine、Host、Context 和 Wrapper 等,它们具有父子关系。

ContainerBackgroundProcessor 实现

我们接来看 ContainerBackgroundProcessor 具体是如何实现的。

protected class ContainerBackgroundProcessor implements Runnable {
 
    @Override
    public void run() {
        // 请注意这里传入的参数是 " 宿主类 " 的实例
        processChildren(ContainerBase.this);
    }
 
    protected void processChildren(Container container) {
        try {
            //1. 调用当前容器的 backgroundProcess 方法。
            container.backgroundProcess();
            
            //2. 遍历所有的子容器,递归调用 processChildren,
            // 这样当前容器的子孙都会被处理            
            Container[] children = container.findChildren();
            for (int i = 0; i < children.length; i++) {
            // 这里请你注意,容器基类有个变量叫做 backgroundProcessorDelay,如果大于 0,表明子容器有自己的后台线程,无需父容器来调用它的 processChildren 方法。
                if (children[i].getBackgroundProcessorDelay() <= 0) {
                    processChildren(children[i]);
                }
            }
        } catch (Throwable t) { ... }

上面的代码逻辑也是比较清晰的,首先 ContainerBackgroundProcessor 是一个 Runnable,它需要实现 run 方法,它的 run 很简单,就是调用了 processChildren 方法。这里有个小技巧,它把“宿主类”,也就是ContainerBase 的类实例当成参数传给了 run 方法

而在 processChildren 方法里,就做了两步:调用当前容器的 backgroundProcess 方法,以及递归调用子孙的 backgroundProcess 方法。请你注意 backgroundProcess 是 Container 接口中的方法,也就是说所有类型的容器都可以实现这个方法,在这个方法里完成需要周期性执行的任务。

这样的设计意味着什么呢?我们只需要在顶层容器,也就是 Engine 容器中启动一个后台线程,那么这个线程不但会执行 Engine 容器的周期性任务,它还会执行所有子容器的周期性任务

backgroundProcess 方法

上述代码都是在基类 ContainerBase 中实现的,那具体容器类需要做什么呢?其实很简单,如果有周期性任务要执行,就实现 backgroundProcess 方法;如果没有,就重用基类 ContainerBase 的方法。ContainerBase 的 backgroundProcess 方法实现如下:

public void backgroundProcess() {
 
    //1. 执行容器中 Cluster 组件的周期性任务
    Cluster cluster = getClusterInternal();
    if (cluster != null) {
        cluster.backgroundProcess();
    }
    
    //2. 执行容器中 Realm 组件的周期性任务
    Realm realm = getRealmInternal();
    if (realm != null) {
        realm.backgroundProcess();
   }
   
   //3. 执行容器中 Valve 组件的周期性任务
    Valve current = pipeline.getFirst();
    while (current != null) {
       current.backgroundProcess();
       current = current.getNext();
    }
    
    //4. 触发容器的 " 周期事件 ",Host 容器的监听器 HostConfig 就靠它来调用
    fireLifecycleEvent(Lifecycle.PERIODIC_EVENT, null);
}

从上面的代码可以看到,不仅每个容器可以有周期性任务,每个容器中的其他通用组件,比如跟集群管理有关的 Cluster 组件、跟安全管理有关的 Realm 组件都可以有自己的周期性任务。

我在前面的专栏里提到过,容器之间的链式调用是通过 Pipeline-Valve 机制来实现的,从上面的代码你可以看到容器中的 Valve 也可以有周期性任务,并且被 ContainerBase 统一处理。

请你特别注意的是,在 backgroundProcess 方法的最后,还触发了容器的“周期事件”。我们知道容器的生命周期事件有初始化、启动和停止等,那“周期事件”又是什么呢?它跟生命周期事件一样,是一种扩展机制,你可以这样理解:

又一段时间过去了,容器还活着,你想做点什么吗?如果你想做点什么,就创建一个监听器来监听这个“周期事件”,事件到了我负责调用你的方法。

总之,有了 ContainerBase 中的后台线程和 backgroundProcess 方法,各种子容器和通用组件不需要各自弄一个后台线程来处理周期性任务,这样的设计显得优雅和整洁。

Tomcat 热加载

有了 ContainerBase 的周期性任务处理“框架”,作为具体容器子类,只需要实现自己的周期性任务就行。而 Tomcat 的热加载,就是在 Context 容器中实现的。Context 容器的 backgroundProcess 方法是这样实现的:

public void backgroundProcess() {
 
    //WebappLoader 周期性的检查 WEB-INF/classes 和 WEB-INF/lib 目录下的类文件
    Loader loader = getLoader();
    if (loader != null) {
        loader.backgroundProcess();        
    }
    
    //Session 管理器周期性的检查是否有过期的 Session
    Manager manager = getManager();
    if (manager != null) {
        manager.backgroundProcess();
    }
    
    // 周期性的检查静态资源是否有变化
    WebResourceRoot resources = getResources();
    if (resources != null) {
        resources.backgroundProcess();
    }
    
    // 调用父类 ContainerBase 的 backgroundProcess 方法
    super.backgroundProcess();
}

从上面的代码我们看到 Context 容器通过 WebappLoader 来检查类文件是否有更新,通过 Session 管理器来检查是否有 Session 过期,并且通过资源管理器来检查静态资源是否有更新,最后还调用了父类 ContainerBase 的 backgroundProcess 方法。

这里我们要重点关注,WebappLoader 是如何实现热加载的,它主要是调用了 Context 容器的 reload 方法,而 Context 的 reload 方法比较复杂,总结起来,主要完成了下面这些任务:

  1. 停止和销毁 Context 容器及其所有子容器,子容器其实就是 Wrapper,也就是说 Wrapper 里面 Servlet 实例也被销毁了。
  2. 停止和销毁 Context 容器关联的 Listener 和 Filter。
  3. 停止和销毁 Context 下的 Pipeline 和各种 Valve。
  4. 停止和销毁 Context 的类加载器,以及类加载器加载的类文件资源。
  5. 启动 Context 容器,在这个过程中会重新创建前面四步被销毁的资源。

在这个过程中,类加载器发挥着关键作用。一个 Context 容器对应一个类加载器,类加载器在销毁的过程中会把它加载的所有类也全部销毁。Context 容器在启动过程中,会创建一个新的类加载器来加载新的类文件。

在 Context 的 reload 方法里,并没有调用 Session 管理器的 distroy 方法,也就是说这个 Context 关联的 Session 是没有销毁的。你还需要注意的是,Tomcat 的热加载默认是关闭的,你需要在 conf 目录下的 Context.xml 文件中设置 reloadable 参数来开启这个功能,像下面这样:

<Context reloadable="true"/>

Tomcat 热部署

我们再来看看热部署,热部署跟热加载的本质区别是,热部署会重新部署 Web 应用,原来的 Context 对象会整个被销毁掉,因此这个 Context 所关联的一切资源都会被销毁,包括 Session。

那么 Tomcat 热部署又是由哪个容器来实现的呢?应该不是由 Context,因为热部署过程中 Context 容器被销毁了,那么这个重担就落在 Host 身上了,因为它是 Context 的父容器。

跟 Context 不一样,Host 容器并没有在 backgroundProcess 方法中实现周期性检测的任务,而是通过监听器 HostConfig 来实现的,HostConfig 就是前面提到的“周期事件”的监听器,那“周期事件”达到时,HostConfig 会做什么事呢?

public void lifecycleEvent(LifecycleEvent event) {
    // 执行 check 方法。
    if (event.getType().equals(Lifecycle.PERIODIC_EVENT)) {
        check();
    } 
}

它执行了 check 方法,我们接着来看 check 方法里做了什么。

protected void check() {
 
    if (host.getAutoDeploy()) {
        // 检查这个 Host 下所有已经部署的 Web 应用
        DeployedApplication[] apps =
            deployed.values().toArray(new DeployedApplication[0]);
            
        for (int i = 0; i < apps.length; i++) {
            // 检查 Web 应用目录是否有变化
            checkResources(apps[i], false);
        }
 
        // 执行部署
        deployApps();
    }
}

其实 HostConfig 会检查 webapps 目录下的所有 Web 应用:

  • 如果原来 Web 应用目录被删掉了,就把相应 Context 容器整个销毁掉。
  • 是否有新的 Web 应用目录放进来了,或者有新的 WAR 包放进来了,就部署相应的 Web 应用。

因此 HostConfig 做的事情都是比较“宏观”的,它不会去检查具体类文件或者资源文件是否有变化,而是检查 Web 应用目录级别的变化。

本期精华

今天我们学习 Tomcat 的热加载和热部署,它们的目的都是在不重启 Tomcat 的情况下实现 Web 应用的更新。

热加载的粒度比较小,主要是针对类文件的更新,通过创建新的类加载器来实现重新加载。而热部署是针对整个 Web 应用的,Tomcat 会将原来的 Context 对象整个销毁掉,再重新创建 Context 容器对象。

热加载和热部署的实现都离不开后台线程的周期性检查,Tomcat 在基类 ContainerBase 中统一实现了后台线程的处理逻辑,并在顶层容器 Engine 启动后台线程,这样子容器组件甚至各种通用组件都不需要自己去创建后台线程,这样的设计显得优雅整洁。

24 | Context容器(上):Tomcat如何打破双亲委托机制?

相信我们平时在工作中都遇到过 ClassNotFound 异常,这个异常表示 JVM 在尝试加载某个类的时候失败了。想要解决这个问题,首先你需要知道什么是类加载,JVM 是如何加载类的,以及为什么会出现 ClassNotFound 异常?弄懂上面这些问题之后,我们接着要思考 Tomcat 作为 Web 容器,它是如何加载和管理 Web 应用下的 Servlet 呢?

Tomcat 正是通过 Context 组件来加载管理 Web 应用的,所以今天我会详细分析 Tomcat 的类加载机制。但在这之前,我们有必要预习一下 JVM 的类加载机制,我会先回答一下一开始抛出来的问题,接着再谈谈 Tomcat 的类加载器如何打破 Java 的双亲委托机制。

JVM 的类加载器

Java 的类加载,就是把字节码格式“.class”文件加载到 JVM 的方法区,并在 JVM 的堆区建立一个java.lang.Class对象的实例,用来封装 Java 类相关的数据和方法。那 Class 对象又是什么呢?你可以把它理解成业务类的模板,JVM 根据这个模板来创建具体业务类对象实例。

JVM 并不是在启动时就把所有的“.class”文件都加载一遍,而是程序在运行过程中用到了这个类才去加载。JVM 类加载是由类加载器来完成的,JDK 提供一个抽象类 ClassLoader,这个抽象类中定义了三个关键方法,理解清楚它们的作用和关系非常重要。

public abstract class ClassLoader {
 
    // 每个类加载器都有个父加载器
    private final ClassLoader parent;
    
    public Class<?> loadClass(String name) {
  
        // 查找一下这个类是不是已经加载过了
        Class<?> c = findLoadedClass(name);
        
        // 如果没有加载过
        if( c == null ){
          // 先委托给父加载器去加载,注意这是个递归调用
          if (parent != null) {
              c = parent.loadClass(name);
          }else {
              // 如果父加载器为空,查找 Bootstrap 加载器是不是加载过了
              c = findBootstrapClassOrNull(name);
          }
        }
        // 如果父加载器没加载成功,调用自己的 findClass 去加载
        if (c == null) {
            c = findClass(name);
        }
        
        return c;
    }
    
    protected Class<?> findClass(String name){
       //1. 根据传入的类名 name,到在特定目录下去寻找类文件,把.class 文件读入内存
          ...
          
       //2. 调用 defineClass 将字节数组转成 Class 对象
       return defineClass(buf, off, len);
    }
    
    // 将字节码数组解析成一个 Class 对象,用 native 方法实现
    protected final Class<?> defineClass(byte[] b, int off, int len){
       ...
    }
}

从上面的代码我们可以得到几个关键信息:

  • JVM 的类加载器是分层次的,它们有父子关系,每个类加载器都持有一个 parent 字段,指向父加载器。
  • defineClass 是个工具方法,它的职责是调用 native 方法把 Java 类的字节码解析成一个 Class 对象,所谓的 native 方法就是由 C 语言实现的方法,Java 通过 JNI 机制调用。
  • findClass 方法的主要职责就是找到“.class”文件,可能来自文件系统或者网络,找到后把“.class”文件读到内存得到字节码数组,然后调用 defineClass 方法得到 Class 对象。
  • loadClass 是个 public 方法,说明它才是对外提供服务的接口,具体实现也比较清晰:首先检查这个类是不是已经被加载过了,如果加载过了直接返回,否则交给父加载器去加载。请你注意,这是一个递归调用,也就是说子加载器持有父加载器的引用,当一个类加载器需要加载一个 Java 类时,会先委托父加载器去加载,然后父加载器在自己的加载路径中搜索 Java 类,当父加载器在自己的加载范围内找不到时,才会交还给子加载器加载,这就是双亲委托机制。

JDK 中有哪些默认的类加载器?它们的本质区别是什么?为什么需要双亲委托机制?JDK 中有 3 个类加载器,另外你也可以自定义类加载器,它们的关系如下图所示。

  • BootstrapClassLoader 是启动类加载器,由 C 语言实现,用来加载 JVM 启动时所需要的核心类,比如rt.jarresources.jar等。
  • ExtClassLoader 是扩展类加载器,用来加载\jre\lib\ext目录下 JAR 包。
  • AppClassLoader 是系统类加载器,用来加载 classpath 下的类,应用程序默认用它来加载类。
  • 自定义类加载器,用来加载自定义路径下的类。

这些类加载器的工作原理是一样的,区别是它们的加载路径不同,也就是说 findClass 这个方法查找的路径不同。双亲委托机制是为了保证一个 Java 类在 JVM 中是唯一的,假如你不小心写了一个与 JRE 核心类同名的类,比如 Object 类,双亲委托机制能保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BootstrapClassLoader,BootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类。

这里请你注意,类加载器的父子关系不是通过继承来实现的,比如 AppClassLoader 并不是 ExtClassLoader 的子类,而是说 AppClassLoader 的 parent 成员变量指向 ExtClassLoader 对象。同样的道理,如果你要自定义类加载器,不去继承 AppClassLoader,而是继承 ClassLoader 抽象类,再重写 findClass 和 loadClass 方法即可,Tomcat 就是通过自定义类加载器来实现自己的类加载逻辑。不知道你发现没有,如果你要打破双亲委托机制,就需要重写 loadClass 方法,因为 loadClass 的默认实现就是双亲委托机制。

Tomcat 的类加载器

Tomcat 的自定义类加载器 WebAppClassLoader 打破了双亲委托机制,它首先自己尝试去加载某个类,如果找不到再代理给父类加载器,其目的是优先加载 Web 应用自己定义的类。具体实现就是重写 ClassLoader 的两个方法:findClass 和 loadClass。

findClass 方法

我们先来看看 findClass 方法的实现,为了方便理解和阅读,我去掉了一些细节:

public Class<?> findClass(String name) throws ClassNotFoundException {
    ...
    
    Class<?> clazz = null;
    try {
            //1. 先在 Web 应用目录下查找类 
            clazz = findClassInternal(name);
    }  catch (RuntimeException e) {
           throw e;
       }
    
    if (clazz == null) {
    try {
            //2. 如果在本地目录没有找到,交给父加载器去查找
            clazz = super.findClass(name);
    }  catch (RuntimeException e) {
           throw e;
       }
    
    //3. 如果父类也没找到,抛出 ClassNotFoundException
    if (clazz == null) {
        throw new ClassNotFoundException(name);
     }
 
    return clazz;
}

在 findClass 方法里,主要有三个步骤:

  1. 先在 Web 应用本地目录下查找要加载的类。
  2. 如果没有找到,交给父加载器去查找,它的父加载器就是上面提到的系统类加载器 AppClassLoader。
  3. 如何父加载器也没找到这个类,抛出 ClassNotFound 异常。

loadClass 方法

接着我们再来看 Tomcat 类加载器的 loadClass 方法的实现,同样我也去掉了一些细节:

public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
 
    synchronized (getClassLoadingLock(name)) {
 
        Class<?> clazz = null;
 
        //1. 先在本地 cache 查找该类是否已经加载过
        clazz = findLoadedClass0(name);
        if (clazz != null) {
            if (resolve)
                resolveClass(clazz);
            return clazz;
        }
 
        //2. 从系统类加载器的 cache 中查找是否加载过
        clazz = findLoadedClass(name);
        if (clazz != null) {
            if (resolve)
                resolveClass(clazz);
            return clazz;
        }
 
        // 3. 尝试用 ExtClassLoader 类加载器类加载,为什么?
        ClassLoader javaseLoader = getJavaseClassLoader();
        try {
            clazz = javaseLoader.loadClass(name);
            if (clazz != null) {
                if (resolve)
                    resolveClass(clazz);
                return clazz;
            }
        } catch (ClassNotFoundException e) {
            // Ignore
        }
 
        // 4. 尝试在本地目录搜索 class 并加载
        try {
            clazz = findClass(name);
            if (clazz != null) {
                if (resolve)
                    resolveClass(clazz);
                return clazz;
            }
        } catch (ClassNotFoundException e) {
            // Ignore
        }
 
        // 5. 尝试用系统类加载器 (也就是 AppClassLoader) 来加载
            try {
                clazz = Class.forName(name, false, parent);
                if (clazz != null) {
                    if (resolve)
                        resolveClass(clazz);
                    return clazz;
                }
            } catch (ClassNotFoundException e) {
                // Ignore
            }
       }
    
    //6. 上述过程都加载失败,抛出异常
    throw new ClassNotFoundException(name);
}

loadClass 方法稍微复杂一点,主要有六个步骤:

  1. 先在本地 Cache 查找该类是否已经加载过,也就是说 Tomcat 的类加载器是否已经加载过这个类。
  2. 如果 Tomcat 类加载器没有加载过这个类,再看看系统类加载器是否加载过。
  3. 如果都没有,就让ExtClassLoader去加载,这一步比较关键,目的防止 Web 应用自己的类覆盖 JRE 的核心类。因为 Tomcat 需要打破双亲委托机制,假如 Web 应用里自定义了一个叫 Object 的类,如果先加载这个 Object 类,就会覆盖 JRE 里面的那个 Object 类,这就是为什么 Tomcat 的类加载器会优先尝试用 ExtClassLoader 去加载,因为 ExtClassLoader 会委托给 BootstrapClassLoader 去加载,BootstrapClassLoader 发现自己已经加载了 Object 类,直接返回给 Tomcat 的类加载器,这样 Tomcat 的类加载器就不会去加载 Web 应用下的 Object 类了,也就避免了覆盖 JRE 核心类的问题。
  4. 如果 ExtClassLoader 加载器加载失败,也就是说 JRE 核心类中没有这类,那么就在本地 Web 应用目录下查找并加载。
  5. 如果本地目录下没有这个类,说明不是 Web 应用自己定义的类,那么由系统类加载器去加载。这里请你注意,Web 应用是通过Class.forName调用交给系统类加载器的,因为Class.forName的默认加载器就是系统类加载器。
  6. 如果上述加载过程全部失败,抛出 ClassNotFound 异常。

从上面的过程我们可以看到,Tomcat 的类加载器打破了双亲委托机制,没有一上来就直接委托给父加载器,而是先在本地目录下加载,为了避免本地目录下的类覆盖 JRE 的核心类,先尝试用 JVM 扩展类加载器 ExtClassLoader 去加载。那为什么不先用系统类加载器 AppClassLoader 去加载?很显然,如果是这样的话,那就变成双亲委托机制了,这就是 Tomcat 类加载器的巧妙之处。

本期精华

今天我介绍了 JVM 的类加载器原理和源码剖析,以及 Tomcat 的类加载器是如何打破双亲委托机制的,目的是为了优先加载 Web 应用目录下的类,然后再加载其他目录下的类,这也是 Servlet 规范的推荐做法。

要打破双亲委托机制,需要继承 ClassLoader 抽象类,并且需要重写它的 loadClass 方法,因为 ClassLoader 的默认实现就是双亲委托。

25 | Context容器(中):Tomcat如何隔离Web应用?

我在专栏上一期提到,Tomcat 通过自定义类加载器 WebAppClassLoader 打破了双亲委托机制,具体来说就是重写了 JVM 的类加载器 ClassLoader 的 findClass 方法和 loadClass 方法,这样做的目的是优先加载 Web 应用目录下的类。除此之外,你觉得 Tomcat 的类加载器还需要完成哪些需求呢?或者说在设计上还需要考虑哪些方面?

我们知道,Tomcat 作为 Servlet 容器,它负责加载我们的 Servlet 类,此外它还负责加载 Servlet 所依赖的 JAR 包。并且 Tomcat 本身也是也是一个 Java 程序,因此它需要加载自己的类和依赖的 JAR 包。首先让我们思考这一下这几个问题:

  1. 假如我们在 Tomcat 中运行了两个 Web 应用程序,两个 Web 应用中有同名的 Servlet,但是功能不同,Tomcat 需要同时加载和管理这两个同名的 Servlet 类,保证它们不会冲突,因此 Web 应用之间的类需要隔离。
  2. 假如两个 Web 应用都依赖同一个第三方的 JAR 包,比如 Spring,那 Spring 的 JAR 包被加载到内存后,Tomcat 要保证这两个 Web 应用能够共享,也就是说 Spring 的 JAR 包只被加载一次,否则随着依赖的第三方 JAR 包增多,JVM 的内存会膨胀。
  3. 跟 JVM 一样,我们需要隔离 Tomcat 本身的类和 Web 应用的类。

在了解了 Tomcat 的类加载器在设计时要考虑的这些问题以后,今天我们主要来学习一下 Tomcat 是如何通过设计多层次的类加载器来解决这些问题的。

Tomcat 类加载器的层次结构

为了解决这些问题,Tomcat 设计了类加载器的层次结构,它们的关系如下图所示。下面我来详细解释为什么要设计这些类加载器,告诉你它们是怎么解决上面这些问题的。

我们先来看第 1 个问题,假如我们使用 JVM 默认 AppClassLoader 来加载 Web 应用,AppClassLoader 只能加载一个 Servlet 类,在加载第二个同名 Servlet 类时,AppClassLoader 会返回第一个 Servlet 类的 Class 实例,这是因为在 AppClassLoader 看来,同名的 Servlet 类只被加载一次。

因此 Tomcat 的解决方案是自定义一个类加载器 WebAppClassLoader, 并且给每个 Web 应用创建一个类加载器实例。我们知道,Context 容器组件对应一个 Web 应用,因此,每个 Context 容器负责创建和维护一个 WebAppClassLoader 加载器实例。这背后的原理是,不同的加载器实例加载的类被认为是不同的类,即使它们的类名相同。这就相当于在 Java 虚拟机内部创建了一个个相互隔离的 Java 类空间,每一个 Web 应用都有自己的类空间,Web 应用之间通过各自的类加载器互相隔离。

SharedClassLoader

我们再来看第 2 个问题,本质需求是两个 Web 应用之间怎么共享库类,并且不能重复加载相同的类。我们知道,在双亲委托机制里,各个子加载器都可以通过父加载器去加载类,那么把需要共享的类放到父加载器的加载路径下不就行了吗,应用程序也正是通过这种方式共享 JRE 的核心类。因此 Tomcat 的设计者又加了一个类加载器 SharedClassLoader,作为 WebAppClassLoader 的父加载器,专门来加载 Web 应用之间共享的类。如果 WebAppClassLoader 自己没有加载到某个类,就会委托父加载器 SharedClassLoader 去加载这个类,SharedClassLoader 会在指定目录下加载共享类,之后返回给 WebAppClassLoader,这样共享的问题就解决了。

CatalinaClassloader

我们来看第 3 个问题,如何隔离 Tomcat 本身的类和 Web 应用的类?我们知道,要共享可以通过父子关系,要隔离那就需要兄弟关系了。兄弟关系就是指两个类加载器是平行的,它们可能拥有同一个父加载器,但是两个兄弟类加载器加载的类是隔离的。基于此 Tomcat 又设计一个类加载器 CatalinaClassloader,专门来加载 Tomcat 自身的类。这样设计有个问题,那 Tomcat 和各 Web 应用之间需要共享一些类时该怎么办呢?

CommonClassLoader

老办法,还是再增加一个 CommonClassLoader,作为 CatalinaClassloader 和 SharedClassLoader 的父加载器。CommonClassLoader 能加载的类都可以被 CatalinaClassLoader 和 SharedClassLoader 使用,而 CatalinaClassLoader 和 SharedClassLoader 能加载的类则与对方相互隔离。WebAppClassLoader 可以使用 SharedClassLoader 加载到的类,但各个 WebAppClassLoader 实例之间相互隔离。

Spring 的加载问题

在 JVM 的实现中有一条隐含的规则,默认情况下,如果一个类由类加载器 A 加载,那么这个类的依赖类也是由相同的类加载器加载。比如 Spring 作为一个 Bean 工厂,它需要创建业务类的实例,并且在创建业务类实例之前需要加载这些类。Spring 是通过调用Class.forName来加载业务类的,我们来看一下 forName 的源码:

public static Class<?> forName(String className) {
    Class<?> caller = Reflection.getCallerClass();
    return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}

可以看到在 forName 的函数里,会用调用者也就是 Spring 的加载器去加载业务类。

我在前面提到,Web 应用之间共享的 JAR 包可以交给 SharedClassLoader 来加载,从而避免重复加载。Spring 作为共享的第三方 JAR 包,它本身是由 SharedClassLoader 来加载的,Spring 又要去加载业务类,按照前面那条规则,加载 Spring 的类加载器也会用来加载业务类,但是业务类在 Web 应用目录下,不在 SharedClassLoader 的加载路径下,这该怎么办呢?

于是线程上下文加载器登场了,它其实是一种类加载器传递机制。为什么叫作“线程上下文加载器”呢,因为这个类加载器保存在线程私有数据里,只要是同一个线程,一旦设置了线程上下文加载器,在线程后续执行过程中就能把这个类加载器取出来用。因此 Tomcat 为每个 Web 应用创建一个 WebAppClassLoarder 类加载器,并在启动 Web 应用的线程里设置线程上下文加载器,这样 Spring 在启动时就将线程上下文加载器取出来,用来加载 Bean。Spring 取线程上下文加载的代码如下:

cl = Thread.currentThread().getContextClassLoader();

本期精华

今天我介绍了 JVM 的类加载器原理并剖析了源码,以及 Tomcat 的类加载器的设计。重点需要你理解的是,Tomcat 的 Context 组件为每个 Web 应用创建一个 WebAppClassLoarder 类加载器,由于不同类加载器实例加载的类是互相隔离的,因此达到了隔离 Web 应用的目的,同时通过 CommonClassLoader 等父加载器来共享第三方 JAR 包。而共享的第三方 JAR 包怎么加载特定 Web 应用的类呢?可以通过设置线程上下文加载器来解决。而作为 Java 程序员,我们应该牢记的是:

  • 每个 Web 应用自己的 Java 类文件和依赖的 JAR 包,分别放在WEB-INF/classesWEB-INF/lib目录下面。
  • 多个应用共享的 Java 类文件和 JAR 包,分别放在 Web 容器指定的共享目录下。
  • 当出现 ClassNotFound 错误时,应该检查你的类加载器是否正确。

线程上下文加载器不仅仅可以用在 Tomcat 和 Spring 类加载的场景里,核心框架类需要加载具体实现类时都可以用到它,比如我们熟悉的 JDBC 就是通过上下文类加载器来加载不同的数据库驱动的,感兴趣的话可以深入了解一下。

26 | Context容器(下):Tomcat如何实现Servlet规范?

我们知道,Servlet 容器最重要的任务就是创建 Servlet 的实例并且调用 Servlet,在前面两期我谈到了 Tomcat 如何定义自己的类加载器来加载 Servlet,但加载 Servlet 的类不等于创建 Servlet 的实例,类加载只是第一步,类加载好了才能创建类的实例,也就是说 Tomcat 先加载 Servlet 的类,然后在 Java 堆上创建了一个 Servlet 实例。

一个 Web 应用里往往有多个 Servlet,而在 Tomcat 中一个 Web 应用对应一个 Context 容器,也就是说一个 Context 容器需要管理多个 Servlet 实例。但 Context 容器并不直接持有 Servlet 实例,而是通过子容器 Wrapper 来管理 Servlet,你可以把 Wrapper 容器看作是 Servlet 的包装。

那为什么需要 Wrapper 呢?Context 容器直接维护一个 Servlet 数组不就行了吗?这是因为 Servlet 不仅仅是一个类实例,它还有相关的配置信息,比如它的 URL 映射、它的初始化参数,因此设计出了一个包装器,把 Servlet 本身和它相关的数据包起来,没错,这就是面向对象的思想。

那管理好 Servlet 就完事大吉了吗?别忘了 Servlet 还有两个兄弟:Listener 和 Filter,它们也是 Servlet 规范中的重要成员,因此 Tomcat 也需要创建它们的实例,也需要在合适的时机去调用它们的方法。

说了那么多,下面我们就来聊一聊是 Tomcat 如何做到上面这些事的。

Servlet 管理

前面提到,Tomcat 是用 Wrapper 容器来管理 Servlet 的,那 Wrapper 容器具体长什么样子呢?我们先来看看它里面有哪些关键的成员变量:

protected volatile Servlet instance = null;

毫无悬念,它拥有一个 Servlet 实例,并且 Wrapper 通过 loadServlet 方法来实例化 Servlet。为了方便你阅读,我简化了代码:

public synchronized Servlet loadServlet() throws ServletException {
    Servlet servlet;
  
    //1. 创建一个 Servlet 实例
    servlet = (Servlet) instanceManager.newInstance(servletClass);    
    
    //2. 调用了 Servlet 的 init 方法,这是 Servlet 规范要求的
    initServlet(servlet);
    
    return servlet;
}

其实 loadServlet 主要做了两件事:创建 Servlet 的实例,并且调用 Servlet 的 init 方法,因为这是 Servlet 规范要求的。

那接下来的问题是,什么时候会调到这个 loadServlet 方法呢?为了加快系统的启动速度,我们往往会采取资源延迟加载的策略,Tomcat 也不例外,默认情况下 Tomcat 在启动时不会加载你的 Servlet,除非你把 Servlet 的loadOnStartup参数设置为true

这里还需要你注意的是,虽然 Tomcat 在启动时不会创建 Servlet 实例,但是会创建 Wrapper 容器,就好比尽管枪里面还没有子弹,先把枪造出来。那子弹什么时候造呢?是真正需要开枪的时候,也就是说有请求来访问某个 Servlet 时,这个 Servlet 的实例才会被创建。

那 Servlet 是被谁调用的呢?我们回忆一下专栏前面提到过 Tomcat 的 Pipeline-Valve 机制,每个容器组件都有自己的 Pipeline,每个 Pipeline 中有一个 Valve 链,并且每个容器组件有一个 BasicValve(基础阀)。Wrapper 作为一个容器组件,它也有自己的 Pipeline 和 BasicValve,Wrapper 的 BasicValve 叫StandardWrapperValve

你可以想到,当请求到来时,Context 容器的 BasicValve 会调用 Wrapper 容器中 Pipeline 中的第一个 Valve,然后会调用到 StandardWrapperValve。我们先来看看它的 invoke 方法是如何实现的,同样为了方便你阅读,我简化了代码:

public final void invoke(Request request, Response response) {
 
    //1. 实例化 Servlet
    servlet = wrapper.allocate();
   
    //2. 给当前请求创建一个 Filter 链
    ApplicationFilterChain filterChain =
        ApplicationFilterFactory.createFilterChain(request, wrapper, servlet);
 
   //3. 调用这个 Filter 链,Filter 链中的最后一个 Filter 会调用 Servlet
   filterChain.doFilter(request.getRequest(), response.getResponse());
 
}

StandardWrapperValve 的 invoke 方法比较复杂,去掉其他异常处理的一些细节,本质上就是三步:

  • 第一步,创建 Servlet 实例;
  • 第二步,给当前请求创建一个 Filter 链;
  • 第三步,调用这个 Filter 链。

你可能会问,为什么需要给每个请求创建一个 Filter 链?这是因为每个请求的请求路径都不一样,而 Filter 都有相应的路径映射,因此不是所有的 Filter 都需要来处理当前的请求,我们需要根据请求的路径来选择特定的一些 Filter 来处理。

第二个问题是,为什么没有看到调到 Servlet 的 service 方法?这是因为 Filter 链的 doFilter 方法会负责调用 Servlet,具体来说就是 Filter 链中的最后一个 Filter 会负责调用 Servlet。

接下来我们来看 Filter 的实现原理。

Filter 管理

我们知道,跟 Servlet 一样,Filter 也可以在web.xml文件里进行配置,不同的是,Filter 的作用域是整个 Web 应用,因此 Filter 的实例是在 Context 容器中进行管理的,Context 容器用 Map 集合来保存 Filter。

private Map<String, FilterDef> filterDefs = new HashMap<>();

那上面提到的 Filter 链又是什么呢?Filter 链的存活期很短,它是跟每个请求对应的。一个新的请求来了,就动态创建一个 FIlter 链,请求处理完了,Filter 链也就被回收了。理解它的原理也非常关键,我们还是来看看源码:

public final class ApplicationFilterChain implements FilterChain {
  
  //Filter 链中有 Filter 数组,这个好理解
  private ApplicationFilterConfig[] filters = new ApplicationFilterConfig[0];
    
  //Filter 链中的当前的调用位置
  private int pos = 0;
    
  // 总共有多少了 Filter
  private int n = 0;
 
  // 每个 Filter 链对应一个 Servlet,也就是它要调用的 Servlet
  private Servlet servlet = null;
  
  public void doFilter(ServletRequest req, ServletResponse res) {
        internalDoFilter(request,response);
  }
   
  private void internalDoFilter(ServletRequest req,
                                ServletResponse res){
 
    // 每个 Filter 链在内部维护了一个 Filter 数组
    if (pos < n) {
        ApplicationFilterConfig filterConfig = filters[pos++];
        Filter filter = filterConfig.getFilter();
 
        filter.doFilter(request, response, this);
        return;
    }
 
    servlet.service(request, response);
   
}

从 ApplicationFilterChain 的源码我们可以看到几个关键信息:

  1. Filter 链中除了有 Filter 对象的数组,还有一个整数变量 pos,这个变量用来记录当前被调用的 Filter 在数组中的位置。
  2. Filter 链中有个 Servlet 实例,这个好理解,因为上面提到了,每个 Filter 链最后都会调到一个 Servlet。
  3. Filter 链本身也实现了 doFilter 方法,直接调用了一个内部方法 internalDoFilter。
  4. internalDoFilter 方法的实现比较有意思,它做了一个判断,如果当前 Filter 的位置小于 Filter 数组的长度,也就是说 Filter 还没调完,就从 Filter 数组拿下一个 Filter,调用它的 doFilter 方法。否则,意味着所有 Filter 都调到了,就调用 Servlet 的 service 方法。

但问题是,方法体里没看到循环,谁在不停地调用 Filter 链的 doFIlter 方法呢?Filter 是怎么依次调到的呢?

答案是Filter 本身的 doFilter 方法会调用 Filter 链的 doFilter 方法,我们还是来看看代码就明白了:

public void doFilter(ServletRequest request, ServletResponse response,
        FilterChain chain){
        
          ...
          
          // 调用 Filter 的方法
          chain.doFilter(request, response);
      
      }

注意 Filter 的 doFilter 方法有个关键参数 FilterChain,就是 Filter 链。并且每个 Filter 在实现 doFilter 时,必须要调用 Filter 链的 doFilter 方法,而 Filter 链中保存当前 FIlter 的位置,会调用下一个 FIlter 的 doFilter 方法,这样链式调用就完成了。

Filter 链跟 Tomcat 的 Pipeline-Valve 本质都是责任链模式,但是在具体实现上稍有不同,你可以细细体会一下。

Listener 管理

我们接着聊 Servlet 规范里 Listener。跟 Filter 一样,Listener 也是一种扩展机制,你可以监听容器内部发生的事件,主要有两类事件:

  • 第一类是生命状态的变化,比如 Context 容器启动和停止、Session 的创建和销毁。
  • 第二类是属性的变化,比如 Context 容器某个属性值变了、Session 的某个属性值变了以及新的请求来了等。

我们可以在web.xml配置或者通过注解的方式来添加监听器,在监听器里实现我们的业务逻辑。对于 Tomcat 来说,它需要读取配置文件,拿到监听器类的名字,实例化这些类,并且在合适的时机调用这些监听器的方法。

Tomcat 是通过 Context 容器来管理这些监听器的。Context 容器将两类事件分开来管理,分别用不同的集合来存放不同类型事件的监听器:

// 监听属性值变化的监听器
private List<Object> applicationEventListenersList = new CopyOnWriteArrayList<>();
 
// 监听生命事件的监听器
private Object applicationLifecycleListenersObjects[] = new Object[0];

剩下的事情就是触发监听器了,比如在 Context 容器的启动方法里,就触发了所有的 ServletContextListener:

//1. 拿到所有的生命周期监听器
Object instances[] = getApplicationLifecycleListeners();
 
for (int i = 0; i < instances.length; i++) {
   //2. 判断 Listener 的类型是不是 ServletContextListener
   if (!(instances[i] instanceof ServletContextListener))
      continue;
 
   //3. 触发 Listener 的方法
   ServletContextListener lr = (ServletContextListener) instances[i];
   lr.contextInitialized(event);
}

需要注意的是,这里的 ServletContextListener 接口是一种留给用户的扩展机制,用户可以实现这个接口来定义自己的监听器,监听 Context 容器的启停事件。Spring 就是这么做的。ServletContextListener 跟 Tomcat 自己的生命周期事件 LifecycleListener 是不同的。LifecycleListener 定义在生命周期管理组件中,由基类 LifeCycleBase 统一管理。

本期精华

Servlet 规范中最重要的就是 Servlet、Filter 和 Listener“三兄弟”。Web 容器最重要的职能就是把它们创建出来,并在适当的时候调用它们的方法。

Tomcat 通过 Wrapper 容器来管理 Servlet,Wrapper 包装了 Servlet 本身以及相应的参数,这体现了面向对象中“封装”的设计原则。

Tomcat 会给每个请求生成一个 Filter 链,Filter 链中的最后一个 Filter 会负责调用 Servlet 的 service 方法。

对于 Listener 来说,我们可以定制自己的监听器来监听 Tomcat 内部发生的各种事件:包括 Web 应用级别的、Session 级别的和请求级别的。Tomcat 中的 Context 容器统一维护了这些监听器,并负责触发。

最后小结一下这 3 期内容,Context 组件通过自定义类加载器来加载 Web 应用,并实现了 Servlet 规范,直接跟 Web 应用打交道,是一个核心的容器组件。也因此我用了很重的篇幅去讲解它,也非常建议你花点时间阅读一下它的源码。

27 | 新特性:Tomcat如何支持异步Servlet?

通过专栏前面的学习我们知道,当一个新的请求到达时,Tomcat 和 Jetty 会从线程池里拿出一个线程来处理请求,这个线程会调用你的 Web 应用,Web 应用在处理请求的过程中,Tomcat 线程会一直阻塞,直到 Web 应用处理完毕才能再输出响应,最后 Tomcat 才回收这个线程。

我们来思考这样一个问题,假如你的 Web 应用需要较长的时间来处理请求(比如数据库查询或者等待下游的服务调用返回),那么 Tomcat 线程一直不回收,会占用系统资源,在极端情况下会导致“线程饥饿”,也就是说 Tomcat 和 Jetty 没有更多的线程来处理新的请求。

那该如何解决这个问题呢?方案是 Servlet 3.0 中引入的异步 Servlet。主要是在 Web 应用里启动一个单独的线程来执行这些比较耗时的请求,而 Tomcat 线程立即返回,不再等待 Web 应用将请求处理完,这样 Tomcat 线程可以立即被回收到线程池,用来响应其他请求,降低了系统的资源消耗,同时还能提高系统的吞吐量。

今天我们就来学习一下如何开发一个异步 Servlet,以及异步 Servlet 的工作原理,也就是 Tomcat 是如何支持异步 Servlet 的,让你彻底理解它的来龙去脉。

异步 Servlet 示例

我们先通过一个简单的示例来了解一下异步 Servlet 的实现。

@WebServlet(urlPatterns = {"/async"}, asyncSupported = true)
public class AsyncServlet extends HttpServlet {
 
    //Web 应用线程池,用来处理异步 Servlet
    ExecutorService executor = Executors.newSingleThreadExecutor();
 
    public void service(HttpServletRequest req, HttpServletResponse resp) {
        //1. 调用 startAsync 或者异步上下文
        final AsyncContext ctx = req.startAsync();
 
       // 用线程池来执行耗时操作
        executor.execute(new Runnable() {
 
            @Override
            public void run() {
 
                // 在这里做耗时的操作
                try {
                    ctx.getResponse().getWriter().println("Handling Async Servlet");
                } catch (IOException e) {}
 
                //3. 异步 Servlet 处理完了调用异步上下文的 complete 方法
                ctx.complete();
            }
 
        });
    }
}

上面的代码有三个要点:

  1. 通过注解的方式来注册 Servlet,除了 @WebServlet 注解,还需要加上 asyncSupported=true 的属性,表明当前的 Servlet 是一个异步 Servlet。
  2. Web 应用程序需要调用 Request 对象的 startAsync 方法来拿到一个异步上下文 AsyncContext。这个上下文保存了请求和响应对象。
  3. Web 应用需要开启一个新线程来处理耗时的操作,处理完成后需要调用 AsyncContext 的 complete 方法。目的是告诉 Tomcat,请求已经处理完成。

这里请你注意,虽然异步 Servlet 允许用更长的时间来处理请求,但是也有超时限制的,默认是 30 秒,如果 30 秒内请求还没处理完,Tomcat 会触发超时机制,向浏览器返回超时错误,如果这个时候你的 Web 应用再调用ctx.complete方法,会得到一个 IllegalStateException 异常。

异步 Servlet 原理

通过上面的例子,相信你对 Servlet 的异步实现有了基本的理解。要理解 Tomcat 在这个过程都做了什么事情,关键就是要弄清楚req.startAsync方法和ctx.complete方法都做了什么。

startAsync 方法

startAsync 方法其实就是创建了一个异步上下文 AsyncContext 对象,AsyncContext 对象的作用是保存请求的中间信息,比如 Request 和 Response 对象等上下文信息。你来思考一下为什么需要保存这些信息呢?

这是因为 Tomcat 的工作线程在Request.startAsync调用之后,就直接结束回到线程池中了,线程本身不会保存任何信息。也就是说一个请求到服务端,执行到一半,你的 Web 应用正在处理,这个时候 Tomcat 的工作线程没了,这就需要有个缓存能够保存原始的 Request 和 Response 对象,而这个缓存就是 AsyncContext。

有了 AsyncContext,你的 Web 应用通过它拿到 request 和 response 对象,拿到 Request 对象后就可以读取请求信息,请求处理完了还需要通过 Response 对象将 HTTP 响应发送给浏览器。

除了创建 AsyncContext 对象,startAsync 还需要完成一个关键任务,那就是告诉 Tomcat 当前的 Servlet 处理方法返回时,不要把响应发到浏览器,因为这个时候,响应还没生成呢;并且不能把 Request 对象和 Response 对象销毁,因为后面 Web 应用还要用呢。

在 Tomcat 中,负责 flush 响应数据的是 CoyoteAdaptor,它还会销毁 Request 对象和 Response 对象,因此需要通过某种机制通知 CoyoteAdaptor,具体来说是通过下面这行代码:

this.request.getCoyoteRequest().action(ActionCode.ASYNC_START, this);

你可以把它理解为一个 Callback,在这个 action 方法里设置了 Request 对象的状态,设置它为一个异步 Servlet 请求。

我们知道连接器是调用 CoyoteAdapter 的 service 方法来处理请求的,而 CoyoteAdapter 会调用容器的 service 方法,当容器的 service 方法返回时,CoyoteAdapter 判断当前的请求是不是异步 Servlet 请求,如果是,就不会销毁 Request 和 Response 对象,也不会把响应信息发到浏览器。你可以通过下面的代码理解一下,这是 CoyoteAdapter 的 service 方法,我对它进行了简化:

public void service(org.apache.coyote.Request req, org.apache.coyote.Response res) {
    
   // 调用容器的 service 方法处理请求
    connector.getService().getContainer().getPipeline().
           getFirst().invoke(request, response);
   
   // 如果是异步 Servlet 请求,仅仅设置一个标志,
   // 否则说明是同步 Servlet 请求,就将响应数据刷到浏览器
    if (request.isAsync()) {
        async = true;
    } else {
        request.finishRequest();
        response.finishResponse();
    }
   
   // 如果不是异步 Servlet 请求,就销毁 Request 对象和 Response 对象
    if (!async) {
        request.recycle();
        response.recycle();
    }
}

接下来,当 CoyoteAdaptor 的 service 方法返回到 ProtocolHandler 组件时,ProtocolHandler 判断返回值,如果当前请求是一个异步 Servlet 请求,它会把当前 Socket 的协议处理者 Processor 缓存起来,将 SocketWrapper 对象和相应的 Processor 存到一个 Map 数据结构里。

private final Map<S,Processor> connections = new ConcurrentHashMap<>();

之所以要缓存是因为这个请求接下来还要接着处理,还是由原来的 Processor 来处理,通过 SocketWrapper 就能从 Map 里找到相应的 Processor。

complete 方法

接着我们再来看关键的ctx.complete方法,当请求处理完成时,Web 应用调用这个方法。那么这个方法做了些什么事情呢?最重要的就是把响应数据发送到浏览器。

这件事情不能由 Web 应用线程来做,也就是说ctx.complete方法不能直接把响应数据发送到浏览器,因为这件事情应该由 Tomcat 线程来做,但具体怎么做呢?

我们知道,连接器中的 Endpoint 组件检测到有请求数据达到时,会创建一个 SocketProcessor 对象交给线程池去处理,因此 Endpoint 的通信处理和具体请求处理在两个线程里运行。

在异步 Servlet 的场景里,Web 应用通过调用ctx.complete方法时,也可以生成一个新的 SocketProcessor 任务类,交给线程池处理。对于异步 Servlet 请求来说,相应的 Socket 和协议处理组件 Processor 都被缓存起来了,并且这些对象都可以通过 Request 对象拿到。

讲到这里,你可能已经猜到ctx.complete是如何实现的了:

public void complete() {
    // 检查状态合法性,我们先忽略这句
    check();
    
    // 调用 Request 对象的 action 方法,其实就是通知连接器,这个异步请求处理完了
request.getCoyoteRequest().action(ActionCode.ASYNC_COMPLETE, null);
    
}

我们可以看到 complete 方法调用了 Request 对象的 action 方法。而在 action 方法里,则是调用了 Processor 的 processSocketEvent 方法,并且传入了操作码 OPEN_READ。

case ASYNC_COMPLETE: {
    clearDispatches();
    if (asyncStateMachine.asyncComplete()) {
        processSocketEvent(SocketEvent.OPEN_READ, true);
    }
    break;
}

我们接着看 processSocketEvent 方法,它调用 SocketWrapper 的 processSocket 方法:

protected void processSocketEvent(SocketEvent event, boolean dispatch) {
    SocketWrapperBase<?> socketWrapper = getSocketWrapper();
    if (socketWrapper != null) {
        socketWrapper.processSocket(event, dispatch);
    }
}

而 SocketWrapper 的 processSocket 方法会创建 SocketProcessor 任务类,并通过 Tomcat 线程池来处理:

public boolean processSocket(SocketWrapperBase<S> socketWrapper,
        SocketEvent event, boolean dispatch) {
        
      if (socketWrapper == null) {
          return false;
      }
      
      SocketProcessorBase<S> sc = processorCache.pop();
      if (sc == null) {
          sc = createSocketProcessor(socketWrapper, event);
      } else {
          sc.reset(socketWrapper, event);
      }
      // 线程池运行
      Executor executor = getExecutor();
      if (dispatch && executor != null) {
          executor.execute(sc);
      } else {
          sc.run();
      }
}

请你注意 createSocketProcessor 函数的第二个参数是 SocketEvent,这里我们传入的是 OPEN_READ。通过这个参数,我们就能控制 SocketProcessor 的行为,因为我们不需要再把请求发送到容器进行处理,只需要向浏览器端发送数据,并且重新在这个 Socket 上监听新的请求就行了。

最后我通过一张在帮你理解一下整个过程:

本期精华

非阻塞 I/O 模型可以利用很少的线程处理大量的连接,提高了并发度,本质就是通过一个 Selector 线程查询多个 Socket 的 I/O 事件,减少了线程的阻塞等待。

同样,异步 Servlet 机制也是减少了线程的阻塞等待,将 Tomcat 线程和业务线程分开,Tomca 线程不再等待业务代码的执行。

那什么样的场景适合异步 Servlet 呢?适合的场景有很多,最主要的还是根据你的实际情况,如果你拿不准是否适合异步 Servlet,就看一条:如果你发现 Tomcat 的线程不够了,大量线程阻塞在等待 Web 应用的处理上,而 Web 应用又没有优化的空间了,确实需要长时间处理,这个时候你不妨尝试一下异步 Servlet。

28 | 新特性:Tomcat和Jetty如何处理Spring Boot应用?

为了方便开发和部署,Spring Boot 在内部启动了一个嵌入式的 Web 容器。我们知道 Tomcat 和 Jetty 是组件化的设计,要启动 Tomcat 或者 Jetty 其实就是启动这些组件。在 Tomcat 独立部署的模式下,我们通过 startup 脚本来启动 Tomcat,Tomcat 中的 Bootstrap 和 Catalina 会负责初始化类加载器,并解析server.xml和启动这些组件。

在内嵌式的模式下,Bootstrap 和 Catalina 的工作就由 Spring Boot 来做了,Spring Boot 调用了 Tomcat 和 Jetty 的 API 来启动这些组件。那 Spring Boot 具体是怎么做的呢?而作为程序员,我们如何向 SpringBoot 中的 Tomcat 注册 Servlet 或者 Filter 呢?我们又如何定制内嵌式的 Tomcat?今天我们就来聊聊这些话题。

Spring Boot 中 Web 容器相关的接口

既然要支持多种 Web 容器,Spring Boot 对内嵌式 Web 容器进行了抽象,定义了WebServer接口:

public interface WebServer {
    void start() throws WebServerException;
    void stop() throws WebServerException;
    int getPort();
}

各种 Web 容器比如 Tomcat 和 Jetty 需要去实现这个接口。

Spring Boot 还定义了一个工厂ServletWebServerFactory来创建 Web 容器,返回的对象就是上面提到的 WebServer。

public interface ServletWebServerFactory {
    WebServer getWebServer(ServletContextInitializer... initializers);
}

可以看到 getWebServer 有个参数,类型是ServletContextInitializer。它表示 ServletContext 的初始化器,用于 ServletContext 中的一些配置:

public interface ServletContextInitializer {
    void onStartup(ServletContext servletContext) throws ServletException;
}

这里请注意,上面提到的 getWebServer 方法会调用 ServletContextInitializer 的 onStartup 方法,也就是说如果你想在 Servlet 容器启动时做一些事情,比如注册你自己的 Servlet,可以实现一个 ServletContextInitializer,在 Web 容器启动时,Spring Boot 会把所有实现了 ServletContextInitializer 接口的类收集起来,统一调它们的 onStartup 方法。

为了支持对内嵌式 Web 容器的定制化,Spring Boot 还定义了WebServerFactoryCustomizerBeanPostProcessor接口,它是一个 BeanPostProcessor,它在 postProcessBeforeInitialization 过程中去寻找 Spring 容器中 WebServerFactoryCustomizer

类型的 Bean,并依次调用 WebServerFactoryCustomizer

接口的 customize 方法做一些定制化。

public interface WebServerFactoryCustomizer<T extends WebServerFactory> {
    void customize(T factory);
}

内嵌式 Web 容器的创建和启动

铺垫了这些接口,我们再来看看 Spring Boot 是如何实例化和启动一个 Web 容器的。我们知道,Spring 的核心是一个 ApplicationContext,它的抽象实现类 AbstractApplicationContext

实现了著名的refresh方法,它用来新建或者刷新一个 ApplicationContext,在 refresh 方法中会调用 onRefresh 方法,AbstractApplicationContext 的子类可以重写这个方法 onRefresh 方法,来实现特定 Context 的刷新逻辑,因此 ServletWebServerApplicationContext 就是通过重写 onRefresh 方法来创建内嵌式的 Web 容器,具体创建过程是这样的:

@Override
protected void onRefresh() {
     super.onRefresh();
     try {
        // 重写 onRefresh 方法,调用 createWebServer 创建和启动 Tomcat
        createWebServer();
     }
     catch (Throwable ex) {
     }
}
 
//createWebServer 的具体实现
private void createWebServer() {
    // 这里 WebServer 是 Spring Boot 抽象出来的接口,具体实现类就是不同的 Web 容器
    WebServer webServer = this.webServer;
    ServletContext servletContext = this.getServletContext();
    
    // 如果 Web 容器还没创建
    if (webServer == null && servletContext == null) {
        // 通过 Web 容器工厂来创建
        ServletWebServerFactory factory = this.getWebServerFactory();
        // 注意传入了一个 "SelfInitializer"
        this.webServer = factory.getWebServer(new ServletContextInitializer[]{this.getSelfInitializer()});
        
    } else if (servletContext != null) {
        try {
            this.getSelfInitializer().onStartup(servletContext);
        } catch (ServletException var4) {
          ...
        }
    }
 
    this.initPropertySources();
}

再来看看 getWebSever 具体做了什么,以 Tomcat 为例,主要调用 Tomcat 的 API 去创建各种组件:

public WebServer getWebServer(ServletContextInitializer... initializers) {
    //1. 实例化一个 Tomcat,可以理解为 Server 组件。
    Tomcat tomcat = new Tomcat();
    
    //2. 创建一个临时目录
    File baseDir = this.baseDirectory != null ? this.baseDirectory : this.createTempDir("tomcat");
    tomcat.setBaseDir(baseDir.getAbsolutePath());
    
    //3. 初始化各种组件
    Connector connector = new Connector(this.protocol);
    tomcat.getService().addConnector(connector);
    this.customizeConnector(connector);
    tomcat.setConnector(connector);
    tomcat.getHost().setAutoDeploy(false);
    this.configureEngine(tomcat.getEngine());
    
    //4. 创建定制版的 "Context" 组件。
    this.prepareContext(tomcat.getHost(), initializers);
    return this.getTomcatWebServer(tomcat);
}

你可能好奇 prepareContext 方法是做什么的呢?这里的 Context 是指Tomcat 中的 Context 组件,为了方便控制 Context 组件的行为,Spring Boot 定义了自己的 TomcatEmbeddedContext,它扩展了 Tomcat 的 StandardContext:

class TomcatEmbeddedContext extends StandardContext {}

注册 Servlet 的三种方式

1. Servlet 注解

在 Spring Boot 启动类上加上 @ServletComponentScan 注解后,使用 @WebServlet、@WebFilter、@WebListener 标记的 Servlet、Filter、Listener 就可以自动注册到 Servlet 容器中,无需其他代码,我们通过下面的代码示例来理解一下。

@SpringBootApplication
@ServletComponentScan
public class xxxApplication
{}
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {}

在 Web 应用的入口类上加上 @ServletComponentScan, 并且在 Servlet 类上加上 @WebServlet,这样 SpringBoot 会负责将 Servlet 注册到内嵌的 Tomcat 中。

2. ServletRegistrationBean

同时 Spring Boot 也提供了 ServletRegistrationBean、FilterRegistrationBean 和 ServletListenerRegistrationBean 这三个类分别用来注册 Servlet、Filter、Listener。假如要注册一个 Servlet,可以这样做:

@Bean
public ServletRegistrationBean servletRegistrationBean() {
    return new ServletRegistrationBean(new HelloServlet(),"/hello");
}

这段代码实现的方法返回一个 ServletRegistrationBean,并将它当作 Bean 注册到 Spring 中,因此你需要把这段代码放到 Spring Boot 自动扫描的目录中,或者放到 @Configuration 标识的类中。

3. 动态注册

你还可以创建一个类去实现前面提到的 ServletContextInitializer 接口,并把它注册为一个 Bean,Spring Boot 会负责调用这个接口的 onStartup 方法。

@Component
public class MyServletRegister implements ServletContextInitializer {
 
    @Override
    public void onStartup(ServletContext servletContext) {
    
        //Servlet 3.0 规范新的 API
        ServletRegistration myServlet = servletContext
                .addServlet("HelloServlet", HelloServlet.class);
                
        myServlet.addMapping("/hello");
        
        myServlet.setInitParameter("name", "Hello Servlet");
    }
 
}

这里请注意两点:

  • ServletRegistrationBean 其实也是通过 ServletContextInitializer 来实现的,它实现了 ServletContextInitializer 接口。
  • 注意到 onStartup 方法的参数是我们熟悉的 ServletContext,可以通过调用它的 addServlet 方法来动态注册新的 Servlet,这是 Servlet 3.0 以后才有的功能。

Web 容器的定制

我们再来考虑一个问题,那就是如何在 Spring Boot 中定制 Web 容器。在 Spring Boot 2.0 中,我们可以通过两种方式来定制 Web 容器。

第一种方式是通过通用的 Web 容器工厂 ConfigurableServletWebServerFactory,来定制一些 Web 容器通用的参数:

@Component
public class MyGeneralCustomizer implements
  WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> {
  
    public void customize(ConfigurableServletWebServerFactory factory) {
        factory.setPort(8081);
        factory.setContextPath("/hello");
     }
}

第二种方式是通过特定 Web 容器的工厂比如 TomcatServletWebServerFactory 来进一步定制。下面的例子里,我们给 Tomcat 增加一个 Valve,这个 Valve 的功能是向请求头里添加 traceid,用于分布式追踪。TraceValve 的定义如下:

class TraceValve extends ValveBase {
    @Override
    public void invoke(Request request, Response response) throws IOException, ServletException {
 
        request.getCoyoteRequest().getMimeHeaders().
        addValue("traceid").setString("1234xxxxabcd");
 
        Valve next = getNext();
        if (null == next) {
            return;
        }
 
        next.invoke(request, response);
    }
 
}

跟第一种方式类似,再添加一个定制器,代码如下:

@Component
public class MyTomcatCustomizer implements
        WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
 
    @Override
    public void customize(TomcatServletWebServerFactory factory) {
        factory.setPort(8081);
        factory.setContextPath("/hello");
        factory.addEngineValves(new TraceValve() );
 
    }
}

本期精华

今天我们学习了 Spring Boot 如何利用 Web 容器的 API 来启动 Web 容器、如何向 Web 容器注册 Servlet,以及如何定制化 Web 容器,除了给 Web 容器配置参数,还可以增加或者修改 Web 容器本身的组件。

29 | 比较:Jetty如何实现具有上下文信息的责任链?

我们知道 Tomcat 和 Jetty 的核心功能是处理请求,并且请求的处理者不止一个,因此 Tomcat 和 Jetty 都实现了责任链模式,其中 Tomcat 是通过 Pipeline-Valve 来实现的,而 Jetty 是通过 HandlerWrapper 来实现的。HandlerWrapper 中保存了下一个 Handler 的引用,将各 Handler 组成一个链表,像下面这样:

WebAppContext -> SessionHandler -> SecurityHandler -> ServletHandler

这样链中的 Handler 从头到尾能被依次调用,除此之外,Jetty 还实现了“回溯”的链式调用,那就是从头到尾依次链式调用 Handler 的方法 A,完成后再回到头节点,再进行一次链式调用,只不过这一次调用另一个方法 B。你可能会问,一次链式调用不就够了吗,为什么还要回过头再调一次呢?这是因为一次请求到达时,Jetty 需要先调用各 Handler 的初始化方法,之后再调用各 Handler 的请求处理方法,并且初始化必须在请求处理之前完成。

而 Jetty 是通过 ScopedHandler 来做到这一点的,那 ScopedHandler 跟 HandlerWrapper 有什么关系呢?ScopedHandler 是 HandlerWrapper 的子类,我们还是通过一张图来回顾一下各种 Handler 的继承关系:

从图上我们看到,ScopedHandler 是 Jetty 非常核心的一个 Handler,跟 Servlet 规范相关的 Handler,比如 ContextHandler、SessionHandler、ServletHandler、WebappContext 等都直接或间接地继承了 ScopedHandler。

今天我就分析一下 ScopedHandler 是如何实现“回溯”的链式调用的。

HandlerWrapper

为了方便理解,我们先来回顾一下 HandlerWrapper 的源码:

public class HandlerWrapper extends AbstractHandlerContainer
{
   protected Handler _handler;
   
   @Override
    public void handle(String target, 
                       Request baseRequest, 
                       HttpServletRequest request, 
                       HttpServletResponse response) 
                       throws IOException, ServletException
    {
        Handler handler=_handler;
        if (handler!=null)
            handler.handle(target,baseRequest, request, response);
    }
}

从代码可以看到它持有下一个 Handler 的引用,并且会在 handle 方法里调用下一个 Handler。

ScopedHandler

ScopedHandler 的父类是 HandlerWrapper,ScopedHandler 重写了 handle 方法,在 HandlerWrapper 的 handle 方法的基础上引入了 doScope 方法。

public final void handle(String target, 
                         Request baseRequest, 
                         HttpServletRequest request,
                         HttpServletResponse response) 
                         throws IOException, ServletException
{
    if (isStarted())
    {
        if (_outerScope==null)
            doScope(target,baseRequest,request, response);
        else
            doHandle(target,baseRequest,request, response);
    }
}

上面的代码中是根据_outerScope是否为 null 来判断是使用 doScope 还是 doHandle 方法。那_outScope又是什么呢?_outScope是 ScopedHandler 引入的一个辅助变量,此外还有一个_nextScope变量。

protected ScopedHandler _outerScope;
protected ScopedHandler _nextScope;
 
private static final ThreadLocal<ScopedHandler> __outerScope= new ThreadLocal<ScopedHandler>();

我们看到__outerScope是一个 ThreadLocal 变量,ThreadLocal 表示线程的私有数据,跟特定线程绑定。需要注意的是__outerScope实际上保存了一个 ScopedHandler。

下面通过我通过一个例子来说明_outScope_nextScope的含义。我们知道 ScopedHandler 继承自 HandlerWrapper,所以也是可以形成 Handler 链的,Jetty 的源码注释中给出了下面这样一个例子:

ScopedHandler scopedA;
ScopedHandler scopedB;
HandlerWrapper wrapperX;
ScopedHandler scopedC;
 
scopedA.setHandler(scopedB);
scopedB.setHandler(wrapperX);
wrapperX.setHandler(scopedC)

经过上面的设置之后,形成的 Handler 链是这样的:

上面的过程只是设置了_handler变量,那_outScope_nextScope需要设置成什么样呢?为了方便你理解,我们先来看最后的效果图:

从上图我们看到:scopedA 的_nextScope=scopedB,scopedB 的_nextScope=scopedC,为什么 scopedB 的_nextScope不是 WrapperX 呢,因为 WrapperX 不是一个 ScopedHandler。scopedC 的_nextScope是 null(因为它是链尾,没有下一个节点)。因此我们得出一个结论:_nextScope指向下一个 Scoped 节点的引用,由于 WrapperX 不是 Scoped 节点,它没有_outScope_nextScope变量。

注意到 scopedA 的_outerScope是 null,scopedB 和 scopedC 的_outScope都是指向 scopedA,即_outScope指向的是当前 Handler 链的头节点,头节点本身_outScope为 null。

弄清楚了_outScope_nextScope的含义,下一个问题就是对于一个 ScopedHandler 对象如何设置这两个值以及在何时设置这两个值。答案是在组件启动的时候,下面是 ScopedHandler 中的 doStart 方法源码:

@Override
protected void doStart() throws Exception
{
    try
    {
        // 请注意 _outScope 是一个实例变量,而 __outerScope 是一个全局变量。先读取全局的线程私有变量 __outerScope 到 _outerScope 中
 _outerScope=__outerScope.get();
 
        // 如果全局的 __outerScope 还没有被赋值,说明执行 doStart 方法的是头节点
        if (_outerScope==null)
            //handler 链的头节点将自己的引用填充到 __outerScope
            __outerScope.set(this);
 
        // 调用父类 HandlerWrapper 的 doStart 方法
        super.doStart();
        // 各 Handler 将自己的 _nextScope 指向下一个 ScopedHandler
        _nextScope= getChildHandlerByClass(ScopedHandler.class);
    }
    finally
    {
        if (_outerScope==null)
            __outerScope.set(null);
    }
}

你可能会问,为什么要设计这样一个全局的__outerScope,这是因为这个变量不能通过方法参数在 Handler 链中进行传递,但是在形成链的过程中又需要用到它。

你可以想象,当 scopedA 调用 start 方法时,会把自己填充到__scopeHandler中,接着 scopedA 调用super.doStart。由于 scopedA 是一个 HandlerWrapper 类型,并且它持有的_handler引用指向的是 scopedB,所以super.doStart实际上会调用 scopedB 的 start 方法。

这个方法里同样会执行 scopedB 的 doStart 方法,不过这次__outerScope.get方法返回的不是 null 而是 scopedA 的引用,所以 scopedB 的_outScope被设置为 scopedA。

接着super.dostart会进入到 scopedC,也会将 scopedC 的_outScope指向 scopedA。到了 scopedC 执行 doStart 方法时,它的_handler属性为 null(因为它是 Handler 链的最后一个),所以它的super.doStart会直接返回。接着继续执行 scopedC 的 doStart 方法的下一行代码:

_nextScope=(ScopedHandler)getChildHandlerByClass(ScopedHandler.class)

对于 HandlerWrapper 来说 getChildHandlerByClass 返回的就是其包装的_handler对象,这里返回的就是 null。所以 scopedC 的_nextScope为 null,这段方法结束返回后继续执行 scopedB 中的 doStart 中,同样执行这句代码:

_nextScope=(ScopedHandler)getChildHandlerByClass(ScopedHandler.class)

因为 scopedB 的_handler引用指向的是 scopedC,所以 getChildHandlerByClass 返回的结果就是 scopedC 的引用,即 scopedB 的_nextScope指向 scopedC。

同理 scopedA 的_nextScope会指向 scopedB。scopedA 的 doStart 方法返回之后,其_outScope为 null。请注意执行到这里只有 scopedA 的_outScope为 null,所以 doStart 中 finally 部分的逻辑被触发,这个线程的 ThreadLocal 变量又被设置为 null。

finally
{
    if (_outerScope==null)
        __outerScope.set(null);
}

你可能会问,费这么大劲设置_outScope_nextScope的值到底有什么用?如果你觉得上面的过程比较复杂,可以跳过这个过程,直接通过图来理解_outScope_nextScope的值,而这样设置的目的是用来控制 doScope 方法和 doHandle 方法的调用顺序。

实际上在 ScopedHandler 中对于 doScope 和 doHandle 方法是没有具体实现的,但是提供了 nextHandle 和 nextScope 两个方法,下面是它们的源码:

public void doScope(String target, 
                    Request baseRequest, 
                    HttpServletRequest request, 
                    HttpServletResponse response)
       throws IOException, ServletException
{
    nextScope(target,baseRequest,request,response);
}
 
public final void nextScope(String target, 
                            Request baseRequest, 
                            HttpServletRequest request,
                            HttpServletResponse response)
                            throws IOException, ServletException
{
    if (_nextScope!=null)
        _nextScope.doScope(target,baseRequest,request, response);
    else if (_outerScope!=null)
        _outerScope.doHandle(target,baseRequest,request, response);
    else
        doHandle(target,baseRequest,request, response);
}
 
public abstract void doHandle(String target, 
                              Request baseRequest, 
                              HttpServletRequest request,
                              HttpServletResponse response)
       throws IOException, ServletException;
       
 
public final void nextHandle(String target, 
                             final Request baseRequest,
                             HttpServletRequest request,
                             HttpServletResponse response) 
       throws IOException, ServletException
{
    if (_nextScope!=null && _nextScope==_handler)
        _nextScope.doHandle(target,baseRequest,request, response);
    else if (_handler!=null)
        super.handle(target,baseRequest,request,response);
}

从 nextHandle 和 nextScope 方法大致上可以猜到 doScope 和 doHandle 的调用流程。我通过一个调用栈来帮助你理解:

A.handle(...)
    A.doScope(...)
      B.doScope(...)
        C.doScope(...)
          A.doHandle(...)
            B.doHandle(...)
              X.handle(...)
                C.handle(...)
                  C.doHandle(...)  

因此通过设置_outScope_nextScope的值,并且在代码中判断这些值并采取相应的动作,目的就是让 ScopedHandler 链上的doScope 方法在 doHandle、handle 方法之前执行。并且不同 ScopedHandler 的 doScope 都是按照它在链上的先后顺序执行的,doHandle 和 handle 方法也是如此。

这样 ScopedHandler 帮我们把调用框架搭好了,它的子类只需要实现 doScope 和 doHandle 方法。比如在 doScope 方法里做一些初始化工作,在 doHanlde 方法处理请求。

ContextHandler

接下来我们来看看 ScopedHandler 的子类 ContextHandler 是如何实现 doScope 和 doHandle 方法的。ContextHandler 可以理解为 Tomcat 中的 Context 组件,对应一个 Web 应用,它的功能是给 Servlet 的执行维护一个上下文环境,并且将请求转发到相应的 Servlet。那什么是 Servlet 执行的上下文?我们通过 ContextHandler 的构造函数来了解一下:

private ContextHandler(Context context, HandlerContainer parent, String contextPath)
{
    //_scontext 就是 Servlet 规范中的 ServletContext
    _scontext = context == null?new Context():context;
  
    //Web 应用的初始化参数
    _initParams = new HashMap<String, String>();
    ...
}

我们看到 ContextHandler 维护了 ServletContext 和 Web 应用的初始化参数。那 ContextHandler 的 doScope 方法做了些什么呢?我们看看它的关键代码:

public void doScope(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException
{
  ...
    //1. 修正请求的 URL,去掉多余的'/',或者加上'/'
    if (_compactPath)
        target = URIUtil.compactPath(target);
    if (!checkContext(target,baseRequest,response))
        return;
    if (target.length() > _contextPath.length())
    {
        if (_contextPath.length() > 1)
            target = target.substring(_contextPath.length());
        pathInfo = target;
    }
    else if (_contextPath.length() == 1)
    {
        target = URIUtil.SLASH;
        pathInfo = URIUtil.SLASH;
    }
    else
    {
        target = URIUtil.SLASH;
        pathInfo = null;
    }
 
  //2. 设置当前 Web 应用的类加载器
  if (_classLoader != null)
  {
      current_thread = Thread.currentThread();
      old_classloader = current_thread.getContextClassLoader();
      current_thread.setContextClassLoader(_classLoader);
  }
  
  //3. 调用 nextScope
  nextScope(target,baseRequest,request,response);
  
  ...
}

从代码我们看到在 doScope 方法里主要是做了一些请求的修正、类加载器的设置,并调用 nextScope,请你注意 nextScope 调用是由父类 ScopedHandler 实现的。接着我们来 ContextHandler 的 doHandle 方法:

public void doHandle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException
{
    final DispatcherType dispatch = baseRequest.getDispatcherType();
    final boolean new_context = baseRequest.takeNewContext();
    try
    {
         // 请求的初始化工作, 主要是为请求添加 ServletRequestAttributeListener 监听器, 并将 " 开始处理一个新请求 " 这个事件通知 ServletRequestListener
        if (new_context)
            requestInitialized(baseRequest,request);
 
        ...
 
        // 继续调用下一个 Handler,下一个 Handler 可能是 ServletHandler、SessionHandler ...
        nextHandle(target,baseRequest,request,response);
    }
    finally
    {
        // 同样一个 Servlet 请求处理完毕,也要通知相应的监听器
        if (new_context)
            requestDestroyed(baseRequest,request);
    }
}

从上面的代码我们看到 ContextHandler 在 doHandle 方法里分别完成了相应的请求处理工作。

本期精华

今天我们分析了 Jetty 中 ScopedHandler 的实现原理,剖析了如何实现链式调用的“回溯”。主要是确定了 doScope 和 doHandle 的调用顺序,doScope 依次调用完以后,再依次调用 doHandle,它的子类比如 ContextHandler 只需要实现 doScope 和 doHandle 方法,而不需要关心它们被调用的顺序。

这背后的原理是,ScopedHandler 通过递归的方式来设置_outScope_nextScope两个变量,然后通过判断这些值来控制调用的顺序。递归是计算机编程的一个重要的概念,在各种面试题中也经常出现,如果你能读懂 Jetty 中的这部分代码,毫无疑问你已经掌握了递归的精髓。

另外我们进行层层递归调用中需要用到一些变量,比如 ScopedHandler 中的__outerScope,它保存了 Handler 链中的头节点,但是它不是递归方法的参数,那参数怎么传递过去呢?一种可能的办法是设置一个全局变量,各 Handler 都能访问到这个变量。但这样会有线程安全的问题,因此 ScopedHandler 通过线程私有数据 ThreadLocal 来保存变量,这样既达到了传递变量的目的,又没有线程安全的问题。

30 | 热点问题答疑(3):Spring框架中的设计模式

在构思这个专栏的时候,回想当时我是如何研究 Tomcat 和 Jetty 源码的,除了理解它们的实现之外,也从中学到了很多架构和设计的理念,其中很重要的就是对设计模式的运用,让我收获到不少经验。而且这些经验通过自己消化和吸收,是可以把它应用到实际工作中去的。

在专栏的热点问题答疑第三篇,我想跟你分享一些我对设计模式的理解。有关 Tomcat 和 Jetty 所运用的设计模式我在专栏里已经有所介绍,今天想跟你分享一下 Spring 框架里的设计模式。Spring 的核心功能是 IOC 容器以及 AOP 面向切面编程,同样也是很多 Web 后端工程师每天都要打交道的框架,相信你一定可以从中吸收到一些设计方面的精髓,帮助你提升设计能力。

简单工厂模式

我们来考虑这样一个场景:当 A 对象需要调用 B 对象的方法时,我们需要在 A 中 new 一个 B 的实例,我们把这种方式叫作硬编码耦合,它的缺点是一旦需求发生变化,比如需要使用 C 类来代替 B 时,就要改写 A 类的方法。假如应用中有 1000 个类以硬编码的方式耦合了 B,那改起来就费劲了。于是简单工厂模式就登场了,简单工厂模式又叫静态工厂方法,其实质是由一个工厂类根据传入的参数,动态决定应该创建哪一个产品类。

Spring 中的 BeanFactory 就是简单工厂模式的体现,BeanFactory 是 Spring IOC 容器中的一个核心接口,它的定义如下:

public interface BeanFactory {
   Object getBean(String name) throws BeansException;
   <T> T getBean(String name, Class<T> requiredType);
   Object getBean(String name, Object... args);
   <T> T getBean(Class<T> requiredType);
   <T> T getBean(Class<T> requiredType, Object... args);
   boolean containsBean(String name);
   boolean isSingleton(String name);
   boolea isPrototype(String name);
   boolean isTypeMatch(String name, ResolvableType typeToMatch);
   boolean isTypeMatch(String name, Class<?> typeToMatch);
   Class<?> getType(String name);
   String[] getAliases(String name);
}

我们可以通过它的具体实现类(比如 ClassPathXmlApplicationContext)来获取 Bean:

BeanFactory bf = new ClassPathXmlApplicationContext("spring.xml");
User userBean = (User) bf.getBean("userBean");

从上面代码可以看到,使用者不需要自己来 new 对象,而是通过工厂类的方法 getBean 来获取对象实例,这是典型的简单工厂模式,只不过 Spring 是用反射机制来创建 Bean 的。

工厂方法模式

工厂方法模式说白了其实就是简单工厂模式的一种升级或者说是进一步抽象,它可以应用于更加复杂的场景,灵活性也更高。在简单工厂中,由工厂类进行所有的逻辑判断、实例创建;如果不想在工厂类中进行判断,可以为不同的产品提供不同的工厂,不同的工厂生产不同的产品,每一个工厂都只对应一个相应的对象,这就是工厂方法模式。

Spring 中的 FactoryBean 就是这种思想的体现,FactoryBean 可以理解为工厂 Bean,先来看看它的定义:

public interface FactoryBean<T> {
  T getObject();
  Class<?> getObjectType();
  boolean isSingleton();
}

我们定义一个类 UserFactoryBean 来实现 FactoryBean 接口,主要是在 getObject 方法里 new 一个 User 对象。这样我们通过 getBean(id) 获得的是该工厂所产生的 User 的实例,而不是 UserFactoryBean 本身的实例,像下面这样:

BeanFactory bf = new ClassPathXmlApplicationContext("user.xml");
User userBean = (User) bf.getBean("userFactoryBean");

单例模式

单例模式是指一个类在整个系统运行过程中,只允许产生一个实例。在 Spring 中,Bean 可以被定义为两种模式:Prototype(多例)和 Singleton(单例),Spring Bean 默认是单例模式。那 Spring 是如何实现单例模式的呢?答案是通过单例注册表的方式,具体来说就是使用了 HashMap。请注意为了方便你阅读,我对代码进行了简化:

public class DefaultSingletonBeanRegistry {
    
    // 使用了线程安全容器 ConcurrentHashMap,保存各种单实例对象
    private final Map<String, Object> singletonObjects = new ConcurrentHashMap<String, Object>;
 
    protected Object getSingleton(String beanName) {
    // 先到 HashMap 中拿 Object
    Object singletonObject = singletonObjects.get(beanName);
    
    // 如果没拿到通过反射创建一个对象实例,并添加到 HashMap 中
    if (singletonObject == null) {
      singletonObjects.put(beanName,
                           Class.forName(beanName).newInstance());
   }
   
   // 返回对象实例
   return singletonObjects.get(beanName);
  }
}

上面的代码逻辑比较清晰,先到 HashMap 去拿单实例对象,没拿到就创建一个添加到 HashMap。

代理模式

所谓代理,是指它与被代理对象实现了相同的接口,客户端必须通过代理才能与被代理的目标类进行交互,而代理一般在交互的过程中(交互前后),进行某些特定的处理,比如在调用这个方法前做前置处理,调用这个方法后做后置处理。代理模式中有下面几种角色:

  • 抽象接口:定义目标类及代理类的共同接口,这样在任何可以使用目标对象的地方都可以使用代理对象。
  • 目标对象: 定义了代理对象所代表的目标对象,专注于业务功能的实现。
  • 代理对象: 代理对象内部含有目标对象的引用,收到客户端的调用请求时,代理对象通常不会直接调用目标对象的方法,而是在调用之前和之后实现一些额外的逻辑。

代理模式的好处是,可以在目标对象业务功能的基础上添加一些公共的逻辑,比如我们想给目标对象加入日志、权限管理和事务控制等功能,我们就可以使用代理类来完成,而没必要修改目标类,从而使得目标类保持稳定。这其实是开闭原则的体现,不要随意去修改别人已经写好的代码或者方法。

代理又分为静态代理和动态代理两种方式。静态代理需要定义接口,被代理对象(目标对象)与代理对象(Proxy) 一起实现相同的接口,我们通过一个例子来理解一下:

// 抽象接口
public interface IStudentDao {
    void save();
}
 
// 目标对象
public class StudentDao implements IStudentDao {
    public void save() {
        System.out.println(" 保存成功 ");
    }
}
 
// 代理对象
public class StudentDaoProxy implements IStudentDao{
    // 持有目标对象的引用
    private IStudentDao target;
    public StudentDaoProxy(IStudentDao target){
        this.target = target;
    }
 
    // 在目标功能对象方法的前后加入事务控制
    public void save() {
        System.out.println(" 开始事务 ");
        target.save();// 执行目标对象的方法
        System.out.println(" 提交事务 ");
    }
}
 
public static void main(String[] args) {
    // 创建目标对象
    StudentDao target = new StudentDao();
 
    // 创建代理对象, 把目标对象传给代理对象, 建立代理关系
    StudentDaoProxy proxy = new StudentDaoProxy(target);
   
    // 执行的是代理的方法
    proxy.save();
}

而 Spring 的 AOP 采用的是动态代理的方式,而动态代理就是指代理类在程序运行时由 JVM 动态创建。在上面静态代理的例子中,代理类(StudentDaoProxy)是我们自己定义好的,在程序运行之前就已经编译完成。而动态代理,代理类并不是在 Java 代码中定义的,而是在运行时根据我们在 Java 代码中的“指示”动态生成的。那我们怎么“指示”JDK 去动态地生成代理类呢?

在 Java 的java.lang.reflect包里提供了一个 Proxy 类和一个 InvocationHandler 接口,通过这个类和这个接口可以生成动态代理对象。具体来说有如下步骤:

\1. 定义一个 InvocationHandler 类,将需要扩展的逻辑集中放到这个类中,比如下面的例子模拟了添加事务控制的逻辑。

public class MyInvocationHandler implements InvocationHandler {
 
    private Object obj;
 
    public MyInvocationHandler(Object obj){
        this.obj=obj;
    }
 
    @Override
    public Object invoke(Object proxy, Method method, Object[] args)
            throws Throwable {
 
        System.out.println(" 开始事务 ");
        Object result = method.invoke(obj, args);
        System.out.println(" 开始事务 ");
        
        return result;
    }
}
  1. 使用 Proxy 的 newProxyInstance 方法动态的创建代理对象:
public static void main(String[] args) {
  // 创建目标对象 StudentDao
  IStudentDao stuDAO = new StudentDao();
  
  // 创建 MyInvocationHandler 对象
  InvocationHandler handler = new MyInvocationHandler(stuDAO);
  
  // 使用 Proxy.newProxyInstance 动态的创建代理对象 stuProxy
  IStudentDao stuProxy = (IStudentDao) 
 Proxy.newProxyInstance(stuDAO.getClass().getClassLoader(), stuDAO.getClass().getInterfaces(), handler);
  
  // 动用代理对象的方法
  stuProxy.save();
}

上面的代码实现和静态代理一样的功能,相比于静态代理,动态代理的优势在于可以很方便地对代理类的函数进行统一的处理,而不用修改每个代理类中的方法。

Spring 实现了通过动态代理对类进行方法级别的切面增强,我来解释一下这句话,其实就是动态生成目标对象的代理类,并在代理类的方法中设置拦截器,通过执行拦截器中的逻辑增强了代理方法的功能,从而实现 AOP。

本期精华

今天我和你聊了 Spring 中的设计模式,我记得我刚毕业那会儿,拿到一个任务时我首先考虑的是怎么把功能实现了,从不考虑设计的问题,因此写出来的代码就显得比较稚嫩。后来随着经验的积累,我会有意识地去思考,这个场景是不是用个设计模式会更高大上呢?以后重构起来是不是会更轻松呢?慢慢我也就形成一个习惯,那就是用优雅的方式去实现一个系统,这也是每个程序员需要经历的过程。

今天我们学习了 Spring 的两大核心功能 IOC 和 AOP 中用到的一些设计模式,主要有简单工厂模式、工厂方法模式、单例模式和代理模式。而代理模式又分为静态代理和动态代理。JDK 提供实现动态代理的机制,除此之外,还可以通过 CGLIB 来实现,有兴趣的同学可以理解一下它的原理。