代码重构(Refactoring)不是“把旧代码重写一遍”,也不是单纯追求更短或更漂亮的代码。它是在外部可观察行为基本不变的前提下,调整代码内部结构,让理解、修改和验证变得更容易。
这里的“行为不变”不只指返回值相同,还包括异常类型、持久化结果、消息发送顺序、性能边界和对外接口等。一次好的重构至少应满足两个条件:原有业务行为有可靠的验证手段;重构后,某类真实变化的成本确实下降。
因此,重构不是一次性清理活动,而是一种围绕变化持续整理代码的工作方式。本文从问题信号开始,介绍常用操作、数据建模、安全流程,并用订单折扣案例串起完整过程。
一、什么时候值得重构
重构最有价值的时机,通常是在准备修改一段代码时。你已经需要理解它,也知道新的变化从哪里进入,此时顺手改善结构,比专门安排一次没有业务目标的大扫除更容易控制范围。
常见时机包括:增加功能前,为新的变化点腾出位置;修复缺陷时,先让错误路径变得可见;代码审查时,处理阻碍理解的局部结构;性能优化前,把计算边界和测量点分离出来。
可以先观察几个现实信号:
- 同一规则散落在项目各处,例如在多个模块中写了token的解析逻辑。
- 一个小需求需要修改许多不相干的类。
- 函数必须滚动多屏,局部变量反复改变含义。
- 调用者必须穿过很长的对象链,才能取得真正需要的数据。
- 一个类同时因为数据库、业务规则、展示格式等不同原因变化。
- 新增一个类型时,总要同步修改一组条件分支或平行子类。
但“看起来不优雅”并不等于值得立即重构。即将废弃的模块、一次性迁移脚本、长期稳定且从不修改的代码,收益可能不足;没有任何行为保护的核心结算流程,也不适合直接做大规模结构调整。先判断变化频率、失败代价和验证能力,再决定投入多少。
二、常见问题
代码坏味道不是缺陷判决书,而是调查入口。下面按变化、函数、职责、耦合、数据和继承归类。每一种都要同时看问题信号、改进方向和风险提醒,避免把经验法则变成机械规则。
1. 重复与变化
重复代码。 问题信号是两处以上出现相同结构,而且表达同一条业务规则;改进方向是先确认它们是否会因同一原因变化,再提取函数、移动到共同对象,或建立明确的规则入口;风险是仅凭文本相似就合并,反而把原本独立的概念绑在一起。测试代码中的少量重复有时能提高场景可读性,也不必强行消除。
修改发散。 一个类经常因多种无关原因改变,例如增加数据库要改三处,调整导出格式又要改另外四处。问题本质是一个模块容纳了多个变化轴;可以提取类、移动方法和字段,让同一变化原因集中;拆分前应先识别稳定边界,否则只会把一个大类切成互相频繁调用的碎片。
散弹式修改。 与修改发散相反,它表现为一次概念上的变化要在许多类中各改一点,例如新增折扣状态要同时改计算、展示、校验和持久化映射。应寻找被拆散的概念,将相关规则移动到单一入口或值对象中;但若修改天然跨越数据库、接口和界面等系统边界,文件数量多不一定代表设计错误,重点是每一层是否只承担自己的转换职责。
平行继承体系。 每给一个体系增加子类,就必须给另一个体系增加对应子类,这是散弹式修改的特殊形式。可以让一个层次引用另一个层次、合并真正一一对应的职责,或用组合替代同步继承;不要只为减少类数量而合并具有不同生命周期的概念。
2. 函数与接口
过长函数。 问题信号不是固定行数,而是函数包含多个抽象层次,读者无法快速概括主流程,或一部分逻辑难以单独测试。优先提取有业务含义的步骤、减少共享临时变量,让顶层函数成为流程摘要;如果拆出的函数只能叫 part1、参数比原函数还多,说明边界尚未找准。
过长参数列表。 多个调用点反复传递同一组参数,参数顺序容易出错,新增数据又会扩散签名修改。可以传入调用者已经拥有的对象、建立参数对象,或让方法向合适的对象查询;但不要把毫无关系的参数塞进一个万能上下文对象,那只是隐藏依赖。
复杂分支。 当同一 if 或 switch 随类型增加持续膨胀,且相似判断散落各处,变化风险就会升高。可以先提取条件和分支函数,再根据变化频率选择策略对象或多态;简单、封闭、容易读懂的分支完全可以保留,并非所有 switch 都需要替换。
接口不一致。 两个类做相近的事,却使用不同的方法名、参数顺序或返回约定,调用者不得不记忆适配细节。可以统一命名和签名,或在边界提供小型适配器;若两个操作只是表面相似、语义和失败方式不同,强行统一会抹掉重要差异。
注释掩盖复杂度。 注释本身不是坏味道。问题在于长段注释只是在翻译难懂的代码,或者解释某段补丁为何“千万不能动”。先用命名、提取函数和显式类型表达意图,再保留决策原因、约束来源和非显然权衡;不要删除有价值的业务背景,也不要假设所有代码都能做到完全自解释。
3. 类的职责
过大类。 大量字段只被不同方法子集使用,类同时处理规则、存储、格式化和外部通信,通常说明职责混合。可以根据字段和方法的聚集关系提取类,并把行为移到真正拥有数据的对象;类的行数不是唯一标准,一个职责单一但规则丰富的类可能很长,拆分应服务于变化边界。
懒惰类。 某个类曾经有职责,但重构后只剩一两个无意义转发;或者它只是为从未出现的变化预留空壳。可以内联到主要使用者或直接删除;如果它承担明确的架构边界、权限隔离或替换点,即使代码少也可能有独立价值。
数据类。 只有字段和访问器的对象值得检查,因为真正的业务规则可能散落在外部服务中。可以把与数据紧密相关的校验、派生值和状态变化移回对象,并限制任意 setter;但数据传输对象、数据库映射和事件载荷本来就以承载数据为职责,不能一概视为问题。
临时字段。 某些实例字段只在特定流程中有值,其余时间为空,读者难以判断对象何时有效。可以把这组字段和相关计算提取为一次性的方法对象,或用不同状态类型表达;若框架生命周期明确要求延迟初始化,应通过命名和不变量说明,而不是为了消灭空值破坏框架约定。
过度设计。 为“也许以后”建立未使用的钩子、接口、抽象基类和配置项,会增加当前理解成本。删除未被真实变化验证的层次,等第二个用例出现后再提炼共同点;但对安全、合规、跨团队协议等高代价边界,适度的前置设计仍然合理,不能把所有扩展性都当成浪费。
4. 对象之间的耦合
依恋情结。 一个方法使用另一个类的数据远多于自身数据,往往说明行为放错了位置。可以把方法或其中一段移动到它真正依赖的对象,再保留必要的协调逻辑;涉及多个对象的领域规则未必属于其中任意一个,强行移动可能造成新的偏爱,此时独立服务更合适。
消息链。 customer.getOrder().getPayment().getCard().lastFourDigits() 让调用者依赖整条导航结构,中间任一关系变化都会影响它。可以由靠近数据的对象提供意图明确的方法,例如 customer.lastPaymentCardDigits();不过流式处理、构建器和不可变值的链式调用通常是稳定接口,不应只凭点号数量判断。
中间人。 一个类的大部分方法只是原样委托给另一个对象,没有增加规则、隔离或转换价值。可以移除部分委托,让调用者直接使用真正的服务;但隐藏第三方类型、事务边界和权限检查都是有意义的中介职责,不能只按转发比例删除。
过度亲密。 两个类频繁读取彼此内部状态、依赖私有细节,任何一方变化都会牵动另一方。可以移动方法和字段、缩小可见性,或提取双方共享的概念;如果它们事实上构成一个内聚整体,也可以考虑合并,但要留意合并后是否承担了更多变化原因。
第三方库能力不足。 无法修改的库类缺少一两个符合本地语义的操作,调用处于是反复拼装低层 API。少量需求可用外加方法,成组需求可建立本地扩展封装;不要修改依赖源码或让库的特殊约定渗透全局,同时要评估升级后自定义封装是否仍然必要。
5. 数据与继承
数据泥团。 地址的省市区、时间段的起止时间、金额的数值与币种,总是成组出现在字段和参数中。可以提取参数对象或值对象,把组内约束集中起来;仅仅同时出现还不够,只有它们共同表达一个稳定概念时才值得成组。
基本类型偏执。 订单号、邮箱、百分比等都使用 String 或数字,导致格式、单位和校验规则散落。可用小型值对象表达含义,让非法状态难以创建;对没有约束、只在局部传递的数据,引入类型可能只增加映射成本。
拒绝继承。 子类只接受父类少量实现,却拒绝父类接口语义,调用者无法把它安全当作父类使用。若违反替换关系,应改用组合、提取真正共享的能力,或重新划分层次;子类不使用父类的某些便利方法并不自动构成问题,关键是它是否兑现父类型契约。
三、常用重构方法
重构方法是小步操作,不是必须套用的配方。选择它们时先明确希望降低哪一种变化成本,并在每一步之后验证行为。
1. 提取函数
当一段代码需要注释才能说明意图、可独立命名,或将在别处复用时,可以提取函数(Extract Function):
void printOwing(Money amount) { printBanner(); printDetails(amount);}
private void printDetails(Money amount) { System.out.println("name: " + name); System.out.println("amount: " + amount);}- 适用条件: 能用一个准确名称概括代码意图,提取后上层流程更清楚。
- 改进方向: 先处理无副作用的计算,再处理依赖外部状态的步骤;必要时先整理参数。
- 不适用情况: 代码与大量局部变量纠缠,提取后产生过长参数列表。此时应先拆分临时变量,或进一步使用方法对象。
2. 用查询替代临时变量
临时变量若只是保存一个可重复计算的派生结果,会限制后续函数提取。可用查询函数(Replace Temp with Query)表达计算:
Money total() { return basePrice().subtract(discount());}
private Money basePrice() { return unitPrice.multiply(quantity);}
private Money discount() { return basePrice().isGreaterThan(Money.of("1000")) ? basePrice().multiply("0.10") : Money.zero();}- 适用条件: 表达式无副作用、含义稳定,重复调用不会改变结果。
- 改进方向: 用领域名称命名查询,使调用者依赖含义而非计算细节。
- 不适用情况: 查询昂贵且位于高频路径,或读取随时变化的外部状态。应先测量性能,必要时显式缓存,而不是假定重复计算总是免费。
3. 引入解释性变量
复杂条件或算式本身不值得提取成公共函数,却需要标出各部分含义时,可引入解释性变量(Introduce Explaining Variable):
boolean canRegister(User user) { boolean ageIsValid = user.age() >= 18 && user.age() <= 120; boolean emailIsValid = user.email() != null && user.email().contains("@"); boolean hasContact = user.phone() != null || user.secondaryPhone() != null; return ageIsValid && emailIsValid && hasContact;}- 适用条件: 一个表达式包含多个值得命名的业务判断,变量能显著提高局部可读性。
- 改进方向: 对跨处复用或需要单独测试的规则,继续提取为查询函数或值对象。
- 不适用情况: 变量只是重复右侧语法,或作用域很长、值会被修改;这种变量会制造新的追踪负担。
4. 拆分临时变量
同一个临时变量先表示速度、后来又表示位移,会让数据流难以追踪。除循环累加器外,一个变量最好只承担一种含义:
double position(double initialVelocity, double acceleration, double time) { double velocityAfterTime = initialVelocity + acceleration * time; double displacement = initialVelocity * time + 0.5 * acceleration * time * time; logVelocity(velocityAfterTime); return displacement;}- 适用条件: 变量被多次赋值,且每次赋值代表不同概念或不同阶段。
- 改进方向: 为每个含义建立独立且尽量不可变的变量,再观察能否提取计算。
- 不适用情况: 循环计数器、累加器和明确的状态机变量本来就描述变化过程,机械拆分反而更难读。
5. 避免给参数赋值
参数既代表调用者输入,又被当作局部结果覆盖,容易让读者误判原值是否仍可用。可保留参数不变,使用新变量或提前返回:
Money applyMinimum(Money amount) { Money normalized = amount.isNegative() ? Money.zero() : amount; return normalized.max(Money.of("10"));}- 适用条件: 方法中出现对参数的重新赋值,后续逻辑又依赖新旧含义。
- 改进方向: 引入准确命名的局部变量,或拆成输入规范化与业务计算两步。
- 不适用情况: 某些语言或性能敏感代码刻意复用变量;Java 中通常没有必要为了微小节省牺牲清晰度。
6. 用方法对象替代复杂函数
一个长函数包含大量互相依赖的局部变量,普通提取会传递十几个参数时,可使用方法对象(Replace Method with Method Object):把一次计算放入专用对象,局部变量变成字段,计算步骤就能继续提取。
final class PriceCalculation { private final Order order; private Money price;
PriceCalculation(Order order) { this.order = order; this.price = order.total(); }
Money calculate() { applyVipDiscount(); applyCoupon(); return price.max(Money.zero()); }
private void applyVipDiscount() { /* 更新 price */ } private void applyCoupon() { /* 更新 price */ }}- 适用条件: 计算有多个阶段,局部状态阻碍提取,但这些状态只服务于一次执行。
- 改进方向: 保持对象生命周期短,把它视为计算边界,而不是新的长期领域实体。
- 不适用情况: 函数只是因为命名差或混合了少量步骤而显长,直接提取函数会更简单。
7. 替换算法
当现有算法正确但难以理解,而有同样行为的清晰实现时,可整体替换算法(Substitute Algorithm):
String findCandidate(List<String> people) { Set<String> candidates = Set.of("Don", "John", "Kent"); return people.stream() .filter(candidates::contains) .findFirst() .orElse("");}- 适用条件: 输入输出和边界行为已由测试固定,新实现更容易证明正确。
- 改进方向: 先把旧算法隔离到单一函数,再一次替换,并比较空值、重复项和顺序等行为。
- 不适用情况: 新算法只是在语法上新潮,却改变顺序、复杂度或异常约定;可读性提升不能掩盖语义变化。
8. 移动方法与移动字段
如果方法主要使用另一个类的数据,或者字段的大多数读写都发生在当前类之外,可以把它们移动到更内聚的对象。移动时先缩小依赖,再迁移测试和调用点,最后删除旧入口。
- 适用条件: 依赖关系长期稳定,移动后参数减少、规则更靠近其数据。
- 改进方向: 方法与字段通常一起观察;先移动行为,再判断数据归属,避免只搬字段却保留远程操控。
- 不适用情况: 规则协调多个聚合、外部系统或事务,独立服务可能比任选一个对象安放更合理。
9. 提取类与内联类
提取类(Extract Class)用于拆开不同变化原因;内联类(Inline Class)则把已经失去独立职责的小类合回主要使用者。二者并不矛盾,而是在当前职责边界下寻找更低的协作成本。
- 适用条件: 提取时存在清晰的字段和行为子集;内联时目标类只服务一个调用者且没有边界价值。
- 改进方向: 小步移动方法和字段,保持每一步可编译,不要一次重写所有调用者。
- 不适用情况: 仅按行数拆类或合类。类是否独立取决于语义、生命周期和变化原因,而不是代码量。
10. 隐藏委托与移除中间人
隐藏委托(Hide Delegate)让上层对象提供意图接口,避免调用者依赖内部导航:
final class Person { private final Department department;
Person(Department department) { this.department = department; }
Manager manager() { return department.manager(); }}如果 Person 后来出现大量不增加价值的转发,又可以移除中间人(Remove Middle Man),让特定调用者直接依赖 Department。这两个方法方向相反,选择依据是边界是否稳定,而不是追求最短调用链。
- 适用条件: 隐藏委托适合隔离易变内部结构;移除中间人适合转发层已经成为扩展阻碍。
- 改进方向: 只隐藏调用者真正需要的意图,不要为委托对象的每个方法都建立镜像。
- 不适用情况: 中间层承担鉴权、事务、日志或类型隔离时,转发不是无价值工作。
11. 外加方法与本地扩展
第三方类型缺少一个简单操作,而我们又不能修改它时,可以在客户端边界增加外加方法(Introduce Foreign Method)。现代 Java 应使用 java.time,而不是过时的 Date 构造器:
final class ReportDates { private ReportDates() {}
static LocalDate nextDay(LocalDate date) { return date.plusDays(1); }}如果围绕第三方类型需要一组操作,可建立本地扩展(Introduce Local Extension),通常优先包装而非继承,以便控制暴露面:
record BusinessDate(LocalDate value) { BusinessDate nextBusinessDay() { LocalDate next = value.plusDays(1); while (next.getDayOfWeek() == DayOfWeek.SATURDAY || next.getDayOfWeek() == DayOfWeek.SUNDAY) { next = next.plusDays(1); } return new BusinessDate(next); }}- 适用条件: 外部类型确实缺少本地业务语义,且无法合理提交到原库。
- 改进方向: 一两个无状态操作用外加方法;操作成组、需要维护不变量时使用包装型本地扩展。
- 不适用情况: 不要复制库已经提供的能力,也不要让包装器无限扩张成第二套通用 API。
12. 根据阻力选择方法
面对一段难改的代码,不必先给它贴上唯一的坏味道标签。更实用的做法是观察修改时遇到的阻力,再选择最小操作。
如果读不懂主流程,先提取函数、引入解释性变量;如果函数无法提取是因为局部变量反复改写,先拆分临时变量、避免给参数赋值;如果提取后参数仍然很多,检查是否存在数据泥团,或者改用短生命周期的方法对象。这个顺序从局部整理开始,不会过早改变对象边界。
如果一个需求必须修改许多位置,先找出被散落的业务概念,再考虑移动方法、移动字段或提取类;如果一个类因许多不同理由变化,则从变化原因切分职责。如果阻力来自对象导航结构,隐藏委托可以保护稳定边界;若大量转发反而限制调用者,就移除中间人。两种方向都可能正确,关键是希望隔离哪一侧的变化。
数据相关问题通常应更谨慎。基本类型缺少单位和约束时,引入值对象;不同字段只因传输方便被塞进数组时,用具名对象替代;集合存在增删规则时,封装集合。不要一开始就建立庞大领域模型,先让一种非法状态无法出现,往往比添加许多类更有价值。
最后检查重构尺度:若一次操作同时改变职责、数据表示和对外协议,应拆成连续步骤。每一步都应能说明“现在哪一种修改更容易”,而不是只说“代码更优雅”。当一个局部方法已经解决阻力,就不必升级为类级或架构级调整。
四、数据与对象重构
函数结构清楚之后,仍可能因数据模型过弱而反复出现校验和转换。本节关注数据的身份、相等性、关联和集合边界。
1. 值对象与实体
值对象(Value Object)由属性决定相等性,通常不可变,例如金额、日期区间和邮箱;实体(Entity)则由稳定身份区分,即使属性相同也可能是两个不同对象,例如订单和客户。
public record Money(BigDecimal amount, Currency currency) { public Money { Objects.requireNonNull(amount); Objects.requireNonNull(currency); amount = amount.setScale(2, RoundingMode.HALF_UP); }}
public final class Order { private final OrderId id;
public Order(OrderId id) { this.id = Objects.requireNonNull(id); }
@Override public boolean equals(Object other) { return other instanceof Order order && id.equals(order.id); }
@Override public int hashCode() { return id.hashCode(); }}值对象不要求全局只有一个实例;两个 Money 实例只要金额和币种相同,就可以相等。实体也不应简单依赖 Java 的 == 判断业务身份,因为对象可能经过持久化重新加载。缓存或工厂驻留有性能和规范化用途,但不是值对象正确性的前提。
- 适用条件: 概念有明确约束、单位或相等性规则,且在多个边界间传递。
- 改进方向: 值对象优先不可变并实现基于属性的相等性;实体用稳定标识表达连续身份。
- 风险提醒: 不要把可变对象放入依赖
hashCode的集合后再修改相等性字段,也不要把数据库主键是否已生成混同于完整业务身份。
2. 用对象替代数据值
当 String customerName 逐渐需要客户编号、等级和校验时,可以用对象替代数据值(Replace Data Value with Object)。迁移时先建立新类型,再逐个替换字段与调用点,最后移除旧表示。
public record CustomerId(String value) { public CustomerId { if (value == null || value.isBlank()) { throw new IllegalArgumentException("Customer id is required"); } }}
public final class Order { private final CustomerId customerId;
public Order(CustomerId customerId) { this.customerId = customerId; }}- 适用条件: 基本值已有多个相关规则,或同类型参数容易传错。
- 改进方向: 先建立最小类型和转换边界,等真实行为出现后再放入更多规则。
- 风险提醒: 不要为了面向对象而复制整份客户资料到订单;订单需要客户身份时,保存标识通常比持有可变客户对象更稳妥。
3. 用对象替代异构数组
数组适合按顺序存放同类元素,不适合用下标暗示不同字段。row[0] 是队名、row[1] 是胜场的约定,既缺少类型也难以演进:
final class PerformanceExample { record Performance(String teamName, int wins) {}
static Performance sample() { return new Performance("Liverpool", 15); }}- 适用条件: 固定位置承载不同含义,调用者必须记住下标约定。
- 改进方向: 建立具名字段的记录或类,在输入边界完成字符串到具体类型的转换。
- 风险提醒: 对真正同质、按序处理的数据继续使用数组或列表;对象化不应妨碍批量算法。
4. 选择单向或双向关联
对象 A 能导航到 B,并不意味着 B 也必须知道 A。默认从满足用例的最小方向开始:单向关联更容易创建、删除和序列化;只有被引用对象确实需要反向导航,而且查询服务或仓储无法更清晰地完成任务时,才增加双向关联。
若采用双向关联,应把两端更新集中在一个操作中:
public final class Team { private final List<Member> members = new ArrayList<>();
public void add(Member member) { if (!members.contains(member)) { members.add(member); member.join(this); } }}- 适用条件: 双向导航是高频领域行为,并且两端需要在内存中保持一致。
- 改进方向: 指定一端负责维护关系,统一处理添加、移除和重复调用。
- 风险提醒: 双向关联会增加一致性、序列化和生命周期成本;若反向导航不再使用,应及时退回单向关联。
5. 用具名常量替代魔法数字
数字 9.81、0.9 或 30 若表达业务或物理含义,应通过具名常量、枚举或配置说明单位和用途:
private static final double STANDARD_GRAVITY_MPS2 = 9.80665;
double potentialEnergy(double massKg, double heightMeters) { return massKg * STANDARD_GRAVITY_MPS2 * heightMeters;}- 适用条件: 数字具有非显然含义,在多处出现,或未来可能统一调整。
- 改进方向: 名称包含必要单位;会随环境变化的值放入配置,而不是假装成永恒常量。
- 风险提醒:
0、1等清楚表达计数和边界的字面量不必全部命名;无意义的常量名只会把谜题搬走。
6. 封装集合
直接返回可变集合,会让调用者绕过对象规则增删元素。应提供只读视图或副本,并用意图方法维护不变量:
public final class Order { private final List<OrderLine> lines = new ArrayList<>();
public List<OrderLine> lines() { return List.copyOf(lines); }
public void addLine(OrderLine line) { Objects.requireNonNull(line); if (line.quantity() <= 0) { throw new IllegalArgumentException("Quantity must be positive"); } lines.add(line); }}- 适用条件: 集合属于对象内部状态,增删需要校验、去重、事件或双向关系维护。
- 改进方向: 不暴露 setter 替换整个集合,提供领域含义明确的添加和移除操作。
- 风险提醒:
Collections.unmodifiableList只是视图,内部变化仍会反映出去;选择视图还是快照要符合接口契约和性能需求。
五、订单折扣案例:把方法组合成安全演进
下面用一个订单折扣流程串起提取函数、策略对象、值对象和测试保护。重点不是把简单逻辑模式化,而是展示规则持续增加时如何逐步集中变化。
1. 起点:先描述当前行为
最初实现把三条折扣规则写在一起:VIP 九折、优惠券减固定金额、首单再减 10 元,最终价格不得小于零。
public Money finalPrice(Order order) { Money price = order.total();
if (order.customer().isVip()) { price = price.multiply("0.90"); } if (order.hasCoupon()) { price = price.subtract(order.coupon().discount()); } if (order.isFirstOrder()) { price = price.subtract(Money.cny("10.00")); } return price.max(Money.cny("0.00"));}这段代码在规则很少时完全可以工作。真正的风险来自隐含约定:折扣有顺序;优惠券和首单优惠可以叠加;最低价格为零;所有金额必须使用同一币种。重构前应先把这些现有行为写进测试,而不是依据“更合理”的想象顺手改变规则。
2. 第一步:提取意图
先把每条规则提取为函数,顶层只保留执行顺序:
public Money finalPrice(Order order) { Money price = applyVipDiscount(order.total(), order); price = applyCoupon(price, order); price = applyFirstOrderDiscount(price, order); return price.max(Money.cny("0.00"));}
private Money applyVipDiscount(Money price, Order order) { return order.customer().isVip() ? price.multiply("0.90") : price;}这一步没有引入新设计,只是让规则可命名、可分别测试。如果折扣长期只有三种,到这里可能已经足够;是否继续取决于变化证据,而不是对设计模式的偏好。
3. 第二步:规则持续增加时引入策略
若促销规则频繁增加,而且不同渠道需要组合不同规则,可以把折扣表示成策略对象:
public interface DiscountPolicy { Money apply(Money currentPrice, Order order);}
public final class VipDiscountPolicy implements DiscountPolicy { @Override public Money apply(Money currentPrice, Order order) { return order.customer().isVip() ? currentPrice.multiply("0.90") : currentPrice; }}
public final class DiscountCalculator { private final List<DiscountPolicy> policies;
public DiscountCalculator(List<DiscountPolicy> policies) { this.policies = List.copyOf(policies); }
public Money finalPrice(Order order) { Money price = order.total(); for (DiscountPolicy policy : policies) { price = policy.apply(price, order); } return price.max(Money.cny("0.00")); }}策略列表明确保留了执行顺序,并允许测试不同组合。代价是类和装配代码增多,因此它只适合规则确实独立变化、组合方式真实存在的情况。如果各策略高度依赖彼此的内部结果,先明确叠加规则可能比继续拆类更重要。
4. 第三步:用金额值对象守住不变量
若价格仍以 double 传递,结构变清楚也无法避免浮点精度、币种混用和舍入差异。可以引入不可变 Money 值对象:
public record Money(BigDecimal amount, Currency currency) { public Money { Objects.requireNonNull(amount); Objects.requireNonNull(currency); amount = amount.setScale(2, RoundingMode.HALF_UP); }
public static Money cny(String amount) { return new Money(new BigDecimal(amount), Currency.getInstance("CNY")); }
public Money subtract(Money other) { requireSameCurrency(other); return new Money(amount.subtract(other.amount), currency); }
public Money multiply(String factor) { return new Money(amount.multiply(new BigDecimal(factor)), currency); }
public Money max(Money other) { requireSameCurrency(other); return amount.compareTo(other.amount) >= 0 ? this : other; }
private void requireSameCurrency(Money other) { if (!currency.equals(other.currency)) { throw new IllegalArgumentException("Currency mismatch"); } }}此时金额的精度、舍入和币种约束有了单一入口。要注意,选择两位小数和四舍五入本身属于业务决定;值对象负责一致执行规则,但不能替产品决定规则。
5. 第四步:用测试锁定组合行为
测试应围绕业务观察点,而不是复制内部实现。下面验证 VIP 与优惠券叠加后的结果;其他场景还应覆盖普通订单、首单、零金额、折扣超过总额、币种不一致和策略顺序。
@Testvoid appliesVipDiscountBeforeCoupon() { Order order = OrderFixture.vipOrder( Money.cny("100.00"), Coupon.fixed(Money.cny("20.00")) ); DiscountCalculator calculator = new DiscountCalculator(List.of( new VipDiscountPolicy(), new CouponDiscountPolicy() ));
assertEquals(Money.cny("70.00"), calculator.finalPrice(order));}
@Testvoid neverReturnsNegativePrice() { Order order = OrderFixture.orderWithCoupon( Money.cny("10.00"), Coupon.fixed(Money.cny("50.00")) ); DiscountCalculator calculator = new DiscountCalculator( List.of(new CouponDiscountPolicy()) );
assertEquals(Money.cny("0.00"), calculator.finalPrice(order));}如果这些测试很难编写,通常意味着折扣计算还依赖数据库、当前时间或全局配置。先把这些依赖作为参数或接口显式注入,再继续结构调整。测试既是保护网,也是边界探测器。
六、容易走偏的做法
1. 为未来需求提前抽象
“以后可能支持十种支付方式”不能单独证明现在需要插件体系。先针对已经出现的两个或三个变化提炼稳定共同点;对尚不存在的需求,保留简单、可替换的代码通常更便宜。
2. 看到重复就立刻合并
重复是信息:它可能表示共同概念尚未显现,也可能表示两个概念暂时长得相似。观察它们是否一起变化、是否使用同一术语,再决定合并。错误抽象会让一方的变化被迫影响另一方。
3. 把所有分支替换成多态
分支是程序表达选择的基本工具。类型集合封闭、逻辑短且集中时,switch 往往比一组小类更直观。只有当分支沿同一维度反复扩展、散落多处或需要独立组合时,策略和多态才更有价值。
4. 把数据类一律改成领域对象
数据传输对象和事件消息的职责就是稳定搬运数据,加入业务方法反而会混淆边界。应寻找被外部反复操控的领域状态,而不是看到 getter 就移动逻辑。领域对象、持久化模型和接口模型可以各有不同职责。
5. 只追求更短或更新的写法
流式调用、表达式折叠和语言新特性有时能改善意图,也可能隐藏顺序、副作用和异常。判断标准是下一位维护者能否更快理解变化点,并能否用测试验证,而不是行数减少多少。
6. 没有保护就大规模移动
没有测试的大范围修改接近重写。先补最关键的特征测试,再做机械性重命名和小函数提取;把外部依赖隔离后,逐步扩大验证范围。若连现有行为都无法描述,应先调查而不是凭直觉重构。
7. 顺手修复所有可疑行为
重构时发现缺陷很常见,但直接修复会破坏“行为不变”的验证前提。记录问题,为它建立能复现旧行为或期望行为的测试,然后用独立变更修复。只有明确获批的新需求才应改变外部行为。
结语
好的重构不会让代码显得更有技巧,而会让变化变得更便宜:规则集中在合适的位置,职责边界更加清楚,非法状态更难出现,测试也更容易编写。
坏味道帮助我们发现调查方向,重构方法提供小步操作,数据建模守住长期不变量,而测试和持续验证确保这些调整没有越过行为边界。最可靠的实践仍然朴素:理解现状,锁定行为,选择一个真实问题,小步修改,在收益达到后停止。等下一个需求到来时,代码会用更清晰的结构回答变化。