☰
SpringBoot2手动渲染Thymeleaf:原理、实现与避坑指南
2026/10/24 1:21:42 网站建设 项目流程

做后端这几年,我越来越觉得 SpringBoot 里很多“太顺理成章”的东西,早晚有一天会卡你一下。Thymeleaf 就是个典型:平时你只要在 Controller 里 return 一个视图名,SpringMVC 就会自动去 templates 目录下找到对应 HTML,再把 Model 里的变量塞进去,最终返回给浏览器。这套流程大多数项目根本不用你操心。可万一你想在 Service 层里发一封 HTML 邮件,或者在定时任务里生成一份 HTML 报表,又或者不想走 Controller、直接拿到渲染后的字符串,就得换一种玩法:SpringBoot2 手动渲染 Thymeleaf。这篇文章把它的原理、三种落地方式、核心参数和我踩过的那些坑一次讲清楚,适合所有用 SpringBoot 做 Web 项目、又迟早会遇到“模板不能自动渲染”这种需求的开发者。

1. SpringBoot2 手动渲染 Thymeleaf:什么时候才用得上

1.1 Controller 自动渲染的机械流程

先理清我们平时依赖的“自动渲染”到底做了什么。当一个请求进来,Controller 方法返回一个逻辑视图名,比如"index",SpringMVC 拿到这个名字后会交给视图解析器链。Thymeleaf 在里面注册了一个ThymeleafViewResolver,它按照配置好的前缀classpath:/templates/和后缀.html,拼出一个真实的模板路径,然后找到index.html,再把 Model 里的数据塞进去,交给 Thymeleaf 引擎执行,最后把生成好的 HTML 写到响应流里。

整个流程里,真正干活的是TemplateEngine.process(),但 SpringMVC 替你把它包了一层。所以平时你感觉不到它的存在。

一旦你的代码不在 Controller 和 Model 这个链路里,自动渲染就失效了。比如你写了一个 Service 方法,里面有业务数据,想让这些数据变成一段 HTML;又比如定时任务里没有 HttpServletRequest,SpringMVC 根本不参与。这时候你必须跳出来,自己调用模板引擎。

1.2 手动渲染能解决的典型场景

根据我在实际项目里遇到的情况,手动渲染 Thymeleaf 主要集中在四类场景。

第一类是邮件发送。这是最常见的需求。订单系统、告警系统常常要发 HTML 邮件,邮件内容是动态的,今天可能是订单号变了,明天可能商品列表变了。通常的做法是把邮件模板放到resources/templates/mail/下,然后在 Service 里用 TemplateEngine 手动渲染出 HTML 字符串,再交给邮件发送组件。这一步没法走 Controller,因为根本没有浏览器请求。

第二类是定时任务生成静态文件。比如每天凌晨生成昨天交易数据的 HTML 报表,存到服务器某个目录,或者生成后推送给运营看。任务里没有 Request、没有 Response,你要手动创建 Context、填充数据、渲染文件。

第三类是动态拼接页面片段。有些接口不是给人看的,而是给其他系统调用的,它返回的是一段已经渲染好的 HTML 片段。你可以把 Controller 方法返回类型改成 String,用@ResponseBody直接返回手动渲染结果。

第四类是模板内容不固定,存在数据库或配置中心里。前端运营人员在后台编辑器里维护一段 Thymeleaf 模板,比如活动页、短信模板或协议内容,后端拿到字符串后要动态渲染。这种情况显然不能用 Controller 的视图解析机制,必须用字符串模板渲染器。

1.3 手动渲染的本质,一句话就能说清

不管场景怎么变,手动渲染的核心就一行:

String html = templateEngine.process(templateName, context);

templateName是模板的逻辑名称,context里放着模板需要的变量,返回的就是渲染好的 HTML 字符串。拿到这个字符串之后,你想写文件、发邮件、返回接口还是存库,都是你自己的事了。

理解了这一行,后面所有代码都是围绕它展开的。

