第 6 章 事件溯源与 CQRS

在本章,我们来了解一下随着云平台一同出现的设计模式

我们先探讨事件溯源和命令查询职责分离(CQRS)背后的动机与哲学

事件溯源简介

事实由事件溯源而来

我们大脑就是一种事件溯源系统,接收感官多种形式刺激,大脑负责对这些刺激进行合适排序,大约每隔几百毫秒,对刺激构成的流进行运算,而运算的结果,就是我们所说的事实

事件溯源的定义

传统应用中,状态由一系列零散的数据所管理,如果客户端向我们发送 PUT 或 POST 请求,状态就会改变

这种方式很好地给出了系统当前状态,却不能指示在当前状态之前,系统是如何变化的

事件溯源可以解决这个问题,因为它把状态管理的职责与接收导致状态变更的刺激的职责区分开来

基于事件溯源的系统需要满足一系列要求

  • 有序:有序事件流
  • 幂等:等价多个有序事件流的操作结果相同
  • 独立:不依赖外部信息
  • 过去式:事件发生在过去

流行的区块链技术的基础就是发生在特定私有资源上的安全、可信的事件序列

拥抱最终一致性

一种我们每天都在用的最终一致性的应用,就是社区网络应用

有时你从一个设备发出的评论要花几分钟才能展示在朋友的浏览器或者其他设备上

这是因为,应用的架构人员做了妥协:通过放弃同步操作的即时一致性,在可接受的范围内增加一定的反馈延迟,就能让应用支持巨大的规模与流量

CQRS 模式

如果把我们讨论的模式直接套用到系统中,很快会发现系统必须对输入命令和查询加以区分,这也被称为命令查询职责分离(CQRS)

我们用一个例子来说明这种模式的实际应用

租户通过一个门户网站查看用电情况,每当用户刷新门户页面时,就调用某种数据服务并请求,汇总一段时间内所有度量事件

但这种对于云规模的现代软件开发来说是不可接受的,如果将计算职责推卸给数据库,很快会造成数据库瓶颈

掌握了大多数客户的使用模式,让我们能够利用事件溯源来构建一个合理的 CQRS 实现。

事件处理器每次收到新事件时重新计算已缓存的度量总和

利用这种机制,在查询时,门户上的用户所期望的结果已经存在于数据库或者缓存中

不需要复制的计算,也没有临时的聚合与繁杂的汇总,只需要一个简单的查询

事件溯源于 CQRS 实战--附件的团队成员

接下来要开发的新版实例中,我们将检测成员彼此相距一个较小距离的时刻

系统将支持对这些接近的结果予以响应

例如我们可能希望向附近的团队成员的移动设备发送推送通知,以提醒他们可以约见对方

为了实现这一功能,我们把系统职责划分为以下四个组件:

  • 位置报送服务(命令)
  • 事件处理器(对事件进行溯源)
  • 事实服务(查询)
  • 位置接近监控器(对事件进行溯源)
位置报送服务

收到新报送的位置后,执行下列操作:

  • 验证上报数据
  • 将命令转换为事件
  • 生成事件,并用消息队列发送出去

GitHub 链接:https://github.com/microservices-aspnetcore/es-locationreporter

创建位置报送控制器

using System;
using Microsoft.AspNetCore.Mvc;
using StatlerWaldorfCorp.LocationReporter.Events;
using StatlerWaldorfCorp.LocationReporter.Models;
using StatlerWaldorfCorp.LocationReporter.Services; namespace StatlerWaldorfCorp.LocationReporter.Controllers
{
[Route("/api/members/{memberId}/locationreports")]
public class LocationReportsController : Controller
{
private ICommandEventConverter converter;
private IEventEmitter eventEmitter;
private ITeamServiceClient teamServiceClient; public LocationReportsController(ICommandEventConverter converter,
IEventEmitter eventEmitter,
ITeamServiceClient teamServiceClient) {
this.converter = converter;
this.eventEmitter = eventEmitter;
this.teamServiceClient = teamServiceClient;
} [HttpPost]
public ActionResult PostLocationReport(Guid memberId, [FromBody]LocationReport locationReport)
{
MemberLocationRecordedEvent locationRecordedEvent = converter.CommandToEvent(locationReport);
locationRecordedEvent.TeamID = teamServiceClient.GetTeamForMember(locationReport.MemberID);
eventEmitter.EmitLocationRecordedEvent(locationRecordedEvent); return this.Created($"/api/members/{memberId}/locationreports/{locationReport.ReportID}", locationReport);
}
}
}

