关于面向对象,我们偶尔会有困惑
问题1:我明明用的是面向对象的语言,但总感觉自己在面向过程地写业务呢?
当我们一开始学习Java,或者其他以面向对向编程(OOP)为核心理念的语言工具时,肯定都听过类似的一个例子:
当我们去对比 “面向对象” 和 “面向过程” 两种编程方式时,可以用 “汽车跑” 这个例子来概括:面向过程,要检查引擎、发动车子、轮子转动,等一系列操作;而面向对象只需要 car.drive()
但是,为什么我们现在如果写一个类似的业务,反而是这样实现的呢?它怎么看,怎么还挺【面向过程】的:
public class CarController() {
private final CarManager carManager;
public RestfulResult makeCarDrive(Integer carId) {
Car car = carManager.getCarById(carId);
assert car != null;
carManager.makeCarDrive(car);
//...略
}
}
public class CarManager() {
private final CarDAO carDAO;
public boolean makeCarDrive(Car car) {
CarDO carDO = carDAO.getById(car.getId);
carDO.setStatus("READY_TO_GO");
//...略
carDO.setStatus("DRIVING");
carDAO.update(carDO);
return true;
}
}
所以,当年那个
car.drive()
去哪了?如果有,可能它长这样:
public class CarController() {
private final CarManager carManager;
public RestfulResult makeCarDrive(Integer carId) {
Car car = carManager.getCarById(carId);
car.drive();
//...略
}
}
问题2:为何总感觉软件设计里的UML图等设计资产,和我们具体的代码相去甚远呢?
比如,我们拿到了这么一个需求:
我们要做一个公司人员和电脑资产管理软件,这里的业务规定有:
- 公司有很多部门,一个
部门
包含很多员工
,但是一个员工只能属于一个部门;- 公司为每个员工配备一个
工作电脑
,一台工作电脑,只能被一人领用,一个人也只能领用最多一台工作电脑;- 为了工作需要,公司还有一些
虚拟团队
,每个员工,都可以加入多个虚拟团队;
拿到这个需求,我们很快画了一个模型图:
关于,为啥【员工】不是 Employee,而是 User,只能用一句
这是演进的过程:这个demo也是一点点做起来的,现在的样子和当初设想的玩一玩也有点不一样了
,就像我们看到了很多历史代码的 if else,也没办法太纠结前人的某种【不优雅】。虽然 if else 也不能和不优雅画等号,
一切满足业务要求(功能要求、性能要求、设计要求等)、易维护(可阅读性强、易扩展等)的代码,都是好代码
。
┌──────────┐
│ Computer │
└────┬─────┘
1│
│
1│
┌────┴─────┐N 1┌──────────────┐
│ User ├─────┤ Department │
└────┬─────┘ └──────────────┘
M│
│
N│
┌────┴─────┐
│ Team │
└──────────┘
和核心模型 User 的类图(示意简图):
┌──────────────────────────────┐
│ User │
├──────────────────────────────┤
│- id: Integer │
│- name: String │
│- email: String │
├──────────────────────────────┤
│+ getDepartment(): Department │
│+ getComputer(): Computer │
│+ listMyTeams(): List<Team> │
│+ joinTeam(Team team): boolean│
└──────────────────────────────┘
然后根据我们写程序的习惯,设计了一个 UserManager:
public interface UserManager(){
User getUserById(int userId);
boolean addUserToTeam(User u, Team t);
boolean assignComputerToUser(User u, Computer c);
// ... 略
}
等一等!
为什么我的业务核心代码,设计的和我的类图不一样了呢???
为了弥补这个【不一样】,可能还要画很多 **Manager 之类的定义。
所以:
- 程序员们一边被逼着,用所谓的教科书style在画图做设计;
- 又在一遍遍地写着 **Manager 这样的业务流程控制服务去缝合业务逻辑;
- 程序员在填补这二者之间的gap的过程中,是比较痛苦的,而且,
造成了设计与实现的割裂
;
这种方式,不能说是【错】。因为它是一个经典的应用广泛的实现思想:
失血模型
失血模型 VS. 充血模型
失血模型
上面提到的 UserManager 为代表的诸如:ComputerManager、TeamManager等各种Manager,就是失血模型
。
它的特点有:
- 数据库表结构设计就是模型设计(基于数据库的):虽然会有很多人做所谓的
领域建模
之类的设计工作,但真正的建模是在数据库表结构上实现
的,所以,关系型数据库/对象数据库,很适合。 - 重biz层:或者准确地说,是biz以及biz向下的一到两级抽象层(如core/service第第),很重!为什么很重?因为它要“面向过程”啊!
- 轻领域层/模型:由于biz层很重,基本上所谓的领域模型,大都是数据库表结构映射出来的 POJO。
┌──────────────────────┐
│ View │
├──────────────────────┤
│ Controller │
├──────────────────────┤
│ │
│ │
│ Biz │
│ │
│ │
├──────────────────────┤
│ Data Access Layer │
└───────────┬──────────┘
│
▼
┌──────────────────────────┐
│ Database │
│Where domains really are! │
└──────────────────────────┘
还是以 User 为例,那 UserDO 和 User 几乎长得一样:
public class UserDO {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
private String name;
private String email;
private Integer departmentId;
private Integer del;
}
public class User {
private Integer id;
private String name;
private String email;
private Integer departmentId;
private Integer del;
}
试着“注血”
先不用一下子就跑到【充血模型】那里去。先做一个假设:
我们所有在程序中需要用到的数据、关系都可以通过Java对象的接口们实现
,就像:
┌──────────────────────────────┐
│ User │
├──────────────────────────────┤
│- id: Integer │
│- name: String │
│- email: String │
├──────────────────────────────┤
│+ getDepartment(): Department │
│+ getComputer(): Computer │
│+ listMyTeams(): List<Team> │
│+ joinTeam(Team team): boolean│
└──────────────────────────────┘
再假设:我们幸福到不用去考虑数据持久化的事,可以把所有数据和模型都装到一个内存无限大且永不宕机的巨牛逼计算机里
。
那我们设计的系统可能会是这样的:
┌──────────────────────┐
│ View │
├──────────────────────┤
│ Controller │
├──────────────────────┤
│ Biz(Services) │
├──────────────────────┤
│ │
│ Domain │
│ │
└──────────────────────┘
在这个模式下,我们一些业务流和数据流会发生一些小的变化,比如,在贫血模式下,我们典型的数据流是这样的:
(Response)
▲
│
│
Controller(负责对请求/响应与业务模型之间进行转化、翻译和发起对biz的业务请求)
▲
│
│
biz(各种 Manager 包装业务逻辑和dataObject,以至于为上层提供友好、原子化的独立服务)
▲
│
│
DAL(专门处理和持久化层交互的事)
在领域模型比较强大的情况下可以这样;
(Response)
▲
│
│
Controller(同贫血模式)
▲
│
◄─────────┐
│ │
│ * biz(由于,Domain具备了业务能力,biz层在很多时候,不是必须的,下面会有举例)
│ ▲
├─────────┘
│
Domain(充血后,Domain 开始有了业务能力,很多简单的逻辑,可以直接由 Domain 执行)
&
Domain's Repo Bean(需要用一类服务承担 Domain 的封装,后面会提到的 **Repo bean)
Service在很多场景下,变得没有必要,它们都可以通过领域模型,直接返回数据!
- 很多查询场景(比如:/query_user_by_id.json)
- 简单的操作场景(比如:/get_users_computer.json)
例如:
public class UserController() {
private final UserRepo userRepo;
public Computer getUsersComputer(Integer userId) {
User u = userRepo.getById(userId);
return u.getComputer();
}
}
Service层的定位成为了:复杂业务的组装和维护。这里不再赘述举例。
注血后还需解决 DAL 的问题
其实并不难,还是需要保留 DAL(Data Access Layer)的存在:
┌──────────────────────┐
│ View │
├──────────────────────┤
│ Controller │
├──────────────────────┤
│ Biz │
├──────────────────────┤
│ │
│ Domain │
│ │
├──────────────────────┤
│ Data Access Layer │
└───────────┬──────────┘
│
│
┌─────────────▼────────────┐
│ Database │
└──────────────────────────┘
充血模型
关于二者的不同,直接贴代码比较高效:
@RequiredArgsConstructor
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true) // toString() contains super class toString()
public class User extends UserDO {
private final UserDOMapper userDOMapper;
private final DepartmentRepo departmentRepo;
private final ComputerRepo computerRepo;
private final TeamRepo teamRepo;
public boolean delete() {
setDel(1);
return userDOMapper.updateById(this) > 0;
}
public boolean recoverFromDeleted() {
setDel(0);
return userDOMapper.updateById(this) > 0;
}
public Department getDepartment() {
return departmentRepo.getDepartmentById(getDepartmentId());
}
public Computer getComputer() {
return computerRepo.getByOwnerId(this.getId());
}
public List<Team> listMyTeams() {
return teamRepo.listUserJoinedTeams(this.getId());
}
}
本Demo对充血模型的实践
领域模型(同上,略)
名词解释
POJO(Plain Ordinary Java Object):POJO 专指只有 setter / getter / toString 的简单类;
DO(DataObject):数据库表一一对应的 POJO 类。此对象与数据库表结构一一对应,通过 DAO/Mapper 层传递数据;
Mapper:Mybatis 中负责 POJO 和 SQL 之间进行转化的工具;
Domain:领域模型,通过 Repo 提供对外服务传递数据;
Repo:负责将 DO 查询、封装、转化成 Domain;
以 User 为例:
工作在 | 作用 | 类型 | 实例化方式 | |
---|---|---|---|---|
UserDO | 持久化层 | 和数据库的字段1:1,承载持久化的职责 | POJO | new |
UserMapper | 持久化层 | 以DO(Data Object,如UserDO)为入参出参的主要形态,完成与数据库持久化层的交互 | Spring bean | mybatis工具自动生成,用 MapperScan 自动注册到 beanFactory |
User | 领域层、业务层 | 充血的领域模型,负责将设计语义,代码化 | Domain | 因为 Domain 里包含诸如 UserRepo 这类 Spring bean,本例用了 AutowireCapableBeanFactory 自动装载。 另外的方法:写一个工厂类负责Domain的实例化,手工set进去依赖的bean也是可以的! |
UserRepo | 领域层 | 以Domain(领域模型,如User)为出入参的主要形态,完成与业务的交互 | Spring bean | @Component/@Repository 等。 |
后记
一些选择和取舍
-
为什么在领域模型类里,需要 Repo/Mapper 这种spring bean 的域?
因为充血的领域模型,需要提供业务操作能力必须这么做,如:user.listMyTeams()
-
为什么用了很多 lombok?
因为想保持代码在业务性上的纯粹,即所写的代码,尽可能多地都在为业务服务。
-
为什么用了 AutowireCapableBeanFactory 去装载Domain
省事,因为用 set 方法把依赖的 bean都set到Domain里,也是没问题的。
-
为什么要
User extends UserDO
?其实只要不嫌麻烦,也可以把 UserDO 里的每个属性拷贝到 User 里。更可以把 UserDO 对象,整个注入到 User 里,只是这样在取 user.getName() 这样的属性时,不直接高效。
-
领域模型里,有大量的 Spring bean(如:UserDOMapper),怎么避免领域模型被直接被Controller给放到Response里?
经测试,这不是一个大问题。因为,所有的 JSON 序列化框架,都是针对 attribute 的序列化(配套 getter 方法),也就是说,类似下面的类:
public class Boy { private String name; private Dad dad; public String getName(){ return name; } public String getAddress(){ return dad.getAddress(); } }
会序列化成:
{ "name": "...", "address": "..." }
而本例子中 User
public class User extends UserDO { // 这些字段不会被 JSON 序列化 private final UserDOMapper userDOMapper; private final DepartmentRepo departmentRepo; private final ComputerRepo computerRepo; private final TeamRepo teamRepo; ... public Department getDepartment() { return departmentRepo.getDepartmentById(getDepartmentId()); } public Computer getComputer() { return computerRepo.getByOwnerId(this.getId()); } }
会被序列化成:
{ ... //略其他基础字段 "department": { ... }, "computer": { ... } }
而如果把UML类图,画成带有 property 的形式(fields, methods, properties),分类会更纯粹:
- fields:略;
- methods:均是能直接为业务提供价值的 服务/方法;
- properties:Domain包含的数据,用 getter 方法获取;
┌──────────────────────────────┐ │ User │ ├──────────────────────────────┤ │- userDOMapper: userDOMapper │ │- computerRepo: ComputerRepo │ // private fields │- ... │ ├──────────────────────────────┤ │+ listMyTeams(): List<Team> │ │+ joinTeam(Team team): boolean│ // methods for business │+ ... │ ├──────────────────────────────┤ │+ department: Department │ │+ computer: Computer │ │+ id: Integer │ │+ name: String │ // properties got by getter methods │+ email: String │ │+ departmentId: Integer │ │+ del: Integer │ └──────────────────────────────┘
这个case,可以直接启动 spring 项目,然后
curl localhost:8081/users/1
看看效果:{ "id": 1, "name": "Leon", "email": "leon@test.com", "departmentId": 1, "del": 0, "department": { "id": 1, "name": "R&D", "del": 0 }, "computer": { "id": 1, "ownerUserId": 1, "sn": "AAAAA", "buyTime": null, "del": 0 } }
另外,即使不小心,给这些 bean 加上了
@Getter
注解,也没关系。因为不是标准数据类型,在 JSON 序列化的时候,会直接报错的。
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class com.sun.proxy.$Proxy82 and no properties discovered to create BeanSerializer
具体原因,不赘述了。
遗留的问题
关于测试友好
TODO。
但不是什么大问题。
为什么没说「贫血模型」?
贴一下代码,大概领会一下各种区别:
public class User{
/*省略各种数据库field*/
private Computer computer;
private Department department;
public getMyComputer(){
return computer;
}
public getDepartment(){
return department;
}
/*省略其他*/
}
贫血模型,不是没有应用场景,只是还没有深究。但是,贫血有几个注意的地方:
- 过多的所依赖的其他领域模型,给类的装载带来很多负担;
- 循环依赖的场景,如:user.getMyComputer().ownedBy().getMyComputer()…
- ……