.net core 微服务之 CAP事件总线
概念
什么是事件
事件就是指事物状态的变化,每一次事物变化的结果都称作为事件

什么是事件总线
就是用来管理所有的事件的一种机制就称作为事件总线
包括事件发布,事件存储,事件订阅,事件处理的统称
作用:
事件总线是一种机制,它允许不同的组件彼此通信而不彼此了解。 组件可以将事件发送到Eventbus,而无需知道是谁来接听或有多少其他人来接听。 组件也可以侦听Eventbus上的事件,而无需知道谁发送了事件。 这样,组件可以相互通信而无需相互依赖。 同样,很容易替换一个组件。 只要新组件了解正在发送和接收的事件,其他组件就永远不会知道.

为什么要使用事件总线
将微服务系统各组件之间进行解耦。使用业务的发展来说,比如说微服务架构中服务之间通信不通过api,而直接通过消息队列事件总线的方式
事件总线框架---CAP
事件 : 就是一些状态信息
发布者:发布事件的角色 cap
订阅者:订阅消费事件的角色 cap
消息传输器:传输事件
消息存储器:存储事件
CAP存储事件消息队列类型
Azure
rabbitmq
kafaka
In Memory Queue
CAP存储事件持久化类型
SQL Server
MySQL
PostgreSQL
MongoDB
InMemoryStorage
环境搭建
RabbitMQ环境安装
Erlang下载地址:https://www.erlang.org/downloads
RabbitMQ下载地址:https://www.rabbitmq.com/download.html
安装RabbitMQ要先安装Erlang,是因为RabbitMQ是Erlang开发的,先安装Erlang环境。
启动命令:
1、在安装目录下添加可视化插件 rabbitmq-plugins enable rabbitmq_management 2、在安装目录下启动 rabbitmq-server 3、查看rabbitmq状态 rabbitmqctl status
安装好了就可以直接启动: http://127.0.0.1:15672 账号密码默认都是 guest
CAP环境
CAP官网地址:https://cap.dotnetcore.xyz/user-guide/zh/monitoring/dashboard/
事件总线示例
简单使用RabbitMQ(有很多种消息队列类型,这里选用的是RabbitMQ作为传输事件)
1. 添加Nuget包
DotNetCore.CAP
DotNetCore.CAP.MySql
DotNetCore.CAP.RabbitMQ
2. 注入服务
services.AddCap(x => { x.UseMySql("server=localhost;port=3306;user=root;password=xxx;database=fcbcap;SslMode=none;"); x.UseRabbitMQ(rb => { rb.HostName = "localhost"; rb.UserName = "guest"; rb.Password = "guest"; rb.Port = 5672; //rb.VirtualHost = "/"; });
x.FailedRetryInterval = 60; //重试间隔时间(秒),默认60秒
x.FailedRetryCount = 50; //重试次数,默认50次;注:这里会先重试3次,四分钟之后才会按照重试间隔去执行剩下重试
x.UseDashboard(dashoptions =>
{
dashoptions.PathMatch = "/cap"; //面板地址
});
});
3. 依赖注入ICapPublisher,添加测试代码
private readonly ICapPublisher capPublisher; public UserController(ICapPublisher capPublisher) { this.capPublisher = capPublisher; } [HttpGet("AddUser")] public async Task<IActionResult> AddUser() { capPublisher.Publish("addcity", new City() { CityName = "广安市" }); return Ok("添加成功"); } [CapSubscribe("addcity")] public async Task<IActionResult> AddCity(City city) { Console.WriteLine($"添加内容:{city.CityName}"); return Ok(); }
运行结果,可以看到生成了数据库记录


发布者和订阅者可以在不同的服务,只要连接的消息队列地址是一致的就行
消息队列通配符
RabbitMQ 中有四种主要类型的交换机(Exchange):
-
直连交换机(Direct Exchange):直连交换机根据消息的路由键(Routing Key)将消息发送到与之完全匹配的队列。如果消息的路由键与绑定到交换机上的队列的路由键完全匹配,那么消息将被路由到该队列。
-
主题交换机(Topic Exchange):主题交换机根据消息的路由键和主题规则(通配符)将消息发送到一个或多个队列。主题规则可以使用通配符符号
*和#进行模糊匹配,其中*表示匹配一个单词,#表示匹配零个或多个单词。 -
扇形交换机(Fanout Exchange):扇形交换机将消息广播到所有绑定到该交换机上的队列。无论消息的路由键是什么,扇形交换机都会将消息发送到与之绑定的所有队列。
-
头交换机(Headers Exchange):头交换机根据消息的头部属性(Headers)进行匹配,并将消息发送到与之匹配的队列。头交换机不使用消息的路由键进行匹配,而是根据消息的头部属性进行匹配。
在Cap中默认为topic类型的交换机
# : 匹配0个或一个或多个词
* : 只能匹配一个词
例如:addcity.#既可以匹配addcity.a.b,也可以匹配addcity.a
而 addcity.*只能匹配item.a
如果发布 addcity.a ,订阅者会优先匹配 addcity.a ,其次是addcity.* , 最后是addcity.# 。


升级用法
死信队列
死信队列(DLX,Dead-Letter-Exchange),利用DLX,当消息在一个队列中变成无法被消费的消息(dead message)之后,它能被重新publish到另一个Exchange,这个Exchange就是DLX。
消息变成死信的几种情况:
1、 消息被拒绝(
channel.basicReject/channel.basicNack)并且request=false;
2、 消息在队列的存活时间超过设置的生存时间(TTL)时间;
3、 队列达到最大长度(队列满了,无法再添加数据到队列中)。
DLX也是一个正常的Exchange,和一般的Exchange没有区别,它能在任何的队列上被指定,实际上就是设置某个队列的属性。
Cap没有看到设置死信队列,我就在消息重试次数用完后仍然失败,就加入了一个死信队列,用于记录消息的内容,方便后续处理维护,用于模拟死信队列
1. 设置program.cs,主要是处理FailedThresholdCallback
builder.Services.AddTransient<CapSubscribeService>();
CapConfig config = Configuration.GetSection("CapConfig").Get<CapConfig>();
builder.Services.AddCap(x =>
{
x.UseMySql(config.CapConnectString);
x.UseRabbitMQ(rb =>
{
rb.HostName = config.HostName;
rb.UserName = config.UserName;
rb.Password = config.Password;
rb.Port = config.Port ;
//rb.VirtualHost = "/";
});
x.FailedRetryInterval = 60; //重试间隔时间(秒),默认60秒
x.FailedRetryCount = 1; //重试次数,默认50次
x.FailedThresholdCallback = (failedinfo) => //如果重试完还是失败会进入这里,这里就处理进死信队列里面,后期可以手动处理
{
var _capPublisher = failedinfo.ServiceProvider.GetService<ICapPublisher>();
var header = new Dictionary<string, string>()
{
["header.error.msgid"] =failedinfo.Message.Headers["cap-msg-id"],
["header.error.msgname"] = failedinfo.Message.Headers["cap-msg-name"]
};
//发布消息失败记录日志
if (failedinfo.MessageType == MessageType.Publish)
{
_capPublisher.Publish("publish-dead-letter-queue", failedinfo.Message.Value, header);
}
if (failedinfo.MessageType == MessageType.Subscribe)
{
_capPublisher.Publish("subscribe-dead-letter-queue", failedinfo.Message.Value, header);
}
};
x.UseDashboard(dashoptions =>
{
dashoptions.PathMatch = "/cap";
});
});
2. 添加发布代码
[HttpGet("TestPublish")]
public async Task<IActionResult> TestPublish()
{
Console.WriteLine($"已经发布消息");
capPublisher.Publish("testsubscribe", "hello");
return Ok();
}
3. 添加订阅代码。这里添加了两个死信队列,分别为发布和订阅的死信队列,可以分别记录失败类型、消息id、失败的消息内容、失败的消息方法等等,方便后期手动处理。
public class CapSubscribeService : ICapSubscribe
{
private readonly ICapPublisher _capPublisher;
public CapSubscribeService(ICapPublisher capPublisher)
{
_capPublisher = capPublisher;
}
[CapSubscribe("testsubscribe")]
private void TestSubscribe(string body, [FromCap] CapHeader header)
{
Console.WriteLine($"已经订阅了消息:{body}");
throw new Exception("手动抛出异常");
}
/// <summary>
/// 发布死信队列监控
/// </summary>
/// <param name="body"></param>
[CapSubscribe("publish-dead-letter-queue")]
private void PublishDeadQueue(string body, [FromCap] CapHeader header)
{
Console.WriteLine("发布异常");
Console.WriteLine($"进入了发布死信队列,消息内容:{body}");
Console.WriteLine($"异常的消息id:{header["header.error.msgid"]}");
Console.WriteLine($"异常的消息方法名:{header["header.error.msgname"]}");
Console.WriteLine($"当前消费时间:{header["cap-senttime"]}");
//写入数据库和日志
}
/// <summary>
/// 订阅死信队列监控
/// </summary>
/// <param name="body"></param>
[CapSubscribe("subscribe-dead-letter-queue")]
private void SubscribeDeadQueue(string body, [FromCap] CapHeader header)
{
Console.WriteLine("订阅异常" );
Console.WriteLine($"进入了订阅死信队列,消息内容:{body}");
Console.WriteLine($"异常的消息id:{header["header.error.msgid"]}");
Console.WriteLine($"异常的消息方法名:{header["header.error.msgname"]}");
Console.WriteLine($"当前消费时间:{header["cap-senttime"]}");
//写入数据库和日志
}
}
幂等性
对于一个资源,不管你请求一次还是请求多次,对该资源本身造成的影响应该是相同的,不能因为重复相同的请求而对该资源重复造成影响。
虽然rabbitmq处理完成功的消息后会删除,但是可能有其他原因导致重复消费。或者有这样的情景:订单业务中,当消费方法处理订单新增的业务已经成功了,但是写入日志的地方报错了,导致触发了重试机制一直进行请求消费。
对于普通业务,可以记录消息id或者自定义的唯一键去处理,每次处理都查询这个消息id或者自定义的唯一键是否存在,再去处理。
[HttpGet("TestPublish2")]
public async Task<IActionResult> TestPublish2()
{
var header = new Dictionary<string, string>()
{
["my.header.id"] = Guid.NewGuid().ToString()
};
capPublisher.Publish("testsubscribe2", "hello", header);
return Ok();
}
[CapSubscribe("testsubscribe2")]
private void TestSubscribe2(string body,[FromCap] CapHeader header)
{
Console.WriteLine($"已经订阅了消息:{body}");
Console.WriteLine($"当前消息id{header["cap-msg-id"]}");
Console.WriteLine($"当前消费时间:{header["cap-senttime"]}");
Console.WriteLine("header 中自定义传送的唯一键:" + header["my.header.id"]);
}
如果是用了集群或者分布式服务来说,或者对幂等性要求比较高,这里可能要处理分布式锁,利用redis执行setnx命令 来实现
延时发布
在指定一段时间之后进行订阅,使用场景比如在下单的时候,超过30分钟会自动取消订单,就可以用延时发布在30分钟后检测订单是否已经完成了支付,如果未完成就取消订单
[HttpGet("TestPublish4")]
public async Task<IActionResult> TestPublish4()
{
Console.WriteLine($"已经发布延时消息,将在20秒之后消费,当前时间为{DateTime.Now}");
capPublisher.PublishDelay(TimeSpan.FromSeconds(20), "testdelaysubscribe", "hello");
return Ok();
}
补偿事务
也就是回调,当消息被订阅后会执行指定的补偿事务,方便执行后续的事务
[HttpGet("TestPublish3")]
public async Task<IActionResult> TestPublish3()
{
Console.WriteLine($"已经发布消息");
capPublisher.Publish("testsubscribe3", "hello",callbackName: "callbacksubscribe3");
return Ok();
}
[CapSubscribe("testsubscribe3")]
private object TestSubscribe3(string body, [FromCap] CapHeader header)
{
Console.WriteLine($"当前消息id{header["cap-msg-id"]}");
Console.WriteLine($"当前消费时间:{header["cap-senttime"]}");
return new { city = "四川" , IsSuccess = true };
}
/// <summary>
/// 补偿事务,理解为回调
/// </summary>
/// <param name="param"></param>
/// <param name="header"></param>
[CapSubscribe("callbacksubscribe3")]
private void CallbackSubscribe3(JsonElement param, [FromCap] CapHeader header)
{
var city = param.GetProperty("city").GetString();
var IsSuccess = param.GetProperty("IsSuccess").GetBoolean();
Console.WriteLine($"上一级订阅的消息id{header["cap-corr-id"]}");
Console.WriteLine($"当前补偿事务消息id{header["cap-msg-id"]}");
Console.WriteLine($"当前消费时间:{header["cap-senttime"]}");
Console.WriteLine($"回调城市: {city}");
Console.WriteLine($"是否成功: {IsSuccess}");
}
Cap集成 Kafka
kafka 安装
1. 安装ZooKeeper
1.1 要使用 Kafka,通常需要安装和配置 ZooKeeper。ZooKeeper 是 Kafka 的依赖组件,用于协调和管理 Kafka 集群的状态信息。
在 Kafka 中,ZooKeeper 用于存储元数据、主题和分区的信息,并协调 Kafka 代理之间的通信。Kafka 代理将自己的状态信息注册到 ZooKeeper,并通过与 ZooKeeper 保持连接来获取集群和主题的元数据。
官网下载地址: 找到适合的版本进行下载
https://zookeeper.apache.org/releases.html#download https://downloads.apache.org/zookeeper/
如果很卡,这里有一个国内的下载地址
https://mirrors.bfsu.edu.cn/apache/zookeeper/
将安装包下载考拷贝到到Linux服务器中。如果是想通过连接下载,直接用命令
wget <ZooKeeper下载链接>
1.2 解压和配置 ZooKeeper
tar -xzf <ZooKeeper安装包文件名>
进入解压后的 ZooKeeper 目录
cd <ZooKeeper解压后的目录>
创建一个用于存储数据的目录
mkdir data
在 ZooKeeper 目录中复制默认配置文件,并进行必要的编辑,根据需要修改 ZooKeeper 的端口、数据目录等设置。将数据目录改成创建的data地址 dataDir=<data目录地址>
cp conf/zoo_sample.cfg conf/zoo.cfg vim conf/zoo.cfg
1.3 启动 ZooKeeper:
启动 ZooKeeper 服务器
./bin/zkServer.sh start
2. 安装kafka
2.1 官网下载地址: 找到适合的版本进行下载。
https://zookeeper.apache.org/releases.html#download
官网版本中Source download版是包含源码,除了正常使用功能,还可以进行修改的;Binary downloads 是编译包,只能用功能,正常我们用这个版本就行了,里面还有Scala2.12 和 Scala2.13 两个版本,Scala 是一种运行在 Java 虚拟机上的编程语言,而 Kafka 是使用 Scala 编写的,两者区别
Scala 2.12: - 这些版本的 Kafka 是使用 Scala 2.12.x 编写的。 - Scala 2.12 是 Scala 的一个主要版本,它引入了一些新的特性和改进。 - 如果你的应用程序或环境使用 Scala 2.12.x,那么你应该选择对应的 Scala 2.12 版本的 Kafka。 Scala 2.13: - 这些版本的 Kafka 是使用 Scala 2.13.x 编写的。 - Scala 2.13 是 Scala 的另一个主要版本,它也带来了一些新的特性和改进。 - 如果你的应用程序或环境使用 Scala 2.13.x,那么你应该选择对应的 Scala 2.13 版本的 Kafka。
如果官网地址很卡,这里有一个国内的下载地址,找到自己对应的版本,注意:例如 kafka_2.12-3.6.0.tgz ,其中2.12是Scala版本,后面的3.6.0才是kafka的版本号
https://downloads.apache.org/kafka/
2.2 解压和配置 Kafka:
tar -xzf <Kafka安装包文件名>
进入解压后的 Kafka 目录
cd <Kafka解压后的目录>
进行必要的配置编辑:
vim config/server.properties
在配置文件中,你可以根据需要修改 Kafka 的监听端口、日志目录,分区,线程等设置。
#这个是配置集群用的,如果需要用到集群,就需要配置不同的id,比如这里用0,下一台kafka就用1 broker.id=0 #这个监听地址把ip改成kafka服务器ip地址,方便其他服务器能够访问 listeners=PLAINTEXT://192.168.230.130:9092 # 日志目录,这里这个目录地址很重要,如果后面需要停止kafka, 但是可能会停止失败,就是这里的目录被占用或者锁住了,就需要先删除这个目录后进行停止 log.dirs=/tmp/kafka-logs # ZK的连接地址 zookeeper.connect=192.168.230.130:2181
2.3 . 启动 Kafka 服务器:
bin/kafka-server-start.sh config/server.properties
注意这里使用启动之后看日志是否有异常,有很多坑,启动成功后看本地服务器能够连接,如果连接不上,看端口是否被其他进程占用了,先杀掉进程或者改监听的端口号再次启动
客户端下载kafka tool工具连接,检验是否已经启动成功了。下载地址
https://www.kafkatool.com/download.html
安装 成功后看Brokers是否有数据,如果有,说明kafka和ZK都没有问题

使用Kafka
相关概念:
Broker:消息中间件处理节点,一个Kafka节点就是一个broker,多个broker可以组成一个Kafka集群。
Topic:一类消息,Kafka集群能够同时负责多个topic的分发。也就是我们订阅和发布的消息名称。
Partition:topic物理上的分组,一个topic可以分为多个partition,每个partition是一个有序的队列。
Segment:partition物理上由多个segment组成
Offset:每个partition都由一系列有序的、不可变的消息组成,这些消息被连续的追加到partition中。partition中的每个消息都有一个连续的序列号叫做offset,用于partition唯一标识一条消息。
groupid(消费者组ID):是用于标识一组消费者的字符串。消费者组是一组具有相同groupid的消费者,它们共同消费一个或多个 Kafka 主题中的消息。
关于groupid的特点和使用方式:
唯一性:每个消费者组的groupid必须是唯一的。不同的消费者组可以同时消费同一个主题,每个消费者组都会独立地管理自己的偏移量。
消费者组协调器:Kafka 使用一个特定的消费者作为消费者组的协调器(coordinator)。协调器负责分配分区给消费者,并跟踪消费者的偏移量。协调器通过groupid来识别消费者组。
负载均衡:当消费者加入或离开消费者组时,协调器会重新分配分区给消费者,实现负载均衡。这样,每个消费者都可以处理一部分分区,并且整个消费者组能够并行地消费消息。
消费者偏移量:协调器会跟踪每个消费者在每个分区上的偏移量。这样,即使消费者组中的消费者发生故障或重新加入,它们也能够从之前的偏移量处继续消费消息。
如果不想用Cap,可以用kafka的集成.net 客户端包 Confluent.Kafka 。官方地址:https://github.com/confluentinc/confluent-kafka-dotnet
Cap使用就很简单,只需要在CapOption中注入使用就行了。
x.UseKafka(KafkaOptions =>
{
KafkaOptions.Servers = "kafka的ip+端口";
});
消息的发布和订阅都和上面RabbitMq一样,只是配置不一样最后整理一下kafka和rabbitmq集成在Cap中的配置
Program.cs 配置
builder.Services.AddTransient<CapSubscribeService>();
CapConfig config = Configuration.GetSection("CapConfig").Get<CapConfig>();
builder.Services.AddCap(x =>
{
x.UseMySql(config.CapConnectString);
if (config.MQType == "kafka") //根据配置切换使用rabbit 还是 kafka
{
x.UseKafka(KafkaOptions =>
{
KafkaOptions.Servers = config.KafkaServers;
});
}
else
{
x.UseRabbitMQ(rb =>
{
rb.HostName = config.RabbitHostName;
rb.UserName = config.RabbitUserName;
rb.Password = config.RabbitPassword;
rb.Port = config.RabbitPort;
//rb.VirtualHost = "/";
});
}
x.FailedRetryInterval = 60; //重试间隔时间(秒),默认60秒
x.FailedRetryCount = 50; //重试次数,默认50次
x.FailedThresholdCallback = (failedinfo) => //如果重试完还是失败会进入这里,这里就处理进死信队列里面,后期可以手动处理
{
var _capPublisher = failedinfo.ServiceProvider.GetService<ICapPublisher>();
var header = new Dictionary<string, string>()
{
["header.error.msgid"] =failedinfo.Message.Headers["cap-msg-id"],
["header.error.msgname"] = failedinfo.Message.Headers["cap-msg-name"]
};
//发布消息失败记录日志
if (failedinfo.MessageType == MessageType.Publish)
{
_capPublisher.Publish("publish-dead-letter-queue", failedinfo.Message.Value, header);
}
if (failedinfo.MessageType == MessageType.Subscribe)
{
_capPublisher.Publish("subscribe-dead-letter-queue", failedinfo.Message.Value, header);
}
};
x.UseDashboard(dashoptions =>
{
dashoptions.PathMatch = "/cap";
});
});
appsettings.json 配置
"CapConfig": {
"CapConnectString": "server=192.168.0.208;port=3306;user=root;password=N5_?MCaE$wDDe2PG;database=netcore3_0_mq;SslMode=none;",
"MQType": "kafka", //kafka rabbitmq
"RabbitHostName": "192.168.0.237",
"RabbitUserName": "admin",
"RabbitPassword": "admin",
"RabbitPort": "5672",
"KafkaServers": "192.168.230.130:9092"
}

浙公网安备 33010602011771号