领域驱动设计 DDD 简介
本篇文章属于《领域驱动设计》系列的第一篇文章。本系列教程将会梳理领域驱动的各种关键概念,并采用 Spring Data Jpa 进行一个领域驱动设计的 Sass 软件的实践。
软件是为了帮助我们处理现代生活中复杂问题而创建的工具,DDD (Domain-Driven Design)是用来设计软件的一种设计方法。在软件设计之初,我们就需要定义软件要解决某一场景的问题,DDD 中称该业务场景为领域(Domain)。
通常技术人员对新软件项目的领域知识是不了解的,懂得该领域业务知识的人在 DDD 中称为领域专家(通常是软件设计者,也可能是销售、技术支持等收集业务需求的人),技术人员与领域专家之间需要使用领域通用语言(Ubiquitous Language)进行业务沟通,领域通用语言可以是 UML、需求文档等提前规定好的双方都能理解的语言。
领域驱动设计 DDD 的最终目的是得到软件设计遵从的领域模型(Domain Model),领域模型是关于特定业务领域的软件模型,通常使用程序中的对象模型实现,对象的数据和行为准确表达该领域的业务含义。
DDD 可以为团队开发带来什么?
使用 DDD 可以使业务人员向技术人员准确地传递业务规则。业务人员与技术人员沟通变得更容易。
业务知识也是需要时间进行学习的,DDD 可以帮助对业务知识进行集中,这样可以保证软件业务知识不止掌握在少数人手中。
DDD 提炼出的领域模型使业务精简明确,易于理解学习和后期维护,开发上手快,人员可替代性强。
DDD 基础
在我们进入 DDD 之前我们先分析一下当前常用的开发方式(Java 为例)。当前大多数 Java 开发工程师采用贫血模型进行开发,我们会解释什么是贫血模型,它与充血模型有什么区别。
贫血模型
贫血模型(anaemic domain model)是指使用的领域对象中只有 setter 和 getter 方法(POJO),所有的业务逻辑都不包含在领域对象中而是放在业务逻辑层 Service 中。贫血模型,目前绝大多数开发者都采用的这种模式进行开发。
贫血模型是不包含任何逻辑的领域模型,它只是客户端可以更改和解释的数据的容器。所有逻辑都放在贫血模型中的域对象之外。
下面的例子使用订单类来演示贫血模型
public class Order {
private BigDecimal total = BigDecimal.ZERO;
private List items = new ArrayList();
public BigDecimal getTotal() {
return total;
}
public void setTotal(BigDecimal total) {
this.total = total;
}
public List getItems() {
return items;
}
public void setItems(List items) {
this.items = items;
}
}
public class OrderItem {
private BigDecimal price = BigDecimal.ZERO;
private int quantity;
private String name;
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public int getQuantity() {
return quantity;
}
public void setQuantity(int quantity) {
this.quantity= quantity;
}
...
}
用于计算订单总量的贫血域服务可能如下所示
public class OrderService {
public void calculateTotal(Order order) {
if (order == null) {
throw new IllegalArgumentException("order must not be null");
}
BigDecimal total = BigDecimal.ZERO;
List items = order.getItems();
for (OrderItem orderItem : items) {
int quantity = orderItem.getQuantity();
BigDecimal price = orderItem.getPrice();
BigDecimal itemTotal = price.multiply(new BigDecimal(quantity));
total = total.add(itemTotal);
}
order.setTotal(total);
}
}
贫血领域模型更改和解释数据的逻辑放在其他地方。大多数情况下,逻辑放在 xxxService 中。存放 xxxService 的包叫做服务层,服务层定义应用程序的边界,建立一组可用的操作并协调每个操作中应用程序的响应。从架构的角度来看,将业务逻辑保持在贫血模型中的“服务”是事务脚本。
事务脚本:可以将大多数业务应用程序视为一系列事务,事务脚本主要将所有业务逻辑组织为一个过程,按过程组织业务逻辑,其中每个过程处理一种请求。
计算订单总价的贫血模型服务 Service 如下
public class OrderService {
public void calculateTotal(Order order) {
if (order == null) {
throw new IllegalArgumentException("order must not be null");
}
BigDecimal total = BigDecimal.ZERO;
List items = order.getItems();
for (OrderItem orderItem : items) {
int quantity = orderItem.getQuantity();
BigDecimal price = orderItem.getPrice();
BigDecimal itemTotal = price.multiply(new BigDecimal(quantity));
total = total.add(itemTotal);
}
order.setTotal(total);
}
}
你可能认为这很清晰简单,因为它是过程编程。但它也很致命,因为贫血模型永远无法保证其正确性。贫血模型没有逻辑来确保它随时处于合法状态。例如,订单对象没有对其项目列表的更改做出反应,因此无法更新其总数,需要手动处理。
对象将数据和逻辑结合起来,而贫血的模型则将它们分开。这与与基本的面向对象原理(例如封装,信息隐藏)相矛盾。
下面例子说明了为什么贫血模型无法保证合法状态
public class OrderTest {
/**
* 贫血模型可能出现不一致的情况,因为它不处理状态更改。
*
* 使用贫血模型时开发者必须知道对象操作应该转变为什么状态,
* 手动变化合适状态,以保证状态合法
*
*/
@Test
public void anAnemicModelCanBeInconsistent() {
OrderService orderService = new OrderService();
Order order = new Order();
BigDecimal total = order.getTotal();
/*
* 订单没有订单项,因此订单总价一定为 0
*/
assertEquals(BigDecimal.ZERO, total);
OrderItem aGoodBook = new OrderItem();
aGoodBook.setName("Domain-Driven");
aGoodBook.setPrice(new BigDecimal("30"));
aGoodBook.setQuantity(5);
/*
* 在这里我们打破了对象封装,因为我们更改了订单项目列表的内部状态
* 这是贫血模型的常见编程模式
*/
order.getItems().add(aGoodBook);
/*
* 当我们修改了订单项,Order 对象就处于非法状态,我们需要手动修改订单状态
*/
BigDecimal totalAfterItemAdd = order.getTotal();
BigDecimal expectedTotal = new BigDecimal("150");
boolean isExpectedTotal = expectedTotal.equals(totalAfterItemAdd);
/*
* 当然,订单总数不能是预期的总数,因为贫血模型不能处理它们的状态变化。
*/
assertFalse(isExpectedTotal);
/*
* 要解决这个问题,我们必须调用OrderService来重新计算总数,并使Order对象再次处于合法状态。
*/
orderService.calculateTotal(order);
/*
* 现在,该 Order 对象再次处于合法状态
*/
BigDecimal totalAfterRecalculation = order.getTotal();
assertEquals(expectedTotal, totalAfterRecalculation);
}
}
贫血模型中数据的解释是由无状态服务完成的。虽然服务是无状态的,但它不知道何时该执行逻辑,何时不执行,并且无状态服务无法缓存其计算出的值。而充血域对象会自动处理其状态更改,知道何时必须重新计算属性的值。
贫血领域模型不是面向对象的编程,贫血模型是过程性编程
在早期编程时期,Order 示例实现如下:
struct order_item {
int amount;
double price;
char *name;
};
struct order {
int total;
struct order_item items[10];
};
int main(){
struct order order1;
struct order_item item;
item.name = "Domain-Driven";
item.price = 30.0;
item.amount = 5;
order.items[0] = item;
calculateTotal(order1);
}
void calculateTotal(order o){
int i, count;
count = 0;
for(i=0; i < 10; i++) {
order_item item = o.items[i];
o.total = o.total + item.price * item.amount;
}
}
使用贫血模型意味着使用过程编程。过程式编程很简单,但是理解应用程序的状态处理很难。此外,贫血模型将状态处理和数据解释的逻辑移到客户端,这通常会导致代码重复或产生非常细粒度的服务。最终产生了大量的服务和服务方法,这些服务和方法在一个广泛而复杂的对象网络中相互连接,这是我们很难找出一个物体处于某种状态的原因。要找出对象处于某种状态的原因,就必须找到对象所经过的方法调用层次结构。
充血模型
与贫血领域模型相反,充血模型遵循面向对象的原则。因此,充血模型实际上是面向对象的编程。充血模型或面向对象编程的目的是将数据和逻辑结合在一起。
面向对象意味着:一个对象管理它的状态,并保证它在任何时候处于合法的状态。贫血模型的 Order 类可以很容易地转换为面向对象的版本。
public class Order {
private BigDecimal total;
private List items = new ArrayList();
/**
* 返回订单总价
*/
public BigDecimal getTotal() {
if (total == null) {
/*
* 必须计算总数并保存结果
*/
BigDecimal orderItemTotal = BigDecimal.ZERO;
List items = getItems();
for (OrderItem orderItem : items) {
BigDecimal itemTotal = orderItem.getTotal();
//获取某一订单项的总价
/*
* 将每个 OrderItem 的总价加到我们的总数中。
*/
orderItemTotal = orderItemTotal.add(itemTotal);
}
this.total = orderItemTotal;
}
return total;
}
/**
* 添加 OrderItem 到 Order
*/
public void addItem(OrderItem orderItem) {
if (orderItem == null) {
throw new IllegalArgumentException("orderItem must not be null");
}
if (this.items.add(orderItem)) {
/*
* 订单项的列表发生了变化,因此我们将 total 字段重置为 null,
* 让 getTotal 重新计算 total。
*/
this.total = null;
}
}
/**
*
* 返回 Order 中的所有 OrderItem ,客户端不能修改返回的 List
*/
public List getItems() {
/*
* 我们对订单项进行封装,以防止客户操纵我们的内部状态。
*/
return Collections.unmodifiableList(items);
}
}
import java.math.BigDecimal;
public class OrderItem {
private BigDecimal price;
private int quantity;
private String name = "no name";
public OrderItem(BigDecimal price, int quantity, String name) {
if (price == null) {
throw new IllegalArgumentException("price must not be null");
}
if (name == null) {
throw new IllegalArgumentException("name must not be null");
}
if (price.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
"price must be a positive big decimal");
}
if (quantity < 1) {
throw new IllegalArgumentException("quantity must be 1 or greater");
}
this.price = price;
this.quantity = quantity;
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public int getQuantity() {
return quantity;
}
public String getName() {
return name;
}
/**
* total = getPrice() * getAmount() 此处的 total 为订单项总价
*/
public BigDecimal getTotal() {
int quantity = getQuantity();
BigDecimal price = getPrice();
BigDecimal total = price.multiply(new BigDecimal(quantity));
return total;
}
}
面向对象编程的优点是,对象可以保证在任何时候都处于合法的状态,并且不再需要服务类。测试用例将显示与贫血模型的差异,贫血模型不能保证它们在任何时候处于合法状态。
public class OrderTest {
/**
* 这个测试表明,充血模型模型保证它在任何时候都处于合法状态*。
*/
@Test
public void richDomainModelMustEnsureToBeConsistentAtAnyTime() {
Order order = new Order();
BigDecimal total = order.getTotal();
/*
* 新订单没有项目,因此总金额必须为零。
*/
assertEquals(BigDecimal.ZERO, total);
OrderItem aGoodBook = new OrderItem(new BigDecimal("30"), 5,
"Domain-Driven");
List items = order.getItems();
try {
items.add(aGoodBook);
} catch (UnsupportedOperationException e) {
/*
* 我们不能破坏封装,因为 order 对象不会向客户端公开它的内部状态。
* 对象关心它自己的状态,并确保自己在任何时候都处于合法状态。
*/
}
/*
* 我们必须使用对象暴露的添加方法
*/
order.addItem(aGoodBook);
/*
* 在添加了 OrderItem 之后。该对象仍然处于合法状态。
*/
BigDecimal totalAfterItemAdd = order.getTotal();
BigDecimal expectedTotal = new BigDecimal("150");
assertEquals(expectedTotal, totalAfterItemAdd);
}
}
使用哪个模型
应用程序应该尽可能多地使用面向对象的方法。面向对象编程的优点是对象可以保证它在任何时候都处于合法的状态。如果您想要保证整个应用程序始终处于合法(预期)的状态,就需要使用充血模型。
大多数工作中常见需求都是 CRUD,POJO 需要经常修改,数据通过 json 在各个模块流动。在这种背景下,应用的扩展往往是横向的扩展,表的字段增加或者联表,而非抽象关系的扩展,推荐使用贫血模型。
充血模型可能更适合复杂且稳定的业务领域。
每种方法论都有自己的局限性和适用范围。 从一个贫血模型开始可能很容易,但是以后将应用程序重构为一个充血领域模型体系结构可能会很麻烦并且容易失败。
总结
DDD 的基础就是充血模型,本篇文章我们先了解充血模型的使用方式,后续文章会逐渐解析 DDD 的各个概念,并给出代码实践。
参考资料
《领域驱动设计:解决软件核心的复杂性》,Eric Evans著
Anemic vs. Rich Domain Models