两种不同充血模型实现中的问题及解决办法

前言

关于贫血模型与充血模型,已经有大量的文章写到了,但大部分都只是写了两种模型的对比,其中的例子也相对比较简单。
当我们真正在使用充血模型的过程中,还会碰到很多问题,本文期望通过我们复杂的业务场景,深入的讲解我们在真正使用充血模型时,到底会碰到哪些问题,我们又要如何来解决。

什么是充血模型

Martin Fowler在2003年发表的一篇文章中,第一次提出贫血模型的概念,他把贫血模型称为反模式,因为贫血模型完全违反了面向对象的基本原则。贫血模型 by Martin Fowler
而充血模型是一种有行为的模型,模型中状态的改变只能通过模型上的行为来触发,同时所有的约束及业务逻辑都收敛在模型上。

贫血模型相对简单,模型上只有数据没有行为,业务逻辑由xxxService、xxxManger等类来承载,相对来说比较直接,针对简单的业务,贫血模型可以快速的完成交付,但后期的维护成本比较高,很容易变成我们所说的面条代码。
充血模型的实现相对比较复杂,但所有逻辑都由各自的类来负责,职责比较清晰,方便后期的迭代与维护。

在国内还有人做了更细的命名,在这种分类下,分成了4种模型:失血模型、贫血模型、充血模型和涨血模型。我这里采用Martin Fowler的命名方式,个人觉得其它几种只是充血模型的不同实现而已。

充血模型的两种实现方式

因为模型状态变更后,还需要做持久化,而持久化需要用到基础设施层的能力,因而充血模型的实现没有特别完美的方式,主流的实现有两种,但都有一定的问题:
实现一:不带Repository的充血模型实现:模型中不带有Repository,调用模型的方法后,内存中的对象状态变更,再通过调用Repository来做数据的持久化。
实现二:带Repository的充血模型实现:模型中带有Repository,调用模型的方法后,模型会自己完成数据的持久化。

另外在我们实现充血模型时,有一个重要的原则:

不管是充血模型的哪一种实现,有一个重要的原则是充血模型中不能有重要属性的set方法,
改变这些属性有且只有通过模型上的行为方法。 比如在Account上不能有setStatus方法,只能有enable方法。

因为我们在enable方法上是有业务逻辑约束的,而setStatus方法是没有任何约束的,我们不能无约束的改变Account的状态。

实现一:不带Repository的充血模型实现

不带Repository的充血模型使用方法如下:

@Transactional(rollbackFor = Exception.class)
public void accountEnable(Long accountId) {
    //查找帐号
    Account account = accountRepository.find(accountId);
    //激活帐号,改变内存中帐号的状态
    account.enable();
    //持久化
    accountRepository.save(account);
}

Account的enable方法实现如下:

/**
 * 启用账号
 */
public void enable() {
    // 前置检验
    if (this.status != AccountStatus.DISABLE) {
        throw new AccountBizException(AccountErrorCode.ACCOUNT_STATUS_ERROR, "账号非关闭状态,无法启用,账号id:" + id);
    }
    // 变更状态
    this.status = AccountStatus.ENABLE;
}

在上面的实现中我们有三个问题还需要解决:

问题一:如果Account上有各个属性的set方法,就能绕过enable或其它状态变更时的约束。

在使用这个Account对象时,如果我们直接调用account.setStatus(AccountStatus.ENABLE),然后再调用Repository.save方法,我们就能直接改变帐号的状态,这样就绕过了所有状态变更的约束了。
这个问题相对好解决,我们可以把Account上的set方法去掉,然后在account中内置一个builder,通过这个builder来构建account对象,或者通过工厂来构建account对象,比如:

        // 通过builder构建account对象
        Account account = Account.builder()
            .tenant(accountCreateCmd.getTenant())
            .bizAccountId(accountCreateCmd.getBizAccountId())
            .mobile(accountCreateCmd.getMobile())
            .tbAccount(accountCreateCmd.getTbAccount())
            .idCard(accountCreateCmd.getIdCard())
            .nick(accountCreateCmd.getNick())
            .registerTime(LocalDateTime.now())
            .build();
        //持久化account对象
        accountRepository.save(account);
问题二:在领域对象中发布领域事件时,对象还没有做持久化,会导致收到事件时在数据库中查不到数据。

在前面的例子中,我们看到当我们在调用account.disable方法时,只是改变了对象在内存中的状态,并没有做持久化,而此时在disable方法中,我们会发布一个accounDisable的领域事件,发布事件时还没有做持久化,接收到这个事件时去查询account的状态就会出问题。
这个问题的解法也不难,我们需要把领域事件的发布拆分成两个阶段:
第一个阶段是创建事件,在account.disable方法中。
第二个阶段是发布事件,并清除所有事件,在repository.save方法中。
在disable方法中代码如下:

    /**
     * 禁用账号
     */
    public void disable() {
        // 前置校验
        if (this.status != AccountStatus.ENABLE) {
            throw new AccountBizException(AccountErrorCode.ACCOUNT_STATUS_ERROR, "账号非开启状态,无法启用,账号id:" + id);
        }
        // 变更状态
        this.status = AccountStatus.DISABLE;

        // 创建领域事件
        this.createEvent(new AccountDisableEvent(this.id));
    }

