DDD你真的理解清楚了吗(2)贫血模型_数据库

充血模型 or 贫血模型

这些年,随着软件业的不断发展,软件系统开始变得越来越复杂而难于维护。这时,越来越多的开发团队开始选择实践DDD领域驱动设计。领域驱动设计是一种非常优秀的软件设计思想,它可以非常好地帮助我们梳理复杂业务,解决大规模业务系统的设计开发与更新维护。但是,领域驱动的学习成本却非常高,使得很多同学难于准确地理解DDD,更难于真正落地实际项目的设计编码。为此,我通过这一系列知识分享,让大家真正准确地理解DDD中这些晦涩的概念,特别是让大家理解最终是怎么落地到软件项目的设计开发中的。

很多团队在实践DDD时,首先都是根据业务需求进行领域建模。然而,当领域模型都分析好了,开始落地软件开发时,往往会非常纠结,到底采用贫血模型还是充血模型。到底是贫血模型好,还是充血模型好?这个问题争论了很多年,依然没有结论。我们今天就来好好讨论一下吧。

首先,我们来理解清楚这2个概念,什么是“贫血模型”?什么是“充血模型”?关于它们还有一段有趣的故事。2003年,软件大师Eric Evans发布了他的不朽著作《领域驱动设计》。接着,另一位软件大师Martin Fowler在自己的博客中就提出了“贫血模型”,并且将其作为反模式来提出批评。那么,什么是贫血模型呢?

譬如,现在有个业务场景是用户下单。按照贫血模型就会设计一个订单对象和一个订单明细对象,以及跟它们配套的订单DAO和订单明细DAO。在订单对象和订单明细对象中,除了一堆属性变量和get/set方法,什么都没有。所有对订单的操作,譬如“下单”、“支付”、“查看订单”,都在订单Service中实现,这样的设计就是贫血模型。

采用贫血模型,领域对象没有体现出领域模型的原貌。当订单Service下单时,既要实现各种业务校验、业务操作,还要调用订单BUS和订单明细BUS去保存数据库,并且保证它们的操作在同一事务中。这样,订单Service既要实现业务,又要实现技术,业务代码与技术实现耦合在一起,就不利于日后的变更维护。

采用充血模型,则是将领域模型的原貌,完全还原为程序中的领域对象。也就是说,领域模型长什么样,领域对象也长什么样。这样,程序中的订单对象首先要保持领域模型中的对象关系,比如对用户、地址、订单明细的引用。其次,订单对象还要实现所有对订单的操作,比如“下单”、“支付”、“查看订单”。这样的设计看着比较优雅,但具体实现时还是有许多难题。

譬如,用户下单时,是否要校验用户是否存在、地址是否存在、查询产品的单价及库存,那么是否要调用其它模块的Service,甚至可能进行微服务间的远程调用。所有这些调用都写在订单这个领域对象里,合适吗?所以我认为,不管是纯粹地采用贫血模型还是充血模型,都是有问题的,应当将它们结合起来,取其精华,去其糟粕。

在将领域模型落地程序编码时,首先将领域模型中各个对象及其关系,落地到程序中领域对象的设计,譬如订单对象中应当包含对用户、地址、订单明细的引用,具体编码如下:

@EqualsAndHashCode(callSuper = true)
@Data
public class Order extends Entity<Long> {
    private Long id;
    private Long customerId;
    private Long addressId;
    private String status;
    private Double amount;
    private Date orderTime;
    private Date modifyTime;
    private Customer customer;
    private Address address;
    private Payment payment;
    private List<OrderItem> orderItems;
}

可以看到,订单到用户和地址是多对一关系,订单到支付是一对一关系。而订单到订单明细是一对多关系,一个订单有多个明细,并且是聚合关系。将领域模型的原貌直接还原到领域对象的程序设计,现在还是纯贫血模型的设计。紧接着,将一些核心的业务操作,以方法的形式编写到领域对象中,如计算每个明细的金额、汇总整个订单的金额,这是充血模型的设计。除此之外,还可以在领域对象中封装一些代码的转换,比如Boolean类型的变量,在get/set中是布尔类型,但到了数据库中可能是0和1,或Y和N,或F与T。这些封装都在领域对象中实现。

@Data
@EqualsAndHashCode(callSuper = true)
public class User extends Entity<Long> {
    private Long id;
    private String username;
    private String password;
    private int disabled; 
    public Boolean getDisabled() {
        return disabled!=0;
    }

