从字段堆砌到类型建模:一个 PricingDetails 的重构实践

从字段堆砌到类型建模:一个 PricingDetails 的重构实践

最近在重构交易模块时,写了一个处理多交易类型定价详情的基类。写完后回头看,觉得这个设计有几个值得记录的点,正好借这个机会做个技术复盘。

一、问题背景:一张表,多种交易类型

我们系统里有一个交易记录表,存储了不同类型的交易数据------认购(Subscription)、赎回(Redemption)、业绩费(PerformanceFee)等。

问题来了:不同交易类型的定价字段完全不同

  • 认购需要:认购金额、认购份额、认购价格、认购费用......
  • 赎回需要:赎回份额、赎回金额、赎回价格、赎回费用、结算金额......
  • 业绩费只需要:业绩费金额

如果用传统思路,可能会这样设计:

java 复制代码
public class PricingDetails {
    private String transactionType;
    
    // 认购字段
    private BigDecimal subscriptionAmount;
    private BigDecimal subscriptionUnits;
    private BigDecimal subscriptionFee;
    
    // 赎回字段
    private BigDecimal redemptionUnits;
    private BigDecimal redemptionAmount;
    private BigDecimal redemptionFee;
    
    // 业绩费字段
    private BigDecimal performanceFee;
    
    // ... 还有更多
}

这种写法我以前也写过,说实话,能用,但很丑

二、传统写法的问题

问题1:字段爆炸,语义混乱

一个类里塞了几十个字段,大部分时候都是null。看代码的人根本不知道哪个字段属于哪种交易类型,只能靠注释和命名猜测。

问题2:类型安全缺失

java 复制代码
PricingDetails details = getPricingDetails();
// 认购场景下,赎回相关字段全是null,但编译器不会告诉你
BigDecimal redemptionAmount = details.getRedemptionAmount(); // 运行时才发现是null

问题3:JSON序列化尴尬

存数据库时,你希望只序列化当前类型相关的字段。但传统写法会把所有字段都序列化出去,JSON里一堆null,既浪费存储,又给下游解析增加负担。

问题4:业务逻辑耦合

不同交易类型的计算逻辑散落在各处,每次加新类型都要改这个大类,违反了开闭原则。

三、这个类的设计思路

先看代码结构:

java 复制代码
/**
 * Cis transaction 定价详情基类
 * <p>
 * use:
 * <p>
 * if(source instanceof PricingDetails.SubscriptionPricingDetails target){}
 *
 */
@JsonTypeInfo(
        use = JsonTypeInfo.Id.NAME,
        property = "transactionType",
        include = JsonTypeInfo.As.EXISTING_PROPERTY,
        visible = true
)
@JsonSubTypes({
        @JsonSubTypes.Type(value = PricingDetails.SubscriptionPricingDetails.class, name = "Subscription"),
        @JsonSubTypes.Type(value = PricingDetails.RedemptionPricingDetails.class, name = "Redemption"),
        @JsonSubTypes.Type(value = PricingDetails.PerformanceFeePricingDetails.class, name = "PerformanceFee")
})
@Getter
@Setter
@SuperBuilder
@NoArgsConstructor
@ToString
public abstract class PricingDetails implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;

    /**
     * 交易类型(用于Jackson类型识别)
     */
    @JsonProperty("transactionType")
    private String transactionType;

    // ==================== 内部子类 ====================

    /**
     * 订阅类型的定价详情
     */
    @Getter
    @Setter
    @SuperBuilder
    @NoArgsConstructor
    @ToString(callSuper = true)
    public static class SubscriptionPricingDetails extends PricingDetails {

        /**
         * 订阅金额
         */
        private BigDecimal subscriptionAmount;

        private BigDecimal paymentAmount;

        /**
         * 订阅份额
         */
        private BigDecimal subscriptionUnits;

        /**
         * 订阅份额价格
         */
        private BigDecimal subscriptionUnitsPrice;

        /**
         * 订阅费用
         */
        private BigDecimal subscriptionFee;

    }

    /**
     * 赎回类型的定价详情
     */
    @Getter
    @Setter
    @SuperBuilder
    @NoArgsConstructor
    @ToString(callSuper = true)
    public static class RedemptionPricingDetails extends PricingDetails {

        /**
         * 赎回份额
         */
        private BigDecimal redemptionUnits;

        private BigDecimal confirmRedemptionUnits;

        /**
         * 赎回份额价格
         */
        private BigDecimal redemptionUnitsPrice;

        /**
         * 赎回金额
         */
        private BigDecimal redemptionAmount;

        /**
         * 赎回费用
         */
        private BigDecimal redemptionFee;

        /**
         * 表现费
         */
        private BigDecimal performanceFee;

        /**
         * 结算费用
         */
        private BigDecimal settleAmount;

    }

    /**
     * 业绩费类型的定价详情
     */
    @Getter
    @Setter
    @SuperBuilder
    @NoArgsConstructor
    @ToString(callSuper = true)
    public static class PerformanceFeePricingDetails extends PricingDetails {

        /**
         * 表现费金额
         */
        private BigDecimal performanceFee;

    }
}

