使用 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 记录作为模型,这很简洁,但是由于其他模板引擎不支持它,我们将忽略它,使示例更具可比性。

构建配置

要将其编译为 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://127.0.0.1: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 类似。模板是用一种自定义语言编写的,这种语言类似于 HTML,并具有附加的 Java 功能(有点像 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";
}

我们使用命名约定将“参数”作为特殊模型属性。这是我们稍后将看到的 View 实现的细节。

Rocker配置

Rocker本身没有自带Spring Boot集成,但实现起来并不难,而且只需要做一次。示例包含一个View实现,一个ViewResolver以及RockerAutoConfiguration中的一些配置。

@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://127.0.0.1:8080/看到主页。生成的源代码以每个模板一个Java类的形式输出到target/generated-sources/rocker/目录。

$ 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://127.0.0.1:8080/看到主页。生成的源代码以每个模板一个Java类的形式输出到target/generated-sources/jte/目录。

$ 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模板语言)是另一个具有类似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://127.0.0.1:8080上检查结果。生成的源代码以每个模板一个类和辅助内容的形式输出。

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

ManTL仅在安装特殊插件后才能在IntelliJ中工作,而在Eclipse、NetBeans或VSCode中则完全无法工作。你或许可以从这些IDE运行主方法,但引用模板的代码将出现编译错误,因为缺少编译器插件。

原生镜像

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社区中所有即将举行的活动。

查看全部