在 ASP.NET Core 项目中使用 MediatR 实现中介者模式

785 阅读6分钟

前言

        最近有在看 DDD 的相关资料以及微软的 eShopOnContainers 这个项目中基于 DDD 的架构设计,在 Ordering 这个示例服务中,可以看到各层之间的代码调用与我们之前传统的调用方式似乎差异很大,整个项目各个层之间的代码全部是通过注入 IMediator 进行调用的,F12 查看源码后可以看到该接口是属于 MediatR 这个组件的。既然要照葫芦画瓢,那我们就先来了解下如何在 ASP.NET Core 项目中使用 MediatR

        仓储地址:github.com/Lanesra712/…

Step by Step

        MediatR,从 github 的项目主页上可以看到作者对于这个项目的描述是基于中介者模式的 .NET 实现,是一种基于进程内的数据传递。也就是说这个组件主要实现的是在一个应用中实现数据传递,如果想要实现多个应用间的数据传递就不太适合了。从作者的 github 个人主页上可以看到,他还是 AutoMapper 这个 OOM 组件的作者,PS,如果你想要了解如何在 ASP.NET Core 项目中使用 AutoMapper,你可以查看我之前写的这一篇文章(电梯直达)。而对于 MediatR 来说,在具体的学习使用之前,我们先来了解下什么是中介者模式。

        一、什么是中介者模式

        很多舶来词的中文翻译其实最终都会与实际的含义相匹配,例如软件开发过程中的 23 种设计模式的中文名称,我们其实可以比较容易的从中文名称中得知出该设计模式具体想要实现的作用,就像这里介绍的中介者模式。

        在我们通过代码实现实际的业务逻辑时,如果涉及到多个对象类之间的交互,通常我们都是会采用直接引用的形式,随着业务逻辑变的越来越复杂,对于一个简单的业务抽象出的实现方法中,可能会被我们添加上各种判断的逻辑或是对于数据的业务逻辑处理方法。

        例如一个简单的用户登录事件,我们可能最终会抽象出如下的业务流程实现。

public bool Login(AppUserLoginDto dto, out string msg)
{
    bool flag = false;
    try
    {
        // 1、验证码是否正确
        flag = _redisLogic.GetValueByKey(dto.VerificationCode);
        if (!flag)
        {
            msg = "验证码错误,请重试";
            return false;
        }

        // 2、验证账户密码是否正确
        flag = _userLogic.GetAppUser(dto.Account.Trim(), dto.Password.Trim(), out AppUserDetailDto appUser);
        if (!flag)
        {
            msg = "账户或密码错误,请重试";
            return false;
        }

        // 3、验证账户是否可以登录当前的站点(未被锁定 or 具有登录当前系统的权限...)
        flag = _authLogic.CheckIsAvailable(appUser);
        if (!flag)
        {
            msg = "用户被禁止登录当前系统,请重试";
            return false;
        }

        // 4、设置当前登录用户信息
        _authLogic.SetCurrentUser(appUser);

        // 5、记录登录记录
        _userLogic.SaveLoginRecord(appUser);

        msg = "";
        return true;
    }
    catch (Exception ex)
    {
        // 记录错误信息
        msg = $"用户登录失败:{ex.Message}";
        return false;
    }
}

        这里我们假设对于登录事件的实现方法存在于 UserAppService 这个类中,对于 redis 资源的操作在 RedisLogic 类中,对于用户相关资源的操作在 UserLogic 中,而对于权限校验相关的资源操作位于 AuthLogic 类中。

        可以看到,为了实现 UserAppService 类中定义的登录方法,我们至少需要依赖于 RedisLogic、UserLogic 以及 AuthLogic,甚至在某些情况下可能在 UserLogic 和 AuthLogic 之间也存在着某种依赖关系,因此我们可以从中得到如下图所示的类之间的依赖关系。

