Spiga

ABP成长系列6:DDD

2024-05-25 11:56:40

ABP是一个面向DDD的项目框架,接下来的内容将学习如何利用ABP来落地DDD的项目,我们今天先来总结一下DDD的核心思想。

一、什么是领域驱动设计

1. 传统软件开发

  • 业务梳理 ==> 软件设计 ==> 开发

  • 初期-简单-迭代-复杂-冗杂-屎山代码-牵一发而动全身-重构系统-演进

    示例:订单服务(查询订单、创建订单、订单评价、支付……)

  • 项目流程:讨论需求==>数据库建模==>项目开发==>迭代数据库的设计==>上线

2. 领域驱动开发

  • 根据领域知识一步步驱动软件设计的过程就是领域驱动设计(DDD,Domain-Driven Design)
  • 领域驱动设计(DDD)是一种处理高度复杂领域的设计思想
  • 设计思想:通过分离技术实现的复杂性,并围绕业务概念构建领域模型来控制业务的复杂性
  • 项目流程:领域模型=>数据库设计

3. 战略设计和战术设计

领域驱动设计包括战略设计和战术设计两部分:

  • 战略设计:从业务视角出发=》业务领域模型=》边界=》通用语言的限界上下文
  • 战术设计:从技术视角出发=》技术实现=》实体、聚合根、值对象、领域服务……

二、领域驱动设计的基础概念

1. 领域和子域

  • 领域(业务问题域)具体指一种特定的范围或区域,就是用来确定范围的,范围就是边界
  • 细分-限定-领域模型-代码实现
  • 领域越大业务范围就越大
  • 领域可以进一步划分为子领域(子域 更小的)
  • 子域对应一个更小的范围

我们来看一张经典的图

给桃树建立领域知识的步骤:

  1. 确定研究对象
  2. 对研究对象进行细分
  3. 对子对象再次进行细分
  4. 细分到最小单元

再来看一个电商项目的领域划分的例子:

  • 电商领域可以分成:商品子域、销售子域、订单子域、物流子域
  • 物流子域又可以分成:拣货子域、发货子域、派送子域

领域建模的过程和方法其核心思想就是将问题域逐步分解

降低业务理解和系统实现的复杂度

2. 核心域、通用域和支撑域

  • 核心域:产品核心业务功能
  • 通用域:被多个子域使用的通用功能(认证、授权、日志)
  • 支撑域:既不包含业务核心功能,也不包含通用功能

电商平台中:销售子域是核心域,物流子域是支撑销售业务的,所以它的支撑域

植物研究领域中,需要根据不同情况来划分核心域

  • 如果是在果园:果实就是核心域。为了让果实更饱满,果农可能会裁剪一些枝和叶
  • 如果是在公园:花就是核心域

3. 通用语言

  • 通过团队交流达成共识的,能够简单、清晰、准确描述业务涵义和规则的语言就是通用语言
  • 通用语言中
    • 术语、用例场景:反映代码
    • 名词:领域对象命名(商品、订单)=》实体对象
    • 动词:动作、事件(商品已下单、订单已付款)=>领域事件、命令
  • 通用语音是一种语义环境

4. 限界上下文

  • 作用:用来确定语义所在的领域边界
  • 目的:为了避免同样的概念或语义,在不同的上下文环境中产生歧义
  • 分开理解:限界 ==> 领域的边界;上下文 ==> 语义环境
  • 合起来理解:限界上下文就是用来细分领域,从而定义通用语言所在的边界
  • 电商领域中,同样商品这个名词:
    • 对于销售子域,商品的概念就是商品,关心的是价格、特性
    • 对于物流子域,商品的概念变成了货物,关心的不再是价格了,而是重量、尺寸、数量

5. 实体和值对象

实体和值对象都是领域模型中的领域对象

实体≠实体类:领域驱动设计中要求实体是唯一的且可持续变化的

  • 唯一性:由唯一的身份标识来决定的;可持续变化:反映了实体本身的状态和行为
  • 实体的多种形态
    • 业务形态===战略设计、领域模型基础单元、操作、行为
    • 代码形态=实体类,属性、方法实现业务逻辑,充血模型,多个实体(领域服务)
    • 运行形态===实体对象,唯一ID
    • 数据形态===领域模型(表格)=》实体对象、行为=》数据模型