2. 动手前的依赖与配置准备

2.1 引入依赖,版本别乱配

SpringBoot2 项目里引入 Thymeleaf 非常简单,Maven 加一个 starter:

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

不需要自己写版本号,SpringBoot 的依赖管理会自动匹配。这里要提醒一点:SpringBoot 2.x 对应的是 Thymeleaf 3.0.x,SpringBoot 2.5 和 2.7 的 Thymeleaf 依赖版本会有细微差别,但 API 层面基本一致。如果你在代码里引用了org.thymeleaf.spring5.SpringTemplateEngine,这个类就是为 Spring 5 设计的,SpringBoot 2 全家桶正好配套。如果手痒去升级 Thymeleaf 版本,要特别小心,我和一些同事都遇到过升级后模板解析行为变化的问题。

2.2 手动渲染绕不开的三个核心对象

手动渲染本质上是一个组装游戏,三个核心对象缺一不可。

第一个是TemplateEngine,它是引擎本体,负责执行渲染。SpringBoot 自动配置里已经提供了一个SpringTemplateEngine,你可以直接注入使用,这个对象内部已经装配好了 Thymeleaf 3 的标准方言和 Spring 标准方言,支持我们常用的th:text、th:if、th:each这些标签。

第二个是ITemplateResolver,它决定了模板从哪里加载。平时我们说templateName只是个逻辑名,真正拼出物理路径、读取文件内容的是 resolver。常用实现有几种:ClassLoaderTemplateResolver从 classpath 下加载,FileTemplateResolver从文件系统加载,StringTemplateResolver直接把传入的字符串当模板。这几个实现的选择,基本决定了你手动渲染的三种玩法。

第三个是IContext,它相当于 MVC 里的 Model,用来存放模板变量。最常用的是org.thymeleaf.context.Context,它不依赖 Web 环境,在 Service、定时任务里都能用。如果你还需要访问 Request、Session,就得用WebContext,这个后面单独说。

打个不严谨但很好懂的比方:TemplateEngine 是厨房,TemplateResolver 是食材供应商,Context 是订单。你拿着订单去厨房,厨房通过供应商拿到食材,最后做出一份 HTML 大餐。

2.3 记住这些默认配置,能少踩很多坑

SpringBoot 自动配置的默认参数里,我们最常碰到的几个是:

配置项默认值作用
spring.thymeleaf.prefixclasspath:/templates/模板前缀,最终路径由 prefix + viewName + suffix 拼出
spring.thymeleaf.suffix.html模板后缀
spring.thymeleaf.modeHTML模板模式,Thymeleaf 3 默认是 HTML
spring.thymeleaf.encodingUTF-8模板编码
spring.thymeleaf.cachetrue是否开启模板缓存

手动渲染时,如果直接用注入的SpringTemplateEngine,它内部用的 resolver 已经吃掉了这些配置。也就是说你放在resources/templates/下的模板,手动渲染时逻辑名可以直接和 Controller 里用的一样。

如果你决定自己 new 一个 TemplateEngine,那这些默认配置一个都不会生效,所有参数都要自己设。很多人第一次手动渲染时在这里翻车:模板明明放在templates/下,代码却一直报找不到模板,原因就是 resolver 没配前缀后缀。

3. 三种手动渲染 Thymeleaf 的落地方式

3.1 方式一:直接注入现成的 SpringTemplateEngine

这是我平时最推荐的方式,原因只有一个:省心。SpringBoot 自动配置已经帮你把引擎、解析器、属性都处理好了,你只要注入再调用就行。

@Service public class MailRenderService { private final SpringTemplateEngine templateEngine; public MailRenderService(SpringTemplateEngine templateEngine) { this.templateEngine = templateEngine; } public String renderOrderMail(OrderVO order) { Context context = new Context(); context.setLocale(Locale.CHINA); context.setVariable("orderNo", order.getOrderNo()); context.setVariable("customerName", order.getCustomerName()); context.setVariable("totalAmount", order.getTotalAmount()); context.setVariable("goodsItems", order.getGoodsItems()); return templateEngine.process("mail/order-mail", context); } }

