The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

如何將 Spring Boot 應用程式部署到 Tomcat 應用程式伺服器

我們在專案上因為面對不同的客戶,有時候會遇到各種形形色色的應用程式伺服器要部署,雖然 Spring Boot 已經有內建 Embedded Tomcat 伺服器,但這套主要用在開發時期或微服務部署之用。如果最終你的應用程式要部署到客戶的 Tomcat / JBoss EAP / IBM WebSphere 等正式環境,還是要做出一些調整才行。今天這篇文章就來深入探討部署到 Apache Tomcat® 的設定過程與完整知識。

Spring Boot and Tomcat

建立範例應用程式

  1. 使用 Spring Boot CLI 快速建立專案 (也可以用 Spring Initializr 建立)

    spring init --dependencies=web --groupId=com.duotify app1
    

    使用 Visual Studio Code 開啟該專案

    code app1
    
  2. 加入一個 HomeController 控制器

    檔名路徑: src/main/java/com/duotify/app1/controllers/HomeController.java

    package com.duotify.app1.controllers;
    
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    public class HomeController {
    
        @GetMapping("/")
        public String home() {
            return "Hello World";
        }
    }
    
  3. 測試執行

    mvn spring-boot:run
    

    http://localhost:8080/

    補充說明: 你可以在 pom.xml<build> 底下新增一個 <defaultGoal>spring-boot:run</defaultGoal> 設定,未來就只要打 mvn 就會自動啟動 Spring Boot 執行喔! 👍

調整專案內容

要部署到獨立的 Tomcat 伺服器,必須做出以下調整,總共只有 3 個步驟而已:

  1. 調整套用 @SpringBootApplication 的類別 (DemoApplication.java)

    原本標註 @SpringBootApplication 的主程式,必須修改成繼承 SpringBootServletInitializer 類別:

    package com.duotify.app1;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
    
    @SpringBootApplication
    public class DemoApplication extends SpringBootServletInitializer {
    
      public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
      }
    
    }
    

    其實 SpringBootServletInitializer 實作了 WebApplicationInitializer 介面,而 WebApplicationInitializer 這個介面是 Servlet 3.0+ (JSR 315) 新增的,實作此介面就會自動設定 ServletContext 並與 Servlet Container 進行通訊,讓應用程式順利掛載到任何支援 Servlet Container 的 Application Server 中。

    這個機制是從 Servlet 3.0 API 以上版本才支援的,而 Apache Tomcat 是從 7.0 版以上才開始支援 Servlet 3.0 規格。如果是 Servlet 2.5 以前的版本,還是必須要透過 web.xml 方式註冊 ApplicationContextDispatcherServlet 才行。不過 Apache Tomcat 7.0 是一個已經廢棄的超舊版本,應該不容易遇到才對。詳見 Apache Tomcat® - Which Version Do I Want?

  2. 調整 pom.xml 並修改 Packaging 格式為 war

    <packaging>war</packaging>
    
  3. 調整 pom.xml 並加入 spring-boot-starter-tomcat 相依套件,並將 <scope> 設定為 provided

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-tomcat</artifactId>
      <scope>provided</scope>
    </dependency>
    

    以下是目前的 pom.xml 檔案內容:

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
      <modelVersion>4.0.0</modelVersion>
      <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.3</version>
        <relativePath/> <!-- lookup parent from repository -->
      </parent>
      <groupId>com.duotify</groupId>
      <artifactId>app1</artifactId>
      <version>0.0.1-SNAPSHOT</version>
    
      <packaging>war</packaging>
    
      <name>demo</name>
      <description>Demo project for Spring Boot</description>
      <properties>
        <java.version>17</java.version>
      </properties>
      <dependencies>
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-test</artifactId>
          <scope>test</scope>
        </dependency>
    
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-tomcat</artifactId>
          <scope>provided</scope>
        </dependency>
    
      </dependencies>
    
      <build>
        <plugins>
          <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
          </plugin>
        </plugins>
      </build>
    
    </project>
    

啟動 Tomcat 應用程式伺服器

以下是在本機啟動 Tomcat 應用程式伺服器的步驟:

  1. 先到 Apache Tomcat 9 Software Downloads 下載 64-bit Windows zip 壓縮檔

    apache-tomcat-9.0.65-windows-x64.zip

  2. 解壓縮到任意資料夾

    假設我們解壓縮到 G:\apache-tomcat-9.0.65 資料夾

  3. 啟動 Tomcat 伺服器

    G:\apache-tomcat-9.0.65\bin\catalina.bat run
    

    預設會 LISTEN Port 8080

