DDD设计实践分析

前言

什么是DDD

DDD(Domain Driven Design),是一种面向对象的软件分析与设计方法,区别于传统的面向数据的建模方法,DDD以领域和领域逻辑为核心,整体设计围绕领域建模展开,将业务概念与业务逻辑转变为领域模型的属性与方法。因此
● 本质上是一种面向对象的建模方法
● 业务逻辑围绕领域模型展开
● 以领域为核心驱动力,聚焦于领域模型
● 目的是降低业务复杂度
传统以数据为中心的设计侧重于数据的关系设计以及数据的传递、处理,与对象的行为分离开,属于贫血模型,当系统复杂时,业务逻辑会散落各处,难以明确原本的意图(失忆症)
DDD以领域模型为核心,领域模型包括了对象的属性和行为,属于充血模型,领域对象内逻辑聚合,与其他模型间耦合度较低,对于复杂系统有着更好的扩展性

DDD发展历程

● 2003 Eric Evans(艾瑞克·埃文斯)《领域驱动设计 软件核心复杂性应对之道》 首次描述了DDD思想,颠覆以往数据驱动设计的思想。
● 2014 Vaughn Vernon(沃恩.弗农)《实现领域驱动设计》分别从战略和战术层面详尽地讨论了如何实现DDD,其中包含了大量的最佳实践、设计准则和对一些问题的折中性讨论。
战略设计偏重于系统架构层面,包括领域的识别与理解,统一语言的建立,限界上下文的划分等,实现分而治之,降低业务复杂度
战术设计是对领域模型的具体设计,包括聚合、实体、值对象、领域服务的设计,侧重于具体的技术实现

从BPO项目角度切入

自己在工作中负责过多个系统,均采用的MVC模式的设计架构,其中包括BPO管理系统(外包人员管理系统)。由于该系统的模型清晰,逻辑迭代复杂,很适合采用DDD的设计模式,因此自己在项目完成后学习了DDD的相关设计理念,并思考如何通过DDD的模式重新设计该系统。
● 领域驱动设计需要对相关业务领域有着比较清晰的理解,并能够提炼出一整套可以在团队内使用的领域模型和通用语言,BPO的业务场景相对成熟,且业内对其模型概念的认知一致,领域建模相对容易,适合作为切入点。如果领域划分不清,模型建立不合理,会造成代码腐化的更快。
● BPO业务模型和逻辑比较复杂,随着需求迭代,代码膨胀较为严重,数据模型关系复杂,代码更新和维护较为麻烦,代码逻辑分散,每次修改需要梳理所有需要改动的点,影响面难以控制。
● DDD的设计不是一蹴而就的,需要在实践中不断积累和改善,以BPO作为切入点,也是在实践中对DDD不断完善。
不是所有的场景都适合DDD,领域模型不清晰,不能获得团队内一致认同,领域对象间耦合关系严重,业务逻辑简单的场景都不适合采用DDD的设计方法。

BPO模型关系

BPO是外包人员管理系统,它的模型构建比较清晰,包括供应商、人员、合同等,同时在结算过程中又会涉及运营模板、账单等。

DDD实践(战略及战术设计)

名词解释

模型

领域模型是关于某个特定业务领域的软件模型。通常,领域模型通过对象模型来实现,这些对象同时包含了数据和行为,并且表达了准确的业务含义,包括实体、值对象、领域服务等。

统一语言

通用语言是指一种围绕域模型构建的语言,由所有团队成员在限界上下文(bounded context)中使用,以将团队的所有活动与软件联系起来(比如上文中所提到的BPO模型术语)
只有团队中所有成员都使用了通用语言(所有的词语、概念都是明确的),才能保证我们各方的理解是正确的;团队内成员合作是紧密高效的;是能激发团队内成员的创造力的。

上下文

在日常说话中,上下文指对话的语境。在领域模型设计中,上下文则是一种设定,在这种设定下,领域模型才有具体的含义,被团队所理解。比如在BPO结算管理中谈及账单,其所指代的就是供应商用于外包人员结算的特定场景。