这里"mail/order-mail"对应resources/templates/mail/order-mail.html。process 的第一个参数只写逻辑名,不要写.html,也不要写templates/前缀,因为自动配置的 resolver 已经处理了。

这种方式适用的场景是:模板放在项目里、相对固定、需要在 Service 或定时任务中渲染。它和 Controller 自动渲染用的是同一个引擎,所以你在模板里写#strings、#dates这些工具类,或者用 SpringEL 访问 bean,都是没有问题的。

有一个容易忽略的点:注入SpringTemplateEngine使用的是构造器注入,而不是@Autowired字段注入。用构造器注入的好处是依赖关系清晰,测试时方便 mock,也不容易出现多重循环依赖时一脸懵的情况。我见过一些项目在 Service 里字段注入它,代码是能跑,但埋的隐患没必要。

3.2 方式二:用 StringTemplateResolver 渲染字符串模板

如果说方式一是“渲染磁盘上的文件”,方式二就是“渲染一段字符串”。它的典型场景是模板内容保存在数据库或配置中心。前端运营在后台编辑好一段带th:属性的模板文本,后端直接拿来渲染。

实现方式是自己组装一个带StringTemplateResolver的引擎:

@Component public class DynamicTemplateRenderer { private final SpringTemplateEngine templateEngine; public DynamicTemplateRenderer() { StringTemplateResolver resolver = new StringTemplateResolver(); resolver.setTemplateMode(TemplateMode.HTML); resolver.setCacheable(false); this.templateEngine = new SpringTemplateEngine(); this.templateEngine.setTemplateResolver(resolver); } public String render(String templateContent, Map<String, Object> params) { Context context = new Context(); context.setVariables(params); return templateEngine.process(templateContent, context); } }

注意一点:StringTemplateResolver不需要设置前缀和后缀,因为模板内容本身就是传入的字符串。它的process第一个参数直接传整个模板内容,而不是逻辑名。

这种方式非常灵活,但也要小心一个问题:模板内容如果来自不可信来源,比如运营后台被劫持或有人故意传了一段恶意 Thymeleaf 表达式,渲染后可能带来注入风险。虽然 Thymeleaf 本身不会执行任意代码,但模板里可以调用 Spring bean 方法,如果放开了不可信用户编辑模板,就相当于把后门给别人了。我的建议是,允许编辑模板的人限制在运营或管理员角色,且模板内容要记录操作日志。

3.3 方式三:完全脱离 Spring 容器,手工组装引擎

有时候你需要在非 Spring 环境里渲染,比如一个纯 Java 工具类、一个脚本,或者你所在的场景不方便注入 Spring 管理的 bean。这时候可以自己 new 一个最基础的TemplateEngine。

ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver(); resolver.setPrefix("templates/"); resolver.setSuffix(".html"); resolver.setTemplateMode(TemplateMode.HTML); resolver.setCharacterEncoding("UTF-8"); TemplateEngine engine = new TemplateEngine(); engine.setTemplateResolver(resolver); Context context = new Context(); context.setVariable("message", "hello"); String html = engine.process("welcome", context);

这段代码里我特意把前缀写成"templates/"而不是"/templates/",是因为ClassLoaderTemplateResolver底层会调用ClassLoader.getResourceAsStream(),路径如果不小心处理,开头的斜杠可能导致加载失败。写"templates/"在我的实践中是最稳妥的。

但请注意,手工构建的TemplateEngine不是SpringTemplateEngine,它没有 Spring 方言。模板里如果使用了@beanName这类 SpringEL 语法,或者依赖 Spring 上下文的工具方法,会直接报错。如果确实需要在手工场景里保留 Spring 的能力,可以自己注册SpringStandardDialect,但这一步做下来代码量就不小了。如果项目是 SpringBoot,我还是建议优先用方式一。