传统模式下的登录功能所涉及的类依赖

        一个简单的登录业务尚且如此,如果我们需要对登录业务添加新的需求,例如现在很多网站的登录和注册其实是放在一起的,当登录时如果判断没有当前的用户信息,其实会催生创建新用户的流程,那么,对于原本的登录功能实现,是不是会存在继续添加新的依赖关系的情况。同时对于很多本身就很复杂的业务,最终实现出来的方法是不是会有更多的对象类之间存在各种的依赖关系,牵一发而动全身,后期修改测试的成本会不会变得更高。

        那么,中介者模式是如何解决这个问题呢?

        在上文有提到,对于舶来词的中文名称,中文更多的会根据实际的含义进行命名,试想一下我们在现实生活中提到中介,是不是更多的会想到房屋中介这一角色。当我们来到一个新的城市,面临着租房的问题,绝大多数的情况下,我们最终需要通过中介去达成我们租房的目的。在租房这个案例中,房屋中介其实就是一个中介者,他承接我们对于想要租的房子的各种需求,从自己的房屋数据库中去寻找符合条件的,最终以一个桥梁的形式,连接我们与房东,最终就房屋的租住达成一致。

        而在软件开发中,中介者模式则是要求我们根据实际的业务去定义一个包含各种对象之间交互关系的对象类,之后,所有涉及到该业务的对象都只关联于这一个中介对象类,不再显式的调用其它类。采用了中介者模式之后设计的登录功能所涉及到的类依赖如下图所示,这里的 AppUserLoginEventHandler 其实就是我们的中介类。

中介者模式下的登录功能所涉及的类依赖

        当然,任何事都会有利有弊,不会存在百分百完美的事情,就像我们通过房租中介去寻找合适的房屋,最终我们需要付给中介一笔费用去作为酬劳,采用中介者模式设计的代码架构也会存在别的问题。因为在代码中引入了中介者这一对象,势必会增加我们代码的复杂度,可能会使原本很轻松就实现的代码变得复杂。同时,我们引入中介者模式的初衷是为了解决各个对象类之间复杂的引用关系,对于某些业务来说,本身就很复杂,最终必定会导致这个中介者对象异常复杂。

        毕竟,软件开发的过程中不会存在银弹去帮我们解决所有的问题。

        那么,在本篇文章的示例代码中,我将使用 MediatR 这一组件,通过引入中介者模式的思想来完成上面的用户登录这一案例。

        二、组件加载

        在使用 MediatR 之前,这里简单介绍下这篇文章的示例 demo 项目。这个示例项目的架构分层可以看成是介于传统的多层架构与采用 DDD 的思想的架构分层。嗯,你可以理解成四不像,属于那种传统模式下的开发人员在往 DDD 思想上进行迁移的成品,具体的代码分层说明解释如下。

        01_Infrastructure:基础架构层,这层会包含一些对于基础组件的配置或是帮助类的代码,对于每个新建的服务来说,该层的代码几乎都是差不多的,所以对于基础架构层的代码其实最好是发布到公有 or 私有的 Nuget 仓库中,然后我们直接在项目中通过 Nuget 去引用。

        对于采用 DDD 的思想构建的项目来说,很多人可能习惯将一些实体的配置也放置在基础架构层,我的个人理解还是应该置于领域层,对于基础架构层,只做一些基础组件的封装。如果有什么不对的地方,欢迎在评论区提出。

        02_Domain:领域层,这层会包含我们根据业务划分出的领域的几乎所有重要的部分,有领域对象(Domain Object)、值对象(Value Object)、领域事件(Domain Event)、以及仓储(Repository)等等领域组件。

        这里虽然我创建了 AggregateModels(聚合实体)这个文件夹,其实在这个项目中,我创建的还是不包含任何业务逻辑的贫血模型。同时,对于仓储(Repository)在领域分层中是置于 Infrastructure(基础架构层)还是位于 Domain(领域层),每个人都会有自己的理解,这里我还是更倾向于放在 Domain 层中更符合其定位。

        03_Application:应用层,这一层会包含我们基于领域所封装出的各种实际的业务逻辑,每个封装出的服务应用之间并不会出现互相调用的情况。

        Sample.Api:API 接口层,这层就很简单了,主要是通过 API 接口暴露出我们基于领域对外提供的各种服务。

        整个示例项目的分层结构如下图所示。