限界上下文

上下文是模型被理解的语境,而限界上下文则圈定了这个上下文的边界,用于保护上下文环境语义的单一性,不被混淆。系统通过确定的限界上下文来进行解耦,而每一个上下文内部紧密组织,职责明确,具有较高的内聚性。
细胞之所以能够存在,是因为细胞膜限定了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜。

上下文映射

在确定完限界上下文之后,需要确定上下文之间的协作关系,通常包括以下几种
● 合作关系(Partnership):两个上下文紧密合作的关系,一荣俱荣,一损俱损。(上下文之间强依赖)
● 共享内核(Shared Kernel):两个上下文依赖部分共享的模型。(通过复用解除不必要依赖)
● 客户方-供应方开发(Customer-Supplier Development):上下文之间有组织的上下游依赖。(上游满足下游需求,下游即客户方,上游即供应方)
● 遵奉者(Conformist):下游上下文只能盲目依赖上游上下文,由上游决定拒绝还是响应下游的需求。(对上游产生强依赖,受上游模型变化影响)
● 防腐层(Anticorruption Layer):一个上下文通过一些适配和转换与另一个上下文交互。通常属于下游限界上下文,用于隔离上游模型变换的影响。
● 开放主机服务(Open Host Service):定义一种协议来让其他上下文来对本上下文进行访问。与防腐层相对,由上游提供协议吸引下游服务使用,并承诺不轻易变化。
● 发布语言(Published Language):通常与OHS一起使用,用于定义开放主机的协议。
● 大泥球(Big Ball of Mud):混杂在一起的上下文关系,边界不清晰。
● 另谋他路(SeparateWay):两个完全没有任何联系的上下文。

战略设计

考虑产品所讲的通用语言,从中提取一些术语称之为概念对象,寻找对象之间的联系;或者从需求里提取一些动词,观察动词和对象之间的关系;我们将紧耦合的各自圈在一起,观察他们内在的联系,从而形成对应的界限上下文。形成之后,我们可以尝试用语言来描述下界限上下文的职责,看它是否清晰、准确、简洁和完整。简言之,限界上下文应该从需求出发,按领域划分。

限界上下文划分及映射关系确认

从 BPO的业务逻辑考虑,BPO有如下业务场景:

  1. 供应商创建并维护供应商、项目、商品信息。
  2. 供应商同步并维护人员信息。
  3. 发起结算账单,进行审批。
  4. 账单审批后流转到采购部门。
  5. 运营后台做一些人员标签、结算模板的配置。
    将一些核心概念提取出来作为子域,划分出如下的限界上下文:供应商管理上下文、结算上下文、运营配置上下文、日志上下文。供应商管理上下文负责供应商基础信息的维护,包括供应商、人员、项目的CRUD操作。结算上下文负责结算的规则配置和账单生成。日志上下文对供应商信息变化及结算操作进行记录并展示。
    账单生成之后需要与外界上下文进行交互,包括发起审批,提交采购等流程,因此需要与外部上下文进行协作,这里通过ACL层隔离上游结构变更产生的影响。
    同时,供应商管理和结算都依赖于运营配置,商品与人员可以配置标签,结算需要配置模板。因此都依赖于外部的运营配置上下文。
    在协作关系上,BPO内部上下文间呈现合作关系,一荣俱荣、一损俱损。BPO同时依赖运营配置、审批、采购上下文,通过防腐层与外界上下文隔离,同时外界上下文通过开放主机服务提供访问机制。

分层架构

