FreeMarker 中的哈希表(Hash)是存储键值对数据的核心结构,类似于 Java 中的 Map。熟练掌握哈希表的访问是构建动态、数据驱动模板的基础。本指南将提供全面、深入的实践指导。


核心概念

  1. 哈希表 (Hash):一种数据结构,通过唯一的键(Key)来存储和检索对应的值(Value)。键通常是字符串,值可以是任何 FreeMarker 支持的类型(字符串、数字、布尔值、日期、其他哈希表、序列等)。
  2. 键 (Key):用于标识哈希表中特定值的唯一标识符,通常为字符串。键是区分大小写的。
  3. 值 (Value):与键关联的数据,可以是简单类型或复杂对象。
  4. 点号访问 (.):最常用的访问哈希表值的方法,语法为 hashName.keyName。它尝试将 keyName 作为哈希表的键查找,如果找不到,则尝试将其解释为哈希表对象的属性(如 Java Bean 的 getter 方法)。
  5. 方括号访问 ([]):另一种访问方法,语法为 hashName["keyName"]hashName[keyExpression]。它严格地将括号内的表达式求值后的结果作为键在哈希表中查找,不会尝试访问对象属性。
  6. 嵌套哈希表 (Nested Hashes):哈希表的值本身也可以是另一个哈希表,形成树状或层级结构。
  7. 动态键 (Dynamic Keys):使用方括号 [] 时,键可以是一个变量或表达式,其值在运行时确定。
  8. ?keys?values 内建函数:分别返回哈希表所有键的序列和所有值的序列。
  9. ?size 内建函数:返回哈希表中键值对的数量。

操作步骤 (非常详细)

步骤 1: 理解哈希表创建与数据准备

  • 目的:明确哈希表数据的来源。
  • 详细步骤
    1. Java 层创建:最常见的来源。在 Java 代码中创建 Map 或 Java Bean 对象,并将其放入数据模型 (dataModel)。
      // 创建一个用户信息 Map
      Map<String, Object> user = new HashMap<>();
      user.put("name", "Alice");
      user.put("age", 30);
      user.put("active", true);
      
      // 创建一个地址哈希表 (嵌套)
      Map<String, Object> address = new HashMap<>();
      address.put("street", "123 Main St");
      address.put("city", "Beijing");
      address.put("zipcode", "100000");
      user.put("address", address); // 嵌套
      
      // 创建一个偏好设置哈希表
      Map<String, Object> preferences = new HashMap<>();
      preferences.put("theme", "dark");
      preferences.put("language", "zh-CN");
      user.put("preferences", preferences); // 嵌套
      
      // 将 user Map 放入数据模型
      Map<String, Object> dataModel = new HashMap<>();
      dataModel.put("user", user);
      
    2. 模板内创建:使用 assign 指令在模板中直接定义哈希表。
      <#assign config = {
        "apiUrl": "https://api.example.com",
        "timeout": 5000,
        "debug": false,
        "features": ["search", "filter", "sort"]
      }>
      
    3. 从其他数据源获取:如 JSON 解析结果、数据库查询结果等,最终都会被转换为 FreeMarker 可识别的哈希表结构。

步骤 2: 使用点号 (.) 访问哈希表值

  • 目的:访问已知键名的哈希表值。
  • 语法hashVariable.keyName
  • 详细步骤
    1. 访问顶层键值对
      <p>姓名: ${user.name}</p>      <!-- 输出: Alice -->
      <p>年龄: ${user.age}</p>       <!-- 输出: 30 -->
      <p>状态: ${(user.active?string("启用", "禁用"))}</p> <!-- 输出: 启用 -->
      
    2. 访问嵌套哈希表:通过链式 . 操作符访问深层结构。
      <p>街道: ${user.address.street}</p>     <!-- 输出: 123 Main St -->
      <p>城市: ${user.address.city}</p>       <!-- 输出: Beijing -->
      <p>邮编: ${user.address.zipcode}</p>    <!-- 输出: 100000 -->
      <p>主题: ${user.preferences.theme}</p>  <!-- 输出: dark -->
      <p>语言: ${user.preferences.language}</p> <!-- 输出: zh-CN -->
      
    3. 处理键名包含特殊字符:如果键名包含空格、连字符 -、点号 . 或其他非字母数字字符,点号访问通常会失败或产生意外结果,因为它可能被解释为属性访问。应优先使用方括号 []
      <#-- 假设有键名为 "first-name" 或 "user info" 的情况 (不推荐,但可能发生) -->
      <#-- ${user.first-name} 会尝试计算 user.first 减去 name,错误! -->
      <#-- ${user.user info} 语法错误! -->
      