示例项目架构分层

        与使用其它的第三方组件的使用方式相同,在使用之前,我们需要在项目中通过 Nuget 添加对于 MediatR 的程序集引用。

        这里需要注意,因为我们主要是通过引用 MediatR 来实现中介者模式,所以我们只需要在领域层和应用层加载 MediatR 即可。而对于 Sample.Api 这个 Web API 项目,因为需要通过依赖注入的方式来使用我们基于 MediatR 所构建出的各种服务,所以这里我们还要添加 MediatR.Extensions.Microsoft.DependencyInjection 这个程序集到 Sample.Api 中。

Install-Package MediatR
Install-Package MediatR.Extensions.Microsoft.DependencyInjection

        三、案例实现

        首先我们在 Sample.Domain 这个类库的 AggregateModels 文件夹下添加 AppUser(用户信息)类 和 Address(地址信息) 类,这里虽然并没有采用 DDD 的思想去划分领域对象和值对象,我们创建出来的都是不含任何业务逻辑的贫血模型。但是在用户管理这个业务中,对于用户所包含的联系地址信息,其实是一种无状态的数据。也就是说对于同一个地址信息,不会因为置于多个用户中而出现数据的二义性。因此,对于地址信息来说,是不需要唯一的标识就可以区分出这个数据的,所以这里的 Address 类就不需要添加主键,其实也就是对应于领域建模中的值对象。

用户对象信息

        这里我是使用的 EF Core 作为项目的 ORM 组件,当创建好需要使用实体之后,我们在 Sample.Domain 这个类库下面新建一个 SeedWorks 文件夹,添加自定义的 DbContext 对象和用于执行 EF Core 第一次生成数据库时写入预置种子数据的信息类。

        这里需要注意,在 EF Core 中,当我们需要将编写的 C# 类通过 Code First 创建出数据库表时,我们的 C# 类必须包含主键信息。而对应到我们这里的 Address 类来说,它更多的是作为 AppUser 类中的属性信息来展示的,所以这里我们需要对 EF Core 生成数据库表的过程进行重写。

        这里我们在 SeedWorks 文件夹下创建一个新的文件夹 EntityConfigurations,在这里用来存放我们自定义的 EF Core 创建表的规则。新建一个继承于 IEntityTypeConfiguration 接口的 AppUserConfiguration 配置类,在接口默认 Configure 方法中,我们需要编写映射规则,将 Address 类作为 AppUser 类中的字段进行显示,最终实现后的代码如下所示。

public class AppUserConfiguration : IEntityTypeConfiguration<AppUser>
{
    public void Configure(EntityTypeBuilder<AppUser> builder)
    {
        // 表名称
        builder.ToTable("appuser");

        // 实体属性配置
        builder.OwnsOne(i => i.Address, n =>
        {
            n.Property(p => p.Province).HasMaxLength(50)
                .HasColumnName("Province")
                .HasDefaultValue("");

            n.Property(p => p.City).HasMaxLength(50)
                .HasColumnName("City")
                .HasDefaultValue("");

            n.Property(p => p.Street).HasMaxLength(50)
                .HasColumnName("Street")
                .HasDefaultValue("");

            n.Property(p => p.ZipCode).HasMaxLength(50)
                .HasColumnName("ZipCode")
                .HasDefaultValue("");
        });
    }
}

        当创建表的映射规则编写完成后,我们就可以对 UserApplicationDbContext 类进行重写 OnModelCreating 方法。在这个方法中,我们就可以去应用我们自定义设置的实体映射规则,从而让 EF Core 按照我们的想法去创建数据库,最终实现的代码如下所示。

