DDD实战(三)

目录

一、用 MediatR 实现领域事件

二、RabbitMQ的基本使用


一、用 MediatR 实现领域事件

        领域事件可以切断领域模型之间的强依赖关系,事件发布完成后,由事件的处理者决定如何响应事件,这样我们可以实现事件发布和事件处理之间的解耦。在NET 中实现领域事件的时候,我们可以使用 C#的事件语法,但是事件语法要求事件的处理者被显式地注册到事件的发布者对象中,耦合性很强,作者推荐使用 MediatR 实现领域事件。

        MediatR 是一个在NET 中实现进程内事件传递的开源库,它可以实现事件的发布和事件的处理之间的解耦。MediatR 中支持“一个发布者对应一个处理者”和“一个发布者对应多个处理者”两种模式,后者的应用更广泛,因此本小节将会讲解这种用法。

        第1步,创建一个ASP.NET Core 项目,然后通过 NuGet 安装MediatR.Extensions.Microsoft DependencyInjection。

        第2步,在项目的 Programcs 中调用AddMediatR 方法把与 MediatR 相关的服务注册到依赖注入容器中,AddMediatR 方法的参数中一般指定事件处理者所在的若干个程序集。注册MediatR 如下代码所示。

builder.Services.AddMediatR(Assembly.Load("领域事件 1"));

        第 3步,定义一个在事件的发布者和处理者之间进行数据传递的类 TestEvent,这个类需要实现 Iotification 接口,如下代码所示。

public record TestEvent(string UserName) : INotification;

        TestEvent 中的UserName 属性代表登录用户的用户名。事件一般都是从发布者传递到处理者的,很少有在事件的处理者处直接反向通知事件发布者的需求,因此实现 INotification 的TestEvent类的属性一般都是不可变的,我们用 record 语法来声明这个类。

        第 4步,事件的处理者要实现 NotificationHandler<TNotification>接口,其中的泛型参数TNotification 代表此事件处理者要处理的消息类型。所有 TNotification 类型的事件都会被事件处理者处理。我们编写两个事件处理者,如代码 9-24 所示,它们分别把收到的事件输出到控制台和写入文件。

public class TestEventHandler1 : INotificationHandler<TestEvent>
{
public Task Handle(TestEvent notification, CancellationToken cancellationToken)
{
Console.writeLine($"我收到了(notification.UserName)");
return Task.CompletedTask;
}
}
public class TestEventHandler2 : INotificationHandler<TestEvent>
{
public async Task Handle(TestEvent notification, CancellationToken cancellationToken)
{
await File.WriteAllTextAsync("d:/1.txt",$"来了{notification.UserName}");
}}

        第5步,在需要发布事件的类中注入 IMediator 类型的服务,然后我们用 Publish 方法来发布。注意不要错误地调用 Send 方法来发布事件,因为 Send 方法是用来发布一对一事件的,而Publish 方法是用来发布一对多事件的。我们需要在控制器的登录方法中发布事件,这里我们省略了实际的登录代码,如下代码中的第 9 行代码所示。

public class TestController : ControllerBase
{
private readonly IMediator mediator;
public TestController(IMediator mediator)
{
this.mediator = mediator;
}
[HttpPost]
public async Task<IActionResult> Login(LoginRequest req)
{
await mediator.Publish (new TestEvent(req.UserName));
return Ok("ok");
}}

        运行上面的程序,然后调用登录接口,我们就可以看到控制台和文件中都有代码输出的信这说明两个事件处理者都被执行了。

        如果我们使用 await 的方式来调用 Publish 方法,那么程序会等待所有的事件处理者的 Handle 方法执行完成后才继续向后执行,因此事件发布者和事件处理者的代码是运行在相同的调用堆栈中的,这样我们可以轻松地实现强一致性的事务。如果事件发布者不需要等待事件处理者的执行,那么我们可以不用 await 方法来调用 Publish 方法; 即使我们需要使用 await方法来调用 Publish 方法发布事件,如果某个事件处理者的代码执行太耗时,为了避免影响用户体验,我们也可以在事件处理者的 Handle 方法中异步执行事件的处理逻辑。如果我们选择不等待事件处理者,就要处理事务的最终一致性。

