2. 落地战术设计
落地实体
实体的唯一性由唯一标识确定。标识的生成策略大概有以下几种
- 用户提供唯一标识
- 程序生成唯一标识
- 持久化机制生成唯一标识
- 另一个限界上下文提供唯一标识
如果你的实体标识策略不是由持久化机制生成的唯一标识。那么应该把委派标识(为了迎合ORM而建的标识)对外隐藏,委派标识不属于实体的一部分。可以使用层超模式
/**
* jpa实体通用字段组成的父类
*
* @author mazhenjie
* @since 2019-11-01
*/
@MappedSuperclass
@Getter
@Setter
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseJpaEntity {
/**
* id
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 创建时间
*/
@Temporal(TemporalType.TIMESTAMP)
@CreatedDate
@Column(name = "date_create", updatable = false)
private Date dateCreate;
/**
* 更新时间
*/
@Temporal(TemporalType.TIMESTAMP)
@LastModifiedDate
@Column(name = "date_update")
private Date dateUpdate;
}
落地值对象
值对象的唯一性是通过属性值去判断的。在建模时我们应该尽可能的将模型建为值对象,使我们更少的进行职责假设。
持久化机制不应该影响到值对象的建模,值对象的持久化方式:
- ORM与单个值对象
实体和值对象一对一映射,值对象的属性作为字段存在和实体同一张表中
- 多个值对象序列化到单个列中
实体引用了List和Set属性的值对象集合
- 使用数据库实体保存多个值对象
值对象单独一个数据库实体表存储,并且带有一个委派主键标识,这个标识不对客户端展示。领域模型依然是一个值对象。持久化相关的逻辑没有泄漏到模型或客户端上去。
持久化机制我是这么感觉的,如果这个不会有对这个值对象里面的属性频繁查找的情况,是可以把这个值对象序列化存入实体表的一个字段中的。如果整个值对象的查找也不是很频繁,也可以选择把这个值对象单独存一张表。如果有对值对象里属性频繁查找的情况,我们可以在实体表上直接存值对象的字段。
public class FulfillmentDemand extends BaseJpaEntity implements Entity<FulfillmentDemand> {
/**
* 需求标识
*/
@Column(name = "demand_id")
private String demandId;
/**
* 订单信息
*/
@Embedded
private Order order;
/**
* 客户信息
*/
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "demand_id", referencedColumnName = "demand_id", insertable = false, updatable = false)
private Buyer buyer;
上面的订单信息里面的字段就直接存入实体表字段中,客户信息就另外建一个表。对聚合来说都是一样的。
落地聚合、聚合根
聚合是领域对象的显式分组,旨在支持领域模型的行为和不变性,同时充当一致性和事务性边界。一个聚合包含聚合根、实体和值对象。
聚合设计的原则
-
在一致性边界之内建模真正的不变条件
- 一致性。事务一致性、最终一致性。一个事务中只修改一个聚合,反之:不能在一个事务中同时修改多个聚合实例,真要这么做的话要考虑最终一致性
- 不变条件。指的是一个业务规则,该规则应该总是保持一致的
- 设计小聚合。根实体表示聚合,绝大多数根实体可以设计为聚合
- 通过唯一标识引用其它聚合
- 在边界之外使用最终一致性
@AggregateRoot
public class FulfillmentRule extends BaseJpaEntity implements Entity<FulfillmentRule>
聚合根本质还是一个实体,所以我们还是实现实体的接口,单独用个注解标注一下就好
聚合根、实体、值对象区分
从标识角度:聚合根是实体,具有全局的唯一标识。而实体只有在聚合内部有唯一的本地标识,值对象没有唯一标识,通过属性判断相等性,实现Equals方法。
从是否只读的角度:聚合根除了唯一标识外,其他所有状态信息都理论上可变。实体是可变的。值对象不可变,是只读的。
从生命周期角度:聚合根有独立的生命周期,实体的生命周期从属于其所属的聚合,实体完全由其所属的聚合根负责管理维护。值对象无生命周期可言,因为只是一个值。
聚合根、实体、值对象对象之间如何建立关联
聚合根到聚合根:通过ID关联;
聚合根到其内部的实体,直接对象引用;
聚合根到值对象,直接对象引用;
实体对其他对象的引用规则:
- 能引用其所属聚合内的聚合根、实体、值对象。
- 能引用外部聚合根,但推荐以ID的方式关联,另外也可以关联某个外部聚合内的实体,但必须是ID关联,否则就出现同一个实体的引用被两个聚合根持有,这是不允许的,一个实体的引用只能被其所属的聚合根持有。
值对象对其他对象的引用规则:只需确保值对象是只读的即可,推荐值对象的所有属性都尽量是值对象。
落地工厂
领域模型中的工厂
- 将创建复杂对象和聚合的职责分配给一个单独的对象,它并不承担领域模型中的职责,但是领域设计的一部份
- 对于聚合来说,我们应该一次性的创建整个聚合,并且确保它的不变条件得到满足
- 工厂只承担创建模型的工作,不具有其它领域行为
聚合根中的工厂方法
- 聚合根中的工厂方法表现出了领域概念
- 工厂方法可以提供守卫措施
- 一个含有工厂方法的聚合根的主要职责是完成它的聚合行为
- 在聚合上使用工厂方法能更好的表达通用语言,这是使用构造函数所不能表达的
private ServiceTask(String demandId, String ruleCode, List<ServiceTaskEnum> nodeRules) {
this.demandId = demandId;
this.ruleCode = ruleCode;
//....
}
public static ServiceTask initOnTriggerProcess(String demandId, String ruleCode, List<ServiceTaskEnum> nodeRules) {
return new ServiceTask(demandId, ruleCode, nodeRules);
}
落地资源库
首先聚合和资源库之间是一一对应的关系。资源库只是一种持久化的手段,不应该包含任何业务操作。
第一步,定义资源库接口,接口中有put或save类似的方法
与面向集合的资源库的不同点:面向集合的资源库只有在新增时调用add即可,面向持久化的无论是新增还是修改都要调用save
实现类放在基础设施层,将领域的概念与持久化相关的概念相分离,依赖倒置原则。基础设施层位与所有层之上,并且单向向下引用领域层
public interface BaseRepository<AGGREGATE, ID extends Serializable> {
/**
* 删除
*
* @param id
*/
void delete(ID id);
/**
* load聚合根
*
* @param id
* @return
*/
AGGREGATE load(ID id);
/**
* 保存或更新聚合根
*
* @param aggregate
* @param <S>
* @return
*/
<S extends AGGREGATE> S save(S aggregate);
}
落地应用服务
应用服务是用来表达用例和用户故事(User Story)的主要手段。
应用层通过应用服务接口来暴露系统的全部功能。在应用服务的实现中,它负责编排和转发,它将要实现的功能委托给一个或多个领域对象来实现,它本身只负责处理业务用例的执行顺序以及结果的拼装。通过这样一种方式,它隐藏了领域层的复杂性及其内部实现机制。
应用层相对来说是较“薄”的一层,除了定义应用服务之外,在该层我们可以进行安全认证,权限校验,持久化事务控制,或者向其他系统发生基于事件的消息通知,另外还可以用于创建邮件以发送给客户等。
应用层作为展现层与领域层的桥梁。展现层使用VO(视图模型)进行界面展示,与应用层通过DTO(数据传输对象)进行数据交互,从而达到展现层与DO(领域对象)解耦的目的。
落地领域服务
个人感觉最理想的情况是没有领域服务,如果领域服务使用不恰当慢慢又演化回了以前逻辑都在service层的局面。可以使用领域服务的情况:
- 执行一个显著的业务操作
- 对领域对象进行转换
- 以多个领域对象作为输入参数进行计算,结果产生一个值对象
落地领域事件
- 根据限界上下文的通用语言来命名事件及其属性
- 如果事件由聚合上的命令操作产生,则应该根据操作方法的名字来命名领域事件
- 事件的名字应该反映过去发生的事情
- 领域事件应该都有一个发生时间属性,同时要包括另外的属性:比哪些聚合跟此事件相关,继承统一的DomainEvent接口
- 事件所带的属性能够反映出该事件的来源。事件对象提供getter方法。事件属性应该是只读的,没有setter方法
- 是否有必要消除事件的重复提交
- 一个业务用例对应一个事务,一个事务对应一个聚合根,也即在一次事务中,只能对一个聚合根进行操作。当一个聚合依赖另一个聚合时,可以通过事件实现它们状态的最终一致性
推荐命名为Domain Name + 动词的过去式 + Event。这样比较可以确切的表达业务语义。
/**
* 领域事件基类
*
* @author mazhenjie
* @since 2019-11-05
*/
@Getter
public abstract class BaseDomainEvent<T> implements Serializable {
private static final long serialVersionUID = 1465328245048581896L;
private String demandId;
private Date occurredOn;
private T data;
private DomainEventEnum eventType;
public BaseDomainEvent(String demandId, T data, DomainEventEnum eventType) {
this.demandId = demandId;
this.data = data;
this.eventType = eventType;
this.occurredOn = new Date();
}
}
事件的技术实现我们可以选择:
- spring event、guava event本地事件
- 发到mq上自己在监听
本地事件的话好处是可以较为自由的管理,坏处是会增加你编码的复杂度。要自己实现持久化、异常处理、至少消费一次机制等
@Aspect
@Component
@Slf4j
public class DomainEventStoreAspect {
@Autowired
private DomainEventRepository domainEventRepository;
@AfterReturning(returning = "result", pointcut = "@annotation(org.springframework.transaction.event.TransactionalEventListener)")
public void handleFinish(JoinPoint joinPoint, InnerResult result) {
BaseDomainEvent event = (BaseDomainEvent) joinPoint.getArgs()[0];
DomainEventEntity domainEventEntity = DomainEventEntity.buildByEvent(event);
if (!result.isSuccess()) {
log.error("事件处理异常:{},ex:{}", joinPoint.toString(), result.getMsg());
domainEventEntity.markError();
}
domainEventRepository.saveAndFlush(domainEventEntity);
}
}
发到mq上的话好处是可靠性完全交给中间件去保证,坏处的话是不方便自己管理,占用公共资源。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。