Appearance
Spring MVC 中的 FreeMarker 模板引擎详解 🎨
引言:为什么需要模板引擎? 🤔
在 Web 开发中,我们经常需要生成动态的 HTML 页面。想象一下,如果没有模板引擎,我们只能这样写代码:
kotlin
// 没有模板引擎的痛苦写法 😵
@GetMapping("/user")
fun showUser(@RequestParam id: Long): String {
val user = userService.findById(id)
return """
<html>
<body>
<h1>用户信息</h1>
<p>姓名: ${user.name}</p>
<p>邮箱: ${user.email}</p>
</body>
</html>
""".trimIndent()
}
WARNING
上面的代码存在严重问题:HTML 代码和业务逻辑混合在一起,难以维护,也无法进行复杂的页面设计。
FreeMarker 的价值在于:它将视图逻辑与业务逻辑完全分离,让前端开发者专注于页面设计,后端开发者专注于数据处理。
FreeMarker 核心概念 📚
什么是 FreeMarker?
FreeMarker 是一个基于模板的文本生成引擎,它可以:
- 🎯 分离关注点:将数据处理和页面展示完全分离
- 🔄 模板复用:通过宏和包含机制实现代码复用
- 🛡️ 安全性:内置 HTML 转义,防止 XSS 攻击
- 🌐 多格式支持:不仅限于 HTML,还可以生成 XML、邮件等
Spring MVC 中的 FreeMarker 配置 ⚙️
基础配置
kotlin
@Configuration
@EnableWebMvc
class WebConfig : WebMvcConfigurer {
// 配置视图解析器
override fun configureViewResolvers(registry: ViewResolverRegistry) {
registry.freeMarker()
}
// 配置 FreeMarker 引擎
@Bean
fun freeMarkerConfigurer() = FreeMarkerConfigurer().apply {
setTemplateLoaderPath("/WEB-INF/freemarker")
setDefaultCharset(StandardCharsets.UTF_8)
// 设置 FreeMarker 配置
freemarkerSettings = Properties().apply {
setProperty("number_format", "0.######")
setProperty("date_format", "yyyy-MM-dd")
setProperty("time_format", "HH:mm:ss")
}
}
}
kotlin
@Configuration
class LegacyWebConfig {
@Bean
fun freeMarkerConfigurer(): FreeMarkerConfigurer {
val configurer = FreeMarkerConfigurer()
configurer.templateLoaderPath = "/WEB-INF/freemarker"
configurer.defaultEncoding = "UTF-8"
return configurer
}
@Bean
fun freeMarkerViewResolver(): FreeMarkerViewResolver {
val resolver = FreeMarkerViewResolver()
resolver.setPrefix("")
resolver.setSuffix(".ftl")
resolver.setContentType("text/html;charset=UTF-8")
return resolver
}
}
TIP
推荐使用现代化的配置方式,它更简洁且功能更强大。
目录结构说明
src/main/webapp/WEB-INF/freemarker/
├── common/
│ ├── header.ftl # 公共头部
│ ├── footer.ftl # 公共底部
│ └── layout.ftl # 布局模板
├── user/
│ ├── list.ftl # 用户列表页
│ ├── detail.ftl # 用户详情页
│ └── form.ftl # 用户表单页
└── error/
└── 404.ftl # 错误页面
实战应用:用户管理系统 🚀
Controller 层
kotlin
@Controller
@RequestMapping("/users")
class UserController(
private val userService: UserService
) {
// 显示用户列表
@GetMapping
fun listUsers(model: Model): String {
val users = userService.findAll()
model.addAttribute("users", users)
model.addAttribute("title", "用户管理")
return "user/list" // 对应 /WEB-INF/freemarker/user/list.ftl
}
// 显示用户详情
@GetMapping("/{id}")
fun showUser(@PathVariable id: Long, model: Model): String {
val user = userService.findById(id)
model.addAttribute("user", user)
return "user/detail"
}
// 显示用户表单
@GetMapping("/new")
fun newUserForm(model: Model): String {
model.addAttribute("user", User())
model.addAttribute("cities", getCityOptions())
return "user/form"
}
// 处理表单提交
@PostMapping
fun saveUser(@ModelAttribute user: User,
bindingResult: BindingResult,
model: Model): String {
if (bindingResult.hasErrors()) {
model.addAttribute("cities", getCityOptions())
return "user/form"
}
userService.save(user)
return "redirect:/users"
}
private fun getCityOptions(): Map<String, String> {
return linkedMapOf(
"BJ" to "北京",
"SH" to "上海",
"GZ" to "广州",
"SZ" to "深圳"
)
}
}
数据模型
kotlin
data class User(
var id: Long? = null,
var name: String = "",
var email: String = "",
var city: String = "",
var age: Int = 0,
var active: Boolean = true,
var createTime: LocalDateTime = LocalDateTime.now()
)
FreeMarker 模板详解 📝
用户列表模板
user/list.ftl
html
<#-- 导入 Spring 宏 -->
<#import "/spring.ftl" as spring/>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${title!"用户管理"}</title>
<style>
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
.btn { padding: 5px 10px; margin: 2px; text-decoration: none; }
.btn-primary { background-color: #007bff; color: white; }
.btn-danger { background-color: #dc3545; color: white; }
</style>
</head>
<body>
<h1>${title!"用户管理"}</h1>
<a href="<@spring.url '/users/new'/>" class="btn btn-primary">新增用户</a>
<#if users?? && users?size > 0>
<table>
<thead>
<tr>
<th>ID</th>
<th>姓名</th>
<th>邮箱</th>
<th>城市</th>
<th>年龄</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<#list users as user>
<tr>
<td>${user.id}</td>
<td>${user.name?html}</td>
<td>${user.email?html}</td>
<td>${user.city}</td>
<td>${user.age}</td>
<td>
<#if user.active>
<span style="color: green;">激活</span>
<#else>
<span style="color: red;">禁用</span>
</#if>
</td>
<td>${user.createTime?string("yyyy-MM-dd HH:mm")}</td>
<td>
<a href="<@spring.url '/users/${user.id}'/>" class="btn btn-primary">查看</a>
<a href="<@spring.url '/users/${user.id}/edit'/>" class="btn">编辑</a>
<a href="<@spring.url '/users/${user.id}/delete'/>"
class="btn btn-danger"
onclick="return confirm('确定要删除吗?')">删除</a>
</td>
</tr>
</#list>
</tbody>
</table>
<#else>
<p>暂无用户数据</p>
</#if>
</body>
</html>
用户表单模板(重点:表单处理)
user/form.ftl
html
<#-- 导入 Spring 宏 -->
<#import "/spring.ftl" as spring/>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>用户表单</title>
<style>
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input, select, textarea { width: 100%; padding: 8px; border: 1px solid #ddd; }
.error { color: red; font-size: 12px; }
.btn { padding: 10px 20px; margin: 5px; }
.btn-primary { background-color: #007bff; color: white; border: none; }
.btn-secondary { background-color: #6c757d; color: white; border: none; }
</style>
</head>
<body>
<h1>${(user.id??)?then("编辑用户", "新增用户")}</h1>
<form action="<@spring.url '/users'/>" method="post">
<#if user.id??>
<input type="hidden" name="id" value="${user.id}"/>
<input type="hidden" name="_method" value="PUT"/>
</#if>
<!-- 姓名字段 -->
<div class="form-group">
<@spring.bind "user.name"/>
<label for="name">姓名 *</label>
<@spring.formInput "user.name" 'id="name" required'/>
<@spring.showErrors "<br/>" "error"/>
</div>
<!-- 邮箱字段 -->
<div class="form-group">
<@spring.bind "user.email"/>
<label for="email">邮箱 *</label>
<@spring.formInput "user.email" 'id="email" type="email" required'/>
<@spring.showErrors "<br/>" "error"/>
</div>
<!-- 城市选择 -->
<div class="form-group">
<@spring.bind "user.city"/>
<label for="city">城市</label>
<@spring.formSingleSelect "user.city" cities 'id="city"'/>
<@spring.showErrors "<br/>" "error"/>
</div>
<!-- 年龄字段 -->
<div class="form-group">
<@spring.bind "user.age"/>
<label for="age">年龄</label>
<@spring.formInput "user.age" 'id="age" type="number" min="0" max="120"'/>
<@spring.showErrors "<br/>" "error"/>
</div>
<!-- 状态复选框 -->
<div class="form-group">
<@spring.bind "user.active"/>
<@spring.formCheckbox "user.active" 'id="active"'/>
<label for="active" style="display: inline; margin-left: 5px;">激活状态</label>
<@spring.showErrors "<br/>" "error"/>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">保存</button>
<a href="<@spring.url '/users'/>" class="btn btn-secondary">取消</a>
</div>
</form>
</body>
</html>
Spring 表单宏详解 🔧
核心表单宏说明
宏名称 | 用途 | 示例 |
---|---|---|
@spring.bind | 绑定字段,建立数据绑定上下文 | <@spring.bind "user.name"/> |
@spring.formInput | 生成 input 输入框 | <@spring.formInput "user.name" 'class="form-control"'/> |
@spring.formTextarea | 生成 textarea 文本域 | <@spring.formTextarea "user.description" 'rows="5"'/> |
@spring.formSingleSelect | 生成下拉选择框 | <@spring.formSingleSelect "user.city" cities/> |
@spring.formCheckbox | 生成复选框 | <@spring.formCheckbox "user.active"/> |
@spring.formRadioButtons | 生成单选按钮组 | <@spring.formRadioButtons "user.gender" genders "<br/>"/> |
@spring.showErrors | 显示验证错误信息 | <@spring.showErrors "<br/>" "error"/> |
表单处理流程图
高级特性与最佳实践 ✨
1. 模板继承与布局
common/layout.ftl
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title><#if title??>${title} - </#if>我的应用</title>
<link rel="stylesheet" href="/css/common.css">
<#-- 允许子模板添加额外的 CSS -->
<#if extraCss??>
<#list extraCss as css>
<link rel="stylesheet" href="${css}">
</#list>
</#if>
</head>
<body>
<header>
<nav>
<a href="<@spring.url '/'/>">首页</a>
<a href="<@spring.url '/users'/>">用户管理</a>
</nav>
</header>
<main>
<#-- 子模板内容插入点 -->
<#nested>
</main>
<footer>
<p>© 2024 我的应用. All rights reserved.</p>
</footer>
<#-- 允许子模板添加额外的 JS -->
<#if extraJs??>
<#list extraJs as js>
<script src="${js}"></script>
</#list>
</#if>
</body>
</html>
2. 自定义宏定义
common/macros.ftl
html
<#-- 分页宏 -->
<#macro pagination currentPage totalPages baseUrl>
<#if totalPages gt 1>
<div class="pagination">
<#if currentPage gt 1>
<a href="${baseUrl}?page=${currentPage-1}">« 上一页</a>
</#if>
<#list 1..totalPages as page>
<#if page == currentPage>
<span class="current">${page}</span>
<#else>
<a href="${baseUrl}?page=${page}">${page}</a>
</#if>
</#list>
<#if currentPage lt totalPages>
<a href="${baseUrl}?page=${currentPage+1}">下一页 »</a>
</#if>
</div>
</#if>
</#macro>
<#-- 状态标签宏 -->
<#macro statusBadge status>
<#switch status>
<#case "ACTIVE">
<span class="badge badge-success">激活</span>
<#break>
<#case "INACTIVE">
<span class="badge badge-secondary">禁用</span>
<#break>
<#case "PENDING">
<span class="badge badge-warning">待审核</span>
<#break>
<#default>
<span class="badge badge-light">未知</span>
</#switch>
</#macro>
3. 国际化支持
kotlin
// Controller 中添加国际化消息
@GetMapping
fun listUsers(model: Model, locale: Locale): String {
model.addAttribute("users", userService.findAll())
model.addAttribute("title", messageSource.getMessage("user.list.title", null, locale))
return "user/list"
}
html
<!-- 模板中使用国际化 -->
<h1><@spring.message "user.list.title"/></h1>
<@spring.message code="user.form.name.label" text="姓名"/>
4. 安全性考虑
IMPORTANT
FreeMarker 的安全特性
html
<!-- 自动转义(推荐) -->
<p>用户输入:${user.description?html}</p>
<!-- 手动控制转义 -->
<#assign htmlEscape = true>
<@spring.formInput "user.name"/>
<#assign htmlEscape = false>
<!-- 此处不转义,需要确保数据安全 -->
html
<!-- 危险:直接输出用户输入 -->
<div>${userInput}</div>
<!-- 安全:转义用户输入 -->
<div>${userInput?html}</div>
<!-- 安全:使用 Spring 表单宏 -->
<@spring.formInput "user.description"/>
常见问题与解决方案 🔧
问题 1:模板找不到
错误信息
Template not found for name "/user/list.ftl"
解决方案:
kotlin
@Bean
fun freeMarkerConfigurer() = FreeMarkerConfigurer().apply {
setTemplateLoaderPath("/WEB-INF/freemarker")
// 确保路径正确,不要遗漏斜杠
}
问题 2:中文乱码
解决方案:
kotlin
@Bean
fun freeMarkerConfigurer() = FreeMarkerConfigurer().apply {
setTemplateLoaderPath("/WEB-INF/freemarker")
setDefaultCharset(StandardCharsets.UTF_8)
freemarkerSettings = Properties().apply {
setProperty("output_encoding", "UTF-8")
setProperty("url_escaping_charset", "UTF-8")
}
}
问题 3:表单数据绑定失败
常见原因:
- Model 属性名与表单字段不匹配
- 缺少默认构造函数
- 字段类型不匹配
解决方案:
kotlin
// 确保数据类有默认构造函数
data class User(
var id: Long? = null,
var name: String = "",
var email: String = ""
)
// Controller 中确保属性名一致
@GetMapping("/new")
fun newUserForm(model: Model): String {
model.addAttribute("user", User())
return "user/form"
}
性能优化建议 ⚡
1. 模板缓存配置
kotlin
@Bean
fun freeMarkerConfigurer() = FreeMarkerConfigurer().apply {
setTemplateLoaderPath("/WEB-INF/freemarker")
setDefaultCharset(StandardCharsets.UTF_8)
freemarkerSettings = Properties().apply {
// 生产环境启用缓存
setProperty("template_update_delay", "3600")
setProperty("cache_storage", "freemarker.cache.MruCacheStorage")
}
}
2. 减少模板复杂度
TIP
性能优化技巧
- ✅ 使用
<#if>
条件判断减少不必要的处理 - ✅ 合理使用
<#assign>
缓存复杂计算结果 - ✅ 避免在循环中进行复杂操作
- ❌ 避免深层嵌套的模板包含
总结 🎯
FreeMarker 作为 Spring MVC 的视图技术,提供了强大而灵活的模板功能:
- 分离关注点 - 清晰分离视图逻辑和业务逻辑
- 丰富的表单支持 - Spring 集成提供完整的表单处理能力
- 安全性 - 内置 HTML 转义和 XSS 防护
- 可扩展性 - 支持自定义宏和国际化
- 高性能 - 模板缓存和优化机制
NOTE
虽然现代前后端分离架构中,传统的服务端模板引擎使用频率降低,但在某些场景下(如 SEO 要求高的网站、内部管理系统等),FreeMarker 仍然是一个优秀的选择。
掌握 FreeMarker 不仅能帮助你构建传统的 Web 应用,更能让你深入理解 MVC 架构中视图层的设计思想! 🚀