    public void setDisabled(Boolean disabled) {
        this.disabled = (disabled!=null&&disabled ? 1 : 0);
    }
}

在以上代码中,“用户”对象有个“是否失效”的属性,在数据库中存的是0和1,然后在领域对象中将其转换为布尔类型。

但是,在领域模型中实现的所有方法所需的数据,要么来源于方法的参数,要么来源于领域对象内部的数据,而不要去调用外部的某些方法,特别是其它模块的方法。譬如,用户在下单时需要查找对应的商品,获得商品价格,就需要调用产品Service的方法;需要计算折扣,就需要调用折扣Service的方法。这些操作的实现就不要写在订单对象中了,而是写在订单Service中。

用户从前端提交订单时,将订单数据以json的形式提交。后台首先通过Controller接受用户请求,将提交的json对象转换为订单对象。这样,订单对象就包含了前端用户提交的订单数据。紧接着,Controller将订单对象作为参数去调用Service中的“下单”方法。在下单的整个操作流程中,一部分操作是以贫血模型的形式在Service中实现的,特别是那些需要调用其它模块Service的操作,而另一部分则是以充血模型的形式调用领域对象中的方法。当所有业务操作都完成以后,将领域对象作为一个整体去调用底层的仓库。代码设计如下:

@Override
public Long create(Order order) {
    valid(order);
    order.calculateAmountForEachItem();
    discount(order);
    order.sumOfAmount();
    order.setOrderTime(DateUtils.getNow());
    return dao.insert(order);
}

在这段代码中可以看到,方法的参数是订单对象,里面携带着订单数据。订单的校验在Service中实现,因为需要调用CustomerService;计算每个订单明细的金额在订单对象中实现;订单的折扣在Service中实现,因为要调用DiscountService;接着汇总订单金额、设计下单时间。最后一步是调用DAO写数据库。模块与模块间的唯一交互形式是Service与Service的交互,而领域对象是内部封装的。

public class OrderServiceImpl implements OrderService {
    @Autowired
    private CustomerService customerService;
    @Autowired
    private ProductService productService;
    
    private void valid(Order order) {
        isNotExists(order.getCustomerId(), (value)-> customerService.exists(value), "the customer of the order");
        isNotExists(order.getAddressId(), (value)-> customerService.existsAddress(value), "the address of the order");
        order.getOrderItems().forEach(
                (orderItem)->{
                    Product product = productService.load(orderItem.getProductId());
                    isNull(product, "Not exists the product[%d] of the order item", orderItem.getProductId());
                    orderItem.setPrice(product.getPrice());
                }
        );
    }
    ...
}

在这段代码中,订单Service通过注入依赖注入了用户Service、产品Service,然后在订单校验过程中远程调用这些Service中的方法,完成用户与产品的校验。示例代码详见我的仓库

特别注意的是,在整个操作过程中,不论采用贫血模型还是充血模型,都是对领域对象的操作,跟数据库设计无关。过去,数据库的设计都是关系型数据库,但未来可能会有NoSQL、NewSQL数据库,数据库表结构的设计也会有很大的变化。但有这样的设计,不管数据库怎么变化,都与上层Service与Entity的设计无关。这种将业务代码与技术实现的分离,更有利于未来技术的更迭。

下单的最后一步是调用DAO写数据库,那么怎么将订单保存到数据库中呢?这些事情就与Service无关,而与底层的仓库有关。底层仓库(Repository)实际上是DAO的一种实现,它会根据领域模型中对象关系的定义来保存数据库。譬如,订单与订单明细是聚合关系,它们是一种整体与部分的关系。有了这种关系,仓库就会同时保存订单表和订单明细表,并将其放到同一事务中。相反,订单与用户、地址不是聚合关系,那么保存订单时就不会保存用户和地址。

DDD你真的理解清楚了吗(2)贫血模型_数据库_02

当需要查询订单时,光光查询订单表是不能获取完整的订单对象的。因此,底层采用工厂来查询并装配订单。工厂根据领域模型定义的关系,同时查询订单表,及其关联的用户表、地址表、订单明细表,最终装配成一个完整的订单对象。那么,如何将领域模型中的关系在程序中表达出来呢?显然,光有领域对象是远远不够的,需要通过DSL的配置来完整地表达领域对象中的关系,这些将在后面的文章中详细分享。

(待续)