精盡Spring Boot原始碼分析 - 支援外部 Tomcat 容器的實現

月圓吖發表於2021-07-02

該系列文章是筆者在學習 Spring Boot 過程中總結下來的,裡面涉及到相關原始碼,可能對讀者不太友好,請結合我的原始碼註釋 Spring Boot 原始碼分析 GitHub 地址 進行閱讀

Spring Boot 版本:2.2.x

最好對 Spring 原始碼有一定的瞭解,可以先檢視我的 《死磕 Spring 之 IoC 篇 - 文章導讀》 系列文章

如果該篇內容對您有幫助,麻煩點選一下“推薦”,也可以關注博主,感激不盡~

該系列其他文章請檢視:《精盡 Spring Boot 原始碼分析 - 文章導讀》

概述

我們知道 Spring Boot 應用能夠被打成 war 包,放入外部 Tomcat 容器中執行。你是否知道 Spring Boot 是如何整合 Spring MVC 的呢?

在上一篇 《Spring Boot 內嵌 Tomcat 容器的實現》 文章中分析了 Spring Boot 白打成 jar 包後是如何建立 Tomcat 容器並啟動的,那麼這篇文章主要告訴你 Spring Boot 應用被打成 war 包後放入外部 Tomcat 容器是如何執行的。

如何使用

在我們的 Spring Boot 專案中通常會引入 spring-boot-starter-web 這個依賴,該模組提供全棧的 WEB 開發特性,包括 Spring MVC 依賴和 Tomcat 容器,我們將內部 Tomcat 的 Starter 模組排除掉,如下:

<packaging>war</packaging>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>4.0.1</version>
    <scope>provided</scope>
</dependency>

然後啟動類這樣寫:

@SpringBootApplication
public class Application extends SpringBootServletInitializer {

    // 可不寫
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
        return builder.sources(Application.class);
    }
}

這樣你打成 war 包就可以放入外部的 Servlet 容器中執行了。

實現原理

原理在分析 Spring MVC 原始碼的時候講過,參考我的 《精盡Spring MVC原始碼分析 - 尋找遺失的 web.xml》 這篇文章

藉助於 Servlet 3.0 的一個新特性,新增的一個 javax.servlet.ServletContainerInitializer 介面,在 Servlet 容器啟動時會通過 Java 的 SPI 機制從 META-INF/services/javax.servlet.ServletContainerInitializer 檔案中找到這個介面的實現類,然後呼叫它的 onStartup(..) 方法。

在 Spring 的 spring-web 模組中該檔案是這麼配置的:

org.springframework.web.SpringServletContainerInitializer

一起來看看這個類:

@HandlesTypes(WebApplicationInitializer.class)
public class SpringServletContainerInitializer implements ServletContainerInitializer {

	@Override
	public void onStartup(@Nullable Set<Class<?>> webAppInitializerClasses, ServletContext servletContext)
			throws ServletException {

		List<WebApplicationInitializer> initializers = new LinkedList<>();

		if (webAppInitializerClasses != null) {
			for (Class<?> waiClass : webAppInitializerClasses) {
				// Be defensive: Some servlet containers provide us with invalid classes,
				// no matter what @HandlesTypes says...
				if (!waiClass.isInterface() && !Modifier.isAbstract(waiClass.getModifiers()) &&
						WebApplicationInitializer.class.isAssignableFrom(waiClass)) {
					try {
						initializers.add((WebApplicationInitializer)
								ReflectionUtils.accessibleConstructor(waiClass).newInstance());
					} catch (Throwable ex) {
						throw new ServletException("Failed to instantiate WebApplicationInitializer class", ex);
					}
				}
			}
		}

		if (initializers.isEmpty()) {
			servletContext.log("No Spring WebApplicationInitializer types detected on classpath");
			return;
		}

		servletContext.log(initializers.size() + " Spring WebApplicationInitializers detected on classpath");
		AnnotationAwareOrderComparator.sort(initializers);
		for (WebApplicationInitializer initializer : initializers) {
			initializer.onStartup(servletContext);
		}
	}
}

通過 @HandlesTypes 註解指定只處理 WebApplicationInitializer 型別的類

這個過程很簡單,例項化所有 WebApplicationInitializer 型別的物件,然後依次呼叫它們的 onStartup(ServletContext) 方法

精盡Spring Boot原始碼分析 - 支援外部 Tomcat 容器的實現

通過打斷點你會發現,有一個 DemoApplication 就是我們的啟動類

這也就是為什麼如果你的 Spring Boot 應用需要打成 war 包放入外部 Tomcat 容器執行的時候,你的啟動類需要繼承 SpringBootServletInitializer 這個抽象類,因為這個抽象類實現類 WebApplicationInitializer 介面,我們只需要繼承它即可

