初识 MASA Framework

MASA Framework
全新的.NET现代应用开发,提供分布式应用运行时–基于Dapr云原生最佳实践,能够快速实现分布式、微服务、DDD,SaaS等现代应用开发。官方文档参阅

先决条件

开发计算机上应安装以下工具:

一个集成开发环境 (比如: Visual Studio) 它需要支持 .NET 6.0 的开发.

环境配置

MacOS dotnet环境配置

1
2
3
4
5
6
7
8
# 下载对应脚本进行安装 https://dotnet.microsoft.com/zh-cn/download/dotnet/scripts 
./dotnet-install.sh --channel 6.0

echo 'export DOTNET_ROOT=$HOME/.dotnet' >> ~/.zshrc
echo 'export PATH=$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools' >> ~/.zshrc

source ~/.zshrc

Quickstarts

Project Creation

create a blank solution

1
dotnet new sln -n Masa.EShop.Demo

Add Contracts Project

1
2
3
dotnet new classlib -n Masa.EShop.Contracts.Catalog -o Contracts/Masa.EShop.Contracts.Catalog -f net6.0
# -f 用于指定使用net sdk 6.0,因为 Masa 目前支持的是net 6.0
dotnet sln add Contracts/**/*.csproj --solution-folder Contracts

Add Services Project

1
2
dotnet new web -n Masa.EShop.Service.Catalog -o Services/Masa.EShop.Service.Catalog -f net6.0
dotnet sln add Services/**/*.csproj --solution-folder Services

Create and use MiniAPIs in Services

1
2
3
cd Services/Masa.EShop.Service.Catalog
dotnet add package Masa.Contrib.Service.MinimalAPIs --prerelease
# --prerelease to use the pre release nuget package
  • To use MinimalAPIs, change file Program.cs:

    use var app = builder.AddServices(); replace var app = builder.AddServices();

  • create Services folder,add class HealthService, inherit from ServiceBase

    1
    2
    3
    4
    public class HealthService : ServiceBase
    {
    public IResult Get() => Results.Ok("success");
    }

Domain

在前面的章节中, 使用MinimalAPIs提供最小依赖项的HTTP API

对于本篇文档, 我们将要展示创建一个充血模型的商品模型, 并实现领域驱动设计 (DDD)的最佳实践

领域层是项目的核心,我们建议您按照以下结构来存放:

  • Domain: 领域层 (可以与主服务在同一项目, 也可单独存储到一个独立的类库中)
    • Aggregates: 聚合根及相关实体
    • Events: 领域事件 (建议以 DomainEvent 结尾)
    • Repositories: 仓储 (仅存放仓储的接口)
    • Services: 领域服务
    • EventHandlers: 进程内领域事件处理程序 (建议以 DomainEventHandler 结尾)

包引入

1
2
3
dotnet add package Masa.Contrib.Ddd.Domain  --prerelease
dotnet add package Masa.Contrib.Data.Mapping.Mapster --prerelease
dotnet add package Masa.BuildingBlocks.Data.MappingExtensions --prerelease

program.cs注册 Mapster 映射器

1
builder.Services.AddMapster();

聚合

