一、从旧三层架构的“乱”说起
很多人接手老项目时,都会遇到一种头疼的情况:代码里业务逻辑、数据库操作、页面渲染逻辑混在一起,改个小功能要翻好几个文件,加新功能怕碰坏旧逻辑,上线更是提心吊胆。这种项目大多是早期用“三层架构”写的,原本三层架构是想把业务、数据、页面分开,可实际做的时候很容易走偏——比如把查数据库的代码写在业务层,把页面要的格式化逻辑写在数据层,最后三层变成了“三层乱炖”。
要改这种项目,直接推倒重来是最傻的,一是成本太高,二是风险太大,很容易改着改着就改崩了。渐进式改造才是正道,核心就是先把混乱的逻辑拆成清晰的边界,再慢慢搭新的架构,这个过程就像给乱成一团的毛线分类,先理出头绪,再慢慢整理成规整的线团。
二、第一步:先给旧项目做“体检”,找清要拆的逻辑
在动手改之前,得先搞清楚旧项目里哪些逻辑是混在一起的。就像装修房子前要先画户型图,得先把旧项目的代码理清楚。这里可以用一个简单的方法:拿个笔记本(或者文档),把所有的逻辑分成三类,看看它们分别属于什么: 第一类是“只跟业务有关的逻辑”,比如计算用户的积分、判断订单是否超时、验证商品库存够不够,这些逻辑不管用什么数据库、用什么页面展示,都不会变; 第二类是“跟数据有关的逻辑”,比如查用户信息的SQL、存订单的操作、数据库的连接配置; 第三类是“跟页面有关的逻辑”,比如给页面返回格式化的时间、根据用户的权限隐藏某些按钮、处理页面的输入参数。
举个例子,旧项目里有这么一段代码(先明确示例技术栈:Java 8 + Spring Boot 2.7 + MyBatis 3.5):
// 旧代码:一个处理订单的接口,三层逻辑混在一起
@RestController
public class OrderController {
// 直接注入MyBatis的Mapper,把数据操作写在控制层
@Autowired
private OrderMapper orderMapper;
@GetMapping("/getOrderDetail")
public Map<String, Object> getOrderDetail(Long orderId) {
// 1. 页面逻辑:处理输入参数(校验orderId是否为空)
if (orderId == null || orderId <= 0) {
Map<String, Object> result = new HashMap<>();
result.put("code", 500);
result.put("msg", "订单ID无效");
return result;
}
// 2. 数据逻辑:直接查数据库
Order order = orderMapper.selectById(orderId);
if (order == null) {
Map<String, Object> result = new HashMap<>();
result.put("code", 500);
result.put("msg", "订单不存在");
return result;
}
// 3. 业务逻辑:计算订单的实际金额(原价减优惠)
BigDecimal actualAmount = order.getOriginalAmount().subtract(order.getDiscountAmount());
// 4. 页面逻辑:格式化返回给页面的时间
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String createTimeStr = sdf.format(order.getCreateTime());
// 5. 页面逻辑:组装返回给页面的Map
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("msg", "成功");
result.put("data", new HashMap<String, Object>() {{
put("orderId", order.getId());
put("actualAmount", actualAmount);
put("createTime", createTimeStr);
}});
return result;
}
}
这段代码就是典型的三层混在一起:控制层(页面相关)里写了数据查询(数据层),还写了业务计算(业务层)。我们要做的,就是把这三类逻辑拆分开。
三、第二步:先拆数据访问逻辑,给业务“松绑”
第一步是找逻辑,第二步就是动手拆最容易拆的——数据访问逻辑。数据访问逻辑的特点是“跟业务无关,只跟数据库打交道”,比如查数据、存数据、删数据,这些逻辑可以先单独抽出来,不管业务怎么变,只要数据库结构不变,这些代码就不用改。
3.1 怎么拆数据访问逻辑?
拆数据访问逻辑的核心是:把所有跟数据库相关的代码,都放到一个专门的“数据访问层”,业务层只需要告诉数据访问层“我要什么数据”,不用管怎么查、怎么存。比如上面的例子里,查订单的代码,就可以单独抽成一个数据访问类,业务层要订单,就直接调用这个类的方法。
3.2 示例:抽离数据访问逻辑
还是用上面的技术栈,我们先把数据访问逻辑抽出来:
// 新的:数据访问层类,专门处理跟数据库相关的操作
public class OrderRepository {
// 注入MyBatis的Mapper,只负责数据库操作
private OrderMapper orderMapper;
// 构造方法注入(方便后续测试和解耦)
public OrderRepository(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
// 方法只做一件事:根据订单ID查订单,不做任何业务或页面处理
public Order findById(Long orderId) {
return orderMapper.selectById(orderId);
}
// 可以加其他数据操作方法,比如存订单、更新订单等
public void save(Order order) {
orderMapper.insert(order);
}
}
抽完数据访问层之后,原来的控制层就不用直接调用Mapper了,而是调用OrderRepository的方法。这时候我们可以做一个“过渡”,就是不直接改原来的控制层,而是先把数据访问层加进去,慢慢替换原来的代码,这样能降低风险——比如先在新的接口里用OrderRepository,旧的接口先不动,等新的跑通了,再慢慢替换旧的。
四、第三步:再拆表示层逻辑,给业务“减负”
拆完数据访问逻辑,接下来拆表示层逻辑。表示层逻辑的特点是“跟用户交互有关”,比如处理页面的输入参数、给页面返回格式化的数据、处理页面的异常信息,这些逻辑跟业务本身无关,比如业务里只需要知道“订单的创建时间是某个时间戳”,而表示层要做的是“把时间戳转成页面要的字符串”。
4.1 怎么拆表示层逻辑?
拆表示层逻辑的核心是:把所有跟页面、接口返回、输入校验相关的代码,都放到专门的“表示层”,业务层只需要处理业务逻辑,不用管返回给页面什么格式、输入参数怎么校验。比如上面的例子里,校验订单ID、格式化时间、组装返回的Map,这些都是表示层的逻辑,要抽出来。
4.2 示例:抽离表示层逻辑
我们先把表示层的逻辑抽出来,单独做一个返回结果的工具类,还有输入校验的逻辑:
// 新的:表示层的返回结果工具类,专门处理给页面返回的格式
public class ResultUtil {
// 成功返回的格式,统一封装,不用每个地方都写Map
public static Map<String, Object> success(Object data) {
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("msg", "成功");
result.put("data", data);
return result;
}
// 失败返回的格式
public static Map<String, Object> fail(String msg) {
Map<String, Object> result = new HashMap<>();
result.put("code", 500);
result.put("msg", msg);
return result;
}
// 格式化时间的方法,专门处理页面要的时间格式
public static String formatDate(Date date) {
if (date == null) return "";
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return sdf.format(date);
}
}
抽完表示层的工具类之后,原来的控制层就可以用这个工具类来处理返回结果,不用自己组装Map了。这时候控制层的代码就会简洁很多,只需要处理输入校验、调用业务逻辑、返回结果。
五、第四步:建立领域层和用例层,搭起新架构的骨架
拆完数据访问和表示层逻辑之后,剩下的就是纯粹的业务逻辑了,这时候我们就可以建立领域层和用例层,把业务逻辑整理成清晰的边界。领域层是整个系统的核心,只包含跟业务有关的逻辑,比如订单的计算、用户的积分规则;用例层是领域层的“调度员”,负责组织业务逻辑的执行顺序,比如“用户下单”这个用例,需要先查库存、再算价格、再存订单,用例层就负责把这些步骤串起来。
5.1 怎么建立领域层?
领域层的核心是“领域模型”,也就是业务里的实体,比如订单、用户、商品,这些实体不仅有属性,还有跟业务相关的方法。比如订单这个实体,原来可能只是一个有id、originalAmount、discountAmount属性的类,现在我们可以把计算实际金额的方法放到这个类里,这样订单的业务逻辑就都集中在这个类里了。
5.2 示例:建立领域层和用例层
我们先建立领域层的订单实体:
// 新的:领域层的订单实体,包含属性和业务方法
public class Order {
// 订单ID
private Long id;
// 原价
private BigDecimal originalAmount;
// 优惠金额
private BigDecimal discountAmount;
// 创建时间
private Date createTime;
// 构造方法,用来创建订单
public Order(Long id, BigDecimal originalAmount, BigDecimal discountAmount, Date createTime) {
this.id = id;
this.originalAmount = originalAmount;
this.discountAmount = discountAmount;
this.createTime = createTime;
}
// 业务方法:计算实际金额,这个逻辑只跟订单有关,放到领域层
public BigDecimal calculateActualAmount() {
// 业务规则:如果优惠金额大于原价,实际金额为0
if (discountAmount.compareTo(originalAmount) > 0) {
return BigDecimal.ZERO;
}
return originalAmount.subtract(discountAmount);
}
// 可以加其他业务方法,比如判断订单是否超时等
public boolean isExpired() {
long now = System.currentTimeMillis();
long createTimeMillis = createTime.getTime();
// 业务规则:订单创建超过30天就算过期
return (now - createTimeMillis) > 30L * 24 * 60 * 60 * 1000;
}
// get方法,用来获取属性
public Long getId() {
return id;
}
public BigDecimal getOriginalAmount() {
return originalAmount;
}
public BigDecimal getDiscountAmount() {
return discountAmount;
}
public Date getCreateTime() {
return createTime;
}
}
然后建立用例层,用来组织业务逻辑的执行:
// 新的:用例层,负责调度业务逻辑的执行
public class GetOrderDetailUseCase {
// 用例层依赖数据访问层,不依赖表示层或控制层
private OrderRepository orderRepository;
// 构造方法注入,方便测试和解耦
public GetOrderDetailUseCase(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
// 用例的执行方法,负责组织业务逻辑
public Order execute(Long orderId) {
// 1. 调用数据访问层查订单
Order order = orderRepository.findById(orderId);
// 2. 可以在这里加其他业务逻辑,比如判断订单是否过期
if (order != null && order.isExpired()) {
// 这里可以抛出自定义的业务异常,由表示层处理
throw new BusinessException("订单已过期");
}
return order;
}
}
这时候,控制层的代码就变成了这样:
// 新的:控制层,只负责表示层的逻辑
@RestController
public class OrderController {
// 控制层依赖用例层,不依赖数据访问层或领域层
private GetOrderDetailUseCase getOrderDetailUseCase;
// 构造方法注入
public OrderController(GetOrderDetailUseCase getOrderDetailUseCase) {
this.getOrderDetailUseCase = getOrderDetailUseCase;
}
@GetMapping("/getOrderDetail")
public Map<String, Object> getOrderDetail(Long orderId) {
try {
// 1. 表示层逻辑:输入参数校验
if (orderId == null || orderId <= 0) {
return ResultUtil.fail("订单ID无效");
}
// 2. 调用用例层,获取业务结果
Order order = getOrderDetailUseCase.execute(orderId);
if (order == null) {
return ResultUtil.fail("订单不存在");
}
// 3. 表示层逻辑:组装返回数据,调用领域层的业务方法
Map<String, Object> data = new HashMap<>();
data.put("orderId", order.getId());
data.put("actualAmount", order.calculateActualAmount());
data.put("createTime", ResultUtil.formatDate(order.getCreateTime()));
// 4. 返回结果
return ResultUtil.success(data);
} catch (BusinessException e) {
// 表示层逻辑:处理业务异常
return ResultUtil.fail(e.getMessage());
}
}
}
这时候整个代码的边界就清晰了:表示层(控制层、ResultUtil)只负责跟用户交互,用例层负责调度业务,领域层负责核心业务逻辑,数据访问层负责跟数据库打交道,每一层只做自己该做的事,不会再混在一起。
六、改造过程中的风险控制:怎么改才不会崩?
渐进式改造的核心是“稳”,不能一下改太多,要一步步来,每一步都要验证,确保改完之后不影响原来的功能。这里有几个常用的风险控制方法:
6.1 新旧代码并行,逐步替换
改造的时候,可以先写新的代码,然后把新的代码和旧的代码并行运行,比如新的接口用新的架构,旧的接口先不动,等新的接口跑通了,再慢慢把旧的接口替换成新的。这样就算新的代码有问题,也不会影响旧的功能,风险就小很多。
6.2 写测试用例,覆盖核心逻辑
在改造之前,最好先给核心的业务逻辑写测试用例,比如计算订单实际金额的逻辑,写个测试用例,确保改完之后这个逻辑还是对的。这样改的时候就可以放心改,改完跑一下测试用例,看看有没有问题。
6.3 小步迭代,每次改一点
不要一下改整个项目,而是每次改一个小功能,比如先改订单详情的接口,改完跑通了,再改订单列表的接口,这样每次改的范围小,出问题的概率也小,就算出问题,也容易定位和修复。
七、应用场景、优缺点和注意事项
7.1 应用场景
这种渐进式改造的方法,适合所有需要改造遗留系统的场景,尤其是那些已经上线运行、不能随便停机、不能推倒重来的项目。比如电商系统、金融系统、政务系统等,这些系统一旦出问题,影响很大,渐进式改造是最稳妥的选择。
7.2 技术优缺点
优点:一是风险低,因为是逐步替换,不会一下影响整个系统;二是成本低,不用从头开发,只需要改现有代码;三是可验证,每一步都可以跑通,确保功能正常;四是可扩展,改造完之后的架构清晰,后续加新功能很方便。 缺点:一是改造周期长,需要一步步来,不能一下完成;二是需要一定的技术能力,要能把逻辑拆清楚,还要能写测试用例;三是需要协调,因为新旧代码并行,可能需要跟测试、运维等部门协调,确保改造过程顺利。
7.3 注意事项
一是不要追求完美,改造的核心是“能用、稳定”,不要一开始就想把所有逻辑都拆得很完美,先把核心逻辑拆清楚,后续再慢慢优化;二是要做好版本控制,每一步改造都要提交代码,做好版本备份,万一改崩了,可以回滚;三是要跟团队沟通,改造过程中要跟开发、测试、运维等团队沟通,让大家知道改造的进度和风险,避免出现混乱;四是要优先改造核心功能,先改那些经常改、经常出问题的功能,再改那些不常用的功能,这样能最快看到改造的效果。
八、文章总结
改造遗留系统是一个很常见的工作,很多人面对混乱的旧代码会不知所措,甚至想推倒重来,但推倒重来的风险太高,成本也太高。渐进式改造的方法,就是从旧的三层架构开始,先拆数据访问逻辑,再拆表示层逻辑,然后建立领域层和用例层,一步步把混乱的逻辑整理成清晰的边界,每一步都稳扎稳打,每一步都验证,这样就能把改造的风险降到最低。
改造的过程就像整理房间,先把杂物分类,再把常用的东西放到合适的位置,最后整理成一个整洁、有序的空间。只要按照这个方法来,再混乱的遗留系统也能改造成清晰、可维护的架构。
Comments