Spring 中的無反射模板

工程 | Dave Syer | 2024 年 3 月 22 日 | ...

最近出現了一些使用文字模板但在構建時編譯成 Java 類的 Java 庫。因此,它們在一定程度上可以聲稱是“無反射”的。除了潛在的執行時效能優勢外,它們還承諾易於使用並與 GraalVM 本機映象編譯整合,因此對於剛開始在 Spring Boot 3.x 中使用該技術棧的人來說非常有趣。我們將介紹一些庫(JStachioRockerJTEManTL)以及如何執行它們。

示例的原始碼位於 GitHub 上,並且每個模板引擎都有自己的分支。示例故意保持非常簡單,沒有使用所有模板引擎的功能。重點在於如何將它們與 Spring Boot 和 GraalVM 整合。

JStachio

由於它是我的最愛,我將從 JStachio 開始介紹。它非常易於使用,佔用空間非常小,並且執行時速度也非常快。模板是使用 Mustache 編寫的純文字檔案,這些檔案在構建時被編譯成 Java 類,並在執行時進行渲染。

在示例中,有一個用於主頁 (index.mustache) 的模板,它只打印問候語和訪客計數

{{<layout}}{{$body}}
Hello {{name}}!
<br>
<br>
You are visitor number {{visits}}.
{{/body}}
{{/layout}}

它使用了一個簡單的“佈局”模板 (layout.mustache)

<html>
  <head></head>
  <body>{{$body}}{{/body}}
  </body>
</html>

(佈局並非嚴格必需,但它是展示如何組合模板的好方法)。

JStachio APT 處理器將為它找到的每個帶有 @JStache 註解的模板生成一個 Java 類,該註解用於在原始碼中標識模板檔案。在此示例中,我們有

@JStache(path = "index")
public class DemoModel {
	public String name;
	public long visits;

	public DemoModel(String name, long visits) {
		this.name = name;
		this.visits = visits;
	}
}

@JStache 註解的 path 屬性是模板檔案的名稱,不包含副檔名(關於如何將它們組合在一起,請參見下文)。您也可以為模型使用 Java record,這很簡潔,但由於其他模板引擎不支援它,我們將忽略此用法,以使示例更具可比性。

構建配置

要將其編譯成 Java 類,您需要在 pom.xml 中的編譯器外掛中新增一些配置

<plugin>
	<artifactId>maven-compiler-plugin</artifactId>
	<configuration>
		<annotationProcessorPaths>
			<annotationProcessorPath>
				<groupId>io.jstach</groupId>
				<artifactId>jstachio-apt</artifactId>
				<version>${jstachio.version}</version>
			</annotationProcessorPath>
		</annotationProcessorPaths>
	</configuration>
</plugin>

JStachio 提供了一些 Spring Boot 整合,因此您只需將其新增到類路徑中即可

<dependency>
	<groupId>io.jstach</groupId>
	<artifactId>jstachio-spring-boot-starter-webmvc</artifactId>
	<version>${jstachio.version}</version>
</dependency>

控制器

您可以在控制器中使用該模板,例如

@GetMapping("/")
public View view() {
	visitsRepository.add();
	return JStachioModelView.of(new DemoModel("World", visitsRepository.get()));
}

此控制器返回一個由 DemoModel 構建的 View。它也可以直接返回 DemoModel,Spring Boot 會自動將其包裝在 JStachioModelView 中。

JStachio 配置

DemoApplication 類中也有全域性配置

@JStachePath(prefix = "templates/", suffix = ".mustache")
@SpringBootApplication
public class DemoApplication {
	...
}

以及一個指向它的 package-info.java 檔案(每個包含 @JStache 模型的 Java 包都需要一個這樣的檔案)

@JStacheConfig(using = DemoApplication.class)
package demo;
...

執行示例

使用 ./mvnw spring-boot:run(或從 IDE 中的 main 方法)執行應用程式,您應該能在 https://:8080/ 看到主頁。