分层架构是一种分而治之的设计思想,是一种基于层次的架构范式。一般开发人员会把一个系统基于分而治之的理念将系统拆分成多个模块或子系统;然后对已拆分出的模块按业务层次分成相关依赖和层次关系的组;从而实现根据功能组实现功能和耦合的隔离。我们称这种范式为分层架构范式。分层之后可以很方便的把一些模块抽离出来,独立成一个系统,并且有如下的特点(好处):
● 高内聚:分层的设计可以简化系统设计,让不同的层专注做某一模块的事
● 低耦合:层与层之间通过接口或API来交互,依赖方不用知道被依赖方的细节
● 复用:分层之后可以做到很高的复用
● 扩展性:分层架构可以让我们更容易做横向扩展
分层设计的本质其实就是将复杂问题简单化,隔离关注点,将各层之间影响降到最低。基于单一职责原则让每层代码各司其职,基于“高内聚,低耦合”的设计思想实现相关层对象之间的交互。从而,提升代码的可维护性和可扩展性。

经典三层架构

经典三层架构的分层做到了“高内聚低耦合”的思想,具体含义如下:

  1. 界面表示层(UI):主要实现和用户的交互界面,以及事件处理程序的编写。
  2. 业务逻辑层(BLL):主要实现数据处理和数据传递,将界面表示层和数据访问层连接起来,起到承上启下的作用。
  3. 数据访问层(DAL):主要实现对数据库数据的增删改查操作。
领域驱动分层架构

主要分为四层:

  1. 展现层:它负责向用户显示信息和解释用户命令,完成前端界面逻辑。这里的用户不一定是使用用户界面的人,也可以是另一个计算机系统
  2. 应用层:它是很薄的一层,负责展现层与领域层之间的协调,也是与其它系统应用层进行交互的必要渠道。应用层要尽量简单,不包含业务规则或者知识,不保留业务对象的状态,只保留有应用任务的进度状态,更注重流程性的东西。它只为领域层中的领域对象协调任务,分配工作,使它们互相协作
  3. 领域层:它是业务软件的核心所在,包含了业务所涉及的领域对象(实体、值对象)、领域服务以及它们之间的关系,负责表达业务概念、业务状态信息以及业务规则,具体表现形式就是领域模型。领域驱动设计提倡富领域模型,即尽量将业务逻辑归属到领域对象上,实在无法归属的部分则以领域服务的形式进行定义
  4. 基础设施层:它向其他层提供通用的技术能力,为应用层传递消息(API 网关等),为领域层提供持久化机制(如数据库资源)等。
    在这里插入图片描述

战术设计

实体

当一个对象由其标识(而不是属性)区分时,这种对象称为实体(Entity)。
实体几乎是领域建模的基础。是我们首选地放置业务逻辑的地方。
一个典型的实体应该具备三个要素:
● 身份标识
● 属性
● 领域行为
比如,基础账单是一个实体,每个基础账单都有唯一的ID标志,可以通过账单ID找到唯一一个账单,且账单本身具有结算周期,供应商ID等元信息属性。可以提供账单创建,账单查询等领域行为。
采购单也是实体,每条采购单数据都有唯一的ID标志,具有账单ID、采购金额等属性,并且提供采购单生成的领域行为。
将领域行为赋予实体可以实现较好的内聚性,同时可以降低代码的重复性。
比如在生成账单的采购单和计算计费项金额时都需要根据计费项ID获取计费项的配置。

type Bill struct {
    temp Temp
}

type Temp struct {
    costs list<Cost>
}

func genCostPurchase(bill Bill, costID int64) (list<Purchase>, error) {
    if bill.temp == nil {
        return ...
    }
    for _, cost := range bill.temp.costs {
        if cost.id == costID {
            purchases:= getPurchases(cost Cost)
            return purchases, nil
        }
    }
}

func calCostAmount(bill Bill, costID int64) (float64, error) {
    if bill.temp == nil {
        return ...
    }
    for _, cost := range bill.temp.costs {
        if cost.id == costID {
            amount := calCost(cost Cost)
            return amount, nil
        }
    }
}

值对象

当一个对象用于对事物进行描述而没有唯一标识时,它被称作值对象(Value Object)。
值对象通常作为实体的属性而存在,比如数量、性质、关系、地点、时间与形态等范畴。是否拥有唯一的身份标识才是实体与值对象的根本区别。
在采购单中,附件作为采购单的值对象,包含附件URL、token等属性,由这些属性确定一个唯一的附件,而外界也只关心的附件的这些属性。

