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

简介: ### 前言关于贫血模型与充血模型,已经有大量的文章写到了,但大部分都只是写了两种模型的对比,其中的例子也相对比较简单。当我们真正在使用充血模型的过程中,还会碰到很多问题,本文期望通过我们复杂的业务场景,深入的讲解我们在真正使用充血模型时,到底会碰到哪些问题,我们又要如何来解决。### 什么是充血模型Martin Fowler在2003年发表的一篇文章中,第一次提出贫血模型的概念,他把贫

前言

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

什么是充血模型

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的充血模型用在我们排班系统中。整体来说这两种方案,第一种实现难度会比第二种大一些,如果是第一次使用充血模型,建议用第二种方案。

相关文章
|
存储 前端开发 测试技术
DDD - 六边形架构和CQRS架构
DDD - 六边形架构和CQRS架构
2202 0
|
前端开发 架构师 搜索推荐
COLA 4.0:直击应用架构本质的最佳实践
COLA 4.0:直击应用架构本质的最佳实践
4456 1
COLA 4.0:直击应用架构本质的最佳实践
|
Java Apache
阿里Java开发手册一方库/二方库/三方库等概念详解
阿里Java开发手册一方库/二方库/三方库等概念详解
3622 0
|
7月前
|
人工智能 安全 JavaScript
零代码部署OpenClaw智能体Win11详细教程(包含新版安装包)
OpenClaw(小龙虾)是GitHub星标28W+的开源本地AI智能体,专为Windows 11优化,支持一键解压即用。无需代码、免配环境,可自动操控电脑、整理文件、浏览器自动化,数据全程本地运行,隐私安全。
|
运维 前端开发 JavaScript
前端埋点校验工具:数据驱动的最后一道防线
数据埋点是企业决策的关键基础,但常面临覆盖率低、数据不准和故障难排查三大难题。本文深入剖析了这些问题的成因与影响,并提出“三维校验矩阵”解决方案:提升覆盖率至99.8%、降低错误率至0.3%、提速故障定位5倍。同时对比Split.io、Tealium、Sentry、板栗看板等工具优劣,为企业选型提供参考。迈向高质量数据治理,从精准埋点开始。
|
存储 消息中间件 JSON
DDD基础教程:一文带你读懂DDD分层架构
DDD基础教程:一文带你读懂DDD分层架构
|
存储 监控 调度
云服务器成本优化深度解析与实战案例
本文深入探讨了云服务器成本优化的策略与实践,涵盖基本原则、具体策略及案例分析。基本原则包括以实际需求为导向、动态调整资源、成本控制为核心。具体策略涉及选择合适计费模式、优化资源配置、存储与网络配置、实施资源监控与审计、应用性能优化、利用优惠政策及考虑多云策略。文章还通过电商、制造企业和初创团队的实际案例,展示了云服务器成本优化的有效性,最后展望了未来的发展趋势,包括智能化优化、多云管理和绿色节能。
|
Shell Windows
Mac使用IntelliJ IDEA报错“Unable to save settings: Failed to save settings. Please restart IntelliJ IDEA”
Mac使用IntelliJ IDEA报错“Unable to save settings: Failed to save settings. Please restart IntelliJ IDEA”
Mac使用IntelliJ IDEA报错“Unable to save settings: Failed to save settings. Please restart IntelliJ IDEA”
|
资源调度 关系型数据库 MySQL
【Flink on YARN + CDC 3.0】神操作!看完这篇教程,你也能成为数据流处理高手!从零开始,一步步教会你在Flink on YARN模式下如何配置Debezium CDC 3.0,让你的数据库变更数据瞬间飞起来!
【8月更文挑战第15天】随着Apache Flink的普及,企业广泛采用Flink on YARN部署流处理应用,高效利用集群资源。变更数据捕获(CDC)工具在现代数据栈中至关重要,能实时捕捉数据库变化并转发给下游系统处理。本文以Flink on YARN为例,介绍如何在Debezium CDC 3.0中配置MySQL连接器,实现数据流处理。首先确保YARN上已部署Flink集群,接着安装Debezium MySQL连接器并配置Kafka Connect。最后,创建Flink任务消费变更事件并提交任务到Flink集群。通过这些步骤,可以构建出从数据库变更到实时处理的无缝数据管道。
1435 2
|
存储 消息中间件 设计模式
缓存数据一致性策略如何分类?
数据库与缓存数据一致性问题的解决方案主要分为强一致性和最终一致性。强一致性通过分布式锁或分布式事务确保每次写入后数据立即一致,适合高要求场景,但性能开销大。最终一致性允许短暂延迟,常用方案包括Cache-Aside(先更新DB再删缓存)、Read/Write-Through(读写穿透)和Write-Behind(异步写入)。延时双删策略通过两次删除缓存确保数据最终一致,适用于复杂业务场景。选择方案需根据系统复杂度和一致性要求权衡。
809 0