创建 AMQP 事件生成器

using System;
using System.Linq;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using StatlerWaldorfCorp.LocationReporter.Models; namespace StatlerWaldorfCorp.LocationReporter.Events
{ public class AMQPEventEmitter : IEventEmitter
{
private readonly ILogger logger; private AMQPOptions rabbitOptions; private ConnectionFactory connectionFactory; public AMQPEventEmitter(ILogger<AMQPEventEmitter> logger,
IOptions<AMQPOptions> amqpOptions)
{
this.logger = logger;
this.rabbitOptions = amqpOptions.Value; connectionFactory = new ConnectionFactory(); connectionFactory.UserName = rabbitOptions.Username;
connectionFactory.Password = rabbitOptions.Password;
connectionFactory.VirtualHost = rabbitOptions.VirtualHost;
connectionFactory.HostName = rabbitOptions.HostName;
connectionFactory.Uri = rabbitOptions.Uri; logger.LogInformation("AMQP Event Emitter configured with URI {0}", rabbitOptions.Uri);
}
public const string QUEUE_LOCATIONRECORDED = "memberlocationrecorded"; public void EmitLocationRecordedEvent(MemberLocationRecordedEvent locationRecordedEvent)
{
using (IConnection conn = connectionFactory.CreateConnection()) {
using (IModel channel = conn.CreateModel()) {
channel.QueueDeclare(
queue: QUEUE_LOCATIONRECORDED,
durable: false,
exclusive: false,
autoDelete: false,
arguments: null
);
string jsonPayload = locationRecordedEvent.toJson();
var body = Encoding.UTF8.GetBytes(jsonPayload);
channel.BasicPublish(
exchange: "",
routingKey: QUEUE_LOCATIONRECORDED,
basicProperties: null,
body: body
);
}
}
}
}
}

配置并启动服务

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
using Microsoft.Extensions.Logging;
using System.Linq;
using StatlerWaldorfCorp.LocationReporter.Models;
using StatlerWaldorfCorp.LocationReporter.Events;
using StatlerWaldorfCorp.LocationReporter.Services; namespace StatlerWaldorfCorp.LocationReporter
{
public class Startup
{
public Startup(IHostingEnvironment env, ILoggerFactory loggerFactory)
{
loggerFactory.AddConsole();
loggerFactory.AddDebug(); var builder = new ConfigurationBuilder()
.SetBasePath(env.ContentRootPath)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
.AddEnvironmentVariables(); Configuration = builder.Build();
} public IConfigurationRoot Configuration { get; } public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddOptions(); services.Configure<AMQPOptions>(Configuration.GetSection("amqp"));
services.Configure<TeamServiceOptions>(Configuration.GetSection("teamservice")); services.AddSingleton(typeof(IEventEmitter), typeof(AMQPEventEmitter));
services.AddSingleton(typeof(ICommandEventConverter), typeof(CommandEventConverter));
services.AddSingleton(typeof(ITeamServiceClient), typeof(HttpTeamServiceClient));
} public void Configure(IApplicationBuilder app,
IHostingEnvironment env,
ILoggerFactory loggerFactory,
ITeamServiceClient teamServiceClient,
IEventEmitter eventEmitter)
{
// Asked for instances of singletons during Startup
// to force initialization early. app.UseMvc();
}
}
}

