InfoPool

私人信息记录

0%

实现领域驱动设计

写代码非常简单,但是写简单的代码却非常难

随着应用程序的变化,有时候,为了节省开发时间会违反一些本应遵守的规则,使得代码变得复杂且难以维护.短期来看确实节省了开发时间,但是后期可能需要花费更多的时间为之前的偷懒而买单.无法对原有的代码进行维护,导致大量的逻辑都需要进行重写.

什么是领域驱动设计?

领域驱动设计(DDD)是一种将实现与持续进化的模型连接在一起来满足复杂需求的软件开发方法.

DDD适用于复杂领域或较大规模的系统,而不是简单的CRUD程序.它着重于核心领域逻辑,而不是基础架构.这样有助于构建一个灵活,模块化,可维护的代码库.

核心构建组成
DDD的关注点在领域层和应用层上,而展现层和基础设施层则视为细节(这个词原文太抽象,自己体会吧),业务层不应依赖它们.

这并不意味着展现层和基础设施层不重要.它们非常重要,但UI框架 和 数据库提供程序 需要你自己定义规则和总结最佳实践.这些不在DDD的讨论范围中.

本节将介绍领域层和应用层的基本构建组件.

领域层构建组成

  • 实体(Entity): 实体是种领域对象,它有自己的属性(状态,数据)和执行业务逻辑的方法.实体由唯一标识符(Id)表示,不同ID的两个实体被视为不同的实体.
  • 值对象(Value Object): 值对象是另外一种类型的领域对象,使用值对象的属性来判断两个值对象是否相同,而非使用ID判断.如果两个值对象的属性值全部相同就被视为同一对象.值对象通常是不可变的,大多数情况下它比实体简单.
  • 聚合(Aggregate) 和 聚合根(Aggregate Root): 聚合是由聚合根包裹在一起的一组对象(实体和值对象).聚合根是一种具有特定职责的实体.
  • 仓储(Repository) (接口): 仓储是被领域层或应用层调用的数据库持久化接口.它隐藏了DBMS的复杂性,领域层中只定义仓储接口,而非实现.
  • 领域服务(Domain Service): 领域服务是一种无状态的服务,它依赖多个聚合(实体)或外部服务来实现该领域的核心业务逻辑.
    规约(Specification): 规约是一种强命名,可重用,可组合,可测试的实体过滤器.
  • 领域事件(Domain Event): 领域事件是当领域某个事件发生时,通知其它领域服务的方式,为了解耦领域服务间的依赖.

应用层构建组成

  • 应用服务(Application Service): 应用服务是为实现用例的无状态服务.展现层调用应用服务获取DTO.应用服务调用多个领域服务实现用例.用例通常被视为一个工作单元.
  • 数据传输对象(DTO): DTO是一个不含业务逻辑的简单对象,用于应用服务层与展现层间的数据传输.
  • 工作单元(UOW): 工作单元是事务的原子操作.UOW内所有操作,当成功时全部提交,失败时全部回滚.

领域驱动设计

领域驱动设计(DDD) 是一种通过将实现连接到持续进化的模型来满足复杂需求的软件开发方法. 领域驱动设计的前提是:

  • 把项目的主要重点放在核心领域和领域逻辑上
  • 把复杂的设计放在领域模型上
  • 发起技术专家和领域专家之间的创造性协作,以迭代方式完善解决特定领域问题的概念模型

分层

ABP框架遵循DDD原则和模式去实现分层应用程序模型,该模型由四个基本层组成:

  • 表示层: 为用户提供接口. 使用应用层实现与用户交互.
  • 应用层: 表示层与领域层的中介,编排业务对象执行特定的应用程序任务. 使用应用程序逻辑实现用例.
  • 领域层: 包含业务对象以及业务规则. 是应用程序的核心.
  • 基础设施层: 提供通用的技术功能,支持更高的层,主要使用第三方类库.

领域层

实体

实体通常映射到关系型数据库的表中.
实体都继承自Entity类,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
public class Book : Entity<Guid>
{
public string Name { get; set; }
public float Price { get; set; }
protected Book()
{
}
public Book(Guid id)
: base(id)
{
}
}

如果你不想继承基类Entity<TKey>,也可以直接实现IEntity<TKey>接口

