Administrator
发布于 2021-01-15 / 1685 阅读
47

DDD 落地第一步:贫血模型到领域模型的转变

一次资损:限领 1 张的券,用户领了 2 张

1 月初的一个上午,运营在群里 @ 我:一张"新客专享 50 元券",配置的是每人限领 1 张,有用户领到了 2 张,已经核销了一张。

我查了数据库:

mysql> SELECT user_id, coupon_id, count(*) c FROM coupon_grant
    -> WHERE coupon_id = 10237 GROUP BY user_id HAVING c > 1 LIMIT 5;
+---------+-----------+---+
| user_id | coupon_id | c |
+---------+-----------+---+
|  882341 |     10237 | 2 |
|  901552 |     10237 | 2 |
|  774109 |     10237 | 2 |
+---------+-----------+---+

3 张超发,金额 150 元。钱不多,但性质很糟——这是规则被绕过,不是并发没控制住。

我们的失血模型长什么样

当时的优惠券代码是标准的三层架构 + 失血模型。Coupon 这个类是这么写的:

@Data
@TableName("coupon")
public class Coupon {
    private Long id;
    private String name;
    private Integer type;
    private BigDecimal amount;
    private BigDecimal threshold;
    private Integer totalCount;
    private Integer grantedCount;
    private Integer limitPerUser;
    private Integer status;
    private LocalDateTime startTime;
    private LocalDateTime endTime;
}

没错,一个纯 Lombok @Data,除了字段什么都没有。所有逻辑都在 CouponService 里,这个类的实际行数是 2147 行。

领券的入口有四个:H5 领券中心、商品详情页的领券组件、新人礼包自动发放、客服后台手动补发。前三个各自调了 CouponService不同方法

// 领券中心
public void grantFromCenter(Long couponId, Long userId) {
    Coupon coupon = couponMapper.selectById(couponId);
    checkTime(coupon);                 // 校验了时间
    checkLimitPerUser(coupon, userId); // 校验了限领
    doGrant(coupon, userId);
}

// 商品详情页,另一个同事写的
public void grantFromDetail(Long couponId, Long userId) {
    Coupon coupon = couponMapper.selectById(couponId);
    checkStatus(coupon);               // 校验了状态
    doGrant(coupon, userId);           // 漏了 checkLimitPerUser
}

// 新人礼包,三个月前写的,那时还没有 limitPerUser 字段
public void grantFromGift(Long couponId, Long userId) {
    Coupon coupon = couponMapper.selectById(couponId);
    doGrant(coupon, userId);
}

出事的就是商品详情页那个入口。它上线于 2020 年 10 月,比 limitPerUser 字段晚一个月,写的时候没人提醒他要加限领校验。

失血模型的三个真实危害

这次事故之后我整理了代码,发现问题比"漏了一行校验"严重得多:

  • 规则可以被绕过。只要有一条路径不经过校验方法,规则就不成立。四个入口就是四份规则副本,改一次要同步四处。
  • 无法单元测试CouponService 依赖 Mapper、Redis、Dubbo,测一个限领规则要 mock 一堆东西。我们这个服务 2147 行的 Service 只有 12 个测试,覆盖率 8%。
  • 并发安全靠运气grantedCount 的更新是 UPDATE coupon SET granted_count = granted_count + 1,靠数据库保证原子性,这没问题。但"查限领数量 + 插入发放记录"这两步之间没有锁,只有一处入口加了 Redis 分布式锁,另外三处没有。之所以只超发 3 张,纯粹是因为并发量低。

我把这段贴给团队看的时候,说了一句:我们的 Coupon 不是对象,是一张会走路的数据库表。

第一步:找出聚合根

我们没搞事件风暴那种大阵仗,就四个人在会议室,把优惠券相关的表画在白板上,问了两个问题:

  1. 哪些东西必须"同生共死",改一个必须同时改另一个?
  2. 外部要操作这批数据时,从谁开始?

结论是 Coupon(券的模板/批次)是聚合根,CouponGrantRecord(发放记录)在它的边界内。判断依据:校验"用户领了几张"必须查发放记录,而发放记录离开了券模板没有意义(没人会单独查"所有用户的领券记录"这个业务动作)。

CouponTemplate(券的展示模板)、UserAccount(用户账户)不在这个聚合里,它们是别的聚合根。

外部只能通过聚合根的方法操作聚合内的一切。改造后:

public class Coupon extends AggregateRoot<Long> {

    private Long id;
    private CouponStatus status;
    private TimeRange validRange;
    private GrantRule grantRule;        // 值对象:限领数量、总量、库存
    private int grantedCount;

