领域驱动设计融合_2_领域驱动设计的战术考量
前言
Github:https://github.com/HealerJean
一、设计概念的统一语言
领域驱动设计引入了一套自成体系的设计概念:限界上下文、应用服务、领域服务、聚合、实体、值对象、领域事件以及资源库和工厂。这些设计概念与其他方法的设计概念互为参考和引用,再糅合不同团队、不同企业、不同领域的设计实践,就产生了更多的设计概念。诸多概念纠缠不清,人们理解不同,就会形成认知上的混乱,干扰整个团队对领域驱动设计的理解。既然领域驱动设计强调为领域逻辑建立统一语言,我们不妨也为这些设计概念定义一套“统一语言”,使不同人的理解一致,保证交流的畅通,确保架构和设计方案的统一性
1、设计术语的统一
1)POJO
对象
Plain Old Java Object
一般指那些没有继承特定框架的类,也没有实现特定接口,仅包含一些属性及对应的
get
和set
方法等简单的Java
类。没有严格的规范要求,它可以包含任意的属性和方法,甚至可以没有无参构造函数,也不要求属性有对应的getter
和setter
方法场景: 在很多现代框架中被广泛使用,因其简单独立,易与各种框架集成,如
Spring
框架中,POJO
可作为业务对象或数据对象使用
// 这是一个简单的 POJO 类
public class SimplePOJO {
public String name;
public int age;
public SimplePOJO(String name, int age) {
this.name = name;
this.age = age;
}
}
2)Java Bean
- 必须有一个无参构造函数:以便于通过反射机制实例化对象。
- 属性必须是私有(
private
)的:通过公共的getter
和setter
方法来访问和修改属性值。- 可序列化:实现
java.io.Serializable
接口,以便在网络传输或持久化存储时使用。认真解读这
3
个条件,你会发现它们都是为支持反射访问类成员而准备的前置条件,包括创建Java Bean
实例和操作内部字段。只要遵循Java Bean
规范,就可以采用完全统一的一套代码实现对Java Bean
的访问。使用Java Bean
,看重的其实是对象携带数据的能力,可通过反射访问对象的字段值来简化代码的编写
import java.io.Serializable;
// 这是一个符合 JavaBean 规范的类
public class PersonBean implements Serializable {
private String name;
private int age;
// 无参构造函数
public PersonBean() {}
// 有参构造函数
public PersonBean(String name, int age) {
this.name = name;
this.age = age;
}
// getter 方法
public String getName() {
return name;
}
// setter 方法
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
a、说明
1、一个
POJO
如果遵循了Java Bean
的设计规范,可以成为一个Java Bean
,但并不意味着POJO
一定是Java Bean
,反过来也一样2、
POJO
可以封装业务逻辑,Java Bean
的规范也没有限制它不能封装业务逻辑。一个提供了丰富领域逻辑的Java
对象,如果同时又遵循了Java Bean
的设计规范,也可以认为是一个Java Bean
3)X
血模型
a、贫血模型(Anemic Domain Model)
贫血模型是一种将数据和行为分离的设计模式。在这种模型中,领域对象(通常是实体类)仅仅包含数据的
getter
和setter
方法,不包含任何业务逻辑,业务逻辑被放在专门的服务层中处理。
优缺点
- 优点:结构简单,易于理解和实现,适合初学者和简单的业务系统。
- 缺点:领域对象缺乏行为,业务逻辑分散在服务层,导致领域对象只是一个数据容器,从贫血一词可知,这种领域模型必然是不健康的。它违背了面向对象设计的关键原则,即“数据与行为应该封装在一起”
- 场景:贫血模型适合简单的业务系统
示例
// 贫血模型的 Order 类
public class Order {
private String orderId;
private double totalAmount;
public Order(String orderId, double totalAmount) {
this.orderId = orderId;
this.totalAmount = totalAmount;
}
public String getOrderId() {
return orderId;
}
public void setOrderId(String orderId) {
this.orderId = orderId;
}
public double getTotalAmount() {
return totalAmount;
}
public void setTotalAmount(double totalAmount) {
this.totalAmount = totalAmount;
}
}
// 订单服务类,负责处理订单的业务逻辑
public class OrderService {
public void calculateDiscount(Order order) {
double discountedAmount = order.getTotalAmount() * 0.9; // 打九折
order.setTotalAmount(discountedAmount);
}
}
b、充血模型(Rich Domain Model)
充血模型将数据和行为封装在同一个领域对象中,领域对象不仅包含数据,还包含与之相关的业务逻辑。这样可以使领域对象更加具有自主性和完整性。
优缺点
- 优点:符合面向对象的设计原则,提高了代码的内聚性和可维护性,使领域对象更加真实地反映业务概念。
- 缺点:对于复杂的业务系统,领域对象可能会变得过于庞大和复杂,导致代码难以理解和维护。
- 场景:充血模型适用于中等复杂度的系统
// 充血模型的 Order 类
public class Order {
private String orderId;
private double totalAmount;
public Order(String orderId, double totalAmount) {
this.orderId = orderId;
this.totalAmount = totalAmount;
}
public String getOrderId() {
return orderId;
}
public void setOrderId(String orderId) {
this.orderId = orderId;
}
public double getTotalAmount() {
return totalAmount;
}
public void setTotalAmount(double totalAmount) {
this.totalAmount = totalAmount;
}
// 计算折扣的业务逻辑封装在 Order 类中
public void calculateDiscount() {
this.totalAmount = this.totalAmount * 0.9; // 打九折
}
}
c、富领域模型(Fully Rich Domain Model)
富领域模型是充血模型的进一步发展,它强调领域对象之间的协作和交互,不仅将业务逻辑封装在单个领域对象中,还考虑了领域对象之间的关系和交互,通过领域事件、聚合根等概念来管理复杂的业务逻辑。
优缺点
- 优点:能够更好地应对复杂的业务场景,通过领域对象之间的协作和交互,使业务逻辑更加清晰和易于管理,提高了系统的可扩展性和可维护性。
- 缺点:设计和实现难度较大,需要对业务领域有深入的理解,并且需要掌握领域驱动设计的相关概念和技术。
- 场景:富领域模型则更适合处理复杂的业务场景。
// 产品类
public class Product {
private String productId;
private double price;
public Product(String productId, double price) {
this.productId = productId;
this.price = price;
}
public double getPrice() {
return price;
}
}
// 客户类
public class Customer {
private String customerId;
private String name;
public Customer(String customerId, String name) {
this.customerId = customerId;
this.name = name;
}
}
// 富领域模型的 Order 类,作为聚合根
public class Order {
private String orderId;
private Customer customer;
private List<Product> products;
private double totalAmount;
public Order(String orderId, Customer customer, List<Product> products) {
this.orderId = orderId;
this.customer = customer;
this.products = products;
this.calculateTotalAmount();
}
private void calculateTotalAmount() {
this.totalAmount = products.stream().mapToDouble(Product::getPrice).sum();
}
public void applyDiscount(double discountRate) {
this.totalAmount = this.totalAmount * (1 - discountRate);
}
}
4)诸多 “XO”
在分层架构的约束以及职责分离的指引下,一个软件系统需要定义各种各样的对象,并在各自的层次承担不同的职责,又彼此协作,共同响应系统外部的各种请求,执行业务逻辑,让整个软件系统真正地跑起来。
若没有真正理解这些对象在架构中扮演的角色和承担的职责,就会导致误用和滥用,适得其反。因此,有必要在领域驱动设计的方法体系下,将各式各样的对象进行一次梳理,形成一套统一语言。由于这些对象皆以
O
结尾,因此我将其戏称为XO
a、DTO
数据传输对象
数据传输对象(data transfer object,
DTO
),用于在进程间传递数据的对象
在菱形对称架构中,,提出了消息契约模型的概念。它实际上就是 DTO
模式的体现,通常定义在北向网关的本地服务层,远程服务和应用服务都以消息契约模型对象作为接口方法的输入参数和返回值。这实际上扩展了 DTO
的应用场景,使其不止限于进程间的数据传递,还能对领域模型提供保护。为了支持进程间的数据传递,
消息契约模型必须支持序列化。最好将其设计为一个 Java
Bean
,消息契约对象通常还应该是一个贫血对象,因为它的目的是传输数据,没有必要定义封装逻辑的方法,但考虑到它与领域模型之间的映射关系,可能需要为其定义转换方法
b、VO
视图对象
视图对象(view object,
VO
)其实是消息契约模型中的一种,往往遵循MVC
模式,为前端UI
提供了视图呈现所需要的数据,我将其称为“视图模型对象”。当然,我们也可以沿用DTO
模式。由于它主要用于后端控制器服务和前端UI
之间的数据传递,这样的视图模型对象自然也属于DTO
对象的范畴。
视图对象可能仅传输了视图需要呈现的数据,也可能为了满足前端UI的可配置,由后端传递与视图元素相关的属性值,如视图元素的位置、大小乃至颜色等样式信息。系统分层架构规定边缘层承担了 BFF
(Backend For Frontend)层的作用,定义在边缘层的控制器会操作这样的视图对象。
c、BO
业务对象
业务对象(business object,
BO
)是企业领域用来描述业务概念的语义对象。这是一个非常宽泛的定义。一些业务建模方法使用了业务对象的概念
业务对象的业务逻辑恰好也是领域驱动设计关注的核心,可认为领域驱动设计建立的领域模型皆是业务对象。业务对象由于并没有清晰地给出粒度的界定、职责的划分,更像组成领域分析模型中的领域概念对象。为避免混淆,我建议不要在领域驱动设计中使用该概念。
d、DO
领域对象
领域驱动设计将业务逻辑层分解为应用层和领域层,业务对象在领域层中就变成了领域对象(
domain
object
,DO
)。
领域驱动设计的准确说法是领域模型对象,领域模型对象包括聚合边界内的实体和值对象、领域事件和领域服务,游离在聚合之外的瞬态对象(往往定义为值对象)只要封装了领域逻辑,也可认为是领域模型对象。
同样是简称惹的祸,DO
也可以认为是数据对象( data
object
)的简称,这就与领域对象的定义完全南辕北辙了。再次强调,在使用简称来指代某一类对象时,交流的双方一定要事先明确设计的统一语言,否则很容易造成误解。
e、PO
持久化对象
对象字段持有的数据需要被持久化到数据表中,参与到持久化操作的对象就被称为持久化对象(persistence object,
PO
)。
注意,持久化对象并不一定就是数据对象。相反,在领域驱动设计中,持久化对象往往指的就是领域模型对象。领域模型对象与持久化对象并不矛盾,它们只是不同场景下扮演的不同角色:
领域模型对象和持久化对象本质上可以是同一类对象,只是在不同的场景下扮演不同的角色。在领域层,我们从业务逻辑的角度看待这些对象,不需要考虑领域模型对象的持久化,故而将其称为领域模型对象;在持久化时,我们从数据存储的角度处理它们,许多满足 ORM规
范的持久化框架操作的仍然是领域模型对象,只是它们并不关心领域对象封装的领域行为逻辑罢了。
不可否认,当我们将领域模型对象作为持久化对象完成数据的持久化时,可能会为领域模型对象带来外部框架的污染
f、DAO
数据访问对象
数据访问对象(
data
access
object
,DAO
) 对持久化对象进行持久化,实现数据的访问。它可以持久化领域模型对象,但对领域模型对象的边界没有任何限制。由于领域驱动设计引入了聚合边界,并力求领域模型与数据模型的分离,且引入了资源库专门用于聚合的生命周期管理,因此在领域驱动设计中,不再使用DAO
这个概念。
2、领域驱动设计的设计统一语言
1、领域模型对象包含实体、值对象、领域服务和领域事件,有时候也可以单指组成聚合的实体与值对象。
2、领域模型必须是富领域模型。
3、远程服务与应用服务接口的输入参数和返回值为遵循 DTO
模式的消息契约模型,若客户端为前端UI,则消息契约模型又称为视图模型 VO
。
4、领域模型对象中的实体与值对象同时作为持久化对象。
5、只有资源库对象,没有数据访问对象。资源库对象以聚合为单位进行领域模型对象的持久化,事件存储对象则负责完成领域事件的持久化。
二、领域模型的持久化
1、对象关系映射
要持久化领域模型对象,需要为对象与关系建立映射,即所谓的“对象关系映射”(object relationship mapping,ORM)。当然,这主要针对关系数据库。对象与关系往往存在“阻抗不匹配”的问题
1)类型的阻抗不匹配:
例如不同关系数据库对浮点数的不同表示方法,字符串类型在数据库的最大长度约束等,又例如
Java
等语言的枚举类型本质上仍然属于基本类型,关系数据库中却没有对应的类型来匹配。
a、浮点数精度差异:
Java
中double
类型支持双精度浮点数(64位),但某些数据库(如MySQL
的FLOAT
类型)仅支持单精度(32位)。当ORM
框架自动映射时,可能导致精度丢失或插入失败。
b、枚举类型映射:
Java
的enum
本质是带有名称的整型或字符串,但关系数据库无原生枚举类型。需将enum
映射为VARCHAR
或INT
字段
c、字符串长度限制:
Java
的String
类型无长度限制,而数据库的VARCHAR(255)
可能截断超长数据。例如用户输入300
字符的地址字段,直接映射会触发数据库异常
2)样式的阻抗不匹配:
领域模型与数据模型不具备一一对应的关系。领域模型是一个具有嵌套层次的对象图结构,数据模型在关系数据库中却是扁平的关系结构,要让数据库能够表示领域模型,就只能通过关系来变通地映射实现。
a、对象图 vs 外键关联:
领域模型中的订单(
Order
)包含多个订单项(OrderItem
)对象,形成嵌套结构,数据库需拆分为orders
和order_items
表,通过外键order_id
关 联。
class Order {
List<OrderItem> items; // 对象直接引用集合
}
b、值对象映射:
领域驱动设计(
DDD
)中的值对象(如Address
包含省、市、街道)需扁平化为数据库单表多字段,ORM
需将User.address
字段拆解到多个列,反之亦然。
CREATE TABLE users (
province VARCHAR(50),
city VARCHAR(50),
street VARCHAR(100)
);
3)对象模式的阻抗不匹配:
面向对象特性(封装、继承、多态)在关系模型中的表达受限。数据表只有组合关系,无法表达对象之间的继承关系。既然无法实现继承关系,就无法满足Liskov替换原则,自然也就无法满足多态
a、继承关系的尴尬实现:
假设存在员工(
Employee
)及其子类程序员(Developer
)、设计师(Designer
),对象模型通过继承表达差异:
class Employee { /* 公共属性 */ }
class Developer extends Employee { String programmingLanguage; }
class Designer extends Employee { String designTool; }
关系数据库的映射策略:
单表继承(Single
Table
):所有子类字段合并到 employees
表,冗余字段导致 NULL
值(如design_tool
对程序员无效)
每个类一张表(Table
per
Class
):需 employees
、developers
、designers
三张表,查询时需 UNION
操作,性能低下。
b、封装性破坏
对象通过私有字段和公有方法实现封装,数据库需将
password
字段暴露为明文或哈希字符串,ORM
框架可能绕过Setter
方法直接注入值,导致安全漏洞
class User {
private String password; // 私有字段
public void setPassword(String hashed) { /* 加密逻辑 */ }
}
c、多态失效
若代码中通过
Employee employee = new Developer()
实现多态,数据库无法存储具体子类类型。查询时需额外字段(如employee_type
)标识类型
4)ORM
框架的妥协方案
复杂领域模型中,可结合
DDD
的聚合根设计,将高频操作封装为原子性数据库事务,减少ORM的映射复杂度
问题类型 | ORM 解决方案示例 |
局限性 |
---|---|---|
类型不匹配 | Hibernate的 @Type 注解指定自定义类型转换 |
需手动配置,增加维护成本 |
样式不匹配 | JPA的 @OneToMany 映射集合关系 |
复杂关联导致 SQL 生成效率低下 |
继承映射 | 单表继承、Joined 子类、Table per Class |
冗余字段或查询性能问题 |