編譯後生成的原始碼位於 target/generated-sources/annotations 中,您可以在那裡看到為 DemoModel 生成的 Java 類

$ tree target/generated-sources/annotations/
target/generated-sources/annotations/
└── demo
    └── DemoModelRenderer.java

示例還包含一個 測試主方法,因此您可以使用命令列 ./mvnw spring-boot:test-run 執行,或者透過 IDE 中的測試主方法執行,並且當您在 IDE 中進行更改時,應用程式將重啟。構建時編譯的缺點之一是,您必須強制重新編譯才能看到模板中的更改。IDE 不會自動執行此操作,因此您可能需要使用其他工具來觸發重新編譯。我曾成功使用以下方法來強制模型類在模板更改時重新編譯

$ while inotifywait src/main/resources/templates -e close_write; do \
  sleep 1; \
  find src/main/java -name \*Model.java -exec touch {} \;; \
done

inotifywait 命令是一個工具,它等待檔案在寫入後關閉。它易於在任何 Linux 發行版或 Mac 上安裝和使用。

本機映象

使用 ./mvnw -P native spring-boot:build-image(或直接使用 native-image 外掛)無需額外配置即可生成本機映象。映象啟動時間不到 0.1 秒

$ docker run -p 8080:8080 demo:0.0.1-SNAPSHOT

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.2.4)

2024-03-22T12:23:45.403Z  INFO 1 --- [           main] demo.DemoApplication                     : Starting AOT-processed DemoApplication using Java 17.0.10 with PID 1 (/workspace/demo.DemoApplication started by cnb in /workspace)
2024-03-22T12:23:45.403Z  INFO 1 --- [           main] demo.DemoApplication                     : No active profile set, falling back to 1 default profile: "default"
2024-03-22T12:23:45.418Z  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8080 (http)
2024-03-22T12:23:45.419Z  INFO 1 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2024-03-22T12:23:45.419Z  INFO 1 --- [           main] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/10.1.19]
2024-03-22T12:23:45.429Z  INFO 1 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2024-03-22T12:23:45.429Z  INFO 1 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 26 ms
2024-03-22T12:23:45.462Z  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 8080 (http) with context path ''
2024-03-22T12:23:45.462Z  INFO 1 --- [           main] demo.DemoApplication                     : Started DemoApplication in 0.069 seconds (process running for 0.073)

Rocker

Rocker 的使用方式與 JStachio 類似。模板是用一種自定義語言編寫的,類似於帶有額外 Java 特性的 HTML(有點像 JSP)。主頁看起來像這樣 (demo.rocker.html)

@import demo.DemoModel

@args(DemoModel model)

@templates.layout.template("Demo") -> {
	<h1>Demo</h1>
	<p>Hello @model.name!</p>
	<br>
	<br>
	<p>You are visitor number @model.visits.</p>
}

它匯入了 DemoModel 物件 - 實現與 JStachio 示例相同。模板還直接引用其佈局(呼叫 templates.layout 上的靜態方法)。佈局是一個獨立的模板檔案 (layout.rocker.html)

@args (String title, RockerBody content)

<html>
    <head>
        <title>@title</title>
    </head>
    <body>
    @content
    </body>
</html>

構建配置

Rocker 需要一個 APT 處理器,並且需要手動將生成的原始碼新增到構建輸入中。所有這些都可以在 pom.xml 中配置

<plugin>
	<groupId>com.fizzed</groupId>
	<artifactId>rocker-maven-plugin</artifactId>
	<version>1.2.1</version>
	<executions>
		<execution>
			<?m2e execute onConfiguration,onIncremental?>
			<id>generate-rocker-templates</id>
			<phase>generate-sources</phase>
			<goals>
				<goal>generate</goal>
			</goals>
			<configuration>
				<javaVersion>${java.version}</javaVersion>
				<templateDirectory>src/main/resources</templateDirectory>
				<outputDirectory>target/generated-sources/rocker</outputDirectory>
				<discardLogicWhitespace>true</discardLogicWhitespace>
				<targetCharset>UTF-8</targetCharset>
				<postProcessing>
					<param>com.fizzed.rocker.processor.LoggingProcessor</param>
					<param>com.fizzed.rocker.processor.WhitespaceRemovalProcessor</param>
				</postProcessing>
			</configuration>
		</execution>
	</executions>
