将 FreeMarker 模板引擎集成到 Spring Boot 应用中是构建动态 Web 应用的常见选择。Spring Boot 提供了强大的自动配置功能,极大地简化了集成过程。本指南将深入探讨 Spring Boot 集成 FreeMarker 的方方面面,涵盖核心概念、详细操作步骤、常见错误、注意事项、使用技巧以及最佳实践与性能优化。


1. 核心概念

  • spring-boot-starter-freemarker: Spring Boot 提供的启动器依赖,它包含了 freemarker 库以及 Spring 对 FreeMarker 的集成支持(spring-context-support)。添加此依赖是集成的第一步。
  • 自动配置 (FreeMarkerAutoConfiguration): Spring Boot 的核心特性。当检测到 freemarker 类在类路径上时,FreeMarkerAutoConfiguration 会自动配置一个 FreeMarkerConfigurer 和一个 FreeMarkerViewResolver
  • FreeMarkerConfigurer: Spring 的 FactoryBean,负责创建和配置 FreeMarker 的核心 Configuration 对象。它读取 application.properties/application.yml 中的配置,并应用 Spring 特定的设置(如模板加载路径、默认编码、共享变量等)。
  • FreeMarkerViewResolver: Spring MVC 的 ViewResolver 实现,负责将控制器返回的逻辑视图名称(如 "home")解析为实际的 FreeMarker 模板文件(如 classpath:/templates/home.ftl),并创建 FreeMarkerView 来渲染视图。
  • FreeMarkerView: Spring MVC 的 View 实现,它使用 FreeMarkerConfigurer 提供的 Configuration 来获取 Template 对象,并将数据模型(Model)传递给 FreeMarker 引擎进行渲染。
  • @Controller: Spring MVC 注解,标记一个类为 Web 控制器,处理 HTTP 请求。
  • Model / ModelMap / ModelAndView: Spring MVC 用于在控制器和视图之间传递数据的接口/类。控制器将数据放入 Model,FreeMarker 视图通过 ${variableName} 访问这些数据。
  • 模板位置 (spring.freemarker.template-loader-path): 配置 FreeMarker 从哪里加载模板文件。默认是 classpath:/templates/
  • 模板后缀 (spring.freemarker.suffix): 配置模板文件的后缀。默认是 .ftl。控制器返回的视图名 "home" 会被解析为 home.ftl
  • 共享变量 (Shared Variables):Configuration 级别定义的变量,可以在所有模板中直接访问,无需在每个 Model 中重复添加。常用于全局配置、工具函数、常量等。
  • @Bean 配置: 可以通过定义 @Bean 方法来覆盖或扩展自动配置的 FreeMarkerConfigurerFreeMarkerViewResolver,实现更精细的控制。

2. 操作步骤 (非常详细)

以下是 Spring Boot 集成 FreeMarker 的详细步骤:

步骤 1: 添加依赖 (Maven)

pom.xml 文件中添加 spring-boot-starter-freemarker 依赖。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <!-- ... 其他配置 ... -->
    <dependencies>
        <!-- Spring Boot Web Starter (包含 Spring MVC) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring Boot FreeMarker Starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
        </dependency>

        <!-- Lombok (可选,用于简化 POJO) -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>

        <!-- Spring Boot Test Starter (可选) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <!-- ... 其他配置 ... -->
</project>

步骤 2: 配置 FreeMarker (可选,使用默认值也可工作)

src/main/resources/application.yml (或 application.properties) 中配置 FreeMarker。

# application.yml
spring:
  # FreeMarker 配置
  freemarker:
    # 模板文件的加载路径 (classpath: 前缀可省略,但建议保留)
    template-loader-path: classpath:/templates/
    # 模板文件的后缀
    suffix: .ftl
    # 内容类型 (MIME type)
    content-type: text/html
    # 默认编码
    charset: UTF-8
    # 是否启用缓存 (生产环境建议开启)
    cache: true
    # 模板更新延迟 (毫秒) - 0 表示每次检查 (开发), 正数表示延迟检查 (生产)
    # 开发环境设为 0,生产环境设为 60000 (1分钟) 或更高
    # 注意: cache: true 时,此设置才生效
    template-update-delay: 0ms # 开发环境
    # template-update-delay: 60000ms # 生产环境

    # 设置日志异常处理器 (便于调试)
    # template-exception-handler: rethrow
    # template-exception-handler: html_debug # 开发环境友好

    # 设置模板的默认编码
    settings:
      # 这里可以设置 FreeMarker Configuration 的原生属性
      # 例如,设置 number_format
      number_format: '0.##'
      # boolean_format (true/false -> yes/no)
      # boolean_format: yes,no
      # locale (影响日期/数字格式化)
      # locale: zh_CN

    # 额外的设置 (等同于 settings)
    # settings:
    #   classic_compatible: true

  # Web 服务器配置 (可选)
  # web:
  #   resources:
  #     static-locations: classpath:/static/,classpath:/public/ # 静态资源路径