步骤 3: 使用方括号 ([]) 访问哈希表值

  • 目的:安全、精确地访问哈希表值,特别是键名含特殊字符或使用动态键时。
  • 语法hashVariable["keyName"]hashVariable[keyExpression]
  • 详细步骤
    1. 访问含特殊字符的键
      <#-- 假设 user 哈希表有一个键 "first-name" -->
      <p>名字: ${user["first-name"]}</p> <!-- 正确访问 -->
      
      <#-- 假设有一个键 "user info" -->
      <p>信息: ${user["user info"]}</p> <!-- 正确访问 -->
      
    2. 使用变量作为键 (动态键)
      • 创建一个变量存储键名。
      • [] 中使用该变量。
      <#assign settingKey = "theme">
      <p>当前设置 (${settingKey}): ${user.preferences[settingKey]}</p> 
      <!-- 输出: 当前设置 (theme): dark -->
      
      <#assign langKey = "language">
      <p>语言: ${user.preferences[langKey]}</p> <!-- 输出: zh-CN -->
      
    3. 使用表达式作为键
      <#-- 假设根据条件选择不同的偏好键 -->
      <#assign prefType = "display">
      <#assign dynamicKey = prefType + "Setting"> <!-- 表达式: "displaySetting" -->
      <#-- 假设 preferences 哈希表有 "displaySetting" 键 -->
      <p>显示设置: ${user.preferences[dynamicKey]}</p>
      
    4. 访问数字索引的序列 (当哈希表模拟序列时)
      • 虽然不常见,但如果哈希表的键是数字字符串,可以用 [] 模拟序列访问。
      <#assign numHash = {"0": "zero", "1": "one", "2": "two"}>
      <p>索引 1: ${numHash["1"]}</p> <!-- 输出: one -->
      <p>索引 1 (变量): <#assign idx = 1>${numHash[idx?string]}</p> <!-- 注意 idx 需转为字符串 -->
      

步骤 4: 遍历哈希表

  • 目的:访问哈希表中的所有键值对。
  • 语法<#list hash as key, value> ... </#list>
  • 详细步骤
    1. 基本遍历
      <h3>用户信息:</h3>
      <ul>
      <#list user as key, value>
        <li><strong>${key?html}:</strong> 
          <#if value?is_hash>
            <!-- 如果值是哈希表,递归或特殊处理 -->
            [对象]
          <#elseif value?is_sequence>
            <!-- 如果值是序列 -->
            ${value?join(", ")}
          <#else>
            ${value?html}
          </#if>
        </li>
      </#list>
      </ul>
      <!-- 输出类似:
           - name: Alice
           - age: 30
           - active: true
           - address: [对象]
           - preferences: [对象]
      -->
      
    2. 只遍历键:使用 ?keys
      <p>可用的用户字段: ${(user?keys?join(", "))}</p>
      <!-- 输出: name, age, active, address, preferences -->
      
    3. 只遍历值:使用 ?values
      <p>所有值: ${(user?values)}</p> <!-- 输出所有值,可能包含对象 -->
      
    4. 获取哈希表大小:使用 ?size
      <p>用户信息包含 ${user?size} 个字段。</p> <!-- 输出: 5 -->
      

步骤 5: 检查键是否存在与处理 null

  • 目的:安全地访问可能不存在的键。
  • 详细步骤
    1. 使用 ?? 检查键/值是否存在
      <#-- 检查顶层键 -->
      <#if user.phone??>
        <p>电话: ${user.phone}</p>
      <#else>
        <p>电话: 未提供</p>
      </#if>
      
      <#-- 检查嵌套键 (链式 ??) -->
      <#if user.address??>
        <#if user.address.country??>
          <p>国家: ${user.address.country}</p>
        <#else>
          <p>国家: 未填写</p>
        </#if>
      </#else>
        <p>地址信息不完整。</p>
      </#if>
      
    2. 使用 ! 提供默认值
      <p>电话: ${user.phone!"未提供"}</p>
      <p>国家: ${user.address.country!"中国"}</p> <!-- 注意: 如果 address 为 null,此表达式会报错! -->
      <!-- 更安全的方式: -->
      <p>国家: ${(user.address!{}).country!"中国"}</p> 
      <!-- 先给 address 提供一个空哈希表 {} 作为默认值,再访问其 country -->
      
    3. 使用 ?? then ... else ... 进行选择
      ${user.email ?? "邮箱: " + user.email else "暂无邮箱"}
      

步骤 6: 在 Java 代码中渲染模板

  • 目的:将包含哈希表访问逻辑的模板与数据结合。
  • 详细步骤
    1. 配置 FreeMarker
      Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
      cfg.setClassForTemplateLoading(YourClass.class, "/templates"); // 模板路径
      cfg.setDefaultEncoding("UTF-8");
      
    2. 加载模板
      Template template = cfg.getTemplate("user_profile.ftl"); // 模板文件名
      
    3. 准备并填充数据模型(如步骤 1 所示)。
    4. 执行渲染
      Writer out = new OutputStreamWriter(System.out, "UTF-8"); // 或写入文件、HTTP 响应等
      template.process(dataModel, out);
      out.flush();
      

