深入剖析 Thymeleaf JavaScript Inline 序列化时的 “Null Key” 错误及解决方案

273 阅读2分钟

深入剖析 Thymeleaf JavaScript Inline 序列化时的 “Null Key” 错误及解决方案

在使用 Thymeleaf 构建 Spring Boot 前后端集成页面时,常会借助模板引擎的 JavaScript inline 功能,将服务器端的 Map<String, T> 直接序列化并注入到前端脚本中。然而,若该 Map 中存在 null 作为 key,Jackson 在序列化时就会直接抛出:

com.fasterxml.jackson.databind.JsonMappingException: Null key for a Map not allowed in JSON

2025年5月3日 00_01_00.png


1. Thymeleaf JavaScript Inline 功能简介

Thymeleaf 的 JavaScript Inline 允许在 <script> 标签中直接嵌入表达式,输出经过 JSON 序列化的 Java 对象,例如:

<script th:inline="javascript">
  // 取出服务端 Model 中的 salesByCategory Map
  let salesData = /*[[${salesByCategory}]]*/ {};
  console.log(salesData);
</script>
  • th:inline="javascript":告诉 Thymeleaf 开启 JS 上下文感知,使其把 [[…]] 内的表达式转为 JavaScript 字面量。
  • 底层使用 Jackson(或其他 JSON 序列化库)将 Java 对象 <Map<String,Double>> 转换为 JS 对象 { "食品":123.45, "日用品": 67.89 }

启发:这种方式省去了手动 JSON.stringify 传参,极大简化了前后端数据传递。


2. Jackson 序列化 Map 时的 Null Key 限制

根据 JSON 规范,对象的属性名(key)必须是字符串,且不能为 null。Jackson 默认实现中:

Map<String, Integer> map = new HashMap<>();
map.put(null, 100);
new ObjectMapper().writeValueAsString(map);

会抛出:

com.fasterxml.jackson.databind.JsonMappingException:
  Null key for a Map not allowed in JSON
  • 根本原因:Java Map 可以接受 null 作为 key,但 JSON 语法不允许 null 作为属性名。
  • Jackson 的抉择:直接报错,避免在序列化时产生不合法或不可预测的输出。

启发:设计之初就应保证任何要导出为 JSON 的键值对,其 key 均为合法非空字符串。


3. 错误复现:最小可重现示例

假设 SaleService.getSalesByCategory() 在数据库中遇到某些商品分类字段为 NULL,最终返回:

// Service 层返回
Map<String, Double> salesByCategory = new HashMap<>();
salesByCategory.put("食品", 1024.50);
salesByCategory.put(null,    204.75); // ← 这是触发异常的“隐形炸弹”
return salesByCategory;

在 Controller 中直接注入到 Model:

model.addAttribute("salesByCategory", salesByCategory);

最终在 Thymeleaf 渲染 /templates/auth/sales.html<script th:inline="javascript"> 时,就会出现:

Null key for a Map not allowed in JSON (through reference chain: HashMap["null"])

堆栈信息中清晰表明:Jackson 在将 key 为 null 的 entry 序列化为 JavaScript 对象字面量时,发现它不合法,于是抛出异常


4. 深入原因:后端数据为何出现 null key

常见场景包括:

  1. 数据库的分类字段允许空值

    • 某些老数据没有填写分类,导致查询返回 category=null
  2. 业务代码没有在聚合时过滤 null

    • saleRepository.findAll() → 按 category 分组求和,忘记在 SQL/Java 逻辑中排除 NULL
  3. 多表关联时引入了意外的空

    • LEFT JOIN 等查询,若右表无匹配,就得到 NULL
// SaleService 中的示例实现
public Map<String, Double> getSalesByCategory() {
    List<Sale> allSales = saleRepository.findAll();
    return allSales.stream()
        .collect(Collectors.groupingBy(
            Sale::getCategory,                 // 可能返回 null
            Collectors.summingDouble(Sale::getTotalPrice)
        ));
}

启发:任何业务聚合、分组步骤,都应明确对 null 做处理——要么归入“未分类”项,要么干脆丢弃。


5. 方案一:在 Controller 里清理 null key

最直接、最易上手的方案:在将 Map 放入 Model 之前,删掉 null 对应的 entry。代码示例如下:

@GetMapping
public String getSalesPage(Model model) {
    Map<String, Double> salesByCategory = saleService.getSalesByCategory();
    // —— 核心:删除 key 为 null 的条目
    if (salesByCategory != null) {
        salesByCategory.remove(null);
    }
    model.addAttribute("salesByCategory", salesByCategory);
    model.addAttribute("totalSales", null);
    return "auth/sales";
}
@PostMapping("/time")
public String getTotalSalesByTime(
        @RequestParam String startTime,
        @RequestParam String endTime,
        Model model) {

    // … 省略时间解析逻辑 …

    Map<String, Double> salesByCategory = saleService.getSalesByCategory();
    if (salesByCategory != null) {
        salesByCategory.remove(null); // 再次清理
    }
    model.addAttribute("salesByCategory", salesByCategory);
    model.addAttribute("totalSales", totalSales);
    return "auth/sales";
}
  • 优点:非常直观,少量代码即可避免序列化异常。
  • 缺点:若需要保留“未分类”数据(而非完全丢弃),则需要额外合并到某个固定 key(如 "未分类")。
