FreeMarker 中的序列(Sequence)是存储有序元素集合的核心数据结构,类似于 Java 中的 List 或数组。对序列进行操作是模板开发中的常见需求,如遍历列表、访问元素、检查内容等。本文将系统介绍 FreeMarker 的序列操作,涵盖核心概念、详细操作步骤、常见错误、注意事项、使用技巧以及最佳实践与性能优化。


1. 核心概念

什么是序列 (Sequence)

  • 定义:序列是 FreeMarker 中一种有序的、可索引的数据结构,用于存储多个值。元素按插入顺序排列,并可通过索引(从 0 开始)访问。
  • 类比:类似于 Java 的 ListSet(当传递给模板时通常视为序列)、数组(Object[], String[] 等)。
  • 创建方式
    • 在 Java 代码中创建 List、数组等对象并放入数据模型。
    • 在模板中使用 [...] 语法创建(较少用,主要用于常量或简单组合)。

序列的关键特性

  • 有序性:元素有固定的顺序。
  • 可索引:可通过数字索引访问特定位置的元素(sequence[0])。
  • 可变长度:序列的长度可以是任意的(包括 0,即空序列)。
  • 元素类型:序列中的元素可以是任何 FreeMarker 支持的类型(字符串、数字、哈希表、其他序列等),且同一个序列中元素类型可以不同。

主要操作类型

  1. 遍历 (Iteration):使用 <#list> 指令逐个处理序列中的元素。
  2. 访问 (Access):通过索引获取特定元素。
  3. 检查 (Checking):判断序列是否为空、是否包含元素、获取长度等。
  4. 切片 (Slicing):获取序列的子集。
  5. 内建函数 (Built-ins):FreeMarker 提供了一系列内建函数来操作序列。

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

以下是使用 FreeMarker 序列操作的详细步骤,帮助您快速掌握并实践。

步骤 1:准备开发环境

  1. 引入 FreeMarker 依赖

    • Maven (pom.xml):
      <dependency>
          <groupId>org.freemarker</groupId>
          <artifactId>freemarker</artifactId>
          <version>2.3.32</version>
      </dependency>
      
    • Gradle (build.gradle):
      implementation 'org.freemarker:freemarker:2.3.32'
      
  2. 创建模板文件

    • src/main/resources/templates 目录下创建 sequence_operations.ftl

步骤 2:定义数据模型(Java 代码)

import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;

import java.io.*;
import java.util.*;

public class SequenceOperationsExample {
    public static void main(String[] args) throws IOException, TemplateException {
        // 1. 创建 FreeMarker 配置
        Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
        cfg.setClassForTemplateLoading(SequenceOperationsExample.class, "/templates");
        
        // 2. 获取模板
        Template template = cfg.getTemplate("sequence_operations.ftl");
        
        // 3. 创建数据模型
        Map<String, Object> dataModel = new HashMap<>();
        
        // 创建用户列表 (List<User>)
        List<Map<String, Object>> users = new ArrayList<>();
        Map<String, Object> user1 = new HashMap<>();
        user1.put("id", 1);
        user1.put("name", "Alice");
        user1.put("age", 30);
        user1.put("active", true);
        user1.put("tags", Arrays.asList("developer", "java")); // 嵌套序列
        
        Map<String, Object> user2 = new HashMap<>();
        user2.put("id", 2);
        user2.put("name", "Bob");
        user2.put("age", 25);
        user2.put("active", false);
        user2.put("tags", Arrays.asList("designer", "ui")); // 嵌套序列
        
        Map<String, Object> user3 = new HashMap<>();
        user3.put("id", 3);
        user3.put("name", "Charlie");
        user3.put("age", 35);
        user3.put("active", true);
        user3.put("tags", Arrays.asList("manager")); // 嵌套序列
        
        users.add(user1);
        users.add(user2);
        users.add(user3);
        
        dataModel.put("users", users);
        
        // 创建一个简单的字符串数组
        String[] colors = {"Red", "Green", "Blue"};
        dataModel.put("colors", colors);
        
        // 创建一个空序列
        List<String> emptyList = Collections.emptyList();
        dataModel.put("emptyItems", emptyList);
        
        // 创建一个数字序列
        List<Integer> numbers = Arrays.asList(10, 20, 5, 15, 30);
        dataModel.put("numbers", numbers);
        
        // 4. 准备输出
        Writer out = new OutputStreamWriter(System.out);
        
        // 5. 合并模板与数据
        template.process(dataModel, out);
        
        // 6. 清理
        out.flush();
        out.close();
    }
}