领域模型基类,我们把事件的创建与清理统一放在基类中,所有领域对象都需要继承它。

public abstract class AggregateRoot<T> {

    private final List<T> domainEvents = new ArrayList<>();

    /**
     * All domain events currently captured by the aggregate.
     */
    public List<T> domainEvents() {
        return Collections.unmodifiableList(domainEvents);
    }

    /**
     * Registers the given event for publication.
     */
    protected void createEvent(T event) {
        this.domainEvents.add(event);
    }

    /**
     * Clears all domain events currently held. Invoked by the infrastructure in place in Spring Data
     * repositories.
     */
    public void clearDomainEvents() {
        this.domainEvents.clear();
    }
}

在repository.save方法中,我们会发布所有创建的事件,并清除创建的事件(以免对象再次变更时重复发送事件),代码如下:

    public void save(Account account) {
         //1. account对象持久化代码
         //省略持久化代码.......

        // 2. 发布领域事件
        account.domainEvents().forEach(event -> {
            applicationContext.publishEvent(event);
            log.info("发布领域事件:{}", event);
        });
        //3. 清除已经发布的事件
        account.clearDomainEvents();
    }
问题三:有时只改了一个属性,save方法却要把整个对象全部保存,对性能有比较大的影响

在复杂业务中,一个对象都是特别的大,变更一个属性就要保存整个对象,对性能影响的确很大,我是否能在调用save方法时只保存更新过的字段呢?
要解决这个问题我们要用到一种比较复杂的技术:Change-Tracking(变更追踪),变更跟踪有两种实现方法:
1、基于Snapshot的方案:当数据从DB里取出来后,在内存中保存一份snapshot,然后在数据写入时和snapshot比较。
2、基于Proxy的方案:当数据从DB里取出来后,通过weaving的方式将所有能改变状态的方法都增加一个切面来判断是否被调用以及值是否变更,如果变更则标记为Dirty。在保存时根据Dirty判断是否需要更新。常见的实现如Entity Framework。

这种方法我并没有使用过,所以就不贴代码了,有使用过的同学可以给我提供一下。

实现二:带Repository的充血模型实现

带Repository的充血模型的使用方法如下:

@Transactional(rollbackFor = Exception.class)
public void accountEnable(Long accountId) {
    //查找帐号
    Account account = accountRepository.find(accountId);
    //激活帐号,注意因为Account带了repository,调用enable方法时已经完成了持久化
    account.enable();
}

Account的enable方法实现如下:

/**
 * 启用账号
 */
public void enable() {
    // 前置检验
    if (this.status != AccountStatus.DISABLE) {
        throw new AccountBizException(AccountErrorCode.ACCOUNT_STATUS_ERROR, "账号非关闭状态,无法启用,账号id:" + id);
    }
    //调用repository做状态持久化
    this.accountRepository.updateStatus(this.getId(), this.status);
    // 更新数据库成功后,变更内存中对象的状态,保持内存中对象为最新状态
    this.status = AccountStatus.ENABLE;
}

在上面的实现中,同样不完美,我们有如下问题需要解决:

问题一:repository中有更新状态的万能方法,调用这个方法将会绕过所有加在对象上的约束

当实现启用帐号方法时,我们通过repository中的updateStatus方法,做到了只更新一个字段,而不是调用save方法更新整个对象,这避免了上一个实现中最复杂的问题三,但repository中却要增加一个万能方法,对此没有好的办法,我们在实践过程中,是通过一个规约来约束大家的,规约如下:

所有对领域对象的写操作,必须通过领域模型,不能直接调用repository的方法
问题二:对象中持有repository,导致模型与repository耦合,不美观

这个问题也没有办法解,对于有代码洁癖的同学来说,只能放弃这种实现了。
但我们要回过头来看看,领域模型与repository的耦合是问题吗? 领域模型与repository都是在领域层的,同一层意味着相互之间是可以引用的,他们本来就是耦合在一起的。同时领域层的repository只定义了接口,而实现是在基础设施层,因此领域层的repository并没有关于持久化的实现,因此就算领域模型中持有repository的接口,也不会也持久化的相关技术耦合。
因此模型与repository的耦合其实是不违反面向对象的SOLID的设计原则,也不违反我们的分层原则,为什么大家会认为它不美观呢?如果你的Repository中有数据库实现的细节,那有可能是你的设计违反了DIP(依赖倒置)的分层原则。

总结

没有完美的方案,只有适合业务场景的方案,所有的架构都是做取舍,并不一定要在项目中使用充血模型才是最好的,也不一定要使用哪种充血模型的方案,明白每一个方案的优点和缺点,选择合适的方案才是架构真正要做的事。
我们在项目的实践过程中,这两种充血模型都有用过,不带Repository的充血模型用在我们的劳动力主数据管理中,带Repository的充血模型用在我们排班系统中。整体来说这两种方案,第一种实现难度会比第二种大一些,如果是第一次使用充血模型,建议用第二种方案。

上一篇:EntityFramework.BulkInsert扩展插入数据和EF本身插入数据比较


下一篇:数据存储之偏好设置NSUserDefaults