public class UserApplicationDbContext : DbContext
{
    public DbSet<AppUser> AppUsers { get; set; }

    public UserApplicationDbContext(DbContextOptions<UserApplicationDbContext> options)
        : base(options)
    {
    }

    /// <summary>
    ///
    /// </summary>
    /// <param name="modelBuilder"></param>
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 自定义 AppUser 表创建规则
        modelBuilder.ApplyConfiguration(new AppUserConfiguration());
    }
}

数据库表配置

        当我们创建好 DbContext 后,我们需要在 Startup 类的 ConfigureServices 方法中进行注入。在示例代码中,我使用的是 MySQL 8.0 数据库,将配置文件写入到 appsettings.json 文件中,最终注入 DbContext 的代码如下所示。

public void ConfigureServices(IServiceCollection services)
{
    // 配置数据库连接字符串
    services.AddDbContext<UserApplicationDbContext>(options =>
        options.UseMySql(Configuration.GetConnectionString("SampleConnection")));
}

        数据库的连接字符串配置如下。

{
  "ConnectionStrings": {
    "SampleConnection": "server=127.0.0.1;database=sample.application;user=root;password=123456@sql;port=3306;persistsecurityinfo=True;"
  }
}

        在上文有提到,除了创建一个 DbContext 对象,我们还创建了一个 DbInitializer 类用于在 EF Core 第一次执行创建数据库操作时将我们预置的信息写入到对应的数据库表中。这里我们只是简单的判断下 AppUser 这张表是否存在数据,如果没有数据,我们就添加一条新的记录,最终实现的代码如下所示。

public class DbInitializer
{
    public static void Initialize(UserApplicationDbContext context)
    {
        context.Database.EnsureCreated();

        if (context.AppUsers.Any())
            return;

        AppUser admin = new AppUser()
        {
            Id = Guid.NewGuid(),
            Name = "墨墨墨墨小宇",
            Account = "danvic.wang",
            Phone = "13912345678",
            Age = 12,
            Password = "123456",
            Gender = true,
            IsEnabled = true,
            Address = new Address("啦啦啦啦街道", "啦啦啦市", "啦啦啦省", "12345"),
            Email = "danvic.wang@yuiter.com",
        };

        context.AppUsers.Add(admin);
        context.SaveChanges();
    }
}

        当我们完成种子数据植入的代码,我们需要在程序启动之前就去执行我们的代码。因此我们需要修改 Program 类中的 Main 方法,实现在运行 web 程序之前去执行种子数据的植入。

public class Program
{
    public static void Main(string[] args)
    {
        var host = CreateWebHostBuilder(args).Build();

        using (var scope = host.Services.CreateScope())
        {
            // 执行种子数据植入
            //
            var services = scope.ServiceProvider;
            var context = services.GetRequiredService<UserApplicationDbContext>();
            DbInitializer.Initialize(context);
        }
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>();
}

        这时,运行我们的项目,程序就会自动执行创建数据库的操作,同时会将我们预设好的种子数据写入到数据库表中,最终实现的效果如下图所示。

数据库创建

        基础的项目代码已经完成之后,我们就可以开始学习如何通过 MediatR 来实现中介者模式。在这一章的示例项目中,我们会使用到 MediatR 中两个很重要的接口类型:IRequest 和 INotification。

        在 Github 上,作者针对这两个接口做了如下的解释,这里我会按照我的理解去进行使用。同时,为了防止我的理解出现了偏差,从而对各位造成影响,这里贴上作者回复解释的原文。

Requests are for: 1 request to 1 handler. Handler may or may not return a value

Notifications are for: 1 notification to n handlers. Handler may not return a value.

In practical terms, requests are "commands", notifications are "events". Command would be directing MediatR to do something like "ApproveInvoiceCommand -> ApproveInvoiceHandler". Event would be notifications, like "InvoiceApprovedEvent -> SendThankYouEmailToCustomerHandler"

