在本系列的前兩篇文章中,筆者分別講到了后端項目的代碼模板和DDD編碼實踐,在本文中,我將繼續以編碼實踐的方式分享如何落地事件驅動架構。
單純地講事件驅動架構(Event Driven Architecture, EDA),那是幾十年前就出現了的話題;單純地講領域事件,那也是這些年被大量提及并討論得快熟透了的軟件用語。然而,就筆者的觀察看,事件驅動架構遠沒有想象中那樣普遍地被開發團隊所接受。即便搞微服務的人都知道除了同步的HTTP還有異步的消息機制,即便搞DDD的人都知道領域事件是其中的一等公民,事件驅動架構所帶來的優點并沒有相應地轉化為軟件從業者的青睞。
我嘗試著去思考其中的原因,總結出了兩點:第一是事件驅動可能是客觀世界的運作方式,但不是人的自然思考問題的方式;第二是事件驅動架構在給軟件帶來好處的同時,又會增加額外的復雜性,比如調試的困難性,又比如并不直觀的最終一致性。
當然,事實上有不少軟件項目都使用了消息隊列,但是這里需要明確的是,對消息隊列的使用并不意味著你的項目就一定是事件驅動架構,很多項目只是由于技術方面的驅動,小范圍地采用了某些消息隊列(比如RabbitMQ和Kafka等)的產品而已。偌大一個系統,如果你的消息隊列只是用作郵件發送的通知,那么這樣系統自然談不上采用了事件驅動架構。
放到當下,微服務興起,DDD重現,在采用事件驅動架構時, 我們需要考慮業務的建模、領域事件的設計、DDD的約束、限界上下文的邊界以及更多技術方面的因素,這一個系統工程應該如何從頭到尾的落地,是需要經過思考和推敲的。還是那句話,有講究的編程并不是一件易事。
誠然,用好事件驅動架構存在實踐上的難處,然而它的優點也委實誘人,本文希望形成一定的“條理”和“套路”,讓事件驅動架構能夠更簡單的落地。
本文主要分為兩大部分,第一部分獨立于具體的消息隊列實現來講解通用的對領域事件的建模,第二部分以一個真實的微服務系統為例,采用RabbitMQ作為消息隊列,并以此分享完整的事件驅動架構落地實踐。
本文以DDD為基礎進行編碼,其中會涉及到DDD中的不少概念,比如聚合根、資源庫和應用服務等,對DDD不熟悉的讀者可以參考筆者的DDD編碼實踐文章。
本文的示例代碼請參考github上的e-commerce-sample項目。
第一部分:領域事件的建模
領域事件是DDD中的一個概念,表示的是在一個領域中所發生的一次對業務有價值的事情,落到技術層面就是在一個業務實體對象(通常來說是聚合根)的狀態發生了變化之后需要發出一個領域事件。雖然事件驅動架構中的“事件”不一定指“領域事件”,但本文由于密切結合DDD,因此當提到事件時,我們特指“領域事件”。
創建領域事件
關于領域事件的基礎知識,請參考筆者的在微服務中使用領域事件文章,本文直接進入編碼實踐環節。
在建模領域事件時,首先需要記錄事件的一些通用信息,比如唯一標識ID和創建時間等,為此創建事件基類DomainEvent
:
public abstract class DomainEvent {
private final String _id;
private final DomainEventType _type;
private final Instant _createdAt;
}
在DDD場景下,領域事件一般隨著聚合根狀態的更新而產生,另外,在事件的消費方,有時我們希望監聽發生在某個聚合根下的所有事件,為此筆者建議為每一個聚合根對象創建相應的事件基類,其中包含聚合根的ID,比如對于訂單(Order)類,創建OrderEvent
:
public abstract class OrderEvent extends DomainEvent {
private final String orderId;
}
然后對于實際的Order事件,統一繼承自OrderEvent
,比如對于創建訂單的OrderCreatedEvent
事件:
public class OrderCreatedEvent extends OrderEvent {
private final BigDecimal price;
private final Address address;
private final List<OrderItem> items;
private final Instant createdAt;
}
領域事件的繼承鏈如下:
在創建領域事件時,需要注意2點:
- 領域事件本身應該是不變的(Immutable);
- 領域事件應該攜帶與事件發生時相關的上下文數據信息,但是并不是整個聚合根的狀態數據,例如,在創建訂單時可以攜帶訂單的基本信息,而對于產品(Product)名稱更新的
ProductNameUpdatedEvent
事件,則應該同時包含更新前后的產品名稱:
public class ProductNameUpdatedEvent extends ProductEvent {
private String oldName; //更新前的名稱
private String newName; // 更新后的名稱
}
發布領域事件
發布領域事件有多種方式,比如可以在應用服務(ApplicationService)中發布,也可以在資源庫(Repository)中發布,還可以引入事件表的方式,這3種發布方式的詳細比較可以參考筆者的在微服務中使用領域事件文章。筆者建議采用事件表方式,這里展開討論一下。
通常的業務處理過程都會更新數據庫然后發布領域事件,這里一個比較重要的點是:我們需要保證數據庫更新和事件發布之間的原子性,也即要么二者都成功,要么都失敗。在傳統的實踐方式中,全局事務(Global Transaction/XA Transaction)通常用于解決此類問題。然而,全局事務本身的效率是很低的,另外,一些技術框架并不提供對全局事務的支持。當前,一種比較受推崇的方式是引入事件表,其流程大致如下:
在更新業務表的同時,將領域事件一并保存到數據庫的事件表中,此時業務表和事件表在同一個本地事務中,即保證了原子性,又保證了效率。
在后臺開啟一個任務,將事件表中的事件發布到消息隊列中,發送成功之后刪除掉事件。
但是,這里又有一個問題:在第2步中,我們如何保證發布事件和刪除事件之間的原子性呢?答案是:我們不用保證它們的原子性,我們需要保證的是“至少一次投遞”,并且保證消費方冪等。此時的大致場景如下:
- 代碼中先發布事件,成功后再從事件表中刪除事件;
- 發布消息成功,事件刪除也成功,皆大歡喜;
- 如果消息發布不成功,那么代碼中不會執行事件刪除邏輯,就像事情沒有發生一樣,一致性得到保證;
- 如果消息發布成功,但是事件刪除失敗,那么在第二次任務執行時,會重新發布消息,導致消息的重復發送。然而,由于我們要求了消費方的冪等性,也即消費方多次消費同一條消息是ok的,整個過程的一致性也得到了保證。
發布領域事件的整個流程如下:
- 接受用戶請求;
- 處理用戶請求;
- 寫入業務表;
- 寫入事件表,事件表和業務表的更新在同一個本地數據庫事務中;
- 事務完成后,即時觸發事件的發送(比如可以通過Spring AOP的方式完成,也可以定時掃描事件表,還可以借助諸如MySQL的binlog之類的機制);
- 后臺任務讀取事件表;
- 后臺任務發送事件到消息隊列;
- 發送成功后刪除事件。
更多有關事件表的介紹,請參考Chris Richardson的"Transaction Outbox模式"和Udi Dahan的"在不使用分布式事務條件下如何處理消息可靠性"的視頻。
在事件表場景下,一種常見的做法是將領域事件保存到聚合根中,然后在Repository保存聚合根的時候,將事件保存到事件表中。這種方式對于所有的Repository/聚合根都采用的方式處理,因此可以創建對應的抽象基類。
創建所有聚合根的基類DomainEventAwareAggregate
如下:
public abstract class DomainEventAwareAggregate {
@JsonIgnore
private final List<DomainEvent> events = newArrayList();
protected void raiseEvent(DomainEvent event) {
this.events.add(event);
}
void clearEvents() {
this.events.clear();
}
List<DomainEvent> getEvents() {
return Collections.unmodifiableList(events);
}
}
這里的raiseEvent()
方法用于在具體的聚合根對象中產生領域事件,然后在Repository中獲取到事件,與聚合根對象一起完成持久化,創建DomainEventAwareRepository
基類如下:
public abstract class DomainEventAwareRepository<AR extends DomainEventAwareAggregate> {
@Autowired
private DomainEventDao eventDao;
public void save(AR aggregate) {
eventDao.insert(aggregate.getEvents());
aggregate.clearEvents();
doSave(aggregate);
}
protected abstract void doSave(AR aggregate);
}
具體的聚合根在實現業務邏輯之后調用raiseEvent()
方法生成事件,以“更改Order收貨地址”業務過程為例:
public class Order extends DomainEventAwareAggregate {
//......
public void changeAddressDetail(String detail) {
if (this.status == PAID) {
throw new OrderCannotBeModifiedException(this.id);
}
this.address = this.address.changeDetailTo(detail);
raiseEvent(new OrderAddressChangedEvent(getId().toString(), detail, address.getDetail()));
}
//......
}
在保存Order的時候,只需要處理Order自身的持久化即可,事件的持久化已經在DomainEventAwareRepository
基類中完成:
@Component
public class OrderRepository extends DomainEventAwareRepository<Order> {
//......
@Override
protected void doSave(Order order) {
String sql = "INSERT INTO ORDERS (ID, JSON_CONTENT) VALUES (:id, :json) " +
"ON DUPLICATE KEY UPDATE JSON_CONTENT=:json;";
Map<String, String> paramMap = of("id", order.getId().toString(), "json", objectMapper.writeValueAsString(order));
jdbcTemplate.update(sql, paramMap);
}
//......
}
當業務操作的事務完成之后,需要通知消息發送設施即時發布事件到消息隊列。發布過程最好做成異步的后臺操作,這樣不會影響業務處理的正常返回,也不會影響業務處理的效率。在Spring Boot項目中,可以考慮采用AOP的方式,在HTTP的POST/PUT/PATCH/DELETE方法完成之后統一發布事件:
@Aspect
@Component
public class DomainEventPublishAspect {
//......
@After("@annotation(org.springframework.web.bind.annotation.PostMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PutMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PatchMapping) || " +
"@annotation(org.springframework.web.bind.annotation.DeleteMapping) ||")
public void publishEvents(JoinPoint joinPoint) {
logger.info("Trigger domain event publish process.");
taskExecutor.execute(() -> publisher.publish());
}
//......
}
以上,我們使用了TaskExecutor在后臺開啟新的線程完成事件發布,實際的發布由RabbitDomainEventPublisher
完成:
@Component
public class DomainEventPublisher {
// ......
public void publish() {
Instant now = Instant.now();
LockConfiguration configuration = new LockConfiguration("domain-event-publisher", now.plusSeconds(10), now.plusSeconds(1));
distributedLockExecutor.execute(this::doPublish, configuration);
}
//......
}
這里,我們使用了分發布鎖來處理并發發送的情況,doPublish()
方法將調用實際的消息隊列(比如RabbitMQ/Kafka等)API完成消息發送。更多的代碼細節,請參考本文的示例代碼。
消費領域事件
在事件消費時,除了完成基本的消費邏輯外,我們需要重點關注以下兩點:
- 消費方的冪等性
- 消費方有可能進一步產生事件
對于“消費方的冪等性”,在上文中我們講到事件的發送機制保證的是“至少一次投遞”,為了能夠正確地處理重復消息,要求消費方是冪等的,即多次消費事件與單次消費該事件的效果相同。為此,在消費方創建一個事件記錄表,用于記錄已經消費過的事件,在處理事件時,首先檢查該事件是否已經被消費過,如果是則不做任何消費處理。
對于第2點,我們依然沿用前文講到的事件表的方式。事實上,無論是處理HTTP請求,還是作為消息的消費方,對于聚合根來講都是無感知的,領域事件由聚合根產生進而由Repository持久化,這些過程都與具體的業務操作源頭無關。
綜上,在消費領域事件的過程中,程序需要更新業務表、事件記錄表以及事件發送表,這3個操作過程屬于同一個本地事務,此時整個事件的發布和消費過程如下:
在編碼實踐時,可以考慮與事件發布過程相同的AOP方式完成對事件的記錄,以Spring和RabbitMQ為例,可以將@RabbitListener
通過AOP代理起來:
@Aspect
@Component
public class DomainEventRecordingConsumerAspect {
//......
@Around("@annotation(org.springframework.amqp.rabbit.annotation.RabbitHandler) || " +
"@annotation(org.springframework.amqp.rabbit.annotation.RabbitListener)")
public Object recordEvents(ProceedingJoinPoint joinPoint) throws Throwable {
return domainEventRecordingConsumer.recordAndConsume(joinPoint);
}
//......
}
然后在代理過程中通過DomainEventRecordingConsumer
完成事件的記錄:
@Component
public class DomainEventRecordingConsumer {
//......
@Transactional
public Object recordAndConsume(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
Optional<Object> optionalEvent = Arrays.stream(args)
.filter(o -> o instanceof DomainEvent)
.findFirst();
if (optionalEvent.isPresent()) {
DomainEvent event = (DomainEvent) optionalEvent.get();
try {
dao.recordEvent(event);
} catch (DuplicateKeyException dke) {
logger.warn("Duplicated {} skipped.", event);
return null;
}
return joinPoint.proceed();
}
return joinPoint.proceed();
}
//......
}
這里的DomainEventRecordingConsumer
通過直接向事件記錄表中插入事件的方式來判斷消息是否重復,如果發生重復主鍵異常DuplicateKeyException
,即表示該事件已經在記錄表中存在了,因此直接return null;
而不再執行業務過程。
需要特別注意的一點是,這里的封裝方法recordAndConsume()
需要打上@Transactional
注解,這樣才能保證對事件的記錄和業務處理在同一個事務中完成。
此外,由于消費完畢后也需要即時發送事件,因此需要在發布事件的AOP配置DomainEventPublishAspect
中加入@RabbitListener
:
@Aspect
@Component
public class DomainEventPublishAspect {
//......
@After("@annotation(org.springframework.web.bind.annotation.PostMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PutMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PatchMapping) || " +
"@annotation(org.springframework.web.bind.annotation.DeleteMapping) ||" +
"@annotation(org.springframework.amqp.rabbit.annotation.RabbitListener) ||")
public void publishEvents(JoinPoint joinPoint) {
logger.info("Trigger domain event publish process.");
taskExecutor.execute(() -> publisher.publish());
}
//......
}
事件驅動架構的2種風格
事件驅動架構存在多種風格,本文就其中的2種主要風格展開討論,它們是:
- 事件通知
- 事件攜帶狀態轉移(Event-Carried State Transfer)
在“事件通知”風格中,事件只是作為一種信號傳遞到消費方,消費方需要的數據需要額外API請求從源事件系統獲取,如圖:
在上圖的事件通知風格中,對事件的處理流程如下:
- 發布方發布事件
- 消費方接收事件并處理
- 消費方調用發布方的API以獲取事件相關數據
- 消費方更新自身狀態
這種風格的好處是,事件可以設計得非常簡單,通常只需要攜帶聚合根的ID即可,由此進一步降低了事件驅動系統中的耦合度。然而,消費方需要的數據依然需要額外的API調用從發布方獲取,這又從另一個角度增加了系統之間的耦合性。此外,如果源系統宕機,消費方也無法完成后續操作,因此可用性會受到影響。
在“事件攜帶狀態轉移”中,消費方所需要的數據直接從事件中獲取,因此不需要額外的API請求:
這種風格的好處在于,即便發布方系統不可用,消費方依然可以完成對事件的處理。
筆者的建議是,對于發布方來說,作為一種數據提供者的“自我修養”,事件應該包含足夠多的上下文數據,而對于消費方來講,可以根據自身的實際情況確定具體采用哪種風格。在同一個系統中,同時采用2種風格是可以接受的。比如,對于基于事件的CQRS而言,可以采用“事件通知”,此時的事件只是一個“觸發器”,一個聚合下的所有事件所觸發的結果是一樣的,即都是告知消費方需要從源系統中同步數據,因此此時的消費方可以對聚合下的所有事件一并處理,而不用為每一種事件單獨開發處理邏輯。
事實上,事件驅動還存在第3種風格,即事件溯源,本文不對此展開討論。更多有關事件驅動架構不同風格的介紹,請參考Martin Fowler的“事件風格”文章。
第二部分:基于RabbitMQ的示例項目
在本部分中,我將以一個簡單的電商平臺微服務系統為例,采用RabbitMQ作為消息機制講解事件驅動架構落地的全過程。
該電商系統包含3個微服務,分別是:
- 訂單(Order)服務:用于用戶下單
- 產品(Product)服務:用于管理/展示產品信息
- 庫存(Inventory)服務:用于管理產品對應的庫存
整個系統包含以下代碼庫:
代碼庫 | 用途 | 地址 |
---|---|---|
order-backend | Order服務 | https://github.com/e-commerce-sample/order-backend |
product-backend | Product服務 | https://github.com/e-commerce-sample/product-backend |
inventory-backend | Inventory服務 | https://github.com/e-commerce-sample/inventory-backend |
common | 共享依賴包 | https://github.com/e-commerce-sample/common |
devops | 基礎設施 | https://github.com/e-commerce-sample/devops |
其中,common
代碼庫包含了所有服務所共享的代碼和配置,包括所有服務中的所有事件(請注意,這種做法只是筆者為了編碼上的便利,并不是一種好的實踐,一種更好的實踐是各個服務各自管理自身產生的事件),以及RabbitMQ的通用配置(即每個服務都采用相同的方式配置RabbitMQ設施),同時也包含了異常處理和分布式鎖等配置。devops
庫中包含了RabbitMQ的Docker鏡像,用于在本地測試。
整個系統中涉及到的領域事件如下:
其中:
- Order服務自己消費了自己產生的所有
OrderEvent
用于CQRS同步讀寫模型; - Inventory服務消費了Order服務的
OrderCreatedEvent
事件,用于在下單之后即時扣減庫存; - Inventory服務消費了Product服務的
ProductCreatedEvent
和ProductNameChangedEvent
事件,用于同步產品信息; - Product服務消費了Inventory服務的
InventoryChangedEvent
用于更新產品庫存。
配置RabbitMQ
閱讀本小節需要熟悉RabbitMQ中的基本概念,建議不熟悉RabbitMQ的讀者事先參考RabbitMQ入門文章。
這里介紹2種RabbitMQ的配置方式,一種簡單的,一種稍微復雜的。兩種配置過程中會反復使用到以下概念,讀者可以先行熟悉:
概念 | 類型 | 解釋 | 命名 | 示例 |
---|---|---|---|---|
發送方Exchange | Exchange | 用于接收某個微服務中所有消息的Exchange,一個服務只有一個發送方Exchange
|
xxx-publish-x | order-publish-x |
發送方DLX | Exchange | 用于接收發送方無法路由的消息 | xxx-publish-dlx | order-publish-dlx |
發送方DLQ | Queue | 用于存放發送方DLX 的消息 |
xxx-publish-dlq | order-publish-dlq |
接收方Queue | Queue | 用于接收發送方Exchange 的消息,一個服務只有一個接收方Queue 用于接收所有外部消息 |
xxx-receive-q | product-receive-q |
接收方DLX | Exchange | 死信Exchange,用于接收消費失敗的消息 | xxx-receive-dlx | product-receive-dlx |
接收方DLQ | Queue | 死信隊列,用于存放接收方DLX 的消息 |
xxx-receive-dlq | product-receive-dlq |
接收方恢復Exchange | Exchange | 用于接收從接收方DLQ 中手動恢復的消息,接收方Queue 應該綁定到接收方恢復Exchange
|
xxx-receive-recover-x | product-receive-recover-x |
在簡單配置方式下,消息流向圖如下:
- 發送方發布事件到
發送方Exchange
- 消息到達消費方的
接收方Queue
- 消費成功處理消息,更新本地數據庫
- 如果消息處理失敗,消息被放入
接收方DLX
- 消息到達死信隊列
接收方DLQ
- 對死信消息做手工處理(比如作日志記錄等)
對于發送方而言,事件驅動架構提倡的是“發送后不管”機制,即發送方只需要保證事件成功發送即可,而不用關心是誰消費了該事件。因此在配置發送方的RabbitMQ時,可以簡單到只配置一個發送方Exchange
即可,該Exchange用于接收某個微服務中所有類型的事件。在消費方,首先配置一個接收方Queue
用于接收來自所有發送方Exchange
的所有類型的事件,除此之外對于消費失敗的事件,需要發送到接收方DLX
,進而發送到接收方DLQ
中,對于接收方DLQ
的事件,采用手動處理的形式恢復消費。
在簡單方式下的RabbitMQ配置如下:
在第2種配置方式稍微復雜一點,其建立在第1種基礎之上,增加了發送方的死信機制以及消費方用于恢復消費的Exchange,此時的消息流向如下:
- 發送方發布事件
- 事件發布失敗時被放入死信Exchange
發送方DLX
- 消息到達死信隊列
發送方DLQ
- 對于
發送方DLQ
中的消息進行人工處理,重新發送 - 如果事件發布正常,則會到達
接收方Queue
- 正常處理事件,更新本地數據庫
- 事件處理失敗時,發到
接收方DLX
,進而路由到接收方DLQ
- 手工處理死信消息,將其發到
接收方恢復Exchange
,進而重新發到接收方Queue
此時的RabbitMQ配置如下:
在以上2種方式中,我們都啟用了RabbitMQ的“發送方確認”和“消費方確認”,另外,發送方確認也可以通過RabbitMQ的事務(不是分布式事務)替代,不過效率更低。更多關于RabbitMQ的知識,可以參考筆者的Spring AMQP學習筆記和RabbitMQ最佳實踐。
系統演示
- 啟動RabbitMQ,切換到
ecommerce-sample/devops/local/rabbitmq
目錄,運行:
./start-rabbitmq.sh
- 啟動Order服務:切換到
ecommerce-sample/order-backend
項目,運行:
./run.sh //監聽8080端口,調試5005端口
- 啟動Product服務:切換到
ecommerce-sample/product-backend
項目,運行:
./run.sh //監聽8082端口,調試5006端口
- 啟動Inventory服務:切換到
ecommerce-sample/inventory-backend
項目,運行:
./run.sh //監聽8083端口,調試5007端口
- 創建Product:
curl -X POST \
http://localhost:8082/products \
-H 'Content-Type: application/json' \
-H 'cache-control: no-cache' \
-d '{
"name":"好吃的蘋果",
"description":"原生態的蘋果",
"price": 10.0
}'
此時返回Product ID:
{"id":"3c11b3f6217f478fbdb486998b9b2fee"}
- 查看Product:
curl -X GET \
http://localhost:8082/products/3c11b3f6217f478fbdb486998b9b2fee \
-H 'cache-control: no-cache'
返回如下:
{
"id": {
"id": "3c11b3f6217f478fbdb486998b9b2fee"
},
"name": "好吃的蘋果",
"price": 10,
"createdAt": 1564361781956,
"inventory": 0,
"description": "原生態的蘋果"
}
可以看到,新創建的Product的庫存(inventory
)默認為0。
- 創建Product時,會創建ProductCreatedEvent,Inventory服務接收到該事件后會自動創建對應的Inventory,日志如下:
2019-07-29 08:56:22.276 -- INFO [taskExecutor-1] c.e.i.i.InventoryEventHandler : Created inventory[5e3298520019442b8a6d97724ab57d53] for product[3c11b3f6217f478fbdb486998b9b2fee].
- 增加Inventory為10:
curl -X POST \
http://localhost:8083/inventories/5e3298520019442b8a6d97724ab57d53/increase \
-H 'Content-Type: application/json' \
-H 'cache-control: no-cache' \
-d '{
"increaseNumber":10
}'
- 增加Inventory之后,會發送InventoryChangedEvent,Product服務接收到該事件后會自動同步自己的庫存,再次查看Product:
curl -X GET \
http://localhost:8082/products/3c11b3f6217f478fbdb486998b9b2fee \
-H 'cache-control: no-cache'
返回如下:
{
"id": {
"id": "3c11b3f6217f478fbdb486998b9b2fee"
},
"name": "好吃的蘋果",
"price": 10,
"createdAt": 1564361781956,
"inventory": 10,
"description": "原生態的蘋果"
}
可以看到,Product的庫存已經更新為10。
- 至此,Product和Inventory都準備好了,讓我們下單吧:
curl -X POST \
http://localhost:8080/orders \
-H 'Content-Type: application/json' \
-H 'cache-control: no-cache' \
-d '{
"items": [
{
"productId": "3c11b3f6217f478fbdb486998b9b2fee",
"count": 2,
"itemPrice": 10
}
],
"address": {
"province": "四川",
"city": "成都",
"detail": "天府軟件園1號"
}
}'
返回Order ID:
{
"id": "d764407855d74ff0b5bb75250483229f"
}
- 創建訂單之后,會發送OrderCreatedEvent,Inventory服務接收到該事件會自動扣減相應庫存:
2019-07-29 09:11:31.202 -- INFO [taskExecutor-1] c.e.i.i.InventoryEventHandler : Inventory[5e3298520019442b8a6d97724ab57d53] decreased to 8 due to order[d764407855d74ff0b5bb75250483229f] creation.
同時,Inventory將發送InventoryChangedEvent,Product服務接收到該事件會自動更新Product的庫存,再次查看Product:
curl -X GET \
http://localhost:8082/products/3c11b3f6217f478fbdb486998b9b2fee \
-H 'cache-control: no-cache'
返回如下:
{
"id": {
"id": "3c11b3f6217f478fbdb486998b9b2fee"
},
"name": "好吃的蘋果",
"price": 10,
"createdAt": 1564361781956,
"inventory": 8,
"description": "原生態的蘋果"
}
可以看到,Product的庫存從10減少到了8,因為先前下單時我們選了2個Product。
總結
本文首先獨立于消息隊列的技術實現,講到了事件驅動架構在落地過程中的諸多方面以及問題,包括領域事件的建模、通過聚合根暫存事件然后由Repository完成存儲,再由后臺任務讀取事件表完成事件的實際發布。在消費方,通過冪等性解決在“至少一次投遞”的情況下所帶來的重復消費問題。另外,還講到了事件驅動架構的2種常見風格,即事件通知和事件攜帶狀態轉移,以及他們之間的優劣勢。在第二部分,以RabbitMQ為例,分享了如何在一個微服務化的系統中落地事件驅動架構。
文/ThoughtWorks滕云
更多精彩洞見,請關注微信公眾號:ThoughtWorks洞見