步骤 3:编写模板文件(sequence_operations.ftl

<#-- 序列操作示例 -->
<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>FreeMarker 序列操作演示</title>
    <style>
        table, th, td { border: 1px solid #ddd; border-collapse: collapse; padding: 8px; }
        th { background-color: #f2f2f2; }
        .inactive { color: gray; }
    </style>
</head>
<body>

<h1>序列操作演示</h1>

<#-- 1. 遍历序列 (<#list>) -->
<h2>1. 用户列表</h2>
<#-- 检查序列是否存在且有内容 -->
<#if users?has_content>
    <table>
        <thead>
            <tr>
                <th>#</th>
                <th>ID</th>
                <th>姓名</th>
                <th>年龄</th>
                <th>状态</th>
                <th>标签</th>
            </tr>
        </thead>
        <tbody>
        <#-- 使用 <#list> 遍历 users 序列 -->
        <#list users as user>
            <tr class="${user.active?then('', 'inactive')}">
                <td>${user_index + 1}</td> <!-- user_index 是 FreeMarker 提供的循环变量,从 0 开始 -->
                <td>${user.id}</td>
                <td>${user.name}</td>
                <td>${user.age}</td>
                <td>${user.active?string("活跃", "非活跃")}</td>
                <td>
                    <#-- 遍历嵌套序列 tags -->
                    <#if user.tags?has_content>
                        <#list user.tags as tag>
                            <span style="background:lightblue; padding:2px 4px; margin:0 2px;">${tag}</span>
                        </#list>
                    <#else>
                        无标签
                    </#if>
                </td>
            </tr>
        </#list>
        </tbody>
    </table>
    <p>共 ${users?size} 位用户。</p>
<#else>
    <p>暂无用户信息。</p>
</#if>

<#-- 2. 访问序列元素 (通过索引) -->
<h2>2. 访问特定元素</h2>
<#-- 检查索引是否有效 -->
<#if users?size > 0>
    <p>第一位用户: ${users[0].name} (ID: ${users[0].id})</p>
<#else>
    <p>无法访问,用户列表为空。</p>
</#if>

<#-- 安全访问 (避免索引越界) -->
<#-- 方法1: 先检查长度 -->
<#assign lastIndex = users?size - 1>
<#if lastIndex >= 0>
    <p>最后一位用户: ${users[lastIndex].name}</p>
<#else>
    <p>用户列表为空。</p>
</#if>

<#-- 方法2: 使用 try/catch (FreeMarker 支持) -->
<#try>
    <p>尝试访问第 5 位用户: ${users[4].name}</p>
<#catch>
    <p>错误:无法访问第 5 位用户(索引越界或列表为空)。</p>
</#try>

<#-- 3. 序列切片 (Slicing) -->
<h2>3. 序列切片</h2>
<#-- 获取前两位用户 -->
<#if users?size >= 2>
    <h3>前两位用户:</h3>
    <ul>
    <#list users[0..1] as user> <!-- 包含索引 0 和 1 -->
        <li>${user.name}</li>
    </#list>
    </ul>
<#else>
    <p>用户不足两位。</p>
</#if>

<#-- 获取从第二位开始到最后的所有用户 -->
<#if users?size > 1>
    <h3>从第二位开始的用户:</h3>
    <ul>
    <#list users[1..] as user> <!-- 从索引 1 到末尾 -->
        <li>${user.name}</li>
    </#list>
    </ul>
<#else>
    <p>用户不足两位或为空。</p>
</#if>

<#-- 4. 使用内建函数 (Built-ins) -->
<h2>4. 内建函数操作</h2>

<#-- ?size: 获取序列长度 -->
<p>颜色数量: ${colors?size}</p>

<#-- ?first: 获取第一个元素 -->
<#if colors?has_content>
    <p>第一种颜色: ${colors?first}</p>
<#else>
    <p>颜色列表为空。</p>
</#if>

<#-- ?last: 获取最后一个元素 -->
<#if colors?has_content>
    <p>最后一种颜色: ${colors?last}</p>
<#else>
    <p>颜色列表为空。</p>
</#if>

<#-- ?sort: 排序 (升序) -->
<#if numbers?has_content>
    <p>排序前的数字: ${numbers?join(", ")}</p>
    <p>升序排序: ${numbers?sort?join(", ")}</p>
    <p>降序排序: ${numbers?sort_by("-.")?join(", ")} <!-- 使用 - 表示降序,. 代表元素自身 --></p>
</#if>

<#-- ?join: 将序列元素连接成字符串 -->
<#if colors?has_content>
    <p>颜色列表: ${colors?join(" | ")}</p>
</#if>

<#-- ?seq_contains: 检查序列是否包含指定元素 -->
<#if users?seq_contains(users[0])> <!-- 检查是否包含第一个用户对象 -->
    <p>用户列表包含 Alice。</p>
<#else>
    <p>用户列表不包含 Alice。</p>
</#if>

<#-- 注意:?seq_contains 通常用于检查简单值 -->
<#assign userNames = users?map(user -> user.name)> <!-- 先提取名字序列 -->
<#if userNames?seq_contains("Bob")>
    <p>存在名为 Bob 的用户。</p>
</#if>

<#-- 5. 处理空序列 -->
<h2>5. 空序列处理</h2>
<#-- ?has_content 是检查空序列的推荐方法 -->
<#if emptyItems?has_content>
    <p>非空序列。</p>
<#else>
    <p>这是一个空序列或 null。</p>
</#if>

<#-- ?size 为 0 也表示空 -->
<#if emptyItems?size == 0>
    <p>序列长度为 0。</p>
</#if>

<#-- 6. 在模板中创建简单序列 (不常用) -->
<h2>6. 模板内创建序列</h2>
<#-- 使用 [...] 语法 -->
<#assign weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]>
<#list weekdays as day>
    <p>${day_index + 1}. ${day}</p>
</#list>

<#-- 7. 使用 ?map, ?filter 进行转换和过滤 (高级) -->
<h2>7. 序列转换与过滤</h2>

<#-- ?map: 将序列中的每个元素转换为新值 -->
<#assign userAges = users?map(user -> user.age)>
<p>用户年龄: ${userAges?join(", ")}</p>

<#-- ?filter: 过滤出满足条件的元素 -->
<#assign activeUsers = users?filter(user -> user.active)>
<h3>活跃用户:</h3>
<ul>
<#list activeUsers as user>
    <li>${user.name} (年龄: ${user.age})</li>
</#list>
</ul>

<#-- ?map 和 ?filter 结合 -->
<#assign activeUserNames = users?filter(user -> user.active)?map(user -> user.name)>
<p>活跃用户姓名: ${activeUserNames?join(", ")}</p>

</body>
</html>

步骤 4:运行程序并查看结果

  1. 编译并运行 SequenceOperationsExample.java
  2. 查看控制台输出的 HTML 内容,验证序列的各种操作(遍历、访问、切片、内建函数等)是否按预期工作。

3. 常见错误

  1. 索引越界 (Index Out of Bounds)

    • 错误${users[10].name},当 users 序列长度小于 11 时。
    • 原因:试图访问不存在的索引位置。
    • 解决
      • 在访问前使用 ?size 检查序列长度。
      • 使用 <#try> / <#catch> 捕获异常。
      • 优先使用 <#list> 遍历,避免硬编码索引。
  2. 访问 null 序列或元素

    • 错误${userList[0].name},如果 userList 本身为 null
    • 原因:序列变量为 null
    • 解决
      • 使用 ?? 操作符提供默认值:${(userList??[])[0].name}(如果 userListnull,则使用空列表 [])。
      • <#if> 中先检查 ?exists??
  3. 混淆 ?has_content??

    • 问题:使用 ?? 检查空序列。
    • 示例<#if users??> 只能判断 users 是否为 null,但无法判断一个非 null 的空列表 []
    • 解决始终使用 ?has_content 来检查序列是否“有内容”(非 null 且长度 > 0)。?? 仅用于检查 null
  4. <#list> 外部错误使用循环变量

    • 错误:在 <#list> 循环结束后,尝试使用 useruser_index 等变量。
    • 原因:这些变量仅在 <#list> 指令的内部作用域内有效。
    • 解决:如果需要在循环外使用某个值,应在循环内使用 <#assign> 将其赋值给一个外部变量。
  5. 对非序列类型使用序列操作

    • 错误${"hello"[0]}<#list "hello" as char>
    • 原因:字符串不是序列,不能像数组一样通过索引访问单个字符(FreeMarker 2.3.21+ 支持 ?string 相关操作,但 ?get 不是标准序列操作),也不能直接用 <#list> 遍历。
    • 解决:如果需要处理字符串中的字符,通常需要在 Java 后端将其转换为字符数组或字符串列表,再传递给模板。
  6. ?sort 的误解

    • 问题:认为 ?sort 会修改原序列。
    • 解决?sort 返回一个新的排序后的序列,原序列不变。需要将结果赋值给新变量或直接使用。

4. 注意事项

  1. ?has_content 是金标准

    • 对于序列(和字符串),?has_content 是最安全、最常用的检查“非空”的方法。它同时检查 null 和长度为 0 的情况。
  2. 循环变量的作用域

    • <#list sequence as item> 中的 itemitem_indexitem_has_next 等变量仅在 <#list></#list> 之间有效。
  3. ?first?last 的安全性

    • 当序列为空时,${sequence?first}${sequence?last} 会抛出异常。
    • 解决:务必先用 <#if sequence?has_content> 检查,或使用 <#try> / <#catch>
  4. ?join 的分隔符

    • ?join(separator) 用指定的分隔符连接序列元素。如果序列为空,结果为空字符串 ""
  5. ?map?filter 的 Lambda 表达式

    • 这些是 FreeMarker 2.3.23+ 引入的高级功能。
    • 语法:sequence?map(item -> item.property)sequence?filter(item -> item.age > 18)
    • -> 左边是参数(元素),右边是表达式。
  6. 性能考虑

    • <#list> 遍历是 O(n) 操作,对于非常大的序列,渲染时间会增加。
    • ?map?filter?sort 等操作也会遍历序列,产生新的序列对象,消耗内存和 CPU。

5. 使用技巧

  1. 结合 <#assign> 和内建函数

    <#-- 提取活跃用户列表 -->
    <#assign activeUsers = users?filter(user -> user.active)>
    <#-- 计算平均年龄 -->
    <#assign totalAge = 0>
    <#list users as user><#assign totalAge = totalAge + user.age></#list>
    <#if users?has_content>
        <p>平均年龄: ${(totalAge / users?size)?string("0.##")}</p>
    </#if>
    
  2. 使用 ?join 格式化输出

    <#-- 创建逗号分隔的标签列表 -->
    <#if user.tags?has_content>
        标签: ${user.tags?join(", ")}
    </#if>
    
  3. 利用 ?sort_by 进行复杂排序

    <#-- 按年龄升序排序 -->
    <#list users?sort_by("age") as user>
        ...
    </#list>
    
    <#-- 按姓名降序排序 -->
    <#list users?sort_by("-name") as user>
        ...
    </#list>
    
    <#-- 先按状态降序,再按年龄升序 -->
    <#list users?sort_by(["-active", "age"]) as user>
        ...
    </#list>
    
  4. ?chunk 分组显示

    • 将序列分割成固定大小的子序列。
    • 示例:<#list users?chunk(2) as userRow>,然后在内层 <#list userRow as user> 遍历每行的两个用户,常用于表格布局。
  5. 安全访问嵌套序列

    <#-- 安全地访问用户的第一种标签 -->
    <#if (user.tags??) && (user.tags?size > 0)>
        第一个标签: ${user.tags[0]}
    </#if>
    

6. 最佳实践与性能优化

  1. 数据预处理(推荐)

    • 最佳实践:在 Java 后端完成复杂的序列操作,如排序、过滤、分组、聚合计算等。将处理好的、适合展示的数据结构(如已排序的列表、分页后的列表、计算好的统计数据)传递给模板。
    • 优点:模板逻辑简单清晰;后端逻辑易于测试和优化;通常性能优于在模板引擎中执行;避免模板中出现大量 ?map/?filter
  2. 避免在模板中进行昂贵操作

    • 不要在 <#list> 循环内部执行复杂的计算或调用开销大的宏。
    • 避免对大序列进行 ?sort?filter 等操作,除非必要且数据量小。
  3. 合理使用缓存

    • 确保 Configuration 被正确配置和重用,利用模板缓存。
    • 对于计算结果稳定且使用频繁的序列处理(如静态的选项列表),可以在 Java 层缓存处理后的结果。
  4. 分页处理大数据集

    • 如果需要展示的序列非常大(如成千上万条记录),绝对不要一次性加载到内存并传递给模板进行 <#list> 遍历。
    • 优化:在后端实现分页查询,每次只获取一页数据(如 10-50 条)传递给模板。
  5. 使用 ?chunk 优化布局

    • 当需要将列表数据按行或按组展示时(如每行显示 3 个商品),使用 ?chunk(N) 可以简化模板逻辑,比手动计算索引更清晰。
  6. 监控与分析

    • 关注模板渲染时间,特别是包含大型序列操作的模板。
    • 使用性能分析工具识别瓶颈,优先考虑将耗时操作移至后端。

通过以上系统性的介绍和详细的实践步骤,您应该能够全面掌握 FreeMarker 序列操作的核心知识,并在实际项目中高效、安全地应用,同时避免常见陷阱,遵循最佳实践以构建高性能的模板系统。