搞定OpenAPI生成DTO的@JsonSerialize:4种方案
2025-04-21 17:44:27
OpenAPI Generator & Jackson 自定义序列化:搞定 @JsonSerialize
用 OpenAPI Generator(特别是 spring
generator)生成 DTO 挺省事的,可以省去手写一堆样板代码的功夫。但有时候,你现有的 DTO 可能加了一些特殊的 Jackson 注解,比如 @JsonSerialize(using = TwoDecimalSerializer.class)
,用来控制某个字段(比如 BigDecimal
)只序列化到小数点后两位。换成 OpenAPI Generator 生成的代码后,这功能就没了,咋整?难道只能在 Mapper 层做转换的时候再处理?
别急,这事儿有几种搞法。咱们来看看怎么给 OpenAPI Generator 生成的 DTO 也加上类似的自定义序列化行为。
问题根源在哪?
OpenAPI 规范(YAML 或 JSON 文件)主要 API 的契约:路径、操作、参数、请求体、响应体和数据模型(Schema)。它定义了数据结构和类型,但通常不关心具体编程语言或框架(比如 Java 的 Jackson)的序列化细节。
OpenAPI Generator 根据这个规范和指定的模板(比如 spring
的模板)来生成代码。默认模板的目标是生成通用、符合规范定义的代码。它们一般不会、也很难自动推断出你需要给某个 BigDecimal
字段加上特定的 Jackson @JsonSerialize
注解,指向一个你项目里才有的 TwoDecimalSerializer.class
。
所以,问题在于:OpenAPI 规范本身缺乏表达特定语言/库级别序列化定制的标准方式,而生成器默认模板也不会帮你处理这种“超纲”的需求。
解决方案大盘点
要把自定义序列化逻辑(比如 @JsonSerialize
)应用到生成的 DTO 上,主要有以下几种思路:
- Jackson Mixin: 这是比较推荐的、非侵入式的方式。
- 定制 OpenAPI Generator 模板: 直接修改生成逻辑,在生成代码时就加上注解。
- 全局配置 Jackson Module: 如果某个类型的序列化方式是全局统一的,可以用这种方式。
- Mapper 层处理(原始思路): 在 DTO 转换逻辑中处理数据格式,作为最后的选择。
下面咱们重点聊聊前三种侵入性更小或者更“自动化”的方案。
方案一:巧用 Jackson Mixin
Jackson 的 Mixin 是个很棒的功能,它允许你把注解“混入”到目标类中,而不需要直接修改目标类的源代码。对于第三方库或者像 OpenAPI Generator 生成的这些我们不想(或不能)直接修改的类,Mixin 简直是量身定做。
原理和作用:
你定义一个“Mixin 接口/抽象类”,在这个接口/抽象类上声明你想要应用到目标类上的注解(比如 @JsonSerialize
)。然后,通过配置 Jackson 的 ObjectMapper
,告诉它在处理某个目标类(比如生成的 ProductDTO.class
)时,要参考你定义的 Mixin 接口/抽象类上的注解。
操作步骤:
-
准备好你的自定义 Serializer:
假设我们有一个TwoDecimalSerializer
,用来把BigDecimal
格式化成两位小数的字符串。import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; import java.text.DecimalFormat; public class TwoDecimalSerializer extends JsonSerializer<BigDecimal> { // 使用 DecimalFormat 可以确保小数点后总是两位,即使是 .00 // 如果直接 setScale(2, RoundingMode.HALF_UP).toString() 整数可能没有小数点 private static final DecimalFormat formatter = new DecimalFormat("0.00"); @Override public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null) { gen.writeNull(); } else { // 设置舍入模式,然后格式化 BigDecimal roundedValue = value.setScale(2, RoundingMode.HALF_UP); gen.writeString(formatter.format(roundedValue)); } } }
-
定义 Mixin 接口/抽象类:
假设 OpenAPI Generator 生成了一个ProductDTO
,里面有个price
字段是BigDecimal
类型,你需要对它应用TwoDecimalSerializer
。import com.fasterxml.jackson.databind.annotation.JsonSerialize; import java.math.BigDecimal; // 假设你的 OpenAPI 生成的 DTO 在 com.example.generated.dto 包下 // Mixin 的目标是 ProductDTO 类 public abstract class ProductDTOMixin { // 重要:这里的字段名、类型必须和生成的 ProductDTO 中的完全一致! @JsonSerialize(using = TwoDecimalSerializer.class) private BigDecimal price; // 如果还需要对其他字段加注解,继续在这里声明 // 例如: @JsonProperty("product_name") // private String productName; }
注意: Mixin 类通常声明为
abstract
,并且里面的字段/方法签名要和目标类精确匹配。你只需要声明你想加注解的那些成员。 -
配置 ObjectMapper:
你需要获取到 Spring Boot 使用的那个ObjectMapper
实例,并告诉它使用你的 Mixin 配置。这通常在 Jackson 的配置类中完成。import com.example.generated.dto.ProductDTO; // 引入你生成的 DTO import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; @Configuration public class JacksonConfig { @Bean public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) { ObjectMapper objectMapper = builder.createXmlMapper(false).build(); // 告诉 ObjectMapper,处理 ProductDTO.class 时,应用 ProductDTOMixin 的注解 objectMapper.addMixIn(ProductDTO.class, ProductDTOMixin.class); // 如果有其他的 Mixin 配置,继续添加... // objectMapper.addMixIn(AnotherGeneratedDTO.class, AnotherDTOMixin.class); return objectMapper; } }
或者,如果你用的是 Spring Boot 自动配置的
ObjectMapper
,可以通过Jackson2ObjectMapperBuilderCustomizer
来添加 Mixin:import com.example.generated.dto.ProductDTO; // 引入你生成的 DTO import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer addCustomMixins() { return builder -> { builder.mixIn(ProductDTO.class, ProductDTOMixin.class); // builder.mixIn(AnotherGeneratedDTO.class, AnotherDTOMixin.class); }; } }
优点:
- 非侵入性: 完全不修改生成的代码,生成器重新生成代码后,Mixin 配置依然有效(只要目标类名和字段名不变)。
- 关注点分离: 序列化逻辑和 DTO 定义分开,更清晰。
- 灵活: 可以为一个类添加多个 Mixin,或者对父类、接口应用 Mixin。
缺点:
- 需要额外的配置代码来注册 Mixin。
- 如果生成的类名或字段名变化,需要同步更新 Mixin 定义和配置。
进阶使用技巧:
- 动态 Mixin: 在运行时根据条件决定是否应用某个 Mixin。
- 模块化 Mixin: 将相关的 Mixin 配置组织在 Jackson Module 中,方便管理。
方案二:定制 OpenAPI Generator 模板
如果你希望生成的代码“天生”就带有这些注解,可以直接修改 OpenAPI Generator 使用的模板文件(通常是 Mustache 模板)。
原理和作用:
OpenAPI Generator 使用模板引擎(如 Mustache)将 OpenAPI 规范映射成源代码。你可以提供自己修改过的模板文件,替换掉默认模板或其中的一部分。在模板里,你可以添加逻辑,根据 OpenAPI 规范中的某些信息(比如 Vendor Extensions)来决定是否在生成的字段上添加 @JsonSerialize
注解。
操作步骤:
-
找到并复制模板文件:
你需要知道你用的 generator(比如spring
)对应的模板在哪。可以通过运行openapi-generator-cli author template -g spring
找到。通常模型(DTO)相关的模板是pojo.mustache
或类似的文件。将你需要修改的模板文件复制到你项目的一个目录下,比如src/main/resources/openapi-templates/spring
。 -
在 OpenAPI 规范中添加标记 (Vendor Extension):
为了让模板知道哪个字段需要特殊处理,你需要在 OpenAPI 定义(YAML/JSON)中给对应的字段加上自定义扩展属性(Vendor Extension,通常以x-
开头)。components: schemas: ProductDTO: type: object properties: id: type: string format: uuid name: type: string price: type: number format: bigdecimal # 假设你用 bigdecimal 扩展格式 description: Product price # 在这里添加自定义扩展! x-jackson-serialize-using: "com.example.serializers.TwoDecimalSerializer" # 其他字段... required: - id - name - price
这里我们加了个
x-jackson-serialize-using
扩展,值为自定义 Serializer 的完整类名。 -
修改模板文件 (pojo.mustache):
打开你复制的pojo.mustache
(或其他相关模板),找到生成字段(vars
循环内)的部分。添加 Mustache 逻辑,检查是否存在x-jackson-serialize-using
扩展,如果存在,就在字段声明上方添加@JsonSerialize
注解。大致的修改可能像这样(具体位置和语法需要看模板原始结构):
{{#vars}} // ... 其他注解 ... {{#vendorExtensions.x-jackson-serialize-using}} // 如果存在 x-jackson-serialize-using 扩展 @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = {{vendorExtensions.x-jackson-serialize-using}}.class) {{/vendorExtensions.x-jackson-serialize-using}} // ... 原来的字段声明,比如: private {{datatypeWithEnum}} {{name}}; {{/vars}}
解释:
{{#vendorExtensions.x-jackson-serialize-using}} ... {{/}}
是 Mustache 的条件判断,检查vendorExtensions
对象下是否有x-jackson-serialize-using
这个 key。如果有,就输出里面的内容,即@JsonSerialize(...)
注解。{{vendorExtensions.x-jackson-serialize-using}}
会被替换成你在 YAML 里写的值。 *注意:*可能需要根据实际的 vendorExtension 访问方式调整路径,比如可能是{{vendorExtensions.x-jackson-serialize-using}}
或者{{#vendorExtensions}}{{^-first}}, {{/-first}}{{key}}={{value}}{{/vendorExtensions}}
这种更通用的循环。检查模板内其他vendorExtensions
的用法。 -
使用自定义模板生成代码:
在运行 OpenAPI Generator 时,通过-t
或--template-dir
参数指向你存放修改后模板的目录。openapi-generator-cli generate \ -g spring \ -i path/to/your/openapi.yaml \ -o ./generated-sources \ -t src/main/resources/openapi-templates/spring \ --additional-properties <your_other_configs>
优点:
- 生成的代码直接包含所需注解,开箱即用,不需要额外配置。
- 将序列化意图直接体现在 OpenAPI 规范中(通过 Vendor Extension),提高了规范的表达力。
缺点:
- 维护成本高: 你需要维护自己的模板副本。当 OpenAPI Generator 更新时,官方模板可能发生变化,你需要手动合并这些更新到你的定制模板中,否则可能导致生成错误或错过新特性。
- 复杂度: 修改模板需要理解 Mustache 语法和 OpenAPI Generator 的模板变量结构。
- 规范污染? Vendor Extension 是非标准的,过度使用可能让规范文件显得杂乱。
进阶使用技巧:
- 使用更复杂的 Vendor Extension 结构: 比如用一个对象
x-jackson-annotations: { serializeUsing: "...", jsonProperty: "..." }
来支持多种注解。 - 创建模板 Partial: 将添加注解的逻辑封装成 Mustache Partial,方便在多个地方复用。
- 结合 Generator 的
typeMappings
或importMappings
: 可能需要配合调整,确保自定义 Serializer 类能被正确导入。
方案三:全局配置 Jackson Module
如果你的自定义序列化逻辑是针对某个特定 类型 的(比如,项目中所有 BigDecimal
都需要格式化为两位小数),而不是仅仅针对某个 DTO 的某个字段,那么配置一个 Jackson Module
是个更简洁、更全局的方式。
原理和作用:
Jackson Module 允许你扩展 Jackson 的功能,比如注册自定义的 Serializer/Deserializer。你可以创建一个 Module,将你的 TwoDecimalSerializer
注册给 BigDecimal.class
类型。然后将这个 Module 注册到 ObjectMapper
中。之后,ObjectMapper
在序列化任何 BigDecimal
类型的值时,都会自动使用你提供的 TwoDecimalSerializer
。
操作步骤:
-
准备好你的自定义 Serializer: (同方案一的
TwoDecimalSerializer
)。 -
创建一个 Jackson Module 并注册 Serializer:
import com.fasterxml.jackson.core.Version; import com.fasterxml.jackson.databind.module.SimpleModule; import java.math.BigDecimal; public class CustomBigDecimalModule extends SimpleModule { private static final String NAME = "CustomBigDecimalModule"; private static final Version VERSION = Version.unknownVersion(); public CustomBigDecimalModule() { super(NAME, VERSION); // 将 TwoDecimalSerializer 注册给 BigDecimal 类型 addSerializer(BigDecimal.class, new TwoDecimalSerializer()); // 如果还需要为 BigDecimal 添加 Deserializer,也在这里添加 // addDeserializer(BigDecimal.class, new CustomBigDecimalDeserializer()); } }
-
将 Module 注册到 ObjectMapper:
同样,在你的 Jackson 配置类中,将这个 Module 添加到ObjectMapper
。import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; @Configuration public class JacksonConfig { @Bean public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) { ObjectMapper objectMapper = builder.createXmlMapper(false).build(); // 注册自定义 Module objectMapper.registerModule(new CustomBigDecimalModule()); // 如果有其他的 Module 或 Mixin 配置,继续添加... return objectMapper; } // 或者使用 Jackson2ObjectMapperBuilderCustomizer /* @Bean public Jackson2ObjectMapperBuilderCustomizer addCustomBigDecimalModule() { return builder -> builder.modulesToInstall(new CustomBigDecimalModule()); } */ // Spring Boot 也可以自动发现类型为 Module 的 Bean 并注册 /* @Bean public CustomBigDecimalModule customBigDecimalModule() { return new CustomBigDecimalModule(); } */ }
最简单的方式通常是直接把
CustomBigDecimalModule
声明为一个 Spring Bean,Spring Boot 的 Jackson 自动配置会发现并注册它。
优点:
- 全局应用: 一次配置,所有
BigDecimal
类型(或者你指定的类型)都生效,非常适合统一的格式化规则。 - 代码简洁: 只需要定义 Module 并注册,不需要为每个 DTO 或字段操心。
- 非侵入性: 不修改生成的 DTO 代码。
缺点:
- 缺乏粒度: 它是基于 类型 的,无法区分“这个 DTO 里的
BigDecimal
需要两位小数,那个 DTO 里的BigDecimal
需要原始精度”。如果需要字段级别的控制,这种方法就不适用了(或者需要写更复杂的上下文相关的 Serializer)。 - 可能产生意外影响: 如果项目中有地方期望
BigDecimal
是默认的序列化行为,全局配置可能会破坏它。
进阶使用技巧:
- Contextual Serializer: 在 Module 中注册一个
ContextualSerializer
,它可以根据序列化发生的上下文(比如哪个类的哪个字段)来动态选择或配置 Serializer,从而实现更细粒度的控制,但写法会复杂很多。
方案四:Mapper 层处理(最后的选择)
就像问题里提到的,你总是可以在数据转换层(比如 Service 层或使用 MapStruct/ModelMapper 的地方)手动处理。从生成的 ProductDTO
映射到你内部使用的 Domain Object 或者另一个 Response DTO 时,进行格式化。
// 伪代码示例
InternalProduct mapToInternal(GeneratedProductDTO generatedDto) {
InternalProduct internal = new InternalProduct();
// ... 其他字段映射 ...
if (generatedDto.getPrice() != null) {
// 在映射时进行格式化
BigDecimal formattedPrice = generatedDto.getPrice().setScale(2, RoundingMode.HALF_UP);
// 假设 InternalProduct 的 price 也是 BigDecimal,但你知道序列化时要用格式化后的
// 或者直接格式化成字符串存到目标对象中
internal.setFormattedPriceString(formatter.format(formattedPrice)); // formatter 是 TwoDecimalSerializer 用的那个 DecimalFormat
}
return internal;
}
优点:
- 完全解耦: 生成的 DTO 保持纯净,所有定制逻辑都在转换层。
- 灵活性最高: 可以在转换时应用任何复杂的逻辑。
缺点:
- 代码冗余: 如果很多 DTO 的很多字段都需要类似处理,会产生大量重复的格式化代码。
- 逻辑分散: 序列化/格式化逻辑散布在各个 Mapper 中,不易维护。
- 关注点混淆: Mapper 层应该主要负责结构转换,混入太多表现层的格式化逻辑可能不理想。
这个方法当然可行,但对于简单的注解驱动的序列化定制,前面几种方案通常更优雅、更符合关注点分离的原则。
总结一下
当遇到 OpenAPI Generator 生成的 DTO 需要类似 @JsonSerialize
的自定义序列化行为时,你有好几种选择:
- Jackson Mixin: 最推荐的方式之一。非侵入性,配置灵活,能精确控制到具体类的具体字段,保持生成代码的纯净。
- 定制 Generator 模板: 如果你想让生成的代码直接带上注解,并且愿意承担模板维护的成本,这是个强大的选项。通过 Vendor Extension 可以将定制意图保留在 OpenAPI 规范里。
- 全局 Jackson Module: 如果某个 类型 的序列化规则是全局统一的,这是最简洁的方式。配置简单,全局生效。
- Mapper 层处理: 作为最后的手段,或者当转换逻辑本身就很复杂时使用。代码直接,但可能导致冗余和逻辑分散。
根据你的具体场景、团队习惯以及对维护成本的考量,选择最适合你的方案吧!通常,Mixin 和 Module 能解决大部分问题,而模板定制则提供了终极的生成控制能力。