    /**
     * 唯一的领券入口。所有发放路径都必须走这里。
     */
    public CouponGrantRecord grantTo(long userId, Clock clock) {
        // 不变量 1:券必须处于可领取状态
        if (!this.status.canGrant()) {
            throw new CouponNotGrantableException(this.id, this.status);
        }
        // 不变量 2:必须在有效期内
        if (!this.validRange.contains(clock.now())) {
            throw new CouponExpiredException(this.id);
        }
        // 不变量 3:总量不能超发
        if (this.grantedCount >= this.grantRule.totalCount()) {
            throw new CouponSoldOutException(this.id);
        }
        this.grantedCount++;

        CouponGrantRecord record = CouponGrantRecord.of(
                this.id, userId, this.grantRule, clock.now());

        registerEvent(new CouponGrantedEvent(this.id, userId, record.getCode()));
        return record;
    }

    /**
     * 限领校验放在发放记录这一侧,由 repository 提供计数。
     * grantedCount 是聚合内的,用户维度的领取数要从仓储查。
     */
    public void checkUserLimit(long userId, int alreadyGranted) {
        this.grantRule.checkUserLimit(userId, alreadyGranted);
    }
}

关键变化:没有任何人能从外部 setGrantedCount()。字段没有 setter,状态只能通过 grantTo() 改变,而 grantTo() 里三个不变量一个都跑不掉。

仓储:只存取聚合根

仓储接口定义在领域层,实现扔到基础设施层。这是依赖倒置的关键——领域层不认识 MyBatis。

// domain 层
public interface CouponRepository {
    Coupon findById(Long id);
    int countGrantedByUser(Long couponId, Long userId);
    void save(Coupon coupon);
    void saveGrantRecord(CouponGrantRecord record);
}

// infrastructure 层
@Repository
public class CouponRepositoryImpl implements CouponRepository {

    @Autowired private CouponMapper couponMapper;
    @Autowired private CouponGrantMapper grantMapper;

    @Override
    public Coupon findById(Long id) {
        CouponPO po = couponMapper.selectById(id);
        return CouponConverter.toDomain(po);   // PO -> 领域对象
    }

    @Override
    public void save(Coupon coupon) {
        couponMapper.updateById(CouponConverter.toPO(coupon));
    }
}

新增了 CouponConverter 做 PO 和领域对象的转换。这层转换挺烦的,但它把数据库表结构和领域模型解耦了——后来我们把 statustype 这些整数换成枚举,数据库一行没动。

仓储有一条铁律:仓储方法返回的是聚合根,不能返回 CouponGrantRecord 让上层自己去改。一旦允许,不变量就又散出去了。

领域服务:只处理跨聚合的事

领券这个动作涉及两个聚合:CouponUserAccount(要判断用户是不是新客)。单个聚合根干不了,这时候上领域服务:

@Service
public class CouponGrantService {

    @Autowired private CouponRepository couponRepository;
    @Autowired private UserAccountRepository accountRepository;
    @Autowired private DistributedLock lock;

    @Transactional
    public String grant(Long couponId, Long userId) {
        String lockKey = "coupon:grant:" + couponId + ":" + userId;
        return lock.execute(lockKey, 3, TimeUnit.SECONDS, () -> {

            Coupon coupon = couponRepository.findById(couponId);

            int already = couponRepository.countGrantedByUser(couponId, userId);
            coupon.checkUserLimit(userId, already);

            UserAccount account = accountRepository.findById(userId);
            coupon.checkUserTag(account.getTags());     // 新客校验

            CouponGrantRecord record = coupon.grantTo(userId, Clock.systemUTC());
            couponRepository.saveGrantRecord(record);
            couponRepository.save(coupon);
            return record.getCode();
        });
    }
}

领域服务本身很薄,它负责编排(加锁、加载、调领域对象、保存),不负责业务规则。规则全在聚合根和值对象里。

现在四个入口全部收敛成一行:

couponGrantService.grant(couponId, userId);

漏校验这件事,从"code review 要盯着"变成了结构上不可能

值对象:把一组规则打包

GrantRule 是个值对象,它把"限领多少、总量多少、什么时间能领"这几条规则打包成一个不可变对象:

public final class GrantRule {

    private final int totalCount;
    private final int limitPerUser;
    private final TimeRange activeRange;

