利用 Watermill 實現 Golang CQRS

為少發表於2020-12-28

CQRS

CQRS 的意思是“命令-查詢責任隔離”。我們分離了命令(寫請求)和查詢(讀請求)之間的責任。寫請求和讀請求由不同的物件處理。

就是這樣。我們可以進一步分割資料儲存,使用單獨的讀寫儲存。一旦發生這種情況,可能會有許多讀取儲存,這些儲存針對處理不同型別的查詢或跨越多個邊界上下文進行了優化。雖然經常討論與 CQRS 相關的單獨讀寫儲存,但這並不是 CQRS 本身。CQRS 只是命令和查詢的第一部分。

術語

Command

該命令是一個簡單的資料結構,表示執行某些操作的請求。

Command Bus

完整原始碼:

  • github.com/ThreeDotsLabs/watermill/components/cqrs/command_bus.go
// ...
// CommandBus 將命令(commands)傳輸到命令處理程式(command handlers)。
type CommandBus struct {
// ...

Command Processor

完整原始碼:

  • github.com/ThreeDotsLabs/watermill/components/cqrs/command_processor.go
// ...
// CommandProcessor 決定哪個 CommandHandler 應該處理這個命令
received from the command bus.
type CommandProcessor struct {
// ...

Command Handler

完整原始碼:

  • github.com/ThreeDotsLabs/watermill/components/cqrs/command_processor.go
// ...
// CommandHandler 接收由 NewCommand 定義的命令,並使用 Handle 方法處理它。
// 如果使用 DDD, CommandHandler 可以修改並持久化聚合。
//
// 與 EvenHandler 相反,每個命令必須只有一個 CommandHandler。
//
// 在處理訊息期間使用 CommandHandler 的一個例項。
// 當同時傳送多個命令時,Handle 方法可以同時執行多次。
// 因此,Handle 方法必須是執行緒安全的!
type CommandHandler interface {
// ...

Event

該事件表示已經發生的事情。 事件是不可變的。

Event Bus

完整原始碼:

  • github.com/ThreeDotsLabs/watermill/components/cqrs/event_bus.go
// ...
// EventBus 將事件傳輸到事件處理程式。
type EventBus struct {
// ...

Event Processor

完整原始碼:

  • github.com/ThreeDotsLabs/watermill/components/cqrs/event_processor.go
// ...
// EventProcessor 確定哪個 EventHandler 應該處理從事件匯流排接收到的事件。
type EventProcessor struct {
// ...

Event Handler

完整原始碼:

  • github.com/ThreeDotsLabs/watermill/components/cqrs/event_processor.go
// ...
// EventHandler 接收由 NewEvent 定義的事件,並使用其 Handle 方法對其進行處理。
// 如果使用 DDD,CommandHandler 可以修改並保留聚合。
// 它還可以呼叫流程管理器、saga 或僅僅構建一個讀取模型。
// 與 CommandHandler 相比,每個 Event 可以具有多個 EventHandler。
//
// 在處理訊息時使用 EventHandler 的一個例項。
// 當同時傳遞多個事件時,Handle 方法可以同時執行多次。
// 因此,Handle 方法必須是執行緒安全的!
type EventHandler interface {
// ...

CQRS Facade

完整原始碼:

  • github.com/ThreeDotsLabs/watermill/components/cqrs/cqrs.go
// ...
// Facade 是用於建立 Command 和 Event buses 及 processors 的 facade。
// 建立它是為了在以標準方式使用 CQRS 時避免使用 boilerplate。
// 您還可以手動建立 buses 和 processors,並從 NewFacade 中獲得靈感。
type Facade struct {
// ...

Command and Event Marshaler

完整原始碼:

  • github.com/ThreeDotsLabs/watermill/components/cqrs/marshaler.go
// ...
// CommandEventMarshaler 將命令和事件 marshal 給 Watermill 的訊息,反之亦然。
// 該命令的有效載荷需要 marshal 至 []bytes。
type CommandEventMarshaler interface {
    // Marshal marshal 命令或事件給 Watermill 的訊息。
   Marshal(v interface{}) (*message.Message, error)

    // Unmarshal Unmarshal watermill的資訊給 v Command 或 Event。
   Unmarshal(msg *message.Message, v interface{}) (err error)

   // Name 返回命令或事件的名稱。
   // Name 用於確定接收到的命令或事件是我們想要處理的事件。
   Name(v interface{}) string

   // NameFromMessage 從 Watermill 的訊息(由 Marshal 生成)中返回命令或事件的名稱。
   // 
   //
   // 當我們將 Command 或 Event marshal 到 Watermill 的訊息中時,
   // 我們應該使用 NameFromMessage 而不是 Name 來避免不必要的 unmarshaling。
   NameFromMessage(msg *message.Message) string
}
// ...

用法

Example domain(領域模型示例)

作為示例,我們將使用一個簡單的 domain,它負責處理酒店的房間預訂。

我們將使用 Event Storming 表示法來展示這個 domain 的模型。

Legend:

  • blue(藍色)便利貼是命令
  • orange(橙色)便利貼是事件
  • green(綠色)便利貼是讀取模型,從事件非同步生成
  • violet(紫色)便利貼是策略,由事件觸發併產生命令
  • pink(粉色)便利貼是熱點(hot-spots);我們標記經常發生問題的地方

domain(領域模型)很簡單:

  • 客人可以預訂房間(book a room)。
  • 每當預訂房間時,我們都會為客人訂購啤酒(Whenever a room is booked, we order a beer)(因為我們愛客人)。
    • 我們知道有時候啤酒不夠(not enough beers)。
  • 我們根據預訂生成財務報告(financial report)。

Sending a command(傳送命令)

首先,我們需要模擬訪客的動作。

完整原始碼:

  • github.com/ThreeDotsLabs/watermill/_examples/basic/5-cqrs-protobuf/main.go
// ...
       bookRoomCmd := &BookRoom{
            RoomId:    fmt.Sprintf("%d", i),
            GuestName: "John",
            StartDate: startDate,
            EndDate:   endDate,
        }
        if err := commandBus.Send(context.Background(), bookRoomCmd); err != nil {
            panic(err)
        }
// ...

Command handler

BookRoomHandler 將處理我們的命令。

完整原始碼:

  • github.com/ThreeDotsLabs/watermill/_examples/basic/5-cqrs-protobuf/main.go
// ...
// BookRoomHandler 是一個命令處理程式,它處理 BookRoom 命令併發出 RoomBooked。
//
// 在 CQRS 中,一個命令只能由一個處理程式處理。
// 將具有此命令的另一個處理程式新增到命令處理器時,將返回錯誤。
type BookRoomHandler struct {
    eventBus *cqrs.EventBus
}

func (b BookRoomHandler) HandlerName() string {
    return "BookRoomHandler"
}

// NewCommand 返回該 handle 應該處理的命令型別。它必須是一個指標。
func (b BookRoomHandler) NewCommand() interface{} {
    return &BookRoom{}
}

func (b BookRoomHandler) Handle(ctx context.Context, c interface{}) error {
    // c 始終是 `NewCommand` 返回的型別,因此強制轉換始終是安全的
   cmd := c.(*BookRoom)

   // 一些隨機的價格,在生產中你可能會用更明智的方式計算
   price := (rand.Int63n(40) + 1) * 10

    log.Printf(
        "Booked %s for %s from %s to %s",
        cmd.RoomId,
        cmd.GuestName,
        time.Unix(cmd.StartDate.Seconds, int64(cmd.StartDate.Nanos)),
        time.Unix(cmd.EndDate.Seconds, int64(cmd.EndDate.Nanos)),
    )

   // RoomBooked 將由 OrderBeerOnRoomBooked 事件處理程式處理,
   // 將來,RoomBooked 可能由多個事件處理程式處理
   if err := b.eventBus.Publish(ctx, &RoomBooked{
        ReservationId: watermill.NewUUID(),
        RoomId:        cmd.RoomId,
        GuestName:     cmd.GuestName,
        Price:         price,
        StartDate:     cmd.StartDate,
        EndDate:       cmd.EndDate,
    }); err != nil {
        return err
    }

    return nil
}

// OrderBeerOnRoomBooked 是事件處理程式,它處理 RoomBooked 事件併發出 OrderBeer 命令。
// ...

Event handler

如前所述,我們希望每次預訂房間時都點一杯啤酒(“每次預訂房間時”便箋)。我們通過使 OrderBeer 命令來實現。

完整原始碼:

  • github.com/ThreeDotsLabs/watermill/_examples/basic/5-cqrs-protobuf/main.go
// ...
// OrderBeerOnRoomBooked 是事件處理程式,它處理 RoomBooked 事件併發出 OrderBeer 命令。
type OrderBeerOnRoomBooked struct {
    commandBus *cqrs.CommandBus
}

func (o OrderBeerOnRoomBooked) HandlerName() string {
   // 此名稱傳遞給 EventsSubscriberConstructor 並用於生成佇列名稱
   return "OrderBeerOnRoomBooked"
}

func (OrderBeerOnRoomBooked) NewEvent() interface{} {
    return &RoomBooked{}
}

func (o OrderBeerOnRoomBooked) Handle(ctx context.Context, e interface{}) error {
    event := e.(*RoomBooked)

    orderBeerCmd := &OrderBeer{
        RoomId: event.RoomId,
        Count:  rand.Int63n(10) + 1,
    }

    return o.commandBus.Send(ctx, orderBeerCmd)
}

// OrderBeerHandler 是命令處理程式,它處理 OrderBeer 命令併發出 BeerOrdered。
// ...

OrderBeerHandler 與 BookRoomHandler 非常相似。唯一的區別是,當啤酒不夠時,它有時會返回一個錯誤,這將導致重新交付命令。您可以在示例原始碼中找到整個實現。

使用事件處理程式構建讀取模型

完整原始碼:

  • github.com/ThreeDotsLabs/watermill/_examples/basic/5-cqrs-protobuf/main.go
// ...
// BookingsFinancialReport 是一個讀取模型,用於計算我們可以從預訂中賺取多少錢。
// 與 OrderBeerOnRoomBooked 一樣,它偵聽 RoomBooked 事件。
//
// 此實現只是寫入記憶體。在生產中,您可能會使用一些永續性儲存。
type BookingsFinancialReport struct {
    handledBookings map[string]struct{}
    totalCharge     int64
    lock            sync.Mutex
}

func NewBookingsFinancialReport() *BookingsFinancialReport {
    return &BookingsFinancialReport{handledBookings: map[string]struct{}{}}
}

func (b BookingsFinancialReport) HandlerName() string {
   // 此名稱傳遞給 EventsSubscriberConstructor 並用於生成佇列名稱
   return "BookingsFinancialReport"
}

func (BookingsFinancialReport) NewEvent() interface{} {
    return &RoomBooked{}
}

func (b *BookingsFinancialReport) Handle(ctx context.Context, e interface{}) error {
   // Handle 可以被併發呼叫,因此它必須是執行緒安全的。
   b.lock.Lock()
    defer b.lock.Unlock()

    event := e.(*RoomBooked)

   // 當我們使用不提供一次精確交付語義的 Pub/Sub 時,我們需要對訊息進行重複資料刪除。
   // GoChannel Pub/Sub 提供了精確的一次交付,
   // 但是讓我們為其他 Pub/Sub 實現準備好這個示例。
   if _, ok := b.handledBookings[event.ReservationId]; ok {
        return nil
    }
    b.handledBookings[event.ReservationId] = struct{}{}

    b.totalCharge += event.Price

    fmt.Printf(">>> Already booked rooms for $%d\n", b.totalCharge)
    return nil
}

var amqpAddress = "amqp://guest:guest@rabbitmq:5672/"

func main() {
// ...

將其連線起來——CQRS facade

我們擁有構建 CQRS 應用程式的所有塊。 現在,我們需要使用某種膠水將其連線起來。

我們將使用最簡單的記憶體訊息傳遞基礎設施: GoChannel

在後臺,CQRS 正在使用 Watermill 的訊息路由器。 如果您不熟悉它,並且想了解它的工作原理,則應查閱《入門指南》。 它還將向您展示如何使用一些標準的訊息傳遞模式,例如 metrics,poison queue,throttling,correlation 以及每個訊息驅動的應用程式使用的其他工具。那些內建於 Watermill 中。

讓我們回到 CQRS。如您所知,CQRS 是由多個元件構建的,如命令(Command)或事件匯流排(Event buses)、處理程式(handlers)、處理器(processors)等。為了簡化所有這些構建塊的建立,我們建立了 cqrs.Facade,它建立所有這些。

完整原始碼:

  • github.com/ThreeDotsLabs/watermill/_examples/basic/5-cqrs-protobuf/main.go
// ...
func main() {
    logger := watermill.NewStdLogger(false, false)
    cqrsMarshaler := cqrs.ProtobufMarshaler{}

    // 您可以從此處使用任何 Pub/Sub 實現:https://watermill.io/docs/pub-sub-implementations/
   // 詳細的 RabbitMQ 實現: https://watermill.io/docs/pub-sub-implementations/#rabbitmq-amqp
   // 命令將被髮送到佇列,因為它們需要被使用一次。
   commandsAMQPConfig := amqp.NewDurableQueueConfig(amqpAddress)
    commandsPublisher, err := amqp.NewPublisher(commandsAMQPConfig, logger)
    if err != nil {
        panic(err)
    }
    commandsSubscriber, err := amqp.NewSubscriber(commandsAMQPConfig, logger)
    if err != nil {
        panic(err)
    }

   // 事件將被髮布到配置了 PubSu b的 Rabbit,因為它們可能被多個使用者使用。
   // (在這種情況下,BookingsFinancialReport 和 OrderBeerOnRoomBooked).
   eventsPublisher, err := amqp.NewPublisher(amqp.NewDurablePubSubConfig(amqpAddress, nil), logger)
    if err != nil {
        panic(err)
    }

    // CQRS 建立在訊息路由器上。詳細文件:https://watermill.io/docs/messages-router/
   router, err := message.NewRouter(message.RouterConfig{}, logger)
    if err != nil {
        panic(err)
    }

   // 簡單的中介軟體,可以從事件或命令處理程式中 recover panics。
   // 您可以在文件中找到有關路由器中介軟體的更多資訊:
   // https://watermill.io/docs/messages-router/#middleware
   //
   // 您可以在 message/router/middleware 中找到可用的中介軟體列表。
   router.AddMiddleware(middleware.Recoverer)

    // cqrs.Facade是命令和事件匯流排與處理器的 facade。
   // 您可以使用 facade,或者手動建立匯流排和處理器(您可以使用 cqrs.NewFacade 激發靈感)
   cqrsFacade, err := cqrs.NewFacade(cqrs.FacadeConfig{
        GenerateCommandsTopic: func(commandName string) string {
            // 我們正在使用RabbitMQ佇列配置,因此我們需要按命令型別指定主題 topic
           return commandName
        },
        CommandHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.CommandHandler {
            return []cqrs.CommandHandler{
                BookRoomHandler{eb},
                OrderBeerHandler{eb},
            }
        },
        CommandsPublisher: commandsPublisher,
        CommandsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) {
            // 我們可以重用訂閱者(subscriber),因為所有命令都有各自的主題(topics)
           return commandsSubscriber, nil
        },
        GenerateEventsTopic: func(eventName string) string {
            // 因為我們使用的是PubSub RabbitMQ配置,所以我們可以對所有事件使用一個主題(topic)
           return "events"

            // 我們還可以按事件型別使用主題(topic)
           // return eventName
       },
        EventHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.EventHandler {
            return []cqrs.EventHandler{
                OrderBeerOnRoomBooked{cb},
                NewBookingsFinancialReport(),
            }
        },
        EventsPublisher: eventsPublisher,
        EventsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) {
            config := amqp.NewDurablePubSubConfig(
                amqpAddress,
                amqp.GenerateQueueNameTopicNameWithSuffix(handlerName),
            )

            return amqp.NewSubscriber(config, logger)
        },
        Router:                router,
        CommandEventMarshaler: cqrsMarshaler,
        Logger:                logger,
    })
    if err != nil {
        panic(err)
    }

    // 每秒釋出 BookRoom 命令以模擬傳入流量
   go publishCommands(cqrsFacade.CommandBus())

    // 處理器(processors)是基於路由器(router)的,所以當路由器啟動時,處理器就會工作
   if err := router.Run(context.Background()); err != nil {
        panic(err)
    }
}
// ...

就這樣。 我們有一個正在執行的 CQRS 應用程式。

我是為少。微信:uuhells123。公眾號:黑客下午茶。

謝謝點贊支援???!

相關文章