选中 Aggregates 文件夹, 我们将新建包括 CatalogItem 、CatalogBrand 的聚合根以及 CatalogType 枚举类, 并在初始化商品时添加商品领域事件

  • 商品

    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
    public class CatalogItem : FullAggregateRoot<Guid, int>
    {
    public string Name { get; private set; } = default!;

    public string Description { get; private set; } = default!;

    public decimal Price { get; private set; }

    public string PictureFileName { get; private set; } = default!;

    private int _catalogTypeId;

    public CatalogType CatalogType { get; private set; } = default!;

    private Guid _catalogBrandId;

    public CatalogBrand CatalogBrand { get; private set; } = default!;

    public int AvailableStock { get; private set; }

    public int RestockThreshold { get; private set; }

    public int MaxStockThreshold { get; private set; }

    public CatalogItem(Guid id, Guid catalogBrandId, int catalogTypeId, string name, string description, decimal price, string pictureFileName) : base(id)
    {
    _catalogBrandId = catalogBrandId;
    _catalogTypeId = catalogTypeId;
    Name = name;
    Description = description;
    Price = price;
    PictureFileName = pictureFileName;
    }
    }
  • 商品类型

    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
    public class CatalogType : Enumeration
    {
    public static CatalogType Cap = new Cap();
    public static CatalogType Mug = new(2, "Mug");
    public static CatalogType Pin = new(3, "Pin");
    public static CatalogType Sticker = new(4, "Sticker");
    public static CatalogType TShirt = new(5, "T-Shirt");

    public CatalogType(int id, string name) : base(id, name)
    {
    }

    public virtual decimal TotalPrice(decimal price, int num)
    {
    return price * num;
    }
    }

    public class Cap : CatalogType
    {
    public Cap() : base(1, "Cap")
    {
    }

    public override decimal TotalPrice(decimal price, int num)
    {
    return price * num * 0.95m;
    }
    }
  • 商品品牌

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class CatalogBrand : FullAggregateRoot<Guid, int>
    {
    public string Brand { get; private set; } = null!;

    public CatalogBrand(string brand)
    {
    Brand = brand;
    }
    }

领域事件

我们将创建商品的领域事件, 它将在创建商品成功后被其它服务所订阅

创建商品的领域事件属于集成事件, 为保证订阅事件的重用以及订阅事件所属类库的最小依赖, 我们将其拆分为 CatalogItemCreatedIntegrationDomainEventCatalogItemCreatedIntegrationEvent 两个类

选中 Masa.EShop.Contracts.Catalog 类库,添加nuget包

1
2
# 注意使用命令的时候切换到对应项目文件夹
dotnet add package Masa.BuildingBlocks.Dispatcher.IntegrationEvents --prerelease

新增 IntegrationEvents 文件夹,新建文件 CatalogItemCreatedIntegrationEvent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public record CatalogItemCreatedIntegrationEvent : IntegrationEvent
{
public Guid Id { get; set; }

public string Name { get; set; } = default!;

public string Description { get; set; }

public decimal Price { get; set; }

public string PictureFileName { get; set; } = "";

public int CatalogTypeId { get; set; }

public Guid CatalogBrandId { get; set; }
}

新建创建商品集成事件 CatalogItemCreatedIntegrationEvent

