Administrator
发布于 2018-05-02 / 1241 阅读
13

从单体到分层:我的第一个项目重构记录

接手了一个 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 行数4127386(拆成 7 个类)
最长方法418 行47 行
单元测试数063
Controller 中 SQL 出现次数230

功能上没有任何新增,但后面加需求的速度明显不一样了。四月份加了个"订单自动确认收货"的定时任务,我直接复用了 Order 领域对象和 Repository,半天就写完了——搁以前,复制粘贴加调试至少两天。

也有我做过了的地方。拆分的时候我一度搞出了 7 层(Controller / Facade / Service / Manager / Domain / Repository / DAO),每层就几行代码,纯转发,debug 的时候跳来跳去很崩溃。后来师傅让我合并掉了 Facade 和 Manager,回到三层加一个 Domain 层。分层是为了降低复杂度,不是为了显得专业,层数本身也是成本。

还有一点:这次重构我没敢一次性做完,是一块一块挪的,每挪一块跑一遍测试,保证行为不变。老系统没有测试保护伞,任何"大爆炸式"重写都是在赌博。

参考