SpringBootServletInitializer

org.springframework.boot.web.servlet.support.SpringBootServletInitializer 抽象類,實現了 WebApplicationInitializer 介面,目的就是支援你將 Spring Boot 應用打包成 war 包放入外部的 Servlet 容器中執行

public abstract class SpringBootServletInitializer implements WebApplicationInitializer {

	protected Log logger; // Don't initialize early

	private boolean registerErrorPageFilter = true;

	protected final void setRegisterErrorPageFilter(boolean registerErrorPageFilter) {
		this.registerErrorPageFilter = registerErrorPageFilter;
	}

	@Override
	public void onStartup(ServletContext servletContext) throws ServletException {
		// Logger initialization is deferred in case an ordered
		// LogServletContextInitializer is being used
		this.logger = LogFactory.getLog(getClass());
		// <1> 建立一個 WebApplicationContext 作為 Root Spring 應用上下文
		WebApplicationContext rootAppContext = createRootApplicationContext(servletContext);
		if (rootAppContext != null) {
			// <2> 新增一個 ContextLoaderListener 監聽器,會監聽到 ServletContext 的啟動事件
			// 因為 Spring 應用上下文在上面第 `1` 步已經準備好了,所以這裡什麼都不用做
			servletContext.addListener(new ContextLoaderListener(rootAppContext) {
				@Override
				public void contextInitialized(ServletContextEvent event) {
					// no-op because the application context is already initialized
				}
			});
		} else {
			this.logger.debug("No ContextLoaderListener registered, as createRootApplicationContext() did not "
					+ "return an application context");
		}
	}
}

onStartup(ServletContext) 方法中就兩步:

  1. 呼叫 createRootApplicationContext(ServletContext) 方法,建立一個 WebApplicationContext 作為 Root Spring 應用上下文
  2. 新增一個 ContextLoaderListener 監聽器,會監聽到 ServletContext 的啟動事件,因為 Spring 應用上下文在上面第 1 步已經準備好了,所以這裡什麼都不用做

1 步是不是和 Spring MVC 類似,同樣建立一個 Root WebApplicationContext 作為 Spring 應用上下文的父物件

createRootApplicationContext 方法

createRootApplicationContext(ServletContext) 方法,建立一個 Root WebApplicationContext 物件,如下:

protected WebApplicationContext createRootApplicationContext(ServletContext servletContext) {
    // <1> 建立一個 SpringApplication 構造器
    SpringApplicationBuilder builder = createSpringApplicationBuilder();
    // <2> 設定 `mainApplicationClass`,主要用於列印日誌
    builder.main(getClass());
    // <3> 從 ServletContext 上下文中獲取最頂部的 Root ApplicationContext 應用上下文
    ApplicationContext parent = getExistingRootWebApplicationContext(servletContext);
    // <4> 如果已存在 Root ApplicationContext,則先置空,因為這裡會建立一個 ApplicationContext 作為 Root
    if (parent != null) {
        this.logger.info("Root context already created (using as parent).");
        servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, null);
        // <4.1> 新增一個 ApplicationContextInitializer 初始器,
        // 用於設定現在要建立的 Root ApplicationContext 應用上下文的父容器為 `parent`
        builder.initializers(new ParentContextApplicationContextInitializer(parent));
    }
    /**
     * <5> 新增一個 ApplicationContextInitializer 初始器
     * 目的是往 ServletContext 上下文中設定 Root ApplicationContext 為現在要建立的 Root ApplicationContext 應用上下文
     * 並將這個 ServletContext 儲存至 ApplicationContext 中,參考 {@link ServletWebServerApplicationContext#createWebServer()} 方法,
     * 如果獲取到了 ServletContext 那麼直接呼叫其 {@link ServletWebServerApplicationContext#selfInitialize} 方法來註冊各個 Servlet、Filter
     * 例如 {@link DispatcherServlet}
     */
    builder.initializers(new ServletContextApplicationContextInitializer(servletContext));
    // <6> 設定要建立的 Root ApplicationContext 應用上下文的型別(Servlet)
    builder.contextClass(AnnotationConfigServletWebServerApplicationContext.class);
    // <7> 對 SpringApplicationBuilder 進行擴充套件
    builder = configure(builder);
    // <8> 新增一個 ApplicationListener 監聽器
    // 用於將 ServletContext 中的相關屬性關聯到 Environment 環境中
    builder.listeners(new WebEnvironmentPropertySourceInitializer(servletContext));
    // <9> 構建一個 SpringApplication 物件,用於啟動 Spring 應用
    SpringApplication application = builder.build();
    // <10> 如果沒有設定 `source` 源物件,那麼這裡嘗試設定為當前 Class 物件,需要有 `@Configuration` 註解
    if (application.getAllSources().isEmpty()
            && MergedAnnotations.from(getClass(), SearchStrategy.TYPE_HIERARCHY).isPresent(Configuration.class)) {
        application.addPrimarySources(Collections.singleton(getClass()));
    }
    // <11> 因為 SpringApplication 在建立 ApplicationContext 應用上下文的過程中需要優先註冊 `source` 源物件,如果為空則丟擲異常
    Assert.state(!application.getAllSources().isEmpty(),
            "No SpringApplication sources have been defined. Either override the "
                    + "configure method or add an @Configuration annotation");
    // Ensure error pages are registered
    if (this.registerErrorPageFilter) {
        // <12> 新增一個錯誤頁面 Filter 作為 `sources`
        application.addPrimarySources(Collections.singleton(ErrorPageFilterConfiguration.class));
    }
    // <13> 呼叫 `application` 的 `run` 方法啟動整個 Spring Boot 應用
    return run(application);
}