值对象≠值类型对象

  • 值对象=属性集合(拥有若干个用于描述目的、具有整体概念和不可修改的属性)
  • 值对象就是将一个值,用对象的方式进行表述,来表达一个具体的固定不变的概念
  • 值:数字(1、2、3)、字符串(“Hello”)、地址(北京朝阳区XX街道)
  • 对象:具体的事务
  • 可以是单一属性;也可以是属性集合

值对象嵌入实体的两种方式:

  • 属性嵌入:适用于单一属性的值对象,或只有一条记录的多属性值对象的实体
  • 序列化大对象:适用于一条或多条记录的多属性值对象的实体

实体与值对象的关系:

  • 值对象依附于实体,是实体属性的一部分
  • 实体着重唯一性和延续性,不在意属性的变化
  • 值对象着重描述性,对属性的变化很敏感
  • 地址,在物流子域是实体,在客户子域是客户实体的值对象

6. 聚合和聚合根

  • 聚合:将相关联的领域对象进行显式分组,来表达整体的概念

  • 聚合由业务和逻辑紧密关联的实体和值对象组合而成的,是数据修改和持久化的基本单元

  • 聚合有一个聚合根和上下文边界,聚合之间的边界是松耦合的

  • 个体(实体、值对象)组成=》社团、机构、公司,组织里的一员

  • 聚合(组织):实体+值对象,聚合根(负责人、实体ID、聚合的管理者、负责内部协调)

  • 聚合根:聚合根也称为根实体,它不仅是实体,还是聚合的管理者

  • 聚合根在聚合内部负责协调实体和值对象,按照固定的业务规则,协同完成共同的业务逻辑

  • 聚合根还是聚合对外的接口,接受外部任务和请求,在上下文内实现聚合之间的业务协同

  • 聚合根、实体、值对象的关系

    • 聚合根是实体,有实体的特点,具有全局唯一标识,有独立的生命周期

    • 关于实体,只要ID相同,就认为是同一个实体。而值对象要求所有的属性都相同。

    • 一个聚合只有一个聚合根

    • 实体具有聚合内唯一性标识,状态可变,依附于聚合根,其生命周期由聚合根管理

    • 值对象无标识,不可变,无生命周期

7. 领域服务与应用服务

  • 跨聚合或聚合内无法通过聚合根自然建模的业务逻辑,使用领域服务来实现
  • 跨聚合或跨服务(通常是多个微服务间)的业务逻辑通过应用服务来实现
  • 领域服务和应用服务虽然都可能涉及“跨聚合”操作,但两者的职责和设计目标有本质区别。以下是关键对比:
维度 领域服务(Domain Service) 应用服务(Application Service)
关注点 实现领域逻辑(业务规则、领域模型的操作) 协调技术实现(用例流程、外部依赖调用)
是否包含业务逻辑 是(核心领域的一部分) 否(仅编排,不包含业务规则)
依赖范围 依赖领域模型(聚合、值对象、仓储接口) 依赖领域服务、仓储、外部系统(如数据库、消息队列、API)

8. 如何设计聚合与聚合根

  • 聚合是领域对象的显式分组,旨在支持领域模型的行为和不变性,同时充当一致性和事务性边界
  • 聚合的主要目的是用来封装业务和保证聚合内领域对象的数据一致性。

订单=必须具有唯一订单编号、订单日期、冗余商品的基本信息(名称、价格、折扣)、至少有一个商品

订单支付成功后=》更新已支付状态(订单子域)=》扣减库存(库存子域)

事务{更新订单状态();扣减库存();……}

示例:订单支付成功后,订单状态要更新为已支付状态,并且现有库存要根据订单中商品实际销售数量进行扣减。

  • 传统方式:通过事务 ==> 更新订单状态 & 扣减库存

  • DDD方式:从领域不变性的角度来看,我们应该维护各自子域内业务规则的不变性

9. 领域对象

一个领域模型会包含多个聚合,一个聚合包含多个领域对象,每个领域对象都有自己的领域类型

领域对象从哪里来?

  • 实体:大多数情况下,领域模型的业务实体与数据库实体是一一对应的
  • 聚合根:找出聚合根
  • 值对象:根据需要将某些实体的某些属性或属性集设计为值对象
  • 领域事件:如果领域模型中领域事件会触发下一步的业务操作,我们就需要设计领域事件
  • 领域服务:如果一个业务动作或行为跨多个实体,我们就需要设计领域服务
  • 仓储:如果需要数据持久化,那么每一个聚合应该有一个与之匹配的仓储

