FreeMarker实战指南:从模板语法到SpringBoot集成与代码生成
说实话看到热搜词里的“前端开发者学习后端Java知识计划”“springmv freemarker 转成springboot项目”我第一反应是越来越多的人正在从前后端分离体系倒回去接触老牌模板引擎。不少刚入行的后端同学觉得FreeMarker是过时技术毕竟现在张口闭口都是Vue、React、接口返回JSON谁还关心服务端渲染但等你真正接手若依这类快速开发框架或者被安排去维护一个SpringMVC老项目甚至要写代码生成器的时候就会明白FreeMarker依然活在Java后端的血脉里。这篇教程我想用最直接的方式把FreeMarker从语法到实战串一遍让后端学习者少走弯路。FreeMarker不是Web框架它只是一个模板引擎引擎负责把模板文件和数据模型拼装成最终文本。这个“职责单一”的定位决定了它既可以用在SpringMVC里渲染HTML页面也可以脱离Web环境生成Java代码、SQL脚本、邮件正文。正因为这种灵活性它在后端开发中的位置一直很稳。先搞清楚一个前提模板引擎到底在解决什么问题很多新手第一次接触FreeMarker时会困惑明明用String拼接也能生成动态内容为什么还要多学一套模板语法其实道理很简单。你把代码里的业务逻辑和展示结构混在一起改一行展示样式就得重新编译、重新部署而模板引擎把“长什么样”和“怎么算出来”拆开了前端模板归模板后端数据归数据两边各改各的。1.1 FreeMarker和后端框架是解耦的这一点特别关键。FreeMarker本身不依赖Spring也不依赖Servlet容器。你可以在一个普通的main方法里new一个Configuration对象指定模板目录然后传入一个Map作为数据模型它就能生成文本。这意味着它不止能生成HTML还能生成任意类型的纯文本文件。我见过有人用FreeMarker生成过代码生成器里的实体类、Mapper接口、XML映射文件数据库初始化脚本静态站点的整站HTML复杂格式的通知邮件导出用的CSV或配置文件相比JSP那种和Servlet容器深度绑定的方案FreeMarker这种解耦设计简直是后端工具箱里的瑞士军刀。1.2 前后端分离时代它为什么还没死你可能会问现在都是Vue、React搞前后端分离后端只出JSONFreeMarker还有存在的必要吗答案是分场景。比如你的页面需要SEO搜索引擎爬虫对JavaScript渲染的页面不友好服务端渲染就有天然优势。再比如后台管理系统里的代码生成器它要动态生成Java文件前端根本参与不了。还有邮件通知这种场景内容由后端组装直接跑模板输出文本比前端搞一套渲染流程高效得多。用一张表简单对比一下常见模板方案。方案渲染端适用场景学习成本JSP服务端传统Java Web高与Servlet耦合Thymeleaf服务端SpringBoot页面渲染中标签写法较繁琐FreeMarker服务端HTML、代码生成、邮件低语法简洁Vue/React客户端SPA单页应用高前端工程化所以结论很明确FreeMarker不是被淘汰了而是它的战场更聚焦了。凡是需要在服务端按模板生成文本的地方它依然是最顺手的工具之一。模板与数据模型FreeMarker执行的三个核心角色理解FreeMarker只需要抓住三样东西模板文件、数据模型、输出过程。模板文件负责描述输出长什么样数据模型由Java对象组成输出过程就是把两者合并。整个过程没有任何黑魔法。2.1 第一个例子从helloworld看懂模型绑定我先给一个最朴素的需求给新注册用户发一封欢迎邮件内容里要带上用户名、账号和当前积分。模板文件 welcome.ftl 这样写html head title欢迎加入/title /head body p${userName}你好/p p你的账号 ${account} 已激活当前积分 ${points}。/p /body /html后端Java代码这样构造数据模型Configuration cfg new Configuration(Configuration.VERSION_2_3_32); cfg.setDefaultEncoding(UTF-8); cfg.setClassLoaderForTemplateLoading( FreeMarkerDemo.class.getClassLoader(), /templates ); Template template cfg.getTemplate(welcome.ftl); MapString, Object dataModel new HashMap(); dataModel.put(userName, 张三); dataModel.put(account, zhangsan); dataModel.put(points, 1200); StringWriter writer new StringWriter(); template.process(dataModel, writer); System.out.println(writer.toString());这就是一个完整的FreeMarker独立运行流程。Configuration负责全局配置Template代表加载后的模板对象dataModel里的key对应模板中的变量名。输出时如果模板里引用了dataModel中不存在的变量默认会直接抛异常。2.2 数据模型支持哪些Java类型FreeMarker的数据模型和Java对象的映射非常宽松它自己定义了一套类型体系来兼容各种Java对象。常见的有这么几种字符串对应String数字对应Integer、Long、BigDecimal等布尔值对应Boolean日期对应Date及其子类序列对应List、数组哈希对应Map、JavaBean这意味着你几乎可以把任何业务对象直接扔进数据模型。比如有个User对象你直接把user对象作为value放进去模板里用${user.name}就能取到属性值。FreeMarker底层通过反射访问JavaBean的getter方法所以你的User类必须有对应的getter这一点很容易被忽略。2.3 模板语法里的两类元素模板文件里混合了两种内容普通文本和FTL指令。普通文本会原样输出FTL指令负责逻辑处理。指令分成两种形式。插值表达式用${}表示比如${userName}作用是计算表达式的值并输出到当前位置。FTL标签则用#...表示比如#if、#list相当于后端代码里的控制语句。注释的写法是#-- 注释内容 --注意FreeMarker的注释不会输出到最终结果里这一点和HTML注释不同。写出一个直观的对照普通文本原样输出 ${expr}输出表达式的计算结果 #directive执行指令 #-- 注释 --不输出把模板写成程序指令、内建函数与宏的实战组合模板里能不能写复杂逻辑答案是可以但我建议保持克制。FreeMarker提供了足够强的指令和内建函数让你在模板层完成展示逻辑的编排。如果逻辑过于复杂那就应该回退到Java层处理而不是在模板里堆长表达式。3.1 条件判断与循环遍历最常见的控制结构就是if和list。#if user.vip p尊贵的VIP用户欢迎回来/p #else p开通VIP可享受更多权益。/p /#if注意#if指令里的表达式直接写对象引用不需要加${}。${}只用于输出这一点新手很容易搞混。判断条件里可以写、!、、这些比较运算符也可以写、||、!逻辑运算。循环遍历列表#list productList as product div${product.name} - ${product.price}元/div /#listproductList是数据模型里的List对象product是循环变量在#list和/#list之间可以任意使用。如果要输出序号可以用product_index这是FreeMarker内置的循环索引从0开始。如果productList可能为null建议写成#list productList![] as product加上![]的意思是如果productList为null就当成空列表处理避免抛异常。3.2 空值处理是模板开发的重灾区FreeMarker默认对空值很敏感访问一个不存在的变量或者一个值为null的属性都会直接抛异常。这个设计是为了尽早暴露问题但实际开发中你不可能保证每个数据都有值。所以空值处理语法必须熟练掌握。${user.name!} !-- 如果user.name为null输出空字符串 -- ${user.name!游客} !-- 如果user.name为null输出游客 -- ${user.name??} !-- 返回true或false用于判断是否存在 --三个符号要记牢!是默认值??是存在性判断?是调用内建函数。尤其注意${}里如果只写${user.name}而user为null会直接抛“undefined”错误这个坑我后面专门讲。3.3 内建函数让模板具备处理能力内建函数是FreeMarker语法里最强大的部分写法是在变量名后面加?然后跟函数名。常用的有这么几个。字符串处理${name?upper_case} !-- 转大写 -- ${name?lower_case} !-- 转小写 -- ${name?trim} !-- 去除两端空格 -- ${name?substring(0, 3)} !-- 截取子串 -- ${name?replace(a, b)} !-- 替换 --数字格式化${price?string(0.00)} !-- 保留两位小数 -- ${count?string(#,##0)} !-- 千分位格式化 --集合操作${list?size} !-- 集合大小 -- ${list?join(, )} !-- 用逗号拼接元素 -- ${list?first} !-- 第一个元素 --日期格式化${createTime?date} !-- 只输出日期 -- ${createTime?time} !-- 只输出时间 -- ${createTime?datetime} !-- 输出日期和时间 -- ${createTime?string(yyyy-MM-dd HH:mm:ss)} !-- 自定义格式 --3.4 宏定义模板里的函数如果同一段模板结构要在多个地方复用可以用宏。宏相当于模板层面的函数可以接收参数输出公共片段。#macro pageHeader title head meta charsetUTF-8 title${title}/title /head /#macro调用方式pageHeader title用户管理 /宏内部还能使用#nested指令嵌入调用者的内容类似Vue里的slot插槽。比如做一个卡片组件#macro card title div classcard div classcard-title${title}/div div classcard-body #nested / /div /div /#macro调用时card title统计概览 p今日新增用户${todayCount}/p /card这个能力对管理后台类的页面非常实用。把公共的布局、卡片、分页条抽成宏模板会干净很多。3.5 引入与复用include和importinclude指令可以把另一个模板文件包含进来相当于把文件内容就地展开。#include /common/header.ftlimport指令则是把另一个模板里的宏导入到当前命名空间。比如把宏单独放一个文件 macros.ftl其他地方import之后就能直接用。#import /common/macros.ftl as ui ui.card title用户信息 p内容/p /ui.cardimport的好处是避免不同文件里的宏命名冲突加了as别名之后用别名点宏名的方式调用代码可读性更好。SpringBoot里接入FreeMarker老项目迁移和新项目配置的区别现在web项目里用FreeMarker主流方式还是通过SpringBoot集成。这部分我把配置步骤和容易踩的坑一起讲清楚。4.1 从SpringMVC到SpringBoot迁移的核心差异很多老项目是SpringMVC FreeMarker迁移到SpringBoot时最大的变化是配置从XML或properties挪到了application.yml依赖从手动引包变成了starter自动装配。老项目SpringMVC里通常这样配置FreeMarker使用spring.freemarker配置前缀配置项几乎一样spring: freemarker: suffix: .ftl content-type: text/html charset: UTF-8 template-loader-path: classpath:/templates/SpringBoot迁移后依赖直接加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-freemarker/artifactId /dependencystarter会自动注册FreeMarkerConfigurer和FreeMarkerViewResolver。也就是说你不需要手动创建Configuration对象了只要往application.yml里写配置然后Controller返回视图名SpringBoot就能自动把模板渲染结果返回给浏览器。4.2 一个完整的Controller渲染流程SpringBoot FreeMarker的Controller写法和JSP时代很相似。Controller RequestMapping(/user) public class UserController { GetMapping(/list) public String list(Model model) { ListUser users userService.listAll(); model.addAttribute(users, users); return user/list; } }代码里的user/list对应src/main/resources/templates/user/list.ftl模板里直接使用users变量。table thead tr thID/th th姓名/th th邮箱/th /tr /thead tbody #list users as user tr td${user.id}/td td${user.name}/td td${user.email}/td /tr /#list /tbody /table这里有个新手容易犯的错Controller返回的视图名不要加.ftl后缀后缀由配置里的suffix自动拼接。如果你返回的是user/list.ftl而配置suffix是.ftl渲染时会去查找user/list.ftl.ftl直接报错。4.3 常用配置项与调试模式application.yml里这几个配置项是项目里最常用的我逐个解释。spring: freemarker: suffix: .ftl template-loader-path: classpath:/templates/ charset: UTF-8 content-type: text/html; charsetutf-8 cache: false settings: number_format: 0.########## classic_compatible: true template_exception_handler: rethrowcache: false是本地开发必须开的配置。因为模板文件是文本文件默认情况下SpringBoot会缓存模板内容如果不开关闭缓存你改完模板刷新页面看不到效果还得重启应用。生产环境再改回true否则每次请求都重新加载模板文件性能损耗很明显。number_format: 0.########## 这个配置也很关键。FreeMarker默认的数字格式化会把1200输出成1,200因为底层使用了Java的Locale格式。后端接口返回给页面时数字带千分位分隔符经常会让人困惑设置了number_format为0.##########就可以保持数字原样输出。classic_compatible: true是为了兼容老项目的行为。经典兼容模式下模板访问不存在的变量时不会直接抛异常而是输出空字符串。如果从SpringMVC老项目迁移过来一时半会改不掉模板里的空值隐患可以先开这个配置过渡但我建议最终还是要修掉模板里的空值问题依赖全局配置兜底不是长久之计。没人明说但最常用的场景基于FreeMarker写代码生成器如果你用过若依这类快速开发框架会发现一个很普遍的设计数据库建好表之后后端代码不用手写点击生成按钮就能自动创建实体类、Mapper、Service、Controller和前端页面。这个功能的底层核心就是模板引擎而FreeMarker在其中占了相当大的比例。5.1 为什么代码生成器偏爱FreeMarker代码生成器的本质是读取数据库表结构把表名、字段名、字段类型、注释等信息组装成数据模型然后套用预先写好的模板文件生成对应语言的代码文本。这个场景里模板可能长这样package ${packageName}.entity; import lombok.Data; import java.time.LocalDateTime; Data public class ${className} { #list fieldList as field /** * ${field.comment} */ private ${field.javaType} ${field.fieldName}; /#list }Java代码端做的事情是构造这个数据模型并调用模板Configuration cfg new Configuration(Configuration.VERSION_2_3_32); cfg.setDefaultEncoding(UTF-8); cfg.setClassLoaderForTemplateLoading( CodeGenerator.class.getClassLoader(), /templates/codegen ); Template template cfg.getTemplate(entity.ftl); MapString, Object dataModel new HashMap(); dataModel.put(packageName, com.example.demo); dataModel.put(className, User); ListMapString, Object fieldList new ArrayList(); MapString, Object idField new HashMap(); idField.put(comment, 用户ID); idField.put(javaType, Long); idField.put(fieldName, id); fieldList.add(idField); MapString, Object nameField new HashMap(); nameField.put(comment, 用户名); nameField.put(javaType, String); nameField.put(fieldName, name); fieldList.add(nameField); dataModel.put(fieldList, fieldList); File outputDir new File(generated-code); if (!outputDir.exists()) { outputDir.mkdirs(); } try (FileWriter writer new FileWriter( new File(outputDir, User.java))) { template.process(dataModel, writer); }这段代码就完成了一个最小可用的实体类生成器。你只需把模板按Controller、Service、Mapper等角色各写一份再封装一下JDBC表结构读取逻辑就是一个五脏俱全的代码生成器。5.2 生产级代码生成器的关键细节参考生成工具后我发现实际项目里代码生成器要考虑的问题远不止拼字符串那么简单。总结下来有这几个关键点。数据库类型到Java类型的映射要单独做一张表。MySQL的varchar映射Stringbigint映射Longdatetime映射LocalDateTimedecimal映射BigDecimal。不同数据库方言差异很大这个映射逻辑最好独立维护。模板文件要支持版本管理。生成的代码不是一次性消耗品项目后续迭代会不断修改生成的代码也可能在表结构调整后重新生成。模板如果频繁变动会重写出和现有代码差异很大的内容。生成时要处理包名、缩进、注释风格。很多团队对代码规范有严格要求模板里写死缩进风格会很难维护。我的做法是让模板尽量贴近团队的Java代码规范生成后再用IDE的格式化功能统一处理。5.3 代码生成器里FreeMarker相对于其他方案的优势现在也有一些基于JavaPoet或者直接字符串拼接的实现方案但碰到复杂模板依然头疼。FreeMarker的优势在于模板文件和Java代码完全分离让会写模板的人不一定要懂Java懂业务的人也能直接改模板结构。特别是在生成前端Vue页面这类长文件时字符串拼接的可读性会迅速恶化而FreeMarker模板结构一目了然。日常开发中反复踩到的坑与排查思路最后这部分是我最想写的因为语法看文档就能学会但有些坑不亲自踩一遍真的很难定位。我挑了六个高频问题附上排查思路和解决方案。6.1 模板变量不存在页面直接500最经典的问题从Controller传了一个user对象到模板模板里写${user.name}结果user对象里没有name这个属性或者user为null页面直接抛错。而且错误信息经常是一大串堆栈新手根本不知道去哪看。排查思路是先看异常信息里有没有类似“The following has evaluated to null or missing”的语句这句话后面会跟着具体的表达式比如user.name。定位到表达式之后再回到Controller看对应的数据是否真的塞进了Model。解决办法有两种一是在模板里加默认值${user.name!未命名}二是在Java端保证user不为空。我的建议是能用默认值就用默认值模板毕竟只是展示层健壮性比严格报错更重要。6.2 数字输出带了千分位分隔符后台页面显示订单金额数据结构里是10000页面却显示10,000。很多人第一反应是数据库里的值有问题实际上这是FreeMarker的数字格式化规则在起作用。解决办法在前文配置里提到过设置settings: number_format: 0.##########如果你不想动全局配置也可以在具体位置用?string(0)强制格式化${price?string(0)}这个坑在金额、ID这类需要在页面原样展示的数字上特别常见。6.3 日期显示成了时间戳或乱码模板里直接输出Date对象往往得到一长串看不懂的数字或者格式完全不对。因为FreeMarker对Date类型的处理依赖对象的java.util.Date类型如果后端传的是LocalDateTime直接输出时会出错。解决办法是在模板里显式指定格式${createTime?string(yyyy-MM-dd HH:mm:ss)}如果是LocalDateTime类型建议在Java端先转成Date或者直接格式化为字符串再放入数据模型。我见过不少团队为了省事统一在VO/DTO里把日期字段格式化为String再传给模板这个方案虽然不算优雅但确实能少踩很多类型转换的坑。6.4 修改模板不生效一直显示旧页面这个问题十有八九是模板缓存没关。SpringBoot默认在生产模式下会缓存模板内容。如果你在开发环境没设置cache: false改完.ftl文件刷新页面看到的还是上一次渲染的结果。排查思路分两步先看application.yml里有没有设置cache: false再看IDE里target目录下有没有生成旧的模板副本。有时候你明明改了src/main/resources/templates下的文件但编译时target目录里的旧文件没被覆盖也会导致页面不更新。6.5 include路径找不到文件include和import路径写错时异常信息会提示模板文件不存在。这里要注意路径是相对template-loader-path配置的比如templates/common/header.ftl在模板里写#include /common/header.ftl路径最开头的斜杠表示从模板根目录开始算不加斜杠则是相对于当前模板文件所在目录。这个规则经常被忽略文件一多就容易混乱。6.6 模板里的逻辑写得太重性能下降有些同事习惯把复杂计算也放进模板比如在#list循环里嵌套调用很多内建函数甚至用宏做递归。模板引擎毕竟不是编程语言运行时逻辑太复杂会拖慢渲染速度。我的建议是模板只做展示和简单判断凡是涉及集合过滤、排序、统计的逻辑一律在Java层算好后再传入模板。这也是FreeMarker的设计初衷保持模板简单利于维护也利于性能。写在最后的实践建议FreeMarker这东西你说它难语法一天就能看完你说它简单真上手做项目又会踩出一堆坑。我见过不少人学完语法就丢到一边直到接手老项目才被迫捡回来。我的经验是与其等踩坑再去翻文档不如主动把它当成一个“通用文本生成工具”来用。比如你下次需要生成一堆重复的类文件、写定时任务导出报表、甚至生成运维脚本的时候都可以想一下这个场景用模板是不是比字符串拼接更优雅一旦建立起这种思维FreeMarker就不再是一个单纯的Web页面技术而是一个能随时拿出来提升效率的工具。最后说一个我自己常干的蠢事在模板里忘了写#-- 注释 --而是写了HTML注释 结果把调试信息泄漏到了生成的文件里。所以模板里的调试信息一定要用FreeMarker注释千万别用HTML注释。这算是和其他人一样容易忽略的小细节但真的值得记下来。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →