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>© ${.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 实现(使用如 wkhtmltopdf
或 Flying 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. 常见错误
TemplateNotFoundException
:- 原因: 模板文件路径错误、文件名拼写错误、文件未放在正确的资源目录(
src/main/resources/templates/
)、Configuration
的templateLoaderPath
配置错误。 - 解决: 检查文件路径、名称、
application.yml
配置。使用freemarkerConfiguration.getTemplate("path/to/template.ftl")
时,path/to/
是相对于template-loader-path
的。
- 原因: 模板文件路径错误、文件名拼写错误、文件未放在正确的资源目录(
InvalidReferenceException
:- 原因: 模板中引用了数据模型中不存在的变量,如
${user.nam}
(拼写错误应为name
),或访问了null
对象的属性。 - 解决: 仔细检查模板变量名和数据模型。使用
??
操作符检查变量是否存在:${user.name!'Default Name'}
。使用?has_content
检查字符串或集合是否非空。
- 原因: 模板中引用了数据模型中不存在的变量,如
乱码 (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。
XSS 安全漏洞:
- 原因: 未对用户输入的数据进行 HTML 转义,直接输出到模板。
- 解决: 在输出任何可能包含用户输入或不受信任数据的变量时,使用
?html
内建函数:${userInput?html}
。在 HTML 属性中使用?html
也能提供基本保护。
性能低下 (高并发下):
- 原因:
Configuration
的cache
设置为false
,导致每次请求都重新解析模板文件。 - 解决: 生产环境务必设置
spring.freemarker.cache=true
。
- 原因:
freemarker.core.ParseException
:- 原因: 模板语法错误,如未闭合的
<#if>
、<#list>
标签,或使用了错误的指令。 - 解决: 仔细检查模板语法。使用 IDE 的 FreeMarker 插件进行语法高亮和检查。
- 原因: 模板语法错误,如未闭合的
4. 注意事项
- 安全第一: 始终对动态内容进行适当的转义(
?html
,?js_string
等),防止 XSS 攻击。不要在模板中执行复杂的业务逻辑或数据库操作。 - 模板缓存: 生产环境必须开启缓存 (
cache: true
)。开发环境可关闭 (cache: false
) 以便修改模板后无需重启应用即可看到效果(但需注意template_update_delay
)。 - 数据模型设计: 设计清晰、结构化的数据模型(POJO/Map),便于模板访问。避免在模板中进行复杂的计算或数据处理。
- 模板复用: 使用
<#import>
或<#include>
将公共的头部、尾部、组件(如按钮、表格样式)提取到单独的模板文件中,提高复用性。 - 错误处理: 在调用
template.process()
时,妥善捕获IOException
(模板加载) 和TemplateException
(模板处理) 异常,并进行日志记录和用户友好的错误反馈。 - 版本兼容性: 注意 FreeMarker 版本间的 API 变化。使用
Configuration(Configuration.VERSION_x_x_x)
构造函数指定兼容版本。 - 资源管理: 如果使用
Writer
(如FileWriter
,StringWriter
),记得在finally
块或使用 try-with-resources 语句关闭它们。 - 邮件内容: HTML 邮件应尽量使用内联 CSS,因为许多邮件客户端不支持
<style>
标签或外部 CSS。测试在不同邮件客户端(Gmail, Outlook, Apple Mail)的显示效果。
5. 使用技巧
- 使用宏 (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"/>
- 使用函数 (Functions): 定义可重用的逻辑。
<#function formatPrice amount currency="USD"> <#return "${currency} ${amount?string.currency}"> </#function> ${formatPrice(item.price)}
- 国际化 (i18n): 结合 Java 的
ResourceBundle
实现多语言。// Java 代码 ResourceBundle bundle = ResourceBundle.getBundle("messages", locale); dataModel.put("i18n", bundle);
<!-- template.ftl --> <p>${i18n.getString("welcome.message")}</p>
- 自定义内建函数/方法: 通过
Configuration.setSharedVariable()
注册自定义 Java 方法,供模板调用。 - 预编译模板: FreeMarker 支持将
.ftl
预编译成.ftlh
(HTML) 或.ftlx
(XML) 以提高解析速度(较少用)。 - 使用
?no_esc
: 当确定内容安全且不需要转义时(如已转义的 HTML 片段),使用${safeHtml?no_esc}
避免双重转义。 - 调试: 在模板中使用
<#-- Comment -->
或<#assign debugInfo = "value">
并在需要时输出debugInfo
。
6. 最佳实践与性能优化
最佳实践:
- 单一职责: 模板只负责展示,Java 代码负责数据准备和业务逻辑。
- 清晰命名: 模板文件、变量、宏命名清晰有意义。
- 模块化: 将大模板拆分为小的、可复用的子模板。
- 配置外化: 将 FreeMarker 配置(如路径、缓存)放在
application.yml
或配置类中。 - 自动化测试: 为关键模板编写单元测试,验证输出是否符合预期。
- 文档化: 记录模板的用途、数据模型结构和使用方法。
性能优化:
- 强制启用缓存:
spring.freemarker.cache=true
是性能优化的基石。 - 优化模板加载器: 确保
TemplateLoader
高效(如ClassTemplateLoader
通常很快)。避免频繁从慢速源(如网络、数据库)加载模板。 - 减少模板复杂度: 避免在模板中进行复杂的循环嵌套或计算。尽量在 Java 代码中准备好最终数据。
- 使用
?no_esc
谨慎: 仅在绝对安全时使用,避免安全风险。 - 监控: 监控模板处理时间和频率,识别性能瓶颈。
- JVM 调优: 确保 JVM 有足够的内存,避免频繁 GC 影响性能。
- 异步处理: 对于耗时较长的报表生成,考虑使用
@Async
注解或消息队列,在后台执行,避免阻塞用户请求。
- 强制启用缓存:
通过遵循本指南,你可以高效、安全地利用 FreeMarker 为你的应用构建动态邮件和报表功能。记住,清晰的分离、安全的编码和正确的配置是成功的关键。