写点什么

DDD 战术设计实践

用户头像
郑印
关注
发布于: 刚刚

在笔者学习 DDD 的过程中,大部分文章通常都是在谈 DDD 的概念,理论,诚然这些很重要,但 DDD 的读者大多还是习惯与传统开发的方式,而 DDD 的思想与传统开发模式大为不同,当大量的理论铺面而来的时候,难免觉得无从着力,本系列文章希望通过一个实际系统的 DDD 案例,让读者对 DDD 的落地有一定的认识,认识的同时也会产生新的疑问,带着这些疑问在回头去学习 DDD 的系统理论,相信能够对读者起到帮助。

DDD 概览

此章节希望读者对 DDD 有一些基本概念,在本章中不会深入到具体概念的细节,在《实现领域驱动设计》一书中 DDD 每个概念背后都有一套详细的设计原则,后续文章中我们将结合编码的同时将一些概念与读者一起描述。

什么是领域驱动设计?

领域驱动设计目前被大量的提及,那么什么是领域驱动设计呢?笔者在刚开始接触时被这个问题纠结了很久,随着持续的学习,搜索大家对 DDD 的总结,发现 DDD 很难用一句话简单的描述清楚,让读者可以理解其含义。因此关于这个问题的解释我们就稍微繁琐一点,在领域驱动设计中,领域可以理解为业务,领域专家就是对业务很了解的人,比如你想要做一个在线车票的售票系统,那么平时我们看到的售票员可能就是领域专家,在比如你已经在一个业务上做了 5 年研发了,经历了各种需求的迭代,讨论,你懂得比新来的产品,业务还多,那么你有可能就是你们公司的领域专家。领域驱动设计的核心就是与领域专家一起通过领域建模的方式去设计的我们的软件程序。


  • 那么领域如何驱动设计?或者说业务如何驱动软件设计?


单纯聊这个问题很奇怪,我们平时开发不都是业务驱动的吗?是的,但仔细的琢磨一下我们的开发过程,你会发现其中的问题。我们在和业务(领域)专家讨论时,我们是想着将需求如何映射到代码上,还是想着应该创建那些表,改那些表字段才能满足需求呢?我们在拿到一个产品原型,需求清单第一步是写代码还是创建数据表呢?大多数时候答案是后者,因此我们实际是将面向业务开发转换为了面向数据开发。


那么 DDD 如何解决这个问题呢,答案是领域模型,我个人认为领域模型的核心是通过模型承载和保存领域知识,并通过模型与代码的映射将这些领域知识保存在程序代码中。在传统的开发中,当业务被转换为一张张数据表时,丢失最多的就是领域知识。

DDD 可以做什么


DDD 主要分为两个部分,战略设计与战术设计,战略设计围绕微服务拆分,战术设计围绕微服务构建

DDD 怎么做


  1. 领域专家与研发人员一起(研发人员可能就是领域专家),通过一系列的方式方法(DDD 并没有明确说明用什么方法),划分出业务的边界,这个边界就是限界上下文,微服务可以以限界上下文指定微服务的拆分,但是微服务的拆分并不是说一定以限界上下文为边界,这里面还需要考虑其它因数,比如 3 个火枪手原则、两个披萨原则以及组织架构对微服务拆分的影响等。

  2. 研发人员通过领域模型,领域模型就是 DDD 中用于指定微服务实现的模型,保存领域知识,通过这种方式 DDD 通过领域模型围绕业务进⾏建模,并将模型与代码进⾏映射,业务调整影响代码的同时,代码也能直接的反映业务。


按照常规的编码⽅式,代码就不能直接反映业务了吗? 请参考贫血模型与充血模型


充血模型编码实践

DDD 领域模型

实体与值对象


  • 实体的特征


  1. 唯一标识,对唯一性事物进行建模

  2. 包含了业务的关键行为,可以随着业务持续变化

  3. 修改时,因为有唯一标识,所以还是同一个实体


在上图中,订单就是一个实体,因为他有订单的唯一 ID,通过它可以表示订单这个事务的唯一性,并且在订单的整个生命周期,随着业务订单也在不断的变化,创建订单到订单完成,订单状态在不断的变化,但是因为它们有唯一的订单 ID,所以它们就是同一个实体。


  • 值对象的特征


  1. 描述事物的某个特征,通常作为实体属性存在

  2. 创建后即不可变

  3. 修改时,用另一个值对象予以替换