常见错误

  1. 点号访问特殊字符键
    • 错误${user.first-name} (试图计算 user.firstname)。
    • 错误${user.user info} (语法错误)。
    • 正确${user["first-name"]}, ${user["user info"]}
  2. 混淆 .[] 的行为
    • 错误假设:认为 hash.keyhash["key"] 在所有情况下都等价。
    • 现实hash.key 会先查哈希表键,查不到会尝试 getKey() 方法;hash["key"] 只查哈希表键。当哈希表来自 Java Bean 且有同名 getter 时,行为可能不同。
  3. 访问 null 哈希表的键
    • 错误${user.address.country}user.addressnull 时,会抛出异常。
    • 正确:使用 ??!${(user.address!{}).country!"未知"}<#if user.address??>...<#if user.address.country??>...</#if></#if>
  4. 动态键未正确求值
    • 错误<#assign keyVar = "theme">${user.preferences.keyVar} — 这会尝试访问名为 keyVar 的键,而不是 theme
    • 正确${user.preferences[keyVar]}
  5. 遍历时未处理复杂值类型:在 <#list> 中直接输出 value,当 value 是哈希表或序列时,可能得到 [Object object] 或错误。
    • 正确:使用 ?is_hash, ?is_sequence 等内建函数进行类型检查和相应处理。
  6. 键名大小写敏感user.Nameuser.name 被视为不同的键。
  7. [] 中使用未定义变量${user[undefinedVar]} 会报错,因为 undefinedVar 不存在。

注意事项

  1. 键的类型:虽然键通常是字符串,但 FreeMarker 也支持其他类型(如数字)。但在实际应用中,字符串键最普遍。
  2. 性能:点号 . 和方括号 [] 的访问性能都非常高,通常不是瓶颈。遍历大型哈希表的性能主要取决于循环体内的操作。
  3. 可读性
    • 对于简单、标准的键名(字母、数字、下划线),优先使用 .,更简洁。
    • 对于含特殊字符或动态键,必须使用 []
  4. null 值 vs null
    • hash["key"] 返回 null:表示键 "key" 存在,但其值为 null
    • hash["nonExistentKey"] 返回 null:表示键 "nonExistentKey" 不存在。
    • ?? 操作符在这两种情况下都返回 false,因为它检查的是表达式整体是否为 null。要区分键是否存在和值是否为 null,需要更复杂的逻辑或在 Java 层处理。
  5. 作用域:在 <#list hash as key, value> 中,keyvalue 是循环变量,作用域仅限于循环体内。
  6. ?keys?values 的顺序:它们返回的序列顺序不保证是哈希表插入的顺序(取决于底层 Map 实现,如 LinkedHashMap 保证顺序,HashMap 不保证)。如果需要有序,应在 Java 层使用 LinkedHashMap 或在模板中对 ?keys 序列进行排序 (?sort)。

使用技巧

  1. 安全的嵌套访问:使用 !{} 为中间哈希表提供空哈希表默认值。
    ${(user.address!{}).zipcode!"未知邮编"}
    
  2. 动态配置访问:利用 [] 和变量轻松访问配置项。
    <#assign env = "prod"> <!-- 或 "dev" -->
    <#assign apiUrl = config[env + "ApiUrl"]> <!-- config.devApiUrl 或 config.prodApiUrl -->
    
  3. 使用 ?keys 进行条件检查
    <#if "admin" in user.roles?keys> <!-- 假设 roles 是一个哈希表 -->
      显示管理员功能
    </#if>
    
  4. 结合 ?index_of 检查键:虽然 ?keys 更直接,但 ?index_of 也可用。
    <#if user?keys?index_of("phone") != -1>
      有电话号码
    </#if>
    
  5. 在宏中传递哈希表:将哈希表作为参数传递给宏,实现代码复用。
    <#macro displayAddress addrHash>
      <address>
        ${addrHash.street}<br>
        ${addrHash.city}, ${addrHash.zipcode}
      </address>
    </#macro>
    <@displayAddress addrHash=user.address />
    

最佳实践与性能优化

  1. 优先使用 . 访问简单键:代码更简洁易读。
  2. 强制使用 [] 访问特殊键或动态键:确保正确性和可预测性。
  3. 防御性编程,无处不在的 ??!:在访问任何可能为 null 的哈希表或其键之前,进行防护。
  4. 在 Java 层进行数据预处理
    • 将复杂的数据结构扁平化或转换为更适合模板展示的形式。
    • 提前计算好模板中需要的标志位(如 isPremium, hasDiscount)。
    • 使用 LinkedHashMap 保证需要有序输出的哈希表的顺序。
  5. 避免在循环中进行昂贵操作:遍历大型哈希表时,确保循环体内的操作(如复杂计算、数据库调用——虽然模板中不应有)尽可能轻量。
  6. 重用 Configuration,缓存 Template:这是 FreeMarker 性能优化的根本。Configuration 是线程安全的,应全局单例;Template 对象会被 Configuration 缓存。
  7. 保持哈希表结构清晰:设计合理的数据模型,避免过深的嵌套或过于复杂的结构,便于模板访问。
  8. 使用有意义的键名:使用清晰、一致的命名约定(如 snake_case, camelCase),避免特殊字符。
  9. 利用 ?size 进行空检查<#if (hash?size > 0)> 可以检查哈希表是否非空。
  10. 文档化数据模型:如果模板使用的数据结构复杂,提供文档说明键的含义和类型。