    public GrantRule(int totalCount, int limitPerUser, TimeRange activeRange) {
        if (totalCount <= 0) {
            throw new IllegalArgumentException("totalCount 必须大于 0");
        }
        if (limitPerUser <= 0) {
            throw new IllegalArgumentException("limitPerUser 必须大于 0");
        }
        this.totalCount = totalCount;
        this.limitPerUser = limitPerUser;
        this.activeRange = activeRange;
    }

    public void checkUserLimit(long userId, int alreadyGranted) {
        if (alreadyGranted >= limitPerUser) {
            throw new CouponLimitExceededException(userId, limitPerUser, alreadyGranted);
        }
    }

    // 值对象没有 setter,改动就创建新对象
    public GrantRule extendTotal(int additional) {
        return new GrantRule(this.totalCount + additional,
                             this.limitPerUser, this.activeRange);
    }

    @Override
    public boolean equals(Object o) { /* 按值比较,不是按引用 */ }
    @Override
    public int hashCode() { ... }
}

值对象的三个特征:构造时校验、没有 setter、按值比较相等。它不可变,所以可以随便传递,不用担心被谁改了。

把校验放进构造函数这一步很关键。以前 limitPerUser 是个 Integer 字段,运营在后台填了 0 也能存进去,运行时才暴露问题。现在构造的时候就直接抛异常,脏数据从源头进不来。

落地中真正麻烦的部分

原理讲清楚只要一小时,做下去全是具体困难:

  • MyBatis 映射富对象很别扭GrantRule 是个值对象,在表里是三个平铺字段。我们没上 JPA,用了 MyBatis 的 <resultMap> + 自定义 TypeHandler,写了大概 200 行转换代码。
  • 团队接受度。有同事问"就加个校验至于这么麻烦吗"。我把那 3 张超发的券截图给他看了,之后没再问。但确实有人不认同,我们最后达成的共识是:核心交易链路(券、库存、订单)用领域模型,后台管理类的 CRUD 继续用失血模型
  • 不要一上来就搞 CQRS、事件溯源。我们 2021 年只做了聚合根、值对象、仓储这三层,事件机制只是发了个 Spring Event 用于发券后清缓存,没有做成事件驱动架构。
  • 单元测试的收益最快。改造后 Coupon 这个聚合根不依赖任何 Spring,测一个"超发时抛异常"只要 6 行代码。三个月后这个包的测试覆盖率从 8% 涨到 61%,新增的规则 bug 是 0 个。
@Test
public void should_throw_when_sold_out() {
    Coupon coupon = CouponFixture.couponWithTotal(100);
    IntStream.range(0, 100).forEach(i -> coupon.grantTo(i, FIXED_CLOCK));

    assertThrows(CouponSoldOutException.class,
            () -> coupon.grantTo(999L, FIXED_CLOCK));
}

最后的目录结构

com.xxx.coupon
├── interfaces/          对外接口层
│   ├── CouponController.java
│   └── GrantRequest.java
├── application/         应用服务层(编排、事务、加锁)
│   ├── CouponGrantService.java
│   └── CouponQueryService.java
├── domain/              领域层(不依赖 Spring、不依赖 MyBatis)
│   ├── model/
│   │   ├── coupon/
│   │   │   ├── Coupon.java              聚合根
│   │   │   ├── CouponGrantRecord.java   实体
│   │   │   ├── GrantRule.java           值对象
│   │   │   └── CouponStatus.java        枚举
│   │   └── shared/
│   │       ├── AggregateRoot.java
│   │       └── TimeRange.java
│   ├── repository/      仓储接口(只有接口)
│   │   ├── CouponRepository.java
│   │   └── UserAccountRepository.java
│   ├── service/         领域服务(跨聚合)
│   │   └── CouponGrantDomainService.java
│   └── event/
│       └── CouponGrantedEvent.java
└── infrastructure/      基础设施层
    ├── repository/
    │   ├── CouponRepositoryImpl.java
    │   └── converter/CouponConverter.java
    └── persistence/
        ├── CouponMapper.java
        └── CouponPO.java

domain 包我特意加了一条检查规则:用 ArchUnit 写了个单测,禁止 domain 包依赖 Spring 和 MyBatis。

@Test
public void domain_should_not_depend_on_framework() {
    noClasses().that().resideInAPackage("..domain..")
        .should().dependOnClassesThat()
        .resideInAnyPackage("org.springframework..", "com.baomidou..", "org.apache.ibatis..")
        .check(classes);
}

这个测试拦下过三次违规,都是有人图省事在领域对象里注入了 Mapper。

下篇预告

这篇先把《DDD 落地第一步:贫血模型到领域模型的转变》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考