使用MediatR代替服务层 - 这样做是否值得?

17

你认为用 MediatR 替换我的服务层或服务类可能是合理的吗?例如,我的服务类看起来像这样:

public interface IEntityService<TEntityDto> where TEntityDto : class, IDto
{
    Task<TEntityDto> CreateAsync(TEntityDto entityDto);
    Task<bool> DeleteAsync(int id);
    Task<IEnumerable<TEntityDto>> GetAllAsync(SieveModel sieveModel);
    Task<TEntityDto> GetByIdAsync(int id);
    Task<TEntityDto> UpdateAsync(int id, TEntityDto entityDto);
}

我希望实现一种模块化设计,以便其他动态加载的模块或插件可以为我的主核心应用程序编写自己的通知或命令处理程序。

目前,我的应用程序没有任何事件驱动,并且没有简单的方法让我的动态加载插件进行通信。

我可以在控制器中加入MediatR完全移除服务层,或者只在服务层中使用它发布通知,以便我的插件可以处理它们。

目前,我的逻辑大多是增删改查,但在创建、更新和删除之前有很多自定义逻辑。

可能替换我的服务看起来像:

public class CommandHandler : IRequestHandler<CreateCommand, Response>, IRequestHandler<UpdateCommand, Response>, IRequestHandler<DeleteCommand, bool>
{
    private readonly DbContext _dbContext;

    public CommandHandler(DbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public Task<Response> Handle(CreateCommand request, CancellationToken cancellationToken)
    {
        //...
    }

    public Task<Response> Handle(UpdateCommand request, CancellationToken cancellationToken)
    {
        //...
    }

    public Task<bool> Handle(DeleteCommand request, CancellationToken cancellationToken)
    {
        ///...
    }
}

是否做这件事情有问题?

基本上,我正在为我的逻辑流程苦苦挣扎:

  • 控制器->服务->MediatR->通知处理程序->存储库
  • 控制器->MediatR->命令处理程序->存储库

使用MediatR似乎无法为创建、更新和删除使用单个模型,因此重用它的一种方法是派生请求,如:

public CreateRequest : MyDto, IRequest<MyDto> {}        
public UpdateRequest : MyDto, IRequest<MyDto> {} 

或者将它嵌入到我的命令中,例如:

public CreateRequest : IRequest<MyDto>
{
    MyDto MyDto { get; set; }
}

MediatR的一个优点是可以轻松地插入和拔出逻辑,这似乎非常适合模块化架构,但我仍然有点困惑如何使用它来构建架构。

我不确定这与DDD有什么关系 - 最多可能是CQRS。关于MediatR处理程序替换服务,是的,我想这就是你会使用它的方式。参考链接:https://codeopinion.com/thin-controllers-cqrs-mediatr/ 和 https://www.stevejgordon.co.uk/cqrs-using-mediatr-asp-net-core。 - guillaume31
1
在这里扮演恶魔的代言人,这篇文章讲述了为什么在将其引入项目之前需要三思而后行 - alex-klaus.com/mediator - Alex Klaus
3个回答

47
更新:我保留了答案,但我的立场有所改变,详见this blog post
更新2:说真的,我不再支持我在这里写的内容。
如果你有一个类,比如一个API控制器,它依赖于 > 那么将你的类改为依赖于IMediator有什么好处呢,
而不是调用
return requestHandler.HandleRequest(request);

它被称为
return mediator.Send(request);

结果是,我们注入了一个服务定位器,而不是我们需要的依赖项,然后这个服务定位器会解析我们需要的依赖项。
引用Mark Seeman的文章,
简而言之,服务定位器的问题在于它隐藏了类的依赖关系,导致在运行时出现错误而不是编译时错误,同时使代码更难以维护,因为不清楚何时会引入破坏性的变更。
这并不完全相同。
var commandHandler = serviceLocator.Resolve<IRequestHandler<CreateCommand, Response>>();
return commandHandler.Handle(request);

因为中介者仅限于解决命令和查询处理程序,但它很接近。它仍然是一个单一的接口,提供对许多其他接口的访问。
这使得代码更难以导航。
在引入IMediator之后,我们的类仍然间接依赖于IRequestHandler。不同之处在于,现在我们无法通过查看它来判断。我们无法从接口导航到其实现。我们可能会推断,如果我们知道要查找什么,我们仍然可以遵循依赖关系 - 也就是说,如果我们知道命令处理程序接口名称的约定。但是,这远不如一个类实际声明它依赖于什么有用。
当然,我们可以通过将接口与具体实现连接起来而无需编写代码来获得好处,但这种节省是微不足道的,而且由于代码的导航变得更加困难(尽管是次要的),我们可能会失去任何节省的时间。而且,还有一些库可以为我们注册这些依赖关系,同时仍然允许我们注入我们实际依赖的抽象。
这是一种奇怪而偏颇的依赖抽象的方式。
有人建议使用中介者来帮助实现装饰器模式。但是,通过依赖于抽象,我们已经获得了这种能力。我们可以使用一个接口的一种实现,或者另一种添加了装饰器的实现。依赖于抽象的重点在于,我们可以更改这样的实现细节,而不改变抽象本身。
具体来说:依赖于ISomethingSpecific的重点在于,我们可以更改或替换实现,而不修改依赖于它的类。但是,如果我们说:“我想要更改ISomethingSpecific的实现(通过添加装饰器),为了实现这一点,我要改变依赖于ISomethingSpecific的类,这些类本来运行良好,并使它们依赖于一些通用的、多用途的接口”,那么就出现了问题。我们有很多其他方法可以添加装饰器,而不需要修改不需要更改的代码部分。
是的,使用IMediator可以促进松耦合。但是,通过使用明确定义的抽象,我们已经实现了这一点。添加层层间接并不能增加这种好处的倍数。如果你有足够的抽象,可以轻松编写单元测试,那就足够了。
模糊的依赖关系使得违反单一职责原则更容易发生。
假设你有一个用于下订单的类,它依赖于`ICommandHandler`。如果有人试图悄悄添加一些不属于该类的东西,比如更新用户数据的命令,会发生什么?他们将不得不添加一个新的依赖项,`ICommandHandler`。如果他们想要继续往该类中添加更多东西,违反了SRP(单一职责原则),他们将不得不继续添加更多的依赖项。这并不能阻止他们这样做,但至少能让人们看清楚正在发生的事情。
另一方面,如果你可以向一个类中添加各种随机的东西而不需要添加更多的依赖项呢?该类依赖于一个可以做任何事情的抽象。它可以下订单、更新地址、请求销售历史等等,而且所有这些都不需要添加任何新的依赖项。这与将IoC容器注入到不应该存在的类中所面临的问题相同。它是一个可以用来请求各种依赖项的单个类或接口。它是一个服务定位器。
`IMediator`不会导致SRP的违规,也不会阻止它们的发生。但是明确、具体的依赖项会引导我们远离这样的违规。
中介者模式
奇怪的是,使用MediatR通常与中介者模式无关。中介者模式通过让对象与中介者而不是直接与彼此交互来促进松耦合。如果我们已经依赖于像ICommandHandler这样的抽象,那么中介者模式所防止的紧耦合在第一次就不存在。
中介者模式还封装了复杂的操作,使其从外部看起来更简单。
return mediator.Send(request);

不比...简单
return requestHandler.HandleRequest(request);

两个交互的复杂性是相同的。没有什么是“被调解的”。想象一下,你正准备在杂货店刷信用卡,然后有人提议通过带你去另一个收银台来简化你的复杂交互,而你在那里做的事情完全相同。
CQRS呢?
在CQRS中,中介者是中立的(除非我们有两个独立的中介者,比如ICommandMediator和IQueryMediator)。将我们的命令处理程序与查询处理程序分开,然后注入一个单一的接口,将它们重新组合并在一个地方公开所有的命令和查询,似乎是逆向的。至少很难说它有助于我们将它们分开。
IMediator用于调用命令和查询处理程序,但它与它们被隔离的程度无关。如果在添加中介者之前它们被隔离了,那么现在它们仍然是被隔离的。如果我们的查询处理程序做了一些不应该做的事情,中介者仍然会愉快地调用它。
我希望这听起来不像是一个调解人撞了我的狗。但这绝对不是一个能够轻松将CQRS洒在我们的代码上,甚至不一定能改善我们的架构的灵丹妙药。
我们应该问问,有什么好处?可能会有什么不良后果?我需要那个工具吗,还是我可以在没有这些后果的情况下获得我想要的好处?
我所断言的是,一旦我们已经依赖于抽象,进一步“隐藏”一个类的依赖通常不会增加任何价值。它们使得代码更难阅读和理解,并削弱了我们检测和预防其他代码问题的能力。

1
这个主题有两篇博客文章:我的“中介者/ MediatR还酷吗?”和Scott的“不,MediatR没有撞到我的狗”。 - Alex Klaus
2
  1. 找到命令/查询的引用,您可以轻松跳转到它。
  2. 通用装饰器是其强大之处。如果您想要内置验证/重试/错误处理,只需在通用接口上实现一次即可。如果您有所有这些单独的操作,您将不得不手动装饰每个操作。毫无用处。
  3. 任何类都可以实现任何接口。说一个类可以实现多个处理程序是一个愚蠢的论点。这就是代码审查的作用。每个命令只知道它包含的数据。
- Daniel Lorenz
1
装饰器模式在 MediatR 之前就已经存在了,可以在不为每个接口构建单独的类的情况下添加 AOP 装饰器。能够使用工具做某事并不意味着这是该工具独有的功能。每个命令只知道它包含的数据,但是添加对 IMediator 的依赖实际上会添加对每个命令和查询处理程序的依赖。当然,您可以在代码审查中捕获违规行为。但我宁愿不引入一个模糊、命名不准确的依赖项,它可以首先调用任何命令或查询。希望我们能在代码审查中发现这一点。 - Scott Hannen
我对是否更新此内容感到犹豫,因为我的立场不像以前那么强烈了。如果很多人认同现状,那我想我应该把它留下来。但是,我已经更新了相应的博客文章,展示了我对此有些不同的看法。https://scotthannen.org/blog/2020/06/20/mediatr-didnt-run-over-dog.html - Scott Hannen

6

这个问题在这里已经有了部分回答: MediatR when and why I should use it? vs 2017 webapi

使用 MediaR(或 MicroBus, 或任何其他中介者实现)的最大好处是隔离和/或分离你的逻辑(这也是使用 CQRS的流行原因之一),并为实现装饰器模式 (例如 ASP.NET Core MVC 过滤器) 奠定良好的基础。从 MediatR 3.0 开始,支持内置此功能 (请参阅Behaviours) (而不是使用 IoC 装饰器)

你也可以将装饰器模式与服务 (例如 FooService 类) 一起使用。你也可以将 CQRS 与服务一起使用 (FooReadService, FooWriteService)

除此之外,这是基于个人观点的,使用你想要实现目标的工具。最终结果除了代码维护外不应有任何区别。
附加阅读:
- 使用 MediatR 制作圆形应用程序(比较自定义中介实现与 MediatR 提供的中介实现和移植过程) - 在单个处理程序中处理多个请求是否好?

2
替换服务层与MediatR - 值得这么做吗?
第三个选项还没有讨论:
1. 将处理程序添加到服务集合中。(当前状态。) 2. 使用像MediatR这样的库扫描您的程序集并将处理程序添加到服务集合中。(值得吗?) 3. 不将处理程序添加到您的服务集合中。(新选项。)
在本答案中,我将通过比较MediatRIGet的几个部分来讨论第三个选项。
首先,让我们看看代码相似度可以达到多少:

MediatR

使用 MediatR,用户电子邮件地址的更新请求可能如下所示:

public class ChangeEmailRequest : IRequest<SomeResult>
{
    public int UserId { get; set; }
    public string NewEmailAddress { get; set; }
}

处理程序可能如下所示:

public class ChangeEmailRequestHandler : IRequestHandler<ChangeEmailRequest, SomeResult>
{
    // Here would be the constructor with dependencies that are injected by the service provider

