资讯动态

Spring Boot整合FreeMarker模板引擎:语法、配置与实战避坑指南

发布时间:2026/10/13 12:13:31 来源:尧图企业网站定制
说实话在Spring Boot的生态里说到模板引擎大多数人第一反应都是Thymeleaf毕竟官方文档推的就是它。但你要是去翻老一点的项目或者接触邮件渲染、报表生成这类对精确输出有严格要求的场景就会发现FreeMarker的身影其实比想象中多得多。我最早接触FreeMarker是在一个电商后台的邮件模板系统里当时要批量生成动态HTML邮件对比了一圈之后发现FreeMarker语法直观、性能扎实而且不强制写XML式标签写起来非常顺手。这篇内容就从一个实际使用者的角度把Spring Boot里整合FreeMarker的步骤、语法要点以及那些文档里不会写清楚的坑一次性梳理清楚。适合谁看刚接触FreeMarker的Java后端开发想在Spring Boot项目里快速落地模板渲染的同事以及正在纠结选Thymeleaf还是FreeMarker的朋友。看完你至少能独立完成一个从Controller到.ftl页面的完整链路并且避开我踩过的那些隐蔽的坑。1. 为什么在Spring Boot里选FreeMarker1.1 FreeMarker到底是什么FreeMarker是一个基于Java的模板引擎核心工作方式可以用一句话概括模板 数据模型 输出文本。模板是静态页面骨架里面留好变量位置和逻辑标记数据模型是后台准备好的Java对象集合FreeMarker负责把两者合并输出成HTML、XML、JSON甚至纯文本。打个比方模板就是一张带空格的填空题试卷数据模型是你手里的一堆答案FreeMarker就是帮你快速填答案的那个人。整个过程不依赖Servlet容器不像JSP那样必须跑在应用服务器里才能编译执行这让FreeMarker能在很多非Web场景下灵活使用——比如生成邮件、导出配置文件、生成代码文件都可以脱离Spring MVC单独跑。另外FreeMarker的输出是纯文本流它根本不关心你要生成的是一段网页还是几行配置文件。这种通用性让它在一众Java模板引擎里显得很特别也是我后来持续使用它的核心原因之一。1.2 和Thymeleaf、JSP比优势在哪挑模板引擎这事我习惯从三个维度评判语法友好度、渲染性能、可测试性。JSP的问题在于它本质上是被容器编译成Servlet的和Servlet API绑定得很死。想单元测试得把容器拉起来成本高而且JSP里写Java代码太方便了方便到很容易写出“页面和业务逻辑混成一团浆糊”的代码后期维护相当痛苦。现在新项目已经很少有人主动用JSP了。Thymeleaf的强项是自然模板它不引入自定义标签而是用HTML标签属性比如th:text来做渲染浏览器直接打开模板文件也能看到占位效果这对前后端协同确实友好。但它的语法在复杂逻辑场景下有点啰嗦遍历嵌套、条件分支写多了标签属性堆得又密又长看久了眼睛累调试相对麻烦。FreeMarker的优势是语法干净利落#if、#list、#assign这些指令写起来非常接近编程语言Java背景的开发者几乎零成本上手。在纯后端渲染的场景下它的执行效率实测通常优于Thymeleaf而且它不止能做HTML生成SQL、XML、CSV这些也不在话下通用性更强。1.3 Spring Boot官方提供的支持情况Spring Boot对FreeMarker有原生自动配置支持引入依赖后基本不用写什么配置就能跑起来这点相当省心。自动配置会帮你搞定模板加载路径、视图解析器、缓存开关这些基础设置。你只要遵循约定把模板放到src/main/resources/templates目录下就行。这里要特别提一下模板缓存。Spring Boot默认在生产模式下开启模板缓存模板文件会被加载到内存中后续请求直接复用避免反复磁盘IO性能优势明显。而在开发调试时你需要手动把缓存关掉这样改完模板刷新页面就能看到效果不用反复重启应用。这个开关我们在后面的配置章节会详细讲到。2. 基本集成从零到一跑通一个页面2.1 引入依赖在Spring Boot项目里加FreeMarker只需要在pom.xml里加一个起步依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-freemarker/artifactId /dependency这个starter会自动带上FreeMarker的核心库以及Spring Boot对它的自动配置类。版本号不用写Spring Boot的依赖管理已经帮我们锁好了。需要提醒一句如果你用的是Gradle对应的写法是implementation org.springframework.boot:spring-boot-starter-freemarker效果一样。2.2 目录结构与基础约定依赖加上之后建议先按下面的目录结构组织文件这也是Spring Boot的默认约定路径不按规矩来后面配置会很别扭src/main/resources/ ├── templates/ # 放FreeMarker模板文件 │ ├── index.ftl │ ├── user/ │ │ └── detail.ftl │ └── common/ │ └── layout.ftl └── static/ # 放静态资源 CSS/JS/图片 ├── css/ └── js/默认情况下视图解析器的前缀是classpath:/templates/后缀是.ftl。所以Controller里返回逻辑视图名index实际加载的就是templates/index.ftl。逻辑视图名可以直接写文件名也可以写带子目录的相对路径比如user/detail对应templates/user/detail.ftl。2.3 配置文件里的几个关键项在application.yml中可以显式控制FreeMarker的行为。我比较推荐在一开始就把下面这些配置写好省得后面出问题再补spring: freemarker: template-loader-path: classpath:/templates/ suffix: .ftl cache: false charset: UTF-8 check-template-location: true settings: number_format: 0.## date_format: yyyy-MM-dd time_format: HH:mm:ss datetime_format: yyyy-MM-dd HH:mm:ss default_encoding: UTF-8 output_encoding: UTF-8 url_escaping_charset: UTF-8重点说几个cache: false开发阶段务必设置否则每次改模板都要重启应用太浪费时间了。上线前记得改成true。charset: UTF-8字符编码一定要显式声明避免默认值不一致导致中文乱码。number_format: 0.##这个非常关键。FreeMarker默认的数字格式化在某些场景下会把整数渲染成带千分位逗号的格式比如1000显示成1,000订单号、金额这类字段一旦被格式化页面数据直接错乱。设置成0.##能规避掉绝大多数问题。2.4 写一个最简单的Controller和模板Controller这边写法跟日常开发基本一致Controller RequestMapping(/demo) public class DemoController { GetMapping(/hello) public String hello(Model model) { model.addAttribute(name, 小王); model.addAttribute(age, 28); return hello; } }对应的hello.ftl!DOCTYPE html html head meta charsetUTF-8 titleHello/title /head body h1你好${name}/h1 p年龄${age}/p /body /html启动应用访问/demo/hello页面就能正确渲染出“你好小王”了。这里有个小提醒Controller上的注解必须是Controller不能写成RestController。因为RestController返回字符串时Spring会把它直接当作HTTP响应体输出而不是走视图解析器去渲染模板。这个坑我见过不止一个人踩。3. FreeMarker语法要点常用功能一次讲透3.1 取值与空值处理${}和!FreeMarker里取值用\${}这个大家都不陌生。但空值处理是个重灾区。默认情况下如果后台传的变量是nullFreeMarker直接渲染会抛出异常页面上会显示一大段错误信息。处理方式有两种${user.name!} !-- 空值显示为空字符串 -- ${user.name!匿名用户} !-- 空值显示默认值 --注意感叹号的位置是紧跟变量名后面。如果整个对象本身可能为null但你又要访问它的属性写法要变成这样${(user.name)!} !-- user为null或name为null都走默认空 -- ${(user.name)!未知} !-- user为null时显示未知 --这里的括号千万不能省。很多人第一次用会漏掉括号写成${user.name!}结果user为null时照样报错因为FreeMarker在解析属性访问那一步就已经抛异常了根本来不及走到感叹号做空值处理。3.2 条件判断#if系列FreeMarker的分支语法风格上很像Java写起来非常顺手#if user.age 18 p成年人/p #elseif user.age 12 p青少年/p #else p儿童/p /#if判断条件中支持、!、、、、、、||、!这些常见操作符。字符串比较直接用即可比如#if user.status ACTIVE。另外判断对象是否存在可以用双问号??#if user?? p用户对象存在/p /#if这个配合默认值使用基本能覆盖所有空值判断场景。3.3 循环遍历#list遍历集合是模板渲染里最高频的操作没有之一。一个典型例子#list userList as user div span${user.name}/span - span${user.age}岁/span /div /#list循环体里还可以用user_index拿到当前索引从0开始。比如做隔行变色#list userList as user tr class${user_index % 2 0 ? even : odd} td${user_index 1}/td td${user.name}/td /tr /#list如果集合为空#list默认不报错只是不渲染任何内容。如果想在空集合时给个提示可以用#else——注意这个else是和list指令同级的不是嵌在循环体里#list userList as user span${user.name}/span #else span暂无数据/span /#list3.4 变量定义与计算#assign在模板里可以临时定义变量适合一些中间计算或复用表达式。比如统计订单总额#assign total 0 #list orderItems as item #assign total total item.price * item.quantity /#list p订单总额${total}元/p需要说明的是#assign定义的变量在模板的后续位置都是可见的但它是模板局部变量不会反向写入后台的Model。另外#assign也支持一次定义多个变量用分号隔开就行。3.5 内建函数让模板具备表达式能力内建函数Built-in是FreeMarker最强大的部分语法形式是变量?函数名。我用得最频繁的几个列出来${name?upper_case}转大写${name?lower_case}转小写${num?c}把数字渲染成纯数字字符串不会出现千分位逗号${dateVal?date}只显示日期部分${dateVal?time}只显示时间${dateVal?datetime}显示完整日期时间${list?size}获取集合长度${str?length}获取字符串长度${str?substring(0, 5)}截取子串${str?trim}去除两端空格${htmlContent?html}对HTML特殊字符做转义防止XSS第二个?c值得单独拿出来说。后台传给模板一个Long类型的订单号1000001234如果不做处理直接输出FreeMarker老版本的默认行为会把数字本地化格式化可能变成1,000,001,234页面直接显示错乱。用\${orderNo?c}就能保证输出纯数字。这是我在实际项目中踩过最隐蔽的坑之一排查了好久才发现是数字格式化的问题。日期格式化可以配合字符串参数精确控制${createTime?string(yyyy年MM月dd日 HH:mm)}3.6 include包含与宏定义模板复用方面我使用最多的是两种方式#include和宏。#include就是简单的文件内容合并适合页头、页脚、版权声明这类固定片段#include /common/header.ftl宏Macro则可以理解为模板里的“函数”通过传参数生成一段输出。定义方式#macro pagination current total div classpagination span第${current}页 / 共${total}页/span /div /#macro使用方式pagination current1 total10 /宏的参数可以设置默认值也支持多个参数。比如做一个通用的分页组件、面包屑导航、卡片通用样式用宏比include灵活得多因为可以传参控制显示行为复用性更高。4. Spring Boot整合FreeMarker的进阶实践4.1 在页面中获取请求上下文模板里经常要拿到当前请求的URL、上下文路径这些信息。Spring Boot的FreeMarker自动配置默认会把request、session、application等对象暴露给模板不需要额外声明但如果你手动配置破坏了自动装配这些对象可能会消失。获取上下文路径的标准写法base href${springMacroRequestContext.contextPath}/这里用的是springMacroRequestContext这是Spring对FreeMarker提供的宏上下文对象。如果模板里访问不到这个对象大概率是自动配置被破坏了。最简单的排查思路检查你有没有手动覆盖UrlBasedViewResolver或者FreeMarkerConfigurer有的话先去掉让Spring Boot自己管理。如果需要拿到URL上的请求参数可以用内置模型${RequestParameters.q!}注意这里的RequestParameters首字母R是大写它是FreeMarker暴露的请求参数模型。大小写写错的话空值处理都不会触发直接报模板异常。4.2 日期格式化的统一处理如果每个模板里都写?string(yyyy-MM-dd)不仅啰嗦还容易漏写。更好的做法是在配置里统一定义格式然后在模板中使用?date、?datetime直接转换spring: freemarker: settings: date_format: yyyy-MM-dd datetime_format: yyyy-MM-dd HH:mm:ss配置之后模板里就能用简洁的写法输出${user.birthday?date} ${order.createTime?datetime}这样一来全项目的日期格式口径统一不会再出现一个模板一种格式的混乱局面。4.3 模板布局继承式页面的实现FreeMarker本身没有像Thymeleaf那样原生提供布局继承但借助#macro完全可以实现类似效果而且用起来不难。先定义一个布局模板layout.ftl#macro page title !DOCTYPE html html head meta charsetUTF-8 title${title}/title link relstylesheet href/css/common.css /head body #include /common/header.ftl div classcontainer #nested / /div #include /common/footer.ftl /body /html /#macro然后具体的页面模板这样写#import /common/layout.ftl as layout layout.page title用户详情 h1${user.name}/h1 p邮箱${user.email}/p /layout.page这里的核心机制是#nested /它标记了宏体中被包裹内容插入的位置。使用import引入布局文件再用layout.page title....../layout.page包裹具体页面内容。header、footer、公共CSS/JS全部收敛到布局文件里改一处全站生效。这个方案虽然比Thymeleaf的布局原生支持稍显手动但胜在逻辑清晰、可控性强我用了很长时间都觉得顺手。4.4 静态资源与模板的配合模板里引用静态资源我统一建议使用带上下文路径的绝对路径link relstylesheet href${springMacroRequestContext.contextPath}/css/app.css script src${springMacroRequestContext.contextPath}/js/app.js/script如果项目没有配置context-path直接用/css/app.css也行。但项目一旦部署在Nginx或网关后面挂了子路径写死的相对路径就会出现404。我在一个前后端分离度不高的老项目里就吃过这个亏上线后发现所有CSS、JS都加载失败排查到最后才发现是子路径的问题。所以稳妥起见把contextPath拼上去一劳永逸。4.5 生成邮件HTML模板FreeMarker在邮件模板场景下堪称神器。业务系统里最常见的需求就是把订单信息、用户信息、退款链接拼成一份HTML邮件发出去。先定义模板mail/order-notify.ftl!DOCTYPE html html body h2订单支付成功通知/h2 p亲爱的 ${userName!}/p p您的订单 strong${orderNo?c}/strong 已支付成功金额 strong${amount?string(0.00)}/strong 元。/p p点击 a href${detailUrl}查看订单详情/a/p /body /html在Service里渲染模板时可以直接注入Spring Boot自动配置好的freemarker.template.Configuration对象不需要额外创建Autowired private freemarker.template.Configuration freeMarkerConfiguration; public String renderOrderMail(OrderModel order) throws Exception { Template template freeMarkerConfiguration.getTemplate(mail/order-notify.ftl); MapString, Object data new HashMap(); data.put(userName, order.getUserName()); data.put(orderNo, order.getOrderNo()); data.put(amount, order.getAmount()); data.put(detailUrl, https://example.com/order/ order.getOrderNo()); StringWriter out new StringWriter(); template.process(data, out); return out.toString(); }这种方式完全脱离了Spring MVC的视图解析流程直接在Service层把模板渲染成字符串再由MailSender发出去。在邮件、短信、报表导出这类场景中特别实用也更容易做单元测试——因为渲染逻辑不依赖Servlet容器普通的JUnit测试就能跑。4.6 国际化的基本思路FreeMarker本身不直接提供i18n能力但结合Spring的MessageSource可以实现国际化。最轻量的做法是把国际化文案作为数据模型传入。模板里写p${i18nMap.confirm}/p后台准备MapMapString, String i18nMap new HashMap(); i18nMap.put(confirm, messageSource.getMessage(common.confirm, null, locale));说实话这种做法对大多数中小项目已经够用了。如果项目对国际化的要求复杂建议去翻一下Spring对FreeMarker提供的spring.ftl宏文件里面封装了springMessage这样的标签配合MessageSource使用体验更完整。这部分的详细用法官方文档写得很清楚我这里就不展开了。5. 常见问题与排查实录5.1 中文乱码问题模板中文乱码绝大多数情况是编码配置不一致导致的。解决方案分两步第一步在application.yml中强制设置spring: freemarker: charset: UTF-8第二步确认.ftl文件本身保存时就是UTF-8编码。IDEA里看右下角编码按钮确认当前文件编码是UTF-8如果不是就转一下。很多乱码问题的根源其实就是文件用了GBK保存配置写上什么都没用。还有一个容易忽略的位置响应头。理论上设置了charsetSpring会返回Content-Type: text/html;charsetUTF-8但如果项目里有自定义Filter改动了响应的Content-Type乱码照样会出现。排查时用浏览器开发者工具看响应头确认编码声明是否正确。5.2 页面打开后显示的是模板源码这个问题的现象是访问URL后浏览器直接显示了.ftl文件的模板源代码而不是渲染后的页面。原因通常有两种Controller用了RestController返回的字符串被直接当作ResponseBody写到了响应里根本没有走视图解析器。改成Controller即可。Controller返回的逻辑视图名写错了比如把user/detail写成了user/detail.ftl。Spring Boot默认的视图解析器会自动拼接后缀.ftl所以直接写user/detail才是对的多写了后缀反而找不对文件。5.3 报错Expected a container属性访问失败这个错误信息在很多新手调试时频繁出现比如模板里写了${user.name}但后台传的user其实是个字符串而不是对象。FreeMarker解析到.这个属性访问符时要求左边的值必须是一个容器类型对象、Map或自定义的bean如果它本质上是一个字符串、数字或者其他基础类型就会抛出类似“Expected a container, but this has evaluated to a string”的异常。解决思路很简单检查后台传给模板的数据类型是否跟模板里写的结构一致。比如后台应该放一个User对象model.addAttribute(user, user);而不是把user.getName()的结果直接放进去model.addAttribute(user, user.getName()); // 错误示例这种错误很隐蔽因为浏览器上看到的是完整报错堆栈很多人第一反应是配置问题其实问题就出在后端塞错了数据类型。5.4 日期显示成了毫秒时间戳后台传了java.util.Date对象模板直接写${createTime}页面显示的是一长串数字。原因很简单FreeMarker默认对Date对象没有可读的字符串转换规则必须显式使用?date、?datetime或者?string(yyyy-MM-dd HH:mm:ss)来指定输出格式。加上之后问题就消失了。5.5 数字出现千分位逗号这个前面提过多次Long类型大数字直接输出会被本地化格式化成带逗号的形式。解决方案有两种一种在模板里处理${orderNo?c}?c是计算机格式computer format意思是输出纯数字字符串不含任何本地化符号。另一种在配置里统一处理把number_format设置成0.##避免默认格式干扰。两种方案可以结合用模板里的?c用于个别特殊字段配置用于全局兜底。5.6 模板修改后不生效开发模式下改完模板刷新页面还是旧内容。这个情况绝大多数是模板缓存没关闭导致的spring: freemarker: cache: false改完配置重启一次应用之后改模板就能实时生效了。不过要记住生产环境千万别关缓存模板渲染性能差距非常明显。我见过有人把cache: false直接带上生产环境高并发下模板解析成了性能瓶颈排查了很久才发现是这里的问题。5.7 模板里访问不到request/session如果你自定义了FreeMarkerConfigurer比如手动设置了TemplateLoaderPath就很可能覆盖Spring Boot的自动配置导致springMacroRequestContext、RequestParameters这些内置模型失效。我的建议是能不动自动配置就不动所有配置通过application.yml完成。如果确实需要自定义FreeMarkerConfigurer记得在自定义的Configuration中加载Spring的宏定义。不过以我的经验绝大多数项目的需求用配置文件已经能完全覆盖并不需要走到自定义这一步。6. 写在最后的几点个人经验FreeMarker在我这里一直是“闷声干活”型的选手。它不会像Thymeleaf那样被放在官方推荐位天天宣传但真到邮件渲染、页面渲染、代码生成这类需要精确控制输出的场景它总能在后台稳稳接住。Spring Boot内置支持这件事本身就说明了它的可靠性不然自动配置不会做得这么顺滑。如果刚开始用我的建议是先别急着钻研宏和嵌套布局这些高级特性把${}、#if、#list这三个最常用的语法练熟足以覆盖八成以上开发场景。等真的遇到重复代码多、需要做布局复用了再去研究宏和import那时候你会觉得模板引擎原来还能这么玩。还有一点要提醒FreeMarker模板里虽然能写逻辑但它毕竟不是一门编程语言。复杂业务计算尽量放到Controller或Service层把数据梳理好之后扔给模板模板只做展示该做的事。别在模板里堆一大堆assign和list嵌套不仅调试困难还容易写出难以维护的面条代码。最后分享一个我坚持了很久的习惯在新项目里我会在模板目录下建一个_common/文件夹专门用来存放宏定义、布局文件和公共片段。命名上用下划线开头一眼就能识别出这些文件不是直接的页面入口。这个习惯帮助我维护过多个长期迭代的后台系统效果非常好。模板引擎的选择没有绝对的对错关键是找到适合你团队、适合你场景的那一个。如果你正在做后端渲染型的Web应用又不喜欢Thymeleaf那种写属性式的风格FreeMarker值得你花一个下午认真试一试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