# 自定义配置示例 (可选)
app:
  title: "My Spring Boot App"
  version: "1.0.0"

application.properties 等效配置:

# application.properties
spring.freemarker.template-loader-path=classpath:/templates/
spring.freemarker.suffix=.ftl
spring.freemarker.content-type=text/html
spring.freemarker.charset=UTF-8
spring.freemarker.cache=true
# 开发环境
spring.freemarker.template-update-delay=0ms
# 生产环境
# spring.freemarker.template-update-delay=60000ms

# FreeMarker 原生设置
spring.freemarker.settings.number_format=0.##
# spring.freemarker.settings.boolean_format=yes,no
# spring.freemarker.settings.locale=zh_CN

app.title=My Spring Boot App
app.version=1.0.0

步骤 3: 创建数据模型 (POJO)

创建用于在控制器和视图之间传递数据的 Java 类。

// src/main/java/com/example/demo/model/User.java
package com.example.demo.model;

import lombok.Data;

@Data // Lombok 注解,生成 getter, setter, toString, equals, hashCode
public class User {
    private Long id;
    private String username;
    private String email;
    private String fullName;
}

步骤 4: 创建控制器 (@Controller)

创建处理 HTTP 请求并返回视图名称的控制器。

// src/main/java/com/example/demo/controller/HomeController.java
package com.example.demo.controller;

import com.example.demo.model.User;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import java.util.Arrays;
import java.util.Date;
import java.util.List;

@Controller // 标记为 Spring MVC 控制器
public class HomeController {

    /**
     * 处理根路径 "/" 的 GET 请求
     * @param model 用于向视图传递数据
     * @return 逻辑视图名称 "home"
     */
    @GetMapping("/")
    public String home(Model model) {
        // 添加单个对象到模型
        User currentUser = new User();
        currentUser.setId(1L);
        currentUser.setUsername("john_doe");
        currentUser.setEmail("john.doe@example.com");
        currentUser.setFullName("John Doe");
        model.addAttribute("user", currentUser);

        // 添加列表到模型
        List<User> recentUsers = Arrays.asList(
            new User() {{ setId(2L); setUsername("jane_smith"); setFullName("Jane Smith"); }},
            new User() {{ setId(3L); setUsername("bob_johnson"); setFullName("Bob Johnson"); }}
        );
        model.addAttribute("recentUsers", recentUsers);

        // 添加基本类型和日期
        model.addAttribute("pageTitle", "Welcome Page");
        model.addAttribute("currentDate", new Date());

        // 返回逻辑视图名,FreeMarkerViewResolver 会解析为 templates/home.ftl
        return "home";
    }

    /**
     * 处理 "/about" 路径的 GET 请求
     */
    @GetMapping("/about")
    public String about(Model model) {
        model.addAttribute("title", "About Us");
        // 返回 templates/about.ftl
        return "about";
    }
}

步骤 5: 创建 FreeMarker 模板