輸出封裝檔案並部署到 Tomcat 應用程式伺服器

最後我們要輸出一個可以部署到 Tomcat 的 *.war 檔,基本上部署步驟如下:

  1. 執行 mvn clean package 命令

    這個命令會產生 target/app1-0.0.1-SNAPSHOT.war 檔案,大小大約 17MB 左右。

    🔽 解壓縮之後的目錄結構可點我展開查看 🔽
    .
    ├─META-INF
    │  │  MANIFEST.MF
    │  │  war-tracker
    │  │
    │  └─maven
    │      └─com.duotify
    │          └─app1
    │                  pom.properties
    │                  pom.xml
    │
    ├─org
    │  └─springframework
    │      └─boot
    │          └─loader
    │              │  ClassPathIndexFile.class
    │              │  ExecutableArchiveLauncher.class
    │              │  JarLauncher.class
    │              │  LaunchedURLClassLoader$DefinePackageCallType.class
    │              │  LaunchedURLClassLoader$UseFastConnectionExceptionsEnumeration.class
    │              │  LaunchedURLClassLoader.class
    │              │  Launcher.class
    │              │  MainMethodRunner.class
    │              │  PropertiesLauncher$1.class
    │              │  PropertiesLauncher$ArchiveEntryFilter.class
    │              │  PropertiesLauncher$ClassPathArchives.class
    │              │  PropertiesLauncher$PrefixMatchingArchiveFilter.class
    │              │  PropertiesLauncher.class
    │              │  WarLauncher.class
    │              │
    │              ├─archive
    │              │      Archive$Entry.class
    │              │      Archive$EntryFilter.class
    │              │      Archive.class
    │              │      ExplodedArchive$AbstractIterator.class
    │              │      ExplodedArchive$ArchiveIterator.class
    │              │      ExplodedArchive$EntryIterator.class
    │              │      ExplodedArchive$FileEntry.class
    │              │      ExplodedArchive$SimpleJarFileArchive.class
    │              │      ExplodedArchive.class
    │              │      JarFileArchive$AbstractIterator.class
    │              │      JarFileArchive$EntryIterator.class
    │              │      JarFileArchive$JarFileEntry.class
    │              │      JarFileArchive$NestedArchiveIterator.class
    │              │      JarFileArchive.class
    │              │
    │              ├─data
    │              │      RandomAccessData.class
    │              │      RandomAccessDataFile$1.class
    │              │      RandomAccessDataFile$DataInputStream.class
    │              │      RandomAccessDataFile$FileAccess.class
    │              │      RandomAccessDataFile.class
    │              │
    │              ├─jar
    │              │      AbstractJarFile$JarFileType.class
    │              │      AbstractJarFile.class
    │              │      AsciiBytes.class
    │              │      Bytes.class
    │              │      CentralDirectoryEndRecord$1.class
    │              │      CentralDirectoryEndRecord$Zip64End.class
    │              │      CentralDirectoryEndRecord$Zip64Locator.class
    │              │      CentralDirectoryEndRecord.class
    │              │      CentralDirectoryFileHeader.class
    │              │      CentralDirectoryParser.class
    │              │      CentralDirectoryVisitor.class
    │              │      FileHeader.class
    │              │      Handler.class
    │              │      JarEntry.class
    │              │      JarEntryCertification.class
    │              │      JarEntryFilter.class
    │              │      JarFile$1.class
    │              │      JarFile$JarEntryEnumeration.class
    │              │      JarFile.class
    │              │      JarFileEntries$1.class
    │              │      JarFileEntries$EntryIterator.class
    │              │      JarFileEntries$Offsets.class
    │              │      JarFileEntries$Zip64Offsets.class
    │              │      JarFileEntries$ZipOffsets.class
    │              │      JarFileEntries.class
    │              │      JarFileWrapper.class
    │              │      JarURLConnection$1.class
    │              │      JarURLConnection$JarEntryName.class
    │              │      JarURLConnection.class
    │              │      StringSequence.class
    │              │      ZipInflaterInputStream.class
    │              │
    │              ├─jarmode
    │              │      JarMode.class
    │              │      JarModeLauncher.class
    │              │      TestJarMode.class
    │              │
    │              └─util
    │                      SystemPropertyUtils.class
    │
    └─WEB-INF
        │  classpath.idx
        │  layers.idx
        │
        ├─classes
        │  │  application.properties
        │  │
        │  └─com
        │      └─duotify
        │          └─app1
        │              │  DemoApplication.class
        │              │
        │              └─controllers
        │                      HomeController.class
        │
        ├─lib
        │      jackson-annotations-2.13.3.jar
        │      jackson-core-2.13.3.jar
        │      jackson-databind-2.13.3.jar
        │      jackson-datatype-jdk8-2.13.3.jar
        │      jackson-datatype-jsr310-2.13.3.jar
        │      jackson-module-parameter-names-2.13.3.jar
        │      jakarta.annotation-api-1.3.5.jar
        │      jul-to-slf4j-1.7.36.jar
        │      log4j-api-2.17.2.jar
        │      log4j-to-slf4j-2.17.2.jar
        │      logback-classic-1.2.11.jar
        │      logback-core-1.2.11.jar
        │      slf4j-api-1.7.36.jar
        │      snakeyaml-1.30.jar
        │      spring-aop-5.3.22.jar
        │      spring-beans-5.3.22.jar
        │      spring-boot-2.7.3.jar
        │      spring-boot-autoconfigure-2.7.3.jar
        │      spring-boot-jarmode-layertools-2.7.3.jar
        │      spring-context-5.3.22.jar
        │      spring-core-5.3.22.jar
        │      spring-expression-5.3.22.jar
        │      spring-jcl-5.3.22.jar
        │      spring-web-5.3.22.jar
        │      spring-webmvc-5.3.22.jar
        │
        └─lib-provided
                tomcat-embed-core-9.0.65.jar
                tomcat-embed-el-9.0.65.jar
                tomcat-embed-websocket-9.0.65.jar
    

    這裡最值得一提的地方,就是 WEB-INF/lib-provided 這個資料夾。由於我們將 pom.xmlspring-boot-starter-tomcat 相依套件的 <scope> 調整為 provided 的關係,這個套件從預設加入到 WEB-INF/lib 改搬到 WEB-INF/lib-provided 這個資料夾,這等於我們部署到 Tomcat 應用程式伺服器的時候,預設不會載入 WEB-INF/lib-provided 這個資料夾中的 *.jar 檔。

  2. target/app1-0.0.1-SNAPSHOT.war 檔案複製到 G:\apache-tomcat-9.0.65\webapps 目錄下

    大約等個 1 ~ 3 秒,Tomcat 就會自動部署這個 app1-0.0.1-SNAPSHOT.war 檔案,並自動解壓縮到 app1-0.0.1-SNAPSHOT 目錄下。

    image

    而且我們從執行 Tomcat 的 Console 畫面也可以看到以下訊息:

    19-Sep-2022 22:43:23.587 INFO [Catalina-utility-2] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive [G:\apache-tomcat-9.0.65\webapps\app1-0.0.1-SNAPSHOT.war]
    19-Sep-2022 22:43:25.458 INFO [Catalina-utility-2] org.apache.jasper.servlet.TldScanner.scanJars At least one JAR was scanned for TLDs yet contained no TLDs. Enable debug logging for this logger for a complete list of JARs that were scanned but no TLDs were found in them. Skipping unneeded JARs during scanning can improve startup time and JSP compilation time.
    
      .   ____          _            __ _ _
    /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
    ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
    \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
      '  |____| .__|_| |_|_| |_\__, | / / / /
    =========|_|==============|___/=/_/_/_/
    :: Spring Boot ::                (v2.7.3)
    
    2022-09-19 22:43:26.340  INFO 10776 --- [alina-utility-2] com.duotify.app1.DemoApplication         : Starting DemoApplication v0.0.1-SNAPSHOT using Java 17.0.2 on WILLSUPERPC with PID 10776 (G:\apache-tomcat-9.0.65\webapps\app1-0.0.1-SNAPSHOT\WEB-INF\classes started by wakau in G:\apache-tomcat-9.0.65)
    2022-09-19 22:43:26.345  INFO 10776 --- [alina-utility-2] com.duotify.app1.DemoApplication         : No active profile set, falling back to 1 default profile: "default"
    2022-09-19 22:43:27.284  INFO 10776 --- [alina-utility-2] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 898 ms
    2022-09-19 22:43:28.411  INFO 10776 --- [alina-utility-2] com.duotify.app1.DemoApplication         : Started DemoApplication in 2.653 seconds (JVM running for 126.74)
    19-Sep-2022 22:43:28.433 INFO [Catalina-utility-2] org.apache.catalina.startup.HostConfig.deployWAR Deployment of web application archive [G:\apache-tomcat-9.0.65\webapps\app1-0.0.1-SNAPSHOT.war] has finished in [4,845] ms
    

    此時你開啟 http://localhost:8080/app1-0.0.1-SNAPSHOT/ 連結,就可以看到網頁成功部署!

    image

  3. 變更 Tomcat 部署的 Context Path 內容路徑

    由於部署 app1-0.0.1-SNAPSHOT.war 到 Tomcat 的時候,預設 WAR 檔的檔名就會自動變成 Tomcat 的 Context Path,所以我們可以調整 pom.xml<build><finalName> 設定,指定最終輸出的檔名即可。我們可以用 ${project.artifactId} 這個 Maven 內建屬性,直接取得本專案的 artifactId 當成檔名:

    <build>
      <finalName>${project.artifactId}</finalName>
      ...
    </build>
    

    此時再執行一次 mvn clean package 就會輸出 target/app1.war 檔案了!👍

    補充說明: 若要在開發測試階段也指定 Context Path 的話,可以到 src/main/resources/application.properties 加入一個 server.servlet.context-path=/app1 屬性設定即可。詳見: Spring Boot Change Context Path

