Martin Fowler在《企业应用架构模式》一书中写道:html
I found this(business logic) a curious term because there are few things that are less logical than business logic.
初略翻译过来能够理解为:业务逻辑是很没有逻辑的逻辑。java
的确,不少时候软件的业务逻辑是没法经过推理而获得的,有时甚至是被臆想出来的。这样的结果使得本来已经很复杂的业务变得更加复杂而难以理解。而在具体编码实现时,除了应付业务上的复杂性,技术上的复杂性也不能忽略,好比咱们要讲究技术上的分层,要遵循软件开发的基本原则,又好比要考虑到性能和安全等等。mysql
在不少项目中,技术复杂度与业务复杂度相互交错纠缠不清,这种火上浇油的作法成为很多软件项目没法继续往下演进的缘由。然而,在合理的设计下,技术和业务是能够分离开来或者至少它们之间的耦合度是能够下降的。在不一样的软件建模方法中,领域驱动设计(Domain Driven Design,DDD)尝试经过其自有的原则与套路来解决软件的复杂性问题,它将研发者的目光首先聚焦在业务自己上,使技术架构和代码实现成为软件建模过程当中的“副产品”。git
DDD分为战略设计和战术设计。在战略设计中,咱们讲求的是子域和限界上下文(Bounded Context,BC)的划分,以及各个限界上下文之间的上下游关系。当前如此火热的“在微服务中使用DDD”这个命题,究其最初的逻辑无外乎是“DDD中的限界上下文能够用于指导微服务中的服务划分”。事实上,限界上下文依然是软件模块化的一种体现,与咱们一直以来追求的模块化原则的驱动力是相同的,即经过必定的手段使软件系统在人的大脑中更加有条理地呈现,让做为“目的”的人可以更简单地了解进而掌控软件系统。程序员
若是说战略设计更偏向于软件架构,那么战术设计便更偏向于编码实现。DDD战术设计的目的是使得业务可以从技术中分离并突显出来,让代码直接表达业务的自己,其中包含了聚合根、应用服务、资源库、工厂等概念。虽然DDD不必定经过面向对象(OO)来实现,可是一般状况下在实践DDD时咱们采用的是OO编程范式,行业中甚至有种说法是“DDD是OO进阶”,意思是面向对象中的基本原则(好比SOLID)在DDD中依然成立。本文主要讲解DDD的战术设计。github
本文以一个简单的电商订单系统为例,经过如下方式能够获取源代码:sql
git clone https://github.com/e-commerce-sample/order-backend git checkout a443dace
在讲解DDD以前,让咱们先来看一下实现业务代码的几种常见方式,在示例项目中有个“修改Order中Product的数量”的业务需求以下:数据库
能够修改Order中Product的数量,但前提是Order处于未支付状态,Product数量变动后Order的总价(totalPrice)应该随之更新。
这种方式当前被不少软件项目所采用,主要的特色是:存在一个贫血的“领域对象”,业务逻辑经过一个Service类实现,而后经过setter方法更新领域对象,最后经过DAO(多数状况下可能使用诸如Hibernate之类的ORM框架)保存到数据库中。实现一个OrderService类以下:编程
@Transactional public void changeProductCount(String id, ChangeProductCountCommand command) { Order order = DAO.findById(id); if (order.getStatus() == PAID) { throw new OrderCannotBeModifiedException(id); } OrderItem orderItem = order.getOrderItem(command.getProductId()); orderItem.setCount(command.getCount()); order.setTotalPrice(calculateTotalPrice(order)); DAO.saveOrUpdate(order); }
这种方式依然是一种面向过程的编程范式,违背了最基本的OO原则。另外的问题在于职责划分模糊不清,使本应该内聚在Order
中的业务逻辑泄露到了其余地方(OrderService
), 致使Order
成为一个只是充当数据容器的贫血模型(Anemic Model),而非真正意义上的领域模型。在项目持续演进的过程当中,这些业务逻辑会分散在不一样的Service类中,最终的结果是代码变得愈来愈难以理解进而逐渐丧失扩展能力。json
在上一种实现方式中,咱们会发现领域对象(Order
)存在的惟一目的实际上是为了让ORM这样的工具可以一次性地持久化,在不使用ORM的状况下,领域对象甚至都没有必要存在。因而,此时的代码实现便退化成了事务脚本(Transaction Script),也就是直接将Service类中计算出的结果直接保存到数据库(或者有时都没有Service类,直接经过SQL实现业务逻辑):
@Transactional public void changeProductCount(String id, ChangeProductCountCommand command) { OrderStatus orderStatus = DAO.getOrderStatus(id); if (orderStatus == PAID) { throw new OrderCannotBeModifiedException(id); } DAO.updateProductCount(id, command.getProductId(), command.getCount()); DAO.updateTotalPrice(id); }
能够看到,DAO中多出了不少方法,此时的DAO再也不只是对持久化的封装,而是也会包含业务逻辑。另外,DAO.updateTotalPrice(id)
方法的实现中将直接调用SQL来实现Order总价的更新。与“Service+贫血模型”方式类似,事务脚本也存在业务逻辑分散的问题。
事实上,事务脚本并非一种全然的反模式,在系统足够简单的状况下彻底能够采用。可是:一方面“简单”这个度其实并不容易把握;另外一方面软件系统一般会在不断的演进中加入更多的功能,使得本来简单的代码逐渐变得复杂。所以,事务脚本在实际的应用中使用得并很少。
在这种方式中,核心的业务逻辑被内聚在行为饱满的领域对象(Order
)中,实现Order
类以下:
public void changeProductCount(ProductId productId, int count) { if (this.status == PAID) { throw new OrderCannotBeModifiedException(this.id); } OrderItem orderItem = retrieveItem(productId); orderItem.updateCount(count); }
而后在Controller或者Service中,调用Order.changeProductCount()
:
@PostMapping("/order/{id}/products") public void changeProductCount(@PathVariable(name = "id") String id, @RequestBody @Valid ChangeProductCountCommand command) { Order order = DAO.byId(orderId(id)); order.changeProductCount(ProductId.productId(command.getProductId()), command.getCount()); order.updateTotalPrice(); DAO.saveOrUpdate(order); }
能够看到,全部业务(“检查Order状态”、“修改Product数量”以及“更新Order总价”)都被包含在了Order
对象中,这些正是Order
应该具备的职责。(不过示例代码中有个地方明显违背了内聚性原则,下文会讲到,做为悬念读者能够先行尝试着找一找)
事实上,这种方式与本文要讲的DDD战术模式已经很相近了,只是DDD抽象出了更多的概念与原则。
所谓基于业务分包即经过软件所实现的业务功能进行模块化划分,而不是从技术的角度划分(好比首先划分出service
和infrastruture
等包)。在DDD的战略设计中,咱们关注于从一个宏观的视角俯视整个软件系统,而后经过必定的原则对系统进行子域和限界上下文的划分。在战术实践中,咱们也经过相似的提纲挈领的方法进行总体的代码结构的规划,所采用的原则依然逃离不了“内聚性”和“职责分离”等基本原则。此时,首先映入眼帘的即是软件的分包。
在DDD中,聚合根(下文会讲到)是主要业务逻辑的承载体,也是“内聚性”原则的典型表明,所以一般的作法即是基于聚合根进行顶层包的划分。在示例电商项目中,有两个聚合根对象Order
和Product
,分别建立order
包和product
包,而后在各自的顶层包下再根据代码结构的复杂程度划分子包,好比对于product
包:
└── product
├── CreateProductCommand.java
├── Product.java
├── ProductApplicationService.java
├── ProductController.java
├── ProductId.java
├── ProductNotFoundException.java
├── ProductRepository.java
└── representation
├── ProductRepresentationService.java
└── ProductSummaryRepresentation.java
能够看到,ProductRepository
和ProductController
等多数类都直接放在了product
包下,而没有单独分包;可是展示类ProductSummaryRepresentation
却作了单独分包。这里的原则是:在全部类已经被内聚在了product
包下的状况下,若是代码结构足够的简单,那么没有必要再次进行子包的划分,ProductRepository
和ProductController
即是这种状况;而若是多个类须要作再次的内聚,那么须要另行分包,好比经过REST API接口返回Product数据时,代码中涉及到了两个对象ProductRepresentationService
和ProductSummaryRepresentation
,这两个对象是紧密关联的,所以将他们放在representation
子包下。而对于更加复杂的Order,分包以下:
├── order
│ ├── OrderApplicationService.java
│ ├── OrderController.java
│ ├── OrderPaymentProxy.java
│ ├── OrderPaymentService.java
│ ├── OrderRepository.java
│ ├── command
│ │ ├── ChangeAddressDetailCommand.java
│ │ ├── CreateOrderCommand.java
│ │ ├── OrderItemCommand.java
│ │ ├── PayOrderCommand.java
│ │ └── UpdateProductCountCommand.java
│ ├── exception
│ │ ├── OrderCannotBeModifiedException.java
│ │ ├── OrderNotFoundException.java
│ │ ├── PaidPriceNotSameWithOrderPriceException.java
│ │ └── ProductNotInOrderException.java
│ ├── model
│ │ ├── Order.java
│ │ ├── OrderFactory.java
│ │ ├── OrderId.java
│ │ ├── OrderIdGenerator.java
│ │ ├── OrderItem.java
│ │ └── OrderStatus.java
│ └── representation
│ ├── OrderItemRepresentation.java
│ ├── OrderRepresentation.java
│ └── OrderRepresentationService.java
能够看到,咱们专门建立了一个model
包用于放置全部与Order聚合根相关的领域对象;另外,基于同类型相聚原则,建立command
包和exception
包分别用于放置请求类和异常类。
UML中有用例(Use Case)的概念,表示的是软件向外提供业务功能的基本逻辑单元。在DDD中,因为业务被提到了第一优先级,那么天然地咱们但愿对业务的处理可以显现出来,为了达到这样的目的,DDD专门提供了一个名为应用服务(ApplicationService)的抽象层。ApplicationService采用了门面模式,做为领域模型向外提供业务功能的总出入口,就像酒店的前台处理客户的不一样需求同样。
在编码实现业务功能时,一般用2种工做流程:
在DDD实践中,天然应该采用自顶向下的实现方式。ApplicationService的实现遵循一个很简单的原则,即一个业务用例对应ApplicationService上的一个业务方法。好比,对于上文提到的“修改Order中Product的数量”业务需求实现以下:
实现OrderApplicationService:
@Transactional public void changeProductCount(String id, ChangeProductCountCommand command) { Order order = orderRepository.byId(orderId(id)); order.changeProductCount(ProductId.productId(command.getProductId()), command.getCount()); orderRepository.save(order); }
OrderController调用OrderApplicationService:
@PostMapping("/{id}/products") public void changeProductCount(@PathVariable(name = "id") String id, @RequestBody @Valid ChangeProductCountCommand command) { orderApplicationService.changeProductCount(id, command); }
此时,order.changeProductCount()
和orderRepository.save()
都没有必要实现,可是由OrderController
和OrderApplicationService
所构成的业务处理的架子已经搭建好了。
能够看到,“修改Order中Product的数量”用例中的OrderApplicationService.changeProductCount()
方法实现中只有很少的3行代码,然而,如此简单的ApplicationService却存在不少讲究。
ApplicationService须要遵循如下原则:
OrderApplicationService.changeProductCount()
方法标记有Spring的@Transactional
注解,表示整个方法被封装到了一个事务中。order.changeProductCount()
方法才是真正实现业务逻辑的地方,而ApplicationService只是做为代理调用order.changeProductCount()
方法,所以,ApplicationService应该是很薄的一层。OrderApplicationService
所接受的Order ID是Java原始的String类型,在调用领域模型中的Repository时,才被封装为OrderId
对象。
接地气一点地讲,聚合根(Aggreate Root, AR)就是软件模型中那些最重要的以名词形式存在的领域对象,好比本文示例项目中的Order
和Product
。又好比,对于一个会员管理系统,会员(Member)即是一个聚合根;对于报销系统,报销单(Expense)即是一个聚合根;对于保险系统,保单(Policy)即是一个聚合根。聚合根是主要的业务逻辑载体,DDD中全部的战术实现都围绕着聚合根展开。
然而,并非说领域模型中的全部名词均可以建模为聚合根。所谓“聚合”,顾名思义,即须要将领域中高度内聚的概念放到一块儿组成一个总体。至于哪些概念才能聚到一块儿,须要咱们对业务自己有很深入的认识,这也是为何DDD强调开发团队须要和领域专家一块儿工做的缘由。近年来流行起来的事件风暴建模活动,究其本意也是经过罗列出领域中发生的全部事件可让咱们全面的了解领域中的业务,进而识别出聚合根。
对于“更新Order中Product数量”用例,聚合根Order
的实现以下:
public void changeProductCount(ProductId productId, int count) { if (this.status == PAID) { throw new OrderCannotBeModifiedException(this.id); } OrderItem orderItem = retrieveItem(productId); orderItem.updateCount(count); this.totalPrice = calculateTotalPrice(); } private BigDecimal calculateTotalPrice() { return items.stream() .map(OrderItem::totalPrice) .reduce(ZERO, BigDecimal::add); } private OrderItem retrieveItem(ProductId productId) { return items.stream() .filter(item -> item.getProductId().equals(productId)) .findFirst() .orElseThrow(() -> new ProductNotInOrderException(productId, id)); }
在本例中,Order
中的品项(orderItems
)和总价(totalPrice
)是密切相关的,orderItems
的变化会直接致使totalPrice
的变化,所以,这两者天然应该内聚在Order
下。此外,totalPrice
的变化是orderItems
变化的必然结果,这种因果关系是业务驱动出来的,为了保证这种“必然”,咱们须要在Order.changeProductCount()
方法中同时实现“因”和“果”,也即聚合根应该保证业务上的一致性。在DDD中,业务上的一致性被称为不变条件(Invariants)。
还记得上文中提到的“违背内聚性的悬念”吗?当时调用Order
上的业务方式以下:
.....
order.changeProductCount(ProductId.productId(command.getProductId()), command.getCount());
order.updateTotalPrice();
.....
为了实现“更新Order中Product数量”业务功能,这里前后调用了Order
上的两个public方法changeProductCount()
和updateTotalPrice()
。虽然这种作法也能正确地实现业务逻辑,可是它将保证业务一致性的职责交给了Order
的调用方(上文中的Controller)而不是Order
自身,此时调用方须要确保在调用了changeProductCount()
以后必须调用updateTotalPrice()
方法,这一方面是Order
中业务逻辑的泄露,另外一方面调用方并不承担这样的职责,而Order
才最应该承担这样的职责。
对内聚性的追求会天然地延伸出聚合根的边界。在DDD的战略设计中,咱们已经经过限界上下文的划分将一个大的软件系统拆分为了避免同的“模块”,在这样的前提下,再在某个限界上下文中来讨论内聚性将比在大泥球系统中讨论变得简单得多。
对聚合根的设计须要提防上帝对象(God Object),也即用一个大而全的领域对象来实现全部的业务功能。上帝对象的背后存在着一种表面上看似合理的逻辑:既然要内聚,那么让咱们把全部相关的东西都聚到一块儿吧,好比用一个Product
类来应付全部的业务场景,包括订单、物流、发票等等。这种机械的方式看似内聚,实则偏偏是内聚性的反面。要解决这样的问题依然须要求助于限界上下文,不一样限界上下文使用各自的通用语言(Ubiquitous Language),通用语言要求一个业务概念不该该有二义性,在这样的原则下,不一样的限界上下文可能都有本身的Product
类,虽然名字相同,却体现着不一样的业务。
除了内聚性和一致性,聚合根还有如下特征:
Order
下的OrderItem
引用了ProductId
,而不是整个Product
。若是一个事务须要更新多个聚合根,首先思考一下本身的聚合根边界处理是否出了问题,由于在设计合理的状况下一般不会出现一个事务更新多个聚合根的场景。若是这种状况的确是业务所需,那么考虑引入消息机制和事件驱动架构,保证一个事务只更新一个聚合根,而后经过消息机制异步更新其余聚合根。
聚合根不该该引用基础设施。
外界不该该持有聚合根内部的数据结构。
尽可能使用小聚合。
软件模型中存在实体对象(Entity)和值对象(Value Object)之说,这种划分方式事实上并非DDD的专属,可是在DDD中咱们很是强调这二者之间的区别。
实体对象表示的是具备必定生命周期而且拥有全局惟一标识(ID)的对象,好比本文中的Order
和Product
,而值对象表示用于起描述性做用的,没有惟一标识的对象,好比Address
对象。
聚合根必定是实体对象,可是并非全部实体对象都是聚合根,同时聚合根还能够拥有其余子实体对象。聚合根的ID在整个软件系统中全局惟一,而其下的子实体对象的ID只需在单个聚合根下惟一便可。 在本文示例项目中,OrderItem
是聚合根Order
下的子实体对象:
public class OrderItem { private ProductId productId; private int count; private BigDecimal itemPrice; }
能够看到,虽然OrderItem
使用了ProductID
做为ID,可是此时咱们并无享受ProductID
的全局惟一性,事实上多个Order
能够包含相同ProductID
的OrderItem
,也即多个订单能够包含相同的产品。
区分实体和值对象的一个很重要的原则即是根据相等性来判断,实体对象的相等性是经过ID来完成的,对于两个实体,若是他们的全部属性均相同,可是ID不一样,那么他们依然两个不一样的实体,就像一对长得如出一辙的双胞胎,他们依然是两个不一样的天然人。对于值对象来讲,相等性的判断是经过属性字段来完成的。好比,订单下的送货地址Address
对象即是一个典型的值对象:
public class Address { private String province; private String city; private String detail; @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } Address address = (Address) o; return province.equals(address.province) && city.equals(address.city) && detail.equals(address.detail); } @Override public int hashCode() { return Objects.hash(province, city, detail); } }
在Address
的equals()
方法中,经过判断Address
所包含的全部属性(province
,city
,detail
)来决定两个Address
的相等性。
值对象还有一个特色是不变的(Immutable),也就说一个值对象一旦被建立出来了便不能对其进行变动,若是要变动,必须从新建立一个新的值对象总体替换原有的。好比,示例项目有一个业务需求:
在订单未支付的状况下,能够修改订单送货地址的详细地址(detail)
因为Address
是Order
聚合根中的一个对象,对Address
的更改只能经过Order
完成,在Order
中实现changeAddressDetail()
方法:
public void changeAddressDetail(String detail) { if (this.status == PAID) { throw new OrderCannotBeModifiedException(this.id); } this.address = this.address.changeDetailTo(detail); }
能够看到,经过调用address.changeDetailTo()
方法,咱们获取到了一个全新的Address
对象,而后将新的Address
对象总体赋值给address
属性。此时Address.changeDetailTo()
的实现以下:
public Address changeDetailTo(String detail) { return new Address(this.province, this.city, detail); }
这里的changeDetailTo()
方法使用了新的详细地址detail
和未发生变动的province
、city
从新建立出了一个Address
对象。
值对象的不变性使得程序的逻辑变得更加简单,你不用去维护复杂的状态信息,须要的时候建立,不要的时候直接扔掉便可,使得值对象就像程序中的过客同样。在DDD建模中,一种受推崇的作法即是将业务概念尽可能建模为值对象。
对于OrderItem
来讲,因为咱们的业务须要对OrderItem
的数量进行修改,也即拥有生命周期的意味,所以本文将OrderItem
建模为了实体对象。可是,若是没有这样的业务需求,那么将OrderItem
建模为值对象应该更合适一些。
另外,须要指明的是,实体和值对象的划分并非一成不变的,而应该根据所处的限界上下文来界定,相同一个业务名词,在一个限界上下文中多是实体,在另外的限界上下文中多是值对象。好比,订单Order
在采购上下文中应该建模为一个实体,可是在物流上下文中即可建模为一个值对象。
通俗点讲,资源库(Repository)就是用来持久化聚合根的。从技术上讲,Repository和DAO所扮演的角色类似,不过DAO的设计初衷只是对数据库的一层很薄的封装,而Repository是更偏向于领域模型。另外,在全部的领域对象中,只有聚合根才“配得上”拥有Repository,而DAO没有这种约束。
实现Order
的资源库OrderRepository
以下:
public void save(Order order) { String sql = "INSERT INTO ORDERS (ID, JSON_CONTENT) VALUES (:id, :json) " + "ON DUPLICATE KEY UPDATE JSON_CONTENT=:json;"; Map<String, String> paramMap = of("id", order.getId().toString(), "json", objectMapper.writeValueAsString(order)); jdbcTemplate.update(sql, paramMap); } public Order byId(OrderId id) { try { String sql = "SELECT JSON_CONTENT FROM ORDERS WHERE ID=:id;"; return jdbcTemplate.queryForObject(sql, of("id", id.toString()), mapper()); } catch (EmptyResultDataAccessException e) { throw new OrderNotFoundException(id); } }
在OrderRepository
中,咱们只定义了save()
和byId()
方法,分别用于保存/更新聚合根和经过ID获取聚合根。这两个方法是Repository中最多见的方法,有的DDD实践者甚至认为一个纯粹的Repository只应该包含这两个方法。
读到这里,你可能会有些疑问:为何OrderRepository
中没有更新和查询等方法?事实上,Repository所扮演的角色只是向领域模型提供聚合根而已,就像一个聚合根的“容器”同样,这个“容器”自己并不关心客户端对聚合根的操做究竟是新增仍是更新,你给一个聚合根对象,Repository只是负责将其状态从计算机的内存同步到持久化机制中,从这个角度讲,Repository只须要一个相似save()
的方法即可完成同步操做。固然,这个是从概念的出发点得出的设计结果,在技术层面,新增和更新仍是须要区别对待,好比SQL语句有insert
和update
之分,只是咱们将这样的技术细节隐藏在了save()
方法中,客户方并没有需知道这些细节。在本例中,咱们经过MySQL的ON DUPLICATE KEY UPDATE特性同时处理对数据库的新增和更新操做。固然,咱们也能够经过编程判断聚合根在数据库中是否已经存在,若是存在则update
,不然insert
。另外,诸如Hibernate这样的持久化框架自动提供saveOrUpate()
方法能够直接用于对聚合根的持久化。
对于查询功能来讲,在Repository中实现查询本无不合理之处,然而项目的演进可能致使Repository中充斥着大量的查询代码“喧宾夺主”似的掩盖了Repository本来的目的。事实上,DDD中读操做和写操做是两种很不同的过程,笔者的建议是尽可能将此两者分开实现,由此查询功能将从Repository中分离出去,在下文中我将详细讲到。
在本例中,咱们在技术实现上使用到了Spring的JdbcTemplate
和JSON格式持久化Order
聚合根,其实Repository并不与某种持久化机制绑定,一个被抽象出来的Repository向外暴露的功能“接口”始终是向领域模型提供聚合根对象,就像“聚合根的家”同样。
好了,至此让咱们来作个回顾,上文中咱们以“更新Order中的Product数量”业务需求为例,讲到了应用服务、聚合根和资源库,对该业务需求的处理流程体现了DDD处理业务需求的最多见最典型的形式:
应用服务做为整体协调者,先经过资源库获取到聚合根,而后调用聚合根中的业务方法,最后再次调用资源库保存聚合根。
流程示意图以下:
稍微提炼一下,咱们便知道软件里面的写操做要么是修改既有数据,要么是新建数据。对于前者,DDD给出的答案已经在上文中讲到,接下来咱们讲讲在DDD中如何新建聚合根。
建立聚合根一般经过设计模式中的工厂(Factory)模式完成,这一方面能够享受到工厂模式自己的好处,另外一方面,DDD中的Factory还具备将“聚合根的建立逻辑”显现出来的效果。
聚合根的建立过程可简单可复杂,有时可能直接调用构造函数便可,而有时却存在一个复杂的构造流程,好比须要调用其余系统获取数据等。一般来说,Factory有两种实现方式:
让咱们先演示一下简单的Factory方法,在示例订单系统中,有个业务用例是“建立Product”:
建立Product,属性包括名称(name),描述(description)和单价(price),ProductId为UUID
在Product
类中实现工厂方法create()
:
public static Product create(String name, String description, BigDecimal price) { return new Product(name, description, price); } private Product(String name, String description, BigDecimal price) { this.id = ProductId.newProductId(); this.name = name; this.description = description; this.price = price; this.createdAt = Instant.now(); }
这里,Product
中的create()
方法并不包含建立逻辑,而是将建立过程直接代理给了Product
的构造函数。你可能以为这个create()
方法有些画蛇添足,然而这种作法的初衷依然是:咱们但愿将聚合根的建立逻辑突显出来。构造函数自己是一个很是技术的东西,任何地方只要涉及到在计算机内存中新建对象都须要使用构造函数,不管建立的初始缘由是业务须要,仍是从数据库加载,亦或是从JSON数据反序列化。所以程序中每每存在多个构造函数用于不一样的场景,而为了将业务上的建立与技术上的建立区别开来,咱们引入了create()
方法用于表示业务上的建立过程。
“建立Product”所设计到的Factory的确简单,让咱们再来看看另一个例子:“建立Order”:
建立Order,包含用户选择的Product及其数量,OrderId必须调用第三方的OrderIdGenerator获取
这里的OrderIdGenerator
是具备服务性质的对象(即下文中的领域服务),在DDD中,聚合根一般不会引用其余服务类。另外,调用OrderIdGenerator生成ID应该是一个业务细节,如前文所讲,这种细节不该该放在ApplicationService中。此时,能够经过Factory类来完成Order的建立:
@Component public class OrderFactory { private final OrderIdGenerator idGenerator; public OrderFactory(OrderIdGenerator idGenerator) { this.idGenerator = idGenerator; } public Order create(List<OrderItem> items, Address address) { OrderId orderId = idGenerator.generate(); return Order.create(orderId, items, address); } }
前面咱们提到,聚合根是业务逻辑的主要载体,也就是说业务逻辑的实现代码应该尽可能地放在聚合根或者聚合根的边界以内。但有时,有些业务逻辑并不适合于放在聚合根上,好比前文的OrderIdGenerator
即是如此,在这种“无可奈何”的状况下,咱们引入领域服务(Domain Service)。仍是先来看一个列子,对于Order的支付有如下业务用例:
经过支付网关OrderPaymentService完成Order的支付。
在OrderApplicationService
中,直接调用领域服务OrderPaymentService
:
@Transactional public void pay(String id, PayOrderCommand command) { Order order = orderRepository.byId(orderId(id)); orderPaymentService.pay(order, command.getPaidPrice()); orderRepository.save(order); }
而后实现OrderPaymentService
:
public void pay(Order order, BigDecimal paidPrice) { order.pay(paidPrice); paymentProxy.pay(order.getId(), paidPrice); }
这里的PaymentProxy
与OrderIdGenerator
类似,并不适合于放在Order
中。能够看到,在OrderApplicationService
中,咱们并无直接调用Order
中的业务方法,而是先调用OrderPaymentService.pay()
,而后在OrderPaymentService.pay()
中完成调用支付网关PaymentProxy.pay()
这样的业务细节。
到此,再来反观在一般的实践中咱们编写的Service类,事实上这些Servcie类将DDD中的ApplicationService和DomainService糅合在了一块儿,好比在”基于Service + 贫血模型”的实现“小节中的OrderService即是如此。在DDD中,ApplicationService和DomainService是两个很不同的概念,前者是必须有的DDD组件,然后者只是一种妥协的结果,所以程序中的DomainService应该越少越好。
一般来讲,DDD中的写操做并不须要向客户端返回数据,在某些状况下(好比新建聚合根)能够返回一个聚合根的ID,这意味着ApplicationService或者聚合根中的写操做方法一般返回void
便可。好比,对于OrderApplicationService
,各个方法签名以下:
public OrderId createOrder(CreateOrderCommand command) ; public void changeProductCount(String id, ChangeProductCountCommand command) ; public void pay(String id, PayOrderCommand command) ; public void changeAddressDetail(String id, String detail) ;
能够看到,在多数状况下咱们使用了后缀为Command
的对象传给ApplicationService,好比CreateOrderCommand
和ChangeProductCountCommand
。Command即命令的意思,也即写操做表示的是外部向领域模型发起的一次命令操做。事实上,从技术上讲,Command对象只是一种类型的DTO对象,它封装了客户端发过来的请求数据。在Controller中所接收的全部写操做都须要经过Command进行包装,在Command比较简单(好比只有1-2个字段)的状况下Controller能够将Command解开以后,将其中的数据直接传递给ApplicationService,好比changeAddressDetail()
即是如此;而在Command中数据字段比较多时,能够直接将Command对象传递给ApplicationService。固然,这并非DDD中须要严格遵循的一个原则,好比不管Command的简繁程度,统一将全部Command从Controller传递给ApplicationService,也不存在太大的问题,更多的只是一个编码习惯上的选择。不过有一点须要强调,即前文提到的“ApplicationService须要接受原始数据类型而不是领域模型中的对象”,在这里意味着Command对象中也应该包含原始的数据类型。
统一使用Command对象还有个好处是,咱们经过查找全部后缀为Command
的对象,即可以概览性地了解软件系统向外提供的业务功能。
阶段性小结一下,以上咱们主要围绕着软件的“写操做”在DDD中的实现进行讨论,而且讲到了3种场景,分别是:
以上3种场景大体上涵盖了DDD完成业务写操做的基本方面,总结下来3句话:建立聚合根经过Factory完成;业务逻辑优先在聚合根边界内完成;聚合根中不合适放置的业务逻辑才考虑放到DomainService中。
软件中的读模型和写模型是很不同的,咱们一般所讲的业务逻辑更多的时候是在写操做过程当中须要关注的东西,而读操做更多关注的是如何向客户方返回恰当的数据展示。
在DDD的写操做中,咱们须要严格地按照“应用服务 -> 聚合根 -> 资源库”的结构进行编码,而在读操做中,采用与写操做相同的结构有时不但得不到好处,反而使整个过程变得冗繁。这里介绍3种读操做的方式:
首先,不管哪一种读操做方式,都须要遵循一个原则:领域模型中的对象不能直接返回给客户端,由于这样领域模型的内部便暴露给了外界,而对领域模型的修改将直接影响到客户端。所以,在DDD中咱们一般为读操做专门建立相应的模型用于数据展示。在写操做中,咱们经过Command后缀进行请求数据的统一,在读操做中,咱们经过Representation后缀进行展示数据的统一,这里的Representation也即REST中的“R”。
这种方式将读模型和写模型糅合到一块儿,先经过资源库获取到领域模型,而后将其转换为Representation对象,这也是当前被大量使用的方式,好比对于“获取Order详情的接口”,OrderApplicationService实现以下:
@Transactional(readOnly = true) public OrderRepresentation byId(String id) { Order order = orderRepository.byId(orderId(id)); return orderRepresentationService.toRepresentation(order); }
咱们先经过orderRepository.byId()
获取到Order
聚合根对象,而后调用orderRepresentationService.toRepresentation()
将Order
转换为展示对象OrderRepresentation
,OrderRepresentationService.toRepresentation()
实现以下:
public OrderRepresentation toRepresentation(Order order) { List<OrderItemRepresentation> itemRepresentations = order.getItems().stream() .map(orderItem -> new OrderItemRepresentation(orderItem.getProductId().toString(), orderItem.getCount(), orderItem.getItemPrice())) .collect(Collectors.toList()); return new OrderRepresentation(order.getId().toString(), itemRepresentations, order.getTotalPrice(), order.getStatus(), order.getCreatedAt()); }
这种方式的优势是很是直接明了,也不用建立新的数据读取机制,直接使用Repository读取数据便可。然而缺点也很明显:一是读操做彻底束缚于聚合根的边界划分,好比,若是客户端须要同时获取Order
及其所包含的Product
,那么咱们须要同时将Order
聚合根和Product
聚合根加载到内存再作转换操做,这种方式既繁琐又低效;二是在读操做中,一般须要基于不一样的查询条件返回数据,好比经过Order
的日期进行查询或者经过Product
的名称进行查询等,这样致使的结果是Repository上处理了太多的查询逻辑,变得愈来愈复杂,也逐渐偏离了Repository本应该承担的职责。
#### 基于数据模型的读操做 这种方式绕开了资源库和聚合,直接从数据库中读取客户端所须要的数据,此时写操做和读操做共享的只是数据库。好比,对于“获取Product列表”接口,经过一个专门的ProductRepresentationService
直接从数据库中读取数据:
@Transactional(readOnly = true) public PagedResource<ProductSummaryRepresentation> listProducts(int pageIndex, int pageSize) { MapSqlParameterSource parameters = new MapSqlParameterSource(); parameters.addValue("limit", pageSize); parameters.addValue("offset", (pageIndex - 1) * pageSize); List<ProductSummaryRepresentation> products = jdbcTemplate.query(SELECT_SQL, parameters, (rs, rowNum) -> new ProductSummaryRepresentation(rs.getString("ID"), rs.getString("NAME"), rs.getBigDecimal("PRICE"))); int total = jdbcTemplate.queryForObject(COUNT_SQL, newHashMap(), Integer.class); return PagedResource.of(total, pageIndex, products); }
而后在Controller中直接返回:
@GetMapping public PagedResource<ProductSummaryRepresentation> pagedProducts(@RequestParam(required = false, defaultValue = "1") int pageIndex, @RequestParam(required = false, defaultValue = "10") int pageSize) { return productRepresentationService.listProducts(pageIndex, pageSize); }
能够看到,真个过程并无使用到ProductRepository
和Product
,而是将SQL获取到的数据直接新建为ProductSummaryRepresentation
对象。
这种方式的优势是读操做的过程不用囿于领域模型,而是基于读操做自己的需求直接获取须要的数据便可,一方面简化了整个流程,另外一方面大大提高了性能。可是,因为读操做和写操做共享了数据库,而此时的数据库主要是对应于聚合根的结构建立的,所以读操做依然会受到写操做的数据模型的牵制。不过这种方式是一种很好的折中,微软也提倡过这种方式,更多细节请参考微软官网。
CQRS(Command Query Responsibility Segregation),即命令查询职责分离,这里的命令能够理解为写操做,而查询能够理解为读操做。与“基于数据模型的读操做”不一样的是,在CQRS中写操做和读操做使用了不一样的数据库,数据从写模型数据库同步到读模型数据库,一般经过领域事件的形式同步变动信息。
这样一来,读操做即可以根据自身所需独立设计数据结构,而不用受写模型数据结构的牵制。CQRS自己是一个很大的话题,已经超出了本文的范围,读者能够自行研究。
到此,DDD中的读操做能够大体分为3种实现方式:
本文主要介绍了DDD中的应用服务、聚合、资源库和工厂等概念以及与它们相关的编码实践,而后着重讲到了软件的读写操做在DDD中的实现方式,其中写操做的3种场景为:
对于读操做,一样给出了3种方式:
以上“3读3写”基本上涵盖了程序员完成业务功能的平常开发之所需,原来DDD就这么简单,不是吗?
做者:滕云
原文地址:https://insights.thoughtworks.cn/backend-development-ddd/