DDD實踐:在SpringBoot中跨微服務透過發件箱模式實現分散式事務機制 - Hans-Peter Grahsl

banq發表於2019-07-20

在任何兩個服務之間傳送的命令或事件時,透過引入松耦合元件避免點對點直接RPC等同步訪問由很多好處。在現代資料架構中,我們經常發現Apache Kafka是所有資料流核心的分散式流媒體平臺。這意味著我們需要找到一種方法來更新資料儲存,並另外將事件作為訊息寫入Kafka主題以供其他服務使用。
事實證明,從頭開始以可靠和一致的方式實現這一點實際上並非易事:
  • 執行兩個單獨的寫操作的天真方法是: 一個針對服務本地資料儲存;另一個針對Kafka主題 - 顯然這只是傻樂的做法
  • 臭名昭著的兩階段提交2PC並不是在這裡的一個選項,因為我們不能簡單地使用跨越任意資料儲存和訊息代理(如Apache Kafka)的XA事務。

以可靠和一致的方式實現這一點的某種“低估”方法是透過所謂的“發件箱模式”(也稱為事務發件箱),這是微服務架構環境中幾種明確定義的模式之一。Gunnar MorlingDebezium部落格上有一篇關於outbox模式的詳細,內容豐富且寫得非常好的文章。如果您想獲得更多背景知識並對此主題進行更深入的調查,這是一個強烈推薦的閱讀。此外,它們還使用Java EE技術堆疊提供交鑰匙就緒參考實現。

基於略微簡化但非常相似的示例,本部落格文章討論了使用不同技術堆疊進行演示微服務的POC實現:
  • Spring Boot和應用程式事件
  • 帶有領域事件的Spring資料
  • 使用MySQL而不是Postgres作為底層RDBMS

基於事件的通訊仍然建立在Apache Kafka與Kafka Connect和Debezium之上。

事件結構
需要寫入“發件箱”的每個事件都必須具有某些屬性。出於這個原因,有一個名為Outboxable的通用介面:

public interface Outboxable {

    /**
     * DDD聚合Id,The id of the aggregate affected by a given event. This is also used to ensure strict
     * ordering of events belonging to one and the same aggregate.
     */
    String getAggregateId();

    /**
     * The type of the aggregate affected by a given event. This needs to be the same type string for all
     * related parts of one an the same aggregate that might get changed.
     */
    String getAggregateType();

    /**
     * The actual event payload as String e.g. JSON serialization.
     */
    String getPayload();

    /**
     * The (sub)type of an actual event which causes any changes to a specific aggregate type.
     */
    String getType();

    /**
     * The application timestamp at which the event has happened.
     */
    Long getTimestamp();
}

資料庫的事件表結構
訂單微服務的資料儲存是MySQL。儲存任何型別Outboxable事件的相應表結構看起來不足為奇,按這裡所示

發件箱事件
需要在資料庫中持久儲存的每個“outboxable”事件都將轉換為@Entity OutboxEvent,它反映了上面顯示的結構:

@Entity
public class OutboxEvent {

    @Id
    @GeneratedValue
    @Type(type = "uuid-char")
    private UUID id;

    @NotNull
    private String aggregateType;

    @NotNull
    private String aggregateId;

    @NotNull
    private String type;

    @NotNull
    private Long timestamp;

    @NotNull
    @Column(length = 1048576) //e.g. 1 MB max
    private String payload;

    private OutboxEvent() {
    }

    //...
}


發件箱監聽器
有一個專門的Spring元件OutboxListener,它負責響應任何“outboxable”事件的排程。它呼叫OutboxEventRepository用於CRUD,以便預先確定實際的OutboxEvent實體:

@Component
public class OutboxListener {

    private OutboxEventRepository repository;

    public OutboxListener(OutboxEventRepository repository) {
        this.repository = repository;
    }

    @EventListener
    public void onExportedEvent(Outboxable event) {

        OutboxEvent outboxEvent = OutboxEvent.from(event);

        // The outbox event will be written to the "outbox" table
        // and immediately afterwards removed again. Thus the
        // "outbox" table is effectively empty all the time. From a
        // CDC perspective this will produce an INSERT operation
        // followed by a DELETE operation of the same record such that
        // both events are captured from the database log by Debezium.
        repository.save(outboxEvent);
        repository.delete(outboxEvent);

    }

}

實現當然與“outboxable”事件的起源無關,因此,如果事件是透過Spring Data @DomainEvents機制釋出還是透過ApplicationEventPublisher手動觸發,則無關緊要。

發射Outboxable事件
由於Spring Boot示例使用Spring Data,因此我們可以為PurchaseOrder實體使用@DomainEvents機制。這樣做,每次呼叫相應的PurchaseOrderRepository的save(...)方法時,Spring都會確保釋出我們需要通知的有關插入或更新一個這樣的實體的任何自定義事件。事實上,這正是我們希望在發件箱模式的上下文中發生的事情。它可以透過遵循下面的程式碼段中的簡單約定輕鬆實現:

@Entity
public class PurchaseOrder {

    //...

    @DomainEvents
    private Collection<Outboxable> triggerOutboxEvents() {
        return Arrays.asList(OrderUpsertedEvent.of(this));
    }

    //...

}

透過使用@DomainEvents批註,Spring Data將呼叫此方法併發布其Collection <Outboxable>返回值中包含的所有事件。上面的程式碼只使用一個“outboxable” OrderUpsertedEvent來反映實體本身的當前狀態:

public class OrderUpsertedEvent implements Outboxable {

    private static ObjectMapper MAPPER = new ObjectMapper();

    private final Long id;
    private final JsonNode payload;
    private final Long timestamp;

    static {
        MAPPER.registerModule(new JavaTimeModule());
    }

    private OrderUpsertedEvent(Long id, JsonNode payload) {
        this.id = id;
        this.payload = payload;
        this.timestamp = Instant.now().getEpochSecond();
    }

    public static OrderUpsertedEvent of(PurchaseOrder order) {
        return new OrderUpsertedEvent(order.getId(), MAPPER.valueToTree(order));
    }

    @Override
    public String getAggregateId() {
        return String.valueOf(id);
    }

    @Override
    public String getAggregateType() {
        return PurchaseOrder.class.getName();
    }

    @Override
    public String getType() {
        return this.getClass().getName();
    }

    @Override
    public Long getTimestamp() {
        return timestamp;
    }

    @Override
    public String getPayload() {
        try {
            return MAPPER.writeValueAsString(payload);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        return null;
    }

}


這個演示應用程式使用Jackson並將事件有效負載結構序列化為JSON字串,但通常任何字串序列化都可以,例如,利用Base64來支援二進位制資料的編碼。此處使用名稱OrderUpsertedEvent,因為此事件型別實際上將在以下兩個條件下發布:a)每次將新的採購訂單實體插入到底層的outbox_event表中時b)每次我們更新現有的採購訂單實體時。在@Service OrderService的placeOrder(...)方法中,沒有此事件的證據,因為它在後臺由Spring Data隱式處理。

@Service
public class OrderService {

  //...

  @Transactional
  public PurchaseOrder placeOrder(PurchaseOrder order) {
    repository.save(order); //NOTE: OrderUpsertedEvent automatically published behind the scenes
    return order;
  }

  //...

}

同樣重要的是要強調所有與永續性相關的行為都發生在同一個事務性範圍內。這保證了ACID屬性,因此兩個寫入 - 完整聚合的插入/更新(訂單後設資料和訂單行詳細資訊)以及相應的“可開箱的” OrderUpsertedEvent - 一致地應用於資料庫或者在錯誤時一起回滾。

雖然Spring Data @DomainEvents是將這些事件的釋出附加到聚合實體以用於通知目的的一種很好的方式,但它們不是特別靈活,也不是那麼直接以更細粒度的方式應用,即當我們只想要考慮並通知彙總的某些部分已發生變化。

正是由於這個原因,該演示還採用了另一種方法,透過Spring的ApplicationEventPublisher顯式地/手動地釋出“outboxable”事件。

@Service
public class OrderService {

    //... 

    @Transactional
    public PurchaseOrder updateOrderLineStatus(long orderId, long orderLineId, OrderLineStatus newStatus) {
        PurchaseOrder po = repository.findById(orderId)
                .orElseThrow(() -> new EntityNotFoundException("order with id " + orderId + " doesn't exist!"));
        OrderLineStatus oldStatus = po.updateOrderLine(orderLineId, newStatus);
        eventBus.publishEvent(OrderLineUpdatedEvent.of(orderId, orderLineId, newStatus, oldStatus));
        repository.save(po);
        return po;
    }

    //...

}

此示例顯示如何在完整訂單的任何單個訂單行更改其狀態時觸發自定義事件有效內容。因此,在執行更新後,我們釋出了一個“outboxable” OrderLineUpdatedEvent來通知訂單行狀態修改。接下來,透過使用完整聚合顯式呼叫儲存庫的save(...)方法,@ DomainEvents機制再次隱式釋出另一個“可開箱的” OrderUpsertedEvent。
這是一個可選步驟,只有在需要透過每次更改的附加發件箱事件來傳達聚合的每個新的完整狀態時才會執行。同樣,透過使用@Transactional進行註釋,我們確保以一致且可靠的方式應用所有更改。

接受和處理發件箱事件
在將Debezium Source Connector安裝到Kafka Connect環境後,您可以針對Kafka Connect的REST API釋出以下配置,以捕獲針對微服務示例的MySQL資料庫的“發件箱表”應用的更改:

{
  “ name ”: “ mysql-outbox-src-connector-01 ”,
  “ config ”:{
    “ connector.class ”: “ io.debezium.connector.mysql.MySqlConnector ”,
    “ tasks.max ”: “ 1 ”,
    “ database.hostname ”: “ localhost ”,
    “ database.port ”: “ 3306 ”,
    “ database.user ”: “ debezium ”,
    “ database.password ”: “ dbz ”,
    “ database.server.id ”: “ 12345 ”,
    “ database.server.name ”: “ dbserver1 ”,
    “ database.whitelist ”: “ outbox-demo ”,
    “ database.serverTimezone ”: “歐洲/維也納”,
    “ table.whitelist ”: “ outbox-demo.outbox_event ”,
    “ database.history.kafka.bootstrap.servers ”: “ localhost:9092 ”,
    “ database.history.kafka.topic ”: “ schema-changes.outbox-demo ”,
    “ tombstones.on.delete ”: “ false ”
  }
}


在Kafka主題中檢查原始發件箱事件
當我們執行Spring Boot應用程式時,在啟動期間會建立兩個帶有幾個訂單行的樣本訂單。在建立訂單後,訂單行的狀態也會立即更改,以模擬與服務公開的API的互動。這導致將幾個事件寫入“發件箱表”,然後由Debezium MySQL源聯結器捕獲。
我們可以在命令列上透過執行以下命令輕鬆檢查寫入配置的Kafka主題dbserver1.outbox-demo.outbox_event的訊息:

bin/kafka-avro-console-consumer --bootstrap-server localhost:9092 --topic dbserver1.outbox-demo.outbox_event --from-beginning | jq


下面是兩個示例訊息,一個反映第一個訂單的插入,然後相應刪除相同的訂單,後者再次完成以保持原始“發件箱表”無限增長。

將原始發件箱事件傳播到MongoDB
嘗試將原始發件箱事件流式傳輸到運營資料儲存時,存在兩個主要挑戰。首先,大多數接收器都不能正確處理CDC事件,例如Debezium釋出的事件,因為它們缺乏對所述事件的必要語義感知。其次,即使他們可以處理它們,它通常也符合CDC識別接收器聯結器的興趣來處理所有不同型別的CDC事件,即INSERT,UPDATE和DELETE。但是,當處理從“發件箱表”派生的CDC事件時,需要進行特殊處理,以允許忽略某些CDC事件型別。具體而言,任何DELETE(如上段所示)都不得反映在接收器中,因為這將始終刪除任何先前的插入。
請記住,這源於這樣一個事實:原始的“發件箱表”也始終是空的,並且僅用於從資料儲存的日誌中執行事務感知捕獲更改。這裡有一個預覽功能更新了MongoDB [url=https://github.com/hpgrahsl/kafka-connect-mongodb?source=post_page---------------------------]社群接收[/url]器聯結器,以透過特定配置選項允許此類方案。
下面的程式碼段顯示了一個示例配置,它能夠處理源自Debezium MySQL源聯結器的原始發件箱事件:

{
  “ name ”: “ mdb-sink-outbox-raw ”,
  “ config ”:{
    “ key.converter ”: “ io.confluent.connect.avro.AvroConverter ”,
    “ key.converter.schema.registry.url ”: “ http:// localhost:8081 ”,
    “ value.converter ”: “ io.confluent.connect.avro.AvroConverter ”,
    “ value.converter.schema.registry.url ”: “ http:// localhost:8081 ”,
    “ connector.class ”: “ at.grahsl.kafka.connect.mongodb.MongoDbSinkConnector ”,
    “ topics ”: “ dbserver1.outbox-demo.outbox_event ”,
    “ mongodb.connection.uri ”: “ mongodb:// localhost:27017 / outboxed ”,
    “ mongodb.collections ”: “發件箱,原料”,
    “ mongodb.collection.dbserver1.outbox-demo.outbox_event ”: “ outbox -raw ”,
    “ mongodb.change.data.capture.handler.outbox-raw ”: “ at.grahsl.kafka.connect.mongodb.cdc.debezium.rdbms.RdbmsHandler ”,
    “ mongodb.change.data.capture.handler.operations.outbox-raw ”: “ c ”
  }
}


最重要的部分是最後一個配置條目mongodb.change.data.capture.handler.operations.outbox-raw ,可以配置一系列CDC操作型別:“c,r,u,d”。在這種情況下,我們只對處理“c”型別的操作感興趣,即INSERT並忽略其他任何“r,u,d”。
根據定義,發件箱表將永遠不會經歷“u”即UPDATE,但當然它會收到“d”即DELETE用於在編寫後立即清理任何事件。透過僅處理INSERT,接收器聯結器能夠保留源資料儲存的原始“發件箱表”中生成的所有原始發件箱事件。
執行此接收器聯結器會導致outboxed.outbox-rawMongoDB 中的集合跟蹤所有建立的原始發件箱事件。

可以在GitHub上找到所討論的示例應用程式的完整原始碼以及Kafka Connector配置。

相關文章