"什么是 CQRS?"你可能会问。我希望你不要以为你会得到一个定义,因为那是维基百科的作用。相反,我希望通过这篇博文达到的目的是通过一些实际的例子来帮助你理解 CQRS。
我假设你是一个初学者,或者是一个对这个话题不熟悉的人,所以也许你每次遇到这些大的编程缩写和概念就会害怕。我自己也曾有过这样的经历,所以我在这里用最简单的方式帮助你搞清楚。
在我们谈论 CQRS 之前,我们需要了解一些概念,如清洁架构,以便更好地理解。如果你已经熟悉了架构简洁之道,那么可以随意跳到下一节。如果你是那些阅读本博客的人之一,不喜欢理论,只想动手写代码,我鼓励你耐心地尝试并掌握这些概念和模式,因为它们最终会被证明是有用的。
在这篇博客中,我将带领您一步一步地学习教程,这样您既可以学习 CQRS,也可以最终获得一个漂亮的项目结构,您可以向您的朋友炫耀。你还可能学到一两个额外的技巧。在博客的最后,我提供了整个解决方案的链接。
在解决 CQRS 的概念之前,我们先来了解一下架构简洁之道。
❓️ Why?
因为结合这两个组合给予我们一个相当不错的基础,可以进一步发展。Clean Architecture是关于层和边界,并创建一个干净的项目结构,就像名字本身所暗示的那样。
我们可以看到这些层次是如何形成一个解决方案的。重要的是要知道,外层依赖于内层,而不是反之。
在一个完美的世界里,这个层不会有任何依赖关系,它只包含实体、值对象,也许还有一些域级的自定义异常和实体逻辑。这个层可以通过遵循领域驱动设计准则来塑造。我建议你深入探讨这些准则,由于这是一个广泛的主题,我将把它留给你。
应用层与领域层一起构成了解决方案的核心,它应该能够独立于外层操作和提供业务逻辑,并完全依赖于领域层。它包含了所有好的东西,比如业务逻辑(用例)、DTO、接口,以及我们后面要讨论的所有CQRS的东西。
这一层要实现所有与外部系统的通信逻辑,如发送邮件、与第三方API通信等。它只取决于应用层。如果不是过于庞大和/或复杂,它也可以包含持久化逻辑。
与基础设施层相比,这一层也存放着与外部系统通信的逻辑,但其具体目的是与数据库通信。这些逻辑也都可以放在基础设施层下。这一层只依赖于应用层。
这是可交互层(由外界),让客户端在请求数据后得到可见的结果。这一层的形式可以是API、控制台应用、GUI客户端应用等。和Persistence一样,它也只依赖于Application层。
既然您已经对架构有了快速的概述,我们可以继续探索CQRS的全部内容。
你是否有过这样的经历:不得不调整逻辑或模型的某些部分,而在完成这项任务后,你意识到你干掉了一半的应用程序?或者,你是否曾经不得不修复一些bug(当然是由其他开发者创建的),然后你在代码库中搜索一些特定的逻辑部分,但很难找到,因为它都是面条代码?又或者你的应用上用户的负载急剧增加,而你当前的机器已经无法处理了,"扩容"按钮被灰化了,因为那是很久以前的事了,你已经用了顶级机器,然后你想到了用微服务来平衡负载,但你掩面而泣,因为你知道重构所有这些意大利面条要花费多少精力和时间?
这就是CQRS努力解决的问题!
CQRS是Command Query Responsibility Segregation的缩写,当我第一次学习这个的时候,我最初的想法是:"好吧,这个名字对理解这个没有什么帮助"。尽管当你开始理解这个名字背后的概念时,它确实有帮助。所以名字基本上就是全部了。让我们把命令和查询的责任分开。
那么接下来的问题就来了,"什么是命令和查询?"
嗯,比较简单,我以CRUD操作为例。
CREATE、UPDATE和DELETE都是用来告诉系统插入、改变或删除某些东西的方法。你可能已经知道了,你是在发出命令。而read方法,你只是想得到一些数据,是的,这就是一个查询,就像你查询数据库一样。
现在,我们对CQRS应该做什么有了一些基本的概念,我们来到了下面的问题:_但我们如何在实践中使用所有这些_?这就给我们带来了一个更具体的问题--_如何分离这些责任_?
这就是我们接下来要解决的问题。
我们来看看CQRS在实践中的表现。
现在,让我们假设我们有一个应用层,其业务逻辑被分离为用例或者说是服务。也许你会有一个论坛帖子的服务,它将包含所有关于论坛帖子的逻辑,并且可能依赖于其他服务。此外,这个服务有可能在其他地方被重复使用。
它看起来像这样:
当你需要调整某个服务中的方法来支持第二个服务时,这种方法可能会出现问题,这可能会破坏使用第一个服务的其他第三个服务中的逻辑。你最终会很头疼,因为你需要支持多种情况,然后想办法为所有这些边况调整逻辑。
或者你想把应用分离成微服务,直到你意识到这将是多么困难,因为逻辑交织在一起?
CQRS模式解决了这些问题,有很多优点。当然,没有什么东西是完美的,所以CQRS模式也有它的缺点,比如不能完全DRY(Don't Repeat Yourself),管理它将需要更多的时间进行一些全局性的改变。
现在让我们看看如何以及为什么。
CQRS的结构是这样的:
正如你所看到的,这些类中的每一个都只有一个责任,而且它们不会被重复使用。它们中的每一个都有自己的模型,即使它们可能与其他模型的相似或完全拷贝。然而,编程和项目架构是主观的东西,所以你可以通过一些可重用的共同事物来结合方法。所有这些分离使得我们很容易发现问题,不会因为摆弄一些东西而毁掉代码库的其他部分。同时,这也使得最终从代码中提取微服务变得容易。
看看我使用的一些额外的插件:
📕 MediatR
在迷上MediatR之前,我真的错过了很多东西。MediatR是CQRS和简洁架构的一个方便的补充,它使开发人员的生活更加轻松,因为它可以独立地处理每件事情,你将在本文后面看到这一点。
我强烈建议你去看看,因为我们要用它来做我们的小项目。
📕 AutoMapper
AutoMapper是一个让两个对象之间的映射变得简单的工具。
📕 Swashbuckle Swagger
没有Swagger的后端应用程序是不完整的。它是用于端点的文档GUI,以及使用它所必需的所有细节。
现在,在我们进入代码之前,重要的是要记住,即使我们可以创建一个Domain层和一些实体,我们也不会在这个项目中使用它们。
作为从头开始创建所有东西的替代方案,你可能想在开始阅读之前先看看这个repo,这样你就可以跟着所有的例子走。它是基于.NET Core 3.1的。
第一步是按照代码简洁之道定义,按层准备项目结构,然后添加额外的Nuggets,确保一切正常运行。
对于这个项目的主题,我们要做一个类似论坛的app的CQRS后台,但这并不重要,因为业务逻辑不是最重要的。以及,我使用JSONPlaceholder来获取数据,作为数据库的替代品。此外,我决定将其创建为API后台解决方案。
🔌 所需插件
在我们的案例中,Presentation层是应用程序的入口,两个主要的入口文件是Program.cs和Startup.cs。对于我们在这里要学习的内容,我们只需要修改Startup,因为我们在那里定义了我们的服务、依赖注入和请求管道。另外,在这一层中,我们有控制器,主要功能是获取一些输入并触发我们的MediatR请求管道。
🥲 Startup.cs
using Application;
using Infrastructure;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
namespace Presentation
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
private IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddApplication();
services.AddInfrastructure(Configuration);
services.AddControllers();
services.AddSwaggerGen(config =>
{
config.SwaggerDoc("v1", new OpenApiInfo() {Title = "CQRS Forum", Version = "v1"});
});
// Make routes globally lowercase.
services.AddRouting(options =>
{
options.LowercaseUrls = true;
options.LowercaseQueryStrings = true;
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
app.UseSwagger();
app.UseSwaggerUI(config => config.SwaggerEndpoint("/swagger/v1/swagger.json", "CQRS Forum v1"));
}
}
}
在Startup中,一个重要的部分是添加那些来自Application和基础设施层的依赖注入容器方法,名为AddApplication()和AddInfrastructure()。我还添加了基本的Swagger配置。
🥲 Controllers/BaseController.cs
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
namespace Presentation.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class BaseController : ControllerBase
{
private IMediator _mediator;
protected IMediator Mediator => _mediator ??= HttpContext.RequestServices.GetService<IMediator>();
}
}
基础控制器是使用新的C# 8特性创建的,因此我们遵循DRY原则,尽可能保持控制器的干净。
🥲 Controllers/PostsController.cs
using System.Collections.Generic;
using System.Threading.Tasks;
using Application.Posts.Commands.CreatePost;
using Application.Posts.Queries.GetAllPosts;
using Microsoft.AspNetCore.Mvc;
namespace Presentation.Controllers
{
public class PostsController : BaseController
{
[HttpGet]
public async Task<ActionResult<IEnumerable<GetAllPostsDto>>> GetAllPosts()
{
var response = await Mediator.Send(new GetAllPostsQuery());
return Ok(response);
}
[HttpPost]
public async Task<ActionResult<CreatePostDto>> CreatePost(CreatePostCommand command)
{
var response = await Mediator.Send(command);
return CreatedAtAction(nameof(CreatePost), response);
}
}
}
由于我们创建了具有所有功能的基础控制器,我们可以用它来保持我们其他控制器的简单。正如你所看到的,传统的方式是在这里注入一些PostsService并调用它的方法。但你可以看到不同的是,我们只把命令或查询作为对象发送给MediatR,它将通过它的管道来处理其余的事情。
🔌 所需插件
就像在基础设施上一样,该层的根部包含一个DI容器和其余的文件夹.这是CQRS魔法发生的地方。
首先,我们来说说Common文件夹。正如名字本身所说,它应该包含一些常用的东西。在这个特殊的情况下,我用它来为AutoMapper添加助手,这将有助于保持我们的代码干净,并位于适当的位置(用于重新绑定模型),这样以后就更容易维护了。
🥲 Common/Mappings/IMapFrom.cs
using AutoMapper;
namespace Application.Common.Mappings
{
public interface IMapFrom<T>
{
void Mapping(Profile profile) => profile.CreateMap(typeof(T), GetType());
}
}
通过在模型上继承IMapFrom,我们获得了对这个Mapping函数的访问权,然后我们可以使用它来设置我们的转换。
🥲 Common/Mappings/MappingProfile.cs
using System;
using System.Linq;
using System.Reflection;
using AutoMapper;
namespace Application.Common.Mappings
{
public class MappingProfile : Profile
{
public MappingProfile()
{
ApplyMappingsFromAssembly(Assembly.GetExecutingAssembly());
}
private void ApplyMappingsFromAssembly(Assembly assembly)
{
var types = assembly.GetExportedTypes()
.Where(t => t.GetInterfaces().Any(i =>
i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IMapFrom<>)))
.ToList();
foreach (var type in types)
{
var instance = Activator.CreateInstance(type);
var methodInfo = type.GetMethod("Mapping");
methodInfo?.Invoke(instance, new object[] { this });
}
}
}
}
现在是主体部分,Posts文件夹。根目录下包含了 PostsApi 的接口与 Commands 和 Queries 文件夹。
在接下来的部分,我们将重点介绍查询部分。
🥲 Posts/IPostsApi.cs
using System.Collections.Generic;
using System.Threading.Tasks;
using Application.Posts.Commands.CreatePost;
using Application.Posts.Queries.GetAllPosts;
namespace Application.Posts
{
public interface IPostsApi
{
Task<IEnumerable<GetAllPostsResponse>> GetAllPosts();
Task<CreatePostResponse> CreatePost(CreatePostRequest request);
}
}
🥲 Posts/Queries/GetAllPosts/GetAllPostsQuery.cs
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
using MediatR;
namespace Application.Posts.Queries.GetAllPosts
{
public class GetAllPostsQuery : IRequest<IEnumerable<GetAllPostsVm>>
{
public class GetAllPostsQueryHandler : IRequestHandler<GetAllPostsQuery, IEnumerable<GetAllPostsVm>>
{
private readonly IPostsApi _postsApi;
private readonly IMapper _mapper;
public GetAllPostsQueryHandler(IPostsApi postsApi, IMapper mapper)
{
_postsApi = postsApi;
_mapper = mapper;
}
public async Task<IEnumerable<GetAllPostsVm>> Handle(GetAllPostsQuery request, CancellationToken cancellationToken)
{
var posts = await _postsApi.GetAllPosts();
return _mapper.Map<IEnumerable<GetAllPostsVm>>(posts);
}
}
}
}
所以如上所述,在使用MediatR时,主要有三个部分。我们有一个查询类、查询处理程序类和处理程序方法。如果我们做的是CREATE方法,我们的查询类,在这种情况下会被称为command,将持有所有必要的输入属性。
它通过继承IRequest接口被定义为一个MediatR类,由于它是通用性质的,我们必须为它添加一个响应类型。如果你的方法是void,那么就不要添加任何返回类型,但是你的handle方法的返回类型必须是Task<Unit>类型。
处理程序类是通过继承IRequestHandler<queryClass returnType>来定义的,如果返回void就省略returnType。
最后,我们有一个handler方法,它承载着业务逻辑,如果它应该返回void,那么它的签名返回类型应该像前面说的那样定义为Task<Unit>,它应该返回Unit.Value。
🥲 Posts/Queries/GetAllPosts/GetAllPostsResponse.cs
using System.Text.Json.Serialization;
namespace Application.Posts.Queries.GetAllPosts
{
public class GetAllPostsResponse
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("body")]
public string Body { get; set; }
[JsonPropertyName("userId")]
public int UserId { get; set; }
}
}
在这里,我们定义了一个类,该类将作为第三方API的响应序列化。我们还添加了数据批注,以作为System.Text.Json如何绑定属性的指南。
🥲 Posts/Queries/GetAllPosts/GetAllPostsDto.cs
using Application.Common.Mappings;
using AutoMapper;
namespace Application.Posts.Queries.GetAllPosts
{
public class GetAllPostsDto : IMapFrom<GetAllPostsResponse>
{
public int UserId { get; set; }
public int Id { get; set; }
public string Title { get; set; }
public string Body { get; set; }
public void Mapping(Profile profile)
{
profile.CreateMap<GetAllPostsResponse, GetAllPostsDto>();
}
}
}
最后,我们看到了将用作视图模型的DTO模型,并且在其中,我们可以看到使用来自响应类的自动映射的示例。
🔌 所需插件
我创建了一个类型化HttpClient基类,它是由为JSONPlaceholder API制作的特定客户端继承的,并将论坛帖子的逻辑做了一个抽象,名为 PostsApi.cs。所有这些都是通过一个DI容器添加的,这个容器位于根目录。我试图让它尽可能的简单,同时使用一些最佳实践。你可能会问:"为什么HTTP客户端有这么多的抽象?" 答案很简单--我是一名.NET开发人员,我喜欢我的抽象。这也使得代码更干净,更了解它在做什么。
🥲 BaseHttpClient.cs
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace Infrastructure
{
public class BaseHttpClient
{
private readonly HttpClient _httpClient;
protected BaseHttpClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
protected async Task<T> Get<T>(string uri)
{
var request = CreateRequest(HttpMethod.Get, uri);
return await ExecuteRequest<T>(request);
}
protected async Task<T> Post<T>(string uri, object content)
{
var request = CreateRequest(HttpMethod.Post, uri, content);
return await ExecuteRequest<T>(request);
}
private static HttpRequestMessage CreateRequest(HttpMethod httpMethod, string uri, object content = null)
{
var request = new HttpRequestMessage(httpMethod, uri);
if (content == null) return request;
// Serialize content
var json = JsonSerializer.Serialize(content);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
return request;
}
private async Task<T> ExecuteRequest<T>(HttpRequestMessage request)
{
try
{
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
.ConfigureAwait(false);
var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return string.IsNullOrEmpty(responseContent) ? default : JsonSerializer.Deserialize<T>(responseContent);
}
catch (Exception ex) when (ex is ArgumentNullException ||
ex is InvalidOperationException ||
ex is HttpRequestException ||
ex is JsonException)
{
throw new Exception("HttpClient exception", ex);
}
}
}
}
现在我们已经实现了一个通用的HttpClient基类,它可以用更多的方法进一步扩展,我将让你来决定是否要进一步扩展它。我之所以决定这样做,是因为让它更简单易用,让它可以重用,并且保持DRY。
🥲 JsonPlaceholderApi/JsonPlaceholderClient.cs
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Application.Posts.Commands.CreatePost;
using Application.Posts.Queries.GetAllPosts;
namespace Infrastructure.JsonPlaceholderApi
{
public class JsonPlaceholderClient : BaseHttpClient
{
public JsonPlaceholderClient(HttpClient httpClient) : base(httpClient)
{
}
public async Task<IEnumerable<GetAllPostsResponse>> GetAllPosts()
{
return await Get<IEnumerable<GetAllPostsResponse>>(Endpoints.Posts.GetAllPosts);
}
public async Task<CreatePostResponse> CreatePost(CreatePostRequest request)
{
return await Post<CreatePostResponse>(Endpoints.Posts.CreatePost, request);
}
}
}
在实现了HttpClient基类,我们可以用它来完成特定的任务,比如在我们的例子中,与JsonPlaceholder API进行通信,以获得一个帖子列表。同样的,由于我们以这样的方式创建了基本的HttpClient,我们可以创建更多这样的特定类型的客户端,用于与一个以上的第三方API进行通信。
🥲 JsonPlaceholderApi/PostsApi.cs
using System.Collections.Generic;
using System.Threading.Tasks;
using Application.Posts;
using Application.Posts.Commands.CreatePost;
using Application.Posts.Queries.GetAllPosts;
namespace Infrastructure.JsonPlaceholderApi
{
public class PostsApi : IPostsApi
{
private readonly JsonPlaceholderClient _client;
public PostsApi(JsonPlaceholderClient client)
{
_client = client;
}
public async Task<IEnumerable<GetAllPostsResponse>> GetAllPosts()
{
return await _client.GetAllPosts();
}
public async Task<CreatePostResponse> CreatePost(CreatePostRequest request)
{
return await _client.CreatePost(request);
}
}
}
在这里,我们在通往HttpClient的路上又多了一个抽象。为什么要这样做呢?为什么不把JsonPlaceholderClient和 PostsApi合并成一个类呢?
原因主要是HttpClient的实例化和分离。我们大多数人都习惯了仓库模式,所以通过一定程度上遵循这些模式,我们知道这个类是怎么回事。
如果你想了解更多关于实例化HttpClient的问题,我建议你阅读这个。
🥲 DependencyInjection.cs
using System;
using Application.Posts;
using Infrastructure.JsonPlaceholderApi;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Infrastructure
{
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpClient<JsonPlaceholderClient>("JsonPlaceholderClient", config =>
{
config.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
config.Timeout = TimeSpan.FromSeconds(30);
});
services.AddTransient<IPostsApi, PostsApi>();
return services;
}
}
}
整个项目可以在这里找到。
读完这篇博客,我希望你对CQRS有了更多的了解,并对迎接新的挑战感到兴奋。就我个人而言,对我来说,CQRS是最好的方式,没有更好的方式来组织你的项目,从主观上讲,直到下一个新的大的编程缩写出现,做更大更好的事情。感谢你耐心地和我一起经历这些,祝你好运,编码愉快!