对 Configure 的两次调用让配置子系统把分别从 amqp 和 teamservice 节加载的配置选项以依赖注入的方式提供出来

这些配置可以由 appsettings.json 文件提供,也可以用环境变量覆盖

{
"amqp": {
"username": "guest",
"password": "guest",
"hostname": "localhost",
"uri": "amqp://localhost:5672/",
"virtualhost": "/"
},
"teamservice": {
"url": "http://localhost:5001"
}
}

消费团队服务

using System;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using Newtonsoft.Json;
using StatlerWaldorfCorp.LocationReporter.Models; namespace StatlerWaldorfCorp.LocationReporter.Services
{
public class HttpTeamServiceClient : ITeamServiceClient
{
private readonly ILogger logger; private HttpClient httpClient; public HttpTeamServiceClient(
IOptions<TeamServiceOptions> serviceOptions,
ILogger<HttpTeamServiceClient> logger)
{
this.logger = logger; var url = serviceOptions.Value.Url; logger.LogInformation("Team Service HTTP client using URL {0}", url); httpClient = new HttpClient();
httpClient.BaseAddress = new Uri(url);
}
public Guid GetTeamForMember(Guid memberId)
{
httpClient.DefaultRequestHeaders.Accept.Clear();
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); HttpResponseMessage response = httpClient.GetAsync(String.Format("/members/{0}/team", memberId)).Result; TeamIDResponse teamIdResponse;
if (response.IsSuccessStatusCode) {
string json = response.Content.ReadAsStringAsync().Result;
teamIdResponse = JsonConvert.DeserializeObject<TeamIDResponse>(json);
return teamIdResponse.TeamID;
}
else {
return Guid.Empty;
}
}
} public class TeamIDResponse
{
public Guid TeamID { get; set; }
}
}

这个例子中,我们使用 .Result 属性在等待异步方法响应期间强行阻塞了线程

在生产级质量的代码里,很可能对此进行重构,确保在服务边界之内整个调用链都传递异步结果

运行位置报送服务

RabbitMQ 已经启动运行,默认的配置也指向了本地的 RabbitMQ 实例

此时可以使用以下方式启动位置报送服务

(确保位于 src/StatlerWaldorfCorp.LocationReporter 子目录中)

$ dotnet restore
$ dotnet build
$ dotnet run --server.urls=http://0.0.0.0:9090

服务运行后,只要向服务提交请求,就可以体验其功能了

$ curl -X POST -d \
'{"reportID":"...", \
"origin":"...", "latitude":10, "longtitude":20, \
"memberID":"..."}' \
http://...le2 \
/locationreports

提交完成后,应该能从服务获得一个 HTTP 201 响应

事件处理器

它的职责是消费来自流的事件,并执行合适的操作

为确保代码整洁、可测试,我们把事件处理的职责划分为如下部分:

  • 订阅队列并从事件流中获取新的消息
  • 将消息写入事件存储
  • 处理事件流(检测附近的队友)
  • 作为流的处理结果,生成新的消息并发送到队列
  • 作为流的处理结果,向事实服务的服务器 / 缓存提交状态变更情况

GitHub 链接:https://github.com/microservices-aspnetcore/es-eventprocessor

检测附近队友的基于 GPS 工具类的检测器