核心设计:Jackson多态序列化 + 内部类继承

这里的关键是@JsonTypeInfo@JsonSubTypes的组合使用。

@JsonTypeInfo告诉Jackson: 根据JSON里的transactionType字段值,决定反序列化成哪个子类。

  • use = JsonTypeInfo.Id.NAME:使用名称作为类型标识
  • property = "transactionType":用哪个字段做类型判断
  • include = JsonTypeInfo.As.EXISTING_PROPERTY:这个字段已经存在于类中,不要额外生成
  • visible = true:反序列化时保留这个字段的值

@JsonSubTypes定义映射关系:

  • "Subscription"SubscriptionPricingDetails
  • "Redemption"RedemptionPricingDetails
  • "PerformanceFee"PerformanceFeePricingDetails

实际效果

序列化时:

java 复制代码
SubscriptionPricingDetails details = SubscriptionPricingDetails.builder()
    .transactionType("Subscription")
    .subscriptionAmount(new BigDecimal("10000"))
    .subscriptionUnits(new BigDecimal("1000"))
    .build();

// 序列化结果
{
    "transactionType": "Subscription",
    "subscriptionAmount": 10000,
    "subscriptionUnits": 1000
}
// 只有认购相关字段,干净利落

反序列化时:

java 复制代码
String json = "{\"transactionType\":\"Redemption\",\"redemptionUnits\":500}";
PricingDetails details = objectMapper.readValue(json, PricingDetails.class);
// details 实际类型是 RedemptionPricingDetails

// 配合模式匹配,类型安全
if (details instanceof PricingDetails.RedemptionPricingDetails redemption) {
    BigDecimal units = redemption.getRedemptionUnits(); // 编译期类型安全
}

四、设计亮点分析

1. 类型安全 + 多态

这是最大的收益。以前用一个大类,字段全是Optional或nullable,现在每个子类只包含自己需要的字段。配合Java 16的模式匹配(代码注释里也写了),类型判断和变量绑定一步到位。

java 复制代码
if (source instanceof PricingDetails.SubscriptionPricingDetails target) {
    // target 已经是具体类型,直接用
}

2. 存储与业务解耦

我们用的是MySQL + JSON字段存储。数据库层面只需要一个JSON列,但应用层面是强类型的对象。Jackson自动完成类型识别和转换,开发者不需要关心序列化细节。

3. 扩展性良好

新增交易类型时:

  1. 加一个内部类继承PricingDetails
  2. @JsonSubTypes里加一行映射
  3. 完事

不需要改数据库schema,不需要改现有业务逻辑。

4. 内部类的设计选择

为什么用内部静态类而不是独立类?

  • 内聚性 :这些子类只服务于PricingDetails,放在一起语义更清晰
  • 访问控制:可以控制子类的可见性
  • 代码组织:一个文件搞定,不用跳来跳去

当然,如果子类逻辑很复杂(比如几百行),还是应该抽出去。

5. Lombok @SuperBuilder

继承体系下用@Builder会有坑------父类字段不会被包含在builder里。@SuperBuilder解决了这个问题,子类可以链式设置父类字段。

java 复制代码
SubscriptionPricingDetails details = SubscriptionPricingDetails.builder()
    .transactionType("Subscription")  // 父类字段
    .subscriptionAmount(amount)       // 子类字段
    .build();

五、适用场景与边界

适合的场景

  1. 同一数据源,多种类型:比如这里的交易表,或者日志表、事件表
  2. 类型数量可控:3-10种比较合适,再多维护成本会增加
  3. 字段差异明显:如果各类型字段重叠度很高,这个设计的收益就有限
  4. JSON存储:配合MySQL 5.7+的JSON类型,或者MongoDB等文档数据库

