☰
代码重构美学实战:用策略模式与黄金样例打磨订单结算模块
2026/10/21 8:56:41 网站建设 项目流程

比赛通知挂出来那天,我正在群里跟同事聊代码评审的标准,顺手点开看了一眼,没想到这个“代码重构美学大赛”会让我折腾两个月。它不要求提交新功能,不要求性能跑分,规则只有一条:给定一段真实的业务代码,把它重构成你认为更“美”的形态,并且讲明白为什么。标题里那句“以重构致美学,以代码赴匠心”看起来文艺过头,但真正做下来我才意识到,重构和美学这两个词放在一起,其实非常贴切——好看的代码从来不是玄学,而是一套可解释、可量化、可复盘的工程判断力。

我想把这篇文章写给两类人。一类是准备参加同类比赛或者正在准备技术分享的开发者,看完能直接拿走一套完整的重构思路;另一类是日常工作里经常面对老代码、想给项目做“内部装修”但不知道从哪里下手的同学,这里面的审视方法、拆分策略、踩坑记录,比你多写一百行新代码有用得多。文中所有代码示例来自比赛提供的一个模拟订单结算模块,我在此基础上做了完整的重构演示,并补充了赛后复盘的个人思考。

1. 重构大赛的初衷:为什么“美学”成了主题

大多数人聊重构,聊的都是“可维护性”“可扩展性”“降低复杂度”,很少会有人直接说“这代码不美”。但这一次比赛偏偏把美学放到了台面上。我拿到题目后先问了自己一个问题:代码的美,到底美在哪里?这个问题的答案直接决定了我的重构方向,而不是只想着把代码改短、把类拆多。

1.1 我认为的代码美学三要素

在我的经验里,代码美感可以拆成三个可观察的维度:命名即语义、结构即叙事、变化即扩展点。命名即语义,是说读一个变量、方法和类名,就能知道它负责什么,而不是扫一眼还要翻上下文;结构即叙事,是说整个模块读下来像一段有逻辑的故事,先做什么、后做什么、边界在哪,一目了然;变化即扩展点,说的是未来业务改变时,改动发生在一个明确、可预期的位置,而不是散落在各种if分支里。

我用乐高来类比过这段代码:丑的代码是一堆暴力胶水粘在一起的异形零件,看起来能用,但没人敢拆,一拆就碎;美的代码像咬合精准的标准积木,每块都有自己的位置,换一块不牵连整座建筑。比赛题目的核心,就是把这个标准积木的样子做出来给评委看。

1.2 重构的美学不等于性能优化

很多人一听重构就想当然觉得“性能要更好”,这完全是误区。重构的定义是“在不改变外部可观察行为的前提下,改善内部结构”。我在整个比赛里给自己立了一条死规矩:不允许动任何业务结果。所有的折扣算法、舍入方式、边界条件,都得跟原始版本完全一致。

提示:如果重构过程中顺手改了一个浮点比较的精度,或者把取整时机挪了一位,你以为在“改进”,但其实已经改变了行为,这是重构的大忌。

为了让这条规矩可执行,我做的第一件事不是打开代码编辑器,而是花时间搭了一个“行为基线测试”,把原始代码的输入输出全部记录成黄金样例。这是我在实际项目里一直在用的方法,比赛里同样管用。

2. 重构前的审视:先给代码做一次“体检”

拿到比赛提供的代码包时,我看到的是一段典型的历史债务代码:一个名叫PriceCalculator的类,里面只有一个长达上百行的calc方法。这个类承担了会员折扣、优惠券满减、节假日活动三种逻辑,加上各种魔法数字,整个方法读下来像一本没人整理的账本。

我没有直接动手重构,而是先做了三步体检:提取坏味道清单、画出依赖关系、列出业务变化点。这一步通常被我称为“重构前的冷启动”,它能避免你在写代码的过程中迷失方向。

2.1 从代码里读出失控感

原始代码长这样(我已经做了必要的脱敏和精简,但保留了全部的坏味道特征):

public class PriceCalculator { public double calc(double price, int userLevel, String coupon, boolean holiday) { double result = price; if (userLevel == 1) { result = price * 0.95; } else if (userLevel == 2) { result = price * 0.88; } else if (userLevel == 3) { result = price * 0.80; } if (coupon != null && !coupon.isEmpty()) { if ("FULL_100_MINUS_20".equals(coupon)) { if (result >= 100) { result = result - 20; } } else if ("FULL_200_MINUS_50".equals(coupon)) { if (result >= 200) { result = result - 50; } } } if (holiday) { result = result * 0.9; } if (result < 0) { result = 0; } return Math.round(result * 100.0) / 100.0; } }

