在 Spring Boot GraalVM 原生镜像中使用 Thymeleaf 布局和 Fragment 表达式

在 Spring Boot + Thyemleaf 的应用中,我们可以使用 thymeleaf-layout-dialect 来定义网页的通用布局,效果很好。

但是当我们将 Spring Boot 应用编译到 GraalVM 原生镜像时,却 出现了问题

GraalVM Native Image: Generating 'demo' (executable)...
========================================================================================================================
[1/7] Initializing...                                                                                    (5,6s @ 0,32GB)
 Version info: 'GraalVM 22.3.1 Java 17 CE'
 Java version info: '17.0.6+10-jvmci-22.3-b13'
 C compiler: gcc (linux, x86_64, 11.3.0)
 Garbage collector: Serial GC
 2 user-specific feature(s)
 - com.oracle.svm.polyglot.groovy.GroovyIndyInterfaceFeature
 - org.springframework.aot.nativex.feature.PreComputeFieldFeature
Field org.apache.commons.logging.LogAdapter#log4jSpiPresent set to true at build time
Field org.apache.commons.logging.LogAdapter#log4jSlf4jProviderPresent set to true at build time
Field org.apache.commons.logging.LogAdapter#slf4jSpiPresent set to true at build time
Field org.apache.commons.logging.LogAdapter#slf4jApiPresent set to true at build time
Field org.springframework.core.KotlinDetector#kotlinPresent set to true at build time
Field org.springframework.core.KotlinDetector#kotlinReflectPresent set to true at build time
Field org.springframework.core.NativeDetector#imageCode set to true at build time
Field org.springframework.format.support.DefaultFormattingConversionService#jsr354Present set to false at build time
Field org.springframework.cglib.core.AbstractClassGenerator#imageCode set to true at build time
[2/7] Performing analysis...  []                                                                         (8,5s @ 3,78GB)
   9 047 (82,33%) of 10 989 classes reachable
  12 287 (66,39%) of 18 507 fields reachable
  37 945 (49,30%) of 76 964 methods reachable
     612 classes,   363 fields, and 3 625 methods registered for reflection

Error: Classes that should be initialized at run time got initialized during image building:
 java.beans.Introspector was unintentionally initialized at build time. To see why java.beans.Introspector got initialized use --trace-class-initialization=java.beans.Introspector
To see how the classes got initialized, use --trace-class-initialization=java.beans.Introspector
Error: Use -H:+ReportExceptionStackTraces to print stacktrace of underlying exception

我尝试了前面 Issue 中提到的许多建议,但都不奏效。

后来,Oliver Drotbohm 向我推荐了 Flexible layouts(灵活布局)的方法,以创建 Thymeleaf 本身提供的布局支持。这种方法在 GraalVM 原生镜像中也同样适用。

本文将带你了解何在不使用 thymeleaf-layout-dialect 的情况下使用 Thymeleaf 创建布局。

创建 Spring Boot 应用

通过 Spring Initializr 初始化 Spring Boot 应用,添加 WebThymeleafGraalVM Native Support 依赖。

创建布局模板

src/main/resources/templates 目录下创建布局模板 layout.html

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org"
      th:fragment="layout (title, content, pageScripts)" >
<head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>MyApp - <th:block th:insert="${title}"/></title>
</head>
<body>
<main>
    <div id="app" class="container">
        <div th:block th:replace="${content}">
            <p>Layout content</p>
        </div>
    </div>
</main>
<th:block th:replace="${pageScripts}">
</th:block>
</body>
</html>

说明:

  • html 根标签有 th:fragment="layout (title, content, pageScripts)" 属性,该属性为片段(Fragment)命名并定义其接受的参数。
  • 布局(Layout)有三个参数:titlecontentpageScripts
    • title 参数用于设置页面标题。
    • content 参数用于包含页面内容。
    • pageScripts 参数用于包含特定页面的脚本。
  • 有些应用的标题(title)模式是 "AppName - PageName"。这里使用了相同的模式。

创建页面模板

src/main/resources/templates 目录下创建一个使用该布局的页面模板 home.html

<!DOCTYPE html>
<html lang="en"
      xmlns="http://www.w3.org/1999/xhtml"
      th:replace="~{layout :: layout(title=~{::title/text()},
                    content=~{::#content}, pageScripts=~{})}">
<head>
    <title>Home</title>
</head>
<body>
<div id="content">
    <h1>This is Home Page</h1>
    <a href="/">Home Page</a>
    <a href="/about">About Page</a>
</div>
</body>
</html>

说明:

  • html 根标签有 th:replace="~{layout :: layout(...)}" 属性,它使用片段表达式指定要替换的片段。这里我们引用了文件名 layout.html 和片段名 layout
  • 然后,用命名参数传递所有参数值
  • title 参数使用片段表达式 ~{::title/text()} 指定。这指的是当前页面模板中 <title> 元素的文本内容。
  • content 参数使用片段表达式 ~{::#content} 指定。这指的是当前页面模板中 <div id="content"> 元素的内容。
  • pageScripts 参数使用片段表达式 ~{} 指定。这指的是一个空片段,指定无标记。

除了使用命名参数指定参数值外,还可以使用默认位置参数指定参数值,如下所示:

<html lang="en"
      xmlns="http://www.w3.org/1999/xhtml"
      th:replace="~{layout :: layout(~{::title/text()}, ~{::#content},~{})}">
...
</html>

创建其他页面模板

src/main/resources/templates 目录下创建另一个使用该布局的页面模板 about.html

<!DOCTYPE html>
<html lang="en"
      xmlns="http://www.w3.org/1999/xhtml"
      th:replace="~{layout :: layout(~{::title/text()}, ~{::#content},~{::#pageScripts})}">
<head>
    <title>About</title>
</head>
<body>
<div id="content">
    <h1>This is About Page</h1>
    <a href="/">Home Page</a>
    <a href="/about">About Page</a>
</div>
<th:block id="pageScripts">
<script src="/webjars/jquery/3.7.1/jquery.js"></script>
</th:block>
</body>
</html>

说明:

  • 我们使用位置参数传递布局片段(Layout Fragment)参数。
  • pageScripts 参数使用片段表达式 ~{::#pageScripts} 指定。这指的是当前页面模板中 <div id="pageScripts"> 元素的内容。在这个特定页面中,我们包含了一个从 webjars 中加载 jQuery 的脚本标签。

运行应用

首先,验证一下应用是否运行正常,运行 Spring Boot 应用并访问主页 http://localhost:8080/

确定一切 Ok 后,将应用编译为 GraalVM 原生镜像并运行它。

#Maven
./mvnw spring-boot:build-image -Pnative -DskipTests -Dspring-boot.build-image.imageName=thymeleaf-demo

#Gradle
./gradlew bootBuildImage -Pnative -x test --imageName=thymeleaf-demo

docker run -p 8080:8080 thymeleaf-demo

访问主页 http://localhost:8080/ 和 About 页面 http://localhost:8080/about。你应该可以顺利看到这些页面。

总结

thymeleaf-layout-dialect 相比,虽然使用片段表达式创建布局略显繁琐,但它在 GraalVM 原生镜像中运行良好。

thymeleaf-layout-dialect 的一个主要问题是它在底层使用了 Groovy,而 Groovy 在 GraalVM 原生镜像中存在一些问题。有人尝试将 thymeleaf-layout-dialect 移植到 Java,github.com/zhanhb/thymeleaf-layout-dialect,如果你感兴趣,可以试试。


Ref:https://www.sivalabs.in/thymeleaf-layouts-using-fragment-expressions/