4. 手动渲染中的核心细节与参数调优

4.1 用 Context 还是 WebContext,要看当前线程有没有 Request

这是初学者最容易踩的坑。Controller 里写惯了 Model,手动渲染时有人会下意识去找 WebContext,觉得这样才能把 Request 里的参数塞进去。但在 Service 层、定时任务里,根本不存在 Request 对象,硬要 new WebContext 只会得到一堆空指针。

Thymeleaf 3 中Context是完全独立的上下文,不依赖任何 Web 容器。模板里的${param.x}、${session.x}这类 Web 相关变量,在普通 Context 里是取不到的。如果你确实需要用到请求参数,可以这样做:

  • 在 Controller 中手动渲染时,可以从RequestContextHolder拿到当前请求,封装成 WebContext;
  • 更好的做法是只把需要的值取出来,放进普通 Context,渲染逻辑不依赖 Web 容器。

我自己更倾向后者。理由很简单:手动渲染的最大优势就是可以脱离 Web 环境复用,一旦模板里依赖了 Request、Session,渲染就被绑死在用户请求线程里,定时任务和消息队列场景全废了。

WebContext 的正确用法也不是不能用,前提是你能拿到这些对象:

HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); WebContext context = new WebContext(request, response, servletContext);

但这段代码在线程池里十有八九要出问题。所以除非非常明确模板里要用到 Web 对象,否则统一用Context,把所需变量手动 setVariable。

4.2 中文乱码,先从字符编码查起

手动渲染出现中文乱码,绝大多数是编码没配对。模板块入口要保证三处一致:模板文件保存编码、resolver 设置的 characterEncoding、读取模板时引擎使用的编码。

使用SpringTemplateEngine自动配置时,spring.thymeleaf.encoding=UTF-8已经生效,模板文件保存成 UTF-8 就不会有问题。但如果你手工创建 resolver,很容易漏掉编码设置,导致模板内容被 ISO-8859-1 读取,中文全部变问号。

resolver.setCharacterEncoding("UTF-8");

同时,如果Context里 setVariable 的值本身来自乱码数据,渲染出来自然也是乱的。排查时先打印变量值,再检查模板文件编码,不要一上来就怀疑引擎。

4.3 缓存:开发关掉,生产开整

Thymeleaf 默认开启模板缓存,模板文件解析一次后会被缓存起来,性能很好。但开发时改动模板要立刻生效,缓存会导致你改了 HTML 却刷新看不到变化,只能重启应用。

在application.yml里加一行即可:

spring: thymeleaf: cache: false

生产环境务必把 cache 设回 true,或者干脆不配置,用默认值。模板解析本身有开销,虽然单次渲染性能差距不大,但在高并发下把模板的 IO 和解析省掉,整体压力会小很多。说实话我见过有同学上线前一天把 cache 设成 false 硬跑了一个星期,CPU 使用率肉眼可见地高。

手工创建的引擎,同样要给 resolver 设置 cacheable:

resolver.setCacheable(false); // 开发环境 resolver.setCacheable(true); // 生产环境

对于StringTemplateResolver,模板内容会随时变化,建议直接设成 false,否则缓存里存的是上一次的模板内容,数据库改了也不生效。

4.4 前缀后缀和模板路径,别想当然

自动配置下的路径规则很清楚:prefix + 逻辑名 + suffix,最终定位到templates/下的文件。

手动配置时,ClassLoaderTemplateResolver和SpringResourceTemplateResolver处理路径的方式不一样。前者基于 classpath 加载,后者支持classpath:前缀。如果你在代码里混用了,比如给SpringResourceTemplateResolver设前缀"/templates/",可能解析出双重斜杠导致找不到文件。

我的习惯是:能用自动配置的 SpringTemplateEngine 就绝不手动配路径;非要手动配,先用一个临时 main 方法测试模板能不能加载成功,再去接业务逻辑。

5. 完整实战:用手动渲染实现订单邮件模板