Entity<TKey>类只是用给定的主 键类型定义了一个Id属性,在上面的示例中是Guid类型.可以是其他类型如string, int, long或其他你需要的类型.

Guid主键的实体

  • 创建一个构造函数,获取ID作为参数传递给基类.如果没有为GUID Id赋值,ABP框架会在保存时设置它,但是在将实体保存到数据库之前最好在实体上有一个有效的Id.
  • 如果使用带参数的构造函数创建实体,那么还要创建一个 privateprotected 构造函数. 当数据库提供程序从数据库读取你的实体时(反序列化时)将使用它.
  • 不要使用 Guid.NewGuid() 来设置Id! 在创建实体的代码中使用IGuidGenerator服务 传递Id参数. IGuidGenerator经过优化可以产生连续的GUID.这对于关系数据库中的聚集索引非常重要.

具有复合键的实体
有些实体可能需要 复合键 .在这种情况下,可以从非泛型Entity类派生实体.如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class UserRole : Entity
{
public Guid UserId { get; set; }

public Guid RoleId { get; set; }

public DateTime CreationTime { get; set; }

public UserRole()
{

}

public override object[] GetKeys()
{
return new object[] { UserId, RoleId };
}
}

上面的例子中,复合键由UserId和RoleId组成.在关系数据库中,它是相关表的复合主键. 具有复合键的实体应当实现上面代码中所示的GetKeys()方法.

需要注意,复合主键实体不可以使用 IRepository<TEntity, TKey> 接口,因为它需要一个唯一的Id属性. 但你可以使用IRepository<TEntity>.

聚合根

AggregateRoot<TKey>类继承自Entity<TKey>类,所以默认有Id这个属性

值得注意的是 ABP 会默认为聚合根创建仓储,当然,ABP也可以为所有的实体创建仓储

聚合根例子
这是一个具有子实体集合的聚合根例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
public class Order : AggregateRoot<Guid>
{
public virtual string ReferenceNo { get; protected set; }

public virtual int TotalItemCount { get; protected set; }

public virtual DateTime CreationTime { get; protected set; }

public virtual List<OrderLine> OrderLines { get; protected set; }

protected Order()
{

}

public Order(Guid id, string referenceNo)
{
Check.NotNull(referenceNo, nameof(referenceNo));

Id = id;
ReferenceNo = referenceNo;

OrderLines = new List<OrderLine>();
}

public void AddProduct(Guid productId, int count)
{
if (count <= 0)
{
throw new ArgumentException(
"You can not add zero or negative count of products!",
nameof(count)
);
}

var existingLine = OrderLines.FirstOrDefault(ol => ol.ProductId == productId);

if (existingLine == null)
{
OrderLines.Add(new OrderLine(this.Id, productId, count));
}
else
{
existingLine.ChangeCount(existingLine.Count + count);
}

TotalItemCount += count;
}
}

public class OrderLine : Entity
{
public virtual Guid OrderId { get; protected set; }

public virtual Guid ProductId { get; protected set; }

public virtual int Count { get; protected set; }

protected OrderLine()
{

}

internal OrderLine(Guid orderId, Guid productId, int count)
{
OrderId = orderId;
ProductId = productId;
Count = count;
}

internal void ChangeCount(int newCount)
{
Count = newCount;
}

public override object[] GetKeys()
{
return new Object[] {OrderId, ProductId};
}
}

如果你不想你的聚合根继承AggregateRoot<TKey>类,你可以直接实现IAggregateRoot<TKey>接口

Order是一个具有Guid类型Id属性的 聚合根.它有一个OrderLine实体集合.OrderLine是一个具有组合键(OrderId和 ProductId)的实体.

虽然这个示例可能无法实现聚合根的所有最佳实践,但它仍然遵循良好的实践:

  • Order有一个公共的构造函数,它需要 minimal requirements 来构造一个”订单”实例.因此,在没有IdreferenceNo的时候是无法创建订单的.protected/private的构造函数只有从数据库读取对象时 反序列化 才需要.
  • OrderLine的构造函数是internal的,所以它只能由领域层来创建.在Order.AddProduct这个方法的内部被使用.
  • Order.AddProduct实现了业务规则将商品添加到订单中
    所有属性都有protected的set.这是为了防止实体在实体外部任意改变.因此,在没有向订单中添加新产品的情况下设置 TotalItemCount将是危险的.它的值由AddProduct方法维护.

