领域驱动设计实现疑难解答(三):如何处理关联
有了模型之后,领域最重要的是处理业务逻辑。如何处理关联?对象都有声明周期,它们在生命周期内与其他对象具有复杂的相互依赖性,它们会经历一些状态变化,并遵守固定的规则。因此,管理这些对象面临挑战!有3种模式解决这些问题:1.使用AGGREGATE(聚合)定义清晰的所属关系和边界,以维护生命周期的完整性2.使用FACTORY(工厂)创建或重建复杂对象和聚合,从而封装它们的内部结构3.使用REPOSITO
有了模型之后,领域最重要的是处理业务逻辑。
如何处理关联?
对象都有声明周期,它们在生命周期内与其他对象具有复杂的相互依赖性,它们会经历一些状态变化,并遵守固定的规则。因此,管理这些对象面临挑战!
有3种模式解决这些问题:
1.使用AGGREGATE(聚合)定义清晰的所属关系和边界,以维护生命周期的完整性
2.使用FACTORY(工厂)创建或重建复杂对象和聚合,从而封装它们的内部结构
3.使用REPOSITORY(资源库)提供查找和持久化对象并封装庞大基础设施的手段
总结起来,就是使用聚合
进行建模和划分范围,工厂
和资源库
将特定生命周期的复杂性封装起来。
1.聚合
现实中有很大的复杂关系网,例如删除一个Person,但是这个人有姓名,生日,工作描述,地址等等。
是否都关联删除呢?
若不删除,这些垃圾数据就会留在数据库。
若删除,将会波及广泛,产生难以预计的潜在影响。
因此,对数据进行的事务修改需要个范围,要保持数据一致性。
我们需要一个抽象来封装模型中的引用,AGGREGATE(聚合)就是一组相关对象的集合,是数据修改的单元。
1.1聚合里有什么?
有根(root),是所包含的一个特定实体,是外部对象唯一允许保持的引用
有边界(boundary),是聚合的内部对象,对象之间可以互相引用。
聚合里的根Entity具有全局唯一标识,而其他Entity具有本地唯一标识。
例如,汽车有车架号或发动机号,作为全局唯一标识,汽车作为根实体,而汽车内部的轮胎,作为其他Entity,不需要全局唯一标识,只在本地唯一。因为不需要跟踪某个轮胎在哪辆车上。
1.2聚合怎么设计?
聚合需要满足一组规则:
- 根实体具有全局唯一标识,内部实体具有本地标识。
- 聚合外部对象不能保持对内部对象除根实体之外的引用,内部对象只能传递引用,不能保持引用。
- 只有根才能直接查询数据库获取,其他对象必须关联遍历查询。
- 聚合内部对象可以保持对其他聚合根的引用
- 删除操作必须一次删除聚合之内所有对象
- 当改变聚合内部对象时,所有固定规则必须被满足
- 关注聚合的一致性边界,而不是创建对象树,即只保留一致性相关集合,而不是所有对象。
- 为了满足业务规则,可以组合多个聚合形成新的聚合概念
然后,实现方法如下:
1.把实体Entity和值对象VO分门别类的放在聚合Aggregate中,并定义聚合的边界
2.选择一个实体Entity作为根,使用根作为聚合,并通过根来控制内部其他对象的访问。
3.只允许外部持有根,而内部对象只能临时传递,只能一次操作中有效。
4.不能绕过它修改内部对象。
例如,采购项目系统里的采购订单,包含一系列条目,条目内包含产品,数量,条目总价。采购订单有固定规则,比如总价必须少于1000元
示例代码:
//采购订单类
class PurchaseOrder{
//标识引用
private PurchaseOrderId purchaseOrderId;
List<PurchaseOrderItem> items;
//订单总价
private double total;
public void check(){
double sum = 0;
for (PurchaseOrderItem item:items){
sum += item.getAmount();
}
//固定规则
if(sum <= 1000){
this.total =sum;
}else {
System.out.println("error!");
}
}
}
//采购订单ID类
class PurchaseOrderId{
private String id;
}
//采购订单条目类
class PurchaseOrderItem{
private int itemId;
//产品
private Product product;
//数量
private int quantity;
//条目总价
private double amount;
//省略getter和setter
}
//产品类
class Product {
private String name;
//产品价格
private double price;
}
1.3聚合中的并发问题
上述代码,有三级嵌套,订单里有条目,条目内有产品。
若修改不同条目的产品类别和数量时会引起并发竞争,进而超过总价,破坏固定规则。需要锁定数据库中的各条目。
然而,修改产品的价格时,也会导致超过总价。若也锁定产品,由于产品被许多订单使用,会导致死锁。
因此,推荐解耦订单与产品的依赖,加深订单与条目的联系,把产品价格直接复制给条目。
//采购订单条目类
class PurchaseOrderItem{
private int itemId;
//产品价格
private double price;
//数量
private int quantity;
//条目总价
private double amount;
//省略getter和setter
}
2工厂
一个对象在它的生命周期要承担大量的责任,如果连自身的创建都由自己负责,职责就过重了。例如,汽车自身只需要行驶就可以了,不需要负责自身的制造。当然,也不适合让客户去创建和装配对象。这时,需要工厂承担对象创建的职责。
好的工厂满足两个基本要求
- 每个创建方法都是原子方法,并满足固定规则。
- 工厂应该被抽象为所需类型,而不是具体的类
2.1疑惑解答
1.工厂用在哪里?
因为工厂与产品是紧密联系的,这时的工厂应该放在关联的产品对象里。
比如在聚合根上遍历对象
1.在聚合根上创建工厂方法来添加元素,这样可以隐藏内部实现细节,确保被添加元素的完整性。
2.在对象上创建工厂方法来生成另一个对象,而该对象不被持有。因为生成的对象依赖另一个对象的数据。
当不适合放在产品里时,需要创建单独工厂或服务。
3.独立工厂通常用于创建整个聚合,并把对根的引用传递出去,并确保满足固定规则。
或者聚合内的某个对象不适合再聚合上创建,也可以由独立工厂创建。
2.领域对象的创建是谁的职责,放在哪个模块?
复杂对象的创建是领域层的职责,工厂可以承担这个职责,工厂应该放在领域层。
另外在重建对象时,或用于转换对象或构建防腐层,工厂应该放在基础设施层。
3.工厂的输入参数的选择有哪些?
工厂的输入参数都是些值对象,这样做可以向客户端隐藏创建的细节。
因为操作必须是原子性的,所以输入参数必须含有一次交互中的所有信息。
4.工厂方法需要守卫措施吗?
不需要。因为所有值对象的构造函数和setter方法提供了守卫措施。
5.工厂方法的缺点有哪些?
性能有影响,因为为了得到某个对象,根据领域规则,先要获得它得根对象,再通过遍历得到。
6.工厂方法的优点有哪些?
减少客户端传入参数的负担,隐藏创建对象的细节,同时拥有更好的语义化表达。
7.工厂方法无法创建对象时可以抛出异常吗?
推荐抛出异常。因为这可以确保不会返回错误值。
8.工厂必须一次性创建好对象吗?
对于值对象,工厂一次性创建好最终状态。对于实体,一次性创建好满足固定规则的聚合,之后可以向聚合添加可选元素。
9.不适合使用工厂,而去使用构造函数的情形有哪些?
当类是一种数据,比如是类型,是某种策略实现,或者可以访问所有对象的所有属性,构造不复杂时,推荐使用构造函数。但是,也必须是原子操作,满足所有固定规则。不要调用其他类的构造函数,否则,还是用工厂吧。
10.对象创建需要遵循的固定规则适合放在工厂中吗?
适合。但是固定规则的检查工作优先考虑放在产品中,再考虑放在工厂中。
11.如何重建对象?
重建对象时,标识必须是输入参数的一部分。若数据库的数据不满足固定规则,需要某种策略修复这种不一致。
对于关系型数据库,需要对象映射技术帮助重建。
应用场景,采购订单包含一系列采购项目,同时拥有采购单编号。
示例代码如下:
//采购订单类
class PurchaseOrder{
//标识引用
private PurchaseOrderId purchaseOrderId;
List<PurchaseOrderItem> items;
//订单总价
private double total;
//工厂方法
public PurchaseOrderItem create(){
PurchaseOrderItem item = new PurchaseOrderItem()
items.add(item );
return item;
}
}
PurchaseOrder类负责PurchaseOrderItem的创建很自然,可以利用前提规则和信息,并且PurchaseOrderItem并不直接属于工厂所属的聚合。
当工厂不适合放在聚合内时,需要单独创建独立的Factory或Service对象。
构造并不复杂时,或属性公开时,可以使用构造函数
2.2 区别对比
实体工厂(Entity Factory)VS 值对象工厂(VO Factory)
实体的工厂只需要加载聚合需要的属性,过后再添加细节,同时控制实体的标识。
而由于值对象的不变性,工厂创造值对象后就是最终形式。
重建 VS 创建
在生命周期开始,工厂要创建对象。在生命周期中间,工厂要重建对象。
- 创建实体时,要分配ID;重建实体时,不需要分配ID
- 创建对象时,不满足固定规则,直接失败;重建对象时,不满足固定规则,需修复对象。
3资源库
3.1 如何找到一个对象?
我们可以在创建对象时,获得对象的引用,也可以从已知对象出发,遍历关联其它对象。
但是,无论如何,都要有一个起点。
很多人将对象存在关系型数据库,然后通过对象的属性,查询到对象,或者重建对象。
数据库是全局可访问的,意味着可以解耦众多对象之间的关联,但同时失去了内聚性。这需要权衡。
//Customer对象内聚Order对象
public class Customer {
private String id;
private Collection<Order> orders;
}
//或者通过属性搜索
public class Customer {
private String id;
}
public class Order {
private String orderId;
//引用外部实体
private String customerId;
private double price;
}
order_id|custermer_id|price
1|1|100.00
2|1|150.00
3|1|200.00
从技术上,在数据库中检索出已存储的Customer对象,是一个创建对象的操作。然而,从业务上这个客户已存在,所以是一个重建的做作。
3.2数据搜索访问
通常由于开发人员执行SQL查询,再创建出对象,会把对象看作容器用来放置数据,这时设计转变为数据处理风格。甚至直接面向数据库查询和操作数据,绕过了聚合(Aggregate)的特色功能。导致领域规则被嵌入到查询代码中。
推荐的设计原则:
- 对于临时对象(一般是值对象),通过实体关联遍历,不要用搜索查询。并且禁止用其他方法对聚合内的任何对象进行访问。
- 对于实体、复杂值对象、枚举值,可以使用基于对象属性的全局搜索访问。
数据搜索访问不再把关注点用于业务,这时需要Repository(资源库)进行封装。
资源库将同一类型的所有对象表示为一个概念集合。
资源库负责将对象添加到数据库中,或从数据库删除对象。
3.3工厂与资源库的配合
工厂用于生命周期开始的创建,资源库用于生命周期中间的重建。
//一
class Client {
public void call(){
//1.客户端调用资源库
User user = repository.query();
}
}
class Repository {
public User query(){
//2.资源库查询数据库
UserPO userPO = dao.query();
//3.查询对象转换成聚合
return Factory.convert(UserPO userPO);
}
}
//二
class Client {
public void call(){
//1.工厂创建聚合
User user = Factory.create();
//2.存储聚合
repository.add(user);
}
}
class Repository {
public void add(user){
//3.资源库查询数据库
dao.insert(user);
}
}
工厂和资源库也可以实现“查找or创建”功能,当然我们最好不要设计,这样会给局面带来混乱。
3.4关于关系型数据库的对象持久化
若要将对象持久化或者传输,基本上都需要将对象转为平面数据才能传输或存储。
因此,重建已存储的对象需要一个复杂的过程将各部分重新装配成可用的对象。
对象数据库或NOSQL还好说,但是对于关系型数据库复杂性就高了。
大多数据库框架都实现有表-对象关系映射(ORM),其中,一个数据表映射到java中的一类,表中的一行代表一个对象,表中的一列代表对象中的一个属性,表中的外键是其他实体的引用,这种简单的映射关系。
然而,为领域对象设计数据库表时,可分为直接映射表的数据模型与业务相关的领域模型。
数据模型有基本属性和公开的getter、setter方法,数据表通过反射给数据模型赋值。
领域模型有业务属性及业务逻辑。可能有多个相似的领域对象,这会带来麻烦。
我们需要折中处理。
为了保证数据模型和对象模型的紧密联系,不妨可以牺牲对象关系的丰富性。
当然,也可以数据模型和对象模型分开,也是一种好选择。
3.5实现
1.若数据模型和对象模型紧密联系,则需要ORM进行辅助。
若使用hibernate框架还好说,可以有很多注解帮助领域对象的持久化,并且可根据对象来创建关系表。
但若使用mybatis框架,由于mybatis是根据数据表作优化的,必须手写XML文件进行映射。
public class User {
private String name;
private Address address;
}
public class Address {
private String province;
private String city;
private String street;
}
<resultMap id="userMap" type="com.example.domain.model.User">
<id column="id" property="id"/>
<result column="name" property="name"/>
<!--内联Address对象-->
<association property="departmentId" javaType="com.example.domain.model.Address">
<result column="province" property="province"/>
<result column="city" property="city"/>
<result column="street" property="street"/>
</association>
</resultMap>
<!--通过关联映射-->
<insert id="insert">
insert into tbl_user (name, province, city, street)
values (#{name} ,#{Address.province} ,#{Address.city} ,#{Address.street})
</insert>
<select id="user" parameterType="java.lang.String" resultMap="userMap">
select * from tbl_user where tbl_user.name = #{name}
</select>
2.若数据模型和对象模型分开,则需要创建转换类,进行数据模型和领域模型的转换。
//领域模型,内含值对象、实体、业务逻辑等
public class User {
private String name;
private Address address;
}
//数据模型,属性与数据表的列一一对应
public class UserPO{
private String name;
private String province;
private String city;
private String street;
}
//需要单独的转换对象的工厂类或服务类
class Factory{
//持久化对象转领域对象
public User convertToUser(UserPO userPO){}
//领域对象转持久化对象
public UserPO convertToPO(User user){}
}
//资源库调用
class Repository {
public User query(){
//2.资源库查询数据库
UserPO userPO = dao.query();
//3.查询对象转换成聚合
return Factory.convertToUser(UserPO userPO);
}
}
该方式优点是领域模型和数据模型解耦,边界清晰,缺点是转换太麻烦了,增加了代码量。
3.4.1存储单个值对象
示例代码:
//存储单个值对象
public class Person {
private String name;
private Address address;
}
public class Address {
private String province;
private String city;
private String street;
}
//表结构
person_name | person_address_province | person_address_city | person_address_street
zhangsan | 湖北 | 武汉 | 光谷街
数据表遵循非范式设计,即使是深层嵌套,也放在一张表中。命名体现嵌套层数。
3.4.2存储多个值对象
聚合中的值对象可能有0个或多个,不能确定大小。
示例代码:
//1.序列化多个值对象作为一列存储
public class Person {
private String name;
private Set<Address> adresses;
}
public class Address implements Serializble{
private String province;
private String city;
private String street;
}
//表结构
person_name | person_addresses
zhangsan | {"province":"湖北 ","city":"武汉 ","street":"光谷街"},{"province":"湖南","city":"长沙","street":"建设大道"}
这样做的缺点是列宽字符可能不够,不能SQL查询内部的属性,需要自定义类型进行序列化和反序列化。
另一种,示例代码:
//2.将多个值对象存在另一张表中
public class Person {
private String id;
private String name;
private Set<Address> addresses;
}
public abstract class IdentifiedValueObject implements Serializble{
//委派表述
private long id;
//getter setter
}
public class Address implements IdentifiedValueObject {
private String province;
private String city;
private String street;
}
//表结构
//person表
person_name | person_id
zhangsan | 1
//address表,person_id外键
id | province | city | street | person_id
1 | 湖北 | 武汉 | 光谷街 | 1
2 | 湖南 | 长沙 | 建设大道 | 1
3 | 广东 | 深圳 | 解放大道 | 1
这里adress值对象专门有个表,并分配了委派标识,与主表person通过外键关联。
缺点是需要层超类型构建隐藏委派标识。
也可以不要委派标识(主键),将值对象存储在表中,通过外键关联实体,但是这样有限制。
一是仍然需要连表查询。
二是属性都不能为空。
三是若值对象里又嵌套集合,就无法处理了。
目录:
领域驱动设计实现疑难解答(一):如何分包及组织工程结构
领域驱动设计实现疑难解答(二):如何建模
领域驱动设计实现疑难解答(四):如何渲染数据
领域驱动设计实现疑难解答(五):如何发布领域事件
更多推荐
所有评论(0)