Double uncategorized = salesByCategory.remove(null);
salesByCategory.put("未分类", uncategorized);

启发:后端清理是最不破坏前端的方式,但也要注意业务含义——你可能需要把 null 映射为“其他”或“未分类”。


6. 方案二:自定义 Jackson NullKeySerializer

若希望在序列化层面照顾到所有使用 ObjectMapper 的场景,可以在 Spring Boot 中全局配置 Jackson,捕获并替换掉 null key,例如:

@Configuration
public class JacksonConfig {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer nullKeySerializerConfigurer() {
        return builder -> builder.serializerByType(
            Entry.class,
            new StdSerializer<Entry<?, ?>>((Class<Entry<?, ?>>>)(Class<?>) Entry.class) {
                @Override
                public void serialize(Entry<?, ?> entry, JsonGenerator gen, SerializerProvider provider) 
                        throws IOException {
                    String key = (entry.getKey() == null) ? "未分类" : entry.getKey().toString();
                    gen.writeFieldName(key);
                    provider.defaultSerializeValue(entry.getValue(), gen);
                }
            }
        );
    }
}

或者更简单地,通过模块注册一个 NullKeySerializer

@Bean
public ObjectMapper objectMapper() {
    SimpleModule module = new SimpleModule();
    module.addKeySerializer(
        Object.class,
        new JsonSerializer<Object>() {
            @Override
            public void serialize(Object key, JsonGenerator gen, SerializerProvider serializers)
                    throws IOException {
                String fieldName = (key == null) ? "未分类" : key.toString();
                gen.writeFieldName(fieldName);
            }
        }
    );
    return new ObjectMapper().registerModule(module);
}
  • 优点:一次配置,项目中所有 Map 序列化都会自动应用,保证前端 JS inline、REST API 以及日志输出等一致性。
  • 缺点:全局改动可能影响其他模块,需谨慎测试;也可能让真正的 null key 隐藏了数据异常。

启发:当“业务层统一转换”更符合项目规范时,优先使用序列化器,而不是孤立地在 Controller 或 Service 中打补丁。


7. 方案三:前端模板中规避 null 条目

如果无法在后端清理,也可以在 Thymeleaf 模板中做判断。但由于 JavaScript inline 必须整体序列化 Map,常见做法是改为输出带过滤逻辑的数组:

<script th:inline="javascript">
  // 把 Map 转成 [ {cat: '食品', val:1024.5}, … ],并排除 cat==null
  let rawMap = /*[[${salesByCategory}]]*/ {};
  let data = Object.entries(rawMap)
    .filter(([cat, val]) => cat !== 'null' && cat !== null)
    .map(([cat, val]) => ({ category: cat, sales: val }));
  console.log(data);
</script>
  • 注意:Thymeleaf 会先把 rawMap 序列化,如果其中有真正的 null key,则依旧会报错。
  • 结论:前端方案只能在“null key 被序列化为字符串 "null"” 时过滤,无法解决 Jackson 拒绝 null key 的根本。

启发:前端虽能做部分“容错”,但最初的序列化阶段就要保证数据合法,才能做到万无一失。


8. 最佳实践建议与性能考量

  1. 数据层校验

    • 在写入数据库时,尽量使用 NOT NULL 约束或业务校验,避免出现 null 分类。
  2. Service 层统一聚合

    • 若“未分类”有业务价值,可在 Service 层统一归并;若无,则扔掉。
  3. Controller 层再保险

    • 做二次清理,防止 Service 或库更新后引入新 null。
  4. 序列化层适度定制

    • 全局配置 Jackson 的 NullKeySerializer,保证 API 及页面都能一致响应 null key。
  5. 性能考量

    • 对大 Map 重复过滤可能轻微影响性能;全局序列化器在启动时注册一次即可,对后续序列化无额外开销。
  6. 日志与监控

    • 如果发现频繁出现 null key,应在日志中记录并跟踪,排查业务流程或数据来源的根本原因。

启发:多层次、多角度地防御,不仅能让单点修复更可靠,也能保证项目中各个模块的一致性。


9. 总结

  • 问题本质:Jackson 在序列化 Map 时,不允许 null 作为 key;Thymeleaf JavaScript inline 底层调用 Jackson,于是报错。
  • 最佳解决:在后端(Service/Controller 或 Jackson 配置)层面清理或替换掉 null key,让数据在进入模板前就合法化。
  • 进阶方案:通过自定义 NullKeySerializer 进行全局配置,以更优雅的方式兼顾所有 JSON 输出场景。
  • 长期策略:完善数据库约束及业务校验,杜绝数据源头出现 null,从根本消灭此类问题。

以上各方案及实践,既可快速上手,也可长期维护。希望本文能帮助你从“为何报错”到“如何优雅解决”全方位掌握这项 Thymeleaf + Jackson 的集成要点。