10. 领域事件

领域事件:用来表示领域中发生的事件

举例:当用户在购物车点击结算时,生成待付款订单,若支付成功,则更新订单状态为已支付,扣减库存,并推送捡货通知信息到捡货中心

领域事件的两个目的:解耦和拆分事务

如何使用领域事件来解耦?

  • 事件是由事件源和事件处理组合而成的。
  • 通过事件源辨别事件的来源,事件处理来表示事件导致的下一步操作,常见的实现方式就是事件总线
  • 事件总线是一种集中式事件处理机制,允许不同的组件之间,进行彼此通信而又不需要相互依赖,达到一种解耦的目的。
  • 最终一致性是一种设计方法,可以通过将某些操作的执行,延迟到稍后的时间,来提高应用程序的可扩展性和性能。

三、分层架构

1. 经典四层架构

  1. 用户接口层:负责向用户展现内容
  2. 应用层:主要面向用例和流程相关的操作
  3. 领域层:实现系统核心业务逻辑
  4. 基础层:向其他层提供通用的技术能力

2. 洋葱架构,又称整洁架构

3. 六边形架构

4. 三种架构的对比

四、ABP提供了什么

ABP框架为领域驱动设计提供了非常全面和深入的底层基础设施支持,这极大地简化了符合DDD原则的企业级应用开发。下面我将从几个核心方面为详细谈谈。

1. 领域模型基础设施

ABP框架提供了一套完整的基类和接口来支持DDD中的核心构建块:

  • 实体(Entity):ABP提供了泛型和非泛型的实体基类(如 Entity和Entity),这些基类内置了主键管理(Id 属性)、基于主键的等值比较(EntityEquals)以及多租户支持(通过 EntityHelper.TrySetTenantId)。
  • 聚合根(Aggregate Root):聚合根继承自 AggregateRoot 基类。这个基类除了具备实体的所有特性,还额外提供了并发控制(ConcurrencyStamp 属性)、扩展属性(ExtraProperties 字典)以及领域事件的收集和发布能力(AddLocalEvent, AddDistributedEvent),这对于维护聚合内的一致性和发布状态变更至关重要。
  • 值对象(Value Object):ABP提供了抽象的 ValueObject 基类。只需要重写 GetAtomicValues() 方法来提供所有属性,基类就会自动为你实现基于值的等值比较(Equals)和哈希码计算(GetHashCode),确保了值对象的正确行为。

类关系图

扩展:值对象的应用

实体聚合根这些对于用过DDD的开发人员来说,都很容易理解。而值对象在开发中,容易被忽略,这里我特别举一个值对象的使用示例。

在对账系统中,金融金额通常直接使用 decimal 类型来表示,但通过值对象我们可以赋予它更多的领域语义和行为。下面是一个完整的示例:

using System;
using System.Collections.Generic;
using Volo.Abp.Domain.Values;

public class Money : ValueObject
{
    public decimal Value { get; private set; }
    public Currency Currency { get; private set; }

    // 私有构造函数确保有效性
    private Money(decimal value, Currency currency)
    {
        if (value < 0)
            throw new ArgumentException("金额不能为负数", nameof(value));
        
        Value = value;
        Currency = currency;
    }

    // 工厂方法创建实例
    public static Money Create(decimal value, Currency currency = Currency.CNY)
    {
        return new Money(value, currency);
    }

    // 业务方法:加法
    public Money Add(Money other)
    {
        CheckSameCurrency(other);
        return new Money(Value + other.Value, Currency);
    }

    // 业务方法:减法
    public Money Subtract(Money other)
    {
        CheckSameCurrency(other);
        if (other.Value > Value)
            throw new InvalidOperationException("金额不足,无法完成减法");
            
        return new Money(Value - other.Value, Currency);
    }

    // 业务方法:乘法(如计算利息)
    public Money MultiplyBy(decimal factor)
    {
        if (factor < 0)
            throw new ArgumentException("乘数不能为负数", nameof(factor));
            
        return new Money(Value * factor, Currency);
    }
    
    // 添加货币转换方法
    public Money ConvertTo(Currency targetCurrency, decimal exchangeRate)
    {
        // 实现货币转换逻辑
    }