在上图中,订单商品就是一个值对象,因为在订单语境下,商品就是订单的一个特征,同时订单中的商品在订单创建的那一刻就会被"快照"下来,如果商品的发生变化,比如价格从 100 元涨价到 10000 元,订单中的商品也不会同步去修改。在此种业务语境下,订单商品就符合对值对象的描述,那么如果卖家修改订单中商品的价格怎么办呢,在 DDD 中通过覆盖的方式进行修改,而不是只修改一个价格属性。


除了订单商品外,收获地址也是一个值对象,那么收获地址可以是一个实体吗? 答案是可以的,当业务在收获地址管理的上下文语境里的时候,收获地址就是一个实体。


更多对实体特征的描述,可以参考《实现领域驱动设计》一书

领域服务


领域服务可以帮助我们分担实体的功能,承接部分业务逻辑,做一些实体不变处理的业务流程,它不是必须的。在上图中,描述的是一个创建消息的领域服务,因为消息的实体中有用户的值对象,但是用户的信息通常在另一个限界上下文,也就是另一个微服务中,因此需要通过一些 facade 接口获取,如果把这些接口的调用防在领域实体中就会导致实体过于臃肿,且也不必保持其独立性,因为它需要被类似于 Spring 这样的框架进行管理,依赖注入一些接口,因此通过领域服务进行辅助是一种很好的方式。

聚合


将实体和值对象在一致性边界之内组成聚合,使用聚合划分限界上下文(微服务)内部的边界,聚合根做为一种特殊的实体,用于管理聚合内部的实体与值对象,并将自身暴露给外部进行引用。



比如在上图中描述的是一个订单聚合,在这个聚合中,它里面有两个实体,一个是订单一个是退货退款协议,显然退货退款协议应该依托于订单,但是它也符合实体的特征,因此被定义为实体。在此情况下,订单实体就是此聚合的聚合根。

聚合的一致性边界

  1. 生命周期的一致性,聚合对外的生命周期保持一致,聚合根生命周期结束,聚合的内部所有对象的生命周期也都应该结束。

  2. 事务的一致性,这里的事务指的是数据库事务,每个数据库事务指包含一个聚合,不应该有垮聚合的事务

领域事件


领域事件表示领域中所发生的事情,通过领域事件可以实现微服务内的信息同步,同时也可以实现对外部系统的解耦。


如上图所示,聚合变更后创建领域事件,领域事件有两种方式进行发布。


  1. 与聚合事务一起进行存储,比如存储进一个本地事件表,在由事件转发器转发到消息队列,这样保证的事件不会丢失。

  2. 直接进行转发到消息队列,但是此时因为事件还未入口,因此需要在聚合事务与消息队列发布事件之间做 XA 的 2PC 事务提交,因为有 2PC 存在,通常性能不会太好。


除了向外部系统发布事件,限界上下文内部的多个聚合也可以通过一些本地事务发布器来进行事务的发布,比如 Spring Event 或 EventBus 等

资源库


资源库是保存聚合的地方,将聚合实例存放在资源库(Repository)中,之后再通过该资源库来获取相同的实例。


  1. Save: 聚合对象由 Repository 的实现,转换为存储所支持的数据结构进行持久化

  2. Find: 根据存储所支持的数据结构,由 Repository 的实现转换为聚合对象

应用服务


应用服务负责流程编排,它将要实现的功能委托给一个或多个领域对象来实现,本身只负责处理业务用例的执行顺序以及结果的拼装同时也可以在应用服务做些权限验证等工作。

DDD 推荐的架构模式

本章我们来聊一聊 DDD 推荐的架构模式,这些架构模式用于指导服务内的具体实现,对于服务内的逻辑分层,职能角色,依赖关系都有现实的指导意义。

DDD 分层

在一个典型的 DDD 分层架构中,分为用户界面层(Interfacce) , 应用层(Application), 领域层(Domain) ,基础设施层 (Infrastructure), 其中领域层是 DDD 分层架构中的核心,它是保存领域知识的地方。


分层架构的一个重要原则是:每层只能与位于其下方的层发生耦合。


在传统的 DDD 分层中,下图是他们的依赖关系。