</plugin>
<plugin>
	<groupId>org.codehaus.mojo</groupId>
	<artifactId>build-helper-maven-plugin</artifactId>
	<executions>
		<execution>
			<phase>generate-sources</phase>
			<goals>
				<goal>add-source</goal>
			</goals>
			<configuration>
				<sources>
					<source>${project.build.directory}/generated-sources/rocker</source>
				</sources>
			</configuration>
		</execution>
	</executions>
</plugin>

控制器

控制器的實現非常常規——它構造一個模型並返回“demo”檢視的名稱

@GetMapping("/")
public String view(Model model) {
	visitsRepository.add();
	model.addAttribute("arguments", Map.of("model", new DemoModel("mystérieux visiteur", visitsRepository.get())));
	return "demo";
}

我們使用命名約定將“arguments”作為特殊的模型屬性。這是我們稍後將看到的 View 實現的細節。

Rocker 配置

Rocker 沒有自帶 Spring Boot 整合,但這不難實現,而且只需實現一次。示例包含一個 View 實現,以及一個 ViewResolverRockerAutoConfiguration 中的一些配置

@Configuration
public class RockerAutoConfiguration {
	@Bean
	public ViewResolver rockerViewResolver() {
		return new RockerViewResolver();
	}
}

RockerViewResolver 是一個使用 Rocker 模板引擎渲染模板的 ViewResolverView 實現是 Rocker 模板類的包裝器

public class RockerViewResolver implements ViewResolver, Ordered {

	private String prefix = "templates/";
	private String suffix = ".rocker.html";

	@Override
	@Nullable
	public View resolveViewName(String viewName, Locale locale) throws Exception {
		RockerView view = new RockerView(prefix + viewName + suffix);
		return view;
	}

	@Override
	public int getOrder() {
		return Ordered.LOWEST_PRECEDENCE - 10;
	}

}

如果檢視 RockerView 的實現,您會發現它是 Rocker 模板類的包裝器,並且它包含一些反射程式碼來查詢模板引數名稱。這對於本機映象可能是一個問題,因此它並不理想,但我們稍後會看到如何解決它。Rocker 內部也使用反射將模板引數繫結到模型,因此它無論如何也不是完全無反射的。

執行示例

如果使用 ./mvnw spring-boot:run 執行示例,您將看到主頁在 https://:8080/。生成的原始碼在 target/generated-sources/rocker/ 中,每個模板生成一個 Java 類

$ tree target/generated-sources/rocker/
target/generated-sources/rocker/
└── templates
    ├── demo.java
    └── layout.java

本機映象

本機映象需要一些額外配置來允許渲染期間的反射。我們對此進行了一些嘗試,很快就發現反射在 Rocker 內部被廣泛使用,使其與 GraalVM 配合工作需要大量精力。或許有一天值得再回來研究。

JTE

(JTE 示例直接複製自專案文件。本文件中的其他示例之所以採用目前的結構,是因為它們模仿了此示例。)

與 Rocker 類似,JTE 具有類似於 HTML 並帶有額外 Java 特性的模板語言。專案文件中的模板位於 java 旁邊的 jte 目錄中,因此我們採用相同的約定。主頁看起來像這樣 (demo.jte)

@import demo.DemoModel

@param DemoModel model

Hello ${model.name}!
<br>
<br>
You are visitor number ${model.visits}.

此示例中沒有佈局模板,因為 JTE 不顯式支援模板組合。DemoModel 與我們用於其他示例的類似。

構建配置

pom.xml 中,您需要新增 JTE 編譯器外掛