過程如下:

  1. 建立一個 SpringApplication 構造器,目的就是啟動 Spring 應用咯

    protected SpringApplicationBuilder createSpringApplicationBuilder() {
        return new SpringApplicationBuilder();
    }
    
  2. 設定 mainApplicationClass,也就是你的啟動類,主要用於列印日誌

  3. 從 ServletContext 上下文中獲取最頂部的 Root ApplicationContext 應用上下文 parent,通常這裡沒有父物件,所以為空

  4. 如果 parent 不為空,則先 ServletContext 中的該屬性置空,因為這裡會建立一個 ApplicationContext 作為 Root

    1. 新增一個 ApplicationContextInitializer 初始器,用於設定現在要建立的 Root ApplicationContext 應用上下文的父容器為 parent
  5. 新增一個 ApplicationContextInitializer 初始器,目的是往 ServletContext 上下文中設定 Root ApplicationContext 為現在要建立的 Root ApplicationContext 應用上下文,並將這個 ServletContext 儲存至 ApplicationContext 中

    注意,這個物件很關鍵,會將當前 ServletContext 上下文物件設定到 ApplicationContext 物件裡面,那麼後續就不會再建立 Spring Boot 內嵌的 Tomcat 了

  6. 設定要建立的 Root ApplicationContext 應用上下文的型別(Servlet)

  7. 對 SpringApplicationBuilder 進行擴充套件,呼叫 configure(SpringApplicationBuilder) 方法,這也就是為什麼我們的啟動類可以重寫該方法,通常不用做什麼

  8. 新增一個 ApplicationListener 監聽器,用於將 ServletContext 中的相關屬性關聯到 Environment 環境中

  9. 構建一個 SpringApplication 物件 application,用於啟動 Spring 應用

  10. 如果沒有設定 source 源物件,那麼這裡嘗試設定為當前 Class 物件,需要有 @Configuration 註解

  11. 因為 SpringApplication 在建立 ApplicationContext 應用上下文的過程中需要優先註冊 source 源物件,如果為空則丟擲異常

  12. 新增一個錯誤頁面 Filter 作為 sources

  13. 呼叫 applicationrun 方法啟動整個 Spring Boot 應用

整個過程不復雜,SpringApplication 相關的內容在前面的 《SpringApplication 啟動類的啟動過程》文章中已經分析過,這裡的關鍵在於第 5

新增的 ServletContextApplicationContextInitializer 會將當前 ServletContext 上下文物件設定到 ApplicationContext 物件裡面

ServletContextApplicationContextInitializer

public class ServletContextApplicationContextInitializer
		implements ApplicationContextInitializer<ConfigurableWebApplicationContext>, Ordered {

	private int order = Ordered.HIGHEST_PRECEDENCE;

	private final ServletContext servletContext;

	private final boolean addApplicationContextAttribute;

	public ServletContextApplicationContextInitializer(ServletContext servletContext) {
		this(servletContext, false);
	}

	public ServletContextApplicationContextInitializer(ServletContext servletContext,
			boolean addApplicationContextAttribute) {
		this.servletContext = servletContext;
		this.addApplicationContextAttribute = addApplicationContextAttribute;
	}

	public void setOrder(int order) {
		this.order = order;
	}

	@Override
	public int getOrder() {
		return this.order;
	}

	@Override
	public void initialize(ConfigurableWebApplicationContext applicationContext) {
		// 將這個 ServletContext 上下文物件設定到 ApplicationContext 中
		applicationContext.setServletContext(this.servletContext);
		if (this.addApplicationContextAttribute) {
			this.servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE,
					applicationContext);
		}
	}
}

可以看到會將這個 ServletContext 上下文物件設定到 ApplicationContext 中

