用了10多年的 Tomcat 居然有bug,这能忍?

170 阅读7分钟

前情回顾

上一篇文章主要了解了一下Tomcat启动入口,以及初步的分析了Tomcat的启动流程,下面我们将会解密Tomcat应用部署的实际流程。

一、直观对比

虽然前面已经说了那么多关于Tomcat的东西,但是我相信绝大部分同学应该都没有专门的去研究过Tomcat的内部实现。我们接触最多的应该还是上传一个war包丢在webapps目录下,然后重启一下Tomcat服务器(甚至不重启)。下面我们以图形的形式,直观的对比Tomcat各组件的关系。

image.png

二、应用部署与加载流程分析

下面就针对应用部署与加载流程展开分析2.1 部署方式

  • 隐式部署

直接丢文件夹、war、jar 到 webapps 目录,tomcat 会根据文件夹名称自动生成虚拟路径,简单,但是需要重启 Tomcat 服务器,包括要修改端口和访问路径的也需要重启。

  • 显示部署

添加 context 元素 server.xml 中的 Host 加入一个 Context(指定路径和文件地址),例如:

<Host name="localhost">
  <Context path="/myapp" docBase="/opt/work_tomcat/myapp.war" />
</Host>

即/myapp 这个虚拟路径映射到了 /opt/work_tomcat/myapp 目录下(war 会解压成文件),修改完 server.xml 需要重启 tomcat 服务器。

  • 创建 xml 文件

在 conf/Catalina/localhost 中创建 xml 文件,访问路径为文件名,例如:在 localhost 目录下新建 demo.xml,内容为:

<Context docBase="/opt/work_tomcat/myapp" />

不需要写 path,虚拟目录就是文件名 demo,path 默认为/demo,添加 demo.xml 不需要重启 tomcat 服务器。

2.2 Web应用加载

Web应用加载属于Server启动的核心处理过程。Catalina对Web应用的加载主要由StandardHost、HostConfig、StandardContext、ContextConfig、StandardWrapper这5个类完成。

2.2.1 StandardHost

  1. 当显示部署时,Context元素将会作为Host容器的子容器添加到Host实例当中,并在Host启动时,由生命周期管理接口的start()方法启动。  
  2. 大多数情况下,我们使用的其实都是隐式部署。我们需要关注的是Digester解析器默认为StandardHost容器添加了一个HostConfig监听器。
@Override
publicvoid addRuleInstances(Digester digester) {
  digester.addObjectCreate(prefix + "Host","org.apache.catalina.core.StandardHost",
                          "className");
  digester.addSetProperties(prefix + "Host");
  digester.addRule(prefix + "Host",
                  new CopyParentClassLoaderRule());
  digester.addRule(prefix + "Host",
                  new LifecycleListenerRule("org.apache.catalina.startup.HostConfig",
                   "hostConfigClass"));
  //省略部分代码...
}

2.2.2 HostConfig

HostConfig处理的生命周期事件包括:START_EVENT、PERIODIC_EVENT、STOP_EVENT。其中,前两者都与Web应用部署密切相关,后者用于在Host停止时注销其对应的MBean。逻辑在Host启动时触发START_EVENT事件,完成服务器启动过程中的Web应用部署(只有当Host的deployOnStartup属性为true时,服务器才会在启动过程中部署Web应用,该属性默认值为true)。

public class HostConfig implements LifecycleListener {
  /**
    * Process a "start" event for this Host.
    */
   public void start() {
       //省略部分代码...
       if (host.getDeployOnStartup()) {
           //部署当前虚拟机的应用
           deployApps();
       }
   }

   protected void deployApps() {
       //默认为¨E95EBASE/webappsFileappBase¨E61Ehost.getAppBaseFile();//默认为CATALINA_BASE/webapps
       File appBase = host.getAppBaseFile();
       //默认为CATALINA¨E95EBASE/webappsFileappBase¨E61Ehost.getAppBaseFile();//默认为CATALINA_BASE/conf/<Engine名称>/<Host名称>
       File configBase = host.getConfigBaseFile();
       String[] filteredAppPaths = filterAppPaths(appBase.list());
       // Deploy XML descriptors from configBase 描述文件部署
       deployDescriptors(configBase, configBase.list());
       // Deploy WARs War包部署
       deployWARs(appBase, filteredAppPaths);
       // Deploy expanded folders 目录部署
       deployDirectories(appBase, filteredAppPaths);
   }
}
  • Context描述文件部署
  1. 扫描$CATALINA_BASE/conf/<Engine名称>/<Host名称>目录下的xml文件 。
  2. 部署描述文件应用的详见HostConfig.deployDescriptor。
  • War包部署
  1. 过滤$CATALINA_BASE/webapps目录下所有符合条件的WAR包:不符合deployIgnore的过滤规则、文件名不为META-INF和WEB-INF、以war作为扩展名的文件。 
  2. 部署WAR包应用的过程详见HostConfig.deployWAR。
  • Web目录部署
  1. 过滤CATALINA_BASE/webapps目录下所有符合条件的WAR包:不符合deployIgnore的过滤规则、文件名不为META-INF和WEB-INF、以war作为扩展名的文件。
  2. 部署Web目录应用的过程详见HostConfig.deployDirectory。

