概念
什麼是事件
事件就是指事物狀態的變化,每一次事物變化的結果都稱作爲事件
什麼是事件總線
就是用來管理所有的事件的一種機制就稱作爲事件總線
包括事件發佈,事件存儲,事件訂閱,事件處理的統稱
作用:
事件總線是一種機制,它允許不同的組件彼此通信而不彼此瞭解。 組件可以將事件發送到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" }