什么时候使用值对象?

合理的采用值对象可以降低系统设计的复杂度,比如采购单中需要包含商品信息,且在账单数据提交之后不允许改变,就可以把商品信息快照作为采购单的值对象,只需要包含账单提交时商品的名称价格等信息即可。
采购单实体和值对象设计如下,采购单数据包含基本的属性,同时包含商品信息和附件值对象。

type BillPurchase struct {
    ID int64.       // 唯一标志
    BillID int 64   // 账单ID
    ProductInfo Product // 商品值对象
    AnnexList []Annex   // 附件值对象列表
    ......
}

聚合

将实体和值对象划分为聚合并围绕着聚合定义边界。选择一个实体作为每个聚合的根,并允许外部对象仅能持有聚合根的引用。作为一个整体来定义聚合的属性和不变量(Invariants),并将执行职责(Enforcement Responsibility)赋予聚合根或指定的框架机制。
Aggregate(聚合)是一组相关对象的集合,作为一个整体被外界访问,聚合根(Aggregate Root)是这个聚合的根节点。
● 聚合是包含了实体和值对象的一个边界
● 聚合内包含的实体和值对象形成了一棵树,只有实体才能作为这棵树的根,这个根称为聚合根(Aggregate Root),这个实体称为根实体
● 外部对象只允许持有聚合或聚合根的引用,如此才能起到边界的控制作用
● 由聚合根统一对外提供履行该领域概念职责的行为方法,实现内部各个对象之间的行为协作

如何创建好的聚合?

● 边界内的内容具有一致性:在一个事务中只修改一个聚合实例。如果你发现边界内很难接受强一致,不管是出于性能或产品需求的考虑,应该考虑剥离出独立的聚合,采用最终一致的方式。
● 设计小聚合:大部分的聚合都可以只包含根实体,而无需包含其他实体。即使一定要包含,可以考虑将其创建为值对象。
● 通过唯一标识来引用其他聚合或实体:当存在对象之间的关联时,建议引用其唯一标识而非引用其整体对象。如果是外部上下文中的实体,引用其唯一标识或将需要的属性构造值对象。 如果聚合创建复杂,推荐使用工厂方法来屏蔽内部复杂的创建逻辑。
聚合内部多个组成对象的关系可以用来指导数据库创建,但不可避免存在一定的抗阻。如聚合中存在List<值对象>,那么在数据库中建立1:N的关联需要将值对象单独建表,此时是有id的,建议不要将该id暴露到资源库外部,对外隐蔽。
比如账单聚合,账单包含金额,而金额具备多种类型以及属性,在表中适合单独存储,因此可以将金额单独设计成一张表,金额也作为聚合中的值对象而存在,它的ID不被外界所关注。
有些业务逻辑不能添加到单一的实体上,比如生成账单,同时需要生成账单的基础信息,这时就无法将生成账单的行为单独赋予基础账单。一种方式是可以通过聚合的方式,建立一个账单聚合,以基础账单作为聚合根,将生成账单的领域行为赋予这个聚合。
另一种方法是通过领域服务组合不同实体的逻辑来实现生成账单这一行为。
我们可以设计如下账单聚合,以基础账单作为聚合根,由基础账单、数据实体以及账单金额值对象共同组成账单聚合对象

type BillAggre struct {
    BasicBill Bill // 账单基础信息聚合根
    Amounts []Amount // 账单金额值对象
    BillDataList []BillData // 数据实体
}

type Bill struct {
    ID int64
    StartTime int64
    EndTime int64 
    TempID int64
    ...
}

工厂(Factory)