需要谨慎的场景

  1. 类型频繁变化:每次加类型都要改基类,违反开闭原则(后面会说优化方案)
  2. 子类逻辑复杂:如果每个子类有大量业务逻辑,内部类会变得臃肿
  3. 需要独立查询子类字段:JSON字段里的内容不好建索引,查询性能受限

潜在风险

  1. 类型识别失败 :如果transactionType值不在映射表里,Jackson会抛异常。需要做好异常处理和默认值策略。

  2. 向后兼容:新增字段还好,但如果要删除或重命名字段,要考虑老数据兼容性。

  3. 调试困难 :JSON反序列化出错时,堆栈可能不太直观。建议在开发环境开启Jackson的FAIL_ON_UNKNOWN_PROPERTIES

六、如果继续优化

说实话,这个设计还有改进空间。

优化1:类型枚举化

现在transactionType是String,容易写错。可以改成枚举:

java 复制代码
public enum TransactionType {
    SUBSCRIPTION("Subscription"),
    REDEMPTION("Redemption"),
    PERFORMANCE_FEE("PerformanceFee");
    
    private final String code;
    // ...
}

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "transactionType")
// Jackson支持枚举作为类型标识

优化2:注册机制替代硬编码

@JsonSubTypes的硬编码是开闭原则的软肋。可以改成:

java 复制代码
// 自定义TypeResolver
public class PricingDetailsTypeResolver extends TypeIdResolverBase {
    private static final Map<String, Class<?>> TYPE_MAP = new HashMap<>();
    
    public static void register(String type, Class<?> clazz) {
        TYPE_MAP.put(type, clazz);
    }
    
    // 运行时动态注册,不需要改基类
}

这样新增类型时,只需要在自己的模块里注册,完全解耦。

优化3:领域行为下沉

现在的设计只是数据载体(贫血模型)。如果业务复杂,可以把行为也下沉到子类:

java 复制代码
public abstract class PricingDetails {
    // 模板方法
    public abstract BigDecimal calculateTotalAmount();
    public abstract void validate();
}

public class SubscriptionPricingDetails extends PricingDetails {
    @Override
    public BigDecimal calculateTotalAmount() {
        return subscriptionAmount.add(subscriptionFee);
    }
}

优化4:考虑Sealed Class(Java 17+)

如果用Java 17,可以用sealed关键字明确限定子类范围:

java 复制代码
public sealed abstract class PricingDetails 
    permits SubscriptionPricingDetails, 
            RedemptionPricingDetails, 
            PerformanceFeePricingDetails {
    // 编译期保证只有这三个子类,模式匹配时可以穷举
}

配合switch表达式,编译器会帮你检查是否覆盖所有类型。

七、总结

这个设计本质上是在存储灵活性和类型安全之间找平衡

传统的一个大类写法,存储灵活但类型不安全;完全拆成多张表,类型安全但存储冗余、查询复杂。Jackson的多态序列化提供了一个中间方案:存储层面用一个JSON字段,应用层面是强类型的继承体系。

当然,没有银弹。这种方案适合字段差异大、类型数量可控、查询需求简单的场景。如果你的业务需要频繁按子类字段查询,或者类型经常变化,可能需要重新评估。

最后说一句:设计模式不是目的,解决问题才是。这个类的设计不是为了炫技,而是为了解决实际业务痛点。如果场景简单,一个类加几个if-else可能更直接。过度设计也是一种罪过。


以上是我在实际项目中的一点思考,欢迎交流讨论。

相关推荐
yinyan13141 小时前
一起学springAI系列一:使用多种聊天模型
java·人工智能·spring boot·后端·spring·springai
de_wizard2 小时前
Spring Boot 项目开发流程全解析
java·spring boot·log4j
阿成学长_Cain2 小时前
Linux 命令:ldconfig —— 动态链接库管理命令
java·开发语言·spring
认真的小羽❅2 小时前
SSE服务器推送事件原理深度解析与实战应用
java·网络
dreamxian2 小时前
苍穹外卖day07
java·spring boot·后端·spring·mybatis
流水武qin2 小时前
SpringAI 使用 RAG
java·spring boot·spring·ai
wayz112 小时前
正则表达式:从入门到精通
java·python·正则表达式·编辑器
网安2311石仁杰2 小时前
深入解析OWASP ZAP:从软件工程视角看安全扫描器的架构设计
java·安全·软件工程
bbq粉刷匠2 小时前
Java--多线程--线程安全3
java·开发语言