FreeMarker 是一个功能强大且灵活的 Java 模板引擎,特别适合用于生成动态内容,如电子邮件报表。它将内容的展示逻辑(模板 .ftl 文件)与业务逻辑(Java 代码)清晰分离,极大地提高了开发效率、可维护性和内容的可定制性。


1. 核心概念

  • 模板 (Template): 一个 .ftl (FreeMarker Template Language) 文件,包含静态文本(如 HTML 结构、纯文本内容)和动态指令(如 ${variable}, <#if condition>, <#list items as item>)。它是邮件或报表的“蓝图”。
  • 数据模型 (Data Model): 一个 Map<String, Object> 或 Java Bean 对象,包含模板中需要引用的所有变量和数据。它为模板提供“填充物”。
  • Configuration 对象: FreeMarker 的核心配置类。负责设置全局选项,如模板加载方式(从 classpath、文件系统、数据库等)、默认编码、模板缓存策略、版本兼容性等。通常一个应用中只需要一个 Configuration 实例(单例)。
  • Template 对象: 通过 Configuration 从模板文件加载并解析后得到的内存对象。它代表了一个具体的、可执行的模板。
  • 模板处理 (Processing):Template 对象与 Data Model 结合,通过 template.process(dataModel, writer) 方法,生成最终的输出文本(HTML 邮件内容、报表文本/HTML/PDF)。
  • 指令 (Directives): FreeMarker 的控制结构,如:
    • 变量输出: ${name}#{name} (数值格式化)
    • 条件判断: <#if condition>...</#if>, <#elseif condition>...</#elseif>, <#else>...</#else>
    • 循环迭代: <#list items as item>...</#list>
    • 宏 (Macro): <#macro name param1 param2>...</#macro>,定义可重用的模板片段。
    • 包含 (Include): <#include "header.ftl">,将其他模板文件的内容嵌入。
  • 内建函数 (Built-in Functions): 作用于变量的函数,如 ?upper_case, ?date, ?time, ?number, ?html (HTML 转义)。
  • 转义 (Escaping): 防止 XSS 攻击或格式错误。FreeMarker 提供 ?html, ?xml, ?js_string, ?json_string 等内建函数。在 HTML 邮件中尤其重要。
  • 模板缓存 (Template Caching): Configuration 可以缓存已解析的 Template 对象,避免重复解析文件,显著提升性能。这是生产环境的必备配置。
  • 模板加载器 (Template Loader): Configuration 使用 TemplateLoader 从不同来源(classpath, file system, string, database)加载模板源。常用 ClassTemplateLoader (classpath), FileTemplateLoader (文件系统)。

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

我们将以一个 Spring Boot 项目为例,因为它简化了配置和集成。核心的 FreeMarker 逻辑在任何 Java 环境下都适用。

前提

  • Java 8+
  • Maven 或 Gradle
  • Spring Boot (可选,但推荐用于 Web/邮件应用)

场景一:发送动态 HTML 邮件

步骤 1: 添加依赖 (Maven pom.xml)

<?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>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.2</version> <!-- 或其他兼容版本 -->
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>freemarker-email-report-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
        <freemarker.version>2.3.32</freemarker.version>
    </properties>

    <dependencies>
        <!-- Spring Boot Web Starter (包含 Tomcat, Spring MVC) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring Boot Mail Starter (简化邮件发送) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-mail</artifactId>
        </dependency>

        <!-- FreeMarker Template Engine -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
            <!-- Spring Boot 会自动管理 freemarker 版本 -->
        </dependency>

        <!-- Lombok (简化 POJO 代码) -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>

        <!-- 测试依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

步骤 2: 配置 application.yml

# application.yml
spring:
  # FreeMarker 配置
  freemarker:
    # 模板文件位置 (classpath:/templates/)
    template-loader-path: classpath:/templates/
    # 模板文件后缀
    suffix: .ftl
    # 内容类型 (邮件 HTML 通常用 text/html)
    content-type: text/html
    # 默认编码
    charset: UTF-8
    # 是否启用模板缓存 (生产环境必须为 true)
    cache: true # 开发环境可设为 false 便于热更新
    # 模板编码
    encoding: UTF-8
    # 是否检查模板更新 (生产环境应为 false)
    check-template-location: true
    # 设置版本 (推荐明确指定)
    settings:
      # 使用新 API 版本
      # template_update_delay: 0 # 开发时可设为 0 实时检查,生产设为较大值如 60
      # number_format: '0.######' # 自定义数值格式

  # 邮件配置 (以 Gmail 为例,请替换为你的 SMTP 服务器)
  mail:
    host: smtp.gmail.com
    port: 587
    username: your-email@gmail.com
    password: your-app-password # 使用应用专用密码,非邮箱登录密码
    protocol: smtp
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
        # 调试模式 (可选)
        # debug: true

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

// src/main/java/com/example/model/OrderConfirmation.java
package com.example.model;

import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;

@Data
public class OrderConfirmation {
    private String customerName;
    private String orderId;
    private LocalDateTime orderDate;
    private List<OrderItem> items;
    private BigDecimal totalAmount;
    private String deliveryAddress;

    @Data
    public static class OrderItem {
        private String productName;
        private int quantity;
        private BigDecimal unitPrice;
        private BigDecimal lineTotal; // quantity * unitPrice
    }
}

步骤 4: 创建 HTML 邮件模板

src/main/resources/templates/ 目录下创建 order-confirmation.ftl:

<!-- src/main/resources/templates/order-confirmation.ftl -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Order Confirmation - ${orderId!}</title>
    <style>
        body { font-family: Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 20px; }
        .container { max-width: 600px; margin: 0 auto; background-color: #ffffff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
        .header { text-align: center; color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px; }
        .header h1 { margin: 0; color: #007bff; }
        .content { margin: 20px 0; }
        .content p { margin: 10px 0; }
        .order-table { width: 100%; border-collapse: collapse; margin: 20px 0; }
        .order-table th, .order-table td { border: 1px solid #ddd; padding: 8px; text-align: left; }
        .order-table th { background-color: #f8f9fa; }
        .total { font-weight: bold; text-align: right; margin-top: 10px; }
        .footer { text-align: center; color: #666; font-size: 0.9em; margin-top: 20px; border-top: 1px solid #eee; padding-top: 10px; }
        .highlight { color: #d9534f; }
    </style>
</head>
<body>
<div class="container">
    <div class="header">
        <h1>Order Confirmation</h1>
        <p>Thank you for your purchase, <strong>${customerName?html}!</strong></p>
    </div>

    <div class="content">
        <p><strong>Order ID:</strong> ${orderId!}</p>
        <p><strong>Order Date:</strong> ${orderDate?date?string.medium} (${orderDate?time?string.short})</p>
        <p><strong>Delivery Address:</strong> ${deliveryAddress?html}</p>

        <h3>Order Items:</h3>
        <table class="order-table">
            <thead>
            <tr>
                <th>Product</th>
                <th>Quantity</th>
                <th>Unit Price</th>
                <th>Line Total</th>
            </tr>
            </thead>
            <tbody>
            <#list items as item>
                <tr>
                    <td>${item.productName?html}</td>
                    <td>${item.quantity}</td>
                    <td>${item.unitPrice?currency}</td>
                    <td>${item.lineTotal?currency}</td>
                </tr>
            </#list>
            </tbody>
        </table>

        <p class="total">Total Amount: <span class="highlight">${totalAmount?currency}</span></p>

        <#if totalAmount?number > 1000>
            <p class="highlight">Congratulations! You've earned a 5% discount on your next order!</p>
        </#if>

        <p>We'll send a shipping confirmation email once your order is dispatched.</p>
    </div>

    <div class="footer">
        <p>&copy; ${.now?date?string('yyyy')} Your Company Name. All rights reserved.</p>
        <p><a href="https://yourcompany.com">Visit our website</a> | <a href="mailto:support@yourcompany.com">Contact Support</a></p>
    </div>
</div>
</body>
</html>

关键点说明:

  • ${variable?html}: 使用 ?html 内建函数转义 HTML 特殊字符,防止 XSS。
  • ${orderDate?date?string.medium}: 将 LocalDateTime 格式化为日期字符串。
  • ${totalAmount?currency}: 格式化为货币。
  • <#list items as item>: 遍历订单项列表。
  • <#if totalAmount?number > 1000>: 条件判断,金额大于1000时显示优惠信息。
  • ${.now?date?string('yyyy')}: 获取当前年份。

步骤 5: 创建邮件服务 (Service)

// src/main/java/com/example/service/EmailService.java
package com.example.service;

import com.example.model.OrderConfirmation;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.springframework.ui.freemarker.FreeMarkerTemplateUtils;

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;

@Service
@RequiredArgsConstructor
@Slf4j
public class EmailService {

    private final JavaMailSender javaMailSender;
    private final Configuration freemarkerConfiguration; // Spring Boot 自动配置并注入

    /**
     * 发送订单确认邮件
     * @param to 收件人邮箱
     * @param orderConfirmation 订单数据模型
     */
    public void sendOrderConfirmationEmail(String to, OrderConfirmation orderConfirmation) {
        try {
            // 1. 加载并处理 FreeMarker 模板
            Template template = freemarkerConfiguration.getTemplate("order-confirmation.ftl");
            String content = FreeMarkerTemplateUtils.processTemplateIntoString(template, Map.of("order", orderConfirmation));

            // 2. 创建 MimeMessage
            MimeMessage message = javaMailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED,
                    StandardCharsets.UTF_8.name());

            helper.setTo(to);
            helper.setSubject("Order Confirmation - " + orderConfirmation.getOrderId());
            helper.setText(content, true); // true 表示内容是 HTML
            // helper.setFrom("no-reply@yourcompany.com"); // 可选,设置发件人

            // 3. 发送邮件
            javaMailSender.send(message);
            log.info("Order confirmation email sent successfully to {}", to);

        } catch (IOException | TemplateException e) {
            log.error("Error loading or processing FreeMarker template for order confirmation", e);
            throw new RuntimeException("Failed to generate email content", e);
        } catch (MessagingException e) {
            log.error("Error sending email to {}", to, e);
            throw new RuntimeException("Failed to send email", e);
        }
    }
}

关键点说明:

  • @RequiredArgsConstructor: Lombok 注解,为 final 依赖生成构造函数。
  • freemarkerConfiguration: Spring Boot 自动配置的 Configuration Bean,已根据 application.yml 设置好。
  • freemarkerConfiguration.getTemplate("order-confirmation.ftl"): 从 classpath 加载模板。cache: true 时,后续调用会返回缓存的 Template 对象。
  • FreeMarkerTemplateUtils.processTemplateIntoString(template, dataModel): Spring 提供的工具类,简化模板处理流程,将结果转为 String
  • MimeMessageHelper.setText(content, true): true 参数表示邮件内容是 HTML 格式。

步骤 6: 创建控制器或测试

// src/main/java/com/example/controller/OrderController.java
package com.example.controller;

import com.example.model.OrderConfirmation;
import com.example.service.EmailService;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final EmailService emailService;

    @PostMapping("/{orderId}/confirm")
    public Map<String, String> confirmOrder(@PathVariable String orderId, @RequestBody Map<String, String> request) {
        // 1. 模拟从数据库获取订单数据
        OrderConfirmation.OrderItem item1 = new OrderConfirmation.OrderItem();
        item1.setProductName("Laptop");
        item1.setQuantity(1);
        item1.setUnitPrice(new BigDecimal("999.99"));
        item1.setLineTotal(item1.getUnitPrice().multiply(BigDecimal.valueOf(item1.getQuantity())));

        OrderConfirmation.OrderItem item2 = new OrderConfirmation.OrderItem();
        item2.setProductName("Mouse");
        item2.setQuantity(2);
        item2.setUnitPrice(new BigDecimal("25.50"));
        item2.setLineTotal(item2.getUnitPrice().multiply(BigDecimal.valueOf(item2.getQuantity())));

        OrderConfirmation order = new OrderConfirmation();
        order.setCustomerName(request.get("customerName"));
        order.setOrderId(orderId);
        order.setOrderDate(LocalDateTime.now());
        order.setItems(List.of(item1, item2));
        order.setTotalAmount(item1.getLineTotal().add(item2.getLineTotal()));
        order.setDeliveryAddress(request.get("address"));

        // 2. 发送邮件
        emailService.sendOrderConfirmationEmail(request.get("email"), order);

        return Map.of("status", "success", "message", "Confirmation email sent!");
    }
}

测试: 使用 curl 或 Postman 发送 POST 请求:

curl -X POST http://localhost:8080/api/orders/ORD-12345/confirm \
  -H "Content-Type: application/json" \
  -d '{
    "customerName": "John Doe",
    "email": "john.doe@example.com",
    "address": "123 Main St, Anytown, USA"
  }'

场景二:生成报表 (HTML/PDF)

报表生成通常涉及更复杂的数据和格式。这里展示生成 HTML 报表,PDF 可通过将 HTML 转换为 PDF 实现(使用如 wkhtmltopdfFlying Saucer 库)。

步骤 1: 创建报表数据模型

// src/main/java/com/example/model/SalesReport.java
package com.example.model;

import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

@Data
public class SalesReport {
    private LocalDate reportDate;
    private String period; // e.g., "Monthly", "Quarterly"
    private List<SalesItem> salesData;
    private BigDecimal totalSales;
    private BigDecimal averageOrderValue;
    private int totalOrders;

    @Data
    public static class SalesItem {
        private String productId;
        private String productName;
        private int quantitySold;
        private BigDecimal unitPrice;
        private BigDecimal revenue;
        private BigDecimal profit; // revenue - cost
    }
}

步骤 2: 创建报表模板

src/main/resources/templates/ 下创建 sales-report.ftl:

<!-- src/main/resources/templates/sales-report.ftl -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Sales Report - ${period} - ${reportDate?date?string.short}</title>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 20px; }
        .header { text-align: center; margin-bottom: 30px; }
        .header h1 { color: #2c3e50; margin: 0; }
        .header p { color: #7f8c8d; margin: 5px 0; }
        .summary { background-color: #ecf0f1; padding: 15px; border-radius: 5px; margin-bottom: 20px; display: flex; justify-content: space-around; flex-wrap: wrap; }
        .summary-item { text-align: center; margin: 10px; }
        .summary-label { font-weight: bold; color: #34495e; }
        .summary-value { font-size: 1.5em; color: #e74c3c; }
        .data-table { width: 100%; border-collapse: collapse; margin: 20px 0; }
        .data-table th, .data-table td { border: 1px solid #bdc3c7; padding: 10px; text-align: left; }
        .data-table th { background-color: #3498db; color: white; }
        .data-table tr:nth-child(even) { background-color: #f8f9fa; }
        .total-row { font-weight: bold; background-color: #2ecc71; color: white; }
        .footer { text-align: center; margin-top: 30px; color: #95a5a6; font-size: 0.9em; }
        @media print {
            /* 为打印/PDF 优化的样式 */
            body { margin: 0; }
            .summary { page-break-inside: avoid; }
            .data-table { page-break-inside: auto; }
            .data-table tr { page-break-inside: avoid; page-break-after: auto; }
        }
    </style>
</head>
<body>
<div class="header">
    <h1>Sales Performance Report</h1>
    <p>Period: ${period} | Generated on: ${reportDate?date?string.long}</p>
</div>

<div class="summary">
    <div class="summary-item">
        <div class="summary-label">Total Sales</div>
        <div class="summary-value">${totalSales?currency}</div>
    </div>
    <div class="summary-item">
        <div class="summary-label">Total Orders</div>
        <div class="summary-value">${totalOrders}</div>
    </div>
    <div class="summary-item">
        <div class="summary-label">Avg. Order Value</div>
        <div class="summary-value">${averageOrderValue?currency}</div>
    </div>
</div>

<h2>Detailed Sales Data</h2>
<table class="data-table">
    <thead>
    <tr>
        <th>Product ID</th>
        <th>Product Name</th>
        <th>Qty Sold</th>
        <th>Unit Price</th>
        <th>Revenue</th>
        <th>Profit</th>
        <th>Profit Margin</th>
    </tr>
    </thead>
    <tbody>
    <#list salesData as item>
        <tr>
            <td>${item.productId}</td>
            <td>${item.productName?html}</td>
            <td>${item.quantitySold}</td>
            <td>${item.unitPrice?currency}</td>
            <td>${item.revenue?currency}</td>
            <td>${item.profit?currency}</td>
            <td>
                <#assign margin = (item.profit / item.revenue) * 100>
                ${margin?string["0.00"]}% 
                <#if margin < 10>
                    <span style="color: red;">(Low)</span>
                <#elseif margin < 20>
                    <span style="color: orange;">(Medium)</span>
                <#else>
                    <span style="color: green;">(Good)</span>
                </#if>
            </td>
        </tr>
    </#list>
    </tbody>
    <tfoot>
    <tr class="total-row">
        <td colspan="4">TOTAL</td>
        <td>${totalSales?currency}</td>
        <td>
            <#-- 计算总利润 -->
            <#assign totalProfit = 0>
            <#list salesData as item>
                <#assign totalProfit = totalProfit + item.profit>
            </#list>
            ${totalProfit?currency}
        </td>
        <td>
            <#assign overallMargin = (totalProfit / totalSales) * 100>
            ${overallMargin?string["0.00"]}% 
            <#if overallMargin < 15>
                <span style="color: red;">(Needs Improvement)</span>
            <#else>
                <span style="color: green;">(Healthy)</span>
            </#if>
        </td>
    </tr>
    </tfoot>
</table>

<div class="footer">
    <p>Confidential - For internal use only.</p>
    <p>Report generated by Sales System on ${.now?datetime?string.medium}.</p>
</div>
</body>
</html>

步骤 3: 创建报表服务

// src/main/java/com/example/service/ReportService.java
package com.example.service;

import com.example.model.SalesReport;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.ui.freemarker.FreeMarkerTemplateUtils;

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

@Service
@RequiredArgsConstructor
@Slf4j
public class ReportService {

    private final Configuration freemarkerConfiguration;

    /**
     * 生成销售报表的 HTML 内容
     * @param report 报表数据模型
     * @return HTML 字符串
     */
    public String generateSalesReportHtml(SalesReport report) {
        try {
            Template template = freemarkerConfiguration.getTemplate("sales-report.ftl");
            return FreeMarkerTemplateUtils.processTemplateIntoString(template, Map.of("report", report));
        } catch (IOException | TemplateException e) {
            log.error("Error generating sales report HTML", e);
            throw new RuntimeException("Failed to generate report", e);
        }
    }

    /**
     * (可选)将 HTML 转换为 PDF 的示例方法 (需要集成如 Flying Saucer)
     * public byte[] generateSalesReportPdf(SalesReport report) {
     *     String html = generateSalesReportHtml(report);
     *     // 使用 ITextRenderer 或其他库将 html 转为 PDF 字节流
     *     // ...
     *     return pdfBytes;
     * }
     */
}

步骤 4: 在控制器中使用

// 在 OrderController 或新建 ReportController 中添加
@GetMapping("/report/sales")
public String getSalesReport(Model model) {
    SalesReport report = generateMockSalesReport(); // 你的业务逻辑获取数据
    String htmlContent = reportService.generateSalesReportHtml(report);
    // 可以直接返回 HTML 字符串,或通过 Model 传递给 Thymeleaf/FreeMarker 视图
    // 或者返回一个包含 HTML 的 ResponseEntity
    return htmlContent; // 注意:直接返回大 HTML 字符串可能不是最佳 REST 实践,更适合文件下载
}

// 用于文件下载的端点
@GetMapping("/report/sales/download")
public ResponseEntity<Resource> downloadSalesReport() throws IOException {
    SalesReport report = generateMockSalesReport();
    String htmlContent = reportService.generateSalesReportHtml(report);

    // 将 HTML 内容转换为 Resource
    InputStreamResource resource = new InputStreamResource(new ByteArrayInputStream(htmlContent.getBytes(StandardCharsets.UTF_8)));

    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=sales-report-" + LocalDate.now() + ".html")
            .contentType(MediaType.TEXT_HTML)
            .body(resource);
}

3. 常见错误

  1. TemplateNotFoundException:

    • 原因: 模板文件路径错误、文件名拼写错误、文件未放在正确的资源目录(src/main/resources/templates/)、ConfigurationtemplateLoaderPath 配置错误。
    • 解决: 检查文件路径、名称、application.yml 配置。使用 freemarkerConfiguration.getTemplate("path/to/template.ftl") 时,path/to/ 是相对于 template-loader-path 的。
  2. InvalidReferenceException:

    • 原因: 模板中引用了数据模型中不存在的变量,如 ${user.nam} (拼写错误应为 name),或访问了 null 对象的属性。
    • 解决: 仔细检查模板变量名和数据模型。使用 ?? 操作符检查变量是否存在:${user.name!'Default Name'}。使用 ?has_content 检查字符串或集合是否非空。
  3. 乱码 (Garbled Text):

    • 原因: 模板文件、Java 源码、FreeMarker 配置、HTTP 响应头、邮件内容类型的编码不一致。
    • 解决: 确保所有环节使用 UTF-8。在 application.yml 中设置 spring.freemarker.charset=UTF-8, spring.freemarker.encoding=UTF-8。保存 .ftl 文件为 UTF-8。设置邮件 MimeMessageHelper 的 charset 为 UTF-8。
  4. XSS 安全漏洞:

    • 原因: 未对用户输入的数据进行 HTML 转义,直接输出到模板。
    • 解决: 在输出任何可能包含用户输入或不受信任数据的变量时,使用 ?html 内建函数:${userInput?html}。在 HTML 属性中使用 ?html 也能提供基本保护。
  5. 性能低下 (高并发下):

    • 原因: Configurationcache 设置为 false,导致每次请求都重新解析模板文件。
    • 解决: 生产环境务必设置 spring.freemarker.cache=true
  6. freemarker.core.ParseException:

    • 原因: 模板语法错误,如未闭合的 <#if><#list> 标签,或使用了错误的指令。
    • 解决: 仔细检查模板语法。使用 IDE 的 FreeMarker 插件进行语法高亮和检查。

4. 注意事项

  1. 安全第一: 始终对动态内容进行适当的转义(?html, ?js_string 等),防止 XSS 攻击。不要在模板中执行复杂的业务逻辑或数据库操作。
  2. 模板缓存: 生产环境必须开启缓存 (cache: true)。开发环境可关闭 (cache: false) 以便修改模板后无需重启应用即可看到效果(但需注意 template_update_delay)。
  3. 数据模型设计: 设计清晰、结构化的数据模型(POJO/Map),便于模板访问。避免在模板中进行复杂的计算或数据处理。
  4. 模板复用: 使用 <#import><#include> 将公共的头部、尾部、组件(如按钮、表格样式)提取到单独的模板文件中,提高复用性。
  5. 错误处理: 在调用 template.process() 时,妥善捕获 IOException (模板加载) 和 TemplateException (模板处理) 异常,并进行日志记录和用户友好的错误反馈。
  6. 版本兼容性: 注意 FreeMarker 版本间的 API 变化。使用 Configuration(Configuration.VERSION_x_x_x) 构造函数指定兼容版本。
  7. 资源管理: 如果使用 Writer (如 FileWriter, StringWriter),记得在 finally 块或使用 try-with-resources 语句关闭它们。
  8. 邮件内容: HTML 邮件应尽量使用内联 CSS,因为许多邮件客户端不支持 <style> 标签或外部 CSS。测试在不同邮件客户端(Gmail, Outlook, Apple Mail)的显示效果。

5. 使用技巧

  1. 使用宏 (Macros): 定义可重用的 UI 组件。
    <!-- button.ftl -->
    <#macro button text color="primary">
        <button style="background-color: ${color}; color: white; padding: 10px;">${text?html}</button>
    </#macro>
    
    <!-- main.ftl -->
    <#import "button.ftl" as btn>
    <@btn.button text="Submit" color="green"/>
    
  2. 使用函数 (Functions): 定义可重用的逻辑。
    <#function formatPrice amount currency="USD">
        <#return "${currency} ${amount?string.currency}">
    </#function>
    ${formatPrice(item.price)}
    
  3. 国际化 (i18n): 结合 Java 的 ResourceBundle 实现多语言。
    // Java 代码
    ResourceBundle bundle = ResourceBundle.getBundle("messages", locale);
    dataModel.put("i18n", bundle);
    
    <!-- template.ftl -->
    <p>${i18n.getString("welcome.message")}</p>
    
  4. 自定义内建函数/方法: 通过 Configuration.setSharedVariable() 注册自定义 Java 方法,供模板调用。
  5. 预编译模板: FreeMarker 支持将 .ftl 预编译成 .ftlh (HTML) 或 .ftlx (XML) 以提高解析速度(较少用)。
  6. 使用 ?no_esc: 当确定内容安全且不需要转义时(如已转义的 HTML 片段),使用 ${safeHtml?no_esc} 避免双重转义。
  7. 调试: 在模板中使用 <#-- Comment --><#assign debugInfo = "value"> 并在需要时输出 debugInfo

6. 最佳实践与性能优化

  1. 最佳实践:

    • 单一职责: 模板只负责展示,Java 代码负责数据准备和业务逻辑。
    • 清晰命名: 模板文件、变量、宏命名清晰有意义。
    • 模块化: 将大模板拆分为小的、可复用的子模板。
    • 配置外化: 将 FreeMarker 配置(如路径、缓存)放在 application.yml 或配置类中。
    • 自动化测试: 为关键模板编写单元测试,验证输出是否符合预期。
    • 文档化: 记录模板的用途、数据模型结构和使用方法。
  2. 性能优化:

    • 强制启用缓存: spring.freemarker.cache=true 是性能优化的基石。
    • 优化模板加载器: 确保 TemplateLoader 高效(如 ClassTemplateLoader 通常很快)。避免频繁从慢速源(如网络、数据库)加载模板。
    • 减少模板复杂度: 避免在模板中进行复杂的循环嵌套或计算。尽量在 Java 代码中准备好最终数据。
    • 使用 ?no_esc 谨慎: 仅在绝对安全时使用,避免安全风险。
    • 监控: 监控模板处理时间和频率,识别性能瓶颈。
    • JVM 调优: 确保 JVM 有足够的内存,避免频繁 GC 影响性能。
    • 异步处理: 对于耗时较长的报表生成,考虑使用 @Async 注解或消息队列,在后台执行,避免阻塞用户请求。

通过遵循本指南,你可以高效、安全地利用 FreeMarker 为你的应用构建动态邮件和报表功能。记住,清晰的分离、安全的编码和正确的配置是成功的关键。