为什么贫血模型是有害的

贫血模型是软件开发中的一种常见方法,许多人甚至不知道他们正在使用它,包括我自己。

1.什么是贫血模型?

封装是面向对象编程的三个特性之一。封装从字面上来理解就是包装的意思。在程序设计中,封装是指利用抽象数据类型将数据和数据相关的操作封装在一起,使其构成一个不可分割的独立整体,数据被保护在抽象数据类型的内部,只保留一些对外接口使之与外部发生联系。

贫血模型则是将数据和与对应操作分离的模型。分离之后的模型由两个独立的类组成:一个是持有数据实体,另一个是操作实体的无状态服务。

实体通常用包含属性、setters 和 getters。典型示例如下所示:

public final class Time {
    public final int hour;
    public int minute;
    
    public int getHour() {
        return hour;
    }
    public void setHour(int hour) {
        this.hour = hour;
    }

    public int getMinute() {
        return minute;
    }
    public void setMinute(int minute) {
        this.minute = minute;
    }
}
复制代码

无状态服务,顾名思义,用无状态类表示,类会成员变量,这些成员变量不是其状态,而是其所依赖的其他服务或者基础设施,比如数据库 DAO(mybatis mapper)。服务封装了本来应该在实体模型中的业务逻辑。无状态服务示例如下:

public class TimeService {
    public Time getServerTime() {
        //省略业务逻辑
        ...
    }
}
复制代码

2.为什么贫血模型有害?

我们可以说贫血模型不符合面向对象的设计。在 OOD 方法中将逻辑分离到其他类中是不正确的。但这并不是贫血域模型有害的主要原因。拥有纯粹的 OOD 本身并不是目标。我们需要考虑更根本的原因。其中有三个:

2.1 首先是可发现性

如果数据与数据相关的操作封装在一个类中,我们在IDE 中找到类,就可以查看相关的业务逻辑了。但是在贫血模型中,我们找到类,只能看到其属性,我们还得知道对应的业务逻辑在哪个服务中。当服务类众多,服务方法也很多的时候,这不一定是件容易的事情,特别是对于接手时间还不长的开发者来说。

2.2 重复

如果开发者不容找到一个相关的逻辑,这就容易导致他去重写一个类似的逻辑。这就是出现了重复。这就违反了DRY(Don't repeat yourself)。

2.3 缺乏封装

缺乏封装是最糟糕的。通过应用代码规范可以比较容易避免前两点。但是,这第三点总是会对你的项目产生有害影响。正是缺乏封装让大多数使用贫血模型的项目代码越来越腐坏。

3.什么是封装?

在本文开头就给出封装的定义。从定义中我们看到两层意思:

  • 封装是信息隐藏:信息隐藏就是将某些类成员设为私有,让其对客户端代码不能直接读写。
  • 封装意味着将数据和操作捆绑在一起。

这其实都是封装的手段,而不是封装的目的。

3.1 封装是一种保护数据一致性约束(invariant)的行为

保护数据一致性约束是指防止调用方将对象的内部数据设置为无效或不一致的状态。通过信息隐藏,以及将数据和操作捆绑在一起实现,我们可以保证对象的一致性约束。 比如前面的 Time 类。我们知道一天是24小时,那么 hour 属性的有效取值是 [0,24]。而 minute 的有效值则是 [0, 60]。这就是时间类的一致性约束。通过将这也的约束封装在如下的 Time 类中,我们就可以放心的去使用 Time,因为一旦输入不合法的 hour 或者 minute,就会抛出异常,不至于让错误的时间对象,继续在其他的业务逻辑中横行,最终被持久化到数据库中。

public final class Time {
    private static final int HOURS_PER_DAY = 24;
    private static final int MINUTES_PER_HOUR = 60;
    public final int hour;
    public final int minute;
    public Time(int hour, int minute) {
        if (hour < 0 || hour >= HOURS_PER_DAY)
            throw new IllegalArgumentException("Hour: " + hour);
        if (minute < 0 || minute >= MINUTES_PER_HOUR)
            throw new IllegalArgumentException("Min: " + minute);
        this.hour = hour;
        this.minute = minute;
    }
... // Remainder omitted
}
复制代码

如果没有这样的封装,每次我们在使用 Time 的时候,都得小心翼翼的,以免设置了非法的值,这对我们的大脑造成了额外的负担。当然这个 Time 的一致性是如此的简单,我们可能并不需要付出多少精力。但是业务上有很多复杂的一致性。比如很多类有开始时间和结束时间,这个结束时间一定要晚于开始时间,这个要比 Time 稍微复杂点(相信大家没少写出过结束时间早过开始时间的 bug )。

更复杂的例子,比如订单的状态,订单状态变化需要严格的一致性约束,订单从一个状态转变为另一个状态,一定是由于发生了某个事件,是一个严格的状态机逻辑。当然你可以说,服务类中也可以实现这些一致性约束。没错,这是可以的,而且很多人肯定是这么做的。只是大多数工程中的服务类都承担了较多职责。不仅承担了业务逻辑,还承担了将业务逻辑、其他领域服务和基础设施服务(数据库、消息队列、缓存等)编排到一起,服务一个客户端请求的处理。也就是说业务逻辑和编排逻辑(通常说的胶水)混到一起,违反了SRP(单一职责)原则。

封装保证了类的一致性约束,同时减轻了我们大脑的精力负载,对任何人来说,项目代码中的众多的复杂的约束不可能靠我们的记忆力和小心翼翼来保证。

3.2 封装带来了测试便利性

实体领域模型是内聚的,不像服务类由众多的依赖。这就让单元测试变得更加容易。

4.为什么贫血模型会变得流行?

贫血模型实际上是一种过程化思维,对于开发人员来说是更加直观的。甚至有些专业的组织机构也曾鼓励过这样的模型,比如著名的J2EE Entity Bean。

MVC 等分层模型建议将领域模型从数据持久化存储和显示层逻辑分开。这可能导致了越来越多的人将业务逻辑在服务实现,这并将很直观,也很方便,

当然有些场景下,贫血模型有时这可能是有益的。

image.png

该图表达以工作时间衡量的开发进度。如图所示,贫血领域模型项目开始的时候速度很快,但是会导致未来维护成本增加。如果你知道项目规模不大或项目不会有频繁的更新,那么贫血领域模型是很好的选择,也可能是最佳选择。

可能正是由于这样那样的原因才会导致贫血模型会变得流行吧。我们应该记住,对于比较复杂的项目,并且会持续迭代更新开放的项目,我们一定要摒弃贫血模型,拥抱领域建模。

参考:

猜你喜欢

转载自juejin.im/post/7087425802219814948