将创建复杂聚合对象的职责分配给一个单独的对象,该对象本身并不承担领域模型中的职责,但是依然是领域设计的一部分。工厂应该提供一个或多个创建对象的接口,该接口封装了所有创建对象的复杂操作过程。对于聚合来说,我们应该一次性地创建整个聚合。
在该目录下,我们就可以考虑通过一些创建型设计模式抽象复杂的实例化逻辑,提高代码复用性,使代码更清晰易读,且对象更易于扩展。
当聚合比较复杂时,我们就需要通过一个参数很多的创建函数来构造该聚合,但这样就会导致很多时候我们实际不关心这些构造参数却需要制定完整入参才能构造出聚合对象,而且所有参数的初始化操作糅合到一起,这就导致对象的实例化过于复杂。
比如账单聚合中,生成账单时需要关注账单基类、数据、金额信息,对接采购时只需要关注账单基类和数据。这时候我们可以考虑把这些信息的构造拆分成独立步骤,采用构造者模式,实现构造逻辑的分解以及复用。
账单的构造器定义如下

type BillBuilder struct {
    billAggre BillAggre
    temp TempAggre
}

func NewBuilder() *BillBuilder {
    return &BillBuilder{
        billAggre:&BillAggre{}
    }
}

func (b *BillBuilder) SetTemp(temp TempAggre) BillBuilder {
    b.temp = temp
}

func (b *BillBuilder) CreateBaseBill(ctx context.Context, settleRange Range) BillBuilder {
    b.billAggre.BasicBill.ID = idGenerator.GenID(ctx)
    b.billAggre.BasicBill.StartTime = settleRange.StartTime
    b.billAggre.BasicBill.EndTime = settleRange.EndTime
    ...
}

func (b *BillBuilder) SetBillData(data []BillData) BillBuilder {
    b.billAggre.BillDataList = data
}

func (b *BillBuilder) SetBillAmounts(amounts []Amount) BillBuilder {
    b.billAggre.Amounts = amounts
}

func (b *BillBuilder) CalBillAmounts(ctx context.Context) []Amount {
    return b.calculate()
}

func (b *BillBuilder) GetBillAggre() BillAggre {
    return b.billAggre
}

func (b *BillBuilder) GenBillData(ctx context.Context) []BillData {
    return b.genData
}

定义账单工厂结构负责管理构造器的执行步骤,生成需要的账单对象

type BillFactory {
    billBuilder BillBuilder
}

func NewFactory(billBuilder BillBuilder) *BillFactory {
    return &BillFactory{
        billBuilder:billBuilder
    }
}

func (b *BillFactory) BuildBill(ctx context, temp TempAggre, settleRange Range) BillAggre {
    b.billBuilder.SetTemp(temp).CreateBaseBill(settleRange)
    data:= b.billBuilder.GenBillData(ctx)
    b.billBuilder.SetBillData(Data)
    amounts := b.billBuilder.CalBillAmounts(ctx)
    b.billBuilder.SetBillAmounts(amounts)
    return b.billBuilder.GetBillAggre()
}

资源库

领域对象需要资源存储,存储的手段可以是多样化的,常见的无非是数据库,分布式缓存,本地缓存等。资源库(Repository)的作用,就是对领域的存储和访问进行统一管理的对象。
资源库的操作对象是聚合而非实体
例如构造账单聚合的资源库,统一管理账单子域的数据存储和访问

struct BillRepository {
    redisClient RedisClient // redis客户端对象
    billFactory BillFactory // Bill工厂
}

func NewBillRepository(redisClient RedisClient) *BillRepository {
    return &BillRepository{redisClient}
}

func (billRepo *BillRepository) GetBill(ctx context.Context, billID int64) (*BillAggre, error) {
    bill, err := redisClient.Get(ctx, billID) // 先从缓存获取
    if bill != nil {
        return bill, nil
    }
    
    err = billRepo.getBillFromDB(ctx, billID)
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return billRepo.billFactory.NewNilBill(billID), nil // 如果账单不存在,返回账单空构造
    } else if err != nil {
        return nil, errors.Wrap(err, "unable to get bill from db")
    }
    
    err = redisClient.Set(ctx, billID, json.ToString(bill))
    if err != nil {
        return bill, errors.Wrap(err, "save bill to redis fail")
    }
    
    return billRepo.billFactory.FormatBill(ctx, bill), nil
}

