一、贫血模型 MVC 是否违背 OOP?
MVC 已经成为标准的 Web 项目开发模式,但却违反了面向对象编程风格,是一种彻彻底底的面向过程的编程风格,更确切的点讲,是一种基于贫血模型的开发模式。
1. 基于贫血模型的传统开发模式
MVC 三层架构中的 M 表示 Model, V 表示 View, C 表示 Controller。将项目拆分为展示层、逻辑层、数据层。
////////// Controller+VO(View Object) //////////
public class UserController {
private UserService userService; //通过构造函数或者IOC框架注入
public UserVo getUserById(Long userId) {
UserBo userBo = userService.getUserById(userId);
UserVo userVo = [...convert userBo to userVo...];
return userVo;
}
}
public class UserVo {//省略其他属性、get/set/construct方法
private Long id;
private String name;
private String cellphone;
}
////////// Service+BO(Business Object) //////////
public class UserService {
private UserRepository userRepository; //通过构造函数或者IOC框架注入
public UserBo getUserById(Long userId) {
UserEntity userEntity = userRepository.getUserById(userId);
UserBo userBo = [...convert userEntity to userBo...];
return userBo;
}
}
public class UserBo {//省略其他属性、get/set/construct方法
private Long id;
private String name;
private String cellphone;
}
////////// Repository+Entity //////////
public class UserRepository {
public UserEntity getUserById(Long userId) { //... }
}
public class UserEntity {//省略其他属性、get/set/construct方法
private Long id;
private String name;
private String cellphone;
}
UserBo 是一个纯粹的数据结构,只包含数据,不包含任何业务逻辑。业务逻辑集中在 UserService 中,通过 User Service 来操作 UserBo。Service 层的数据和业务逻辑,被分割为 BO 和 Service 两个类中。类似 UserBo 这种,只包含数据不包含业务逻辑的类,就叫做贫血模型,破坏了面向对象的封装特性。
2. 基于充血模型的 DDD 开发模式
充血模型:数据和对应的业务逻辑被封装到同一个类中。满足面向对象的封装特性,典型的面向对象编程风格。
领域驱动设计(Domain Driven Design, DDD)主要用来指导如何解耦业务系统,划分业务模块,定于业务领域模型及其交互。
实际上,基于充血模型的 DDD 开发模式实现的代码,也是按照 MVC 三层架构分层的,与基于贫血模型的传统开发模式的区别主要在于 Service 层。在 DDD 的开发模式中,Service 层包含 Service 类和 Domain 类两部分。Domain 相当于贫血模型中的BO,不过它既包含数据,也包含业务逻辑,Service 类变的非常单薄。
总结一下:基于贫血模型的传统开发模式,重 Service 轻 BO;基于充血模型的 DDD 开发模式,轻 Service 重 Domain。
领域驱动设计有点类似敏捷开发、SOA、PAAS 等概念,听起来很高大上,但实际上只值“五分钱”。即使对这个概念一无所知,只要在开发业务系统,也或多或少都在使用它。做好领域驱动设计的关键是,取决于你对业务的熟悉程度,而不是对领域驱动设计这个概念本身的掌握程度。即便对领域驱动搞的再清楚,但对业务不熟悉,也并不一定能做出合理的领域设计。不要把领域驱动设计当银弹,不要花太多的时间去过度的研究它。
3. 为什么基于贫血模型的开发模式如此受欢迎?
- 开发的系统业务比较简单,简单到就是基于 SQL 的 CRUD 操作;
- 充血模型的设计要比贫血模型更加有难度;
- 固化思维,转型又成本。
4. 什么项目考虑使用基于充血模型的 DDD 开发模式?
基于贫血模型的传统开发模式,比较适合业务比较简单的系统开发;基于充血模型的 DDD 开发模式,更合适业务复杂的系统开发。
平时的开发,大部分就是 SQL 驱动(SQL- Driven)的开发模式。开发一个后端接口,就去看接口需要的数据对应到数据库中,需要哪张表或者哪几张表,然后思考如何编写 SQL 语句来获取数据。自由就是定义 Entity, BO, VO, 然后模板式地往对应的 Repository, Service, Controller 类中添加代码。
业务逻辑包裹在大的 SQL 语句中,Serivce 层可以做的事情很少。SQL 是针对特定的业务功能编写的,复用性差。开发另一个业务功能的时候,只能重写新需求的 SQL 语句,导致各种长的长不多、区别很小的 SQL 语句满天飞。
基于充血模型的 DDD 开发模式,对应的开发流程完全不一样。在这种开发模式下,需要先理清楚所有的业务,定义领域模型所包含的属性和方法。领域模型相当于可复用的业务中间层。
5. 辩证思考与灵活运用
基于充血模型的 DDD 开发模式中,业务逻辑移动到 Domain 中,Service 类变得很薄,但并没有完全将 Service 类去掉,这是为什么?或者说 Service 类在这种情况下担当的职责是什么?哪些功能逻辑会放到 Service 类中?
区别于 Domain 的职责,Service 类主要有下面这样几个职责。
- Service 类负责与 Repository 交流。
- Service 类负责垮领域模型的业务聚合功能。
- Service 类负责一些非功能性及与三房系统交互的工作。比如幂等、事务、日志、调用 RPC 接口等。
基于充血模型的 DDD 开发模式中,尽管 Service 层被改造成了充血模型,但是 Controller 层和 Repository 层还是贫血模型,是否有必要也进行充血领域建模呢?
没有必要,Controller 层主要负责接口的暴露,Repository 层主要负责与数据库打交道,这两层包含的业务逻辑并不多,如果业务逻辑比较简单,就没必要做充血模型。
- Entity 的生命周期是有限的,从 Repository 传递到 Service 层之后,就会转化成 BO 或者 Domain 来继续后面的业务逻辑。Entity 的生命周期就到此结束了,所以也不会被到处任意修改。
- Controller 层的 VO,实际上就是一种 DTO(Data Transfer Object,数据传输对象)。主要是作为接口的数据传输载体,将数据发送给其他系统。从功能上来说,不包含业务逻辑、只包含数据。
设计成充血模型的原因是 Service 逻辑拆分并且转移一部分逻辑,略微提高代码的可读性,此外,模型充血以后,基于模型的业务抽象在不断地迭代之后会越来越明确,业务的细节会越来越精准,通过阅读模型的充血行为代码,能够极快地了解系统的业务,对于开发来说能明显地提升开发效率。贫血模型的 Service 代码,无论代码如何清晰,注释如何完备,代码结构设计得如何优雅,都没有办法第一时间理解系统的核心业务逻辑。充血模型可以说是业务的精准抽象。
二、面向对象方式做需求
1. 如何进行面向对象设计?
面向对象分析的产出是详细的需求描述,那面向对象设计的产出就是类。这一设计环节包含以下几个部分:
1.1 划分职责进而识别出有哪些类
类是现实世界中事物的一个建模。但并不是每个需求都能映射到现实世界,也不是每个类都能与现实世界中的事物一一对应。
建议的做法:根据需求描述,把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,是否应该归为同一个类。
1.2 定义类及其属性和方法
识别出需求描述中的动词,作为候选的方法。把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选。
1.3 定义类与类之间的交互关系
UML 统一建模语言中定义了六中类之间的关系。分别是:泛化、实现、关联、聚合、组合、依赖。
- 泛化(Generalization)可以简单理解为继承关系。
- 实现(Realization)一般是指接口和实现类之间的关系。
- 聚合(Aggregation)是一种包含关系,A 类对象包含 B 类对象,B 类对象的生命周期可以不依赖 A 类对象的生命周期,单独销毁 A 类对象而不影响 B 对类。如下代码所示:
type struct B {
}
type struct A {
fieldB B
}
func NewA(b B) A {
return A{filedB: b}
}
- 组合(Composition)也是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期依赖 A 类对象的生命周期,B 类对象不可单独存在。如下代码所示
type struct b {
}
type struct A {
fieldB b
}
func NewA() A {
filedB := b{}
return A{filedB: fieldB}
}
- 关联(Association)是一种非常弱的关系,包含聚合、组合两种关系。如果 B 类对象是 A 类的成员变量,那 B 类和 A 类就是关联关系。
- 依赖(Dependency)是一种比关联关系更加弱的关系,包含关联关系。只要 B 类对象和 A 类对象有任何使用关系(B 类是 A 类的成员变量、入参、出参、局部变量等),都称它们有依赖关系。
UML 6种类关系的拆分有点太细,增加了学习成本,对于指导编程开发没有什么太大意义。从更加贴近编程的角度,对类与类之间的关系做了调整,只保留了四个关系:泛化、实现、组合、依赖,组合关系替代 UML 中组合、聚合、关联三个概念,只要 B 类对象是 A 类对象的成员变量,就称,A 类跟 B 类是组合关系。
1.4 将类组装起来并提供执行入口
定义好类,类之间的交互关系也设计好了,接下来要将所有的类组装在一起,提供一个执行入口。可以是一个 main 函数,也可能是一组给外部使用的 API 接口。通过这个入口,能触发整个代码跑起来。
很多时候,都是在脑子里或者草稿纸上完成面向对象分析和设计,然后就开始写代码了,边写边思考边重构,并不会严格地按照刚刚的流程来执行。即便在写代码之前,花很多时间做分析和设计,绘制出完美的类图、UML 图,也不可能把每个细节、交互都想得清楚。在落实到代码的时候,还是要反复迭代、重构、打破重写。
但也要尽可能的理清思路,对于后续编码才有遍布!不能因为后续肯定要修改,放弃前期的分析与设计,这样后期可能会形成破窗效应,代码惨不忍睹。重视前期的分析与设计,但不过量的分析与设计,最起码大体的思路一定要清晰。总体的流程、技术路线要清晰,细节确实可以反复重构。