理解 CQRS 架构





5.00/5 (4投票s)
理解 CQRS 架构并学习如何实现它
在深入探讨主题之前,值得注意的是,CQRS 代表命令查询职责分离(Command Query Responsibility Segregation)。
引言
微服务是一种架构风格,它将软件应用程序构建为一系列小型、独立且松散耦合的服务。在微服务架构中,应用程序被分解为一组可独立部署的服务,每个服务代表一个特定的业务能力。这些服务可以独立开发、部署和扩展,从而实现更大的灵活性、可维护性和可伸缩性。
这种介绍微服务的方式在各种博客中都很常见;然而,实际上,这仅仅是叙述的开端。在实践中,将分散的服务有机地组织起来是一项极具挑战性的任务,需要对业务有深刻的理解。尽管复杂,这项任务可以说最为关键,因此,它被称为将领域组织成战略设计。一旦这个战略设计被清晰地定义,并且各个服务获得了利益相关者的共识,它们的实现就会变得流畅,并且每个服务都可以采用不同的架构风格。这些风格就是战术模式。例如,我们可以构建一个 CRUD 应用程序,或者在它们之间实现更复杂的策略。
在本系列文章中,我们将深入探讨一种广泛采用的战术范例,称为 CQRS,其目标是将数据读取请求与数据修改请求明确分离。我们将探讨在 Azure Functions 中为无服务器架构实现 CQRS。
以下关于此主题的教科书是永恒的经典,值得放在每位开发者的书架上。它们超越了 CQRS 架构,涵盖了众多广泛而通用的实际用例。
本文最初发布于 此处。
免责声明
本文提出的概念是主观的,并非普遍适用于所有组织。
微服务已成为一个热门词汇,许多组织都在讨论它们并渴望转向面向服务的架构。虽然理论概念很有吸引力,并且分布式架构被设想为具有变革性,但实际实现却要困难得多。为什么?因为将整个企业组织成明确的孤岛是一项复杂的任务。
什么是战略设计?
战略设计,在软件架构和微服务的背景下,指的是应用程序或系统内服务的高层规划和组织。它涉及如何构建和划分应用程序以符合业务领域和目标的决策,侧重于创建一种连贯有效的服务安排,反映业务的需求和优先事项,并最终为系统的整体架构奠定基础,直接影响各个微服务的结构以及它们之间的交互方式。
它需要对业务领域有深入的了解,与利益相关者合作,并关注长期目标和可伸缩性。在战略设计期间做出的决策对微服务架构的成功和可维护性有显著影响。
重要
在 DDD 术语中,战略设计涉及将企业组织成有界上下文。不同有界上下文之间的关系和交互构成了上下文图。
什么是战术设计?
一旦这些模式到位,启动开发就相对简单了,并且每个服务都可以采用自己的架构风格。
战术设计,在微服务架构的背景下,侧重于在单个服务级别做出的实现细节和具体技术选择。它涉及与每个微服务内的内部结构、通信机制和技术栈相关的决策。战术设计更关注构建和维护微服务的实际方面。
微服务中战术设计的关键方面包括,例如,为单个服务选择架构模式和风格,如 RESTful API、事件驱动架构或微内核架构,决定数据存储方法,包括每个微服务使用何种数据库以及如何持久化和检索数据,确定微服务之间如何通信,是通过同步 RESTful 调用、异步消息传递还是其他通信模式等等。
什么是 CRUD?什么是 CQRS?
CRUD 和 CQRS 都是战术模式,侧重于单个服务级别的实现细节。因此,断言一个组织完全依赖 CQRS 架构可能不完全准确。虽然某些服务可能采用这种架构,但通常其他服务会采用更简单的范例。整个组织可能不会对所有问题都遵循统一的风格。
-
CRUD 架构假定读写操作存在一个单一的模型。CRUD 操作通常与传统的 relacion databases 系统相关联,并且许多应用程序在数据管理中采用基于 CRUD 的方法。
-
相反,CQRS 架构假定查询和命令存在不同的模型。虽然这种范例实现起来更复杂且引入了一些细微之处,但它具有能够更严格地执行数据验证、实现强大的安全措施和优化性能的优势。
这些定义目前可能显得有些模糊和抽象,但随着我们深入细节,清晰度将会显现。在此需要指出的是,CQRS 或 CRUD 不应被视为一种盲目适用于所有情况的总体哲学。相反,它们之间的选择应根据所讨论微服务的具体特征和需求谨慎做出。现在是时候看看它们的实际应用了。
CRUD 架构的局限性是什么?
CRUD 架构风格得到了广泛应用,并且主要强调读写操作具有单一模型的模式。
我们将此模式应用于一家虚构的公司,该公司负责开发两个简单的服务:一个负责存储所有参考数据(国家、货币等),另一个负责管理身份和身份验证过程。
ReferenceData 服务的 CRUD
ReferenceData
服务负责管理整个组织使用的信息,包括国家、货币、部门等。我们将使用 Azure Function 来实现它,并编写一些 CRUD 方法。
- 定义一个
Country
类,以示例形式模拟一个国家public class Country { public string Id { get; set; } public string Name { get; set; } }
- 在存储库中定义一些有用的方法来查找或保存国家
public class CountryRepository : ICountryRepository { public async Task<Country> FindById(string id) { // ... } public Task SaveCountry(Country country) { // ... } }
- 以下是一个使用此存储库的 Azure Function 示例。
[FunctionName(nameof(FindCountryById))] public async Task<IActionResult> GetAccountById([HttpTrigger (AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req, ILogger log) { var id = req.Query["countryId"]; var repository = new CountryRepository(); // In a real-world application, use DI. return new OkObjectresult(await repository.FindById(id).ConfigureAwait(false)); } [FunctionName(nameof(SaveCountry))] public async Task<IActionResult> SaveCountry([HttpTrigger (AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, ILogger log) { var body = await new StreamReader(req.Body).ReadToEndAsync(); var data = JsonConvert.DeserializeObject<List<SaveCountryRequest>>(body); var country = new Country() { Id = data.Id, Name = data.Name }; var repository = new CountryRepository(); // In a real-world application, use DI. await repository.SaveCountry(country).ConfigureAwait(false); return new OkResult(); }
信息
出于简洁性考虑,某些类和接口(ICountryRepository
等)在此处未开发。
在这种情况下,这种方法非常合适,因为读写操作的需求是相同的。当请求一个国家时,客户端应用程序只需要一个 id 和一个名称。同样,为了创建或编辑一个国家,id 和名称是唯一需要的属性。在此上下文中,分离两者将是不必要的复杂性。
Membership 服务的 CRUD
现在,让我们考虑一个更复杂的场景,其中管理员注册一个新用户(带有电子邮件和密码),然后可以在其仪表板中查看所有已注册的用户。再次,我们按照为 ReferenceData
服务提到的过程开始。
- 定义一个
User
类,以示例形式模拟一个用户public class User { public string Email { get; set; } public string Password { get; set; } }
- 在存储库中定义一个创建用户的方法
public class UserRepository : IUserRepository { public Task SaveUser(User user) { // ... } }
- 在同一存储库中定义一个查找所有用户的方法
public class UserRepository : IUserRepository { public Task<List<User>> FindUsers() { // ... } }
- 以下是一个使用此存储库的 Azure Function 示例
[FunctionName(nameof(FindAllUsers))] public async Task<IActionResult> FindAllUsers([HttpTrigger (AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req, ILogger log) { var repository = new UserRepository(); // In a real-world application, use DI. return new OkObjectresult(await repository.FindUsers().ConfigureAwait(false)); // // Passwords are returned to the client !!! // } [FunctionName(nameof(SaveUser))] public async Task<IActionResult> SaveUser([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, ILogger log) { var body = await new StreamReader(req.Body).ReadToEndAsync(); var data = JsonConvert.DeserializeObject<List<SaveUserRequest>>(body); var repository = new UserRepository(); // In a real-world application, use DI. await repository.SaveUser(data.ConvertToUser()).ConfigureAwait(false); return new OkResult(); }
然而,FindAllUsers
函数出现了一个问题:用户的密码会被返回给客户端,使其容易被攻击者拦截。更重要的是,即使是管理员也不应该访问其客户的凭据。这构成了严重的安全漏洞。
具体问题是什么?
根本问题在于,我们需要使用同一个模型执行两个具有不同需求的不同操作
- 写模型必须存储电子邮件和密码,以便用户在后续登录尝试中进行身份验证。
- 读模型不仅仅是显示用户的所有属性;它还包括遵守安全要求和执行保密规则。
为什么不简单地限制 FindUsers 方法中要检索的属性?
如果数据存储在关系数据库中,我们可以,例如,设想如下编码检索用户属性
SELECT t.Email --Password is not returned.
FROM Users
这样,通过不从数据库检索密码,它就不会返回给客户端。虽然这是一个解决方案,但仓促之间,一个粗心的开发人员有一天可能会将此代码修改为如下所示
SELECT *
FROM Users
因此,这需要严格的纪律,而这种纪律并不总是可持续的。
我们也可以直接在 Azure Function 中处理此逻辑。
[FunctionName(nameof(FindAllUsers))]
public async Task<IActionResult> FindAllUsers([HttpTrigger
(AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req, ILogger log)
{
var repository = new UserRepository(); // In a real-world application, use DI.
var users = await repository.FindUsers().ConfigureAwait(false);
users.ToList().ForEach(x =>
{
x.Password = "";
});
return new OkObjectresult(users);
}
这又是一个解决方案,但业务逻辑与技术代码纠缠在一起。对于非关键应用程序,这完全可以接受,但对于典型的业务线应用程序,我们需要找到更好的替代方案。CQRS 来拯救!
什么是 CQRS?
如前所述,CQRS 架构假定查询和命令存在不同的模型。
重要
值得注意的是,上图未显示数据库等管道机器。事实上,CQRS 强制分离模型(读写),但并不要求为每个模型使用不同的数据存储。因此, nothing 阻止使用单个关系数据库,甚至是同一个表,进行读写。
这个定义有些简约,但它包含了底层哲学,无需夸大:CQRS 仅仅是查询和命令的分离。
信息
在 CQRS 术语中,写模型表示为“命令”,而读模型称为“查询”。
我们如何实现这种范例?
- 定义一个
User
类public class User { public string Email { get; set; } public string Password { get; set; } }
信息
这个类与其前一个版本保持不变;然而,这一次,它将专门由写模型使用。
- 定义一个
UserQuery
类public class UserQuery { public string Email { get; set; } }
这个类仅限于显示在屏幕上所需的必要元素。即使一个不留神的工程师无意中将底层 SQL 查询修改为包含密码,它也不会被序列化,因此也不会返回。这种方法通过设计确保了安全性。
- 定义
UserRepository
类public class UserRepository : IUserRepository { public Task<List<UserQuery>> FindUsers() { // ... } public Task SaveUser(User user) { // ... } }
- 以下是一个使用此存储库的 Azure Function 示例
[FunctionName(nameof(FindAllUsers))] public async Task<IActionResult> FindAllUsers([HttpTrigger (AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req, ILogger log) { var repository = new UserRepository(); // In a real-world application, use DI. return new OkObjectresult(await repository.FindUsers().ConfigureAwait(false)); } [FunctionName(nameof(SaveUser))] public async Task<IActionResult> SaveUser([HttpTrigger (AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, ILogger log) { var body = await new StreamReader(req.Body).ReadToEndAsync(); var data = JsonConvert.DeserializeObject<List<SaveUserRequest>>(body); var repository = new UserRepository(); // In a real-world application, use DI. await repository.SaveUser(data.ConvertToUser()).ConfigureAwait(false); return new OkResult(); }
所有这些就为了这个!是的,这种实现可能有些令人失望(尽管我们的用例特别简单),但我们再次满足于分离不同的概念。谁说架构必须复杂?
信息
在现实场景中,User
类通常会更复杂,包含复杂的验证规则和创建或更新时需要验证的附加业务要求。通常,检索数据时所有这些规则都是不必要的。分离两个模型的明显优势显而易见,因为查询(读模型)不会被与其关注点无关的代码所负担。
尽管如此,CQRS 在实践中通常与更复杂的平台结合使用,我们现在将简要探讨它可能引入的一些复杂性。但是,为了避免使本文过于冗长,有兴趣深入探讨的读者可以在 此处 找到后续内容。
历史
- 2024 年 1 月 24 日:初始版本