<plugin>
	<groupId>gg.jte</groupId>
	<artifactId>jte-maven-plugin</artifactId>
	<version>${jte.version}</version>
	<configuration>
		<sourceDirectory>${basedir}/src/main/jte</sourceDirectory>
		<contentType>Html</contentType>
		<binaryStaticContent>true</binaryStaticContent>
	</configuration>
	<executions>
		<execution>
			<?m2e execute onConfiguration,onIncremental?>
			<phase>generate-sources</phase>
			<goals>
				<goal>generate</goal>
			</goals>
		</execution>
	</executions>
</plugin>

以及一些原始碼和資源複製

<plugin>
	<groupId>org.codehaus.mojo</groupId>
	<artifactId>build-helper-maven-plugin</artifactId>
	<executions>
		<execution>
			<phase>generate-sources</phase>
			<goals>
				<goal>add-source</goal>
			</goals>
			<configuration>
				<sources>
					<source>${project.build.directory}/generated-sources/jte</source>
				</sources>
			</configuration>
		</execution>
	</executions>
</plugin>

<plugin>
	<artifactId>maven-resources-plugin</artifactId>
	<version>3.0.2</version>
	<executions>
		<execution>
			<id>copy-resources</id>
			<phase>process-classes</phase>
			<goals>
				<goal>copy-resources</goal>
			</goals>
			<configuration>
				<outputDirectory>${project.build.outputDirectory}</outputDirectory>
				<resources>
					<resource>
						<directory>${basedir}/target/generated-sources/jte</directory>
						<includes>
							<include>**/*.bin</include>
						</includes>
						<filtering>false</filtering>
					</resource>
				</resources>
			</configuration>
		</execution>
	</executions>
</plugin>

執行時依賴是

<dependency>
	<groupId>gg.jte</groupId>
	<artifactId>jte</artifactId>
	<version>${jte.version}</version>
</dependency>
<dependency>
	<groupId>gg.jte</groupId>
	<artifactId>jte-spring-boot-starter-3</artifactId>
	<version>${jte.version}</version>
</dependency>

控制器

控制器的實現非常常規——事實上,它與我們用於 Rocker 的實現完全相同。

JTE 配置

JTE 自帶 Spring Boot 自動配置(我們已在 pom.xml 中新增),因此您幾乎不需要做任何其他事情。要使其與 Spring Boot 3.x 配合工作,您只需做一件小事,即在 application.properties 檔案中新增一個屬性。在開發時,特別是如果您使用 Spring Boot Devtools,您會希望設定

gg.jte.developmentMode=true

在生產環境中,透過 Spring profile 關閉它,並改用 gg.jte.usePrecompiledTemplates=true

執行示例

如果使用 ./mvnw spring-boot:run 執行示例,您將看到主頁在 https://:8080/。生成的原始碼在 target/generated-sources/jte/ 中,每個模板生成一個 Java 類

$ tree target/generated-sources/jte/
target/generated-sources/jte/
└── gg
    └── jte
        └── generated
            └── precompiled
                ├── JtedemoGenerated.bin
                └── JtedemoGenerated.java

.bin 檔案是文字模板的有效二進位制表示形式,在執行時使用,因此需要將其新增到類路徑中。

本機映象

可以透過一些額外配置生成本機映象。我們需要確保 .bin 檔案可用,並且生成的 Java 類可以被反射

@SpringBootApplication
@ImportRuntimeHints(DemoRuntimeHints.class)
public class DemoApplication {
	...
}

class DemoRuntimeHints implements RuntimeHintsRegistrar {

	@Override
	public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
		hints.resources().registerPattern("**/*.bin");
		hints.reflection().registerType(JtedemoGenerated.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_DECLARED_METHODS);
	}

}

因此,JTE 並非完全無反射,但可以相當容易地配置它與 GraalVM 本機一起工作。

ManTL

ManTL (Manifold Template Language) 是另一個具有 Java 類似語法的模板引擎。模板在構建時編譯成 Java 類,與其他示例一樣。主頁看起來像這樣 (Demo.html.mtl)

<%@ import demo.DemoModel %>

<%@ params(DemoModel model) %>

