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

简介: ### 前言关于贫血模型与充血模型,已经有大量的文章写到了,但大部分都只是写了两种模型的对比,其中的例子也相对比较简单。当我们真正在使用充血模型的过程中,还会碰到很多问题,本文期望通过我们复杂的业务场景,深入的讲解我们在真正使用充血模型时,到底会碰到哪些问题,我们又要如何来解决。### 什么是充血模型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的充血模型用在我们排班系统中。整体来说这两种方案,第一种实现难度会比第二种大一些,如果是第一次使用充血模型,建议用第二种方案。

相关文章
|
8月前
|
SQL 设计模式 数据库
领域模型:贫血模型与充血模型的深度解析
领域模型:贫血模型与充血模型的深度解析
|
8天前
|
机器学习/深度学习 自然语言处理 并行计算
【大模型】解释自我注意力的概念及其在LLM表现中的作用
【5月更文挑战第6天】【大模型】解释自我注意力的概念及其在LLM表现中的作用
|
2月前
大模型开发:描述一个你遇到过的具有挑战性的数据集问题以及你是如何解决它的。
在大模型开发中,面对不平衡数据集(某些类别样本远超其他类别)的问题,可能导致模型偏向多数类。在二分类问题中,正样本远少于负样本,影响模型学习和性能。为解决此问题,采用了数据重采样(过采样、欠采样)、SMOTE技术合成新样本、使用加权交叉熵损失函数、集成学习(Bagging、Boosting)以及模型调整(复杂度控制、早停法、正则化)。这些策略有效提升了模型性能,尤其是对少数类的预测,强调了针对数据集问题灵活运用多种方法的重要性。
9 0
|
2月前
|
机器学习/深度学习 算法
大模型开发:解释反向传播算法是如何工作的。
反向传播算法是训练神经网络的常用方法,尤其适用于多层前馈网络。它包括前向传播、计算损失、反向传播和迭代过程。首先,输入数据通过网络层层传递至输出层,计算预测值。接着,比较实际输出与期望值,计算损失。然后,从输出层开始,利用链式法则反向计算误差和权重的梯度。通过梯度下降等优化算法更新权重和偏置,以降低损失。此过程反复进行,直到损失收敛或达到预设训练轮数,优化模型性能,实现对新数据的良好泛化。
|
6月前
|
机器学习/深度学习 人工智能 数据可视化
【网安AIGC专题10.19】论文4:大模型(CODEX 、CodeGen 、INCODER )+自动生成代码评估:改进自动化测试方法、创建测试输入生成器、探索新的评估数据集扩充方法
【网安AIGC专题10.19】论文4:大模型(CODEX 、CodeGen 、INCODER )+自动生成代码评估:改进自动化测试方法、创建测试输入生成器、探索新的评估数据集扩充方法
305 1
|
10月前
|
机器学习/深度学习 PyTorch 测试技术
使用PyTorch构建神经网络(详细步骤讲解+注释版) 01-建立分类器类
神经网络中,一个非常经典的案例就是手写数据的识别,本文我们以手写数据识别为例进行讲解。用到的数据是MNIST数据集。MNIST数据集是一个常用的用于计算机视觉的测试数据集,包含了70,000张手写数字的图片,用于训练和测试模型识别手写数字的能力。MNIST数据集中的图片大小都是28x28像素,图片中的数字是黑白的,每张图片都有对应的标签,表示图片中的数字是什么。MNIST数据集是计算机视觉领域的“Hello World”级别的数据集,被广泛用于计算机视觉模型的训练和测试。
|
机器学习/深度学习 并行计算 算法
像Transformer一样思考!DeepMind发布全新模型设计工具Tracr:从可解释逻辑反向搭建模型
像Transformer一样思考!DeepMind发布全新模型设计工具Tracr:从可解释逻辑反向搭建模型
160 0
|
机器学习/深度学习 算法 BI
使用手工特征提升模型性能
本文将使用信用违约数据集介绍手工特征的概念和创建过程。
75 0
使用手工特征提升模型性能
|
存储 编译器 数据库
9.1充血模型和贫血模型
贫血模型:一个类中只有属性或者成员变量 充血模型:一个类中除了属性和成员变量,还有方法
|
机器学习/深度学习 自然语言处理 TensorFlow
用LSTM的模型实现自动编写古诗
用LSTM的模型实现自动编写古诗
177 0
用LSTM的模型实现自动编写古诗