从字段堆砌到类型建模:一个 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. 扩展性良好
新增交易类型时:
- 加一个内部类继承
PricingDetails - 在
@JsonSubTypes里加一行映射 - 完事
不需要改数据库schema,不需要改现有业务逻辑。
4. 内部类的设计选择
为什么用内部静态类而不是独立类?
- 内聚性 :这些子类只服务于
PricingDetails,放在一起语义更清晰 - 访问控制:可以控制子类的可见性
- 代码组织:一个文件搞定,不用跳来跳去
当然,如果子类逻辑很复杂(比如几百行),还是应该抽出去。
5. Lombok @SuperBuilder
继承体系下用@Builder会有坑------父类字段不会被包含在builder里。@SuperBuilder解决了这个问题,子类可以链式设置父类字段。
java
SubscriptionPricingDetails details = SubscriptionPricingDetails.builder()
.transactionType("Subscription") // 父类字段
.subscriptionAmount(amount) // 子类字段
.build();
五、适用场景与边界
适合的场景
- 同一数据源,多种类型:比如这里的交易表,或者日志表、事件表
- 类型数量可控:3-10种比较合适,再多维护成本会增加
- 字段差异明显:如果各类型字段重叠度很高,这个设计的收益就有限
- JSON存储:配合MySQL 5.7+的JSON类型,或者MongoDB等文档数据库
需要谨慎的场景
- 类型频繁变化:每次加类型都要改基类,违反开闭原则(后面会说优化方案)
- 子类逻辑复杂:如果每个子类有大量业务逻辑,内部类会变得臃肿
- 需要独立查询子类字段:JSON字段里的内容不好建索引,查询性能受限
潜在风险
-
类型识别失败 :如果
transactionType值不在映射表里,Jackson会抛异常。需要做好异常处理和默认值策略。 -
向后兼容:新增字段还好,但如果要删除或重命名字段,要考虑老数据兼容性。
-
调试困难 :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可能更直接。过度设计也是一种罪过。
以上是我在实际项目中的一点思考,欢迎交流讨论。