Skip to content

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>&copy; 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}">&laquo; 上一页</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}">下一页 &raquo;</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:表单数据绑定失败

常见原因:

  1. Model 属性名与表单字段不匹配
  2. 缺少默认构造函数
  3. 字段类型不匹配

解决方案:

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 的视图技术,提供了强大而灵活的模板功能:

  1. 分离关注点 - 清晰分离视图逻辑和业务逻辑
  2. 丰富的表单支持 - Spring 集成提供完整的表单处理能力
  3. 安全性 - 内置 HTML 转义和 XSS 防护
  4. 可扩展性 - 支持自定义宏和国际化
  5. 高性能 - 模板缓存和优化机制

NOTE

虽然现代前后端分离架构中,传统的服务端模板引擎使用频率降低,但在某些场景下(如 SEO 要求高的网站、内部管理系统等),FreeMarker 仍然是一个优秀的选择。

掌握 FreeMarker 不仅能帮助你构建传统的 Web 应用,更能让你深入理解 MVC 架构中视图层的设计思想! 🚀