.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):

  1. 直连交换机(Direct Exchange):直连交换机根据消息的路由键(Routing Key)将消息发送到与之完全匹配的队列。如果消息的路由键与绑定到交换机上的队列的路由键完全匹配,那么消息将被路由到该队列。

  2. 主题交换机(Topic Exchange):主题交换机根据消息的路由键和主题规则(通配符)将消息发送到一个或多个队列。主题规则可以使用通配符符号 * 和 # 进行模糊匹配,其中 * 表示匹配一个单词,# 表示匹配零个或多个单词。

  3. 扇形交换机(Fanout Exchange):扇形交换机将消息广播到所有绑定到该交换机上的队列。无论消息的路由键是什么,扇形交换机都会将消息发送到与之绑定的所有队列。

  4. 头交换机(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"
  }

  

 

 

 

posted @ 2023-02-15 16:27  Joni是只狗  阅读(1957)  评论(2)    收藏  举报