接手了一个 4000 行的 Service 类
三月底,组里那个做了两年的老项目交到我手上。第一次打开 OrderServiceImpl.java,IDEA 右下角显示 4127 lines,我愣了一会儿。往下翻,一个方法从 1032 行开始,到 1450 行结束,中间嵌套了 7 层 if。
更绝望的是,这个类的单元测试覆盖率为 0——根本没法测,因为方法里直接 new 了数据库访问对象、直接调用第三方 HTTP 接口、直接写 Redis。
五一假期我没出去玩,在家把它重构了一遍。记录一下这个过程和我的思考,都是很基础的东西,权当给同样是新人的同学参考。
原来的代码长什么样
典型的"Controller 里写业务,Service 里写一切":
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
private JdbcTemplate jdbcTemplate; // Controller 直接碰数据库
@PostMapping("/create")
public Map<String, Object> create(@RequestBody Map<String, Object> params) {
Map<String, Object> result = new HashMap<>();
// 参数校验
Integer userId = (Integer) params.get("userId");
if (userId == null || userId <= 0) {
result.put("code", 400);
result.put("msg", "用户ID非法");
return result;
}
// 查库存
String skuId = (String) params.get("skuId");
Integer stock = jdbcTemplate.queryForObject(
"SELECT stock FROM t_sku WHERE sku_id = ?", Integer.class, skuId);
if (stock == null || stock < 1) {
result.put("code", 500);
result.put("msg", "库存不足");
return result;
}
// 扣库存、创建订单、发券、发短信……(后面还有 200 行)
...
result.put("code", 200);
return result;
}
}
问题罗列一下:
- Controller 直接依赖 JdbcTemplate,等于把 HTTP 层和数据访问层焊死了。
- 入参出参都是 Map,字段全靠字符串 key,改个名字编译器不报错,运行时才炸。
- 业务逻辑全挤在 Controller 里,复用不了。后来要做个定时任务批量下单,只能复制粘贴一份。
- 没有任何单元测试的可能,因为没法 mock 掉 JdbcTemplate 和 HTTP 调用(其实可以,但那时候我不会)。
第一步:把职责拆到三层
我按最经典的 Controller / Service / DAO 重新划分。
Controller 只做三件事:接收参数、校验参数格式、返回结果。不碰数据库、不含业务判断:
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping("/create")
public Result<OrderVO> create(@Valid @RequestBody OrderCreateRequest request) {
Long orderId = orderService.createOrder(request);
return Result.success(orderService.getDetail(orderId));
}
}
入参出参都换成了明确的 DTO,配合 @Valid 做校验:
public class OrderCreateRequest {
@NotNull(message = "用户ID不能为空")
private Long userId;
@NotBlank(message = "商品编码不能为空")
private String skuId;
@NotNull @Min(value = 1, message = "数量至少为 1")
private Integer quantity;
}
这么改之后最大的收益是:接口文档可以直接从 DTO 生成,字段改动编译期就报错。以前那种"改了数据库字段,忘了改 Map 的 key"的低级错误再也不会有了。
Service 负责业务编排,它调用 DAO 拿数据,调用各种 Domain 服务完成业务动作:
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private SkuRepository skuRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private InventoryService inventoryService;
@Transactional(rollbackFor = Exception.class)
@Override
public Long createOrder(OrderCreateRequest request) {
inventoryService.deduct(request.getSkuId(), request.getQuantity());
Sku sku = skuRepository.findBySkuId(request.getSkuId());
Order order = Order.create(request.getUserId(), sku, request.getQuantity());
orderRepository.save(order);
return order.getId();
}
}
DAO 只做数据存取,一个方法对应一次数据操作,不做业务判断。我用 MyBatis 3.4.6 重写了一遍:
@Mapper
public interface OrderRepository {
int insert(Order order);
Order selectById(Long id);
List<Order> selectByUserIdAndStatus(@Param("userId") Long userId,
@Param("status") Integer status);
}
贫血模型:我一开始没意识到的坑
拆完三层我以为大功告成,但师傅 review 的时候说了一句:"你这是典型的贫血模型,Order 类就是个字段袋子。"
我看看自己写的 Order:
public class Order {
private Long id;
private Long userId;
private BigDecimal amount;
private Integer status;
private Date createTime;
// 加 17 个 getter、17 个 setter,没有别的方法
}
确实。所有跟订单相关的行为——能不能取消、能不能支付、状态怎么流转——全写在 Service 里,变成了一堆散落的 if:
// Service 里 4 个地方都在判断同一件事
if (order.getStatus() == 1 || order.getStatus() == 2) {
// 允许取消
}
if (order.getStatus() != 0) {
throw new BizException("只有待支付订单才能支付");
}
贫血模型的代价就是这样:业务逻辑脱离了它所作用的数据,散落在各个 Service 方法里,重复、易漏、难测。哪天新增一个状态,得全局搜索所有 status 判断的地方。
我的改法是把行为放回对象里:
public class Order {
private Long id;
private BigDecimal amount;
private OrderStatus status;
public boolean canCancel() {
return status == OrderStatus.WAIT_PAY || status == OrderStatus.PAID;
}
public boolean canPay() {
return status == OrderStatus.WAIT_PAY;
}
public void cancel(String reason) {
if (!canCancel()) {
throw new BizException("当前状态不允许取消,status=" + status);
}
this.status = OrderStatus.CANCELLED;
this.cancelReason = reason;
}
public void pay() {
if (!canPay()) {
throw new BizException("只有待支付订单才能支付");
}
this.status = OrderStatus.PAID;
this.payTime = new Date();
}
}
Service 里就干净了:order.cancel("用户主动取消"),一行搞定,而且这个规则现在是有单元测试的——不需要起 Spring 容器,直接 new 一个 Order 就能测。
依赖倒置:让 Service 不依赖具体实现
重构到后半段遇到个实际问题:下单成功后要发短信通知,而短信服务是第三方 HTTP 接口,单元测试的时候不能真发。
我一开始的写法:OrderServiceImpl 里直接依赖 SmsServiceImpl。现在改成一个接口:
// 定义抽象,放在 domain 层
public interface NotificationService {
void notifyOrderCreated(Long orderId);
}
// 具体实现
@Service
public class SmsNotificationServiceImpl implements NotificationService {
@Override
public void notifyOrderCreated(Long orderId) { ... }
}
OrderServiceImpl 只依赖 NotificationService 接口:
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private NotificationService notificationService; // 依赖抽象
}
这就是依赖倒置(DIP)最朴素的实践:高层模块不依赖低层模块,二者都依赖抽象。带来的好处立竿见影——单元测试里我塞了个假实现进去,测试跑得飞快,也不再担心发短信欠费。
public class OrderServiceTest {
@Test
public void testCreateOrder() {
OrderService service = new OrderServiceImpl(
mockSkuRepository, mockOrderRepository, new NoopNotificationService());
// ...
}
}
后来我们要接微信模板消息,就是新增了一个 WechatNotificationServiceImpl,OrderService 一行没动。
重构结果和一些反思
折腾了四天,代码的变化:
| 指标 | 重构前 | 重构后 |
|---|---|---|
| OrderServiceImpl 行数 | 4127 | 386(拆成 7 个类) |
| 最长方法 | 418 行 | 47 行 |
| 单元测试数 | 0 | 63 |
| Controller 中 SQL 出现次数 | 23 | 0 |
功能上没有任何新增,但后面加需求的速度明显不一样了。四月份加了个"订单自动确认收货"的定时任务,我直接复用了 Order 领域对象和 Repository,半天就写完了——搁以前,复制粘贴加调试至少两天。
也有我做过了的地方。拆分的时候我一度搞出了 7 层(Controller / Facade / Service / Manager / Domain / Repository / DAO),每层就几行代码,纯转发,debug 的时候跳来跳去很崩溃。后来师傅让我合并掉了 Facade 和 Manager,回到三层加一个 Domain 层。分层是为了降低复杂度,不是为了显得专业,层数本身也是成本。
还有一点:这次重构我没敢一次性做完,是一块一块挪的,每挪一块跑一遍测试,保证行为不变。老系统没有测试保护伞,任何"大爆炸式"重写都是在赌博。