前面把原理和方式都过了一遍,现在做一个完整的例子,把整条链路串起来。假设我们要做订单邮件,邮件内容包含订单号、客户姓名、商品明细列表,商品数量超过 2 件时额外显示一行提示。

5.1 准备模板文件

在resources/templates/mail/下创建order-mail.html:

<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>订单通知</title> </head> <body> <h1 th:text="'亲爱的' + ${customerName} + ',您的订单已确认'">订单标题</h1> <p>订单号:<span th:text="${orderNo}">000000</span></p> <table border="1"> <thead> <tr> <th>商品名称</th> <th>数量</th> <th>单价</th> </tr> </thead> <tbody> <tr th:each="item : ${goodsItems}"> <td th:text="${item.goodsName}">商品</td> <td th:text="${item.quantity}">1</td> <td th:text="${item.price}">0.00</td> </tr> </tbody> </table> <p th:if="${#lists.size(goodsItems) >= 2}" style="color: red;">提示:您的订单包含多件商品,请留意包裹配送时效。</p> <p>合计:<span th:text="${totalAmount}">0.00</span></p> </body> </html>

这里用到了th:each循环、th:if条件判断、#lists工具类,都是日常最常见的语法。邮件客户端对 CSS 支持很弱,布局用表格,能避免很多兼容性问题。

5.2 编写渲染服务

接着写一个 Service,注入SpringTemplateEngine,把 OrderVO 对象里的字段放进 Context。

@Service public class MailRenderService { private final SpringTemplateEngine templateEngine; public MailRenderService(SpringTemplateEngine templateEngine) { this.templateEngine = templateEngine; } public String renderOrderMail(OrderVO order) { Context context = new Context(); context.setLocale(Locale.CHINA); context.setVariable("orderNo", order.getOrderNo()); context.setVariable("customerName", order.getCustomerName()); context.setVariable("totalAmount", order.getTotalAmount()); context.setVariable("goodsItems", order.getGoodsItems()); return templateEngine.process("mail/order-mail", context); } }

注意这里的OrderVO不是 Thymeleaf 的类,就是普通业务对象。模板能读取到它的属性,靠的是 getter 方法,所以 OrderVO 里的字段必须有对应的 get 方法,否则${item.goodsName}会取不到值。

5.3 调用与验证

在一个定时任务里调用这个方法:

List<GoodsItem> items = Arrays.asList( new GoodsItem("机械键盘", 1, "399.00"), new GoodsItem("显示器", 1, "1299.00") ); OrderVO order = new OrderVO(); order.setOrderNo("202501150001"); order.setCustomerName("阿宁"); order.setGoodsItems(items); order.setTotalAmount("1698.00"); String html = mailRenderService.renderOrderMail(order); System.out.println(html);

输出结果的核心部分:

<h1>亲爱的阿宁,您的订单已确认</h1> <p>订单号:<span>202501150001</span></p> <table border="1"> <thead>...</thead> <tbody> <tr><td>机械键盘</td><td>1</td><td>399.00</td></tr> <tr><td>显示器</td><td>1</td><td>1299.00</td></tr> </tbody> </table> <p style="color: red;">提示:您的订单包含多件商品,请留意包裹配送时效。</p>

这个字符串就是最终发给用户的邮件 HTML。在实际项目里,我会在渲染前对数据做空值校验,至少保证订单号不为空,避免邮件里出现“订单号:”这种残缺句子。

5.4 在生产环境用这个功能时,我建议多做几步

第一,渲染结果要打日志。邮件发送失败时,如果日志里没有渲染后的 HTML 内容,排查会非常被动。第二,Context 里的变量不要裸传 null。有些模板业务上有默认值,但很多东西没有 null 容忍,比如#{...}表达式,变量为 null 时往往渲染成空。第三,如果模板片段还需要复用,可以用 Thymeleaf 的片段表达式。例如模板中定义了<div th:fragment="summary">,渲染时把逻辑名写成"mail/order-mail :: summary",就可以只渲染这个片段,非常适合邮件的商品摘要区。