带有组合键的聚合根
虽然这种聚合根并不常见(也不建议使用),但实际上可以按照与上面提到的跟实体相同的方式定义复合键.在这种情况下,要使用非泛型的AggregateRoot基类.

BasicAggregateRoot类
AggregateRoot 类实现了 IHasExtraProperties 和 IHasConcurrencyStamp 接口,这为派生类带来了两个属性. IHasExtraProperties 使实体可扩展(请参见下面的 额外的属性部分) 和 IHasConcurrencyStamp 添加了由ABP框架管理的 ConcurrencyStamp 属性实现乐观并发. 在大多数情况下,这些是聚合根需要的功能.

但是,如果你不需要这些功能,你的聚合根可以继承 BasicAggregateRoot<TKey>(或BasicAggregateRoot).

值对象

值对象类必须实现 GetAtomicValues()方法来返回原始值

仓储

仓储用于领域对象在数据库中的操作, 通常每个 聚合根 或不同的实体创建对应的仓储.

通用(泛型)仓储
ABP为每个聚合根或实体提供了 默认的通用(泛型)仓储 . 你可以在服务中注入 IRepository<TEntity, TKey> 使用标准的CRUD操作.

默认通用仓储用法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class PersonAppService : ApplicationService
{
private readonly IRepository<Person, Guid> _personRepository;

public PersonAppService(IRepository<Person, Guid> personRepository)
{
_personRepository = personRepository;
}

public async Task Create(CreatePersonDto input)
{
var person = new Person { Name = input.Name, Age = input.Age };

await _personRepository.InsertAsync(person);
}

public List<PersonDto> GetList(string nameFilter)
{
var people = _personRepository
.Where(p => p.Name.Contains(nameFilter))
.ToList();

return people
.Select(p => new PersonDto {Id = p.Id, Name = p.Name, Age = p.Age})
.ToList();
}
}

在这个例子中

  • PersonAppService 在它的构造函数中注入了 IRepository<Person, Guid> .
  • Create 方法使用了 InsertAsync 创建并保存新的实体.
  • GetList 方法使用标准LINQ Where 和 ToList 方法在数据源中过滤并获取People集合.

通用仓储提供了一些开箱即用的标准CRUD功能:

  • 提供 Insert 方法用于保存新实体.
  • 提供 Update 和 Delete 方法通过实体或实体id更新或删除实体.
  • 提供 Delete 方法使用条件表达式过滤删除多个实体.
  • 实现了 IQueryable<TEntity>, 所以你可以使用LINQ和扩展方法 FirstOrDefault, Where, OrderBy, ToList 等…
  • 所有方法都具有 sync(同步) 和 async(异步) 版本

只读仓储

对于想要使用只读仓储的开发者,我们提供了IReadOnlyRepository<TEntity, TKey> 与 IReadOnlyBasicRepository<Tentity, TKey>接口.

无主键的通用(泛型)仓储

如果你的实体没有id主键 (例如, 它可能具有复合主键) 那么你不能使用上面定义的 IRepository<TEntity, TKey>, 在这种情况下你可以仅使用实体(类型)注入 IRepository<TEntity>.

IRepository<TEntity> 有一些缺失的方法, 通常与实体的 Id 属性一起使用. 由于实体在这种情况下没有 Id 属性, 因此这些方法不可用. 比如 Get 方法通过id获取具有指定id的实体. 不过, 你仍然可以使用IQueryable<TEntity>的功能通过标准LINQ方法查询实体.

自定义仓储

对于大多数情况, 默认通用仓储就足够了. 但是, 你可能会需要为实体创建自定义仓储类.

自定义仓储接口,首先在领域层定义一个仓储接口:

1
2
3
4
public interface IPersonRepository : IRepository<Person, Guid>
{
Task<Person> FindByNameAsync(string name);
}

此接口扩展了 IRepository<Person, Guid> 以使用已有的通用仓储功能.