领域服务

一些重要的领域行为或操作,可以归类为领域服务。它既不是实体,也不是值对象的范畴。
一个自治的聚合无法完成一个完整的业务场景,需要共同协作才能完成。然而,聚合的设计原则却要求聚合之间只能通过聚合的身份标识进行协作。这就意味着在聚合之上,需要引入一个设计对象来封装这种聚合之间的协作行为。领域服务正是将领域对象、资源库和防腐层等一系列领域内的对象的行为串联起来,聚焦于一个完整的业务场景。
我们以账单服务为例,可以通过以下形式组织用户发起账单的行为

struct BillService {
    bill *BillAggre
    temp *TempAggre
    billRepo *BillRepository
    tempRepo *TempRepository
    billFactory *BillFactory
}

// 构造服务实例
func NewBillService(...) *BillService {
    return &BillService{...}
}

// 发起账单
func (b *BillService) InitiateBill(ctx context.Context, req *InitiateBillRequest) error {
    // 校验逻辑
    ......
    // 获取模板信息
    temp := b.tempRepo.GetTemp(ctx, req.TempID)
    // 调用工厂方法创建账单实例,屏蔽内部复杂的创建逻辑
    bill := b.billFactory.BuildBill(ctx, temp, req)
    // 保存账单
    err := b.billRepo.CreateBill(ctx, bill)
}

可以看出,领域服务是对聚合、资源库、工厂等对象业务逻辑的编排。

应用服务

应用层要尽量简单,不包含业务规则或者知识,而只为下一层(指领域层)中的领域对象协调任务,分配工作,使它们互相协作。
应用服务是对领域服务的编排,在涉及到多个领域服务相互协作的场景,需要通过应用服务进行协作。
通常可以在应用服务层做一些横向处理,比如用户鉴权,消息触达,错误处理,监控等。
比如提交账单的逻辑,需要账单服务的提交逻辑处理,同时需要审批服务发起审批流的操作。

项目分层结构

根据DDD的指导,可以对项目按如下结构划分代码目录(并非绝对的,需要在实践中不断完善)

├── pkg                     // 业务代码全部放在该目录下
│   ├── application         // 应用服务目录,ddd中,如果有复杂业务,则在此对多个领域服务做编排
│   ├── common              // 通用常量及工具函数目录,业务内,通用函数包,具备以下特点:无状态、依赖少(标准库)
│   │   ├── constant        // 项目内常量
│   │   └── util            // 业务无关的工具函数(时间、数组、字典、随机数等)
│   ├── domain              // 领域服务、领域对象放在此目录下
│   │   └── BPO             // 不同domain为独立package
│   │       ├── aggregate   // 聚合目录
│   │       │   ├── bill_aggre.go      // 账单聚合
│   │       ├── dto         // 对外接口出入参与领域模型对象的结构转化
│   │       ├── entity      // ddd中的实体与值对象
│   │       │   ├── bill.go      // 账单实体(包括值对象定义)
│   │       │   ├── bill_data.go      // 数据实体(包括值对象定义)
│   │       ├── repo        // 资源库目录,操作entity和aggregate
│   │       │   ├── bill_repo.go      // 账单资源库
│   │       ├── factory        // 工厂目录,用于生成复杂聚合对象
│   │       │   ├── bill_factory.go      // 账单工厂
│   │       ├── service  // 领域服务目录
│   │       │   ├── bill_service.go      // 账单服务
│   │       ├── task  // 定时任务,消费任务
│   ├── infra               // 基础设施层,与业务无关,
│   │   ├── rocket_mq
│   │   ├── rpc_client     // 第三方服务调用
│   │   │   ├── purchase
│   │   └── redis
├── output
├── config
├── main.go
├── script           
├── go.mod
└── go.sum
  • 15
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值