6. 常见问题与排查技巧实录

6.1 模板找不到:NoSuchTemplateException

这个异常出现时,第一反应不是怀疑代码,而是先看一眼 resolver。自动配置下模板必须放在resources/templates/下,逻辑名不要带后缀。手工配置的 resolver,检查前缀后缀拼出来的实际路径,在 classpath 里能不能找到。

排查步骤很简单:

  1. 确认模板文件确实在resources/templates/下;
  2. 在 IDE 里看 target/classes 目录,确认 Maven 编译后模板被复制过去了;
  3. 打印 resolver 拼接后的完整路径,别猜;
  4. 检查逻辑名是否多写了.html或templates/前缀。

我之前遇到过一种隐蔽情况:Maven 的<resources>配置把resources/templates里的 html 文件过滤掉或者排除掉了,导致项目编译后根本不存在这些模板。这个问题排查起来特别花时间,因为 IDE 里看源码是有的,运行时就没了。

6.2 中文乱码,问题往往不在渲染代码

中文乱码我遇到过三种情况。第一种是模板文件本身的编码不是 UTF-8,这个在 IDE 右下角就能看到,改过来重新编译。第二种是手工创建 resolver 时没设setCharacterEncoding("UTF-8"),模板被 ISO-8859-1 读取。第三种是响应输出端没配编码,如果你把渲染结果直接返回给浏览器或写文件,没有声明 UTF-8,浏览器或文件读取方会用默认编码解码。

简单说,模板、resolver、输出端三方编码一致,乱码基本消失。

6.3 字符串模板渲染出来还是原样

用StringTemplateResolver时,如果最终输出把th:text原原本本打印出来,说明引擎根本没解析这个模板。最常见原因是你用了普通TemplateEngine而不是SpringTemplateEngine,并且模板中包含 Spring 方言标签,解析时没有对应的方言处理器。

另一个原因是StringTemplateResolver没有设置TemplateMode,导致模板字符串被当成纯文本处理。这个问题很隐蔽,因为不设置也能启动,渲染结果却是原样文本。

resolver.setTemplateMode(TemplateMode.HTML);

6.4 改了模板,渲染结果始终是旧的

这是缓存问题。开发环境把spring.thymeleaf.cache设为 false,或者手工把 resolver 的cacheable设为 false。生产环境建议保持缓存,模板更新后通过发布流程让应用重新加载,而不是无脑关掉缓存。

6.5 在线程池里用 WebContext 导致空指针

异步任务里如果存在RequestContextHolder取不到值、WebContext 构造失败的问题,八成是你把渲染逻辑绑定了 Web 上下文。解决办法是改用普通Context,在进入异步线程之前先把需要的参数取出来,塞进一个普通 Map,再放到 Context 里。这样异步线程完全不依赖请求上下文,逻辑也更好测试。

6.6 SpringTemplateEngine 注入失败或循环依赖

正常情况下SpringTemplateEngine已经被自动配置为 bean,Service 构造器注入不会出问题。如果出现循环依赖,先看看是不是 A 依赖 B、B 又依赖 SpringTemplateEngine,而 A 和 B 之间有互相依赖。尽量把渲染相关 Service 从业务链路里抽成独立组件,只负责接收参数和返回字符串,不参与复杂业务回调。

我个人在实际操作中的体会是:手动渲染 Thymeleaf 真正难的地点不在 API,而在于把“什么时候用”想清楚。凡是模板内容跟着业务数据走的场景,手动渲染都是天然解;凡是模板依赖请求上下文的场景,先怀疑设计是不是把渲染绑错地方了。最后再分享一个小技巧,如果你项目里要手动渲染的地方不止一个,比如邮件、短信、报表都要用,建议封装一个统一的渲染服务,接收模板名和参数 Map,内部统一处理 Context、编码、异常,这样别处的业务代码只需要一行调用,维护成本能低一个档次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询