
在 JVM 生态里做 Web 开发模板引擎一直是绕不开的话题。从早年的 JSP到后来流行的 FreeMarker、Thymeleaf再到各种前后端分离方案模板技术的演进一直没停过。但很多团队在实际使用模板引擎时都遇到过类似的尴尬模板里变量名写错了本地开发不报错直到页面真正渲染时才抛异常模板语法写错了也要等运行到那一行才暴露。对于页面多、模板多的大型项目来说这类问题排查成本很高还经常在测试阶段才被发现。如果有一种模板方案能让变量名、类型、方法调用在编译期就被检查出来很多问题就能提前暴露。这正是“Compile time safe templates on the JVM”要解决的核心问题。这篇文章会围绕 JVM 上编译期安全模板展开先讲清楚概念和原理再对比主流方案最后通过完整项目实战演示如何落地。1. 为什么需要编译期安全的模板1.1 传统模板引擎的痛点先回顾一下传统模板引擎的工作方式。以早期的 JSP 为例JSP 文件会被容器转成 Servlet 源码再编译成 Class 文件。这种“动态生成代码”的思路看起来不错但问题在于编译时机往往在应用启动或首次请求时才发生而且模板中的 EL 表达式、自定义标签大多是通过反射或运行时解析执行的。FreeMarker、Thymeleaf 这类现代模板引擎更典型它们在工作时会把模板解析为抽象语法树AST然后在运行时根据模型数据渲染输出。这带来了两个明显问题模板中引用的变量如果不存在不会在项目构建时报错而是在页面渲染时返回空值或直接抛异常。模板中调用的方法如果签名写错也只有运行到对应片段才会暴露。换句话说这类模板引擎的“类型安全”边界是运行时边界不是编译期边界。对于持续集成、快速发布、多人协作的团队来说这种延迟暴露问题的模式会增加很多不必要的返工。1.2 编译期安全模板的含义编译期安全compile-time safety模板核心思路是把模板本身当作编译器的一等公民让模板在项目构建阶段就参与编译。模板中的变量引用、方法调用、类型匹配全部通过编译器的类型检查来校验。一旦写错构建直接失败并输出明确的错误信息。这里要区分两个容易混淆的概念项目构建时的编译期安全与 JVM 运行时的 JIT 编译Just-In-Time Compilation即时编译。很多人看到“编译”就会联想到-XX:CompileThreshold这类 JVM 参数但实际上它们是不同层面的东西。本文讨论的是 javac 或同类编译器在构建期完成的类型检查而-XX:CompileThreshold控制的是 JVM 运行时把热点代码编译为本地机器码的阈值两者互不干扰。简单说编译期安全模板关心的是“代码写没写对”JIT 编译关心的是“运行时执行快不快”。1.3 编译期安全模板能带来什么价值在我看来编译期安全模板最大的价值不是“多了一种新写法”而是把问题暴露的时间点提前了。它带来的具体收益可以这样概括变量拼写错误、类型不匹配、方法调用错误在本地构建阶段就能被发现。模板被编译成普通类文件后可以参与 IDE 的代码跳转、重构、自动补全。模板渲染性能通常更好因为省去了运行时解析和反射调用。代码审查更容易模板和程序的接口边界变得清晰。当然编译期安全模板也有它的适用边界。它更适合服务端渲染、模板逻辑较多、团队希望借助编译器能力保证质量的场景如果只是纯静态页面或简单的邮件模板未必需要引入这套复杂度。2. JVM 上编译期安全模板的主流方案JVM 生态里其实有不少能实现编译期安全模板的方案只是在不同语言、不同社区里的知名度不一样。这里挑选几个有代表性的方向来拆解。2.1 JTEJava 生态里的编译期模板引擎JTEJava Template Engine是近几年比较受关注的编译期模板引擎由 GitHub 上的casid/jte 项目维护。它的核心思路是模板在构建阶段通过 Gradle 或 Maven 插件生成 Java 源码然后参与项目编译。模板里的param声明参数模板中的变量引用和表达式都是强类型的如果引入不存在的变量或类型不匹配编译会直接失败。JTE 在语法设计上比较贴近现代 Java支持if、for、switch等控制结构也支持局部函数和模板继承。它不依赖运行时的反射解析渲染时直接调用编译生成的类性能表现也不错。目前不少 Spring Boot 项目会选择 JTE 作为 JSP 或 Thymeleaf 的替代方案。2.2 Kotlin kotlinx.html类型安全的 HTML DSLKotlin 生态里有另一个思路不用传统模板语法而是直接用类型安全的 DSL 结构来构建 HTML。kotlinx.html 是 JetBrains 官方维护的库它把 HTML 标签封装成 Kotlin 函数利用 Kotlin 的 lambda 和 receiver 机制让 HTML 结构在代码中就是以类型安全的方式描述的。这种方案最大的特点是没有模板文件HTML 就是 Kotlin 代码。因此变量引用、标签嵌套、属性名等全部受编译器检查。比如把h1写成了hh1编译器会直接报错。它的体验更像“用代码构造页面”而不是“用模板渲染数据”。2.3 Scala TwirlPlay Framework 的编译期模板如果你使用 Scala 技术栈Twirl 是绕不开的方案。Twirl 是 Play Framework 默认的模板引擎模板文件以.scala.html结尾在编译期被生成 Scala 代码再由 scalac 编译成 Class 文件。模板参数通过(参数列表)声明类型完全由 Scala 编译器检查。Twirl 的语法对 Scala 开发者来说很自然缺点是它绑定在 Scala 和 Play 生态中Java 项目基本不会用到。2.4 把“编译期安全”扩展到 SQL 与配置如果把“模板”这个概念稍微放宽编译期安全的思想还可以延伸到 SQL 和配置领域。比如 jOOQ 通过类型安全的 DSL 构造 SQL表名、字段名在编译期就能校验Spring Boot 的ConfigurationProperties让外部配置映射到强类型对象避免散落的字符串 key。这些实践本质上都在做同一件事把错误尽早拦截在编译期而不是等到运行时。为了更直观地对比我用一张表来总结这几类方案的特点方案语言模板形态类型检查时机适用场景JTEJava.jte模板文件编译生成 Java构建期Spring Boot 服务端渲染kotlinx.htmlKotlinKotlin DSL 代码构建期Kotlin 服务端渲染、组件化页面TwirlScala.scala.html模板文件构建期Play Framework 项目jOOQJava类型安全 SQL DSL构建期SQL 拼接、动态查询3. 环境准备与版本说明在开始实战之前先把环境准备好。本文的两个实战示例都是独立的小项目不依赖复杂的中间件只要本机有 JDK 和对应的构建工具即可。版本方面我以常见的稳定版本为例来演示JDK 17JTE 示例使用Gradle 8.x也可以使用 Maven思路一致Kotlin 1.9.xkotlinx.html 示例使用Spring Boot 3.xJTE 示例用来演示 Web 集成需要说明的是JTE、kotlinx.html 以及 Spring Boot 的版本都处于持续迭代中具体版本号请以 Maven 中央仓库和官方文档为准不要直接照抄本文的版本。文章的重点是讲清楚配置思路和代码结构版本升级不会改变整体方案。如果你本机还没有安装 JDK可以先用 SDKMAN 或包管理器安装一个 OpenJDK 17然后通过java -version确认环境正常。4. 实战一基于 JTE 实现编译期安全 HTML 模板JTE 是目前 Java 生态里最典型的编译期安全模板引擎所以第一个实战就用 JTE 来演示。这个例子会创建一个普通的 Java 项目包含一个 HTML 模板和一个入口类通过命令行渲染输出 HTML。4.1 创建项目结构首先创建一个目录jte-demo并在里面创建如下结构jte-demo/ ├── build.gradle ├── settings.gradle └── src ├── main │ ├── java │ │ └── com │ │ └── example │ │ └── Main.java │ └── jte │ └── index.jte4.2 添加构建配置在build.gradle中配置 JTE 插件和依赖plugins { id java id application id gg.jte.gradle version 3.1.9 } group com.example version 1.0.0 sourceCompatibility 17 repositories { mavenCentral() } dependencies { implementation gg.jte:jte:3.1.9 testImplementation org.junit.jupiter:junit-jupiter:5.10.0 } jte { generate() sourceDirectory file(src/main/jte) targetDirectory file($buildDir/generated/jte) // 如果模板中使用 Java 8 时间类型可以在这里配置扩展 } application { mainClass com.example.Main }这里的几个配置项需要注意gg.jte.gradle插件负责在构建期把src/main/jte下的模板编译为 Java 类。sourceDirectory指定模板文件所在目录。targetDirectory指定编译生成的源码目录这个目录会被自动加入编译路径。generate()表示使用预生成模式模板在构建期就转换为 Java 代码。执行gradle build时插件会先编译模板再编译项目代码。如果模板里存在类型错误构建会在这一步失败。4.3 编写模板文件在src/main/jte/index.jte中编写如下模板内容import java.util.List param String title param ListString items !DOCTYPE html html langzh head meta charsetUTF-8 title${title}/title /head body h1${title}/h1 ul for (var item : items) li${item}/li endfor /ul /body /html模板语法说明import用于引入 Java 类型。param声明模板参数类型必须写清楚这是编译期安全的关键。${title}输出变量内容JTE 默认会对 HTML 字符进行转义能有效防止 XSS。for是 JTE 的循环语法以endfor结束。如果模板里把items误写成了itemList编译时 JTE 插件会报参数未定义的错误。这就是编译期安全检查在起作用。4.4 编写核心入口代码在src/main/java/com/example/Main.java中编写如下代码package com.example; import gg.jte.TemplateEngine; import gg.jte.output.StringOutput; import java.util.List; import java.util.Map; public class Main { public static void main(String[] args) { // 使用预编译模板引擎 TemplateEngine engine TemplateEngine.createPrecompiled(); StringOutput output new StringOutput(); MapString, Object params Map.of( title, JTE 编译期安全模板示例, items, List.of(Java, Kotlin, Scala, JTE) ); // 渲染模板第二个参数是模板参数 Map第三个参数是输出缓冲区 engine.render(index.jte, params, output); System.out.println(output.toString()); } }这里有一个细节值得解释JTE 支持两种引擎创建方式TemplateEngine.createPrecompiled()适合构建期已经生成模板类的情况它会直接使用编译好的类来渲染性能更好。另一种创建方式是在运行时动态编译模板适合开发调试场景但会牺牲一部分类型安全优势。Map 是 JTE 渲染时的标准参数传递方式。虽然在这里参数是通过字符串 key 传入的但模板编译生成的代码内部会对参数做类型转换和校验因此最终渲染时不会出现“字段不存在”这种运行时错误。4.5 运行与验证在项目根目录执行gradle run如果一切正常控制台会输出如下 HTML!DOCTYPE html html langzh head meta charsetUTF-8 titleJTE 编译期安全模板示例/title /head body h1JTE 编译期安全模板示例/h1 ul liJava/li liKotlin/li liScala/li liJTE/li /ul /body /html4.6 制造一个编译期错误看看效果为了验证编译期安全的作用可以故意把模板里的items改成itemList然后重新执行gradle build。此时构建会在 JTE 模板编译阶段失败错误信息会明确告诉你模板中引用了不存在的参数。这种“错误提前暴露”的能力正是编译期安全模板和传统运行时模板最根本的区别。在大型项目中它所节省的排错时间是非常可观的。5. 实战二Kotlin kotlinx.html 类型安全 DSL第二个实战演示 Kotlin 生态里的类型安全 HTML DSL。与 JTE 不同这种方式没有独立的模板文件HTML 结构直接用 Kotlin 代码构建。5.1 创建项目结构创建目录kotlinx-html-demo结构如下kotlinx-html-demo/ ├── build.gradle.kts ├── settings.gradle.kts └── src └── main └── kotlin └── com └── example └── Main.kt5.2 配置 Gradle 构建脚本在build.gradle.kts中写入plugins { kotlin(jvm) version 1.9.24 application } repositories { mavenCentral() } dependencies { implementation(org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0) } application { mainClass.set(com.example.MainKt) }我这里使用kotlinx-html-jvm作为依赖它提供了 JVM 平台上构建 HTML 的 DSL。版本号以实际安装为准这里只作示例。5.3 编写类型安全的 HTML 构建代码在src/main/kotlin/com/example/Main.kt中写入package com.example import kotlinx.html.* import kotlinx.html.stream.createHTML data class User( val name: String, val bio: String, val isVip: Boolean ) fun renderUserCard(user: User): String { return createHTML().html { head { title { 用户卡片 } } body { div(card) { h1 { user.name } p { user.bio } if (user.isVip) { span(vip-badge) { VIP } } } } }.toString() } fun main() { val user User( name Alice, bio 专注于 Kotlin 与 JVM 后端开发, isVip true ) println(renderUserCard(user)) }这段代码展示了几个关键点createHTML()创建一个 HTML 构建器最终可以调用toString()获取完整 HTML 字符串。div(card)会生成div classcard。h1 { user.name }中的运算符表示向标签中添加文本内容。if (user.isVip)是普通的 Kotlin 条件表达式可以灵活控制节点的渲染。因为user是强类型的User对象所以user.nme这种拼写错误会在编译期直接报错编译器会提示没有nme这个属性。5.4 运行与验证在项目根目录执行gradle run控制台会输出html head title用户卡片/title /head body div classcard h1Alice/h1 p专注于 Kotlin 与 JVM 后端开发/p span classvip-badgeVIP/span /div /body /htmlKotlin 的类型安全 DSL 很适合组件化场景。你可以把一段页面结构封装成函数在不同地方复用编译器会保证每个节点结构合法。如果团队正在使用 Kotlin 做服务端渲染这个方案非常值得尝试。6. 常见问题与排查思路编译期安全模板虽然能避免很多运行时问题但实际使用中仍然会遇到一些新的坑。下面整理几个常见问题和排查思路。问题现象常见原因解决思路JTE 构建时报“找不到模板”sourceDirectory配置与模板实际路径不一致检查build.gradle中jte.sourceDirectory的路径并确认模板文件确实放在该目录下JTE 构建时报“参数未定义”模板中引用了param未声明的变量查看模板头部param声明并在调用处确保 Map 中传入对应 keyJTE 渲染结果为空字符串模板编译成功但引擎render的方法签名使用错误确认使用的是预编译引擎TemplateEngine.createPrecompiled()并正确传递输出缓冲区kotlinx.html 编译失败标签嵌套错误或未导入对应的包查看编译器错误提示检查import kotlinx.html.*是否完整页面出现未转义的 HTML 代码模板输出中混杂了用户输入内容确认输出表达式使用的是默认转义如 JTE 中应该用${}而不是raw()Kotlin DSL 中避免直接拼接HTML字符串构建变慢每构建一次都会重新编译模板JTE 支持增量编译确认 Gradle 或 Maven 插件已开启增量编译功能并确保模板内容没有频繁变动混淆了 JIT 编译与模板编译有人认为-XX:CompileThreshold参数会影响模板编译明确两者层面不同模板编译属于构建期任务JVM 参数只影响运行时热点代码优化如果遇到其他问题最好的排查思路是先看构建日志中的错误位置再看模板编译产物的源代码。JTE 会把生成的 Java 源码输出到targetDirectory可以直接定位是哪一行模板代码触发了问题。7. 最佳实践与工程建议编译期安全模板不是银弹落地时也要讲究方式方法。这里总结一些工程实践建议方便你在真实项目中避坑。7.1 什么时候适合用编译期安全模板如果你的项目符合以下特征可以考虑引入编译期安全模板服务端渲染为主页面逻辑包含条件判断、循环、变量格式化等复杂操作。团队成员较多希望借助编译器能力减少低级的变量拼写错误。对渲染性能有要求不希望每次都做运行时解析。项目愿意引入 Gradle/Maven 构建插件并接受构建步骤变复杂。反过来如果只是简单的邮件模板、静态页面或者团队已经全面采用前后端分离架构那么编译期安全模板并不一定合适传统模板或纯字符串拼接反而更轻量。7.2 参数建模要尽量强类型在使用 JTE 这类模板引擎时容易走回老路把MapString, Object当万能传参工具。虽然 Map 也能工作但会削弱强类型优势。更推荐的做法是定义一个专门的 ViewModel 对象把模板需要的参数封装成强类型字段。public record UserView(String name, String bio, boolean vip) { }模板中这样声明参数param com.example.UserView user这样做的好处是如果 ViewModel 字段改名编译器会同时检查 Java 代码和模板里的引用避免漏改。7.3 关注 XSS 转义编译期安全主要解决的是“类型错误”和“变量写错”的问题并不能自动解决安全问题。HTML 模板中最常见的风险是 XSS 注入。使用 JTE 时${}默认会做 HTML 转义但如果需要输出原始 HTML要使用raw()或类似语法这时必须确保内容来源可信。Kotlin 的 kotlinx.html DSL 同样需要注意运算符写入的字符串会被当作纯文本处理如果你需要插入 HTML 片段要明确使用专门的 API并做好过滤。7.4 为模板建立独立的模块在大型多模块项目中可以把模板和对应 ViewModel 放在独立的模块里避免模板引擎插件影响所有模块的构建。这样既能让模板的编译产物被其他模块复用也能在构建图上更清晰地表达依赖关系。7.5 结合 JVM 参数优化运行时性能虽然模板编译发生在构建期但模板渲染仍然是运行时行为。如果模板生成的类成为热点代码JVM 的 JIT 编译器会进一步优化它们这也是编译期安全模板在性能上的额外红利。可以通过-XX:CompileThreshold等参数调整 JIT 编译策略但这是通用的 JVM 调优手段与模板本身没有直接关系不建议在项目初期就陷入微调。7.6 日志与错误处理模板渲染过程中的异常比如数据缺字段、IO 异常仍然需要在运行时捕获。建议统一封装一个渲染服务统一处理异常并记录日志避免模板异常直接上抛导致页面 500。8. 总结与学习路线编译期安全模板的核心不难理解让模板参与构建期的类型检查把错误暴露时间提前。JTE 把模板编译成 Java 类kotlinx.html 直接用类型安全 DSL 构建页面Twirl 在 Scala 编译期完成校验。它们实现方式不同但目标一致让“写错模板”这件事在集成阶段就被发现而不是拖到测试或线上。如果你现在还在用字符串拼接 HTML或者正在被传统运行时模板引擎的隐性错误困扰我建议先拿 JTE 做一个小的 Spring Boot 页面练手体验一下编译期安全的开发手感。之后可以再试试 kotlinx.html感受一下 DSL 构建页面和模板文件的差异。学完这两套方案后可以继续深入了解几个方向JTE 的模板继承、局部模板、方法扩展等进阶语法。kotlinx.html 与 Ktor 服务端框架的集成。类型安全 SQL 方案例如 jOOQ 或 Querydsl把编译期安全思想延伸到数据访问层。浏览 JEP 中关于 Java 字符串模板的相关提案了解未来 Java 官方层面的进展。在真实项目中优先验证团队的技术栈是否匹配尤其是模板引擎与构建工具的集成方式。提前在 demo 项目里跑通再逐步推广到业务模块是比较稳妥的落地路径。如果这篇文章对你有帮助可以先收藏备用后续需要时再照着实操一遍。