自定义存储库依赖于你使用的数据访问工具. 在此示例中, 我们将使用Entity Framework Core:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class PersonRepository : EfCoreRepository<MyDbContext, Person, Guid>, IPersonRepository
{
public PersonRepository(IDbContextProvider<TestAppDbContext> dbContextProvider)
: base(dbContextProvider)
{

}

public async Task<Person> FindByNameAsync(string name)
{
var dbContext = await GetDbContextAsync();
return await dbContext.Set<Person>()
.Where(p => p.Name == name)
.FirstOrDefaultAsync();
}
}

你可以直接使用数据库访问提供程序 (本例中是 DbContext ) 来执行操作.

IAsyncQueryableExecuter
IAsyncQueryableExecuter 是一个用于异步执行 IQueryable<T>对象的服务,不依赖于实际的数据库提供程序.

示例:
注入并使用IAsyncQueryableExecuter.ToListAsync()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Linq;

namespace AbpDemo
{
public class ProductAppService : ApplicationService, IProductAppService
{
private readonly IRepository<Product, Guid> _productRepository;
private readonly IAsyncQueryableExecuter _asyncExecuter;

public ProductAppService(
IRepository<Product, Guid> productRepository,
IAsyncQueryableExecuter asyncExecuter)
{
_productRepository = productRepository;
_asyncExecuter = asyncExecuter;
}

public async Task<ListResultDto<ProductDto>> GetListAsync(string name)
{
//Create the query
var query = _productRepository
.Where(p => p.Name.Contains(name))
.OrderBy(p => p.Name);

//Run the query asynchronously
List<Product> products = await _asyncExecuter.ToListAsync(query);

//...
}
}
}

ABP框架使用实际数据库提供程序的API异步执行查询.虽然这不是执行查询的常见方式,但它是使用异步API而不依赖于数据库提供者的最佳方式.

领域服务

领域驱动设计(DDD) 解决方案中,核心业务逻辑通常在聚合(实体)和领域服务中实现.

在以下情况下特别需要创建领域服务

  • 你实现了依赖于某些服务(如存储库或其他外部服务)的核心域逻辑.
  • 你需要实现的逻辑与多个聚合/实体相关,因此它不适合任何聚合.

领域服务是简单的无状态类. 虽然你不必从任何服务或接口派生,但ABP框架提供了一些有用的基类和约定.

DomainServiceIDomainService
DomainService基类派生领域服务或直接实现 IDomainService接口.

示例:
创建从DomainService基类派生的领域服务.

1
2
3
4
5
6
7
8
using Volo.Abp.Domain.Services;
namespace MyProject.Issues
{
public class IssueManager : DomainService
{

}
}

当你这样做时:ABP框架自动将类注册为瞬态生命周期到依赖注入系统.
你可以直接使用一些常用服务作为基础属性,而无需手动注入 (例如ILogger and IGuidGenerator).

建议使用ManagerService 后缀命名领域服务. 我们通常使用如上面示例中的 Manager 后缀.

示例:
实现将问题分配给用户的领域逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class IssueManager : DomainService
{
private readonly IRepository<Issue, Guid> _issueRepository;
public IssueManager(IRepository<Issue, Guid> issueRepository)
{
_issueRepository = issueRepository;
}

public async Task AssignAsync(Issue issue, AppUser user)
{
var currentIssueCount = await _issueRepository
.CountAsync(i => i.AssignedUserId == user.Id);

//Implementing a core business validation
if (currentIssueCount >= 3)
{
throw new IssueAssignmentException(user.UserName);
}
issue.AssignedUserId = user.Id;
}
}

问题是定义如下所示的 聚合根:

1
2
3
4
5
6
public class Issue : AggregateRoot<Guid>
{
public Guid? AssignedUserId { get; internal set; }

//...
}

使用internalset确保外层调用者不能直接在调用 set,并强制始终使用IssueManagerUser分配 Issue.

应用程序服务与领域服务
虽然应用服务领域服务都实现了业务规则,但存在根本的逻辑和形式差异:

  • 应用程序服务实现应用程序的用例(典型Web应用程序中的用户交互),而领域服务实现核心的、用例独立的领域逻辑.
  • 应用程序服务获取/返回 数据传输对象,领域服务方法通常获取和返回领域对象(实体,值对象).
  • 领域服务通常由应用程序服务或其他领域服务使用,而应用程序服务由表示层或客户端应用程序使用.

生命周期
领域服务的生命周期是瞬态的,它们会自动注册到依赖注入服务.