二、RabbitMQ的基本使用

        和领域事件不同,集成事件用于在微服务间进行事件的传递,因为这是服务器间的通信,所以必须借助于第三方服务器作为事件总线。我们一般使用消息中间件来作为事件总线,目前常用的消息中间件有 Redis、RabbitMQ、Kafka、ActiveMO 等,本项目使用 RabbitMQ。

        我们先来了解一下 RabbitMQ 中的几个基本概念。

        (1)信道(channel):信道是消息的生产者、消费者和服务器之间进行通信的虚拟连接为什么叫“虚拟连接”呢?因为 TCP 连接的建立是非常消耗资源的,所以 RabbitMo在 TCP连接的基础上构建了虚拟信道。我们尽量重复使用 TCP 连接,而信道是可以用完就关闭的。

        (2)队列(queue): 队列是用来进行消息收发的地方,生产者把消息放到队列中,消费者从队列中获取消息。

        (3)交换机 (exchange):交换机用于把消息路由到一个或者多个队列中。

        RabbitMO 有非常多的使用模式,读者可以查阅相关文档。这里介绍在集成事件中用到的
模式,即routing 模式。

        在这种模式中,生产者把消息发布到交换机中,消息会携带 routingKey 属性,交换机会根据routingKey 的值把消息发送到一个或者多个队列;消费者会从队列中获取消息;交换机和队列都位于RabbitMO 服务器内部。这种模式的优点在于,即使消费者不在线,消费者相关的消息也会被保存到队列中,当消费者上线之后,就可以获取离线期间错过的消息。我们知道,在软件系统中,消息的生产者和消费者都不可能 24 小时在线,这种模式可以保证消费者收到因为服务器重启等原因而错过的消息。

        RabbitMO 服务的安装比较简单,这里主要讲解如何在NET 中连接 RabbitMQ 进行消息收发。

        首先,我们分别创建发送消息的项目和接收消息的控制台项目,这两个项目都需要安装NuGet 包 RabbitMO.Client。

        接下来,我们如下代码所示进行消息发送。

var factory = new ConnectionFactory();
factory.HostName = "127.0.0.1";        //RabbitMQ 服务器地址
factory.DispatchConsumersAsync = true;
string exchangeName = "exchangel";     //交换机的名字
string eventName = "myEvent";          //routingKey 的值
using var conn = factory.CreateConnection();
while(true)
{
string msg = DateTime.Now.TimeOfDay.ToString(); //待发送消息
using (var channel = conn.CreateModel())        //创建虚似信道
{
var properties = channel.CreateBasicProperties();
properties.DeliveryMode = 2;
channel.ExchangeDeclare(exchange: exchangeName, type:"direct");//声明交换
byte[] body = Encoding.UTE8.GetBytes (msg);
channel.BasicPublish(exchange: exchangeName,routingKey: eventName,mandatory: true,basicProperties: properties,body: body); //发布消息
}
Console.WriteLine("发布了消息:” + msg);
Thread.Sleep(1000);
}

        第6行代码创建了一个客户端到 RabbitMQ 的TCP 连接,这个TCP 连接我们尽量重复使用;第9行代码把当前时间作为待发送的消息;第 10 行代码创建了虚拟信道,这个虚拟信道是可以用完就关闭的,需要注意的是,信道关闭之后,消息才会发出;第 14 行代码使用ExchangeDeclare 方法声明了一个指定名字的交换机,如果指定名字的交换机已经存在,则再重复创建,type 参数设置为 direct 表示这个交换机会根据消息 routingKey 的值进行相等性配,消息会发布到和它的routingKey 绑定的队列中去;因为 RabbitMo 中的消息都是按照bytel类型进行传递的,所以我们在第 15 行中把 string 类型的消息转换为 bytel]类型;为了便于演示这里使用一个无限循环来实现每隔 1s 发送一次消息。

        最后,我们来编写消息的消费端项目的代码,如下代码所示。

