1. Introduction
1.绪论
Asynchronous messaging is a type of loosely-coupled distributed communication that is becoming increasingly popular for implementing event-driven architectures. Fortunately, the Spring Framework provides the Spring AMQP project allowing us to build AMQP-based messaging solutions.
异步消息传递是一种松散耦合的分布式通信,在实现event-driven architectures时越来越受欢迎。幸运的是,Spring框架提供了Spring AMQP项目,使我们能够构建基于AMQP的消息传递解决方案。
On the other hand, dealing with errors in such environments can be a non-trivial task. So in this tutorial, we’ll cover different strategies for handling errors.
另一方面,在这种环境下处理错误可能是一项非同小可的任务。因此在本教程中,我们将介绍处理错误的不同策略。
2. Environment Setup
2.环境设置
For this tutorial, we’ll use RabbitMQ which implements the AMQP standard. Also, Spring AMQP provides the spring-rabbit module which makes integration really easy.
在本教程中,我们将使用RabbitMQ,它实现了AMQP标准。而且,Spring AMQP提供了spring-rabbit模块,这使得集成变得非常简单。
Let’s run RabbitMQ as a standalone server. We’ll run it in a Docker container by executing the following command:
让我们将 RabbitMQ 作为一个独立的服务器运行。我们将通过执行以下命令在Docker 容器中运行它。
docker run -d -p 5672:5672 -p 15672:15672 --name my-rabbit rabbitmq:3-management
For detailed configuration and project dependencies setup, please refer to our Spring AMQP article.
关于详细的配置和项目依赖性设置,请参考我们的Spring AMQP文章。
3. Failure Scenario
3.失败情况
Usually, there are more types of errors that can occur in messaging-based systems compared to a monolith or single-packaged applications due to its distributed nature.
通常情况下,由于其分布式的性质,与单体或单一包装的应用程序相比,基于消息的系统中可能会出现更多类型的错误。
We can point out some of the types of exceptions:
我们可以指出一些例外情况的类型。
- Network- or I/O-related – general failures of network connections and I/O operations
- Protocol- or infrastructure-related – errors that usually represent misconfiguration of the messaging infrastructure
- Broker-related – failures that warn about improper configuration between clients and an AMQP broker. For instance, reaching defined limits or threshold, authentication or invalid policies configuration
- Application- and message-related – exceptions that usually indicate a violation of some business or application rules
Certainly, this list of failures is not exhaustive but contains the most common type of errors.
当然,这个失败的清单并不详尽,但包含了最常见的错误类型。
We should note that Spring AMQP handles connection-related and low-level issues out of the box, for example by applying retry or requeue policies. Additionally, most of the failures and faults are converted into an AmqpException or one of its subclasses.
我们应该注意到,Spring AMQP开箱即处理与连接相关的和低级别的问题,例如应用重试或重发策略。此外,大多数故障和失误都被转化为AmqpException或其子类之一。
In the next sections, we’ll mostly focus on application-specific and high-level errors and then cover global error handling strategies.
在接下来的章节中,我们将主要关注特定应用和高层错误,然后再介绍全局错误处理策略。
4. Project Setup
4.项目设置
Now, let’s define a simple queue and exchange configuration to start:
现在,让我们定义一个简单的队列和交换配置来开始。
public static final String QUEUE_MESSAGES = "baeldung-messages-queue";
public static final String EXCHANGE_MESSAGES = "baeldung-messages-exchange";
@Bean
Queue messagesQueue() {
return QueueBuilder.durable(QUEUE_MESSAGES)
.build();
}
@Bean
DirectExchange messagesExchange() {
return new DirectExchange(EXCHANGE_MESSAGES);
}
@Bean
Binding bindingMessages() {
return BindingBuilder.bind(messagesQueue()).to(messagesExchange()).with(QUEUE_MESSAGES);
}
Next, let’s create a simple producer:
接下来,让我们创建一个简单的生产商。
public void sendMessage() {
rabbitTemplate
.convertAndSend(SimpleDLQAmqpConfiguration.EXCHANGE_MESSAGES,
SimpleDLQAmqpConfiguration.QUEUE_MESSAGES, "Some message id:" + messageNumber++);
}
And finally, a consumer that throws an exception:
最后,一个抛出异常的消费者。
@RabbitListener(queues = SimpleDLQAmqpConfiguration.QUEUE_MESSAGES)
public void receiveMessage(Message message) throws BusinessException {
throw new BusinessException();
}
By default, all failed messages will be immediately requeued at the head of the target queue over and over again.
默认情况下,所有失败的消息将被立即重新排在目标队列的头部,一遍又一遍。
Let’s run our sample application by executing the next Maven command:
让我们通过执行下一条Maven命令来运行我们的示例应用程序。
mvn spring-boot:run -Dstart-class=com.baeldung.springamqp.errorhandling.ErrorHandlingApp
Now we should see the similar resulting output:
现在我们应该看到类似的结果输出。
WARN 22260 --- [ntContainer#0-1] s.a.r.l.ConditionalRejectingErrorHandler :
Execution of Rabbit message listener failed.
Caused by: com.baeldung.springamqp.errorhandling.errorhandler.BusinessException: null
Consequently, by default, we will see an infinite number of such messages in the output.
因此,在默认情况下,我们将在输出中看到无限多的此类信息。
To change this behavior we have two options:
要改变这种行为,我们有两个选择。
- Set the default-requeue-rejected option to false on the listener side – spring.rabbitmq.listener.simple.default-requeue-rejected=false
- Throw an AmqpRejectAndDontRequeueException – this might be useful for messages that won’t make sense in the future, so they can be discarded.
Now, let’s discover how to process failed messages in a more intelligent way.
现在,让我们发现如何以一种更智能的方式处理失败的信息。
5. Dead Letter Queue
5 死信队列
A Dead Letter Queue (DLQ) is a queue that holds undelivered or failed messages. A DLQ allows us to handle faulty or bad messages, monitor failure patterns and recover from exceptions in a system.
死信队列(DLQ)是一个保存未交付或失败的消息的队列。DLQ使我们能够处理有问题的或坏的消息,监测故障模式,并从系统的异常中恢复。
More importantly, this helps to prevent infinite loops in queues that are constantly processing bad messages and degrading system performance.
更重要的是,这有助于防止队列的无限循环,因为队列不断处理坏消息,降低系统性能。
Altogether, there are two main concepts: Dead Letter Exchange (DLX) and a Dead Letter Queue (DLQ) itself. In fact, DLX is a normal exchange that we can define as one of the common types: direct, topic or fanout.
总的来说,有两个主要概念。死信交换(DLX)和死信队列(DLQ)本身。事实上,DLX是一个正常的交换,我们可以定义为常见的类型之一。direct, topic 或fanout。
It’s very important to understand that a producer doesn’t know anything about queues. It’s only aware of exchanges and all produced messages are routed according to the exchange configuration and the message routing key.
了解以下一点非常重要:生产者不知道任何关于队列的事情。它只知道交换,所有产生的消息都是根据交换配置和消息路由键来路由的。
Now let’s see how to handle exceptions by applying the Dead Letter Queue approach.
现在让我们来看看如何通过应用死信队列的方法来处理异常情况。
5.1. Basic Configuration
5.1.基本配置
In order to configure a DLQ we need to specify additional arguments while defining our queue:
为了配置DLQ,我们需要在定义队列的时候指定额外的参数。
@Bean
Queue messagesQueue() {
return QueueBuilder.durable(QUEUE_MESSAGES)
.withArgument("x-dead-letter-exchange", "")
.withArgument("x-dead-letter-routing-key", QUEUE_MESSAGES_DLQ)
.build();
}
@Bean
Queue deadLetterQueue() {
return QueueBuilder.durable(QUEUE_MESSAGES_DLQ).build();
}
In the above example, we’ve used two additional arguments: x-dead-letter-exchange and x-dead-letter-routing-key. The empty string value for the x-dead-letter-exchange option tells the broker to use the default exchange.
在上面的例子中,我们使用了两个额外的参数。x-dead-letter-exchange和x-dead-letter-routing-key。x-dead-letter-exchange选项的空字符串值告诉经纪人使用默认交易所。
The second argument is as equally important as setting routing keys for simple messages. This option changes the initial routing key of the message for further routing by DLX.
第二个参数与设置简单报文的路由键同样重要。这个选项改变了信息的初始路由键,以便DLX进一步进行路由。
5.2. Failed Messages Routing
5.2.失败信息的路由
So, when a message fails to deliver, it’s routed to the Dead Letter Exchange. But as we’ve already noted, DLX is a normal exchange. Therefore, if the failed message routing key doesn’t match the exchange, it won’t be delivered to the DLQ.
因此,当一封邮件无法送达时,它就会被转到死信交换所。但正如我们已经指出的,DLX是一个正常的交易所。因此,如果失败的消息路由密钥与交易所不匹配,它将不会被传递到DLQ。
Exchange: (AMQP default)
Routing Key: baeldung-messages-queue.dlq
So, if we omit the x-dead-letter-routing-key argument in our example, the failed message will be stuck in an infinite retry loop.
因此,如果我们在例子中省略了x-dead-letter-routing-key参数,失败的消息将陷入无限的重试循环。
Additionally, the original meta information of the message is available in the x-death header:
此外,在x-death头中可以获得消息的原始元信息。
x-death:
count: 1
exchange: baeldung-messages-exchange
queue: baeldung-messages-queue
reason: rejected
routing-keys: baeldung-messages-queue
time: 1571232954
The information above is available in the RabbitMQ management console usually running locally on port 15672.
上述信息可在 RabbitMQ 管理控制台中获得,该控制台通常在本地 15672 端口运行。
Besides this configuration, if we are using Spring Cloud Stream we can even simplify the configuration process by leveraging configuration properties republishToDlq and autoBindDlq.
除此配置外,如果我们使用Spring Cloud Stream,我们甚至可以通过利用配置属性republishToDlq和autoBindDlq简化配置过程。
5.3. Dead Letter Exchange
5.3.死信交换
In the previous section, we’ve seen that the routing key is changed when a message is routed to the dead letter exchange. But this behavior is not always desirable. We can change it by configuring DLX by ourselves and defining it using the fanout type:
在上一节中,我们已经看到,当一个消息被路由到死信交换处时,路由密钥会被改变。但这种行为并不总是可取的。我们可以通过自己配置DLX并使用fanout类型定义来改变它。
public static final String DLX_EXCHANGE_MESSAGES = QUEUE_MESSAGES + ".dlx";
@Bean
Queue messagesQueue() {
return QueueBuilder.durable(QUEUE_MESSAGES)
.withArgument("x-dead-letter-exchange", DLX_EXCHANGE_MESSAGES)
.build();
}
@Bean
FanoutExchange deadLetterExchange() {
return new FanoutExchange(DLX_EXCHANGE_MESSAGES);
}
@Bean
Queue deadLetterQueue() {
return QueueBuilder.durable(QUEUE_MESSAGES_DLQ).build();
}
@Bean
Binding deadLetterBinding() {
return BindingBuilder.bind(deadLetterQueue()).to(deadLetterExchange());
}
This time we’ve defined a custom exchange of the fanout type, so messages will be sent to all bounded queues. Furthermore, we’ve set the value of the x-dead-letter-exchange argument to the name of our DLX. At the same time, we’ve removed the x-dead-letter-routing-key argument.
这次我们定义了一个fanout类型的自定义交换,所以消息将被发送到所有有边界的队列。此外,我们将x-dead-letter-exchange参数的值设置为我们的DLX的名称。同时,我们删除了x-dead-letter-routing-key参数。
Now if we run our example the failed message should be delivered to the DLQ, but without changing the initial routing key:
现在,如果我们运行我们的例子,失败的消息应该被传递到DLQ,但没有改变初始路由密钥。
Exchange: baeldung-messages-queue.dlx
Routing Key: baeldung-messages-queue
5.4. Processing Dead Letter Queue Messages
5.4.处理死信队列消息
Of course, the reason we moved them to the Dead Letter Queue is so they can be reprocessed at another time.
当然,我们之所以把它们移到死信队列中,是为了在另一个时间重新处理它们。
Let’s define a listener for the Dead Letter Queue:
让我们为死信队列定义一个监听器。
@RabbitListener(queues = QUEUE_MESSAGES_DLQ)
public void processFailedMessages(Message message) {
log.info("Received failed message: {}", message.toString());
}
If we run our code example now, we should see the log output:
如果我们现在运行我们的代码例子,我们应该看到日志输出。
WARN 11752 --- [ntContainer#0-1] s.a.r.l.ConditionalRejectingErrorHandler :
Execution of Rabbit message listener failed.
INFO 11752 --- [ntContainer#1-1] c.b.s.e.consumer.SimpleDLQAmqpContainer :
Received failed message:
We’ve got a failed message, but what should we do next? The answer depends on specific system requirements, the kind of the exception or type of the message.
我们收到了一条失败的消息,但我们接下来应该怎么做?答案取决于具体的系统要求、异常的种类或消息的类型。
For instance, we can just requeue the message to the original destination:
例如,我们可以直接向原目的地重新发送消息。
@RabbitListener(queues = QUEUE_MESSAGES_DLQ)
public void processFailedMessagesRequeue(Message failedMessage) {
log.info("Received failed message, requeueing: {}", failedMessage.toString());
rabbitTemplate.send(EXCHANGE_MESSAGES,
failedMessage.getMessageProperties().getReceivedRoutingKey(), failedMessage);
}
But such exception logic is not dissimilar from the default retry policy:
但这样的异常逻辑与默认重试策略并无不同。
INFO 23476 --- [ntContainer#0-1] c.b.s.e.c.RoutingDLQAmqpContainer :
Received message:
WARN 23476 --- [ntContainer#0-1] s.a.r.l.ConditionalRejectingErrorHandler :
Execution of Rabbit message listener failed.
INFO 23476 --- [ntContainer#1-1] c.b.s.e.c.RoutingDLQAmqpContainer :
Received failed message, requeueing:
A common strategy may need to retry processing a message for n times and then reject it. Let’s implement this strategy by leveraging message headers:
一个常见的策略可能需要重试处理一个消息n次,然后拒绝它。让我们通过利用消息头来实现这一策略。
public void processFailedMessagesRetryHeaders(Message failedMessage) {
Integer retriesCnt = (Integer) failedMessage.getMessageProperties()
.getHeaders().get(HEADER_X_RETRIES_COUNT);
if (retriesCnt == null) retriesCnt = 1;
if (retriesCnt > MAX_RETRIES_COUNT) {
log.info("Discarding message");
return;
}
log.info("Retrying message for the {} time", retriesCnt);
failedMessage.getMessageProperties()
.getHeaders().put(HEADER_X_RETRIES_COUNT, ++retriesCnt);
rabbitTemplate.send(EXCHANGE_MESSAGES,
failedMessage.getMessageProperties().getReceivedRoutingKey(), failedMessage);
}
At first, we are getting the value of the x-retries-count header, then we compare this value with the maximum allowed value. Subsequently, if the counter reaches the attempts limit number the message will be discarded:
首先,我们得到x-retries-count头的值,然后我们将这个值与允许的最大值进行比较。随后,如果计数器达到了尝试次数的限制,该信息将被丢弃。
WARN 1224 --- [ntContainer#0-1] s.a.r.l.ConditionalRejectingErrorHandler :
Execution of Rabbit message listener failed.
INFO 1224 --- [ntContainer#1-1] c.b.s.e.consumer.DLQCustomAmqpContainer :
Retrying message for the 1 time
WARN 1224 --- [ntContainer#0-1] s.a.r.l.ConditionalRejectingErrorHandler :
Execution of Rabbit message listener failed.
INFO 1224 --- [ntContainer#1-1] c.b.s.e.consumer.DLQCustomAmqpContainer :
Retrying message for the 2 time
WARN 1224 --- [ntContainer#0-1] s.a.r.l.ConditionalRejectingErrorHandler :
Execution of Rabbit message listener failed.
INFO 1224 --- [ntContainer#1-1] c.b.s.e.consumer.DLQCustomAmqpContainer :
Discarding message
We should add that we can also make use of the x-message-ttl header to set a time after that the message should be discarded. This might be helpful for preventing queues to grow infinitely.
我们应该补充说,我们也可以利用x-message-ttl头来设置一个时间,在这个时间之后,消息应该被丢弃。这对于防止队列的无限增长可能很有帮助。
5.5. Parking Lot Queue
5.5.停车场排队
On the other hand, consider a situation when we cannot just discard a message, it could be a transaction in the banking domain for example. Alternatively, sometimes a message may require manual processing or we simply need to record messages that failed more than n times.
另一方面,考虑到我们不能直接丢弃一条消息的情况,例如,它可能是银行领域的交易。另外,有时一条消息可能需要人工处理,或者我们只是需要记录失败超过n次的消息。
For situations like this, there is a concept of a Parking Lot Queue. We can forward all messages from the DLQ, that failed more than the allowed number of times, to the Parking Lot Queue for further processing.
对于这样的情况,有一个停车场队列的概念。我们可以将DLQ中失败次数超过允许次数的所有消息转发到停车场队列中进行进一步处理。
Let’s now implement this idea:
现在让我们来实现这个想法。
public static final String QUEUE_PARKING_LOT = QUEUE_MESSAGES + ".parking-lot";
public static final String EXCHANGE_PARKING_LOT = QUEUE_MESSAGES + "exchange.parking-lot";
@Bean
FanoutExchange parkingLotExchange() {
return new FanoutExchange(EXCHANGE_PARKING_LOT);
}
@Bean
Queue parkingLotQueue() {
return QueueBuilder.durable(QUEUE_PARKING_LOT).build();
}
@Bean
Binding parkingLotBinding() {
return BindingBuilder.bind(parkingLotQueue()).to(parkingLotExchange());
}
Secondly, let’s refactor the listener logic to send a message to the parking lot queue:
其次,让我们重构监听器的逻辑,向停车场队列发送一个消息。
@RabbitListener(queues = QUEUE_MESSAGES_DLQ)
public void processFailedMessagesRetryWithParkingLot(Message failedMessage) {
Integer retriesCnt = (Integer) failedMessage.getMessageProperties()
.getHeaders().get(HEADER_X_RETRIES_COUNT);
if (retriesCnt == null) retriesCnt = 1;
if (retriesCnt > MAX_RETRIES_COUNT) {
log.info("Sending message to the parking lot queue");
rabbitTemplate.send(EXCHANGE_PARKING_LOT,
failedMessage.getMessageProperties().getReceivedRoutingKey(), failedMessage);
return;
}
log.info("Retrying message for the {} time", retriesCnt);
failedMessage.getMessageProperties()
.getHeaders().put(HEADER_X_RETRIES_COUNT, ++retriesCnt);
rabbitTemplate.send(EXCHANGE_MESSAGES,
failedMessage.getMessageProperties().getReceivedRoutingKey(), failedMessage);
}
Eventually, we also need to process messages that arrive at the parking lot queue:
最终,我们还需要处理到达停车场队列的消息。
@RabbitListener(queues = QUEUE_PARKING_LOT)
public void processParkingLotQueue(Message failedMessage) {
log.info("Received message in parking lot queue");
// Save to DB or send a notification.
}
Now we can save the failed message to the database or perhaps send an email notification.
现在,我们可以将失败的信息保存到数据库中,或许可以发送电子邮件通知。
Let’s test this logic by running our application:
让我们通过运行我们的应用程序来测试这个逻辑。
WARN 14768 --- [ntContainer#0-1] s.a.r.l.ConditionalRejectingErrorHandler :
Execution of Rabbit message listener failed.
INFO 14768 --- [ntContainer#1-1] c.b.s.e.c.ParkingLotDLQAmqpContainer :
Retrying message for the 1 time
WARN 14768 --- [ntContainer#0-1] s.a.r.l.ConditionalRejectingErrorHandler :
Execution of Rabbit message listener failed.
INFO 14768 --- [ntContainer#1-1] c.b.s.e.c.ParkingLotDLQAmqpContainer :
Retrying message for the 2 time
WARN 14768 --- [ntContainer#0-1] s.a.r.l.ConditionalRejectingErrorHandler :
Execution of Rabbit message listener failed.
INFO 14768 --- [ntContainer#1-1] c.b.s.e.c.ParkingLotDLQAmqpContainer :
Sending message to the parking lot queue
INFO 14768 --- [ntContainer#2-1] c.b.s.e.c.ParkingLotDLQAmqpContainer :
Received message in parking lot queue
As we can see from the output, after several failed attempts, the message was sent to the Parking Lot Queue.
我们可以从输出中看到,在几次失败的尝试之后,消息被发送到了停车场队列。
6. Custom Error Handling
6.自定义错误处理
In the previous section, we’ve seen how to handle failures with dedicated queues and exchanges. However, sometimes we may need to catch all errors, for example for logging or persisting them to the database.
在上一节中,我们已经看到了如何通过专用队列和交换来处理故障。然而,有时我们可能需要捕捉所有的错误,例如用于记录或将其持久化到数据库中。
6.1. Global ErrorHandler
6.1 全局ErrorHandler
Until now, we’ve used the default SimpleRabbitListenerContainerFactory and this factory by default uses ConditionalRejectingErrorHandler. This handler catches different exceptions and transforms them into one of the exceptions within the AmqpException hierarchy.
到目前为止,我们一直使用默认的SimpleRabbitListenerContainerFactory,这个工厂默认使用ConditionalRejectingErrorHandler。这个处理程序捕捉不同的异常,并将它们转化为AmqpException层次结构中的一个异常。
It’s important to mention that if we need to handle connection errors, then we need to implement the ApplicationListener interface.
值得一提的是,如果我们需要处理连接错误,那么我们需要实现ApplicationListener接口。
Simply put, ConditionalRejectingErrorHandler decides whether to reject a specific message or not. When the message that caused an exception is rejected, it won’t be requeued.
简单地说,ConditionalRejectingErrorHandler决定是否拒绝一个特定的消息。当引起异常的消息被拒绝时,它将不会被重新排队。
Let’s define a custom ErrorHandler that will simply requeue only BusinessExceptions:
让我们定义一个自定义的ErrorHandler,它将只请求BusinessExceptions。
public class CustomErrorHandler implements ErrorHandler {
@Override
public void handleError(Throwable t) {
if (!(t.getCause() instanceof BusinessException)) {
throw new AmqpRejectAndDontRequeueException("Error Handler converted exception to fatal", t);
}
}
}
Furthermore, as we are throwing the exception inside our listener method it is wrapped in a ListenerExecutionFailedException. So, we need to call the getCause method to get a source exception.
此外,由于我们在监听器方法中抛出异常,它被包裹在一个ListenerExecutionFailedException中。所以,我们需要调用getCause方法来获取异常源。
6.2. FatalExceptionStrategy
6.2.FatalExceptionStrategy
Under the hood, this handler uses the FatalExceptionStrategy to check whether an exception should be considered fatal. If so, the failed message will be rejected.
在引擎盖下,这个处理程序使用FatalExceptionStrategy来检查一个异常是否应该被认为是致命的。如果是这样,失败的消息将被拒绝。
By default these exceptions are fatal:
默认情况下,这些异常是致命的。
- MessageConversionException
- MessageConversionException
- MethodArgumentNotValidException
- MethodArgumentTypeMismatchException
- NoSuchMethodException
- ClassCastException
Instead of implementing the ErrorHandler interface, we can just provide our FatalExceptionStrategy:
我们不需要实现ErrorHandler接口,而只需要提供我们的FatalExceptionStrategy。
public class CustomFatalExceptionStrategy
extends ConditionalRejectingErrorHandler.DefaultExceptionStrategy {
@Override
public boolean isFatal(Throwable t) {
return !(t.getCause() instanceof BusinessException);
}
}
Finally, we need to pass our custom strategy to the ConditionalRejectingErrorHandler constructor:
最后,我们需要将我们的自定义策略传递给ConditionalRejectingErrorHandler构造器。
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
ConnectionFactory connectionFactory,
SimpleRabbitListenerContainerFactoryConfigurer configurer) {
SimpleRabbitListenerContainerFactory factory =
new SimpleRabbitListenerContainerFactory();
configurer.configure(factory, connectionFactory);
factory.setErrorHandler(errorHandler());
return factory;
}
@Bean
public ErrorHandler errorHandler() {
return new ConditionalRejectingErrorHandler(customExceptionStrategy());
}
@Bean
FatalExceptionStrategy customExceptionStrategy() {
return new CustomFatalExceptionStrategy();
}
7. Conclusion
7.结语
In this tutorial, we’ve discussed different ways of handling errors while using Spring AMQP, and RabbitMQ in particular.
在本教程中,我们已经讨论了在使用 Spring AMQP,特别是 RabbitMQ 时处理错误的不同方法。
Every system needs a specific error handling strategy. We’ve covered the most common ways of error handling in event-driven architectures. Furthermore, we’ve seen that we can combine multiple strategies to build a more comprehensive and robust solution.
每个系统都需要一个特定的错误处理策略。我们已经介绍了事件驱动架构中最常见的错误处理方式。此外,我们还看到,我们可以将多种策略结合起来,建立一个更全面、更强大的解决方案。
As always, the full source code of the article is available over on GitHub.
一如既往,文章的完整源代码可在GitHub上获得over。