Skip to content

贫血模型与充血模型

大部分的业务系统其实都是基于MVC三层架构来进行开发的,更确切地讲,这是一种基于贫血模型的MVC三层架构开发模式

基于贫血模型的MVC三层架构开发模式

要讲述基于贫血模型的MVC三层架构开发模式,就得先统一一下MVC的概念

MVC 三层架构中的 M 表示 Model,V 表示 View,C 表示 Controller。它将整个项目分为三层:展示层、逻辑层、数据层。MVC 三层开发架构是一个比较笼统的分层方式,落实到具体的开发层面,很多项目也并不会 100% 遵从 MVC 固定的分层方式,而是会根据具体的项目需求,做适当的调整。

以最经典的前后端分离的后端项目分层来说,这种情况下,我们一般就将后端项目分为 Repository 层、Service 层、Controller 层。其中,Repository 层负责数据访问,Service 层负责业务逻辑,Controller 层负责暴露接口。

上述是对MVC架构的描述,那么什么是贫血模型?

贫血模型(Anemic Domain Model)

贫血模型指的是领域对象中只有数据,没有行为,由于过于单薄,就好像人贫血了一样,显得不太健康,这种风格违背了面向对象的原则,所以一般也被认为是一种反模式,但事实上因为一些使用惯性,目前绝大部分的java程序员都在用着贫血模型来做对应的业务系统

java
	----- entity ------
public class UserEntity {
	 // 省略其他属性、get/set/construct方法
	 private Long id;
	 private String name;
	 private String cellphone;
 }
 
 ------ service ------
  public class UserService {
        private UserRepository userRepository; //通过构造函数或者IOC框架注入

        public UserEntity getUserById(Long userId) {
            UserEntity userEntity = userRepository.getUserById(userId);
            业务逻辑
            return xxx;
        }
    }
    
    
  ------ controller -----
  public class UserController {
        private UserService userService; //通过构造函数或者IOC框架注入

        public UserEntity getUserById(Long userId) {
            UserEntity userEntity = userService.getUserById(userId);
            业务逻辑
        }
    }

以上述代码为例子,我们可以发现在传统MVC框架写法中,UserEntity是一个纯粹的数据结构,只包含数据,不包含任何业务逻辑,业务逻辑集中在 UserService 中,而Controller负责暴露接口,这就是非常典型的贫血模型的代码结构,将数据与业务逻辑分离,违反了 OOP 的封装特性,实际上是一种面向过程的编程风格

为什么基于贫血模型的传统开发模式如此受欢迎?