        对于继承于 IRequest 接口的类来说,一个请求(request)只会有一个针对这个请求的处理程序(requestHandler),它可以返回值或者不返回任何信息;

        而对于继承于 INotification 接口的类来说,一个通知(notification)会对应多个针对这个通知的处理程序(notificationHandlers),而它们不会返回任何的数据。

        请求(request)更像是一种命令(command),而通知(notification)更像是一种事件(event)。嗯,可能看起来更晕了,jbogard 这里给了一个案例给我们进一步的解释了 request 与 notification 之间的差异性。

        双十一刚过,很多人都会疯狂剁手,对于购买大件来说,为了能够更好地拥有售后服务,我们在购买后肯定会期望商家给我们提供发票,这里的要求商家提供发票就是一种 request,而针对我们的这个请求,商家会做出回应,不管能否开出来发票,商家都应当通知到我们,这里的通知用户就是一种 notification。

        对于提供发票这个 request 来说,不管最终的结果如何,它只会存在一种处理方式;而对于通知用户这个 notification 来说,商家可以通过短信通知,可以通过公众号推送,也可以通过邮件通知,不管采用什么方式,只要完成了通知,对于这个事件来说也就已经完成了。

        而对应于用户登录这个业务来说,用户的登录行为其实就是一个 request,对于这个 request 来说,我们可能会去数据库查询账户是否存在,判断是不是具有登录系统的权限等等。而不管我们在这个过程中做了多少的逻辑判断,它只会有两种结果,登录成功或登录失败。而对于用户登录系统之后可能需要设置当前登录人员信息,记录用户登录日志这些行为来说,则是归属于 notification 的。

        弄清楚了用户登录事件中的 request 和 notification 划分,那么接下来我们就可以通过代码来实现我们的功能。这里对于示例项目中的一些基础组件的配置我就跳过了,如果你想要具体的了解这里使用到的一些组件的使用方法,你可以查阅我之前的文章。

        首先,我们在 Sample.Application 这个类库下面创建一个 Commands 文件夹,在下面存放用户的请求信息。现在我们创建一个用于映射用户登录请求的 UserLoginCommand 类,它需要继承于 IRequest<T&gt 这个泛型接口。因为对于用户登录这个请求来说,只会有可以或不可以这两个结果,所以对于这个请求的响应的结果是 bool 类型的,也就是说,我们具体应该继承的是 IRequest<bool>。

        对于用户发起的各种请求来说,它其实只是包含了对于这次请求的一些基本信息,而对于 UserLoginCommand 这个用户登录请求类来说,它可能只会有账号、密码、验证码这三个信息,请求类代码如下所示。

public class UserLoginCommand : IRequest<bool>
{
    /// <summary>
    /// 账户
    /// </summary>
    public string Account { get; private set; }

    /// <summary>
    /// 密码
    /// </summary>
    public string Password { get; private set; }

    /// <summary>
    /// 验证码
    /// </summary>
    public string VerificationCode { get; private set; }

    /// <summary>
    /// ctor
    /// </summary>
    /// <param name="account">账户</param>
    /// <param name="password">密码</param>
    /// <param name="verificationCode">验证码</param>
    public UserLoginCommand(string account, string password, string verificationCode)
    {
        Account = account;
        Password = password;
        VerificationCode = verificationCode;
    }
}

        当我们拥有了存储用户登录请求信息的类之后,我们就需要对用户的登录请求进行处理。这里,我们在 Sample.Application 这个类库下面新建一个 CommandHandlers 文件夹用来存放用户请求的处理类。

        现在我们创建一个继承于 IRequestHandler 接口的 UserLoginCommandHandler 类用来实现对于用户登录请求的处理。IRequestHandler 是一个泛型的接口,它需要我们在继承时声明我们需要实现的请求,以及该请求的返回信息。因此,对于 UserLoginCommand 这个请求来说,UserLoginCommandHandler 这个请求的处理类,最终需要继承于 IRequestHandler<UserLoginCommand, bool>。