    public async Task<SomeResult> Handle(ChangeEmailRequest request, CancellationToken cancellationToken)
    {
        // trim and validate input
        // return if not valid (with reason)
        // save new email address in database
        // send e-mail to the new address for verification
        // return a success result
    }
}

那么,调用处理程序看起来像这样

var result = await mediator.Send(request);

IGet

使用IGet,用户的电子邮件地址更新请求可能如下所示:

public class ChangeEmailRequest
{
    public int UserId { get; set; }
    public string NewEmailAddress { get; set; }
}

处理程序可能如下所示:

public class ChangeEmailRequestHandler
{
    // Here would be the constructor with dependencies that are injected by the service provider

    public async Task<SomeResult> Handle(ChangeEmailRequest request, CancellationToken cancellationToken)
    {
        // trim and validate input
        // return if not valid (with reason)
        // save new email address in database
        // send e-mail to the new address for verification
        // return a success result
    }
}

然后,调用处理程序将如下所示:

var result = await i.Get<ChangeEmailRequestHandler>().Handle(request);

讨论上面的代码示例

  • 无论是使用 MediatR 还是 IGet,都可以将一个PageModelController的注入构造函数的参数减少为一个:IMediator mediator vs IGet i
  • MediatR 需要实现IRequestIRequestHandler接口。这使得 MediatR 可以扫描你的程序集并在启动时将处理程序添加到你的服务集合中。MediatR 从你的服务集合获取处理程序,因此它可以通过所谓的Behaviours在调用Handle之前装饰处理程序。
  • IGet 使用泛型和ActivatorUtilities类来实例化处理程序,然后你自己调用Handle方法 - 因此Handle方法可用于更容易地在编辑器中导航代码,并且 IGet 提供了编译时安全检查以确保处理程序存在。当使用 IGet 时,将共享行为添加到处理程序应该更显式,因为它不能由服务提供者或"mediator"在获取处理程序时完成。你可以通过如IGet 的 readme所示的基类添加共享行为。

使用ActivatorUtilities.CreateInstance时是否存在性能问题? - MrDave1999
MediatR也在内部使用ActivatorUtilities.CreateInstance,但这是在github.com/jbogard/MediatR/blob/master/src/MediatR/Mediator.cs中,并不用于实例化处理程序。IServiceProvider的实现负责实例化处理程序,而这并不是MediatR库的一部分。我没有比较IServiceProviderActivatorUtilities.CreateInstance的性能。有一个合并了一些性能问题修复的拉取请求在2019年被合并了:https://github.com/dotnet/extensions/pull/1796 - SymboLinker
对于怀疑的人们,请注意IGet.Get<Handler>()不需要进行任何程序集扫描来查找处理程序。无论是在启动时还是在运行时都不需要。如果那样的话,会影响性能。但我们在这里谈论的性能差异要小得多:它只涉及将构造函数参数与要注入的服务进行匹配。IGet可能比MediatR更快-我没有测试过,因为我认为这个差异不是问题。 - SymboLinker

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接