逻辑对于上述自动部署过程中,我们可以发现,经过一系列的条件判断,最终工作就是构建了一个Context实例,并添加ContextConfig生命周期监听器。 通过Host的addChild()方法将Context实例添加到Host。并在Host启动时启动Context。并根据不同的部署方式添加文件到守护资源,以便文件发生变更时重新部署或者加载Web应用。 逻辑 在Container容器的backgroundProcess()定期扫描Web应用发生变更,并从新加载处理完成之后触发PERIODIC_EVENT事件。 在HostConfig中通过DeployedApplication维护了两个守护资源列表:redeployeResources和reloadResources,前者用于守护导致应用重新部署的资源,后者守护导致应用重新加载的资源。两个列表分别维护了资源及其最后修改的时间。 当HostConfig接收到PERIODIC_EVENT事件后,会检测守护资源的变更情况。如果发生变更,将重新加载或者部署应用以及更新资源的最后修改时间。

2.2.3 StandardContext

WebappLoader

需要特别关注的是在StandardContext.startInternal()方法中,每个Context都创建了一个WebappLoader应用类加载器。那么它到底具备什么特殊的意义呢?

public class StandardContext extends ContainerBase
        implements Context, NotificationEmitter {
    protected synchronized void startInternal() throws LifecycleException {
                //每个context新建一个应用类加载器
        if (getLoader() == null) {
            WebappLoader webappLoader = new WebappLoader();
            webappLoader.setDelegate(getDelegate());
            setLoader(webappLoader);
        }
    }
      public void setLoader(Loader loader) {
        if (getState().isAvailable() && (loader != null) &&
                (loader instanceof Lifecycle)) {
                try {
                    //执行webapploader.starter ==> webappclassloader.startInternal
                    ((Lifecycle) loader).start();
                } catch (LifecycleException e) {
                    log.error(sm.getString("standardContext.setLoader.start"), e);
                }
            }
    }
}  
public abstract class WebappClassLoaderBase extends URLClassLoader
        implements Lifecycle, InstrumentableClassLoader, WebappProperties, PermissionCheck {
        public void start() throws LifecycleException {

        state = LifecycleState.STARTING_PREP;
        //只加载当前context下的类,应用级别隔离
        WebResource[] classesResources = resources.getResources("/WEB-INF/classes");
        for (WebResource classes : classesResources) {
            if (classes.isDirectory() && classes.canRead()) {
                localRepositories.add(classes.getURL());
            }
        }
        //只加载当前context下的jar包,应用级别隔离
        WebResource[] jars = resources.listResources("/WEB-INF/lib");
        for (WebResource jar : jars) {
            if (jar.getName().endsWith(".jar") && jar.isFile() && jar.canRead()) {
                localRepositories.add(jar.getURL());
                jarModificationTimes.put(
                        jar.getName(), Long.valueOf(jar.getLastModified()));
            }
        }

        state = LifecycleState.STARTED;
    }
}

打破了双亲委派模型:

  1. 首先从JVM的Bootstrap类加载器加载.
  2. 优先加载WEB-INF/classes,WEB-INF/lib.
  3. 然后再按照System,Common,Shared的顺序加载.

image.png

这么设计的目的主要是考虑到以下三个方面:

  1. 逻辑隔离性:Web应用类库相互隔离,避免依赖库或者应用包相互影响。比如有两个应用分别采用了Spring2.5和Spring5.0,如果应用服务器使用同一个类加载器加载,那么Web应用将会由于Jar包覆盖而导致无法启动成功。
  2. 灵活性:既然Web应用之间的类加载器相互独立,那么我们就能只针对一个Web应用进行重新部署,此事该Web应用的类加载器将会重新创建,而且不会影响其他Web应用。如果共用一个类加载器显然无法实现,因为只有一个类加载器的时候,类质检的依赖是杂乱无章的,无法完整的移出某一个Web应用的类。
  3. 性能:由于每个Web应用都有一个类加载器,因此Web应用再加载类时,不会搜索其他应用包含的Jar包,性能自然高于应用服务器只有一个类加载器的情况。

2.2.4 ContextConfig

当我们在创建Context的时候会同时创建ContextConfig作为它的状态监听器,在Context执行startInternal()方法时,会发布一个Lifecycle.CONFIGURE_START_EVENT事件通知ContextConfig做后续的工作。需要注意的是:

①当触发AFTER_INIT_EVENT事件时,解析ConfigFile文件,按优先级顺序从高到底依次为:

  1. Web应用配置(META-INF/context.xml)。

  2. Host配置(conf/context.xml.default)。

  3. Catalina配置(conf/context.xml)。

②当触发BEFORE_START_EVENT事件时,会执行ExpandWar.expand方法去解压war包。③当触发CONFIGURE_START_EVENT事件时,ContextConfig.webConfig()方法会解析web.xml,创建Servlet,Filter,ServletContextListener等Web容器相关的对象从而完成初始化。

2.2.5 StandardWrapper

StandardWrapper具体维护了Servlet实例,当ContextConfig完成初始化之后,会根据WebXml中的Servlet定义创建Wrapper。创建Servlet实例,执行javax.servlet.Servlet.init()完成Servlet的初始化。

TIP:如果想要详细了解服务启动及加载的流程图可以查看官网提供的资料 tomcat.apache.org/tomcat-9.0-…

三、本文小结

我们发现Tomcat可以部署多个应用,每个Context则对应了一个应用,应用部署的方式可以是文件夹也可以是war包,如果是war包部署,它还会自动帮我们将war包解压出来。每个应用中又有各自的Servlet。 至此我们可以说Tomcat将应用已经部署完毕,下次我们将分析一个普通的HTTP请求是如何经过网络层,到达我们的Tomcat,再经过我们的应用处理,最后返回出请求结果。