前言
每个Java程序员的职业生涯中,都会遇到那么几个”祖传系统”。
代码写得早,业务跑得久,逻辑绕三圈,注释全靠猜。这种系统改一个小功能,可能要牵一动百;加一个新需求,要先花三天读懂现有逻辑。更难受的是,测试覆盖率只有百分之十几,修改全靠胆量。
本文从一个真实的遗留系统改造项目出发,聊聊代码重构的实战手法与经验总结。
项目背景
这个项目是一个企业内部的用户权限管理系统,2018年上线,主要负责用户、角色、权限的管理。技术栈是 Spring Boot 2.3 + MyBatis + MySQL,业务逻辑不复杂,但代码质量堪忧。
具体问题:
- 业务逻辑散落:权限校验逻辑散落在Controller、Service、工具类各处,有五六个地方都在做类似的事情
- 数据库实体与业务对象混淆:OrderDO既是数据库映射对象,又是业务对象,贫血模型导致大量getter/setter
- 重复代码泛滥:类似的查询逻辑复制了七八份,只有细微差异
- 测试覆盖率低:核心模块测试覆盖率只有12%,大量关键逻辑是”裸奔”状态
- 事务边界模糊:有些方法有@Transactional,有些没有,事务传播行为完全靠试错确定
改造目标:将测试覆盖率提升到80%,建立清晰的分层架构,让新功能开发效率提升一倍以上。
策略一:小步快走,不要憋大招
遗留系统改造最容易犯的错误,就是”憋大招”——花两个月重写,结果业务方等不及,线上出了bug要修,两头顾不上,最后重写失败。
正确的做法是小步快走,每次只改一小块。
我们采用的策略是”Branch by Abstraction”:在不改变外部行为的前提下,逐步替换内部实现。具体做法是:
- 识别一个独立的业务模块(如角色权限校验)
- 提取接口,保留原有实现
- 在接口下开发新实现
- 通过配置开关切换新旧实现
- 验证通过后,删除旧实现
这样每次改动的影响范围可控,不会影响其他模块的正常运作。
策略二:门脸模式(Facade Pattern)隔离新旧代码
门脸模式是处理遗留系统最有效的结构型模式。
当我们的旧系统需要被新模块调用时,直接在旧代码上改风险太大。更稳妥的做法是新增一个Facade层,作为新旧系统的边界。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| public class LegacyPermissionService { public boolean checkPermission(String userId, String resource) { return false; } }
public interface PermissionService { boolean checkPermission(String userId, String resource); }
public class ModernPermissionService implements PermissionService { private final PermissionRepository repository; @Override public boolean checkPermission(String userId, String resource) { Permission permission = repository.findByUserAndResource(userId, resource); return permission != null && permission.isValid(); } }
@Component public class PermissionServiceFacade implements PermissionService { private final ModernPermissionService modernService; private final LegacyPermissionService legacyService; @Value("${feature.new-permission-service:false}") private boolean useNewService; @Override public boolean checkPermission(String userId, String resource) { return useNewService ? modernService.checkPermission(userId, resource) : legacyService.checkPermission(userId, resource); } }
|
通过配置项切换,可以在生产环境逐步灰度新逻辑,降低全量发布的风险。
策略三:策略模式消除重复分支
遗留系统中常见这样的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public String calculateDiscount(OrderType type, BigDecimal amount) { if (type == OrderType.STANDARD) { ... } else if (type == OrderType.PREMIUM) { ... } else if (type == OrderType.BULK) { ... } else if (type == OrderType.TRIAL) { ... } return amount; }
|
这种多重if-else的问题是:每加一个订单类型,要改这个方法;折扣计算逻辑变化,也要改这个方法。违反开闭原则,测试困难。
用策略模式重构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| public interface DiscountStrategy { BigDecimal apply(Order order); }
@Component public class StandardDiscountStrategy implements DiscountStrategy { @Override public BigDecimal apply(Order order) { return order.getAmount().multiply(new BigDecimal("0.95")); } }
@Component public class PremiumDiscountStrategy implements DiscountStrategy { @Override public BigDecimal apply(Order order) { return order.getAmount().multiply(new BigDecimal("0.85")); } }
@Component public class BulkDiscountStrategy implements DiscountStrategy { @Override public BigDecimal apply(Order order) { if (order.getAmount().compareTo(new BigDecimal("1000")) >= 0) { return order.getAmount().multiply(new BigDecimal("0.8")); } return order.getAmount().multiply(new BigDecimal("0.9")); } }
@Component public class TrialDiscountStrategy implements DiscountStrategy { @Override public BigDecimal apply(Order order) { return order.getAmount(); } }
@Component public class DiscountStrategyFactory { private final Map<OrderType, DiscountStrategy> strategyMap; @Autowired public DiscountStrategyFactory(List<DiscountStrategy> strategies) { strategyMap = strategies.stream() .collect(Collectors.toMap(this::detectType, s -> s)); } public DiscountStrategy getStrategy(OrderType type) { return strategyMap.getOrDefault(type, new TrialDiscountStrategy()); } private OrderType detectType(DiscountStrategy strategy) { String name = strategy.getClass().getSimpleName(); String typeStr = name.replace("DiscountStrategy", "").toUpperCase(); return OrderType.valueOf(typeStr); } }
@Service public class OrderService { @Autowired private DiscountStrategyFactory factory; public BigDecimal calculateDiscount(Order order) { DiscountStrategy strategy = factory.getStrategy(order.getType()); return strategy.apply(order); } }
|
策略模式的优势:
- 新增订单类型只需新增策略类,不影响现有代码
- 每个策略独立,单元测试简单
- 策略可以复用,不限于折扣计算这一场景
策略四:贫血模型到充血模型的演进
很多遗留系统用的是贫血模型,Domain Object只有getter/setter,业务逻辑全堆在Service层。这导致Service越来越臃肿,对象越来越”失血”。
合理的做法是逐步将业务逻辑下沉到Domain Object,让对象自己管理自己的行为。
但这个过程要谨慎,不能一口气把所有逻辑都迁移,否则改动范围太大,风险不可控。
我们的做法是:
- 先在Service层保留现有逻辑
- 新增Domain Method,保持与Service逻辑一致
- 逐步将Service中的逻辑调用替换为Domain Method调用
- 确认稳定后,删除Service中的旧逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public class OrderService { public boolean isExpired(Order order) { return order.getCreateTime() + 30 * 24 * 3600 * 1000 < System.currentTimeMillis(); } }
public class Order { private Date createTime; public boolean isExpired() { return System.currentTimeMillis() - createTime.getTime() > 30 * 24 * 3600 * 1000L; } }
|
策略五:用测试围栏保护重构成果
重构最怕的是”改完上线,系统挂了”。解决这个问题的关键是用测试围栏把核心逻辑圈起来。
我们的测试覆盖率策略:
- 核心业务必须100%覆盖:权限校验、订单计算、支付流程
- 接口层要覆盖:Controller的每个分支都要测到
- 集成测试要跑:确保数据库交互、事务传播符合预期
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| @Test void shouldDenyAccessWhenUserNotHasPermission() { User user = createUserWithoutPermission(); Resource resource = createResource(); boolean result = permissionService.checkPermission(user.getId(), resource.getId()); assertThat(result).isFalse(); }
@Test void shouldAllowAccessWhenUserHasValidPermission() { User user = createUserWithValidPermission(); Resource resource = createResource(); boolean result = permissionService.checkPermission(user.getId(), resource.getId()); assertThat(result).isTrue(); }
|
每次重构前,先写测试;重构后,测试必须全绿。这是保证重构不出问题的根本保障。
实战复盘数据
改造进行了四个月,核心成果:
| 指标 |
改造前 |
改造后 |
| 测试覆盖率 |
12% |
78% |
| 核心模块覆盖率 |
8% |
96% |
| 平均功能开发周期 |
3天 |
1天 |
| P0线上事故 |
每月2-3次 |
连续2月0次 |
| 代码重复率 |
35% |
8% |
开发团队的反馈是:新功能开发确实快了很多,改动的影响范围也更容易评估了。
避坑指南
坑一:过早优化
重构不是重写,不要一开始就想着”我要把这个系统设计得完美”。遗留系统能跑这么多年,说明核心业务逻辑是可靠的。保持谦逊,分步推进。
坑二:忽略业务方沟通
技术改造不能脱离业务。我们在改造期间,每周与产品侧同步进展,确保没有遗漏业务需求,避免”技术做完了,业务不认”的尴尬。
坑三:缺乏回滚预案
每次发布都要有回滚方案。一旦新逻辑出问题,能在五分钟内切回旧逻辑,不影响业务。
坑四:忽略数据库迁移
很多遗留系统的数据库设计也有问题,但改动数据库的风险最高。我们采用的策略是:保持表结构不变,只优化SQL和索引,把数据库改动放到最后阶段。
结论
代码重构不是一蹴而就的工程,而是一个持续改进的过程。核心原则是:
- 小步快走:每次只改一小块,快速验证
- 安全第一:测试先行,发布前要有回滚方案
- 业务对齐:重构服务于业务,不能为了技术理想而脱离实际
- 数据说话:用覆盖率、缺陷率、开发周期等指标衡量改进效果
遗留系统改造没有捷径,但有方法论。选对策略,步步为营,再老的系统也能焕发新生。