集成事件在规约层存储, 后期可将规约层通过nuget方式引用, 以方便其它服务订阅事件使用 (IntegrationEvent

选中项目 Masa.EShop.Service.Catalog 的领域事件 (Events)文件夹, 新建创建商品集成领域事件 CatalogItemCreatedIntegrationDomainEvent

并在该项目添加对类库 Masa.EShop.Contracts.Catalog 的引用

1
2
3
public record CatalogItemCreatedIntegrationDomainEvent : CatalogItemCreatedIntegrationEvent, IIntegrationDomainEvent
{
}
1
2
# 注意使用命令的时候切换到对应项目文件夹
dotnet add reference ../../Contracts/Masa.EShop.Contracts.Catalog/Masa.EShop.Contracts.Catalog.csproj

领域事件可以在聚合根或领域服务中发布, 例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CatalogItem : FullAggregateRoot<Guid, int>
{
--------------

public CatalogItem(Guid id, Guid catalogBrandId, int catalogTypeId, string name, string description, decimal price, string pictureFileName) : base(id)
{
--------------

AddCatalogItemDomainEvent();//添加创建商品集成领域事件
}

private void AddCatalogItemDomainEvent()
{
var domainEvent = this.Map<CatalogItemCreatedIntegrationDomainEvent>();
domainEvent.CatalogBrandId = _catalogBrandId;
domainEvent.CatalogTypeId = _catalogTypeId;
AddDomainEvent(domainEvent);
}
}

对象映射功能为 CatalogItem 类转换为 CatalogItemCreatedIntegrationDomainEvent 提供了帮助, 具体可查看对象映射文档

仓储

选中领域层 Masa.EShop.Service.Catalog 中的 Repositories 文件夹并创建 ICatalogItemRepository 接口, 继承 IRepository<CatalogItem, Guid>, 可用于扩展商品仓储

1
2
3
4
public interface ICatalogItemRepository : IRepository<CatalogItem, Guid>
{
//如果有需要扩展的能力, 可在自定义仓储中扩展
}

对于新增加继承IRepository<CatalogItem, Guid>的接口, 我们需要在Repository<CatalogDbContext, CatalogItem, Guid>的基础上扩展其实现, 由于实现并不属于领域层, 这里我们会在后面的文档实现这个Repository

领域服务

选中领域层 Masa.EShop.Service.Catalog 中的 Services 文件夹并创建 商品领域服务

1
2
3
public class CatalogItemDomainService : DomainService
{
}
  • 继承 DomainService 的类会自动完成服务注册, 无需手动注册

最终解决方案结构类似下图

userface

Save Or Get Data

在开发中, 我们需要用到数据库, 以便对数据能进行存储或读取, 下面例子我们将使用Sqlite数据库进行数据的存储与读取, 如果你的业务使用的是其它数据库, 可参考文档选择与之匹配的数据库包

前提

选中领域层 Masa.EShop.Service.Catalog ,在项目根目录下,新建如下层次文件夹

  • Infrastructure
    • Repositories
    • Middleware
    • Extensions
    • EntityConfigurations

安装 Masa.Contrib.Data.EFCore.SqliteMasa.Contrib.Data.Contracts

1
2
dotnet add package Masa.Contrib.Data.EFCore.Sqlite  --prerelease
dotnet add package Masa.Contrib.Data.Contracts --prerelease //根据需要选择性引用

Masa.Contrib.Data.Contracts 提供了数据过滤的能力, 但它不是必须的

使用

  1. Infrastructure 文件夹新建数据上下文 CatalogDbContext

    • 数据上下文的格式 : XXXDbContext, 并继承 MasaDbContext<XXXDbContext>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class CatalogDbContext : MasaDbContext<CatalogDbContext>
    {
    public CatalogDbContext(MasaDbContextOptions<CatalogDbContext> options) : base(options)
    {ss

    }

    protected override void OnModelCreatingExecuting(ModelBuilder builder)
    {
    builder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly);
    base.OnModelCreatingExecuting(builder);
    }
    }

    数据库迁移时将执行 OnModelCreatingExecuting 方法, 我们可以在其中配置与数据库表的映射关系, 为避免出现流水账式的数据库映射记录, 我们通常会将不同表的映射情况分别写到不同的配置对象中去, 并在 OnModelCreatingExecuting 指定当前上下文映射的程序集.

  2. 配置数据库中商品表与 CatalogItem 的映射关系,在文件夹 EntityConfigurations 新建 CatalogItemEntityTypeConfiguration

    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
    public class CatalogItemEntityTypeConfiguration
    : IEntityTypeConfiguration<CatalogItem>
    {
    public void Configure(EntityTypeBuilder<CatalogItem> builder)
    {
    builder.ToTable("Catalog");

    builder.Property(ci => ci.Id)
    .IsRequired();

    builder.Property(ci => ci.Name)
    .IsRequired()
    .HasMaxLength(50);

    builder.Property(ci => ci.Price)
    .IsRequired();

    builder.Property(ci => ci.PictureFileName)
    .IsRequired(false);

    builder
    .Property<Guid>("_catalogBrandId")
    .UsePropertyAccessMode(PropertyAccessMode.Field)
    .HasColumnName("CatalogBrandId")
    .IsRequired();

    builder
    .Property<int>("_catalogTypeId")
    .UsePropertyAccessMode(PropertyAccessMode.Field)
    .HasColumnName("CatalogTypeId")
    .IsRequired();

    builder.HasOne(ci => ci.CatalogBrand)
    .WithMany()
    .HasForeignKey("_catalogBrandId");

    builder.HasOne(ci => ci.CatalogType)
    .WithMany()
    .HasForeignKey("_catalogTypeId");
    }
    }
  3. 配置数据库连接字符串

    通常情况下数据库链接字符串配置信息存储在本地配置文件中, 框架支持在不同的配置文件中存放不同环境下使用的数据库链接字符串, 而不需要修改任何代码

    1
    2
    3
    4
    5
    {
    "ConnectionStrings": {
    "DefaultConnection": "Data Source=./Data/Catalog.db;"
    }
    }

    如果你的项目使用了配置中心, 数据库链接字符串也在配置中心存储, 那么请跳过步骤3, 它不会对你有任何的帮助

  4. Program.cs里注册数据上下文

    1
    2
    3
    4
    5
    6
    builder.Services.AddMasaDbContext<CatalogDbContext>(dbContextBuilder =>
    {
    dbContextBuilder
    .UseSqlite() //使用Sqlite数据库
    .UseFilter(); //数据数据过滤
    });

    UseSqlite 方法由 Masa.Contrib.Data.EFCore.Sqlite 提供, 我们建议在使用时不传入数据库字符串