using System.Collections.Generic;
using StatlerWaldorfCorp.EventProcessor.Location;
using System.Linq;
using System; namespace StatlerWaldorfCorp.EventProcessor.Events
{
public class ProximityDetector
{
/*
* This method assumes that the memberLocations collection only
* applies to members applicable for proximity detection. In other words,
* non-team-mates must be filtered out before using this method.
* distance threshold is in Kilometers.
*/
public ICollection<ProximityDetectedEvent> DetectProximityEvents(
MemberLocationRecordedEvent memberLocationEvent,
ICollection<MemberLocation> memberLocations,
double distanceThreshold)
{
GpsUtility gpsUtility = new GpsUtility();
GpsCoordinate sourceCoordinate = new GpsCoordinate() {
Latitude = memberLocationEvent.Latitude,
Longitude = memberLocationEvent.Longitude
}; return memberLocations.Where(
ml => ml.MemberID != memberLocationEvent.MemberID &&
gpsUtility.DistanceBetweenPoints(sourceCoordinate, ml.Location) < distanceThreshold)
.Select( ml => {
return new ProximityDetectedEvent() {
SourceMemberID = memberLocationEvent.MemberID,
TargetMemberID = ml.MemberID,
TeamID = memberLocationEvent.TeamID,
DetectionTime = DateTime.UtcNow.Ticks,
SourceMemberLocation = sourceCoordinate,
TargetMemberLocation = ml.Location,
MemberDistance = gpsUtility.DistanceBetweenPoints(sourceCoordinate, ml.Location)
};
}).ToList();
}
}
}

接着我们就可以用这个方法的结果来产生对应的额外效果,例如可能需要发出一个 ProximityDetectorEvent 事件,并将事件写入事件存储

作为主体的事件处理器代码

using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using StatlerWaldorfCorp.EventProcessor.Location;
using StatlerWaldorfCorp.EventProcessor.Queues; namespace StatlerWaldorfCorp.EventProcessor.Events
{
public class MemberLocationEventProcessor : IEventProcessor
{
private ILogger logger;
private IEventSubscriber subscriber; private IEventEmitter eventEmitter; private ProximityDetector proximityDetector; private ILocationCache locationCache; public MemberLocationEventProcessor(
ILogger<MemberLocationEventProcessor> logger,
IEventSubscriber eventSubscriber,
IEventEmitter eventEmitter,
ILocationCache locationCache
)
{
this.logger = logger;
this.subscriber = eventSubscriber;
this.eventEmitter = eventEmitter;
this.proximityDetector = new ProximityDetector();
this.locationCache = locationCache; this.subscriber.MemberLocationRecordedEventReceived += (mlre) => { var memberLocations = locationCache.GetMemberLocations(mlre.TeamID);
ICollection<ProximityDetectedEvent> proximityEvents =
proximityDetector.DetectProximityEvents(mlre, memberLocations, 30.0f);
foreach (var proximityEvent in proximityEvents) {
eventEmitter.EmitProximityDetectedEvent(proximityEvent);
} locationCache.Put(mlre.TeamID, new MemberLocation { MemberID = mlre.MemberID, Location = new GpsCoordinate {
Latitude = mlre.Latitude, Longitude = mlre.Longitude
} });
};
} public void Start()
{
this.subscriber.Subscribe();
} public void Stop()
{
this.subscriber.Unsubscribe();
}
}
}

事件处理服务唯一的额外职责是需要将收到的每个事件都写入事件存储

这样做到原因有很多,包括向其他服务提供可供搜索的历史记录

如果缓存崩溃、数据丢失、事件存储也可用于重建事实缓存

请记住,缓存在架构里仅提供便利性,我们不应该在缓存中存储任何无法从其他位置重建的数据

我们要给服务里每一个团队创建一个 Redis 哈希(hash)

在哈希中,把团队成员的位置经序列化得到的 JSON 正文存储为字段(团队成员的 ID 用作键)

这样就能轻松地并发更新多个团队成员地位置而不会覆盖数据,同时也很容易查询给定的任意团队的位置列表,因为团队就是一个个哈希

事实服务

事实服务负责维护每个团队成员的位置,不过这些位置只代表最近从一些应用那里收到的位置

关于事实服务的这类服务,有两条重要的提醒需要记住:

  • 事实服务并不是事件存储
  • 事实服务是不可依赖服务
位置接近监控器

位置接近监控器的代码包括

  • 基本的微服务结构
  • 一个队列消费端,订阅 ProximityDetectedEvent 事件到达的消息
  • 调用一些第三方或云上的服务来发送推动通知

运行示例项目

下面列出运行本章示例的依赖项:

  • RabbitMQ 服务器
  • Redis 服务器