src/main/resources/templates/ 目录下创建 .ftl 文件。

  1. 创建基础布局模板 (templates/layout/base.ftl):

    <#-- templates/layout/base.ftl -->
    <#macro layout title="Default Title" extraHead="">
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>${title} - ${appTitle!}</title>
        <!-- Bootstrap CSS (假设已下载或使用 CDN) -->
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
        <!-- 页面特定的额外 head 内容 -->
        ${extraHead!}
    </head>
    <body>
        <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
            <div class="container">
                <a class="navbar-brand" href="/">My App</a>
                <div class="navbar-nav">
                    <a class="nav-link" href="/">Home</a>
                    <a class="nav-link" href="/about">About</a>
                </div>
            </div>
        </nav>
    
        <main class="container mt-4">
            <#-- 主要内容区域 -->
            <#nested>
        </main>
    
        <footer class="bg-dark text-white text-center py-3 mt-5">
            &copy; ${.now?string("yyyy")} ${appTitle!} v${appVersion!}. All rights reserved.
        </footer>
    
        <!-- Bootstrap JS (可选) -->
        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
    </body>
    </html>
    </#macro>
    
  2. 创建首页模板 (templates/home.ftl):

    <#-- 导入基础布局宏 -->
    <#import "layout/base.ftl" as layout>
    <#-- 使用 app 配置 (来自 application.yml) -->
    <#assign appTitle = app.title>
    <#assign appVersion = app.version>
    
    <@layout.layout title=pageTitle extraHead="">
        <h1 class="mb-4">${pageTitle}</h1>
    
        <div class="row">
            <div class="col-md-6">
                <div class="card">
                    <div class="card-body">
                        <h5 class="card-title">Current User</h5>
                        <p><strong>ID:</strong> ${user.id}</p>
                        <p><strong>Username:</strong> ${user.username}</p>
                        <p><strong>Email:</strong> ${user.email}</p>
                        <p><strong>Full Name:</strong> ${user.fullName}</p>
                        <p><strong>Joined:</strong> ${currentDate?string.medium} at ${currentDate?string.time}</p>
                    </div>
                </div>
            </div>
            <div class="col-md-6">
                <div class="card">
                    <div class="card-body">
                        <h5 class="card-title">Recent Users</h5>
                        <ul class="list-group">
                            <#list recentUsers as u>
                                <li class="list-group-item d-flex justify-content-between align-items-center">
                                    ${u.fullName} (<em>${u.username}</em>)
                                    <span class="badge bg-primary rounded-pill">${u.id}</span>
                                </li>
                            </#list>
                        </ul>
                    </div>
                </div>
            </div>
        </div>
    </</@layout.layout>
    
  3. 创建关于页模板 (templates/about.ftl):

    <#import "layout/base.ftl" as layout>
    <#assign appTitle = app.title>
    
    <@layout.layout title=title>
        <h1>About Us</h1>
        <p>This is the about page of <strong>${appTitle!}</strong>.</p>
        <p>We are building awesome applications with Spring Boot and FreeMarker!</p>
    </</@layout.layout>
    

步骤 6: 配置共享变量 (可选但推荐)

将应用配置等全局数据作为共享变量注入 Configuration,使其在所有模板中可用。

// src/main/java/com/example/demo/config/FreeMarkerConfig.java
package com.example.demo.config;

import freemarker.template.TemplateException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class FreeMarkerConfig {

    @Value("${app.title}")
    private String appTitle;

    @Value("${app.version}")
    private String appVersion;

    /**
     * 配置 FreeMarkerConfigurer,可以覆盖自动配置或添加共享变量
     * @return FreeMarkerConfigurer
     */
    @Bean
    public FreeMarkerConfigurer freeMarkerConfigurer() {
        FreeMarkerConfigurer configurer = new FreeMarkerConfigurer();
        // 可以在这里设置 templateLoaderPath, defaultEncoding 等
        // configurer.setTemplateLoaderPath("classpath:/my-templates/");
        // configurer.setDefaultEncoding("UTF-8");

        // 添加共享变量 (可在所有模板中直接使用,如 ${appTitle})
        Map<String, Object> sharedVars = new HashMap<>();
        sharedVars.put("appTitle", appTitle);
        sharedVars.put("appVersion", appVersion);
        // 可以添加工具类实例 (需要确保线程安全)
        // sharedVars.put("dateUtil", new DateUtil());
        configurer.setFreemarkerVariables(sharedVars);

        // 可以设置 FreeMarker 原生 Configuration 的 Settings
        // Map<String, Object> settings = new HashMap<>();
        // settings.put("number_format", "0.##");
        // configurer.setFreemarkerSettings(settings);

        return configurer;
    }

    // 注意: 通常不需要手动配置 FreeMarkerViewResolver,自动配置已足够。
    // 如果需要自定义 (如 viewNames, order),可以定义此 Bean
    /*
    @Bean
    public FreeMarkerViewResolver freeMarkerViewResolver() {
        FreeMarkerViewResolver resolver = new FreeMarkerViewResolver();
        resolver.setPrefix(""); // 通常与 template-loader-path 结合使用
        resolver.setSuffix(".ftl");
        resolver.setContentType("text/html; charset=UTF-8");
        resolver.setCache(true);
        resolver.setOrder(1); // 视图解析器顺序
        return resolver;
    }
    */
}