继承 MasaDbContext 的数据库默认使用 ConnectionStrings 节点下的 DefaultConnection 配置, 想了解更多关于链接字符串相关的知识可查看 文档, 除了使用本地配置文件存放数据库链接字符串之外, 它还支持其它方式, 详细请查看 文档

其它

MasaFramework 并未约束项目必须使用 Entity Framework Core, 查看已支持的ORM框架

自定义仓储实现

虽然框架已经提供了仓储功能, 但它的功能是有限的, 当默认仓储提供的功能不足以满足我们的需求时, 我们就需要在默认仓储的基础上进行扩展或者重写, 自定义仓储的接口与实现是一对一的, 它们必须是成对出现的

前提2

安装 Masa.Contrib.Ddd.Domain.Repository.EFCore

1
dotnet add package Masa.Contrib.Ddd.Domain.Repository.EFCore  --prerelease

如果后续考虑可能更换ORM框架, 建议将仓储的实现可以单独存储到一个独立的类库中

使用2

在文件夹里 Infrastructure/Repositories 新建 CatalogItemRepository 用于实现 ICatalogItemRepository

1
2
3
4
5
6
public class CatalogItemRepository : Repository<CatalogDbContext, CatalogItem, Guid>, ICatalogItemRepository
{
public CatalogItemRepository(CatalogDbContext context, IUnitOfWork unitOfWork) : base(context, unitOfWork)
{
}
}

自定义仓储实现可以继承 Repository<CatalogDbContext, CatalogItem, Guid>, 我们只需要在默认仓储实现的基础上扩展新扩展的方法即可, 如果你不满意默认实现, 也可重写父类的方法, 默认仓储支持了很多功能, 查看详细文档

无论是直接使用框架提供的仓储能力, 还是基于默认仓储提供的能力基础上进行扩展, 都需要我们在Program中进行注册, 否则仓储将无法正常使用, 例如:

1
2
3
4
builder.Services.AddDomainEventBus(options =>
{
options.UseRepository<CatalogDbContext>();
});

框架是如何完成自动注册, 为何项目提示仓储未注册, 点击查看文档

如果不在默认仓储的的基础上扩展, 而是完全自定义仓储, 则可以使用按约定自动注册功能简化服务注册

事件总线

通过事件总线帮助我们解耦不同架构层次, 根据事件类型我们将事件总线划分为:

  • 进程内事件总线
  • 集成事件总线

必要条件

  • 进程内事件总线

    1
    dotnet add package Masa.Contrib.Dispatcher.Events  --prerelease // 支持进程内事件

    进程内事件总线的实现由 Masa.Contrib.Dispatcher.Events 提供

  • 集成事件总线

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    dotnet add package Masa.Contrib.Dispatcher.IntegrationEvents  --prerelease
    # 使用具有发件箱模式的集成事件
    dotnet add package Masa.Contrib.Dispatcher.IntegrationEvents.Dapr --prerelease
    # 使用dapr提供的pubsub能力
    dotnet add package Masa.Contrib.Dispatcher.IntegrationEvents.EventLogs.EFCore --prerelease
    # 本地消息表
    dotnet add package Masa.BuildingBlocks.Data.UoW --prerelease
    #提供uow
    dotnet add package Masa.Contrib.Ddd.Domain.Repository.EFCore --prerelease
    #提供Efcore Repository

    而后续发送集成事件的类所在类库只需引用 Masa.BuildingBlocks.Dispatcher.IntegrationEvents 即可 (如果当前类库已经引用了 Masa.Contrib.Dispatcher.IntegrationEvents.*, 则无需重复引用 Masa.BuildingBlocks.Dispatcher.IntegrationEvents)

    集成事件总线的实现由 Masa.Contrib.Dispatcher.IntegrationEvents.DaprMasa.Contrib.Dispatcher.IntegrationEvents.EventLogs.EFCore提供

使用事件总线

  1. 注册集成事件与进程内事件, 修改 Program

    1
    2
    3
    4
    5
    6
    builder.Services
    .AddIntegrationEventBus(integrationEventBus =>
    integrationEventBus
    .UseDapr()
    .UseEventLog<CatalogDbContext>()
    .UseEventBus())

    由于我们的项目使用了DDD, 我们可以将领域事件总线与进程内事件总线、集成事件总线注册代码简写为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    builder.Services
    .AddDomainEventBus(options =>
    {
    options.UseIntegrationEventBus(integrationEventBus =>
    integrationEventBus
    .UseDapr()
    .UseEventLog<CatalogDbContext>())
    .UseEventBus()
    .UseRepository<CatalogDbContext>();
    });
  2. 中间件

    进程内事件支持 AOP, 提供与ASP.NET Core类似的中间件的功能, 例如: 记录所有事件的日志

    • 自定义日志中间件
    1. 文件夹 Infrastructure/Middleware 新建日志中间件 LoggingEventMiddleware, 并继承 EventMiddleware<TEvent>

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      public class LoggingEventMiddleware<TEvent> : EventMiddleware<TEvent>
      where TEvent : IEvent
      {
      private readonly ILogger<LoggingEventMiddleware<TEvent>> _logger;
      public LoggingEventMiddleware(ILogger<LoggingEventMiddleware<TEvent>> logger) =>_logger = logger;

      public override async Task HandleAsync(TEvent @event, EventHandlerDelegate next)
      {
      _logger.LogInformation("----- Handling command {CommandName} ({@Command})", @event.GetType().GetGenericTypeName(), @event);
      await next();
      }
      }
    2. 修改注册进程内事件代码, 指定需要执行的中间件, 修改Program

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
      builder.Services
    .AddDomainEventBus(options =>
    {
    options.UseIntegrationEventBus(integrationEventBus =>
    integrationEventBus
    .UseDapr()
    .UseEventLog<CatalogDbContext>())
    .UseEventBus(eventBusBuilder => eventBusBuilder.UseMiddleware(typeof(LoggingEventMiddleware<>))) //指定需要执行的中间件
    .UseRepository<CatalogDbContext>();
    });

    进程内事件总线的中间件是先进先执行

    除此之外, 进程内事件还支持[Handler编排]、[Saga]等, 查看详细文档

    • 验证中间件

    Masa.Contrib.Dispatcher.Events.FluentValidation 中我们提供了基于 FluentValidation 的验证中间件, 它可以帮助我们在发送进程内事件后自动调用验证, 协助我们完成对参数的校验

    1. 安装 Masa.Contrib.Dispatcher.Events.FluentValidationFluentValidation.AspNetCore

      1
      2
      dotnet add package Masa.Contrib.Dispatcher.Events.FluentValidation --prerelease 
      dotnet add package FluentValidation.AspNetCore
    2. 指定进程内事件使用 FluentValidation 的中间件

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      builder.Services
      .AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()) //添加指定程序集下的`FluentValidation`验证器
      .AddDomainEventBus(options =>
      {
      options.UseIntegrationEventBus(integrationEventBus =>
      integrationEventBus
      .UseDapr()
      .UseEventLog<CatalogDbContext>())
      .UseEventBus(eventBusBuilder => eventBusBuilder.UseMiddleware(new[] { typeof(ValidatorMiddleware<>), typeof(LoggingEventMiddleware<>) })) //使用验证中间件、日志中间件
      .UseUoW<CatalogDbContext>() //使用工作单元, 确保原子性
      .UseRepository<CatalogDbContext>();
      });

    基于FluentValidation的验证部分代码将在下面会讲到

Application 应用服务层

我们在应用服务层, 存放事件以及事件处理程序, 使用CQRS模式我们将事件分为命令端 (Command)、查询端 (Query)

前提3

选中规约层 Masa.EShop.Contracts.Catalog, 新建文件夹 Request,并安装 Masa.BuildingBlocks.ReadWriteSplitting.Cqrs

选中领域层 Masa.EShop.Service.Catalog ,在项目根目录下新建文件夹结构如下:

  • Application
    • Catalogs
      • Commands
      • Queries
1
2
dotnet add package Masa.ReadWriteSplitting.Cqrs --prerelease 
dotnet add package Masa.Utils.Extensions.Expressions --prerelease

命令端(Command)

  1. 选中领域层 Masa.EShop.Service.Catalog, 在 Commands 文件夹中新建 CatalogItemCommand 类并继承 Command

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public record CatalogItemCommand : Command
    {
    public string Name { get; set; } = null!;

    public string Description { get; set; } = string.Empty;

    public decimal Price { get; set; }

    public string PictureFileName { get; set; } = string.Empty;

    public Guid CatalogBrandId { get; set; }

    public int CatalogTypeId { get; set; }
    }
  2. Commands 文件夹中新建 CatalogItemCommandValidator 类并继承 AbstractValidator<CatalogItemCommand>

我们建议使用FluentValidation提供的验证功能, 为每个 Command 定义对应的验证类, 排除那些参数不符合规定的请求进入 Handler, 如果不需要使用它, 可跳过此步骤

自定义验证提供了很多验证方法, 比如NotNullLength等, 更多使用技巧查看文档

1
2
3
4
5
6
7
8
public class CatalogItemCommandValidator : AbstractValidator<CatalogItemCommand>
{
public CatalogItemCommandValidator()
{
RuleFor(command => command.Name).NotNull().Length(1, 20).WithMessage("商品名称长度介于在1-20之间");
RuleFor(command => command.CatalogTypeId).Must(typeId => Enumeration.GetAll<CatalogType>().Any(item => item.Id == typeId)).WithMessage("不支持的商品分类");
}
}

除此之外, 我们还扩展了其它验证方法, 例如: 中文验证手机号验证身份证验证等, 查看文档

查询端(Query)

  1. 选中规约层 Masa.EShop.Contracts.Catalog, 在文件夹 Request 新建类 ItemsQueryBase, 并继承 Query

    1
    2
    3
    4
    5
    6
    public abstract record ItemsQueryBase<TResult> : Query<TResult>
    {
    public virtual int Page { get; set; } = 1;

    public virtual int PageSize { get; set; } = 20;
    }
  2. 选中领域层 Masa.EShop.Service.Catalog, 在 Queries 文件夹中新建 CatalogItemQuery 类并继承 ItemsQueryBase

1
2
3
4
5
6
7
8
9
10
11
12
13
public record CatalogItemQuery: ItemsQueryBase<PaginatedListBase<CatalogListItemDto>>
{
public string Name { get; set; }

public override int Page { get; set; } = 1;

public override int PageSize { get; set; } = 20;

/// <summary>
/// 存储查询结果
/// </summary>
public override PaginatedListBase<CatalogListItemDto> Result { get; set; }
}

验证类不是必须的, 根据业务情况选择性创建即可, 并没有强制性要求每个事件都必须有对应一个的事件验证类, 通常情况下查询端可以忽略参数校验

处理程序(Handler)

选中规约层 Masa.EShop.Contracts.Catalog,新建文件夹 Dto, 并新建类 CatalogListItemDto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class CatalogListItemDto
{
public Guid Id { get; set; }

public string Name { get; set; } = null!;

public decimal Price { get; set; }

public string PictureFileName { get; set; } = "";

public int CatalogTypeId { get; set; }

public string CatalogTypeName { get; set; }

public Guid CatalogBrandId { get; set; }

public string CatalogBrandName { get; set; }

public int AvailableStock { get; set; }
}

选中领域层 Masa.EShop.Service.Catalog,在 Infrastructure 文件夹新建类 GlobalMappingConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using Mapster;

namespace Masa.EShop.Service.Catalog.Infrastructure;

public static class GlobalMappingConfig
{
public static void Mapping()
{
MappingCatalogItemToCatalogListItemDto();
}

private static void MappingCatalogItemToCatalogListItemDto()
{
TypeAdapterConfig<CatalogItem, CatalogListItemDto>
.NewConfig()
.Map(dest => dest.CatalogTypeName, catalogItem => catalogItem.CatalogType.Name)
.Map(dest => dest.CatalogBrandName, catalogItem => catalogItem.CatalogBrand.Brand);
}
}

选中领域层 Masa.EShop.Service.Catalog, 在 Program.cs文件里注册 GlobalMappingConfig

1
GlobalMappingConfig.Mapping();//指定自定义映射

选中领域层 Masa.EShop.Service.Catalog, 在 Catalogs 文件夹中新建 CatalogItemHandler 类,用于存放商品事件的处理程序

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
using System.Linq.Expressions;
using Masa.BuildingBlocks.Data;
using Masa.BuildingBlocks.Ddd.Domain.Repositories;
using Masa.EShop.Service.Catalog.Application.Catalogs.Commands;
using Masa.EShop.Service.Catalog.Application.Catalogs.Queries;
using Masa.EShop.Service.Catalog.Domain.Aggregates;
using Masa.EShop.Service.Catalog.Domain.Repositories;
using Masa.Contrib.Dispatcher.Events;
using Masa.EShop.Contracts.Catalog.Dto;
using Masa.Utils.Models;
namespace Masa.EShop.Service.Catalog.Application.Catalogs;

public class CatalogItemHandler
{
private readonly ICatalogItemRepository _catalogItemRepository;

public CatalogItemHandler(ICatalogItemRepository catalogItemRepository)
{
_catalogItemRepository = catalogItemRepository;
}

/// <summary>
/// 创建商品处理程序
/// </summary>
[EventHandler]
public async Task AddAsync(CatalogItemCommand command, ISequentialGuidGenerator guidGenerator, CancellationToken cancellationToken)
{
var catalogItem = new CatalogItem(guidGenerator.NewId(), command.CatalogBrandId, command.CatalogTypeId, command.Name, command.Description, command.Price, command.PictureFileName);
await _catalogItemRepository.AddAsync(catalogItem, cancellationToken);
}

/// <summary>
/// 查询处理程序
/// </summary>
[EventHandler]
public async Task GetListAsync(CatalogItemQuery query, CancellationToken cancellationToken)
{
Expression<Func<CatalogItem, bool>> condition = catalogItem => true;
condition = condition.And(!query.Name.IsNullOrWhiteSpace(), catalogItem => catalogItem.Name.Contains(query.Name!));//此处使用了`Masa.Utils.Extensions.Expressions`提供的扩展

var catalogItems = await _catalogItemRepository.GetPaginatedListAsync(condition, new PaginatedOptions(query.Page, query.PageSize), cancellationToken);

query.Result = new PaginatedListBase<CatalogListItemDto>()
{
Total = catalogItems.Total,
TotalPages = catalogItems.TotalPages,
Result = catalogItems.Result.Map<List<CatalogListItemDto>>()//使用了对象映射功能
};
}
}

多级缓存(MultilevelCache)

随着业务的增长, 访问系统的用户越来越多, 直接读取数据库的性能也变得越来越差, IO读取出现瓶颈, 这个时候我们可以有两种选择:

  • 使用IO读写更快的磁盘, 比如: 使用固态磁盘代替读写速度差一点的机械磁盘

    • 优点: 无需更改代码
    • 缺点: 读写速度更高的磁盘意味着更大的成本压力, 且提升是有限的
  • 使用缓存技术代替直接读取数据库

    • 优点: 服务器硬件成本未上涨, 但可以带来十倍的性能提升
    • 缺点: 针对读大于写的场景更为实用, 不可用于复杂查询

而多级缓存是由分布式缓存与内存缓存的组合而成, 它可以给我们提供比分布式缓存更强的读取能力, 下面我们将使用多级缓存技术, 用于提升获取 商品 详情的速度

MultilevelCache前提

选中领域层 Masa.EShop.Service.Catalog

1
2
3
4
5
dotnet add package Masa.Contrib.Caching.MultilevelCache  --prerelease
# 多级缓存提供者