Hello ${model.name}!
<br>
<br>
You are visitor number ${model.visits}.

其中 DemoModel 與其他示例中的相同。

構建配置

Manifold 與其他示例有點不同,它使用 JDK 編譯器外掛,而不是 APT 處理器。pom.xml 中的配置稍微複雜一些。有 maven-compiler-plugin

<plugin>
	<groupId>org.apache.maven.plugins</groupId>
	<artifactId>maven-compiler-plugin</artifactId>
	<version>3.8.0</version>
	<configuration>
		<compilerArgs>
			<arg>-Xplugin:Manifold</arg>
		</compilerArgs>
		<annotationProcessorPaths>
			<path>
				<groupId>systems.manifold</groupId>
				<artifactId>manifold-templates</artifactId>
				<version>${manifold.version}</version>
			</path>
		</annotationProcessorPaths>
	</configuration>

和執行時依賴

<dependency>
	<groupId>systems.manifold</groupId>
	<artifactId>manifold-templates-rt</artifactId>
	<version>${manifold.version}</version>
</dependency>

控制器

此示例中的控制器更像 JStachio 的控制器,而不是 Rocker/JTE 的控制器

@GetMapping("/")
public View view(Model model, HttpServletResponse response) {
	visitsRepository.add();
	return new StringView(() -> Demo.render(new DemoModel("mystérieux visiteur", visitsRepository.get())));
}

其中 StringView 是一個方便的類,用於包裝模板並渲染它

public class StringView implements View {

	private final Supplier<String> output;

	public StringView(Supplier<String> output) {
		this.output = output;
	}

	@Override
	public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response)
			throws Exception {
		String result = output.get();
		response.setContentType(MediaType.TEXT_HTML_VALUE);
		response.setCharacterEncoding(StandardCharsets.UTF_8.name());
		response.setContentLength(result.getBytes().length);

		response.getOutputStream().write(result.getBytes());
		response.flushBuffer();
	}
}

執行示例

您可以使用命令列 ./mvnw spring-boot:run 構建並執行應用程式,並在 https://:8080 上檢視結果。生成的原始碼每個模板包含一個類和一些輔助檔案

$ tree target/classes/templates/
target/classes/templates/
├── Demo$LayoutOverride.class
├── Demo.class
└── Demo.html.mtl

ManTL 只能在安裝特殊外掛後在 IntelliJ 中工作,完全不能在 Eclipse、NetBeans 或 VSCode 中工作。您或許可以從這些 IDE 執行 main 方法,但引用模板的程式碼會出現編譯錯誤,因為編譯器外掛缺失。

本機映象

GraalVM 不支援此編譯器外掛,因此您無法將 ManTL 與 GraalVM 本機映象一起使用。

總結

我們在此介紹的所有模板引擎在構建時將模板編譯成 Java 類,因此在這一點上是無反射的。它們都易於使用並與 Spring 整合,並且都具有或可以提供某種 Spring Boot 自動配置。JStachio 是執行時最輕量、最快的,並且對 GraalVM 本機映象的支援最好。Rocker 在執行時也非常快,但它內部使用反射,並且不易使其與 GraalVM 配合工作。JTE 配置稍微複雜一些,但執行時也非常快,並且易於使其與 GraalVM 配合工作。ManTL 配置最複雜,並且完全無法與 GraalVM 配合工作。它也只能在 IntelliJ 作為 IDE 時工作。

如果您想檢視更多示例,每個模板引擎都有自己的文件,請點選上面的連結。我在 JStachio 方面的工作產生了一些額外的示例,例如 Mustache PetClinic,以及由 Ollie Drotbohm 最初實現的 Todo MVC,並已適配到各種不同的模板引擎。

Dave Syer
倫敦 2024

獲取 Spring 時事通訊

透過 Spring 時事通訊保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助您快速進步。

瞭解更多

獲取支援

Tanzu Spring 在一個簡單的訂閱中提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案。

瞭解更多

即將舉行的活動

檢視 Spring 社群所有即將舉行的活動。

檢視全部