上文中提到贫血模型违反了OOP的封装特性,本质上是一种面向过程的编程风格,那为什么当前绝大部分的业务系统还是使用贫血模型来做业务开发?

  • 大部分情况下,我们开发的系统业务可能都比较简单,简单到就是基于 SQL 的 CRUD 操作,所以,我们根本不需要动脑子精心设计充血模型,贫血模型就足以应付这种简单业务的开发工作。除此之外,因为业务比较简单,即便我们使用充血模型,那模型本身包含的业务逻辑也并不会很多,设计出来的领域模型也会比较单薄,跟贫血模型差不多,没有太大意义(简单的系统中贫血模型与充血模型设计出来其实大差不差
  • 充血模型的设计要比贫血模型更加有难度。因为充血模型是一种面向对象的编程风格。我们从一开始就要设计好针对数据要暴露哪些操作,定义哪些业务逻辑。而不是像贫血模型那样,我们只需要定义数据,之后有什么功能开发需求,我们就在 Service 层定义什么操作,不需要事先做太多设计。
  • 思维已固化,转型有成本。基于贫血模型的传统开发模式经历了这么多年,已经深得人心、习以为常,随便问一个旁边的大龄同事,基本上他过往参与的所有 Web 项目应该都是基于这个开发模式的,而且也没有出过啥大问题。如果转向用充血模型、领域驱动设计,那势必有一定的学习成本、转型成本,很多人在没有遇到开发痛点的情况下,是不愿意做这件事情的。

充血模型

既然基于贫血模的开发模式已经是一种约定俗成的开发习惯,那么什么样的项目可以考虑用充血模型呢?

事实上刚刚已经提到了,基于贫血模型的传统开发模式,比较适合业务比较简单的系统开发,相应的,基于充血模型的开发模式,往往更适合业务复杂的系统开发

为什么充血模型更适合业务复杂的系统开发?

摘取一个网上的说法


不夸张地讲,我们平时的开发,大部分都是 SQL 驱动(SQL-Driven)的开发模式。我们接到一个后端接口的开发需求的时候,就去看接口需要的数据对应到数据库中,需要哪张表或者哪几张表,然后思考如何编写 SQL 语句来获取数据。之后就是定义 Entity、BO、VO,然后模板式地往对应的 Repository、Service、Controller 类中添加代码。

业务逻辑包裹在一个大的 SQL 语句中,而 Service 层可以做的事情很少。SQL 都是针对特定的业务功能编写的,复用性差。当我要开发另一个业务功能的时候,只能重新写个满足新需求的 SQL 语句,这就可能导致各种长得差不多、区别很小的 SQL 语句满天飞。

所以,在这个过程中,很少有人会应用领域模型、OOP 的概念,也很少有代码复用意识。对于简单业务系统来说,这种开发方式问题不大。但对于复杂业务系统的开发来说,这样的开发方式会让代码越来越混乱,最终导致无法维护。

如果我们在项目中,应用基于充血模型的 DDD 的开发模式,那对应的开发流程就完全不一样了。在这种开发模式下,我们需要事先理清楚所有的业务,定义领域模型所包含的属性和方法。领域模型相当于可复用的业务中间层。新功能需求的开发,都基于之前定义好的这些领域模型来完成。

我们知道,越复杂的系统,对代码的复用性、易维护性要求就越高,我们就越应该花更多的时间和精力在前期设计上。而基于充血模型的 DDD 开发模式,正好需要我们前期做大量的业务调研、领域模型设计,所以它更加适合这种复杂系统的开发。


java
---- 贫血模型 ----
String sql = "SELECT * FROM BOOKS WHERE BOOK_ID = :bookId";
Book book = DB.query(sql);
if (book.getBorrowerId() != null) { }
---- 充血模型 ----
String sql = "SELECT * FROM BOOKS WHERE BOOK_ID = :bookId";
Book book = DB.query(sql);
if (book.isBorrowed()) { }

本质上充血模型就是把模型当做数据和行为的载体,把行为封装在了领域模型内部

数据映射器和仓库

在上面的代码中,并没有添加任何数据访问相关的逻辑,这也是领域模型模式的一个难点。领域模型中的字段需要与数据库中的表字段进行双向映射,通常来说,我们可以继续使用之前的 Dao 来实现这种映射,例如当一次书本借阅发生时

java
public class Borrowing {
  private User user;
  private Book book;
}

public class User {
  private List<Borrowing> borrowings;
  public void borrow(Book[] books) {
    for(Book book : books)
      borrowings.add(new Borrowing(this, book));
  }
}
public class BorrowingDao {
  public void insert(Borrowing borrowing) {
    String sql = "INSERT INTO BORROWINGS...";
    // 执行SQL
  }
}

和以前一样,可以利用BorrowingDao来像以往一样执行sql,完成数据相关的访问

我们把这种方式叫做数据映射器(Data Mapper)模式,它分离了领域模型和数据库访问代码的细节,也封装了数据映射的细节

然而不管是叫 BorrowingDao 还是 BorrowingMapper,都暗示了它们与数据库的关系。在领域模型中,我们往往希望模型更加“干净”,希望使用的是一种和数据访问无关的组件。另一方面,这种模式也导致表和领域对象的一一对应。在简单的业务场景下这并不是问题,但在复杂的情况下,你就无法设计出合理的模型。

比如上面的例子,一个借阅就是一个 Borrowing,这时你很可能放弃给 Book 建模,而直接去构建 Borrowing 模型,这就又回到事务脚本的老路上去了。还有一点就是,当查询的需求变得复杂时,数据映射器就显得力不从心了。

这时我们需要使用的是仓储(Repository)模式,让它来负责协调领域模型和数据映射器

仓库的接口与集合的接口十分接近,你可以向仓库中添加对象,也可以从中删除对象,就好像是在操作内存中的集合一样。而实际上,真正执行操作的,是封装在仓库内部的数据映射器。仓库不过是提供了一个更加面向对象的方式,将领域对象和数据访问隔离开来

在使用仓库模式时,我们只从领域对象的源头操作。我们不会去对 Borrowing 创建一个 BorrowingRepository,而是将 Borrowing 放到 User 内部,然后通过 UserRepository 去获取 User,进而获取到当前 User 所有的 Borrowing。

这么做的原因是,Borrowing 只是一个关联对象,并不是一个所谓的“源头”。如果用领域驱动设计中的术语来说就是,Borrowing 不是一个聚合根(Aggregate Root)

我们也可以将这个“源头”理解为工厂模式创建出来的产品。你要去仓库中取的是一个产品(聚合根),而不是这个产品的某个零件(关联对象)。这也是为什么在 DDD 中,仓库只是针对聚合根的,只有聚合根才有仓库,聚合根上的其他实体或值对象是没有仓库的。

java
public class UserRepository {
  public void add(User user) { }
  public void save(User user) { }
  public void update(User user) { }
  public User findById(long userId) { }
}

public class BorrowService {
  public void borrow(long userId, long[] bookIds) {
    User user = userRepository.findById(userId);
    Book[] books = bookRepository.findByIds(bookIds);
    user.borrow(books);
    userRepository.update(user);
  }
}

最后更新于: