领域模型对象的主力军是实体与值对象。这些实体与值对象又被聚合统一管理起来,形成一个个具有一致生命周期的“命运共同体”自治单元。管理领域模型对象的生命周期,实则就是管理聚合的生命周期。
所谓“生命周期”,就是聚合对象从创建开始,在成长过程中经历各种状态的变化,直至最终消亡的过程。在软件系统中,生命周期经历的各种状态取决于存储介质,分为两个层次:内存与硬盘,分别对应对象的实例化与数据的持久化。
当今的主流开发语言大都具备垃圾回收的功能。因此,除了少量聚合对象可能因为持有外部资源(通常要避免这种情形)而需要手动释放内存资源,在内存层次的生命周期管理,主要牵涉到的工作就是创建。一旦创建了聚合的实例,聚合内部各个实体与值对象的状态变更就都发生在内存中,直到聚合对象因为没有引用而被垃圾回收。
由于计算机没法做到永不宕机,且内存资源相对昂贵,一旦创建好的聚合对象在一段时间用不上,就需要被持久化到外部存储设备中,以避免其丢失,节约内存资源。无论采用什么样的存储格式与介质,在持久化层次,针对聚合对象的生命周期管理不外乎增、删、改、查这4个操作。
从对象的角度看,生命周期代表了一个实例从创建到回收的过程,就像从出生到死亡的生命过程。而数据记录呢?生命周期的起点是指插入一条新记录,该记录被删除就是生命周期的终点。领域模型对象的生命周期将对象与数据记录二者结合起来,换言之就是将内存(堆与栈)管理的对象与数据库(持久化)管理的数据记录结合起来,用二者共同表达聚合的整体生命周期。
在领域模型的设计要素中,由聚合根实体的构造函数或者工厂负责聚合的创建,而后对应数据记录的“增删改查”则由资源库进行管理。
聚合在工厂创建时诞生;为避免内存中的对象丢失,由资源库通过新增操作完成聚合的持久化;若要修改聚合的状态,需通过资源库执行查询,对查询结果进行重建获得聚合;在内存中进行状态变更,然后通过持久化确保聚合对象与数据记录的一致;直到删除了持久化的数据,聚合才真正宣告死亡。
以文章聚合的生命周期为例:
// 创建文章
// 通过Post的工厂方法在内存中创建
Post post = Post.of(title, author, abstract, content);
//持久化到数据库
postRepository.add(post);
// 发布文章
// 根据postId查找数据库的Post,在内存重建Post对象
Post post = postRepository.postOf(postId);
// 内存的操作,内部会改变文章的状态
post.publish();
//将改变的状态持久化到数据库
postRepository.update(post);
// 删除文章
//从数据库中删除指定文章
postRepository.remove(postId);
一、工厂概述
1.工厂与构造函数
许多面向对象语言都支持类通过构造函数创建它自己。
对象自己创建自己,就好像自己扯着自己的头发离开地球表面,完全不合情理,只是开发人员已经习以为常了。然而,构造函数差劲的表达能力与脆弱的封装能力,在面对复杂的构造逻辑时,显得有些力不从心。
遵循“最小知识法则”,我们不能让调用者了解太多创建的逻辑,以免加重其负担,并带来创建代码的四处泛滥,何况创建逻辑在未来很有可能发生变化。基于以上因素考虑,有必要对创建逻辑进行封装。领域驱动设计引入工厂(factory)承担这一职责。
2.工厂与设计模式
工厂是创建产品对象的一种隐喻。《设计模式:可复用面向对象软件的基础》的创建型模式引入了工厂方法(factory method)模式、抽象工厂(abstract factory)模式和构建者(builder)模式,可在封装创建逻辑、保证创建逻辑可扩展的基础上实现产品对象的创建。
除此之外,通过定义静态工厂方法创建产品对象的简单工厂模式也因其简单性得到了广泛使用。
领域驱动设计的工厂并不限于使用哪一种设计模式。一个类或者方法只要封装了聚合对象的创建逻辑,都可以被认为是工厂。
2.工厂的职责
将创建复杂对象和聚合的职责分配给一个单独的对象,该对象本身并不承担领域模型中的职责,但是依然是领域设计的一部分。
工厂应该提供一个创建对象的接口,该接口封装了所有创建对象的复杂操作过程,同时,它并不需要客户去引用那个实际被创建的对象。
除了创建对象之外,工厂并不需要承担领域模型中的其他职责。
对于聚合来说,我们应该一次性地创建整个聚合,并且确保它的不变条件得到满足。
二、工厂的优点
在DDD中,工厂是生产领域模型的地方,特别是聚合。它为用户抽象了对象的创建过程,通过制定专门的语义(通用语言)和更精细地控制对象的实例化过程来达到这一目的。简而言之,工厂模式的主要目的是生产对象的实例并提供给调用者。
工厂的优点如下:
1.解耦:分离领域职责与创建工序
工厂的主要目的是分离模型的领域职责及其复杂的创建工序。模型创建本身就是一个复杂的操作,尤其在面对大而丰富的领域和关系众多的聚合时更是如此。工厂和聚合是天生的好搭档,因为聚合不仅要初始化各类数据,还要体现某种装配规则。把这个任务交给用户,显然超出了他们的意愿与能力范围。
模型本身并不适合承担装配自己的复杂操作,如果将其领域职责与创建逻辑混在一起,则会破坏领域模型的纯洁性。
同时,创建模型的职责也不适合放到应用层中。虽然应用层领域模型的用户将使用领域模型来实现用例和完成需求,但装配方式在某种程度上仍是一种领域逻辑,应用层代表了“业务”,但绝不代表“业务逻辑”。
所以,创建复杂对象是领域层的职责,但同时又不适合放在领域模型内部。因此,我们需要一个单独的领域对象来负责创建模型,不承担其他领域逻辑,这个对象就是工厂。
第一种实现方式:应用层创建对象。
public class CartService {
public void add(Product product , Guid cartId){
cart = cartRepository.of(cartId);
rate = TaxRateService.obtainTaxRateFor(product,country.id);
item = new CartItem(rate,product,id,product.price);
cart.add(item);
}
}
以上是一个跨境电商的购物车对象,添加一个购物项,必须计算相应税率。我们可以看到,应用层必须理解这个逻辑才能够创建购物项,这样领域逻辑就泄露到了应用层。下面是改进后的设计。
第二种实现方式:非工厂领域模型创建对象。
public class CartService {
public void add(Product product, Guid cartId){
cart = cartRepository.of(cartId);
cart.add(product);
}
}
//领域层
public class Cart {
public void add(Product product){
rate = TaxRateService.obtainTaxRateFor(product,country.id);
item = new CartItem(rate,product,id,product.price);
items.add(item);
}
}
这一版使用领域对象Cart来创建购物项,将领域逻辑保留在领域层。然而,购物车对象Cart和税费计算服务TaxRateService之间不可避免地产生了耦合,这破坏了Cart的纯洁性,未来任何与购物车内在逻辑无关的变化都会影响到购物车。这是自找麻烦,因此我们用工厂模式将两者解耦是最佳选择。
第三种实现方式:工厂创建对象。
public class CartService {
public void add(Product product, Guid cartId){
cart = cartRepository.of(cartId);
cart.add(product);
}
}
//领域层
public class Cart {
public void add(Product product){
items.add(CartItemFactory.createCartItemFor(product));
}
}
//工厂
public class CartItemFactory {
public static CartItem createCartItemFor(Product product,Country country){
rate = TaxRateService.obtainTaxRateFor(product,country.id);
item = new CartItem(rate,product,id,product.price);
return item;
}
}
工厂隐藏了创建对象的细节,从而购物车无须再关心与其内在业务逻辑无关的创建信息。
2.通用语言:让创建过程体现业务含义
工厂可以让模型更好地表达通用语言。为什么会这样呢?我们换个思路就明白了,工厂创建实例时,命名方法不一定是GetInstanceOf×××而可以根据通用语言来命名,如BookTicket(预订车票)、ScheduleMeeting(安排会议)、RegisterUser(注册用户)、Offer-Invitation(发送邀请)、ScheduleCalendarEntry(添加日程)。这些操作本质上都是要创建新对象的工厂方法,并且表现力要强得多。
public class Customer{
public Ticket bookTicket(TicketInfo ticketInfo){
Ticket aTicket = new Ticket(this.id,ticketInfo);
DomainPublisher.instance.publish(new TicketBookedEvent);
return aTicket;
}
}
BookTicket是客户对象中的一个工厂方法,用于返回一个车票的实例。我们把创建车票的工厂方法放在了Customer对象中,这与前面的不应将创建方法放入对象的说法并不矛盾,因为Customer是聚合根,在聚合根中放置创建成员的工厂是合适的。并非所有的工厂都是单独的领域服务,只要没产生多余的耦合,就可以灵活选择厂址。
事实上,很多工厂方法都不叫工厂,它们将创建新对象的操作与领域的通用语言相结合,使DDD的模型和代码都更具表现力。
3.验证:确保所创建的聚合处于正确状态
在工厂中创建新对象时,可以添加逻辑验证以确保创建出的聚合符合领域内在规则。
例如,在聚合根账户上创建订单,要满足账户必须有足够的余额。
public class Account {
public Order createOrder() {
//如果余额充足
if(hasEnoughMoney){
return new Order(this.id,this.address);
}else {
//否则抛出异常
throw new MoneyNotEnoughException();
}
}
}
工厂是领域层的对象,在其中包含领域逻辑是合理且必要的。
4.多态:为一种接口生产多个组件
什么是多态?简单来说,多态即“多种实现形态”之意。这些实现形态是针对抽象方法的多种实现形态,它们位于接口和抽象类之中。从业务上来讲,就是一个操作的多种实现方式。一个支持抽象对象的工厂能够根据业务需要灵活地返回所需的具体类,同时使工厂的用户与具体的类完全解耦。这是符合OCP的完美解决方案。
例如,一个订单需要创建一个快递对象,完成该订单的派送。但使用哪一种快递类型(比如不同的快递公司)是由货物和目的地决定的,显然,这个业务逻辑的变化频率很高,它不适合放在订单对象内,交由工厂来创建对应的快递对象比较合适。
public class DeliveryFactory {
public static Delivery getDeliveryFor(){
if(isRemote()){
return new EMSDelivery();
}
if(isCommonProduct()){
return new YuanTongDelivery();
}else {
return new ShunfengDelivery();
}
}
}
其中,EMSDelivery、YuanTongDelivery、ShunfengDelivery都是具体的执行类,按照一定的业务逻辑由DeliveryFactory返回。它们都是Delivery抽象类的子类,因此可以完成订单中定义的Delivery对象的任务,而订单无须关心是谁来完成,创建订单类的程序员也可以不考虑这个复杂的问题。
把选择逻辑分离在工厂内,减轻了模型的负担,并使领域模型符合开闭原则,通过针对接口(抽象类)编程,订单不再关心具体类,因此后续我们修改快递选择逻辑或扩展快递类型时,都无须修改订单模型,极大提升了模型的稳定性。
5.重建:重建已存储的对象
虽然DDD不关注技术复杂性,但领域模型实例会被持久化存储在数据库中或序列化成文件以供网络传输。工厂的最后一个目的就是重建已存储的对象,将散布在文件或数据库中的各个部分重新组装成一个可用的对象。
不能要求模型的用户在创建模型时除了调用代码还要做其他的工作,比如访问数据库或文件,也不应让他们生成资源文件。因此,基于持久化机制的对象重建一定要封装在工厂之内。
重建工厂与创建新对象的工厂有以下两个不同点:
- 创建实体对象时,新对象是生成新的标识符,而重建工厂则是获取已有的标识符ID。
- 模型或聚合的内在领域逻辑不满足时,新对象工厂可以直接拒绝生成对象,而重建工厂生成的对象违背规则时,需要设计师采用一种纠错机制,比如默认值等策略来处理冲突。
三、工厂的实现方式
工厂的实现主要表现为以下形式:
- 由被依赖聚合担任工厂;
- 引入专门的聚合工厂;
- 聚合自身担任工厂;
- 消息契约模型或装配器担任工厂;
- 使用构建者组装聚合。
1.由被依赖聚合担任工厂
领域驱动设计虽然建议引入工厂创建聚合,但并不要求必须引入专门的工厂类,而是可由一个聚合担任另一个“聚合的工厂”。担任工厂角色的聚合称为“聚合工厂”,被创建的聚合称为“聚合产品”。聚合工厂往往由被引用的聚合来承担,如此就可以将自己拥有的信息传给被创建的聚合产品。例如,Blog聚合可以作为Post聚合的工厂:
//博客的聚合
public class Blog {
//工厂方法是实例方法,无须传入id
public Post createPost(String title, String content){
//这里的id是Blog的id,通过value()传入Post,建立与Blog的关联
return new Post(this.id.value(),title,content,this.authorId);
}
}
PostService领域服务作为调用者,可通过Blog聚合创建文章:
public class PostService {
public void writePost(String title,String content){
private BlogRepository blogRepository;
private PostRepository postRepository;
Blog blog = blogRepository.blogOf(BlogId.of(blogId));
Post post = blog.createPost(title,content);
}
}
由于创建方法会产生聚合工厂与聚合产品之间的依赖,若二者位于不同限界上下文,遵循菱形对称架构的要求,应当避免这一设计。
2.引入专门的聚合工厂
当创建的聚合属于一个多态的继承体系时,构造函数就无能为力了。例如,航班Flight聚合本身形成了一个继承体系,并组成图所示的聚合:
根据进出港标志,可确定该航班针对当前机场究竟为进港航班还是离港航班,从而创建不同的子类。由于子类的构造函数无法封装这一创建逻辑,我们又不能将创建逻辑的判断职责“转嫁”给调用者,就有必要引入专门的FlightFactory工厂类:
public class FlightFactory {
public static Flight createFlight(String flightId,String ioFlag,String airportCode){
if(ioFlag.equals("A")){
return new ArrivalFlight(flightId,airportCode);
}else {
return new DepartualFlight(flightId,airportCode);
}
}
}
由于不建议聚合依赖于访问外部资源的端口,引入专门工厂类的另一个好处是可以通过它依赖端口获得创建聚合时必需的值。
例如,在创建跨境电商平台的商品聚合时,海外商品的价格采用了不同的汇率,在创建商品时,需要将不同的汇率按照当前的汇率牌价统一换算为人民币。汇率换算器ExchangeRateConverter需要调用第三方的汇率换算服务,实际上属于商品上下文南向网关的客户端端口。工厂类ProductFactory会调用它:
public class ProductFactory {
@Autowired
private ExchangeRateConverter converter;
public Product createProduct(String name, String description , Price price){
Money valueOfPrice = converter.convert(price.getValue());
return new Product(name,description,new Price(valueOfPrice));
}
}
由于需要通过依赖注入将适配器实现注入工厂类,故而该工厂类定义的工厂方法为实例方法。为了防止调用者绕开工厂直接实例化聚合,可考虑将聚合根实体的构造函数声明为包范围内限制,并将聚合工厂与聚合产品放在同一个包。
3.聚合自身担任工厂
聚合产品自身也可以承担工厂角色。这是一种典型的简单工厂模式,例如由Order类定义静态方法,封装创建自身实例的逻辑:
public class Order {
private Order(CustomerId customerId,ShippingAddress shippingAddress,Basket basket){...}
public static Order createOrder(CustomerId customerId,ShippingAddress shippingAddress,Basket basket){
return new Order(customerId,shippingAddress,basket);
}
}
这一设计方式无须多余的工厂类,创建聚合对象的逻辑也更加严格。由于静态工厂方法属于产品自身,因此可将聚合产品的构造函数定义为私有。调用者除了通过公开的工厂方法获得聚合对象,别无他法可寻。
当聚合作为自身实例的工厂时,该工厂方法不必死板地定义为create××× ()。可以使用诸如of()、instanceOf()等方法名,使得调用代码看起来更加自然:
Order order = Order.of(customerId,shippingAddress,basket);
不只聚合的工厂,对于领域模型中的实体与值对象(包括ID类),都可以考虑定义这样具有业务含义或提供自然接口的静态工厂方法,使得创建逻辑变得更加合理而贴切。
4.消息契约模型或装配器担任工厂
设计服务契约时,如果远程服务或应用服务接收到的消息是用于创建的命令请求,则消息契约与领域模型之间的转换操作,实则是聚合的工厂方法。
例如,买家向目标系统发起提交订单的请求就是创建Order聚合的命令请求。该命令请求包含了创建订单需要的客户ID、配送地址、联系信息、购物清单等信息,这些信息被封装到PlacingOrderRequest消息契约模型对象中。
响应买家请求的是OrderController远程服务,它会将该消息传递给应用服务,再进入领域层发起对聚合的创建。应用服务在调用领域服务时,需要将消息契约模型转换为领域模型,也就是调用消息契约模型的转换方法toOrder()。它实际上就是创建Order聚合的工厂方法:
public class PlacingOrderRequest {
//创建Order聚合的工厂方法
public Order toOrder(){}
}
public class OrderAppService{
private OrderService orderService;
@Transactional
public void placeOrder(PlacingOrderRequest orderRequest){
orderService.placeOrder(orderRequest.toOrder());
}
}
如果消息契约模型持有的信息不足以创建对应的聚合对象,可以在北向网关层定义专门的装配器,将其作为聚合的工厂。它可以调用南向网关的端口获取创建聚合需要的信息。
5.使用构建者组装聚合
聚合作为相对复杂的自治单元,在不同的业务场景可能需要有不同的创建组合。一旦需要多个参数进行组合创建,构造函数或工厂方法的处理方式就会变得很笨拙,需要定义各种接收不同参数的方法响应各种组合方式。构造函数尤为笨拙,毕竟它的方法名是固定的。如果构造参数的类型与个数一样,含义却不相同,构造函数更是无能为力。
构建者模式有两种实现风格。一种风格是单独定义Builder类,由它对外提供组合构建聚合对象的API。单独定义的Builder类可以与产品类完全分开,也可以定义为产品类的内部类。例如对航班聚合对象的创建:
public class Flight{
private String flightNo;
private Carrier carrier;
private Gate boardingGate;
private LocalDate flightDate;
public Builder prepareBuilder(String flightNo){
return new Builder(flightNo);
}
public class Builder {
private String flightNo;
private Carrier carrier;
private Builder(String flightNo){
this.flightNo = flightNo;
}
public Builder beCarriedBy(String airlineCode){
Carrier carrier = new Carrier(airlineCode);
return this;
}
public Builder boardingOn(String airlineCode){
boardingGate= new Gate(airlineCode);
return this;
}
public Builder flightDate(LocalDate flyingInDate){
flightDate = flyingInDate;
return this;
}
public Flight build(){
return new Flight(this);
}
}
private Flight(Builder builder){
flightNo = builder.flightNo;
carrier= builder.carrier;
boardingGate = builder.boardingGate;
flightDate = builder.flightDate;
}
}
客户端可以使用如下的流畅接口创建Flight聚合:
Flight flight = Flight.prepareBuilder("CA4116")
.beCarriedBy("CA")
.boardingOn("C29")
.flyingIn(LocalData.of(2019,8,8))
.build();
构建者的构建方法可以对参数施加约束条件,避免非法值传入。在上述代码中,由于实体属性大多数被定义为值对象,故而构建方法对参数的约束被转移到了值对象的构造函数中。定义构建方法时,要结合自然语言风格与领域逻辑为方法命名,使得调用代码看起来更像进行一次英文交流。
另一种实现风格是由被构建的聚合对象担任近乎Builder的角色,然后将可选的构造参数定义到每个单独的构建方法中,并返回聚合对象自身以形成流畅接口。仍然以Flight聚合根实体为例:
public class Flight{
private String flightNo;
private Carrier carrier;
//聚合必备的参数需要在构造函数中给出
private Flight(String flightNo){
this.flightNo = flightNo;
}
public static Flight withFlightNo(String flightNo){
return new Flight(flightNo);
}
public Flight beCarriedBy(String airlineCode){
this.carrier = new Carrier(airlineCode);
return this;
}
public Builder boardingOn(String airlineCode){
this.boardingGate= new Gate(airlineCode);
return this;
}
public Builder flightDate(LocalDate flyingInDate){
this.flightDate = flyingInDate;
return this;
}
}
相较于第一种风格,它的构建方式更为流畅。从调用者角度看,它没有显式的构建者类,也没有强制要求在构建最后调用build()方法:
Flight flight = Flight.withFlightNo("CA4116")
.beCarriedBy("CA")
.boardingOn("C29")
.flyingIn(LocalData.of(2019,8,8));
四、厂址的选择
1.聚合根担任工厂
应用场景:对象属于聚合根内部,与聚合根”同生共死“
如果往一个聚合内添加元素,可以在聚合根上添加一个工厂方法,这样聚合内部的元素的生成细节,外部就无须关心了。同时,因为聚合的内在原则检查都在聚合根内,所以可以保证添加的元素都符合领域内在规则。
例如,订单的聚合根上创建订单项、支付对象等
public class Order implements AggregateRoot {
private List<OrderItem> orderItems;
private Pay pay;
public Pay createPay(String orderId){
return new Pay(orderId);
}
}
基于聚合成员与聚合根的“同生共消亡”的特性,将工厂建在聚合根上是合理的,可以检查生成的成员对象是否符合内在逻辑。
这时候注意聚合根内部成员的类应该在同一包中,且构造函数应该为protected,保证只能让聚合根才能创建该对象。
2.”信息专家“工厂
信息专家模式是把职责分配给具有完成该职责所需信息的那个模型。我们在分配模型职责的时采用的是信息专家模式,这个模式也可以用在厂址的选择上。
比如,如果一个对象A拥有创建另一个对象B所需的信息,则可以在A上构建一个工厂方法用于创建B。这种就近原则可以避免把A的信息提取到其他地方,增加不必要的复杂性,最重要的是往往B和A有深层的领域连接逻辑,可以通过工厂得以体现。与上面聚合根上工厂的区别在于,A和B不一定属于一个聚合。
应用场景:聚合根之间有自然的业务流转关系
例如,我们把购物车添加进来。虽然购物车不属于订单聚合,但按照信息专家原则,它拥有创建订单所需的所有信息,依然可以负责订单的创建。
public class Cart implements AggregateRoot {
private List<CartItem> cartItems;
public Order createOrder(List<CartItem> cartItems){
return new order(cartItems);
}
}
在购物车对象上定义订单的工厂方法是再自然不过的了,这样做并没有增加购物车的负担,也没有增加耦合度。否则,我们需要把购物车的信息提取到额外的对象中,这会模糊购物车到订单这种自然的业务流转关系。
3.领域服务类工厂
将工厂单独地构建为领域服务是一种不错的方法,也是最常用的工厂形式。
前两种厂址的选择方式必须在不增加模型负担和耦合度,且创建新对象的流程比较自然的情况下才可以进行。如果这种衔接关系并不明显,且会影响模型的职责,增加耦合度,那么还是要秉持初衷,分离模型的领域职责及其创建环节,构建单独的工厂对象来创建复杂对象和聚合,实现形式一般是领域服务。整个聚合的创建由一个单独的工厂完成,工厂负责把对根的引用传递出去,并确保创建出的聚合满足领域内在规则。
应用场景:连接不同的限界上下文
应用场景之一是把一个不同上下文的模型翻译成另一个上下文的模型:
public class CustomerCreatorService {
private List<CartItem> cartItems;
public Customer CustomerFrom(String loginId){
return cusomerRepository.findBy(loginId);
}
}
应用场景:创建对象需要引用外部接口
参考前面代码,若创建对象需要引用外部资源接口,例如需要rpc请求等,则建议用专门的领域服务类工厂进行封装。
应用场景:为一种接口生产多个组件
参考前面代码,把多态的选择逻辑分离在工厂内,减轻了模型的负担,并使领域模型符合开闭原则,通过针对接口(抽象类)编程,调用者不再关心具体类,因此后续我们修改快递选择逻辑或扩展快递类型时,都无须修改领域模型,极大提升了模型的稳定性。
4.只需使用构造函数的场合
是否任何模型的创建都要经过工厂呢?恰恰相反,我们应该优先使用构造函数而不是工厂,因为领域模型并不一定都是复杂对象或聚合。如果在不需要解耦、不需要创建聚合、不需要表达通用语言、没有内在规则或不需要多态的场合,应该直接使用构造函数new,因为构造函数更简单、方便。另外,没有参与工厂建模的团队成员可能意识不到工厂的存在,而直接使用构造函数,这也是模型构建团队需要注意的地方。
具体来说,若满足以下条件,则可直接选择简单的、公共构造函数。
- 对象是值对象,且不是任何相关层次结构的一部分,而且不需要创建对象多态性。
- 客户关心的是具体类,而不是只关心接口。
- 客户可以访问对象的所有属性,且模型没有嵌套对象的创建。
- 构造环节并不复杂,客户端创建代价不高。
- 构造函数必须满足工厂的相同规则:创建过程必须是一个原子操作,且能满足领域内在规则。
5.厂名选择的注意事项
- 1)选择与领域含义相关的命名,如BookTicket(预订车票)、ScheduleMeeting(安排会议)。
- 2)将Create与要创建的类型名连在一起,以此来命名工厂方法,如CreateWhiteBoard。
- 3)将创建的类型名与Factory连接在一起,以此来命名工厂类型。例如,可以将创建Role对象的工厂类型命名为RoleFactory。
五、资源库
资源库(repository)是对数据访问的一种业务抽象。在菱形对称架构中,它是南向网关的端口,可以解耦领域层与外部环境,使领域层变得更为纯粹。资源库可以代表任何可以获取资源的仓库,例如网络或其他硬件环境,而不局限于数据库。
领域驱动设计引入资源库,主要目的是管理聚合的生命周期。工厂负责聚合实例的诞生,垃圾回收负责聚合实例的消亡,资源库就负责聚合记录的查询与状态变更,即“增删改查”操作。资源库分离了聚合的领域行为和持久化行为,保证了领域模型对象的业务纯粹性。它和其他端口一起,成为隔离业务复杂度与技术复杂度的关键。
1.一个聚合一个资源库
聚合是领域建模阶段的基本设计单元,因此,管理领域模型对象生命周期的基本单元就是聚合,领域驱动设计规定:一个聚合对应一个资源库。如果要访问聚合内的非根实体,也只能通过资源库获得整个聚合后,将根实体作为入口,在内存中访问封装在聚合边界内的非根实体对象。
“我们可以通过对象之间的关联来找到对象。但当它处于生命周期的中间时,必须要有一个起点,以便从这个起点遍历到一个实体或者对象。” ——Eric Evans
这个所谓的“起点”,就是通过资源库查询重建后得到聚合对象的那个点,因为只有在这个时候,我们才能获得聚合对象,并以此为起点遍历聚合的根实体及内部的实体和值对象。
资源库与数据访问对象的区别
同样都是访问数据,资源库与数据访问对象(data access object,DAO)有何区别呢?
数据访问对象封装了管理数据库连接以及存取数据的逻辑,对外为调用者提供了统一的访问接口。在为数据访问对象建立抽象接口后,利用依赖注入改变依赖方向,即可解除领域层对数据访问技术细节的依赖,满足“整洁架构”思想,隔离业务逻辑与数据访问逻辑。从对技术的隔离和访问逻辑的职责分配来看,二者没有区别。
根本区别在于,数据访问对象在访问数据时,并无聚合的概念,也就是没有定义聚合的边界约束领域模型对象,使得数据访问对象的操作粒度可以针对领域层的任何模型对象。这就为调用者打开了“方便之门”,使其能够自由自在地操作实体和值对象。没有聚合边界控制的数据访问,会在不经意间破坏领域概念的完整性,突破聚合不变量的约束,也无法保证聚合对象的独立访问与内部数据的一致性。
资源库是完美匹配聚合的设计模式,要管理一个聚合的生命周期,不能绕开资源库。同时,资源库也不能绕开聚合根实体直接操作聚合边界内的其他非根实体。
例如,要为订单添加订单项,不能为OrderItem定义专门的资源库。如下做法是错误的:
OrderItemRepository oderItemRepo;
orderItemRepo.add(orderId, orderItem);
OrderItem作为Order聚合的内部实体,添加订单项要以Order根实体作为唯一的操作入口:
OrderRepository orderRepo;
Order order = orderRepo.orderOf(orderId).get(); //orderOf()返回的是Optional<Order>
order.addItem(orderItem);
orderRepo.update(order);
在引入聚合与资源库后,对聚合内部实体的操作,应从对象模型的角度考虑。通过Order聚合根的addItem()方法实现对订单项的添加,亦可保证订单领域概念的完整性,满足不变量。例如,该方法可以判断要添加的OrderItem对象是否有效,并根据OrderItem中的productId判断究竟是添加订单项,还是合并订单项,然后修改订单项中所购商品的数量。
2.资源库端口的定义
资源库作为端口,可以视为存取聚合资源的容器。
Eric Evans认为:“它(指资源库)的行为类似于集合(collection),只是具有更复杂的查询功能。在添加和删除相应类型的对象时,资源库的后台机制负责将对象添加到数据库中,或从数据库中删除对象。这个定义将一组紧密相关的职责集中在一起,这些职责提供了对聚合根的整个生命周期的全程访问。”
既然认为资源库是“聚合集合”的隐喻,在设计资源库端口时,亦可参考此特征定义接口方法的名称。例如,定义通用的Repository:
public interface Repository<T extends AggregateRoot> {
// 查询
Optional<T> findById(Identity id);
List<T> findAll();
List<T> findAllMatching(Criteria criteria);
boolean contains(T t);
// 新增
void add(T t);
void addAll(Collection<? extends T> entities);
// 更新
void replace(T t);
void replaceAll(Collection<? extends T> entities);
// 删除
void remove(T t);
void removeAll();
void removeAll(Collection<? extends T> entities);
void removeAllMatching(Criteria criteria);
}
资源库端口定义的接口使用了泛型,泛型约束为AggregateRoot类型,它的接口方法涵盖了与聚合生命周期有关的所有“增删改查”操作。理论上,所有聚合的资源库都可以实现该接口,如Order聚合的资源库为Repository<Order>
。根据ORM框架持久化机制的不同,可以为Repository<T>
接口提供不同的实现。
这么一个通用的资源库接口看似美好,实则具有天生的缺陷。
其一,并非所有聚合的资源库都愿意拥有大而全的资源库接口方法。例如,Order聚合不需要删除方法,又或者虽然对外公开为delete(),内部却按照需求执行了订单状态的变更操作。该如何让Repository<Order>
满足这一特定需求?
其二,过于通用的接口无法体现特定的业务需求。接口定义的查询或删除方法可以接收条件参数Criteria,目的是满足各种不同的查询与删除需求,但Criteria的组装无疑加重了调用者的负担。例如,查询指定顾客正在处理中的订单:
Criteria customerIdCriteria = new EquationCriteria("customerId", customerId);
Criteria inProgressCriteria = new EquationCriteria("orderStatus", OrderStatus.InProgress);
orderRepository.findAllMatching(customerIdCriteria.and(inProgressCriteria));
虽然通用的资源库接口有种种不足,但它的通用意义与复用价值仍有可取之处。要在复用、封装和代码可读性之间取得平衡,需将南向网关的端口与适配器视为两个不同的关注点。
扮演端口角色的资源库接口面向以聚合为基本自治单元的领域逻辑,扮演适配器角色的资源库实现则面向持久化框架,负责完成整个聚合的生命周期管理。由于通用的资源库接口未体现业务含义,不应视为资源库端口的一部分,需转移到适配器层,被不同的资源库适配器复用。
以订单聚合为例。它的资源库端口面向聚合:
package com.dddexplained.ecommerce.ordercontext.southbound.port.repository;
public interface OrderRepository {
// 查询方法的命名更加倾向于自然语言,不必体现find的技术含义
Optional<Order> orderOf(OrderId orderId);
// 以下两个方法在内部实现时,需要组装为通用接口的criteria
Collection<Order> allOrdersOfCustomer(CustomerId customerId);
Collection<Order> allInProgressOrdersOfCustomer(CustomerId customerId);
void add(Order order);
void addAll(Iterable<Order> orders);
// 业务上是更新(update),而非替换(replace)
void update(Order order);
void updateAll(Iterable<Order> orders);
// 根据订单的需求,不提供删除方法
}
对应的资源库适配器提供了具体的实现:
package com.dddexplained.ecommerce.ordercontext.southbound.adapter.repository;
public class OrderRepositoryAdapter implements OrderRepository {
// 以委派形式复用通用的资源库接口
private Repository<Order, OrderId> repository;
// 注入真正的资源库实现
public OrderRepositoryAdapter(Repository<Order, OrderId> repository) {
this.repository = repository;
}
public Optional<Order> orderOf(OrderId orderId) {
return repository.findById(orderId);
}
public Collection<Order> allOrdersOfCustomer(CustomerId customerId) {
// 封装了组装查询条件的逻辑
Criteria customerIdCriteria = new EquationCriteria("customerId", customerId);
return repository.findAllMatching(customerIdCriteria);
}
public Collection<Order> allInProgressOrdersOfCustomer(CustomerId customerId) {
Criteria customerIdCriteria = new EquationCriteria("customerId", customerId);
Criteria inProgressCriteria = new EquationCriteria("orderStatus",OrderStatus.
InProgress);
return repository.findAllMatching(customerIdCriteria.and(inProgressCriteria));
}
public void add(Order order) {
repository.save(order);
}
public void addAll(Collection<Order> orders) {
repository.saveAll(orders);
}
public void update(Order order) {
repository.save(order);
}
public void updateAll(Collection<Order> orders) {
repository.saveAll(orders);
}
}
OrderRepositoryAdapter适配器注入通用的资源库接口,实际上是将持久化的实现委派给了通用资源库接口的实现类。既然通用的资源库接口不再面向领域层的聚合,设计时就无须考虑所谓“集合”的隐喻,可以根据持久化实现机制的要求,将add()操作与replace()操作合二为一,用save()方法代表。接口方法的命名也可以遵循数据库操作的通用叫法,如删除操作仍然命名为delete(),以下是修改后的资源库通用接口:
public interface Repository<E extends AggregateRoot, ID extends Identity> {
Optional<E> findById(ID id);
List<E> findAll();
List<E> findAllMatching(Criteria criteria);
boolean exists(ID id);
void save(E entity);
void saveAll(Collection<? extends E> entities);
void delete(E entity);
void deleteAll();
void deleteAll(Collection<? extends E> entities);
void deleteAllMatching(Criteria criteria);
}
资源库端口、资源库适配器和通用资源库(包括接口与实现)组成了南向网关的资源库网关层。它们各自承担自己的职责,在限界上下文的南向网关中扮演各自的角色,既做到了对聚合生命周期管理的可读接口定义,又做到了业务逻辑与技术实现的隔离,还在一定程度上满足了持久化实现的复用要求。
领域服务OrderService
调用OrderRepository
端口管理Order聚合,端口的实现则为资源库适配器OrderRepositoryAdapter
,通过依赖注入。为避免重复实现,在OrderRepositoryAdapter
类的内部,持久化的真正工作又委派给了通用接口Repository<T>
,实现了Repository<T>
接口的具体类再完成聚合的生命周期管理。
针对资源库查询方法的设计,社区存在争议。大致可分为两派。
一派支持设计简单通用的资源库查询接口,让资源库回归本质,老老实实做好查询的工作。条件查询接口保持通用性,将查询条件的组装工作交由调用者,不然,资源库接口就需要穷举所有可能的查询条件。一旦业务增加了新的查询条件,就要修改资源库接口。如订单聚合的接口定义,在定义了allInProgressOrdersOfCustomer(customerId)方法之后,是否意味着还需要定义allCancelledOrdersOfCustomer(customerId)之类的方法呢?
另一派坚持将查询接口明确化,根据资源库的个性需求定义查询方法,方法命名也体现了领域逻辑。封装了查询条件的查询接口不会将Criteria泄露出去,归根结底,Criteria的定义本身并不属于领域层。这样的查询方法既有其业务含义,又能通过封装减轻调用者的负担。
两派观点各有其道理。一派以通用性换取接口的可扩展,却牺牲了接口方法的可读性;另一派以封装获得接口的可读性,却因为方法过于具体导致接口膨胀与不稳定。
此外,查询接口的具体化与抽象化也可折中。如查询“处理中”与“已取消”的订单,差异在于被查询订单的状态,故可将订单状态提取为查询方法的参数:
Collection<Order> allOrdersOf(CustomerId customerId, OrderStatus orderStatus);
从资源库的调用角度分析,资源库的调用者包括领域服务和应用服务。如果没有严格地设计约束限制应用服务与资源库之间的协作,一旦资源库提供了通用的查询接口,就会将组装查询条件的代码混入应用层,违背了保持应用层“轻薄”的原则。要么限制资源库的通用查询接口,要么限制应用层直接依赖资源库,如何取舍,还得结合具体业务场景做出最适合当前情况的判断。
资源库的条件查询接口设计还有第三条路可走,即引入规格(specification)模式来封装查询条件。查询条件与规格模式是两种不同的设计模式。
查询条件是一种表达式,采用了解释器(interpreter)模式的设计思想,为逻辑表达式建立统一的抽象(如前所示的Criteria接口),然后将各种原子条件表达式定义为表达式子类(如前所示的AndCriteria类)。这些子类会实现解释方法,将值解释为条件表达式。
规格模式是策略(strategy)模式的体现,为所有规格定义一个共同的接口,如Specification接口的isSatisfied()方法。各个规格子类实现该方法,结合规则返回Boolean值。
相较于查询条件表达式,规格模式的封装性更好。可以按照业务规则定义不同的规格子类,并且通过规格接口做到对领域规则的扩展,但业务规则的组合可能带来规格子类的数量产生爆炸性增长。与之相反,查询条件的设计方式着重寻找原子表达式,然后将组装的职责交由调用者,因此能够更加灵活地应对各种业务规则的变化,但欠缺足够的封装,将条件的组装逻辑暴露在外,加重了调用者的负担,也容易带来组装逻辑的重复。
如果系统采用CQRS模式将查询与命令分离,则在命令模型的资源库中,除了保留根据聚合根实体ID获得聚合的查询方法,其余查询方法皆转移到了查询模型。CQRS模式的查询模型不再使用领域模型,也就没有了聚合的概念,可以自由自在地运用数据访问对象模式,甚至支持直接编写SQL语句。故而,CQRS模式的查询接口不在资源库的讨论之列。
3.资源库实现注意事项
(1)不要提供无条件随机查询接口
无条件随机查询接口类似于我们前面提到的IEnumerable FindAllThatMatch(string sql)接口,使用者可以通过查询字符串编写任何类型的查询。操作缺乏业务场景,给了使用者无限的自由。
(2)可以像工厂一样在资源库中使用多态,返回子类或实现类
资源库也可以像工厂的多态机制一样,不一定返回固定的子类,而是返回一个层次结构中的抽象基类和接口。但由于数据库缺乏这样的多态机制,设计这种功能时需要做很多额外的工作。
(3)充分利用资源库接口与实现解耦的特点
这种特点可以方便我们自由地切换持久化底层技术。如果这种切换让开发者为难,那么很可能是底层的持久化技术浸入到了领域层,破坏了领域模型的独立性,要认真梳理问题所在。同时,通过一个易于操控的内存中的模拟实现(比如用集合类模拟访问数据库),还能够方便测试阶段对领域对象进行测试。
(4)资源库中不要涉及事务
尽管资源库执行数据库的插入和删除操作,但通常不会提交事务。事务的管理不应该出现在领域模型和领域层中。与领域模型的相关操作通常是非常细粒度的,不适合管理事务。此外,领域模型也不应意识到事务的存在,事务管理可以在应用层和基础设施层中进行。