前言

每个Java程序员的职业生涯中,都会遇到那么几个”祖传系统”。

代码写得早,业务跑得久,逻辑绕三圈,注释全靠猜。这种系统改一个小功能,可能要牵一动百;加一个新需求,要先花三天读懂现有逻辑。更难受的是,测试覆盖率只有百分之十几,修改全靠胆量。

本文从一个真实的遗留系统改造项目出发,聊聊代码重构的实战手法与经验总结。

项目背景

这个项目是一个企业内部的用户权限管理系统,2018年上线,主要负责用户、角色、权限的管理。技术栈是 Spring Boot 2.3 + MyBatis + MySQL,业务逻辑不复杂,但代码质量堪忧。

具体问题:

  1. 业务逻辑散落:权限校验逻辑散落在Controller、Service、工具类各处,有五六个地方都在做类似的事情
  2. 数据库实体与业务对象混淆:OrderDO既是数据库映射对象,又是业务对象,贫血模型导致大量getter/setter
  3. 重复代码泛滥:类似的查询逻辑复制了七八份,只有细微差异
  4. 测试覆盖率低:核心模块测试覆盖率只有12%,大量关键逻辑是”裸奔”状态
  5. 事务边界模糊:有些方法有@Transactional,有些没有,事务传播行为完全靠试错确定

改造目标:将测试覆盖率提升到80%,建立清晰的分层架构,让新功能开发效率提升一倍以上。

策略一:小步快走,不要憋大招

遗留系统改造最容易犯的错误,就是”憋大招”——花两个月重写,结果业务方等不及,线上出了bug要修,两头顾不上,最后重写失败。

正确的做法是小步快走,每次只改一小块

我们采用的策略是”Branch by Abstraction”:在不改变外部行为的前提下,逐步替换内部实现。具体做法是:

  1. 识别一个独立的业务模块(如角色权限校验)
  2. 提取接口,保留原有实现
  3. 在接口下开发新实现
  4. 通过配置开关切换新旧实现
  5. 验证通过后,删除旧实现

这样每次改动的影响范围可控,不会影响其他模块的正常运作。

策略二:门脸模式(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) {
// 满1000打8折
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);
}
}

策略模式的优势:

  1. 新增订单类型只需新增策略类,不影响现有代码
  2. 每个策略独立,单元测试简单
  3. 策略可以复用,不限于折扣计算这一场景

策略四:贫血模型到充血模型的演进

很多遗留系统用的是贫血模型,Domain Object只有getter/setter,业务逻辑全堆在Service层。这导致Service越来越臃肿,对象越来越”失血”。

合理的做法是逐步将业务逻辑下沉到Domain Object,让对象自己管理自己的行为。

但这个过程要谨慎,不能一口气把所有逻辑都迁移,否则改动范围太大,风险不可控。

我们的做法是:

  1. 先在Service层保留现有逻辑
  2. 新增Domain Method,保持与Service逻辑一致
  3. 逐步将Service中的逻辑调用替换为Domain Method调用
  4. 确认稳定后,删除Service中的旧逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 改造前:逻辑全在Service
public class OrderService {
public boolean isExpired(Order order) {
return order.getCreateTime() + 30 * 24 * 3600 * 1000 < System.currentTimeMillis();
}
}

// 改造后:逻辑下沉到Domain
public class Order {
private Date createTime;

public boolean isExpired() {
return System.currentTimeMillis() - createTime.getTime() > 30 * 24 * 3600 * 1000L;
}
}

策略五:用测试围栏保护重构成果

重构最怕的是”改完上线,系统挂了”。解决这个问题的关键是用测试围栏把核心逻辑圈起来。

我们的测试覆盖率策略:

  1. 核心业务必须100%覆盖:权限校验、订单计算、支付流程
  2. 接口层要覆盖:Controller的每个分支都要测到
  3. 集成测试要跑:确保数据库交互、事务传播符合预期
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() {
// given
User user = createUserWithoutPermission();
Resource resource = createResource();

// when
boolean result = permissionService.checkPermission(user.getId(), resource.getId());

// then
assertThat(result).isFalse();
}

@Test
void shouldAllowAccessWhenUserHasValidPermission() {
// given
User user = createUserWithValidPermission();
Resource resource = createResource();

// when
boolean result = permissionService.checkPermission(user.getId(), resource.getId());

// then
assertThat(result).isTrue();
}

每次重构前,先写测试;重构后,测试必须全绿。这是保证重构不出问题的根本保障。

实战复盘数据

改造进行了四个月,核心成果:

指标 改造前 改造后
测试覆盖率 12% 78%
核心模块覆盖率 8% 96%
平均功能开发周期 3天 1天
P0线上事故 每月2-3次 连续2月0次
代码重复率 35% 8%

开发团队的反馈是:新功能开发确实快了很多,改动的影响范围也更容易评估了。

避坑指南

坑一:过早优化

重构不是重写,不要一开始就想着”我要把这个系统设计得完美”。遗留系统能跑这么多年,说明核心业务逻辑是可靠的。保持谦逊,分步推进。

坑二:忽略业务方沟通

技术改造不能脱离业务。我们在改造期间,每周与产品侧同步进展,确保没有遗漏业务需求,避免”技术做完了,业务不认”的尴尬。

坑三:缺乏回滚预案

每次发布都要有回滚方案。一旦新逻辑出问题,能在五分钟内切回旧逻辑,不影响业务。

坑四:忽略数据库迁移

很多遗留系统的数据库设计也有问题,但改动数据库的风险最高。我们采用的策略是:保持表结构不变,只优化SQL和索引,把数据库改动放到最后阶段。

结论

代码重构不是一蹴而就的工程,而是一个持续改进的过程。核心原则是:

  1. 小步快走:每次只改一小块,快速验证
  2. 安全第一:测试先行,发布前要有回滚方案
  3. 业务对齐:重构服务于业务,不能为了技术理想而脱离实际
  4. 数据说话:用覆盖率、缺陷率、开发周期等指标衡量改进效果

遗留系统改造没有捷径,但有方法论。选对策略,步步为营,再老的系统也能焕发新生。