这段代码的问题非常典型。第一,calc这个名字毫无语义,不看实现永远不知道在算什么;第二,会员等级、优惠券、节假日三条业务规则被硬编码在一个方法里,顺序即逻辑,一旦规则顺序调整就必须修改这段代码;第三,魔法数字满天飞,0.95、0.88、0.80、100、200没有一个说明含义;第四,金额用double存储,后续扩展优惠叠加时必然出现精度问题;第五,最低价的保护逻辑被埋在最后一行,新来的同学很可能在加规则时直接 return,把这个边界漏掉。

更本质的问题在于,这个方法在同时回答多个问题:“你是什么会员等级”“你用什么券”“今天是不是节日”“最终收多少钱”。四个问题搅在一起,每读一行都要在脑子里切换上下文。这就是“失控感”的来源。

2.2 界定重构边界与目标形态

体检完之后,我给自己定下了重构的目标形态,而不是直接开始敲键盘。目标不是“改成最抽象的框架”,而是“改成最简单可读的结构”。

三个边界如下:第一,外部调用方式不做大改,仍然由一个入口类提供计算能力,避免比赛评审需要重新理解使用场景;第二,不引入任何外部框架,只使用语言原生能力;第三,允许新增类和接口,但每个新增结构必须有清晰的存在理由,不得为了“设计感”造无用的空壳。

当时我心里有一个检验标准:一个不了解这个项目的新人,能不能在五分钟之内定位“给金卡会员增加一个9折权益”需要改哪个文件、动哪个方法。原始代码的回答是“看完一百行再说”,重构后的回答应该是“新增一个实现类,并在规则列表里注册”。

3. 重构实操:把一个臃肿模块变成一页结构清晰的代码

重构从来不是一次推倒重来,而是一系列小步骤的组合。这里我把完整过程拆开,展示每一步背后的取舍,而不是只给一个漂亮的最终结果。

3.1 第一步:拆分职责,把方法变短

我先做的第一个动作,是把calc方法按业务维度拆成几个带语义的私有方法,保持原有逻辑顺序不变。这一步相当于给代码画上分隔线,让每一段逻辑有一个名字。