    // 检查货币类型是否相同
    private void CheckSameCurrency(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException($"货币类型不匹配: {Currency} 和 {other.Currency}");
    }

    // 格式化输出
    public override string ToString() => $"{Value:N2} {Currency}";

    // ABP ValueObject 需要实现此方法用于比较
    protected override IEnumerable<object> GetAtomicValues()
    {
        yield return Value;
        yield return Currency;
    }

    // 运算符重载
    public static Money operator +(Money left, Money right) => left.Add(right);
    public static Money operator -(Money left, Money right) => left.Subtract(right);
}

上面是使用基础ABP的ValueObject来实现的,对于简单的字段,如果需要考虑性能的话,也可以使用结构体来实现:

public readonly struct MoneyStruct : IEquatable<MoneyStruct>
{
    public decimal Value { get; }
    public CurrencyType Currency { get; }

    public MoneyStruct(decimal value, CurrencyType currency = CurrencyType.CNY)
    {
        // 验证逻辑
        if (value < 0)
            throw new ArgumentException("金额不能为负数", nameof(value));
            
        Value = value;
        Currency = currency;
    }

    // 工厂方法
    public static MoneyStruct Create(decimal value, CurrencyType currency = CurrencyType.CNY)
    {
        return new MoneyStruct(value, currency);
    }

    // 业务方法:加法
    public MoneyStruct Add(MoneyStruct other)
    {
        CheckSameCurrency(other);
        return new MoneyStruct(Value + other.Value, Currency);
    }

    // 业务方法:减法
    public MoneyStruct Subtract(MoneyStruct other)
    {
        CheckSameCurrency(other);
        if (other.Value > Value)
            throw new InvalidOperationException("金额不足,无法完成减法");
            
        return new MoneyStruct(Value - other.Value, Currency);
    }

    // 检查货币类型是否相同
    private void CheckSameCurrency(MoneyStruct other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException($"货币类型不匹配: {Currency} 和 {other.Currency}");
    }

    // 实现值对象相等性比较
    public bool Equals(MoneyStruct other)
    {
        return Value == other.Value && Currency == other.Currency;
    }

    public override bool Equals(object obj)
    {
        return obj is MoneyStruct other && Equals(other);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(Value, Currency);
    }

    // 格式化输出
    public override string ToString()
    {
        return Currency switch
        {
            CurrencyType.CNY => $"¥{Value:N2}",
            CurrencyType.USD => $"${Value:N2}",
            CurrencyType.EUR => $"€{Value:N2}",
            CurrencyType.JPY => $"¥{Value:N0}",
            CurrencyType.GBP => $"£{Value:N2}",
            _ => $"{Value:N2} {Currency}"
        };
    }

    // 运算符重载
    public static bool operator ==(MoneyStruct left, MoneyStruct right) => left.Equals(right);
    public static bool operator !=(MoneyStruct left, MoneyStruct right) => !left.Equals(right);
    public static MoneyStruct operator +(MoneyStruct left, MoneyStruct right) => left.Add(right);
    public static MoneyStruct operator -(MoneyStruct left, MoneyStruct right) => left.Subtract(right);
}

与ABP框架结合

// ABP 的 Money 类包装 struct
public class Money : ValueObject
{
    public MoneyStruct Value { get; }

    public Money(decimal amount, CurrencyType currency = CurrencyType.CNY)
    {
        Value = MoneyStruct.Create(amount, currency);
    }

    protected override IEnumerable<object> GetAtomicValues()
    {
        yield return Value.Value;
        yield return Value.Currency;
    }

    // 可以添加更多ABP特有的功能...
}

实际应用建议

  • 推荐使用 ABP 的 class 实现:

    • 当需要与 ABP 框架深度集成(如仓储、领域事件等)

    • 值对象较复杂或包含集合属性时

    • 需要参与依赖注入时

  • 考虑使用 struct 的情况:

    • 极简的值对象(如仅包含1-2个原始类型字段)

    • 对性能极度敏感的场景(高频创建/销毁)

    • 明确不需要框架集成的简单值对象

2. 领域服务