那麼我們回顧到上一篇 《Spring Boot 內嵌 Tomcat 容器的實現》 文章的 1. onRefresh 方法小節呼叫的 createWebServer() 方法,如下:

// ServletWebServerApplicationContext.java
private void createWebServer() {
    // <1> 獲取當前 `WebServer` 容器物件,首次進來為空
    WebServer webServer = this.webServer;
    // <2> 獲取 `ServletContext` 上下文物件
    ServletContext servletContext = getServletContext();
    // <3> 如果 WebServer 和 ServletContext 都為空,則需要建立一個
    // 使用 Spring Boot 內嵌 Tomcat 容器則會進入該分支
    if (webServer == null && servletContext == null) {
        // <3.1> 獲取 Servlet 容器工廠物件(預設為 Tomcat)`factory`
        ServletWebServerFactory factory = getWebServerFactory();
        /**
         * <3.2> 先建立一個 {@link ServletContextInitializer} Servlet 上下文初始器,實現也就是當前類的 {@link this#selfInitialize(ServletContext)} 方法
         * 至於為什麼不用 Servlet 3.0 新增的 {@link javax.servlet.ServletContainerInitializer} 這個類,我在
         * [精盡Spring MVC原始碼分析 - 尋找遺失的 web.xml](https://www.cnblogs.com/lifullmoon/p/14122704.html)有提到過
         *
         * <3.3> 從 `factory` 工廠中建立一個 WebServer 容器物件
         * 例如建立一個 {@link TomcatWebServer} 容器物件,並初始化 `ServletContext` 上下文,建立 {@link Tomcat} 容器並啟動
         * 啟動過程非同步觸發了 {@link org.springframework.boot.web.embedded.tomcat.TomcatStarter#onStartup} 方法
         * 也就會呼叫這個傳入的 {@link ServletContextInitializer} 的 {@link #selfInitialize(ServletContext)} 方法
         */
        this.webServer = factory.getWebServer(getSelfInitializer());
    }
    // <4> 否則,如果 ServletContext 不為空,說明使用了外部的 Servlet 容器(例如 Tomcat)
    else if (servletContext != null) {
        try {
            /** 那麼這裡主動呼叫 {@link this#selfInitialize(ServletContext)} 方法來註冊各種 Servlet、Filter */
            getSelfInitializer().onStartup(servletContext);
        }
        catch (ServletException ex) {
            throw new ApplicationContextException("Cannot initialize servlet context", ex);
        }
    }
    // <5> 將 ServletContext 的一些初始化引數關聯到當前 Spring 應用的 Environment 環境中
    initPropertySources();
}

我們看到上面第 4 步,如果從當前 Spring 應用上下文獲取到了 ServletContext 物件,不會走上面的第 3 步,也就是不建立 Spring Boot 內嵌的 Tomcat

主動呼叫它的 getSelfInitializer() 方法來往這個 ServletContext 物件中註冊各種 Servlet、Filter 和 EventListener 物件,包括 Spring MVC 中的 DispatcherServlet 物件,該方法參考上一篇 《Spring Boot 內嵌 Tomcat 容器的實現》 文章的 2. selfInitialize 方法 小節

總結

本文分析了 Spring Boot 應用被打成 war 包後是如何支援放入外部 Tomcat 容器執行的,原理也比較簡單,藉助 Spring MVC 中的 SpringServletContainerInitializer 這個類,它實現了 Servlet 3.0 新增的 javax.servlet.ServletContainerInitializer 介面

  1. 通過 Java 的 SPI 機制,在 META-INF/services/javax.servlet.ServletContainerInitializer 檔案中寫入 SpringServletContainerInitializer 這個類,那麼在 Servlet 容器啟動的時候會呼叫這個類的 onStartup(..) 方法,會找到 WebApplicationInitializer 型別的物件,並呼叫他們的 onStartup(ServletContext) 方法

  2. 在我們的 Spring Boot 應用中,如果需要打成 war 包放入外部 Tomcat 容器執行,啟動類則需要繼承 SpringBootServletInitializer 抽象類,它實現了 WebApplicationInitializer 介面

  3. SpringBootServletInitializer 中會建立一個 WebApplicationContext 作為 Root Spring 應用上下文,同時會將 ServletContext 物件設定到 Spring 應用上下文中

  4. 這樣一來,因為已經存在 ServletContext 物件,那麼不會再建立 Spring Boot 內嵌的 Tomcat 容器,而是對 ServletContext 進行一些初始化工作

好了,到這裡關於 Spring Boot 啟動 Spring 應用的整個主流程,包括內嵌 Tomcat 容器的實現,以及支援執行在外部 Servlet 容器的實現都分析完了

那麼接下來,我們一起來看看 @SpringBootApplication 這個註解,也就是 @EnableAutoConfiguration 自動配置註解的實現原理

相關文章