var factory = new ConnectionFactory();
factory.HostName = "127.0.0.1";
factory.DispatchConsumersAsync = true;
string exchangeName = "exchangel";
string eventName = "myEvent";
using var conn = factory.CreateConnection();
using var channel = conn.CreateModel();
string queueName = "queuel";
channel.ExchangeDeclare (exchange: exchangeName,type: "direct");
channel.QueueDeclare(queue: queueName,durable: true,
exclusive: false,autoDelete: false,arguments: nul1);
channel.QueueBind(queue: queueName,
exchange: exchangeName,routingKey: eventName);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.Received += Consumer Received;
channel.BasicConsume (queue: queueName, autoAck: false,consumer: consumer);
Console.ReadLine();
async Task Consumer_Received(object sender, BasicDeliverEventArgs args)
{
try
{
var bytes = args.Body.ToArray();
string msg = Encoding.UTF8.GetString(bytes);
Console.WriteLine(DateTime.Now “收到了消息”+msg);
channel.BasicAck(args.DeliveryTag, multiple: false);
await Task.Delay(800);
}
catch (Exception ex)
{
channel.BasicReject(args.DeliveryTag, true);
Console.writeLine("处理收到的消息出错"+ex);
}
}

        在第 9行代码中,我们声明了和消息发送端的名字一样的交换机,如果消息发送端已经声明了同名的交换机,这里的调用就会被忽略。但是第9行代码仍然是不可缺少的,因为有可能是消息的消费端先于消息的发送端启动。

        第 10 行代码中,我们声明了一个队列用于接收交换机转发过来的消息,若已经存在指定名字的队列,则忽略调用 QueueDeclare 方法。因为消息被取走之后,这条消息就被从队列中移除了,所以队列的命名非常重要。如果我们有 A、B 两个程序都想读取同一条消息,那么它们声明的队列不能同名,也就是我们要创建两个队列;如果两个程序声明的队列同名,也就是它们共享同一个队列,那么一条消息就只能按照“先到先得”的原则被其中一个程序收到。

        在第 12、13 行代码中,我们把队列绑定到交换机中,并且设定了 routingKey 参数。这样当交换机收到 routingKey 的值和设定的值相同的消息的时候,就会把消息转发到我们指定的列。二个交换机可以绑定多个队列,如果这些队列 routingKey 的值相同,那么当交换机收到一个同样 routingKey 值的消息的时候,它就会把这条消息同时转发给这些队列,也就是同样一条消息可以被多个消费者接收到。这在微服务环境中非常重要,因为一个微服务发出的集成事件可能有多个微服务都希望接收到,比如订单系统发布的“订单创建完成”事件就可能会被物流系统、支付系统、日志系统等多个微服务接收到。

        第 14~17 行代码中,我们创建了一收消息,当一条消息被接收到的时候,Received 事件就会被触发,我们在 Consumer_Received方法中处理收到的消息。

        BasicConsume 调用不是用于阻塞执行的,我们测试的时候创建的是控制台程序,为了避免程序执行完 BasicConsume 方法就退出,我们在第 17 行代码中等待用户按 Enter 键之后再退出程序。

        第 22~24 行代码中,我们把收到的消息转换为 string 类型,然后输出到控制台。为了能够显示消息的接收时间,我们把接收到消息的时间也输出。

        RabbitMo 中的消息支持“失败重发”,也就是消费者在接收到消息并处理的过程中,如果消息的处理过程出错导致消息没有被完整处理,队列会再次尝试把这条消息发送给消费者。消费者需要在消息处理成功后调用 BasicAck 通知队列“消息处理完成”,如果消息处理出错,则需要调用 BasicReject 通知队列“消息处理出错”。因此我们在第 25 行代码中调用了 BasicAck进行消息的确认,并且把消息的处理过程用 ty··catch 代码块包裹起来,当代码块中发生异的时候,BasicReject 会被调用以便安排消息重发。由于同样一条消息可能会被重复投递,因此我们一定要确保消息处理的代码是幂等的。

        AsyncEventingBasicConsumer 的 Received 是用于阻塞执行的,也就是当一条消息触发的Received 的回调方法执行完成后,才会触发下一条消息的 Received 事件。为了演示 Received事件阻塞执行的效果,我们在第 26 行代码中加入了一个延迟操作。

        完成上面的代码之后,我们分别运行消息发送端和消费端的程序。从运行结果可以看到两个程序能够得到预期的运行效果,即使消费端关闭,等消费端重新启动之后,也能收到离线的这段时间内错过的消息。

猜你喜欢

转载自blog.csdn.net/xxxcAxx/article/details/128486391
ddd