所有依赖项都启动运行后,可从 GitHub 拉取 es-locationreporter 和 es-eventprocessor 两个服务的代码

此外需要一份 teamservice 服务

请确保获取的是 master 分支,因为在测试期间只需要用到内存存储

要启动团队服务,在命令行中转到 src/StatlerWaldorfCorp.TeamService 目录并运行以下命令

$ dotnet run --server.urls=http://0.0.0.:5001

要启动位置报送服务,在命令行中转到 src/StatlerWaldorfCorp.LocationReporter 目录下并运行以下命令

$ dotnet run --server.urls=http://0.0.0:5002

启动事件处理器(从 src/StatlerWaldorfCorp.EventProcessor 目录运行)

$ dotnet run --server.urls=http://0.0.0.:5003

可用下列步骤端到端地检验整个事件溯源/CQRS系统:

  • (1)向 http://localhost:5001/teams 发送一个 POST 请求,创建一个新团队
  • (2)向 http://localhost:5001/teams//members 发送一个 POST 请求,往团队中添加一个成员
  • (3)向 http://localhost:5002/api/members//locationreports 发送一个 POST 请求,报送团队成员位置
  • (4)观察由报送的位置转换而成、被放到对应队列中的 MemberLocationReportedEvent 事件
  • (5)再重复几次第 3 步,添加一些相距较远的位置,确保不会触发并被检测到位置接近事件
  • (6)重复第 2 步,往第一名测试成员所在团队添加一名新成员
  • (7)为第二名成员再次重复第 3 步,添加一个于第一名成员最近的位置相距几公里以内的位置
  • (8)现在应该能够在 proximitydetected 队列中看到一条新消息
  • (9)可用直接查询 Redis 缓存,也可以利用事实服务来查看各团队成员最新的位置状态

手动操作几次后,大多数团队会花些时间把这一过程自动化

借助 docker compose 之类的工具,或者创建 Kubernetes 部署,或者其他容器编排环境,可自动将所有服务部署到集成测试环境

接着用脚本发送 REST 请求

待测试运行完成后,断言出现了正确的接近检测的次数,值也是正确的

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://www.cnblogs.com/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

如有任何疑问,请与我联系 (MingsonZheng@outlook.com) 。