        就像上面提到的一样,我们需要在这个请求的处理类中对用户请求的信息进行处理,在 UserLoginCommandHandler 类中,我们应该在 Handle 方法中去执行我们的判断逻辑,这里我们会引用到仓储来获取用户的相关信息。仓储中的代码这里我就不展示了,最终我们实现后的代码如下所示。

public class UserLoginCommandHandler : IRequestHandler<UserLoginCommand, bool>
{
    #region Initizalize

    /// <summary>
    /// 仓储实例
    /// </summary>
    private readonly IUserRepository _userRepository;

    /// <summary>
    /// ctor
    /// </summary>
    /// <param name="userRepository"></param>
    public UserLoginCommandHandler(IUserRepository userRepository)
    {
        _userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
    }

    #endregion Initizalize

    /// <summary>
    /// Command Handler
    /// </summary>
    /// <param name="request"></param>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    public async Task<bool> Handle(UserLoginCommand request, CancellationToken cancellationToken)
    {
        // 1、判断验证码是否正确
        if (string.IsNullOrEmpty(request.VerificationCode))
            return false;

        // 2、验证登录密码是否正确
        var appUser = await _userRepository.GetAppUserInfo(request.Account.Trim(), request.Password.Trim());
        if (appUser == null)
            return false;

        return true;
    }
}

Request 请求示意

        当我们完成了对于请求的处理代码后,就可以在 controller 中提供用户访问的入口。当然,因为我们需要采用依赖注入的方式去使用 MediatR,所以在使用之前,我们需要将请求的对应处理关系注入到依赖注入容器中。

        在通过依赖注入的方式使用 MediatR 时,我们需要将所有的事件(请求以及通知)注入到容器中,而 MediatR 则会自动寻找对应事件的处理类,除此之外,我们也需要将通过依赖注入使用到的 IMediator 接口的实现类注入到容器中。而在这个示例项目中,我们主要是在 Sample.Domain、Sample.Application 以及我们的 Web Api 项目中使用到了 MediatR,因此,我们需要将这三个项目中使用到 MediatR 的类全部注入到容器中。

        一个个的注入会比较的麻烦,所以这里我还是采用对指定的程序集进行反射操作,去获取需要加载的信息批量的进行注入操作,最终实现后的代码如下。

public static IServiceCollection AddCustomMediatR(this IServiceCollection services, MediatorDescriptionOptions options)
{
    // 获取 Startup 类的 type 类型
    var mediators = new List<Type> { options.StartupClassType };

    // IRequest<T> 接口的 type 类型
    var parentRequestType = typeof(IRequest<>);

    // INotification 接口的 type 类型
    var parentNotificationType = typeof(INotification);

    foreach (var item in options.Assembly)
    {
        var instances = Assembly.Load(item).GetTypes();

        foreach (var instance in instances)
        {
            // 判断是否继承了接口
            //
            var baseInterfaces = instance.GetInterfaces();
            if (baseInterfaces.Count() == 0 || !baseInterfaces.Any())
                continue;

            // 判断是否继承了 IRequest<T> 接口
            //
            var requestTypes = baseInterfaces.Where(i => i.IsGenericType
                && i.GetGenericTypeDefinition() == parentRequestType);

            if (requestTypes.Count() != 0 || requestTypes.Any())
                mediators.Add(instance);

            // 判断是否继承了 INotification 接口
            //
            var notificationTypes = baseInterfaces.Where(i => i.FullName == parentNotificationType.FullName);

            if (notificationTypes.Count() != 0 || notificationTypes.Any())
                mediators.Add(instance);
        }
    }

    // 添加到依赖注入容器中
    services.AddMediatR(mediators.ToArray());

    return services;
}

        因为需要知道哪些程序集应该进行反射获取信息,而对于 Web Api 这个项目来说,它只会通过依赖注入使用到 IMediator 这一个接口,所以这里需要采用不同的参数的形式去确定具体需要通过反射加载哪些程序集。

public class MediatorDescriptionOptions
{
    /// <summary>
    /// Startup 类的 type 类型
    /// </summary>
    public Type StartupClassType { get; set; }

