接手一个跑了三年的老项目是什么感觉?最直观的体验是:改一个看似不起眼的 bug,顺着调用链翻下去,发现牵扯了十几个类;修好之后跑测试,又炸了两个毫不相干的模块。时间长了,团队里慢慢形成一种共识——这块代码“碰不得”,谁碰谁背锅。我后来总结了一下,这类问题的根源其实不在某个具体的 bug,而是可维护性出了问题。
可维护性差,不是靠定几条代码规范就能解决的。规范只能管住新增代码的长相,管不住历史代码腐烂的趋势。真正有效的办法,是在日常迭代中持续做重构,让代码的结构跟上业务的变化。这篇文章我整理了 5 个实战中最常用、见效最快的重构模式,每个模式都配了真实的代码演化过程和操作步骤。不管你是刚工作不久想提升代码质量的新人,还是维护着老系统天天被复杂逻辑折磨的开发,应该都能从中找到能直接上手的东西。
1. 提取方法:把“做什么”和“怎么做”拆开
1.1 一个让我印象深刻的反面案例
有一回我接手一个订单导出功能,函数不算长,200 多行。真正让我头皮发麻的,是函数里有一大半代码在做同一件事——根据订单状态拼装不同的展示文本。大概长这样:
java复制public String buildOrderDescription(Order order) {
StringBuilder sb = new StringBuilder();
sb.append("订单号:").append(order.getOrderNo()).append("\n");
// 计算运费
double shippingFee = 0;
if (order.getWeight() > 10) {
shippingFee = order.getWeight() * 1.5 + 8;
} else if (order.getWeight() > 5) {
shippingFee = order.getWeight() * 1.2 + 5;
} else {
shippingFee = 5;
}
sb.append("运费:").append(shippingFee).append("元\n");
// 拼接商品清单
List<OrderItem> items = order.getItems();
double totalAmount = 0;
for (OrderItem item : items) {
sb.append(item.getName()).append(" x").append(item.getQuantity()).append("\n");
totalAmount += item.getPrice() * item.getQuantity();
}
sb.append("合计:").append(totalAmount).append("元\n");
// 根据支付状态附加说明
if ("PAID".equals(order.getPayStatus())) {
sb.append("已支付").append(order.getPayTime()).append("\n");
} else if ("UNPAID".equals(order.getPayStatus())) {
sb.append("请尽快完成支付\n");
} else if ("REFUNDED".equals(order.getPayStatus())) {
sb.append("已退款,退款金额").append(order.getRefundAmount()).append("元\n");
}
return sb.toString();
}
这段代码的问题很明显:运费计算、商品清单拼接、支付状态描述三个逻辑全挤在一个函数里。读代码的人想搞清楚“支付状态为 REFUNDED 时展示什么”,得先在脑子里把运费那段跳过去。
1.2 提取的完整操作步骤
我处理的时候,遵循了一个很简单的方法论:每个代码块如果可以用一句自然语言描述清楚,就应该把它变成一个独立的方法。
第一步,识别逻辑块。从上面那段代码看,“计算运费”“拼接商品清单”“拼接支付状态说明”是三个可以独立描述的行为。
第二步,把每个逻辑块抽成方法,方法名直接表达意图:
java复制public String buildOrderDescription(Order order) {
StringBuilder sb = new StringBuilder();
sb.append("订单号:").append(order.getOrderNo()).append("\n");
sb.append(appendShippingFee(order));
sb.append(appendItems(order));
sb.append(appendPaymentStatus(order));
return sb.toString();
}
private String appendShippingFee(Order order) {
double shippingFee = calculateShippingFee(order.getWeight());
return "运费:" + shippingFee + "元\n";
}
private double calculateShippingFee(double weight) {
if (weight > 10) {
return weight * 1.5 + 8;
} else if (weight > 5) {
return weight * 1.2 + 5;
}
return 5;
}
private String appendItems(Order order) {
StringBuilder sb = new StringBuilder();
double totalAmount = 0;
for (OrderItem item : order.getItems()) {
sb.append(item.getName()).append(" x").append(item.getQuantity()).append("\n");
totalAmount += item.getPrice() * item.getQuantity();
}
sb.append("合计:").append(totalAmount).append("元\n");
return sb.toString();
}
private String appendPaymentStatus(Order order) {
if ("PAID".equals(order.getPayStatus())) {
return "已支付" + order.getPayTime() + "\n";
} else if ("UNPAID".equals(order.getPayStatus())) {
return "请尽快完成支付\n";
} else if ("REFUNDED".equals(order.getPayStatus())) {
return "已退款,退款金额" + order.getRefundAmount() + "元\n";
}
return "";
}
第三步,跑一遍测试,确认行为没变。
1.3 为什么这个方法能提升可维护性
提取方法的核心价值是降低认知负荷。人脑的工作记忆是有限的,200 行的函数,读到第 150 行时,前面 149 行的细节早已模糊。而拆成多个小方法后,主流程变成了一行行“叙述性代码”——读 buildOrderDescription 时,你只需要理解四个短句的顺序组合;想深入某个细节时,再点进对应的方法即可。
这就像读一本书。好的目录能让你快速定位感兴趣的章节,而不是把整本书从头到尾读一遍才能找到一句话。很多团队抱怨“代码只有写的人能维护”,很大程度是因为函数名没有承担起“目录”的作用。
实操中有一个值得注意的细节:提取方法时不要顺手改代码逻辑。一次只做一件事,要么重构结构,要么修改行为。把两者混在一起,出了 bug 很难定位是结构调整引入的还是行为变更导致的问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分支策略化:状态叠加的 if/else 怎么从根上治理
2.1 复盘一次促销活动的翻车现场
去年公司做一次大促,上线前测试发现结算金额总是对不上。排查到最后,问题出在一个 300 行的 calculateDiscount 方法里。那个方法里有十几层 if/else,判断组合包括:新用户、会员等级、品类折扣、是否使用优惠券、是否参与满减、是否是秒杀商品……每一种排列组合都会影响最终的折扣系数。
核心片段是这样的:
java复制public double calculateDiscount(Order order) {
double discount = 0;
if (order.getUser().isNewUser()) {
discount += 0.15;
if (order.getAmount() > 200) {
discount += 0.05;
}
}
if ("GOLD".equals(order.getUser().getLevel())) {
discount += 0.1;
if (order.isFlashSale()) {
discount += 0.2;
}
}
if (order.hasCoupon()) {
discount += order.getCoupon().getDiscountRate();
}
if (order.getUser().isNewUser() && order.isFlashSale()) {
discount -= 0.05; // 新用户和秒杀共享优惠,避免叠加过高
}
if (order.getAmount() > 500 && "GOLD".equals(order.getUser().getLevel())) {
discount += 0.02;
}
// 继续往下还有十几个 if...
return Math.min(discount, 0.35);
}
这组嵌套条件背后,实际上是五六个原本独立的业务规则被强行揉在了一起。每次新增一种营销规则,都要往这个函数里塞一个 if,然后祈祷不要和其他条件冲突。
2.2 用策略模式替换分支的具体过程
重构这类代码,我的思路是:把“判断折扣”这件事从一个大函数变成一组可独立演化的策略对象。
第一步,定义策略接口,所有折扣规则实现同一个方法:
java复制public interface DiscountRule {
boolean match(Order order);
double calculate(Order order);
}
第二步,把原来的每个 if 分支改写成独立的策略类。比如新用户折扣:
java复制public class NewUserDiscountRule implements DiscountRule {
@Override
public boolean match(Order order) {
return order.getUser().isNewUser();
}
@Override
public double calculate(Order order) {
double discount = 0.15;
if (order.getAmount() > 200) {
discount += 0.05;
}
return discount;
}
}
其他分支类似,每个类只负责一条规则。
第三步,用一个规则引擎类把这些策略组合起来:
java复制public class DiscountCalculator {
private List<DiscountRule> rules = new ArrayList<>();
public DiscountCalculator() {
// 通过构造器注入或Spring自动装配
rules.add(new NewUserDiscountRule());
rules.add(new GoldMemberDiscountRule());
rules.add(new FlashSaleDiscountRule());
rules.add(new CouponDiscountRule());
rules.add(new SharedDiscountAdjustRule());
}
public double calculate(Order order) {
double totalDiscount = rules.stream()
.filter(rule -> rule.match(order))
.mapToDouble(rule -> rule.calculate(order))
.sum();
return Math.min(totalDiscount, 0.35);
}
}
2.3 重构后带来的实际变化
这个重构最大的收益,不是代码行数变少了,而是每次新增促销规则,改动范围被限制在一个新文件里。你不需要去读那 300 行的大函数,不需要担心自己加的条件会不会和某个隐藏分支冲突,只需要新建一个 XxxDiscountRule 实现 match 和 calculate,然后在构造函数里注册一下就行。
还有一个容易被忽略的收益:规则可单测了。以前那个大函数,要测“新用户+GOLD会员+满500元”的组合,必须构造一整套复杂订单数据。现在每个规则类都可以独立写单元测试,测试代码的阅读成本也大幅降低。上线后如果再出问题,定位一个规则类比在 300 行 if/else 里逐行打断点快得多。
不过要提醒一句:不是所有 if/else 都值得改造成策略模式。我自己的判断标准是——分支数量是否超过 5 个、新增分支是否频繁、每条分支是否附带独立的业务逻辑。如果只是两三个简单的条件判断,硬套策略模式反而会让代码更难读。这个度,需要结合具体的业务频率来把握。
3. 依赖倒置:核心业务不再看第三方脸色
3.1 从一次换短信服务商的经历说起
很多团队都经历过类似场景:短信服务商 A 的 SDK 用了一年,因为价格或到达率的原因要换成服务商 B。如果你的业务代码里到处散落着 SmsServiceA.send(...),替换的时候就会陷入机械而枯燥的修改中。更麻烦的是,A 的 SDK 可能有自己特有的参数结构——比如 sendSms(String mobile, String content, SmsType type),B 的接口则是 send(Message message),两边的方法签名、参数模型完全对不上。
我遇到过一个极端案例:业务代码里引用了第三方推送 SDK 的 PushMessage 类,并把它作为方法参数一层层往下传,最后换服务商的时候,牵涉了 30 多个文件。那个项目最后花了整整三天才改完,期间改漏了一处,线上推送直接失效。
3.2 加一层接口,把依赖方向反转
依赖倒置原则在这里的落地方式很简单:你的业务代码不应该依赖任何具体第三方实现,而应该依赖自己定义的接口。
重构前,业务代码长这样:
java复制public class OrderService {
private SmsServiceA smsService;
public void sendOrderNotify(Order order) {
// 直接依赖具体SDK A
smsService.sendSms(order.getUser().getMobile(),
"您的订单已发货", SmsType.TRADE);
}
}
重构后,业务代码依赖的是自己定义的接口:
java复制public interface NotifySender {
void sendSms(String mobile, String content);
}
给服务商 A 写一个适配器:
java复制public class SmsServiceAAdapter implements NotifySender {
private SmsServiceA smsServiceA;
@Override
public void sendSms(String mobile, String content) {
smsServiceA.sendSms(mobile, content, SmsType.TRADE);
}
}
业务层代码变成:
java复制public class OrderService {
private NotifySender notifySender;
public void sendOrderNotify(Order order) {
notifySender.sendSms(order.getUser().getMobile(), "您的订单已发货");
}
}
以后再换服务商 B,只需要新建 SmsServiceBAdapter,通过配置或工厂切换注入即可。业务层零改动。
3.3 这个模式真正解决的问题
表面上,它解决的是“换第三方服务时改动量太大”的问题。但本质上,它解决了依赖方向错位的问题——业务代码是内核,不该被外围的具体实现绑架。
我见过很多团队用这个模式的时候有个误区:只给第三方封装一层,但封装的接口还是模仿第三方 API 的形式。这样做意义不大,因为如果接口的抽象层级没有高于第三方 SDK,将来换实现的时候,适配器内部依然要处理模型转换的问题。正确的做法是:站在业务的角度定义接口——业务需要什么能力,接口就提供什么方法,参数返回都用业务自己的领域模型。适配器内部再去处理 SDK 的数据转换。
还有一个实操中的建议:接口的粒度要按业务场景划分,不要搞一个包罗万象的 MessageService。比如订单通知、营销推送、验证码发送,如果是三个不同的使用场景,就定义三个接口,哪怕它们底层用的是同一个服务商。这样每个消费方依赖的都是自己真正需要的最小接口,不会被无关方法干扰,测试时也更容易 mock。
4. 防腐层:控制不住的第三方变化,在边界内消化
4.1 为什么内部模型会被外部系统污染
依赖倒置解决的是“替换实现”的问题,但还有一种更隐蔽的痛苦:外部系统的数据模型慢慢渗透进了你的核心代码。
举个真实场景。系统里对接了一个物流开放平台,对方的 TrackInfo 对象里有十几个字段,其中 status 字段的值一直很稳定,比如 ACCEPT、TRANSPORT、DELIVERED。直到有一天,对方接口升级,status 的值改成了 ACCEPTED、IN_TRANSIT、SIGNED。你的业务代码里如果到处在用 "TRANSPORT".equals(trackInfo.getStatus()) 做判断,就必须全局搜一遍,找到所有相关的 equals 判断逐个改。改漏一个,就意味着某个环节的物流状态显示错误。
更麻烦的是,外部系统的字段语义经常很模糊。比如对方接口里有一个 bizType 字段,文档写着“业务类型”,实际跑起来发现,不同业务场景下这个字段可能为空、可能是数字串、可能是枚举名。你把这样的字段直接存进自己的数据库,后续所有查询这道数据的人都得猜它的含义。
这类问题的根源是:你没有在自己系统和外部系统之间建立清晰的边界。外部数据长驱直入,直接污染了内部逻辑。
4.2 用防腐层模式重建边界
防腐层(Anti-Corruption Layer)的模式很简单:在系统边界处加一层转换器,对外部模型进行翻译,翻译后的数据以你内部的领域模型进入业务代码。
具体操作分三步:
第一步,定义自己业务里的领域模型,只保留真正关心的字段。
java复制public class LogisticsTrack {
private String trackingNo;
private TrackStatus status; // 内部枚举
private String location;
private LocalDateTime eventTime;
}
public enum TrackStatus {
ACCEPTED, IN_TRANSIT, DELIVERED, EXCEPTION
}
第二步,写一个转换器,把外部系统的 TrackInfo 转换成自己的 LogisticsTrack。
java复制public class TrackInfoTranslator {
public LogisticsTrack toLogisticsTrack(ExternalTrackInfo external) {
LogisticsTrack track = new LogisticsTrack();
track.setTrackingNo(external.getMailNo());
track.setStatus(convertStatus(external.getStatus()));
track.setLocation(external.getCurrentLocation());
track.setEventTime(external.getOpTime());
return track;
}
private TrackStatus convertStatus(String externalStatus) {
switch (externalStatus) {
case "ACCEPT": return TrackStatus.ACCEPTED;
case "ACCEPTED": return TrackStatus.ACCEPTED;
case "TRANSPORT": return TrackStatus.IN_TRANSIT;
case "IN_TRANSIT": return TrackStatus.IN_TRANSIT;
case "DELIVERED": return TrackStatus.DELIVERED;
case "SIGNED": return TrackStatus.DELIVERED;
default: return TrackStatus.EXCEPTION;
}
}
}
第三步,业务代码只依赖 LogisticsTrack,不再出现任何外部类。
java复制public class LogisticsQueryService {
private ExternalLogisticsApi externalApi;
private TrackInfoTranslator translator;
public LogisticsTrack queryTrack(String trackingNo) {
ExternalTrackInfo info = externalApi.query(trackingNo);
return translator.toLogisticsTrack(info);
}
}
4.3 防腐层的适用范围与坑
防腐层不是万能的,它适合用在你无法控制外部变化的场景,例如第三方开放平台、跨部门接口、遗留系统。
但有一个很容易踩的坑:防腐层做成了“数据搬运工”,没有真正消化外部变化。比如外部 status 值变了,你还是在转换器里做一个映射,这没问题,但如果你把外部十几个字段原封不对地搬进内部对象,那这个防腐层就只是换了个名字的 DTO,没有起到“防腐”的作用。防腐层真正要做的,是翻译语义,而不是拷贝字段——只保留业务需要的字段,并把外部模糊的值转换为内部清晰的枚举或值对象。
还有一点:调用外部接口的协议转换(比如 HTTP 参数签名、错误码映射)也应该收敛在防腐层内部,不要让业务代码感知到外部接口的异常细节。我的做法是:防腐层对外统一抛出业务异常,把“对方接口超时”“对方返回格式错误”这类技术异常包装成业务可感知的失败原因。这样上层代码处理错误时,只需要面对一种异常风格。
5. 上帝类拆解:按变化原因切分职责边界
5.1 一个 8000 行的服务类是怎么长成的
如果你维护过老项目,大概率见过这种类:MemberService 有 8000 行,里面包含积分计算、等级晋升、地址管理、登录日志、优惠券发放、邀请返利、消息通知、客服备注……类名前面是 Member,但实际承载了会员域的所有事情。
为什么会长成这样?因为一开始大家觉得“都是会员相关的逻辑,放一起方便”。后来不断往里面加方法,不知不觉类就膨胀了,等发现不对时,已经没人敢动它了——所有模块都引用了它。
这种上帝类的可维护性差,体现在几个层面:第一,类太庞大了,IDE 的智能提示都开始卡顿;第二,修改一个方法,IDE 上显示的影响范围是全部;第三,团队成员同时改这个类,经常产生 Git 冲突。
5.2 拆解的思路:找变化原因,而不是按功能模块
拆解上帝类,最容易想到的做法是“按功能模块拆”。这样做看起来很合理,但我试过之后发现有个副作用:比如你把“积分”和“等级”拆成两个类,但积分计算需要依赖等级,等级晋升又需要消费积分,两个类之间产生循环依赖,还是要靠一个上层类去编排顺序。
我后来找到更靠谱的思路,源自《重构》这本书里的一句话:把不同时间、不同原因变化的东西分开。具体操作是:
第一步,列出这个类中的所有方法,按变化原因归类。比如:
- 积分变化:加积分、扣积分、查积分、积分过期
- 等级变化:晋升、降级、查等级
- 地址变化:新增地址、修改地址、删除地址
- 邀请变化:生成邀请码、记录邀请关系、返利计算
第二步,每个归类的集合里如果存在“相互之间没有强依赖关系”的,直接拆出来独立成类。逐步提取,每次提取一个类,跑一次测试。
第三步,把这些类的实例在原来的服务类里通过聚合方式组合起来,而不是继承。原来的 MemberService 变成门面,调用的代码基本不用改,但内部已经变成了多个类协作。
一个重要的经验是:拆类的时候不要追求一步到位。我先拆出最独立的 AddressManager,测试通过后再拆 InvitationHandler,这样每一步的改动都是可控的。如果一次性从一个大类里拆出四五个新类,出了问题时很难判断是哪个拆解动作导致的行为变化。
5.3 拆完之后的改进效果
拆完之后,最立竿见影的是协作效率提升和变更影响范围变小。积分模块的改动就只影响 PointsService,不用再拉上整个 MemberService 的调用方一起回归测试。另外,类的可测试性也提升了,以前测 MemberService 要 mock 十几个依赖,现在每个小类的依赖少,测试构造简单得多。
这里不得不提一个“拆分粒度”的争议。有段时间“微服务”理念盛行,代码层面也流行把类拆得极细,一个类只保留一个方法。这其实是走到了另一个极端。衡量粒度是否合适的标准很简单:看一个类能不能用一句业务场景概括出它的职责。比如 “积分过期处理”、“地址簿管理”,能做到这样粒度就是健康的。如果拆出来一个 PointsAndLevelAdapter 这种要解释半天才能说清楚职责的类,说明拆分了但职责没有对齐。
5.4 识别该拆的信号:四个预警指标
不是所有类都必须拆,服务类膨胀到一定程度才值得动。我总结了几个识别信号:
- 一个类的方法数量超过 20,且分布在多个业务子域。
- 类的字段很多,但只有一小部分字段被绝大多数方法使用。
- 提交记录里,这个类频繁出现在不同功能分支的改动中。
- 团队成员反映改这个类容易改出问题,因为它在太多地方被引用。
如果中了三到四条,这个类就是典型的上帝类。拆解它,是这一步重构投入产出比最高的操作。
6. 这些重构模式背后的共同逻辑
6.1 从四个维度看待维护性
把上面五个模式放在一起看,会发现它们各有侧重,但都指向同一个核心目标:降低后续变更的成本。
第一个模式“提取方法”,改善的是代码的可读性,让人更容易理解代码意图;第二个模式“分支策略化”,改善的是代码的可扩展性,让新增规则不需要触碰已有代码;第三个模式“依赖倒置”,改善的是系统的可替换性,让核心逻辑不受外围实现的影响;第四个模式“防腐层”,改善的是系统的隔离性,让外部变化无法穿透边界;第五个模式“上帝类拆解”,改善的是系统的可理解性和可测试性,让模块边界清晰。
维护性的本质其实就是一句话:当需求变化时,你需要修改的地方越少、越集中,维护性就越好。这五个模式,本质上都是围绕着“减少改动影响范围”在做文章。
6.2 我的实操经验:重构要遵循的三个原则
踩过不少坑之后,我总结了三个适用于所有重构场景的原则。
第一个原则是小步提交。一次重构只做一件事,每次提交保持编译通过、测试通过。不要在一个分支里攒了十几个重构动作再提交,否则出了回归问题,你根本不知道是哪个步骤引入的。
第二个原则是重构和功能开发分离。在改 bug 或加功能的过程中顺手重构,听起来效率高,实际上风险很大。功能逻辑没理清时重构,很容易把原本正确的行为改偏。我的习惯是:先理解现有行为,写完测试把行为固定住,再动结构,最后才改功能。
第三个原则是让自动化测试兜底。重构前不补测试,等于蒙眼换零件。哪怕是简单的“提取方法”这种操作,也应该在重构前用现有的测试或刚补的测试把行为锁住。没有测试保护的重构,就像在高速公路上换轮胎,技术再好也挡不住运气不好。
6.3 哪些代码值得重构,哪些不值得
这一点特别想说清楚。经常有新人问我:“这段代码这么烂,要不要重构?”我的回答一般是:先看它的变化频率和使用频率。
稳定的代码,哪怕写得丑,也不值得投入重构。比如一个配置解析工具类,三个月没改过,重构它纯粹是给自己找活干。相反,那些每周都在改、每次改都让你很痛苦的代码,才是重构的重点对象。重构的意义不是消灭一切坏味道,而是让经常变化的区域保持健康。
另外,业务价值不高的代码也尽量别碰。如果这是一段边缘功能、迟早被替换掉的模块,重构的投入回报率就很低。把精力集中在核心业务链路和高频迭代的模块上,是性价比最高的策略。
6.4 把这些模式嵌入日常工作流
最后一个建议是:重构不要做成“年度大扫除”,而是做成日常迭代的一部分。
我现在的节奏是:每完成一个功能开发,回头看一眼这次经手的方法,有没有明显的“提取方法”或“消除重复”的机会;每接手一个旧模块,先看它有没有“上帝类”的迹象,如果有,在日常修改中顺手拆一点。这样下来,三个月左右,经常维护的模块会越来越清爽。
有一句话我一直很认同:代码就像花园,不是种完就完事的,需要日常修剪,而不是等长成灌木丛再想办法。这篇讲到的五个模式,就是日常修剪时最顺手、最常用的几把剪刀。