领域服务通常实现 IDomainService 接口或继承自 DomainService 基类。

  • 自动依赖注入(DI)注册:类实现了 ITransientDependency 接口。
  • 懒加载依赖
    • 这是 DomainService 最巧妙的设计!它没有通过构造函数注入各种服务,而是注入了一个 LazyServiceProvider。
    • 通过 LazyServiceProvider,那些常用的工具属性(如 Clock, GuidGenerator)只有在第一次被访问时才会从DI容器中解析。这带来了两个巨大好处:
      • 保持构造函数简洁:你的领域服务可能继承自此基类,但你可能只用到一两个工具。懒加载避免了注入一堆未使用的依赖,使构造函数非常干净。
      • 解决循环依赖:在复杂的领域模型中,领域服务之间可能存在循环依赖。懒加载打破了这种构造函数注入的循环依赖链。
  • 开箱即用的常用工具:
    • IClock: 用于获取当前时间,抽象了系统时间,便于测试。
    • IGuidGenerator: 用于生成GUID,提供了优化的生成策略。
    • ILoggerFactory: 用于创建日志记录器。
    • ICurrentTenant: 在多租户应用中,用于访问当前租户信息。
    • IObjectMapper: 对象映射接口(默认集成AutoMapper),用于在领域服务中进行DTO与实体间的转换。

核心:DomainService 基类

public abstract class DomainService : IBusinessService,    // 业务服务标记接口
                                     ITransientDependency // 自动注册为瞬态生命周期
{
   // 1. 懒加载服务提供商
   public IServiceProvider ServiceProvider { get; set; }
   protected readonly LazyServiceProvider LazyServiceProvider;

   // 2. 常用工具属性的懒加载声明
   protected IClock Clock => LazyServiceProvider.LazyGetService<IClock>(NullClock.Instance);
   protected IGuidGenerator GuidGenerator => LazyServiceProvider.LazyGetService<IGuidGenerator>(SimpleGuidGenerator.Instance);
   protected ILoggerFactory LoggerFactory => LazyServiceProvider.LazyGetService<ILoggerFactory>();
   protected ICurrentTenant CurrentTenant => LazyServiceProvider.LazyGetService<ICurrentTenant>();
   protected IObjectMapper ObjectMapper => LazyServiceProvider.LazyGetService<IObjectMapper>(NullObjectMapper.Instance);

   // 3. 构造函数,注入LazyServiceProvider
   protected DomainService()
   {
       LazyServiceProvider = this.GetRequiredService<LazyServiceProvider>();
   }

   // 4. 便捷方法:解析服务
   protected virtual TService LazyGetRequiredService<TService>()
   {
       return LazyServiceProvider.LazyGetRequiredService<TService>();
   }
}

3. 领域事件

ABP内置了强大的领域事件机制,支持聚合内事件的自动发布。

  • 自动收集与发布:事件在聚合根方法内触发,由框架自动收集并在事务提交后发布
  • 事务边界保障:确保事件只在业务操作成功完成后才发布
  • 本地与分布式事件分离:区分进程内事件和跨服务事件
  • 松耦合:事件发布者不直接依赖订阅者

4. 应用服务

虽然应用服务属于应用层而非领域层,但它是DDD架构中协调领域对象的重要角色。

  • ApplicationService 基类:ABP提供了 ApplicationService 基类,它封装了大量横切关注点,如权限检查(IsGrantedAsync)、会话访问(AbpSession)、工作单元管理和异常处理等。这让你能更专注于业务逻辑的协调。
  • DTO与自动映射:ABP深度集成AutoMapper,提供了声明式的映射配置(如 [AutoMapTo]、[AutoMapFrom])和丰富的DTO基类(如 EntityDto、PagedResultDto),极大地简化了领域对象与数据传输对象之间的转换。

5. 审计与多租户

ABP为常见的企业级需求提供了内置支持。

  • 审计日志:框架提供了一系列审计实体基类(如 CreationAuditedEntity, AuditedEntity, FullAuditedEntity),它们内置了创建时间、修改时间、创建人、修改人等属性的管理和自动填充逻辑。
  • 多租户:通过 IMultiTenant 接口和 EntityHelper.TrySetTenantId 方法,ABP能够自动为新建的实体设置当前租户ID,简化了多租户应用中的数据隔离。

总结:ABP框架通过提供这套开箱即用、高度抽象且可扩展的DDD基础设施,极大地降低了正确实践DDD的门槛。它让使用者能从编写繁琐的基础设施代码中解脱出来,更专注于核心业务逻辑和领域模型的构建。