注意:FreeMarkerConfig 类中,我们通过 @Value 注解读取 application.yml 中的 app.titleapp.version,然后在 freeMarkerConfigurer() 方法中将它们作为共享变量 (freemarkerVariables) 添加。这样,在任何 .ftl 模板中都可以直接使用 ${appTitle}${appVersion},无需在每个控制器的 Model 中重复添加。

步骤 7: 启动应用并测试

  1. 确保 DemoApplication.java (主类) 位于正确的包下并有 @SpringBootApplication
    // src/main/java/com/example/demo/DemoApplication.java
    package com.example.demo;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    
    @SpringBootApplication
    public class DemoApplication {
        public static void main(String[] args) {
            SpringApplication.run(DemoApplication.class, args);
        }
    }
    
  2. 运行 DemoApplication.main() 方法启动应用。
  3. 打开浏览器,访问 http://localhost:8080/,应看到首页。
  4. 访问 http://localhost:8080/about,应看到关于页。

3. 常见错误

  1. Whitelabel Error Page / Template not found:

    • 原因: FreeMarker 模板文件不存在或路径错误。控制器返回的视图名 "home" 无法解析为 templates/home.ftl
    • 解决: 检查 templates/ 目录下是否存在 home.ftl 文件。检查 spring.freemarker.template-loader-path 配置是否正确。确认文件后缀是否匹配 spring.freemarker.suffix
  2. ClassNotFound / NoClassDefFoundError for FreeMarker classes:

    • 原因: spring-boot-starter-freemarker 依赖未正确添加或未下载。
    • 解决: 检查 pom.xml,确保依赖正确。执行 mvn clean compile 或刷新 Maven 项目。
  3. 共享变量 (appTitle) 无法访问:

    • 原因: FreeMarkerConfig 类未被 Spring 扫描到(包路径问题)。@Configuration 注解缺失。@Value 注解的属性名与 application.yml 不匹配。
    • 解决: 确保 FreeMarkerConfig.java 在主应用类 (DemoApplication) 的包或其子包内。检查 @Configuration@Value 注解。检查 application.yml 中的属性名拼写。
  4. Model 中的数据在模板中显示为 null 或未定义:

    • 原因: 控制器中 model.addAttribute("key", value)key 与模板中 ${key} 的名称不匹配。传递的对象属性名错误。
    • 解决: 仔细核对键名。使用 Lombok 的 @Data 确保 getter 方法存在。在模板中使用 ?? 操作符检查变量是否存在:<#if user??>${user.username}</#if>
  5. 中文乱码:

    • 原因: 编码配置不一致。spring.freemarker.charsetspring.freemarker.settings.output_encoding、HTTP 响应头、HTML <meta charset>、文件本身编码不统一。
    • 解决: 确保 application.ymlspring.freemarker.charset=UTF-8。确保 .ftl 文件本身以 UTF-8 编码保存(IDE 设置)。检查 FreeMarkerViewResolvercontentType 是否包含 charset=UTF-8(自动配置通常已设置)。
  6. @Bean freeMarkerConfigurer() 未生效:

    • 原因: Spring Boot 的自动配置 FreeMarkerAutoConfiguration 可能会覆盖你的自定义 @Bean。通常是因为条件不满足或顺序问题。
    • 解决: Spring Boot 的自动配置设计为可被用户自定义 @Bean 覆盖。确保你的 @Bean 方法名是 freeMarkerConfigurer。如果仍有问题,可以尝试使用 @Primary 注解(不推荐,除非必要)或检查是否有其他配置冲突。

4. 注意事项

  1. 自动配置优先: Spring Boot 的自动配置非常强大。在大多数情况下,只需添加依赖和基本配置即可工作。优先使用 application.yml 配置,而不是过早地编写自定义 @Bean
  2. @Controller vs @RestController: 使用 @Controller 返回视图名称。@RestController@Controller + @ResponseBody,用于返回 JSON/XML 数据,不适用于返回 FreeMarker 模板。
  3. 模板路径: spring.freemarker.template-loader-path 默认是 classpath:/templates/。路径必须以 / 结尾。#import#include 中的相对路径是相对于当前模板文件的。
  4. 共享变量线程安全: 注入到 Configuration 的共享变量(如工具类实例)必须是线程安全的,因为 Configuration 是单例,会被多个请求线程共享。
  5. 开发环境热重载: 结合 spring-boot-devtools,修改 .ftl 文件后应用会自动重启(或使用 LiveReload),修改会立即生效。确保 spring.freemarker.cache=falsetemplate-update-delay=0
  6. 静态资源: CSS, JS, 图片等静态资源放在 src/main/resources/static/ 目录下,可通过 / 直接访问(如 /css/style.css)。
  7. FreeMarkerViewResolver 顺序: 如果应用中存在多个 ViewResolver(如 Thymeleaf),可以通过 setOrder() 设置优先级。数字越小,优先级越高。