规约

规约模式用于为实体和其他业务对象定义 命名、可复用、可组合和可测试的过滤器 .
你可以创建一个由Specification<Customer>派生的新规约类.

1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
using System.Linq.Expressions;
using Volo.Abp.Specifications;
namespace MyProject
{
public class Age18PlusCustomerSpecification : Specification<Customer>
{
public override Expression<Func<Customer, bool>> ToExpression()
{
return c => c.Age >= 18;
}
}
}

你也可以直接实现ISpecification<T>接口,但是基类Specification<T>做了大量简化.

虽然规约模式通常与C#lambda表达式相比较,算是一种更老的方式.一些开发人员可能认为不再需要它,我们可以直接将表达式传入到仓储或领域服务中,如下所示:

1
var count = await _customerRepository.CountAsync(c => c.Balance > 100000 && c.Age => 18);

自从ABP的仓储支持表达式,这是一个完全有效的用法.你不必在应用程序中定义或使用任何规约,可以直接使用表达式.

所以,规约的意义是什么?为什么或者应该在什么时候考虑去使用它?

何时使用?
使用规约的一些好处:

  • 可复用:假设你在代码库的许多地方都需要用到优质顾客过滤器.如果使用表达式而不创建规约,那么如果以后更改“优质顾客”的定义会发生什么?假设你想将最低余额从100000美元更改为250000美元,并添加另一个条件,成为顾客超过3年.如果使用了规约,只需修改一个类.如果在任何其他地方重复(复制/粘贴)相同的表达式,则需要更改所有的表达式.
  • 可组合:可以组合多个规约来创建新规约.这是另一种可复用性.
    命名:PremiumCustomerSpecification更好地解释了为什么使用规约,而不是复杂的表达式.因此,如果在你的业务中使用了一个有意义的表达式,请考虑使用规约.
  • 可测试:规约是一个单独(且易于)测试的对象.

什么时侯不要使用?

  • 没有业务含义的表达式:不要对与业务无关的表达式和操作使用规约.
  • 报表:如果只是创建报表,不要创建规约,而是直接使用IQueryable 和LINQ表达式.你甚至可以使用普通SQL、视图或其他工具生成报表.DDD不关心报表,因此从性能角度来看,查询底层数据存储的方式可能很重要.

应用服务层

应用服务

应用服务实现应用程序的用例, 将领域层逻辑公开给表示层.从表示层调用应用服务,DTO作为参数. 返回DTO给表示层.

  1. 从github下载nvm仓库到 ~/目录 地址:https://github.com/nvm-sh/nvm.git
    git clone https://github.com/nvm-sh/nvm.git
  2. 进入 nvm目录中执行install.sh 等待执行完成
    sh install.sh
  3. 配置nvm环境变量将下述代码复制到 ~/.bash_profile
    vim ~/.bash_profile
1
2
3
4
5
6
7
8
9
export NVM_DIR="$HOME/.nvm"

[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

# This loads nvm

[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"

# This loads nvm bash_completion
  1. 执行source ~/.bash_profile
  2. 执行nvm –version是否可以正常输出,若不行则重启终端再次尝试
  3. nvm操作
    ①:使用 nvm install node版本号 也可直接输入nvm install node 最新版本
    ②:使用 nvm list 或 nvm ls 可查看当前安装的node版本
    ③:使用 nvm use node版本 可以切换当前使用的node
    ④:使用 nvm alias default node版本 可以指定默认打开终端时的node版本

问题

每开一次终端,要 source ~/.bash_profile 环境变量才生效。

原因

MacOS Catalina(10.15),macOS的默认终端从bash变成了zsh。
Mac10.15以下版本,默认shell环境是bash,系统环境变量的配置文件是 /etc/profile 文件。
Mac10.15以上版本,默认shell环境是zsh,系统环境变量的配置文件是 /etc/zshrc 文件。

解决方法

编辑个人主目录下的.zshrc 这个文件

1
vim ~/.zshrc

在最后一行少添加一句:source ~/.bash_profile

这样每次打开新窗口或标签页就自动执行了source ~/.bash_profile,环境变量就有了

您浏览到的信息,来源于网络,网站中的内容只为个人研究记录,对于信息真实性本站点概不负责,如有侵权,请留意联系,谢谢!