dotnet add package Masa.Contrib.Caching.Distributed.StackExchangeRedis --prerelease
# 分布式Redis缓存提供者

MultilevelCache 使用

  1. 配置分布式 Redis 缓存配置信息, 修改 appsettings.json

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {
    "RedisConfig": {
    "Servers": [
    {
    "Host": "localhost",
    "Port": 6379
    }
    ],
    "DefaultDatabase": 0
    }
    }
  2. 配置多级缓存中内存缓存的配置信息, 修改 appsettings.json

    1
    2
    3
    4
    5
    6
    7
    8
    {
    "MultilevelCache": {
    "CacheEntryOptions": {
    "AbsoluteExpirationRelativeToNow": "72:00:00", //绝对过期时间(从当前时间算起)
    "SlidingExpiration": "00:05:00" //滑动到期时间(从当前时间开始)
    }
    }
    }
  3. 注册缓存, 修改 Program.cs

    1
    2
    3
    4
    builder.Services.AddMultilevelCache(distributedCacheOptions =>
    {
    distributedCacheOptions.UseStackExchangeRedisCache();
    });

    分布式Redis缓存、多级缓存支持通过其它方式配置, 详细可参考Redis文档, 多级缓存文档

  4. 重写 FindAsync , 优先从缓存中获取数据, 缓存不存在时读取数据库

    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
    public class CatalogItemRepository : Repository<CatalogDbContext, CatalogItem, Guid>, ICatalogItemRepository
    {
    /// <summary>
    /// 使用多级缓存
    /// </summary>
    private readonly IMultilevelCacheClient _multilevelCacheClient;

    public CatalogItemRepository(CatalogDbContext context, IUnitOfWork unitOfWork, IMultilevelCacheClient multilevelCacheClient) : base(context, unitOfWork)
    {
    _multilevelCacheClient = multilevelCacheClient;
    }

    public override async Task<CatalogItem?> FindAsync(Guid id, CancellationToken cancellationToken = default)
    {
    TimeSpan? timeSpan = null;
    var catalogInfo = await _multilevelCacheClient.GetOrSetAsync(id.ToString(), () =>
    {
    //仅当内存缓存、Redis缓存都不存在时执行, 当db不存在时此数据将在5秒内被再次访问时将直接返回`null`, 如果db存在则写入`redis`, 写入内存缓存 (并设置滑动过期: 5分钟, 绝对过期时间: 3小时)
    var info = Context.Set<CatalogItem>()
    .Include(catalogItem => catalogItem.CatalogType)
    .Include(catalogItem => catalogItem.CatalogBrand)
    .AsSplitQuery()
    .FirstOrDefaultAsync(catalogItem => catalogItem.Id == id, cancellationToken).ConfigureAwait(false).GetAwaiter().GetResult();

    if (info != null)
    return new CacheEntry<CatalogItem>(info, TimeSpan.FromDays(3))
    {
    SlidingExpiration = TimeSpan.FromMinutes(5)
    };

    timeSpan = TimeSpan.FromSeconds(5);
    return new CacheEntry<CatalogItem>(info);
    }, timeSpan == null ? null : new CacheEntryOptions(timeSpan));
    return catalogInfo;
    }
    }

多级缓存与分布式缓存相比, 它有更高的性能, 对Redis集群的压力更小, 但当缓存更新时, 多级缓存会有1-2秒左右的刷新延迟, 详细可查看文档

全局异常和国际化(Exception & I18n)

我们建议在项目中使用全局异常处理, 它对外提供了统一的响应信息, 这将使得我们的项目体验更好

Exception前提

选中领域层 Masa.EShop.Service.Catalog

1
dotnet add package Masa.Contrib.Exceptions --prerelease

全局异常

使用全局异常处理, 修改Program.cs

1
app.UseMasaExceptionHandler();

针对未处理的异常, 将返回Internal service error, 自定义异常处理可参考文档

框架提供了友好异常、参数校验异常等, 我们可以通过抛出友好异常来中断请求, 并输出友好的错误提示信息, 还支持与多语言配合输出本地化的错误信息

Source Code Download

see it on github