5. 使用技巧

  1. 利用 @Value 注解: 在控制器或服务中,使用 @Value("${property.name}") 直接注入配置属性,减少 Model 中的重复添加。

    @Controller
    public class MyController {
        @Value("${app.title}")
        private String appTitle; // 注入配置
    
        @GetMapping("/page")
        public String page(Model model) {
            model.addAttribute("appTitle", appTitle); // 仍需手动添加到 Model
            // ... 其他逻辑
            return "page";
        }
    }
    

    注意:这仍需手动添加到 Model。共享变量是更好的全局方案。

  2. 创建自定义宏库: 将常用的 UI 组件(按钮、表单、卡片)定义为宏,放在 templates/macros/ 目录下,并在 FreeMarkerConfig 中作为共享变量导入或在基础布局中统一 #import

  3. 使用 #attempt/#recover: 在模板中处理可能出错的操作(如访问可能为 null 的对象属性)。

    <#attempt>
        <p>User's bio: ${user.bio?html}</p>
    <#recover>
        <p>User has no bio.</p>
    </#attempt>
    
  4. 集成 Spring Security (可选): 如果使用 spring-boot-starter-security,可以集成 thymeleaf-extras-springsecurity6 的 FreeMarker 等效库(可能需要自定义标签或使用其他方式处理权限)。

  5. 单元测试: 使用 @WebMvcTest 注解测试控制器。

    @WebMvcTest(HomeController.class)
    class HomeControllerTest {
        @Autowired
        private MockMvc mockMvc;
    
        @Test
        void shouldReturnHomePage() throws Exception {
            mockMvc.perform(get("/"))
                   .andExpect(status().isOk())
                   .andExpect(view().name("home"))
                   .andExpect(model().attributeExists("user", "recentUsers"));
        }
    }
    

6. 最佳实践与性能优化

  1. 最佳实践:

    • 遵循 MVC 模式: 控制器处理请求和业务逻辑,准备数据模型;模板负责展示。
    • 模板逻辑最小化: 避免在模板中进行复杂计算或业务逻辑。在控制器或服务层处理好数据,传递给模板的是“准备好”的数据。
    • 使用共享变量: 将全局配置、常量、线程安全的工具函数作为共享变量注入,避免在每个控制器中重复添加。
    • 模块化模板: 使用 #import#include#macro 实现模板分离与模块化(参考前一指南)。
    • 清晰的命名: 视图名、模型属性名、宏名、文件名都应清晰、一致。
    • 配置文件化: 将 FreeMarker 配置(如 template-update-delay)放在 application-{profile}.yml 中,实现不同环境(dev/prod)的差异化配置。
    • 利用 IDE 支持: 使用支持 FreeMarker 的 IDE(如 IntelliJ IDEA Ultimate)获得语法高亮、自动补全和错误检查。
  2. 性能优化:

    • 启用缓存 (cache: true): 生产环境中务必设置 spring.freemarker.cache=true 和一个合理的 template-update-delay(如 60000ms),让 FreeMarker 的模板缓存充分发挥作用。
    • 优化 template-update-delay: 在生产环境,设置一个足够长的延迟(如 5-15 分钟),最大化缓存命中率,避免频繁的文件检查 I/O。
    • 预热缓存 (可选): 在应用启动后,可以主动访问几个关键页面,使常用模板提前加载到缓存中。
    • 避免在模板中进行昂贵操作: 不要在循环内进行数据库查询模拟或复杂计算。
    • JVM 调优: 确保 JVM 有足够的堆内存,选择合适的垃圾回收器。
    • 监控: 监控应用的响应时间、模板处理时间、内存使用情况。

通过遵循这些步骤和最佳实践,你可以高效地在 Spring Boot 应用中集成并使用 FreeMarker,构建出功能强大且易于维护的 Web 应用。