返回

搞定OpenAPI生成DTO的@JsonSerialize:4种方案

java

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 上,主要有以下几种思路:

  1. Jackson Mixin: 这是比较推荐的、非侵入式的方式。
  2. 定制 OpenAPI Generator 模板: 直接修改生成逻辑,在生成代码时就加上注解。
  3. 全局配置 Jackson Module: 如果某个类型的序列化方式是全局统一的,可以用这种方式。
  4. Mapper 层处理(原始思路): 在 DTO 转换逻辑中处理数据格式,作为最后的选择。

下面咱们重点聊聊前三种侵入性更小或者更“自动化”的方案。

方案一:巧用 Jackson Mixin

Jackson 的 Mixin 是个很棒的功能,它允许你把注解“混入”到目标类中,而不需要直接修改目标类的源代码。对于第三方库或者像 OpenAPI Generator 生成的这些我们不想(或不能)直接修改的类,Mixin 简直是量身定做。

原理和作用:

你定义一个“Mixin 接口/抽象类”,在这个接口/抽象类上声明你想要应用到目标类上的注解(比如 @JsonSerialize)。然后,通过配置 Jackson 的 ObjectMapper,告诉它在处理某个目标类(比如生成的 ProductDTO.class)时,要参考你定义的 Mixin 接口/抽象类上的注解。

操作步骤:

  1. 准备好你的自定义 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));
            }
        }
    }
    
  2. 定义 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,并且里面的字段/方法签名要和目标类精确匹配。你只需要声明你想加注解的那些成员。

  3. 配置 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 注解。

操作步骤:

  1. 找到并复制模板文件:
    你需要知道你用的 generator(比如 spring)对应的模板在哪。可以通过运行 openapi-generator-cli author template -g spring 找到。通常模型(DTO)相关的模板是 pojo.mustache 或类似的文件。将你需要修改的模板文件复制到你项目的一个目录下,比如 src/main/resources/openapi-templates/spring

  2. 在 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 的完整类名。

  3. 修改模板文件 (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 的用法。

  4. 使用自定义模板生成代码:
    在运行 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 的 typeMappingsimportMappings 可能需要配合调整,确保自定义 Serializer 类能被正确导入。

方案三:全局配置 Jackson Module

如果你的自定义序列化逻辑是针对某个特定 类型 的(比如,项目中所有 BigDecimal 都需要格式化为两位小数),而不是仅仅针对某个 DTO 的某个字段,那么配置一个 Jackson Module 是个更简洁、更全局的方式。

原理和作用:

Jackson Module 允许你扩展 Jackson 的功能,比如注册自定义的 Serializer/Deserializer。你可以创建一个 Module,将你的 TwoDecimalSerializer 注册给 BigDecimal.class 类型。然后将这个 Module 注册到 ObjectMapper 中。之后,ObjectMapper 在序列化任何 BigDecimal 类型的值时,都会自动使用你提供的 TwoDecimalSerializer

操作步骤:

  1. 准备好你的自定义 Serializer: (同方案一的 TwoDecimalSerializer)。

  2. 创建一个 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());
        }
    }
    
  3. 将 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 能解决大部分问题,而模板定制则提供了终极的生成控制能力。