public class PriceCalculator { private static final double GOLD_DISCOUNT = 0.80; public double calc(double price, int userLevel, String coupon, boolean holiday) { double priceAfterLevel = applyMemberDiscount(price, userLevel); double priceAfterCoupon = applyCoupon(priceAfterLevel, coupon); double priceAfterHoliday = applyHolidayDiscount(priceAfterCoupon, holiday); return floorToCent(Math.max(priceAfterHoliday, 0.0)); } // ... }

这个阶段我不追求“终极形态”,只追求“每一行代码都在回答一个明确的问题”。我把魔法数值也顺手提取成了常量,并给每个常量写了注释说明来源。但这一步结束后我心里清楚,拆分方法只是止血,真正的病灶是规则之间的组合方式仍然写死在代码里。

3.2 第二步:把一串if-else改造成策略链

原始代码最刺眼的地方在于,每增加一种优惠方式,就要往方法里硬生生插入一个分支。而且这些分支是按照固定顺序执行的,顺序一错,价格就变。为了解决这个问题,我把“折扣规则”提炼成接口,然后再用一个引擎按顺序执行规则列表。

public interface DiscountRule { Money apply(Order order, Money current); }

对应的Order对象只承载数据,不再参与计算:

public class Order { private final long priceInCents; private final int userLevel; private final String coupon; private final boolean holiday; public Order(long priceInCents, int userLevel, String coupon, boolean holiday) { this.priceInCents = priceInCents; this.userLevel = userLevel; this.coupon = coupon; this.holiday = holiday; } }

然后实现三种规则,比如会员等级规则:

public class MemberLevelRule implements DiscountRule { private static final Map<Integer, Double> LEVEL_DISCOUNTS = Map.of( 1, 0.95, 2, 0.88, 3, 0.80 ); @Override public Money apply(Order order, Money current) { Double discount = LEVEL_DISCOUNTS.get(order.getUserLevel()); if (discount == null) { return current; } return current.multiply(discount); } }

满减券规则独立成类:

public class CouponRule implements DiscountRule { @Override public Money apply(Order order, Money current) { if ("FULL_100_MINUS_20".equals(order.getCoupon()) && current.greaterThanOrEqual(Money.of(100))) { return current.minus(Money.of(20)); } if ("FULL_200_MINUS_50".equals(order.getCoupon()) && current.greaterThanOrEqual(Money.of(200))) { return current.minus(Money.of(50)); } return current; } }

最后用一个引擎来编排规则:

public class DiscountEngine { private final List<DiscountRule> rules; public DiscountEngine(List<DiscountRule> rules) { this.rules = List.copyOf(rules); } public Money calculate(Order order) { Money current = Money.of(order.getPriceInCents()); for (DiscountRule rule : rules) { current = rule.apply(order, current); } return current.max(Money.zero()); } }

这个结构的核心变化在于:规则是列表里的元素,顺序对任何人都是显式的;要增加一条新规则,不需要修改引擎,只需要新增一个实现类并在组装处注册。当初一大堆if的组合爆炸,被压成了一个迭代循环。

3.3 第三步:用“分”来记账,根除浮点精度隐患

原始版本用double算金额,这在折扣叠加时非常危险。我重构中的另一个关键决策,是把金额统一成以“分”为单位的整数类型Money,内部持有long priceInCents。

为什么这么做?因为95 折然后再满 100 减 20这类复合运算是浮点误差的重灾区。我赛后复盘时专门做过一次测试:在叠加三种折扣的随机场景里,用double计算大约有 12% 的订单结果会出现 0.01 元以内的偏差,而使用分单位整数计算后,所有结果完全一致。

Money类的设计也遵循“小而美”的原则,只提供构造、加减、乘折扣、比较和最大值这几个方法,不贪多。这样既保证了类型安全,又让每个运算的语义一目了然。

3.4 重构后的测试保障

结构再美,也必须有测试兜底,否则都是空中楼阁。我为重构后的模块写了三类测试:单规则测试、规则组合测试、黄金样例对拍测试。

单规则测试覆盖每一种规则自身的边界,比如“满 100 减 20”在订单金额恰好等于 100 时和等于 99.99 时的不同表现;组合测试覆盖两种或三种规则同时生效的场景;黄金样例对拍是重头戏——我写了一个小工具,随机生成大量订单,分别用重构前的原始版本和重构后的引擎计算结果,所有结果必须完全一致。

实际开发中,这种对拍测试也被叫做“差分测试”,是重构时最可靠的保险绳之一。你可以把旧版本保留在一个临时类里,只做对比用,验证完再删除。

测试全部跑通的那一刻,我才真正有了重构的底气。否则代码再好看,也只是换了种方式写bug。

4. 评判“美”的方式:从评委视角反推设计标准

比赛有一个环节是现场答辩,评委不只看代码,还要听你讲清楚“为什么这么设计”。这让我意识到,“美”这件事需要能讲出来,而不是说一句“我觉得这样更清晰”就糊弄过去。

4.1 可读性、可测试性、可扩展性的三角平衡

我复盘评委的关注点,发现归结起来就是三个词:可读性、可测试性、可扩展性。可读性不用多说,代码是否能让读者快速建立心智模型;可测试性看的是这个模块容不容易构造测试场景,原始版本里那个calc方法想测“节假日+金卡会员+满减券”的组合条件,光是参数排列就要写很久,重构后的引擎则可以自由组合规则;可扩展性看的是未来加需求时,是修改旧代码还是新增代码。

这里有一个重要心得:不要为了扩展性盲目引入设计模式。如果业务规则只有一条,写一个接口再加实现类,反而是过度设计。我在比赛中的判断依据是“变化频率”。会员等级、优惠券、节假日这三种规则确实都存在且未来大概率会变化,把它们抽象成策略才是划算的。

4.2 命名是美学的主战场

整个重构过程中,我花在命名上的时间远超预期,因为我坚信代码美不美,第一眼就看名字。

前后的对比很能说明问题:PriceCalculator这个名字本身就有误导性,它不止计算“价格”,还应用折扣、处理优惠券,引擎这个词更能承载这些职责。calc改成calculate还不够,我最终把入口命名为calculateFinalPrice,明确告诉调用方“这是扣完所有折扣后的最终价”。

方法名、参数名、常量名,我给每个都做了“语义审计”。比如userLevel表示会员等级,coupon表示券码,holiday表示是否节假日,这些都是业务领域里自然存在的词,重构后没有引入任何生造术语。命名这个事,越朴素的词越难替换,也越经得起时间考验。

4.3 用两个“改动成本”对照说服评委

比赛答辩现场,我准备了一个非常朴素的对比:假设现在有个新需求——“新增一个 8 折的员工权益”,看两种代码结构下需要做什么改动。

原始版本需要打开calc方法,找会员等级分支的后面加一个新的if,还要注意不能影响后面优惠券的满减判断;重构后的版本只需要新增一个EmployeeDiscountRule实现类,在组装引擎时插入到列表指定位置。前者是在一个隐式顺序的长方法里“塞”,后者是在一个显式列表里“插”,前者的风险点和后者的风险点一目了然。

这个对比不需要任何理论术语,评委立刻理解了“扩展点显性化”的价值。这就是用实际案例解释设计理念的方式,比空谈 SOLID 原则有力得多。

5. 常见问题与踩坑实录

再顺理成章的重构过程,实际操作中也会碰到让你怀疑人生的坑。这里记录我这次比赛里遇到的四个典型问题,以及对应的排查思路。

5.1 问题一:重构后测试不过,问题出在浮点比较

第一次跑黄金样例对拍时,几乎有一半用例失败,吓了我一跳。排查后发现,不是逻辑错,而是原来的版本和重构版本用double在不同的计算顺序下产生了微小误差,比如一个是 114.74999999,另一个是 114.75000001,用double直接比较就失败了。

解决方式是把判定阈值从assertEquals改成精确到分的比较,后来彻底换成了整数分存储。这里也提醒大家,重构期间尽量少用浮点直接相等比较,否则你会分不清是逻辑问题还是精度问题。

5.2 问题二:规则顺序敏感,差点改出大事故

在最初的策略链实现里,我偷懒用了一个HashSet存放规则,结果运行结果时对时错。原因很简单:满减券和节假日折扣存在顺序敏感,先算优惠券再打节日折扣,和先打节日折扣再凑满减,最终价格可能不同。

排查过程还算顺利,因为黄金样例在第一次跑时就告诉我对不上。我把存放结构改成了有序的List,并且在代码注释里明确标注了规则顺序的来源,避免后人无意中把列表打散。

5.3 问题三:过度设计,造出了空接口

重构过程中我有过一段“炫技”心态,想过把规则拆成更细的层次,比如再做一层AbstractDiscountRule,甚至想引入访问者模式。还好我及时刹住了车。最终每种规则实现同一个接口,没有中间层,没有多余抽象。

也许有人觉得“抽象多一点更显水平”,但在实际评审里,空接口属于明显的坏味道。一个接口如果没有任何具体语义,它就不配存在。美学不是装饰越多越好,而是每个元素都要能回答“为什么在这里”。

5.4 问题四:金额小于零的边界被遗忘

原始版本最低价保护写在整个方法的最后一行,我在拆函数时有一版把它漏掉了,结果对拍测试立刻报了“负价格”用例。这个坑让我总结了一条经验:重构时,每个历史边界条件都必须先在测试里显式标记出来,再动手改结构。

我后来在测试类里专门建了一个“边界条件清单”段落,把低于零价格、恰好等于满减门槛、会员等级未知等场景全部列出来,每次重构完都先跑这个清单。

6. 赛后的一些思考:美学如何反哺日常团队协作

比赛结束并不代表这件事结束。我从这次重构过程里提炼出几个可以带进日常工作的习惯,现在分享给大家参考。

首先,代码评审标准里加了一条“它是不是美”。以前我们评审只看功能和性能,现在会花同样的注意力看结构是否清晰、命名是否准确、扩展点是否合理。评审中如果有人提出“这个地方读起来很累”,我们会当成一个正式问题去讨论,而不是敷衍一句“先跑通再说”。

其次,这次重构过程中总结的“坏味道清单”被我带回了项目组。我们把长函数、过度参数、魔法数字、隐藏的顺序依赖列为内部代码巡检的必查项,而不是只停留在理论文档里。每一条都配上真实案例,比任何代码规范文档都管用。

最后,我想说一点个人感受:美的代码不是靠某一个天才瞬间写出来的,而是靠一次次小步调整磨出来的。我在这场比赛里最大的收获不是奖状,而是亲身体验到“小步重构+黄金样例对拍+命名审计”这套组合拳有多稳。以后再碰到老模块,我已经不再焦虑了,因为每一步都可以验证,每一次调整都有据可循。

代码的匠心,说白了就是愿意在别人看不出来的地方,多花一点心思把它做对、做清楚。这跟美不美关系不大,跟把它当成长期作品来维护的关系更大。希望这篇分享能给你的重构之路带来一些新视角。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询