如果读者没有使用过 DDD 可能对此理解不是很直观,可以将用户界面层想象为 Controller,应用层与领域层想象为 Service,基础设施层想象为 Repository 或者 DAO,可能会好理解一些


可以看到,在传统的 DDD 分层架构中,基础层是被其它层所共同依赖的,它处于最底层,这可能导致重心偏移(想象一下在 Service 依赖 DAO 的场景),然而在 DDD 中领域层才是核心,因此要改变这种依赖。


如何改变这种依赖关系呢,在面向对象设计中有一种设计原则叫做依赖导致原则( Dependence Inversion Principle,DIP)。


DIP 的定义为:


高层模块不应该依赖于底层模块,二者都应该依赖于抽象。


抽象不应该依赖于细节,细节应该依赖于抽象。


根据 DIP 改进以后的架构如下图所示。



改进后的 DDD 分层,将整个依赖过程反过来了,但实际上仅仅是反过来了这么简单吗?在 DIP 的理论中,高层模块与低层模块是不相互依赖的,他们都依赖于一个抽象,那么这么看来,模块之间就不在是一种强耦合的关系了。


比如,在 DIP 之前,领域层之间依赖于基础设施层。



改进后,他们后依赖于 IUserRepository 的抽象,抽象由基础层去实现,领域层并不关心如何实现。



由此各模块可以对内实现强内聚对外提供松耦合依赖。

六边形架构(端口适配器架构)

六边形架构,对于每种外界类型,都有一个适配器与之相对应。业务核心逻辑被包裹在内部,外界通过应用层 API 与内部进行交互,内部的实现无须关注外部的变化,更加聚焦。在这种架构下还可以轻易地开发用于测试的适配器。同时六边形架构又名“端口适配器架构”, 这里的端口不一定指传统意义上的服务端口,可以理解为一种通讯方式,比如在一个服务中,我们可能会提供给用户浏览器的基于 HTTP 的通讯方式,提供给服务内部的基于 RPC 的通讯方式,以及基于 MQ 的通讯方式等,适配器指的是用于将端口输入转换为服务内部接口可以理解的输入。



刚才我们讨论的是外部向领域服务内部输入部分的端口+适配器模式,同时输出时也同样,比如当我们的要将领域对象进行存储时,我们知道有各种各样的存储系统,比如 Mysql、ES、Mongo 等,假如说我们可以抽象出一个适配器,用于适配不同的存储系统,那么我们就可以灵活的切换不同的存储介质,这对于我们开发测试,以及重构都是很有帮助的,而在 DDD 中这个抽象的适配器就资源库。


理解到这些以后,我们来看下六边形架构的整体架构。



在此中架构下,业务层被聚焦在内部的六边形,内部的六边形不关心外部如何运作,只关注与内部的业务实现,这也是 DDD 推崇的方式,研发人员应该更关注于业务的实现也就是领域层的工作,而不是聚焦在技术的实现。结合分层架构的思想,外部的六边形可以理解为接口层与基础层,内部理解为应用层与领域层,内部通过 DIP 与外部解耦。


在《实现领域驱动设计》一书中,作者认为它是一种具有持久生命力的架构。


充血模型编码实践

本章我们将对通过《重构》一书中的案例,回顾贫血模型与充血模型,为后面的编码做知识储备,在 DDD 实践中,我们将大量用到充血模型的编码方式,如果你对贫血模型与充血模型已经了解了,可以跳过本章。

什么是贫血模型与充血模型?

回答这个问题,我们从《重构》一书中的一个影片租赁的案例,以及一个订单的开发场景,分别使用贫血模型与充血模型来实现,读者可以从中感受其差别理解它们的不同。

影片租赁场景

需要说明的是下面的代码基本与《重构》一书中的代码相同,但笔者省略了重构的各个代码优化环节,只展示了贫血模型与充血模型代码的不同。书中源代码,笔者也手写了一份实现,感兴趣可以通过以下链接点击查看。


https://gitee.com/izhengyin/some-code/tree/master/refactor/src/main/java/com/izhengyin/somecode/refactor/movierental/version0

需求描述

