先说一个实际场景。很多人初学 Spring Boot 的时候,thymeleaf基本就是跟着一起用:写一个 Controller,返回字符串,模板丢在resources/templates下面,然后框架自动帮你找到对应的 HTML 文件完成渲染。但有一次我接手一个老项目,里面有一个定时任务模块,需要在每天凌晨给几百个用户发送日报邮件,邮件正文是一个相当复杂的HTML 表格,数据要动态生成。第一反应是不是用thymeleaf的邮件扩展?但项目里压根没引入专门模板,我只是想把模板内容当成一个字符串处理,然后拼装成 HTML 塞进MimeMessage里。这时“手动渲染”就派上用场了——不依赖 MVC 层的自动视图解析,直接拿到模板引擎实例,传入模板字符串和变量,手动调用渲染方法。这篇文章我不想讲 Spring MVC 那套自动视图解析,而是聚焦在SpringBoot2 手动渲染 thymeleaf这个相对冷门但极其实用的技术上,适合做定时任务、消息推送、邮件生成、或者任何需要把静态模板和动态数据分离的项目场景。
1. 为什么需要手动渲染而不是走自动视图解析
先说清楚一个概念:Spring Boot 默认的视图解析链路,核心是ThymeleafAutoConfiguration,它会自动配置好SpringTemplateEngine以及ThymeleafViewResolver。当你在 Controller 里面return "hello"的时候,框架走的是internalResourceViewResolver + ThymeleafViewResolver这一整套 MVC 流程。
这套流程的问题在哪里?它和 HTTP 请求、Servlet 容器绑定死。但现实中有些场景根本不需要一个完整的 HTTP Response 视图:
- 定时任务中生成 HTML 邮件
- 消息队列里消费数据后动态渲染页面片段
- 报表模块需要把同一个模板渲染成多个版本并存储到文件系统
- 单元测试里渲染模板字符串并校验生成的 HTML 内容
在这些场景下,你没法靠框架自动去解析视图名,因为压根没有请求进来。你需要的只是一个“参数进、字符串出”的渲染动作,这就是手动渲染。类比一下:自动视图解析是“你去饭店点菜,服务员帮你把菜端到桌面上”;手动渲染是“你直接进入厨房,跟厨师说‘把你做的这道菜打包给我’”。前者是完整的服务流程,后者只取了其中一部分能力。
手动渲染最核心的价值在于:解耦模板渲染与 Web 层。你的模板还是.html文件,你的变量还是Map或者一个对象,只是调用方从 MVC View 变成了你自己的业务代码。
2. 环境准备与依赖配置
要在 Spring Boot 2 中手动渲染 Thymeleaf,环境准备并不复杂。我基于SpringBoot 2.7.x版本演示,因为这是目前生产环境最常见的一个 2.x 系列版本,不过代码逻辑向下兼容。
2.1 Maven 依赖
首先你依然需要引入thymeleaf的 starter 依赖,因为即使手动渲染,你也需要一整套 Thymeleaf 的表层能力。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>如果你仅仅只是为了邮件渲染,不想要连带出整个 Web MVC 的自动配置,也可以只引入thymeleaf-spring5(Spring Boot 2 对应 Spring 5):
<dependency> <groupId>org.thymeleaf</groupId> <artifactId>thymeleaf-spring5</artifactId> <version>3.0.15.RELEASE</version> </dependency>这里有个小坑:spring-boot-starter-thymeleaf会自动引入spring-boot-starter-web相关的内容,如果你确认自己不需要完整 Web MVC,只是想在某个独立的服务中去渲染模板,那建议不要使用 starter,自己显式引入thymeleaf-spring5即可。不过绝大多数情况下,你的项目本来就是一个 Web 项目,直接引用 starter 并不冲突。
2.2 基础配置项
在手动渲染模式下,我们依然可以复用application.yml中 Thymeleaf 的配置:
spring: thymeleaf: prefix: classpath:/templates/ suffix: .html mode: HTML encoding: UTF-8 cache: false需要注意:手动渲染的时候,模式的合法性验证仍然会加载。比如 HTML5 模式下,如果模板是无标签的纯文本片段,渲染就会报错。后面踩坑部分我会专门提到。
2.3 如何确认当前 Spring Boot 版本中的 Thymeleaf 版本
这一点值得一提。Spring Boot 2.x 的各个小版本对应 Thymeleaf 版本变化不小,比如 Boot 2.0 对应 Thymeleaf 3.0.9,Boot 2.7 对应 Thymeleaf 3.0.15。手动渲染时如果引用了thymeleaf的扩展工具类(比如#dates、#numbers的处理逻辑),不同 Thymeleaf 版本之间的语法基本兼容,但是在表达式工具对象上有少量新增方法,建议以 Spring Boot 官方 BOM(Bill of Materials)为准,不要自己手动指定 Thymeleaf 版本来“升级”,否则容易和 Spring 5 的集成层冲突。
3. 手动渲染的核心 API 与两种配置方式
手动渲染的本质就是三步:拿到模板解析器、构建模板引擎、调用 process 方法。但细节藏在配置方式里,这里我讲两种常用方式,各有各的适用场景。
3.1 方式一:直接使用 Thymeleaf 原生 API
如果你不需要与 Spring 的容器做过多的整合,只想要一个轻量的渲染工具,可以直接用 Thymeleaf 原生类。这个方式不需要任何 Spring 自动配置,完全是独立运行。
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver(); resolver.setPrefix("templates/"); resolver.setSuffix(".html"); resolver.setTemplateMode(TemplateMode.HTML); resolver.setCharacterEncoding("UTF-8"); SpringTemplateEngine engine = new SpringTemplateEngine(); engine.setTemplateResolver(resolver); String template = "<p th:text=\"${name}\">默认名字</p>"; Context context = new Context(); context.setVariable("name", "程序员鱼皮"); String result = engine.process(template, context);但这里我不建议直接用裸的TemplateEngine,而是用SpringTemplateEngine,因为这个类继承了SpringStandardDialect,它支持的表达式语法更丰富,比如能使用 SpringEL 的一些特性,以及在表达式里通过@beanName直接调用 Spring 容器中的 Bean。如果你只做简单的字符串替换,类名差异感知不明显,但是一旦涉及到 Spring 的MessageSource、ConversionService,区别就很大。
3.2 方式二:注入 Spring Boot 自动配置好的引擎
Spring Boot 在ThymeleafAutoConfiguration中已经默认帮你建好了一个SpringTemplateEngine实例,配置前缀是基于spring.thymeleaf这个命名空间。我们完全可以直接@Autowired它,然后自己指定 Template 的位置。问题在于:自动配置的引擎默认的解析器是SpringResourceTemplateResolver,它的前缀和后缀是绑定在配置文件里的。
@Service public class ThymeleafKit { @Autowired private SpringTemplateEngine templateEngine; public String render(String templatePath, Map<String, Object> variables) { Context context = new Context(); context.setVariables(variables); return templateEngine.process(templatePath, context); } }实际使用验证下来,你会发现方式二有一个很舒服的点:你不需要自己创建和配置 resolver,也不需要关心字符编码,因为自动配置已经读取了application.yml里的spring.thymeleaf.encoding=UTF-8。但对应的也有人觉得不够爽的地方:默认解析器只能解析classpath:/templates/下的文件。如果你想把模板放在数据库或者别的自定义存储下,方式二就只能靠扩展实现了。
3.3 两种方式的选型建议
| 维度 | 方式一(原生 API) | 方式二(自动注入) |
|---|---|---|
| 依赖 Spring 容器 | 不依赖,可独立运行 | 必须依赖 Spring 容器 |
| 配置来源 | 全部自己写代码配置 | 沿用application.yml |
| 模板存储位置 | 自定义能力强(可改 resolver 改变加载源) | 默认定位classpath:/templates/,扩展需覆盖 Bean |
| Spring 集成(MessageSource、ConversionService) | 需要自己装配 | 自动支持 |
| 适合场景 | 独立工具类、非 Spring 环境、特殊存储 | 常规 Web 项目,推荐优先使用 |
有一点需要特别提醒:就算使用方式二,你仍然需要创建org.thymeleaf.context.Context对象,千万不要用org.springframework.ui.Model直接传进去。这两个上下文结构完全不同,用错了会出现变量全部取不到的现象。原因很简单:SpringTemplateEngine.process(String template, IContext context)接收的是Thymeleaf 的 IContext接口实现,虽然SpringTemplateEngine内部也提供了process方法重载来接收Model对象,但这时变量的获取逻辑走的是另一条路径,建议别给自己挖坑,统一用Context处理。
4. 完整实操案例:把定时邮件模板改成手动渲染
一个很刚的实战场景来了。我上一家公司做的是 SaaS 后台,每周一早上要自动给运营发送上周数据汇总邮件。邮件正文是 HTML,里面包含一个数据表格,还要把每个模块的最新状态用不同颜色标出来。直接拼接字符串太痛苦,还容易漏转义,于是我用 Thymeleaf 模板重写整个流程。
4.1 项目目录结构
src/main/resources/ └── templates/ └── mail/ └── weekly-report.html src/main/java/com/example/mail/ ├── MailRenderService.java ├── WeeklyReportJob.java └── model/ └── ReportData.java4.2 邮件模板
weekly-report.html内容大致是这样的:
<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>每周数据汇总</title> </head> <body> <h2 th:text="${title}">默认标题</h2> <p>生成时间:<span th:text="${#dates.format(now, 'yyyy-MM-dd HH:mm:ss')}">默认时间</span></p> <table border="1"> <thead> <tr> <th>业务模块</th> <th>本周新增用户</th> <th>上周新增用户</th> <th>环比增长</th> </tr> </thead> <tbody> <tr th:each="item : ${reportList}" th:style="${item.growth >= 0} ? 'background-color: #e1f3d8' : 'background-color: #ffdcdc'"> <td th:text="${item.moduleName}">模块名</td> <td th:text="${item.currentCount}">0</td> <td th:text="${item.lastCount}">0</td> <td th:text="${#numbers.formatPercent(item.growth / 100.0, 1)}">0%</td> </tr> </tbody> </table> <p>详细数据见附件。</p> </body> </html>这个模板里我用了:
${title}:普通变量${#dates.format(now, 'yyyy-MM-dd HH:mm:ss')}:日期格式化工具对象${#numbers.formatPercent(item.growth / 100.0, 1)}:百分比格式化工具对象th:each:循环遍历列表th:style:根据条件动态拼接样式
以上全部是 Thymeleaf 标准方言的能力,在手动渲染模式下语法和自动视图解析模式完全一致,不存在什么“手动模式只能做简单替换”的说法。
4.3 Java 渲染服务
@Service public class MailRenderService { private final SpringTemplateEngine templateEngine; public MailRenderService(SpringTemplateEngine templateEngine) { this.templateEngine = templateEngine; } public String renderWeeklyReport(String title, List<ReportData> reportList) { Context context = new Context(); context.setVariable("title", title); context.setVariable("now", new Date()); context.setVariable("reportList", reportList); return templateEngine.process("mail/weekly-report", context); } }注意这里templateEngine.process("mail/weekly-report", context)中的第一个参数,自动配置下的SpringResourceTemplateResolver会自动拼接前缀classpath:/templates/和后缀.html,所以传入字符串不要带完整路径和后缀,否则会拼出一个找不到的文件路径。我见过很多人在这一步踩坑:以为手动渲染就一定要写“templates/mail/weekly-report.html”,结果系统提示找不到模板。为什么?因为Resolver对if (templateName.startsWith("/"))这类情况也有特殊处理,最好就传干净的模板路径。
4.4 定时任务使用
@Component public class WeeklyReportJob { private final MailRenderService mailRenderService; private final MailSender mailSender; public WeeklyReportJob(MailRenderService mailRenderService, MailSender mailSender) { this.mailRenderService = mailRenderService; this.mailSender = mailSender; } @Scheduled(cron = "0 0 9 * * MON") public void sendReport() { List<ReportData> dataList = loadReportData(); String htmlContent = mailRenderService.renderWeeklyReport("上周业务数据", dataList); // 正常发送邮件,htmlContent 直接作为邮件正文 mailSender.sendHtml("ops@example.com", "每周数据汇总", htmlContent); } private List<ReportData> loadReportData() { List<ReportData> list = new ArrayList<>(); list.add(new ReportData("用户增长", 1200, 1000, 0.2)); list.add(new ReportData("付费转化", 300, 350, -0.14)); list.add(new ReportData("内容创作", 4567, 4321, 0.05)); return list; } }跑出一个真实的邮件效果,HTML 会被邮件客户端正确解析成表格背景色交替的样式。这个案例最直接的收益是:再改邮件样式的时候,我不需要去改 Java 代码,改.html模板文件然后重新部署即可。
提示:如果是发送 HTML 邮件,必须保证
<html>根标签存在,并且xmlns:th需要声明。有些邮件客户端兼容性不佳,建议表结构保持简单,内部样式优于外部样式表,Thymeleaf 内联样式可以预判。
5. 手动渲染中的变量注入与表达式工具补充
前面案例里用到的Context是一个轻量级的变量容器。我自己的经验里,很多从 JSP 转过来的人容易把 Thymeleaf 的 Context 和 Servlet 的 request/session 去做类比,但其实它只是一个Map<String, Object>的包装器。在你往里面塞变量的时候,有几个容易忽略的细节值钱,这里集中讲。
5.1Context与WebContext的取舍
手动渲染中我优先推荐使用org.thymeleaf.context.Context,而不是 Web 环境下的WebContext。WebContext是 Thymeleaf 为了解决 Web 请求中可以直接获取HttpServletRequest属性、HttpSession属性以及application属性的场景而存在的。但它和 Servlet API 强耦合,如果只是生成邮件,用完就丢,没有必要引入这么重的上下文。
同时注意:在 Spring Boot 2 的 Thymeleaf 3.x 中,WebContext已经标记为 deprecated,新的使用方式是WebSession+WebExchange的组合概念,但对于手动渲染非 Web 场景,直接忽略即可。
5.2 变量名不能使用复合表达式
下面这种写法是错误的:
context.setVariable("name.list", "foo");变量名包含.会让模板解析器误认为是对象属性访问,你在模板里写${name.list}的时候,它尝试解析name这个变量然后访问其list属性,而不是直接取名为name.list的变量。这种隐蔽问题排查起来特别费时,所以建议变量名统一使用驼峰命名,不要使用任何特殊符号。
5.3 充分使用工具对象
Thymeleaf 自带一批工具对象,手动渲染模式下同样可用:
| 工具对象 | 说明 | 示例 |
|---|---|---|
#dates | 日期格式化 | ${#dates.format(now, 'yyyy-MM-dd')} |
#calendars | 日历对象格式化 | ${#calendars.monthName(...)} |
#numbers | 数字格式化 | ${#numbers.formatInteger(num, 3)} |
#strings | 字符串操作 | ${#strings.toUpperCase(str)} |
#lists | 列表操作 | ${#lists.size(list)} |
#maps | 集合操作 | ${#maps.containsKey(map, 'key')} |
#sets | 集合操作 | ${#sets.contains(set, item)} |
#bools | 布尔运算 | ${#bools.isTrue(bool)} |
熟悉这些工具对象的最大好处是模板里尽量少写th:if判断冗余逻辑。比如要拼接人员姓名中的省略号,直接用${#strings.abbreviate(name, 14)}比在 Java 里处理完再传变量更灵活。
5.4 内联表达式与手动渲染的配合
有些特殊业务中,模板变量是动态生成的页面组件数据,我会直接在模板中使用内联表达式:
<p>[[${user.name}]]</p>这个写法在手动渲染中完全可用,因为最终process就是把文本渲染为纯字符串。但要注意一个细节:[[...]]内联表达式默认会做 HTML 转义。如果你存的是富文本 HTML 并且信任其来源,需要写成[(${content})]这个不支持转义的语法。在邮件模板中,正文内容来自后台编辑器,一般不信任用户输入,我用[[${content}]]防止脚本注入到邮件里。
6. 实测中的意外坑:字符编码、模板片段与缓存问题
手动渲染这条路看着平坦,真正趟进去会有不少暗坑。我把真实场景里踩过的几个典型问题整理出来,这些在普通博客里很少会专门提到。
6.1 中文乱码,明明配置了 UTF-8 却仍然乱码
你可能会觉得手动渲染是纯 Java 代码调用,不像 Servlet 有写在代码前面的request.setCharacterEncoding("UTF-8")的问题。实测下来,发生乱码通常有三个层次的问题:
第一层:TemplateResolver的setCharacterEncoding没有设置。如果你是方式一(原生 API)自己创建了一个ClassLoaderTemplateResolver,必须显式设置:resolver.setCharacterEncoding("UTF-8"),否则默认使用系统编码,Windows 下大概率是 GBK,中文模板乱码没商量。
第二层:模板文件本身的编码就不是 UTF-8。IDE 默认的编码可能跟随系统地区设置,如果你是 Windows 中文环境,默认GBK,因此即使 Java 层设置了 UTF-8,文件本身就乱了,渲染出来自然也是乱码。检查文件中文字符是否显示正常,必要时使用“File Encoding”统一改成 UTF-8 保存。
第三层:输出端编码错误。如果你把渲染后的字符串直接写入文件或数据库,也可能出现乱码。例如要把字符串String html = templateEngine.process(...)写入文件,必须指定写入文件的字符集:
Files.write(Paths.get("D:/report.html"), html.getBytes(StandardCharsets.UTF_8));很多人忘记了文件写入这一步单独指定,导致页面上看代码没问题、存储到文件后打开却是乱码。
6.2 模板文件的th:inline="javascript"与手动渲染的边界
有时候我们会使用 Thymeleaf 的 JavaScript 内联能力:
<script th:inline="javascript"> var user = [[${user}]]; </script>这种语法在自动视图解析中工作良好,但在手动渲染时有个隐藏要求:你必须在 Context 中传入一个合法的 Java 对象,而且 Thymeleaf 会调用它的 toString 方法生成 JSON 风格输出。如果你传过去的对象没有 getter 方法,可能会被渲染成空对象{},别问我为什么知道。解决方案是传一个简单 POJO,或者手动把对象转换成 JSON 字符串后再传入。
6.3 缓存问题:为什么改了模板却没生效
Spring Boot 2 中spring.thymeleaf.cache默认是true,生产环境这样没问题,但是你在本地调试手动渲染代码时,改了模板刷新却看不到效果,会相当难受。解决方案有两层:
本地环境把cache设成false:
spring: thymeleaf: cache: false但如果模板文件是从自定义存储加载的,那还要检查你的 resolver 是否开启缓存。方式一原生 API 创建的 resolver,默认也是启用缓存(setCacheable(true)),而手动渲染工具类往往是一个长生命周期组件,你如果不想每次重新加载模板,也可以主动缓存;但如果你还在开发阶段,记得把 cache 关掉,否则你会陷入“代码改了、日志打了、渲染结果没变化”的常见死循环。
6.4 Fragment 片段渲染
手动渲染还能处理一个特别细节的需求——只渲染模板的一部分。利用 Thymeleaf 的 fragment 能力,你可以把邮件模板拆分成头部、主体、尾部三个代码片段,在生成不同邮件时复用。
模板文件email-layout.html:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <body> <div th:fragment="header(title)"> <h3 th:text="${title}">默认标题</h3> </div> <div th:fragment="footer(company)"> <p th:text="'Copyright ' + ${company} + ' 保留所有权利'">版权声明</p> </div> </body> </html>渲染时指定 fragment:
String footer = templateEngine.process("mail/email-layout :: footer('某公司')", context);这里::是标记片段选择符。我被这个功能惊艳过:处理不同客户邮件时,可以只渲染同一个模板的某个局部模块,拼接出最终内容,而不用写一堆if/else逻辑。
提示:如果你用
SpringTemplateEngine渲染一个 fragment 字符串,而这个 fragment 的独立内容不是一个完整 html 文档,TemplateMode.HTML也能正常解析,这是它的容错性优势。但如果你用.txt模板则必须设置TemplateMode.TEXT,否则渲染结果可能会包含莫名其妙的多余标签。
6.5 表达式中的 Bean 调用
这是SpringTemplateEngine比原生TemplateEngine更适合项目集成的核心原因。在手动渲染时我可以直接在模板里调用 Spring 管理的 Bean 方法,比如在模板中拼接当前用户的展示名:
<p th:text="${@userService.getDisplayName(userId)}">调用Bean方法</p>使用方式就是在表达式里写@beanName然后跟上方法调用。这一点在纯原生TemplateEngine中是不支持的,除非额外添加SpringStandardDialect。非常适合在做报表、发通知时,直接在模板中调用充血模型方法,减少在 Java 代码里预先处理一堆变量的繁琐。
7. 封装一个可靠的渲染工具类
最后,实战中用得多还是封装一个独立的渲染工具类,把上面讨论的几种方式融合起来。我这里提供一个生产项目可用的简易版本,同时兼顾了自定义模板存储和缓存控制。
其实我不太喜欢用静态方法,因为SpringTemplateEngine从使用逻辑上是一个重量级组件,初始化成本不小,但它在 Spring 容器中默认是单例的,所以直接注入使用最优雅。下面是我经常用的一个封装:
@Component public class ThymeleafRenderUtils { private final SpringTemplateEngine templateEngine; public ThymeleafRenderUtils(SpringTemplateEngine templateEngine) { this.templateEngine = templateEngine; } /** * 渲染指定路径模板 * * @param templatePath 模板路径,相对于 classpath:/templates/ * @param variables 变量集合 */ public String render(String templatePath, Map<String, Object> variables) { Context context = new Context(); context.setVariables(variables); return templateEngine.process(templatePath, context); } /** * 渲染指定模板片段 */ public String renderFragment(String templatePath, String fragmentSelector, Map<String, Object> variables) { Context context = new Context(); context.setVariables(variables); return templateEngine.process(templatePath + " :: " + fragmentSelector, context); } }这个工具类本身不复杂,但要注意的一点是:如果你继承它并且自己扩展了renderFragment,模板路径拼上:: selector时,必须确保fragmentSelector内没有空格引起歧义,同时 fragment 表达式中如果包含参数,建议用单引号包住字符串字面量。
基于我的个人经验,贴一段使用方式:
@RestController public class DemoController { private final ThymeleafRenderUtils renderUtils; public DemoController(ThymeleafRenderUtils renderUtils) { this.renderUtils = renderUtils; } @GetMapping("/api/demo/render") public String demo() { Map<String, Object> vars = new HashMap<>(); vars.put("message", "手动渲染成功"); return renderUtils.render("demo/render-demo", vars); } }这里返回的是 String 类型,配合@ResponseBody就是纯文本输出,不会经过视图解析器二次处理。如果你不小心返回了一个模板路径,框架还可能会去解析视图,产生预期之外的异常。
8. 手动渲染的性能优化与命名规范建议
既然手动渲染常用于高频率的邮件、通知推送,那性能不可回避。分享一些实测过的优化思路。
8.1 模板缓存不必全关
虽说开发阶段建议关闭缓存方便调试,但一旦进入生产,建议将spring.thymeleaf.cache设为true。Thymeleaf 的模板缓存是解析之后的 AST(抽象语法树)缓存,重复渲染同一个模板文件时速度能提升好几个数量级。如果模板数量不大,缓存命中率几乎是 100%,内存开销也有限。还有一种折中方案:自定义 resolver 时设置setCacheTTLMs()来控制缓存清理周期,兼顾模板更新与性能。
8.2 变量对象的 getter 效率
Thymeleaf 通过反射访问 POJO 的 getter 方法来获取属性值。如果我们传入的是某些远程服务代理对象,每取一次属性都有可能触发远程调用,渲染时间会让人崩溃。在邮件报表场景中,建议先组装好一个独立的 DTO 对象,属性全部是内存取值,而不是直接传入 ORM 实体或远程代理对象,否则你会在性能日志中看到process方法耗时动辄好几秒。
8.3 命名规范上的个人建议
- 模板文件一律小写,使用中划线分隔,如
weekly-report.html,不要混用驼峰和中文命名。 - 变量命名采用驼峰。
- Controller 中尽量不要直接调用
templateEngine.process,而是封装成 Service 层能力,分离关注点。 Context创建尽量局部化,避免复用同一个Context实例在多个线程中使用,因为它是非线程安全的。正确做法是每次渲染都new Context(),成本很低。
最后再分享一个心得:手动渲染 Thymeleaf 的能力比想象中还要大。不仅邮件,我用它做过导出 HTML 格式的月度报表转移到另一个系统展示,也用它在单元测试中对比渲染结果快照。它和自动视图解析并不冲突,两者更像是“一个管请求视图、一个管内容生产”。在 Spring Boot 2 项目中,只要你的业务有“根据模板 + 数据生成 HTML 字符串”的需求,无论这个需求发生在定时任务、MQ 消费者、还是某个服务层的工具方法中,手动渲染都是直接且可靠的答案。