ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Jackson自定义序列化与反序列化:从配置到安全实践

2026/9/12 2:19:24 拓冰建站 浏览量
Jackson自定义序列化与反序列化:从配置到安全实践 在Java后端开发这个圈子里序列化这件事看起来简单但真到了线上出问题的时候往往都是最让人头疼的。尤其是前后端交互时日期格式不统一、空值处理策略不一致、老系统返回的字段和前端命名规范不一样这些问题如果不从底层就把Jackson的序列化和反序列化行为控制住后面只能用各种脏补丁去填窟窿。这篇文章我就结合实际项目经验把Jackson自定义序列化和反序列化的完整方案拆开揉碎讲清楚从基础用法到Spring Boot中的全局配置再到安全防护一次说透。1. 为什么是Jackson选型逻辑与实际收益1.1 Jackson和Fastjson的取舍聊到JSON解析绕不开的话题就是Jackson和Fastjson到底选哪个。我在不同团队里见过两种极端的争执有人觉得Fastjson性能好、API简洁有人坚持Jackson稳定、生态好。我个人的观点很明确如果不是特别老的存量项目新项目一律优先Jackson。先说性能。很多人对Fastjson的“性能优势”的印象还停留在几年以前的测试报告上。实际上Jackson经过持续优化在绝大多数业务场景下性能差距已经缩小到可以忽略的程度除非你要处理每秒几十万次序列化的极端场景否则两者在性能上的差异几乎体感不到。但稳定性和维护度上的差异却是实打实的Jackson的发布节奏可控、社区活跃、三方库兼容性好Spring Boot默认使用Jackson就是最好的背书。再说安全。Fastjson历史上出过多次高危反序列化漏洞其中一部分是因为它设计了自动类型解析的机制攻击者可以通过构造恶意JSON触发任意类实例化进而实现远程代码执行。虽然最新版本做了很多加固但“autoType”这个设计本身带来的攻击面是客观存在的。Jackson也出过反序列化相关漏洞比如启用enableDefaultTyping()时可能被利用但Jackson的默认配置是相对安全的只要你没有乱开全局多态就不用太担心。如果你是从安全整改的角度重构老项目从Fastjson迁到Jackson也是一个很常规的做法。1.2 什么时候会用到自定义序列化或反序列化有些同事会问Jackson不是已经很强大了吗大部分场景加个JsonFormat、JsonProperty不就够了为什么还需要自定义这里我梳理了几个实际踩过的场景日期时间格式不统一。接口层要输出yyyy-MM-dd HH:mm:ss数据库里存的是LocalDateTime日志又希望保留毫秒一条数据在不同出口长得不一样基于注解无法灵活响应这种“同一字段多格式”的需求。对象结构和服务端模型不一致。比如前端需要的是一个扁平化的{userName: xx, userAge: 18}而后端内部用的却是User对象嵌套了Profile对象直接在实体上堆注解会让Java代码看起来极其混乱。需要对字段值做清洗。比如数据库里存了一段富文本序列化时要剥离HTML标签或者字段到了前端需要自动脱敏把手机号中间四位打码这类逻辑放在getter里面容易污染领域模型放在自定义序列化器里反而干净。第三方接口返回的数据结构不规范。比如对方返回的看似是数字但是加了引号或者是时间戳但单位不统一有的是秒、有的是毫秒。自定义反序列化器能把脏数据挡在系统之外。枚举自定义映射。数据库存的是int前端要传的是中文名称或者你希望反序列化的时候更宽容不因为一个非标准值就让整个请求报错。说白了自定义序列化/反序列化就是要把“JSON格式控制权”从框架默认行为中夺回来让数据的输入输出规则由你说了算。2. 快速上手第一个自定义反序列化器2.1 核心接口与实现思路Jackson自定义反序列化的核心入口是JsonDeserializerT抽象类你只需要继承它并重写deserialize(JsonParser p, DeserializationContext ctxt)方法。这个方法会拿到一个JsonParser你可以用它读取当前节点的各种类型然后返回你期望的目标对象。我拿一个最典型的场景举例接口入参的金额是字符串但内部模型使用的是BigDecimal而且字符串里可能包含逗号千分位分隔符。比如前端传的是1,234.56这种格式如果直接让Jackson转BigDecimal它会直接抛异常。public class FlexibleMoneyDeserializer extends JsonDeserializerBigDecimal { Override public BigDecimal deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { String text p.getText(); if (text null || text.trim().isEmpty()) { return null; } // 去掉千分位逗号 String cleaned text.replace(,, ); try { return new BigDecimal(cleaned); } catch (NumberFormatException e) { // 记录日志按需求决定抛异常还是返回兜底值 throw new JsonMappingException(p, 金额格式非法: text); } } }这段代码看起来很简单但里面有三个细节值得注意第一判断空值的时候不能只判断null还要考虑空字符串。如果接口层把空串当正常值传递而你直接new BigDecimal()就会抛出NumberFormatException。当然这里也要看你接口定义的约束空字符串是返回null还是返回BigDecimal.ZERO业务不同选择不同。第二p.getText()对JSON字符串、数字类型的节点都有效所以你既能把123解析成BigDecimal也能把123直接解析成BigDecimal。这就是反序列化器的“容错”能力。第三异常处理上我建议不要直接在deserialize里打印日志后返回兜底值因为你可能无法判断所有调用方对异常值的处理预期。最稳妥的方式是抛出JsonMappingException或它的子类这样错误会沿着Jackson的标准错误链路往上走被全局异常处理器统一接住转换成更友好的错误提示返回给前端。2.2 注册反序列化器到字段或类写完反序列化器下一步是让它生效。最简单的方式是加在字段上public class OrderCreateRequest { JsonDeserialize(using FlexibleMoneyDeserializer.class) private BigDecimal orderAmount; private String orderNo; // getter/setter 略 }这样在该请求对象被反序列化时orderAmount字段就会走你自定义的逻辑。如果你的场景是“一个类所有的反序列化行为都要定制”则可以把注解加在类上JsonDeserialize(using UserInfoDeserializer.class) public class UserInfo { private String name; private Integer age; }这要求UserInfoDeserializer继承的是JsonDeserializerUserInfo并在内部完成整个对象树的手工装配。这种方式适合接收第三方脏数据时使用因为你可以在里面做大量的容错判断——比如漏字段时给默认值、字段值非法时跳过而不是报错。3. 更复杂的反序列化场景自定义字段名映射与宽容策略3.1 处理驼峰和下划线混乱的接口做系统对接的同学应该深有体会各个公司接口的命名风格堪称群魔乱舞。有的是user_name有的是userName还有的是USER_NAME。如果你在实体上堆一大排JsonProperty(user_name)代码会变得非常啰嗦而且一旦对方改了字段名你要同步改所有注解。我这里提供一种思路通过自定义的反序列化器统一做字段名归一化。思路是重写deserialize方法时遍历JsonNode的所有字段名把下划线转驼峰后与目标字段进行映射或者反过来把各种风格的字段名统一转成小写之后再匹配。public class LenientObjectDeserializer extends JsonDeserializerObject { Override public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { // 通用字段映射逻辑 // 1. 读取JsonNode JsonNode root p.getCodec().readTree(p); // 2. 遍历字段统一转为小写下划线风格 // 3. 与目标Bean的setter映射 // 4. 通过ctxt或反射创建目标实例并赋值 return null; } }不过说实话在Jackson里实现一个完全通用的“字段名自动纠正”反序列化器并不容易因为你要处理嵌套对象、泛型集合等各种情况。我更推荐的做法是结合PropertyNamingStrategy来解决命名风格问题比如全局配置SNAKE_CASE这样Jackson会自动处理user_name到userName的转换比你手写字段遍历可靠得多。只有遇到那种字段名毫无规律的脏接口时才值得动手写一个专用的反序列化器。3.2 枚举的宽容反序列化枚举是反序列化里最容易出问题的地方。Java的枚举默认反序列化是根据name()匹配的也就是说你的JSON里传的字符串必须和枚举常量名完全一致。有一次我接第三方下单接口对方文档里明确写了支付方式传支付宝、微信但我们的枚举定义是ALIPAY、WECHAT直接反序列化必然报错。自定义枚举反序列化器的核心逻辑就是建立“外部值”到“枚举常量”的映射表public class PaymentTypeDeserializer extends JsonDeserializerPaymentType { Override public PaymentType deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { String value p.getText(); if (value null || value.trim().isEmpty()) { return null; } for (PaymentType type : PaymentType.values()) { if (type.getCode().equals(value) || type.getDesc().equals(value) || type.name().equalsIgnoreCase(value)) { return type; } } throw new JsonMappingException(p, 未知支付方式: value); } }这里我的建议是枚举里增加一个code或desc属性让外部值、数据库存储值和Java枚举名解耦。然后在反序列化时按“code desc name”的优先级去匹配既照顾了文档规范又照顾了开发习惯。3.3 空值容忍与默认值填充还有一种常见场景前端传了字段但值是null或者压根没传后端希望给个默认值。虽然可以在setter里做默认值处理但如果你希望保持领域模型的纯洁度不让它感知外部输入规则那么做一个自定义反序列化器统一处理会更合适。public class DefaultStringDeserializer extends JsonDeserializerString { Override public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { String value p.getText(); return (value null || value.trim().isEmpty()) ? UNKNOWN : value; } }从设计角度看这种“默认值填充”逻辑还是应该谨慎使用。比如用户主动传了空字符串你到底是要尊重它还是替换成默认值这往往取决于业务约定而不是技术方案。我在项目中通常只对“数字类型字段缺失时填0”做全局默认值处理其他的字段还是保持null让业务代码自行判断。4. 自定义序列化器控制输出格式与脱敏4.1 基础序列化器写法自定义序列化的核心是继承JsonSerializerT并重写serialize(T value, JsonGenerator gen, SerializerProvider serializers)方法。和反序列化器不同序列化器的逻辑通常是“往JsonGenerator里写入什么类型的数据”。一个简单的手机号脱敏序列化器public class PhoneNumberMaskSerializer extends JsonSerializerString { Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value null || value.length() 7) { gen.writeString(value); return; } String masked value.substring(0, 3) **** value.substring(7); gen.writeString(masked); } }用法同样是加注解public class UserVO { private String name; JsonSerialize(using PhoneNumberMaskSerializer.class) private String phone; }这个方案比在getter里裁剪字符串的好处在于序列化规则和领域模型解耦。同一个phone字段在管理后台接口可以全量输出在C端接口就脱敏输出只需要在对应VO或字段上使用不同的序列化器即可业务代码完全不用改。4.2 日期格式的动态输出日期序列化是另一个高频定制场景。比如后端返回给前端的LocalDateTime默认的Jackson序列化结果是2025-01-01T10:30:00这种ISO格式。但有些老前端只认2025-01-01 10:30:00如果前端不可控你只能在后端适配。我一般写一个支持时间戳和格式化字符串两种模式的动态日期序列化器public class FlexibleLocalDateTimeSerializer extends JsonSerializerLocalDateTime { private static final DateTimeFormatter DEFAULT_FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); Override public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value null) { gen.writeNull(); return; } // 这里可以通过注解属性或线程上下文变量控制输出格式 gen.writeString(DEFAULT_FORMATTER.format(value)); } }如果同一个项目的不同接口对日期格式要求不同可以在序列化器中配合JsonSerialize(using ..., as ...)或者引入一个ContextualSerializer来根据注解属性动态切换格式。不过对大多数项目来说全局统一一种格式足够没必要过度设计。4.3 对象结构的扁平化输出有一种更复杂的序列化需求把嵌套对象“拍平”输出。比如内部模型是public class Equipment { private String id; private EquipmentDetail detail; // 包含 brand 和 model }但前端要的数据结构是{ id: E001, brand: Dell, model: XPS-13 }这时候你可以为Equipment写一个自定义序列化器在serialize方法里把子对象字段直接写到当前JsonGenerator上public class EquipmentSerializer extends JsonSerializerEquipment { Override public void serialize(Equipment equipment, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeStartObject(); gen.writeStringField(id, equipment.getId()); if (equipment.getDetail() ! null) { gen.writeStringField(brand, equipment.getDetail().getBrand()); gen.writeStringField(model, equipment.getDetail().getModel()); } gen.writeEndObject(); } }这种方式的优点是完全由你控制输出结构不受内部模型嵌套关系的限制。缺点是你需要手工处理每一个字段如果模型字段很多代码量会让人崩溃。我的建议是只有结构差异真的很大、不太可能稳定演变时才用这种“拍平”写法一般场景用JsonUnwrapped注解就够了。5. Spring Boot中的全局配置与注册方式5.1 覆盖默认ObjectMapper的两种姿势在Spring Boot项目里配置Jackson最常见的方式是定义一个Jackson2ObjectMapperBuilderCustomizerBean在它里面定制全局的序列化器和反序列化器。这种方式不会破坏Spring Boot自动配置机制推荐作为首选。Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { // 全局注册自定义序列化器 builder.serializerByType(String.class, new PhoneNumberMaskSerializer()); // 全局注册自定义反序列化器 builder.deserializerByType(BigDecimal.class, new FlexibleMoneyDeserializer()); // 全局日期格式 builder.simpleDateFormat(yyyy-MM-dd HH:mm:ss); // 忽略未知字段防止接口升级导致旧客户端大面积报错 builder.failOnUnknownProperties(false); }; } }另一种做法是直接声明一个ObjectMapper的Bean覆盖Spring Boot默认提供的实例。这种写法控制力最强但同时意味着你要自己维护所有Jackson配置如果Spring Boot升级后引入了一些默认配置策略你可能会漏掉。我一般只在需要彻底替换JsonMapper构建方式的时候才这么做。Bean public ObjectMapper objectMapper() { ObjectMapper mapper new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.registerModule(new SimpleModule() .addSerializer(BigDecimal.class, new CustomBigDecimalSerializer()) .addDeserializer(BigDecimal.class, new FlexibleMoneyDeserializer())); return mapper; }注意一点如果项目中同时引入了spring-boot-starter-web和spring-boot-starter-webflux你可能会遇到两个ObjectMapper并存的问题一个管Servlet MVC一个管WebFlux注册逻辑需要分别确认。5.2 通过SimpleModule注册与模块管理除了serializerByType和deserializerByType更面向对象的方式是用Jackson的SimpleModule然后把模块注册到ObjectMapper上。模块化的好处是可以把一组相关的序列化器聚合到一起按业务域拆分测试和复用都比较方便。Configuration public class JacksonModuleConfig { Bean public SimpleModule myModule() { SimpleModule module new SimpleModule(MyCustomModule); module.addSerializer(LocalDateTime.class, new FlexibleLocalDateTimeSerializer()); module.addDeserializer(OrderStatus.class, new OrderStatusDeserializer()); module.addDeserializer(BigDecimal.class, new FlexibleMoneyDeserializer()); return module; } }然后在Jackson2ObjectMapperBuilderCustomizer中用builder.modules(myModule)把它装配进去。如果你的项目是多模块架构可以把不同的自定义模块放在不同的子模块里由主应用统一装配模块边界清晰。5.3 注解注册和全局注册的优先级这里我踩过一个坑字段上的JsonSerialize注解优先级高于全局注册。也就是说如果一个字段已经显式标注了某个序列化器你在ObjectMapper里全局注册的处理器不会生效。这其实是个好特性意味着你可以用全局注册处理“大多数”情况再用注解覆盖“少数”特例。但要记住一旦线上发现“全局配置没生效”首先检查字段上有没有JsonSerialize或者JsonDeserialize。另外需要注意JsonMixin机制也是一种很有用的定制方式。如果你不能修改第三方类源码又想给它加序列化规则可以通过mapper.addMixIn(TargetClass.class, MixInClass.class)来注入注解非常适用于对老代码进行非侵入式扩展。6. 高级应用结合Spring Boot 4与新版依赖的适配6.1 关注Jackson版本升级带来的行为变化和问题这些年Jackson经历过多次大版本升级API行为上有些细节变化值得注意。比如较新的版本对Java 8时间类型的处理默认走JSR-310模块如果你要自定义LocalDateTime序列化最好确认一下jackson-datatype-jsr310在你的依赖树里。网上最近讨论比较多的JsonMapper$Builder问题本质上是Jackson在新版本里推荐使用JsonMapper.builder()这种流式API来构建ObjectMapper实例而老代码直接new ObjectMapper()配合JacksonObjectMapperBuilder的时候某些配置项的行为会有细微差异。比如JsonMapper.builder().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build()和传统的mapper.configure(...)在结果上等价但如果你混用两者可能会出现配置互相覆盖的情况。我的建议是在Spring Boot项目里尽可能依赖Jackson2ObjectMapperBuilderCustomizer来做配置而不是手动构建JsonMapper这样可以减少版本升级带来的回归风险。6.2 从Fastjson迁移到Jackson的注意事项如果你正在做从Fastjson到Jackson的迁移工作有几个点要提前规划。第一Fastjson的JSONField注解和Jackson的JsonProperty在行为上不完全对应。比如JSONField(name xxx)对应JsonProperty(xxx)JSONField(serialize false)对应JsonIgnore但Fastjson有ordinal控制字段顺序Jackson没有直接等价物需要通过JsonPropertyOrder来控制。第二Fastjson对Date默认序列化成时间戳Jackson默认序列化成ISO字符串。迁移后接口输出格式会变前端必须同步测试。第三Fastjson默认会把null字段输出出来Jackson默认也会输出null但如果你配置了NON_NULL这种Include策略很多老接口的响应体会变小需要提醒前端兼容“字段不存在”和“字段值为null”两种情况。第四也是最关键的Fastjson的反序列化在TypeReference泛型处理上存在一些细节差异Jackson在复杂泛型比如ListListMapString, Object的识别上也有自己的规则迁移时建议对每个接口做一次回归验证。7. 反序列化安全常见漏洞原理与防御建议7.1 反序列化漏洞为什么危险既然热词里提到了反序列化漏洞作为Jackson使用者这块内容必须重视。所谓反序列化漏洞通俗讲就是程序接收到一段不可信的JSON数据在转换成对象的过程中攻击者通过精心构造的JSON内容诱导程序执行了非预期的代码。Java生态里反序列化漏洞的历史非常悠久从原生的ObjectInputStream到各种JSON库都出过类似问题。核心原因都是一样的反序列化不仅仅是“创建对象”它还会调用对象的某些方法、触发内部状态变更某些类的readObject方法或者setter方法里存在危险逻辑攻击者通过构造特殊的属性值把危险逻辑串成一条“利用链”。以Jackson为例风险主要来自多态类型的处理。当JSON里包含class或type这种类型标识字段时Jackson会尝试实例化JSON中指定的类。如果应用允许这种类型标识并且攻击者可控那么他就可以指定任意类。某些底层的类库比如常见的JNDI相关类在被实例化或触发某方法时可以从远程加载恶意信息最终演变成远程命令执行。7.2 Jackson安全使用的几条底线在实际项目中我给自己定了几条安全底线也建议你严格遵守永远不要全局启用enableDefaultTyping()。除非你的架构里确实需要多态反序列化并且你能确保输入源绝对可信否则这就是给攻击者开门。如果必须用到多态请使用白名单机制。比如用一个可以校验的ObjectIdResolver或者自定义的类型解析器保证只能实例化你允许的那些类。对JSON输入做大小限制和深度限制。超大JSON体和超深嵌套可以耗尽内存和栈空间造成拒绝服务。Jackson有StreamReadConstraints相关配置可以对字符串长度、嵌套深度做限制。不要信任第三方数据。来自外部系统的数据在反序列化前先做一次基础字段校验必要时直接使用DNF先设计好的数据校验层挡在入口。另外及时升级版本是成本最低的安全控制手段。Jackson官方在修复反序列化CVE之后通常会在新版本中收紧默认限制。我遇到过有团队因为担心升级带来兼容问题一直停留在老版本结果安全扫描一查一个准。对于Jackson这种底层组件建议在测试环境专门跑一遍升级兼容性用例确保能跟上新版本节奏。7.3 依赖管理中的自查清单项目里Jackson的依赖族主要包括jackson-databind、jackson-core、jackson-annotations以及各种数据类型模块。由于Spring Boot的依赖管理会自动锁定版本平时不太容易出问题。但如果项目里同时依赖了很多其他框架可能会出现传递依赖带来的Jackson版本冲突。排查思路很简单在Maven里执行mvn dependency:tree -Dincludescom.fasterxml.jackson.core看到底引了哪些版本的jackson-databind。如果出现多个版本优先在dependencyManagement里显式声明一个统一的安全版本把其他传递依赖压下来。我见过因为版本不一致导致NoSuchMethodError和序列化行为异常的案例绝大多数都是这个原因。8. 实操复盘一个订单系统里的Jackson配置全记录8.1 需求背景与配置目标前面讲的都是方法论这一节我拿一个实际项目来复盘。去年我给一个订单系统做接口规范化改造核心目标有三个全接口日期格式统一为yyyy-MM-dd HH:mm:ss彻底废弃时间戳。金额字段允许前端传入字符串形式的千分位格式输出时统一为两位小数的数字。手机号在C端接口脱敏在管理端接口原样输出。这三个需求看起来不难但涉及几十个接口、上百个字段如果靠一个一个改实体类工作量大且容易遗漏。最终的方案是设计了一套全局Jackson配置加局部注解覆盖的组合策略。8.2 最终配置代码全局配置类Configuration public class OrderJacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer orderCustomizer() { return builder - { builder.simpleDateFormat(yyyy-MM-dd HH:mm:ss); builder.serializerByType(LocalDateTime.class, new LocalDateTimeFormatSerializer()); builder.serializerByType(LocalDate.class, new LocalDateFormatSerializer()); builder.serializerByType(BigDecimal.class, new BigDecimalScaleSerializer()); builder.deserializerByType(BigDecimal.class, new FlexibleMoneyDeserializer()); builder.featuresToDisable( DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, SerializationFeature.WRITE_DATES_AS_TIMESTAMPS ); builder.featuresToEnable(SerializationFeature.INDENT_OUTPUT); }; } }局部覆盖的脱敏序列化器public class UserVO { private Long id; JsonSerialize(using PhoneNumberMaskSerializer.class) private String phone; JsonSerialize(using UserNameMaskSerializer.class) private String name; }管理端输出时不加注解使用另一个VO或者在字段上配置一个不脱敏的序列化器确保内部接口看到的是完整数据。8.3 复盘踩坑记录整个过程并非一帆风顺有几个坑分享出来坑一LocalDateTime序列化器的选择。刚开始我直接在ObjectMapper里注册了LocalDateTimeSerializer但某一个接口返回的时间还是数组格式。排查半天发现是项目中某个老组件自己声明了一个独立ObjectMapper并没有走Spring Boot的自动配置导致我全局的设置对它无效。最终的解决办法是把需要通用化的配置收敛到一个工具类所有手动创建的ObjectMapper统一从工具类获取。坑二脱敏导致的数据关联问题。C端订单详情接口脱敏了手机号但订单中心需要根据手机号显示关联信息前后端传参又是脱敏后的手机号导致后端查询不到原始数据。最终方案是脱敏只发生在VO层前端查询条件里的手机号不经过脱敏序列化器或者只做展示层脱敏不从源头改数据。坑三全局配置对象复用问题。在一次单元测试里我直接用new ObjectMapper()去反序列化测试JSON结果自定义处理器全都不生效以为代码写错了。后来才反应过来测试里没有加载Spring上下文自然拿不到全局Bean。所以针对序列化器的测试要么通过Autowired注入配置好的ObjectMapper要么在测试类里手动registerModule。9. 测试策略如何验证自定义序列化器可靠9.1 单元测试里验证局部逻辑每一个自定义序列化器都应该有对应的单元测试不能等到联调才去发现格式问题。测试的核心思路是准备一个目标对象调用ObjectMapper.writeValueAsString()验证输出字符串再准备一段JSON调用ObjectMapper.readValue()验证反序列化结果。Test void testFlexibleMoneyDeserializer() throws Exception { ObjectMapper mapper new ObjectMapper(); SimpleModule module new SimpleModule(); module.addDeserializer(BigDecimal.class, new FlexibleMoneyDeserializer()); mapper.registerModule(module); String json {\amount\:\1,234.56\}; OrderRequest req mapper.readValue(json, OrderRequest.class); assertEquals(new BigDecimal(1234.56), req.getAmount()); }注意测试类里串行化器和反序列化器都要覆盖特别是空值、非法值、边界值这些异常输入一定要写进测试用例。9.2 集成测试里验证全局配置集成测试一定要把ObjectMapper从Spring容器里拿出来测否则测不到真实效果。可以使用SpringBootTest配合Autowired ObjectMapper把真实场景下的Bean配置全部加载一遍。SpringBootTest class JacksonIntegrationTest { Autowired ObjectMapper objectMapper; Test void testGlobalDateSerializer() throws Exception { MapString, Object data Map.of(time, LocalDateTime.of(2025, 3, 1, 10, 30, 0)); String json objectMapper.writeValueAsString(data); assertTrue(json.contains(2025-03-01 10:30:00)); } }集成测试看起来简单但它是对“配置是否真正生效”最有力的证据。我在实际项目中通常会把这类测试纳入CI流水线防止后续有人改动全局配置导致回归。9.3 线上问题排查的调试技巧线上如果遇到序列化输出不符合预期第一步不是去看业务代码而是直接打印当前使用的ObjectMapper配置。你可以在临时接口里输出objectMapper.writeValueAsString(objectMapper.getSerializationConfig())确认这个Bean到底加载了哪些模块、开启了哪些特性。很多时候别人引入的某个组件会修改全局配置眼见为实比猜代码更高效。10. 常见问题速查表与经验总结现象可能原因解决办法配置了自定义序列化器但输出没变化字段上也被其他注解覆盖或注入的是另一个ObjectMapper检查字段注解确认全局Bean被正确注入日期反序列化时报not a valid representation未注册JSR-310模块或全局格式与实体注解冲突确认jackson-datatype-jsr310存在统一日期格式配置枚举反序列化时遇到未知值直接报错枚举没有自定义反序列化器Jackson默认严格匹配编写枚举宽容反序列化器接口返回的null字段消失了NON_NULL或NON_EMPTY的Include策略被全局启用确认builder.serializationInclusion配置是否符合预期深嵌套JSON导致栈溢出未限制嵌套深度攻击者构造超深JSON启用StreamReadConstraints限制深度多个ObjectMapper实例导致配置不同步某些组件或手写代码单独创建了Mapper统一从Spring容器注入或提供静态工具类从我个人的体会来说Jackson自定义序列化/反序列化的核心价值不是“炫技”而是给系统建立一套明确的输入输出契约。当你把格式控制权掌握在自己手里前后端争议会变少接口联调变快安全风险也可控。很多人觉得这类配置属于“一次性工作”不重视但实际上系统演进到中后期各种边界情况会慢慢冒出来有一套清晰的自定义序列化体系能帮你省掉大量返工时间。最后再分享一个小技巧定义序列化器时尽量做到“一个类只专注一件事”比如脱敏、格式化、容错拆分到不同类中然后用组合的方式复用。我见过有人把脱敏、日期格式化、金额清洗全写在一个序列化器里结果一旦某个规则变化整个类都要改耦合度极高。按职责拆开后配置即插即用测试也好写这才是长期演进里真正值得坚持的做法。