《ASP.NET Core 微服务实战》-- 读书笔记(第6章)的更多相关文章

  1. JavaScript DOM编程艺术读书笔记(三)

    第七章 动态创建标记 在web浏览器中往文档添加标记,先回顾下过去使用的技术: <body> <script type="text/javascript"> ...

  2. JavaScript DOM编程艺术读书笔记(二)

    第五章 最佳实践 平稳退化(graceful degradation):如果正确使用了JavaScript脚本,可以让访问者在他们的浏览器不支持JavaScript的情况下仍能顺利地浏览你网站.虽然某 ...

  3. JavaScript DOM编程艺术读书笔记(一)

    第一章,第二章 DOM:是一套对文档的内容进行抽象和概念化的方法. W3C中的定义:一个与系统平台和编程语言无关的接口,程序和脚本可以通过这个接口动态的访问和修改文档的内容,结构和样式. DHTML( ...

  4. JavaScript DOM编程艺术 - 读书笔记1-3章

    1.JavaScript语法 准备工作 一个普通的文本编辑器,一个Web浏览器. JavaScript代码必须通过Html文档才能执行,第一种方式是将JavaScript代码放到文档<head& ...

  5. JavaScript DOM编程艺术 读书笔记

    2. JavaScript语法 2.1 注释      HTML允许使用"<!--"注释跨越多个行,但JavaScript要求这种注释的每行都必须在开头加上"< ...

  6. JavaScript DOM编程艺术读书笔记(四)

    第十章 实现动画效果 var repeat = "moveElement('"+elementID+"',"+final_x+","+fin ...

  7. JavaScript DOM编程艺术-学习笔记(第二章)

    1.好习惯从末尾加分号:开始 2.js区分大小写 3.程序界万能的命名法则:①不以,数字开头的数字.字母.下划线.美元符号 ②提倡以下划线命名法来命名变量,以驼峰命名法来命名函数.但是到了公司往往会身 ...

  8. 《javascript dom编程艺术》笔记(一)——优雅降级、向后兼容、多个函数绑定onload函数

    刚刚开始自学前端,如果不对请指正:欢迎各位技术大牛指点. 开始学习<javascript dom编程艺术>,整理一下学习到的知识.今天刚刚看到第六章,记下get到的几个知识点. 优雅降级 ...

  9. JavaScript DOM编程艺术学习笔记(一)

    嗯,经过了一周的时间,今天终于将<JavaScript DOM编程艺术(第2版)>这本书看完了,感觉受益匪浅,我和作者及出版社等等都不认识,无意为他们做广告,不过本书确实值得一看,也值得推 ...

  10. JavaScript DOM编程艺术-学习笔记

    发现基础不是很好,补习一下.37买了2本书(dom编程和高级程序设计). 以前读书总是自己勾勾画画,有点没意思.现在写下来,说不定会成为传世经典.哈哈...........随便扯扯淡. 第一天(201 ...

随机推荐

  1. shell脚本(8)-流程控制if

    一.单if语法 1.语法格式: if [ condition ] #condition值为 then commands fi 2.举例: [root@localhost test20210725]# ...

  2. Mysql 查询优化及索引优化总结

    本文为博主原创,转载请注明出处: 一.Mysql 索引的优缺点: 优点:加快数据的检索速度,这是应用索引的主要用途: 使用唯一索引可以保证数据的唯一性 缺点: 索引也需要占用物理空间,并不是索引越多越 ...

  3. .NET静态代码织入——肉夹馍(Rougamo)发布2.2

    肉夹馍(https://github.com/inversionhourglass/Rougamo)通过静态代码织入方式实现AOP的组件,其主要特点是在编译时完成AOP代码织入,相比动态代理可以减少应 ...

  4. 异步httpClient(Async HttpClient)

    一.简介 二.mvn依赖 三.客户端 3.1 官网实例 3.2. 根据官方文档的介绍,简单封装了一个异步HttpClient工具类 3.3 基本原理 四.参考文档 一.简介 HttpClient提供了 ...

  5. Cortex-M3 MCU的技术特点

    1.Cortex-M3 MCU的技术特点 MCU简单来说就是一个可编程的中央处理器(CPU)加上一些必要的外设.不管是中央处理器还是整个MCU都是复杂的时序数字电路,根据程序或者指令来完成特定的任务. ...

  6. 【canvas】 绘制七巧板

    效果图: 代码 : <!DOCTYPE html> <html lang="en"> <head> <meta charset=" ...

  7. [转帖]tgz 安装clickhouse

    一.什么是clickhouse ClickHouse是开源的列式存储数据库(DBMS),主要用于在线处理查询(OLAP),能够使用SQL查询实时生成数据分析报告. 下面介绍下安装clickhouse. ...

  8. [转帖]harbor镜像仓库清理操作

    https://www.cnblogs.com/FengGeBlog/p/15517706.html 两年前清理过一次harbor镜像,而现在又要面临清镜像的操作了,笔者目前所在的公司镜像是存放在ce ...

  9. Whisper对于中文语音识别与转写中文文本优化的实践(Python3.10)

    阿里的FunAsr对Whisper中文领域的转写能力造成了一定的挑战,但实际上,Whisper的使用者完全可以针对中文的语音做一些优化的措施,换句话说,Whisper的"默认"形态 ...

  10. Docker与虚拟化技术浅析第一弹之docker与Kubernetes

    1 前言 Docker是一个开源的引擎,可以轻松地为任何应用创建一个轻量级的. 可移植的.自给自足的容器.开发者在笔记本电脑上编译测试通过的容器可以批量地在生产环境中部署,包括VMs (虚拟机).ba ...