關於 Maven 相依管裡的 provided scope 的技術細節

我們這個 Spring Boot 應用程式在封裝時,我還有發現一個魔鬼般的細節。

我原本以為只要相依套件設定為 <scope>provided</scope> 的話,就只有在 compiletest 的時候會用到,實際上在 runtime 的時候就不會載入。那如果不會載入,理論上是不是應該從最終封裝的 *.war 檔中排除,這樣可以讓 *.war 的整體檔案大小降低,更快速的部署才對。

結果我研究後發現,實則不然,整體 *.war 檔的大小完全不會降低,所有 Tomcat Embedded 相關檔案還是被包進去了:

7-Zip

而且我還發現,這個 app1.war 檔案,不單單可以部署到 Tomcat 應用程式伺服器,他依然可以透過 java -jar app1.war 獨立執行。其實想想這樣的設計還是挺方便的,可以隨時在本機執行,同時又可以進行遠端部署,唯一的缺點就是檔案比較大而已。

我花了許多時間嘗試了許多作法,想透過 Maven 建置的過程自動排除掉 Tomcat Embedded 相關檔案,後來有研究出可以跳過 spring-boot-maven-plugin plugin 執行 repackage 目標的方法。你只要調整一下 spring-boot-maven-plugin plugin 設定即可:

<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <executions>
    <execution>
      <id>repackage</id>
      <goals>
        <goal>repackage</goal>
      </goals>
      <configuration>
        <skip>true</skip>
      </configuration>
    </execution>
  </executions>
</plugin>

如果要透過 Spring Profiles 自動切換設定,完整設定如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.3</version>
    <relativePath /> <!-- lookup parent from repository -->
  </parent>
  <groupId>com.duotify</groupId>
  <artifactId>app1</artifactId>
  <version>0.0.1-SNAPSHOT</version>

  <packaging>war</packaging>

  <name>demo</name>
  <description>Demo project for Spring Boot</description>
  <properties>
    <java.version>17</java.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-tomcat</artifactId>
      <scope>provided</scope>
    </dependency>

  </dependencies>

  <build>
    <finalName>${project.artifactId}</finalName>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
        <executions>
          <execution>
            <id>repackage</id>
            <goals>
              <goal>repackage</goal>
            </goals>
            <configuration>
              <skip>${skip.repackage}</skip>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
  <profiles>
    <profile>
      <id>default</id>
      <activation>
        <activeByDefault>true</activeByDefault>
      </activation>
      <properties>
        <skip.repackage>false</skip.repackage>
      </properties>
    </profile>
    <profile>
      <id>prod</id>
      <properties>
        <skip.repackage>true</skip.repackage>
      </properties>
    </profile>
  </profiles>
</project>

今後你就可以依據不同的 Profile 執行不同的命令,產生不同的 WAR 檔:

  1. 發行測試環境的 WAR 檔,可以執行:

    mvn clean package
    

    輸出的 app1.war 大約 17MB 左右

  2. 發行生產環境的 WAR 檔,可以執行:

    mvn clean package -Pprod
    

    輸出的 app1.war 大約 12MB 左右

相關連結