    /// <summary>
    /// 包含使用到 MediatR 组件的程序集
    /// </summary>
    public IEnumerable<string> Assembly { get; set; }
}

        最终,我们就可以在 Startup 类中通过扩展方法的信息进行快速的注入,实际使用的代码如下,这里我是将需要加载的程序集信息放在 appsetting 这个配置文件中的,你可以根据你的喜好进行调整。

public class Startup
{
    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        // Config mediatr
        services.AddCustomMediatR(new MediatorDescriptionOptions
        {
            StartupClassType = typeof(Startup),
            Assembly = Configuration["Assembly:Mediator"].Split("|", StringSplitOptions.RemoveEmptyEntries)
        });
    }
}

        在这个示例项目中的配置信息如下所示。

{
  "Assembly": {
    "Function": "Sample.Domain",
    "Mapper": "Sample.Application",
    "Mediator": "Sample.Application|Sample.Domain"
  }
}

        当我们注入完成后,就可以直接在 controller 中进行使用。对于继承了 IRequest 的方法,可以直接通过 Send 方法进行调用请求信息,MediatR 会帮我们找到对应请求的处理方法,最终登录 action 中的代码如下。

[ApiVersion("1.0")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class UsersController : ControllerBase
{
    #region Initizalize

    /// <summary>
    ///
    /// </summary>
    private readonly IMediator _mediator;

    /// <summary>
    /// ctor
    /// </summary>
    /// <param name="mediator"></param>
    public UsersController(IMediator mediator)
    {
        _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
    }

    #endregion Initizalize

    #region APIs

    /// <summary>
    /// 用户登录
    /// </summary>
    /// <param name="login">用户登录数据传输对象</param>
    /// <returns></returns>
    [HttpPost("login")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    public async Task<IActionResult> Post([FromBody] AppUserLoginDto login)
    {
        // 实体映射转换
        var command = new UserLoginCommand(login.Account, login.Password, login.VerificationCode);

        bool flag = await _mediator.Send(command);

        if (flag)
            return Ok(new
            {
                code = 20001,
                msg = $"{login.Account} 用户登录成功",
                data = login
            });
        else
            return Unauthorized(new
            {
                code = 40101,
                msg = $"{login.Account} 用户登录失败",
                data = login
            });
    }

    #endregion APIs
}

        当我们完成了对于用户登录请求的处理之后,就可以去执行后续的“通知类”的事件。与用户登录的请求信息类相似,对于用户登录事件的通知类也只是包含一些通知的基础信息。在 Smaple.Domain 这个类库下面,创建一个 Events 文件用来存放我们的事件,我们来新建一个继承于 INotification 接口的 AppUserLoginEvent 类,用来对用户登录事件进行相关的处理。

public class AppUserLoginEvent : INotification
{
    /// <summary>
    /// 账户
    /// </summary>
    public string Account { get; }

    /// <summary>
    /// ctor
    /// </summary>
    /// <param name="account"></param>
    public AppUserLoginEvent(string account)
    {
        Account = account;
    }
}

        在上文中有提到过,对于一个通知事件可能会存在着多种处理方式,所以这里我们在 Smaple.Application 这个类库的 DomainEventHandlers 文件夹下面会按照事件去创建对应的文件夹去存放实际处理方法。

        对于继承了 INotification 接口的通知类来说,在 MediatR 中我们可以通过创建继承于 INotificationHandler 接口的类去处理对应的事件。因为一个 notification 可以有多个的处理程序,所以我们可以创建多个的 NotificationHandler 类去处理同一个 notification。一个示例的 NotificationHandler 类如下所示。

public class SetCurrentUserEventHandler : INotificationHandler<AppUserLoginEvent>
{
    #region Initizalize

    /// <summary>
    ///
    /// </summary>
    private readonly ILogger<SetCurrentUserEventHandler> _logger;

    /// <summary>
    ///
    /// </summary>
    /// <param name="logger"></param>
    public SetCurrentUserEventHandler(ILogger<SetCurrentUserEventHandler> logger)
    {
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    #endregion Initizalize

    /// <summary>
    /// Notification handler
    /// </summary>
    /// <param name="notification"></param>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    public Task Handle(AppUserLoginEvent notification, CancellationToken cancellationToken)
    {
        _logger.LogInformation($"CurrentUser with Account: {notification.Account} has been successfully setup");

        return Task.FromResult(true);
    }
}

对于通知事件的处理

        如何去引发这个事件,对于领域驱动设计的架构来说,一个更好的方法是将各种领域事件添加到事件的集合中,然后在提交事务之前或之后立即调度这些域事件,而对于我们这个项目来说,因为这不在这篇文章考虑的范围内,只是演示如何去使用 MediatR 这个组件,所以这里我就采取在请求逻辑处理完成后直接触发事件的方式。

        在 UserLoginCommandHandler 类中,修改我们的代码,在确认登录成功后,通过调用 AppUser 类的 SetUserLoginRecord 方法来触发我们的通知事件,修改后的代码如下所示。

public class UserLoginCommandHandler : IRequestHandler<UserLoginCommand, bool>
{
    #region Initizalize

    /// <summary>
    /// 仓储实例
    /// </summary>
    private readonly IUserRepository _userRepository;

    /// <summary>
    ///
    /// </summary>
    private readonly IMediator _mediator;

    /// <summary>
    /// ctor
    /// </summary>
    /// <param name="userRepository"></param>
    /// <param name="mediator"></param>
    public UserLoginCommandHandler(IUserRepository userRepository, IMediator mediator)
    {
        _userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
        _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
    }

    #endregion Initizalize

    /// <summary>
    /// Command Handler
    /// </summary>
    /// <param name="request"></param>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    public async Task<bool> Handle(UserLoginCommand request, CancellationToken cancellationToken)
    {
        // 1、判断验证码是否正确
        if (string.IsNullOrEmpty(request.VerificationCode))
            return false;

        // 2、验证登录密码是否正确
        var appUser = await _userRepository.GetAppUserInfo(request.Account.Trim(), request.Password.Trim());
        if (appUser == null)
            return false;

        // 3、触发登录事件
        appUser.SetUserLoginRecord(_mediator);

        return true;
    }
}

        与使用 Send 方法去调用 request 类的请求不同,对于继承于 INotification 接口的事件通知类,我们需要采用 Publish 的方法去调用。至此,对于一个采用中介者模式设计的登录流程就结束了,SetUserLoginRecord 方法的定义,以及最终我们实现的效果如下所示。

public void SetUserLoginRecord(IMediator mediator)
{
    mediator.Publish(new AppUserLoginEvent(Account));
}

效果示例

总结

        这一章主要是介绍了如何通过 MediatR 来实现中介者模式,因为自己也是第一次接触这种思想,对于 MediatR 这个组件也是第一次使用,所以仅仅是采用案例分享的方式对中介者模式的使用方法进行了一个解释。如果你想要对中介者模式的具体定义与基础的概念进行进一步的了解的话,可能需要你自己去找资料去弄明白具体的定义。因为初次接触,难免会有遗漏或错误,如果从文章中发现有不对的地方,欢迎在评论区中指出,先行感谢。

参考

        1、中介者模式— Graphic Design Patterns - 图说设计模式

        2、MediatR 知多少

占坑

        作者:墨墨墨墨小宇
        个人简介:96年生人,出生于安徽某四线城市,毕业于Top 10000000 院校。.NET程序员,枪手死忠,喵星人。于2016年12月开始.NET程序员生涯,微软.NET技术的坚定坚持者,立志成为云养猫的少年中面向谷歌编程最厉害的.NET程序员。
        个人博客:yuiter.com
        博客园博客:www.cnblogs.com/danvic712