根据顾客租聘的影片打印出顾客消费金额与积分


  • 积分规则

  • 默认租聘积一分,如果是新片且租聘大于 1 天,在加一分

  • 费用规则

  • 普通片 ,租聘起始价 2 元,如果租聘时间大于 2 天,每天增加 1.5 元

  • 新片 ,租聘价格等于租聘的天数

  • 儿童片 ,租聘起始价 1.5 元,如果租聘时间大于 3 天,每天增加 1.5 元

基于贫血模型的实现

下面是影片 Movie 、租赁 Rental 两个贫血模型类,下面这样的代码在我们日常开发中是比较常见,简单来说它们就是只包含数据,不包含业务逻辑的类,从面向对象角度来说也违背了面向对象里面封装的设计原则。


面向对象封装:隐藏信息、保护数据,只暴露少量接口,提高代码的可维护性与易用性;


public class Movie {    public static final int CHILDRENS = 2;    public static final int REGULAR = 0;    public static final int NEW_RELEASE = 1;    private String title;    private Integer priceCode;
public Movie(String title, Integer priceCode) { this.title = title; this.priceCode = priceCode; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public Integer getPriceCode() { return priceCode; }
public void setPriceCode(Integer priceCode) { this.priceCode = priceCode; }}
复制代码


public class Rental {    /**     * 租的电影     */    private Movie movie;    /**     * 已租天数     */    private int daysRented;
public Rental(Movie movie, int daysRented) { this.movie = movie; this.daysRented = daysRented; }
public Movie getMovie() { return movie; }
public void setMovie(Movie movie) { this.movie = movie; }
public int getDaysRented() { return daysRented; }
public void setDaysRented(int daysRented) { this.daysRented = daysRented; }}
复制代码


接着是我们的 Customer 类,Customer 类的问题是里面包含了原本应该是 Movie 与 Reatal 的业务逻辑,给人感觉很重,Customer 可以类别我们日常开发的 XxxService,想想我们是不是在 Service 层中不断的堆砌业务逻辑。


public class Customer {    private String name;    private List<Rental> rentals = new ArrayList<>();    public Customer(String name) {        this.name = name;    }    public void addRental(Rental rental) {        this.rentals.add(rental);    }    public String getName() {        return name;    }
/** * 根据顾客租聘的影片打印出顾客消费金额与积分 * @return */ public String statement(){ double totalAmount = 0; String result = getName()+"的租聘记录 \n"; for (Rental each : rentals){ double thisAmount = getAmount(each); result += "\t" + each.getMovie().getTitle() + " \t" + thisAmount +" \n"; totalAmount += thisAmount; } int frequentRenterPoints = getFrequentRenterPoints(rentals); result += "租聘总价 : "+ totalAmount + "\n"; result += "获得积分 : "+ frequentRenterPoints; return result; }
/** * 获取积分总额 * @param rentals * @return */ private int getFrequentRenterPoints(List<Rental> rentals){ return rentals.stream() .mapToInt(rental -> { //默认租聘积一分,如果是 Movie.NEW_RELEASE 且租聘大于1天,在加一分 int point = 1; if(rental.getMovie().getPriceCode().equals(Movie.NEW_RELEASE) && rental.getDaysRented() > 1){ point ++; } return point; }) .sum(); }
/** * 获取单个影片租聘的价格 * 1. 普通片 ,租聘起始价2元,如果租聘时间大于2天,每天增加1.5元 * 2. 新片 ,租聘价格等于租聘的天数 * 3. 儿童片 ,租聘起始价1.5元,如果租聘时间大于3天,每天增加1.5元 * @param rental * @return */ private double getAmount(Rental rental){ double thisAmount = 0; switch (rental.getMovie().getPriceCode()){ case Movie.REGULAR: thisAmount += 2; if(rental.getDaysRented() > 2){ thisAmount += (rental.getDaysRented() - 2) * 1.5; } break; case Movie.NEW_RELEASE: thisAmount += rental.getDaysRented(); break; case Movie.CHILDRENS: thisAmount += 1.5; if(rental.getDaysRented() > 3){ thisAmount += (rental.getDaysRented() - 3) * 1.5; } break; default: //nothings todo break; } return thisAmount; }
}
复制代码


最后我们运行主程序类,进行输出,得到下面结果,记住这个结果,我们会通过重新模型重构后,保证同样的输出。


张三的租聘记录   儿童片   1.5   普通片   3.5   新片   5.0 租聘总价 : 10.0获得积分 : 4
复制代码


主程序类


public class Main {    public static void main(String[] args) {        Movie movie1 = new Movie("儿童片", Movie.CHILDRENS);        Movie movie2 = new Movie("普通片", Movie.REGULAR);        Movie movie3 = new Movie("新片", Movie.NEW_RELEASE);        Customer customer = new Customer("张三");        customer.addRental(new Rental(movie1,1));        customer.addRental(new Rental(movie2,3));        customer.addRental(new Rental(movie3,5));        System.out.println(customer.statement())    }}
复制代码

基于充血模型的实现

我们的类没有变化,只是类里面的实现发生了变化,接下来就逐一看看类的实现都发生了那些改变。


重构后影片 Movie 类


  1. 删除了不必要 setXXX 方法

  2. 增加了 getCharge 获取费用电影费用的方法,将原本 Customer 的逻辑交由 Movie 类实现。


注:Movie 类还有优化空间,但不是本文的重点,读者感兴趣可以查看此链接https://gitee.com/izhengyin/some-code/tree/master/refactor/src/main/java/com/izhengyin/somecode/refactor/movierental/version2


public class Movie {    public static final int CHILDRENS = 2;    public static final int REGULAR = 0;    public static final int NEW_RELEASE = 1;    private String title;    private Integer priceCode;
public Movie(String title, Integer priceCode) { this.title = title; this.priceCode = priceCode; }
public String getTitle() { return title; }
public Integer getPriceCode() { return priceCode; }
/** *获取单个影片租聘的价格 * 1. 普通片 ,租聘起始价2元,如果租聘时间大于2天,每天增加1.5元 * 2. 新片 ,租聘价格等于租聘的天数 * 3. 儿童片 ,租聘起始价1.5元,如果租聘时间大于3天,每天增加1.5元 * @param daysRented * @return */ public double getCharge(int daysRented){ double thisAmount = 0; switch (this.priceCode){ case REGULAR: thisAmount += 2; if(daysRented > 2){ thisAmount += (daysRented - 2) * 1.5; } break; case NEW_RELEASE: thisAmount += daysRented; break; case CHILDRENS: thisAmount += 1.5; if(daysRented > 3){ thisAmount += (daysRented - 3) * 1.5; } break; default: //nothings todo break; } return thisAmount; }}
复制代码


重构后租赁 Rental 类


  1. 移除了部分不必要的 get / set 方法

  2. 增加一个 getPoint 方法,计算租赁积分,将原本 Customer 的逻辑交由获取积分的业务交由 getPoint 实现,但总积分的计算还是在 Customer。

  3. 增加一个 getCharge 方法,具体调用 Movie::getCharge 传入租赁天数得到租赁的费用,因为在这个需求中主体是租赁


public class Rental {
/** * 租的电影 */ private Movie movie;
/** * 已租天数 */ private int daysRented;
public Rental(Movie movie, int daysRented) { this.movie = movie; this.daysRented = daysRented; }
public Movie getMovie() { return movie; }
/** * 默认租聘积一分,如果是新片且租聘大于1天,在加一分 * @return */ public int getPoint(){ int point = 1; if(this.movie.getPriceCode().equals(Movie.NEW_RELEASE) && this.daysRented > 1){ point ++; } return point; } /** * 获取费用 * @return */ public double getCharge(){ return this.movie.getCharge(this.daysRented); }}
复制代码


瘦身后的 Customer


public class Customer {    private String name;    private List<Rental> rentals = new ArrayList<>();    public Customer(String name) {        this.name = name;    }    public void addRental(Rental rental) {        this.rentals.add(rental);    }    public String getName() {        return name;    }
/** * 根据顾客租聘的影片打印出顾客消费金额与积分 * @return */ public String statement(){ double totalAmount = 0; String result = getName()+"的租聘记录 \n"; for (Rental each : rentals){ double thisAmount = each.getCharge(); result += "\t" + each.getMovie().getTitle() + " \t" + thisAmount +" \n"; totalAmount += thisAmount; } int frequentRenterPoints = getFrequentRenterPoints(rentals); result += "租聘总价 : "+ totalAmount + "\n"; result += "获得积分 : "+ frequentRenterPoints; return result; }
/** * 获取积分总额 * @param rentals * @return */ private int getFrequentRenterPoints(List<Rental> rentals){ return rentals.stream() .mapToInt(Rental::getPoint) .sum(); }}
复制代码


最后我们运行主程序类,得到同样的输出。


源码地址: https://gitee.com/izhengyin/ddd-message/tree/master/src/main/java/democode/movierental

订单的场景

需求描述

  1. 创建订单

  2. 设置订单优惠

订单场景贫血模型实现

Order 类 , 只包含了属性的 Getter,Setter 方法


@Datapublic class Order {    private long orderId;    private int buyerId;    private int sellerId;    private BigDecimal amount;    private BigDecimal shippingFee;    private BigDecimal discountAmount;    private BigDecimal payAmount;    private String address;}
复制代码


OrderService ,根据订单创建中的业务逻辑,组装 order 数据对象,最后进行持久化


    /**     * 创建订单     * @param buyerId     * @param sellerId     * @param orderItems     */    public void createOrder(int buyerId,int sellerId,List<OrderItem> orderItems){        //新建一个Order数据对象        Order order = new Order();        order.setOrderId(1L);        //算订单总金额        BigDecimal amount = orderItems.stream()                .map(OrderItem::getPrice)                .reduce(BigDecimal.ZERO,BigDecimal::add);        order.setAmount(amount);        //运费        order.setShippingFee(BigDecimal.TEN);        //优惠金额        order.setDiscountAmount(BigDecimal.ZERO);        //支付总额 = 订单总额 + 运费 - 优惠金额        BigDecimal payAmount = order.getAmount().add(order.getShippingFee()).subtract(order.getDiscountAmount());        order.setPayAmount(payAmount);        //设置买卖家        order.setBuyerId(buyerId);        order.setSellerId(sellerId);        //设置收获地址        order.setAddress(JSON.toJSONString(new Address()));        //写库        orderDao.insert(order);        orderItems.forEach(orderItemDao::insert);    }
复制代码


在此种方式下,核心业务逻辑散落在 OrderService 中,比如获取订单总额与订单可支付金额是非常重要的业务逻辑,同时对象数据逻辑一同混编,在此种模式下,代码不能够直接反映业务,也违背了面向对象的 SRP 原则。


设置优惠


 /**     * 设置优惠     * @param orderId     * @param discountAmount     */    public void setDiscount(long orderId, BigDecimal discountAmount){        Order order = orderDao.find(orderId);        order.setDiscountAmount(discountAmount);        //从新计算支付金额        BigDecimal payAmount = order.getAmount().add(order.getShippingFee()).subtract(discountAmount);        order.setPayAmount(payAmount);        //orderDao => 通过主键更新订单信息        orderDao.updateByPrimaryKey(order);    }
复制代码


贫血模型在设置折扣时因为需要考虑到折扣引发的支付总额的变化,因此还需要在从新的有意识的计算支付总额,因为面向数据开发需要时刻考虑数据的联动关系,在这种模式下忘记了修改某项关联数据的情况可能是时有发生的。

订单场景充血模型实现

Order 类,包含了业务关键属于以及行为,同时具有良好的封装性


/** * @author zhengyin * Created on 2021/10/18 */@Getterpublic class Order {    private long orderId;    private int buyerId;    private int sellerId;    private BigDecimal shippingFee;    private BigDecimal discountAmount;    private Address address;    private Set<OrderItem> orderItems;
//空构造,只是为了方便演示 public Order(){}
public Order(long orderId,int buyerId ,int sellerId,Address address, Set<OrderItem> orderItems){ this.orderId = orderId; this.buyerId = buyerId; this.sellerId = sellerId; this.address = address; this.orderItems = orderItems; }
/** * 更新收货地址 * @param address */ public void updateAddress(Address address){ this.address = address; } /** * 支付总额等于订单总额 + 运费 - 优惠金额 * @return */ public BigDecimal getPayAmount(){ BigDecimal amount = getAmount(); BigDecimal payAmount = amount.add(shippingFee); if(Objects.nonNull(this.discountAmount)){ payAmount = payAmount.subtract(discountAmount); } return payAmount; }
/** * 订单总价 = 订单商品的价格之和 * amount 可否设置为一个实体属性? */ public BigDecimal getAmount(){ return orderItems.stream() .map(OrderItem::getPrice) .reduce(BigDecimal.ZERO,BigDecimal::add); }

/** * 运费不能为负 * @param shippingFee */ public void setShippingFee(BigDecimal shippingFee){ Preconditions.checkArgument(shippingFee.compareTo(BigDecimal.ZERO) >= 0, "运费不能为负"); this.shippingFee = shippingFee; }
/** * 设置优惠 * @param discountAmount */ public void setDiscount(BigDecimal discountAmount){ Preconditions.checkArgument(discountAmount.compareTo(BigDecimal.ZERO) >= 0, "折扣金额不能为负"); this.discountAmount = discountAmount; }
/** * 原则上,返回给外部的引用,都应该防止间接被修改 * @return */ public Set<OrderItem> getOrderItems() { return Collections.unmodifiableSet(orderItems); }}
复制代码


OrderService , 仅仅负责流程的调度


    /**     * 创建订单     * @param buyerId     * @param sellerId     * @param orderItems     */    public void createOrder(int buyerId, int sellerId, Set<OrderItem> orderItems){        Order order = new Order(1L,buyerId,sellerId,new Address(),orderItems);        //运费不随订单其它信息一同构造,因为运费可能在后期会进行修改,因此提供一个设置运费的方法        order.setShippingFee(BigDecimal.TEN);        orderRepository.save(order);    }
复制代码


在此种模式下,Order 类完成了业务逻辑的封装,OrderService 仅负责业务逻辑与存储之间的流程编排,并不参与任何的业务逻辑,各模块间职责更明确。


设置优惠



/** * 设置优惠 * @param orderId * @param discountAmount */ public void setDiscount(long orderId, BigDecimal discountAmount){ Order order = orderRepository.find(orderId); order.setDiscount(discountAmount); orderRepository.save(order); }
复制代码


在充血模型的模式下,只需设置具体的优惠金额,因为在 Order 类中已经封装了相关的计算逻辑,比如获取支付总额时,是实时通过优惠金额来计算的。


   /**     * 支付总额等于订单总额 + 运费 - 优惠金额     * @return     */    public BigDecimal getPayAmount(){        BigDecimal amount = getAmount();        BigDecimal payAmount = amount.add(shippingFee);        if(Objects.nonNull(this.discountAmount)){            payAmount = payAmount.subtract(discountAmount);        }        return payAmount;    }
复制代码


写到这里,可能读者会有疑问,文章都在讲充血模型的业务,那数据怎么进行持久化?


数据持久化时我们通过封装的 OrderRepository 来进行持久化操作,根据存储方式的不同提供不同的实现,以数据库举例,那么我们需要将 Order 转换为 PO 对象,也就是持久化对象,这时的持久化对象就是面向数据表的贫血模型对象。


比如下面的伪代码


public class OrderRepository {    private final OrderDao orderDao;    private final OrderItemDao orderItemDao;

public OrderRepository(OrderDao orderDao, OrderItemDao orderItemDao) { this.orderDao = orderDao; this.orderItemDao = orderItemDao; }
public void save(Order order){ // 在此处通过Order实体,创建数据对象 new OrderPO() ; new OrderItemPO(); // orderDao => 存储订单数据 // orderItemDao => 存储订单商品数据
}
public Order find(long orderId){ //找到数据对象,OrderPO //找到数据对象,OrderItemPO //组合返回,order实体 return new Order(); }}
复制代码


通过上面两种实现方式的对比,相信读者对两种模型已经有了明确的认识了,在贫血模型中,数据和业务逻辑是割裂的,而在充血模型中数据和业务是内聚的。

电商消息系统编码实践


编码实践部分需要涉及大量的源码信息,请根据下面的链接访问源码进行查阅​


DDD电商消息系统编码实践(一)DDD电商消息系统编码实践(二)DDD电商消息系统编码实践(三)DDD电商消息系统编码实践(四)DDD电商消息系统编码实践(五)​​


本文源码地址:https://gitee.com/izhengyin/ddd-message

参考:



发布于: 刚刚阅读数: 3
用户头像

郑印

关注

还未添加个人签名 2017.10.17 加入

还未添加个人简介

评论

发布
暂无评论
DDD战术设计实践