通過重構 Go 項目來介紹基本的 CQRS

您很可能接觸過其中某種項目:

但通常往往是三件壞事一起發生的,有這些問題的服務並不少見。

要解決這些問題,首先想到的是什麼?讓我們把它拆分成更多的微服務!

遺憾的是,如果不進行適當的研究和規劃,盲目重構後的情況可能會比之前更糟:

微服務是有用的,但它們不能解決你所有的問題...

先說好,我不是微服務的敵人。我只是反對盲目地應用微服務,因爲這樣會帶來不必要的複雜性和混亂,而不是讓我們的生活更輕鬆。

另一種方法是將 CQRS(Command Query Responsibility Segregation) 與之前介紹的 Clean ArchitectureDDD Lite 結合使用。它能以更簡單的方式解決上述問題

CQRS 不是一項複雜的技術嗎?

CQRS 難道不是 C#/Java/über 這些企業模式之一嗎? 它們很難實現,並且會在代碼中造成很大的混亂?許多書籍、演示文稿和文章都將 CQRS 描述爲一種非常複雜的模式。但事實並非如此。

實際上,CQRS 是一種非常簡單的模式,它可以很輕易地使用更復雜的技術進行擴展,如事件驅動 (event-driven) 架構、事件源 (event-sourcing) 或混合持久化(polyglot persistence)。 但也並不是一定要使用這些技術,即使不應用任何額外的模式,CQRS 也能提供更好的解耦和更易於理解的代碼結構。

何時不在 Go 中使用 CQRS?如何充分發揮 CQRS 的優點?您可以在今天的文章中瞭解到這一切😉

像往常一樣,我將通過重構 Wild Workouts 應用程序 [1] 來實現這一點。

如何在 Go 中實現基本的 CQRS

CQRS(Command Query Responsibility Segregation) 最初是由 Greg Young[2] 描述的。它有一個簡單的假設:與其爲讀取和寫入建立一個大模型,不如建立兩個獨立的模型, 一個用於寫,一個用於讀。它還引入了命令 (command) 和查詢 (query) 的概念,並將應用服務分成兩種不同的類型:命令處理程序和查詢處理程序。

標準的,非 CQRS 架構 CQRS 架構

命令和查詢 (Command vs Query)

簡單來說就是:查詢不應修改任何內容,只需返回數據。命令則恰恰相反:它應該在系統內進行修改,但不返回任何數據。 因此,我們可以更有效地緩存查詢,並降低命令的複雜性。

這聽起來像是一個限制,但實際上並非如此。我們執行的大多數操作都是讀取或寫入,極少數情況下,兩者兼而有之。

與大多數規則一樣,只要你完全理解爲什麼要引入這些規則以及你需要做出哪些權衡,那麼打破這些規則也是可以的。實際上,你很少需要打破這些規則,我將在文章最後舉例說明。

在實踐中最基本的實現是什麼樣的呢?在上一篇文章中,Miłosz 介紹了training服務, 讓我們先把這項服務分成單獨的 "命令處理程序" 和 "查詢處理程序"。

ApproveTrainingReschedule 命令

以前,重新安排培訓是由TrainingService批准的。

// 完整源碼 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/commit/8d9274811559399461aa9f6bf3829316b8ddfb63#diff-ddf06fa26668dd91e829c7bfbd68feaeL127
- func (c TrainingService) ApproveTrainingReschedule(ctx context.Context, user auth.User, trainingUUID string) error {
-  return c.repo.ApproveTrainingReschedule(ctx, trainingUUID, func(training Training) (Training, error) {
-     if training.ProposedTime == nil {
-        return Training{}, errors.New("training has no proposed time")
-     }
-     if training.MoveProposedBy == nil {
-        return Training{}, errors.New("training has no MoveProposedBy")
-     }
-     if *training.MoveProposedBy == "trainer" && training.UserUUID != user.UUID {
-        return Training{}, errors.Errorf("user '%s' cannot approve reschedule of user '%s'", user.UUID, training.UserUUID)
-     }
-     if *training.MoveProposedBy == user.Role {
-        return Training{}, errors.New("reschedule cannot be accepted by requesting person")
-     }
-
-     training.Time = *training.ProposedTime
-     training.ProposedTime = nil
-
-     return training, nil
-  })}

那裏面有一些神奇的驗證,現在它們都是在 domain 層中完成的。我還發現,我們忘了調用外部trainer服務來移動培訓。讓我們用 CQRS 方法重構一下吧。

由於 CQRS 與遵循領域驅動設計(Domain-Driven Design)的應用程序配合得最好,因此在重構 CQRS 的過程中,我也將現有模型重構爲 DDD Lite。DDD Lite 在之前的文章中有更詳細的介紹。

我們從定義 command 結構體開始,該結構體提供了執行命令所需的所有數據。如果命令只有一個字段,可以跳過結構,直接將其作爲參數傳遞。

最好在命令中使用由 domain 定義的類型,例如 training.User。這樣我們就不需要進行任何轉換,而且還能確保類型安全, 可以避免很多字符串參數傳遞順序錯誤的問題

// 完整源碼 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainings/app/command/approve_training_reschedule.go#L10
package command

// ...

type ApproveTrainingReschedule struct {
   TrainingUUID string
   User         training.User
}

第二部分是命令處理程序 (command handler),它知道如何執行命令:

// 完整源碼 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainings/app/command/approve_training_reschedule.go#L39
package command

// ...

type ApproveTrainingRescheduleHandler struct {
   repo           training.Repository
   userService    UserService
   trainerService TrainerService
}

// ...

func (h ApproveTrainingRescheduleHandler) Handle(ctx context.Context, cmd ApproveTrainingReschedule) (err error) {
   defer func() {
      logs.LogCommandExecution("ApproveTrainingReschedule", cmd, err)
   }()

   return h.repo.UpdateTraining(
      ctx,
      cmd.TrainingUUID,
      cmd.User,
      func(ctx context.Context, tr *training.Training) (*training.Training, error) {
         originalTrainingTime := tr.Time()

         if err := tr.ApproveReschedule(cmd.User.Type()); err != nil {
            return nil, err
         }

         err := h.trainerService.MoveTraining(ctx, tr.Time(), originalTrainingTime)
         if err != nil {
            return nil, err
         }

         return tr, nil
      },
   )
}

現在流程更容易理解了。您可以清楚地看到,我們批准了重新安排*training.Training的操作 (tr.ApproveReschedule),如果成功,我們將調用外部trainer服務。由於使用了 DDD Lite 文章中描述的技術,命令處理程序無需知道何時可以執行此操作。這一切都由我們的 domain 層處理。

這種清晰的流程在更復雜的命令中更加明顯。幸運的是,當前的實現非常簡單。我們的目標不是創建複雜的軟件,而是創建簡單的軟件。

如果 CQRS 成爲團隊中構建應用程序的標準方法,它還能加快不瞭解該服務的團隊成員學習該服務的速度。您只需要一個可用命令和查詢的列表,並快速瞭解其執行方式。不需要在代碼中隨意跳轉。

這就是我團隊中最複雜的一個服務的情況:

Karhoo 中一個服務的應用程序層示例。

您可能會問——難道不應該將它們拆分到多個服務裏嗎?實際上,這是一個糟糕的想法,這裏的很多操作都需要事務。如果將其分割成不同的服務,就會涉及到一些分佈式事務(Sagas)。這將使流程變得更加複雜,更難維護和調試,因此它不是最好的選擇。

在這裏,複雜性可以很好地橫向擴展。我們很快就會更深入地介紹拆分微服務這個極其重要的話題。我之前提到過,我們在 Wild Workouts 中故意把它弄得一團糟。

但讓我們回到我們的命令。是時候在 HTTP 中使用它了。它可以通過注入的 Application 結構在 HttpServer 中使用,該結構包含我們所有的命令和查詢處理程序。

// 完整源碼 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainings/app/app.go#L8
package app

import (
   "github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/app/command"
   "github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/app/query"
)

type Application struct {
   Commands Commands
   Queries  Queries
}

type Commands struct {
   ApproveTrainingReschedule command.ApproveTrainingRescheduleHandler
   CancelTraining            command.CancelTrainingHandler
   // ...
// 完整源碼 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainings/ports/http.go#L160
type HttpServer struct {
   app app.Application
}

// ...

func (h HttpServer) ApproveRescheduleTraining(w http.ResponseWriter, r *http.Request) {
   trainingUUID := chi.URLParam(r, "trainingUUID")

   user, err := newDomainUserFromAuthUser(r.Context())
   if err != nil {
      httperr.RespondWithSlugError(err, w, r)
      return
   }

   err = h.app.Commands.ApproveTrainingReschedule.Handle(r.Context(), command.ApproveTrainingReschedule{
      User:         user,
      TrainingUUID: trainingUUID,
   })
   if err != nil {
      httperr.RespondWithSlugError(err, w, r)
      return
   }
}

命令處理程序可以通過這種方式從任何入口調用:HTTP、gRPC 或 CLI。它對於執行遷移和加載固定數據 [3] 也很有用(我們在 Wild Workouts 中已經這樣做了)。

RequestTrainingReschedule 命令

有些命令處理程序可能非常簡單:

// 完整源碼 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainings/app/command/request_training_reschedule.go#L32
func (h RequestTrainingRescheduleHandler) Handle(ctx context.Context, cmd RequestTrainingReschedule) (err error) {
    defer func() {
        logs.LogCommandExecution("RequestTrainingReschedule", cmd, err)
    }()

    return h.repo.UpdateTraining(
        ctx,
        cmd.TrainingUUID,
        cmd.User,
        func(ctx context.Context, tr *training.Training) (*training.Training, error) {
            if err := tr.UpdateNotes(cmd.NewNotes); err != nil {
                return nil, err
            }

            tr.ProposeReschedule(cmd.NewTime, cmd.User.Type())

            return tr, nil
        },
    )
}

在這種比較簡單的情況下,跳過這一層可能會節省很多模板代碼。的確如此,但您需要記住,編寫代碼總是比維護要簡單得多。添加這個簡單的類型只需要 3 分鐘的工作。日後閱讀和擴展這些代碼的人會感謝你的努力。

AvailableHoursHandler 查詢

應用層中的查詢通常非常枯燥。我們需要編寫一個讀取模型接口(AvailableHoursReadModel)來定義如何查詢數據。

命令和查詢也是所有交叉問題(如日誌和監控)的一個很好的解決方案。有了這些,我們就能確保無論從 HTTP 還是 gRPC 入口調用,都能以相同的方式衡量性能。

// 完整鏈接 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainer/app/query/available_hours.go#L11
package query

// ...

type AvailableHoursHandler struct {
    readModel AvailableHoursReadModel
}

type AvailableHoursReadModel interface {
    AvailableHours(ctx context.Context, from time.Time, to time.Time) ([]Date, error)
}

// ...

type AvailableHours struct {
    From time.Time
    To   time.Time
}

func (h AvailableHoursHandler) Handle(ctx context.Context, query AvailableHours) ([]Date, err error) {
    start := time.Now()
    defer func() {
        logrus.
            WithError(err).
            WithField("duration", time.Since(start)).
            Debug("AvailableHoursHandler executed")
    }()

    if query.From.After(query.To) {
        return nil, errors.NewIncorrectInputError("date-from-after-date-to""Date from after date to")
    }

    return h.readModel.AvailableHours(ctx, query.From, query.To)
}

我們還需要定義查詢返回的數據類型,在本例中是query.Date

// 完整源碼 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainer/app/query/types.go
package query

import (
    "time"
)

type Date struct {
    Date         time.Time
    HasFreeHours bool
    Hours        []Hour
}

type Hour struct {
    Available            bool
    HasTrainingScheduled bool
    Hour                 time.Time
}

我們的查詢模型比 domain 中的hour.Hour類型更復雜。這是很常見的情況。通常,這是由網站的用戶界面驅動的,在後臺生成最優的響應會更有效率。

隨着應用程序的增長,domain 模型和查詢模型之間的差異可能會越來越大。通過分離和解耦,我們可以獨立地對這兩個模型進行更改,這對於保持長期快速開發至關重要。

// 完整源碼 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainer/domain/hour/hour.go#L11
package hour

type Hour struct {
    hour time.Time

    availability Availability
}

AvailableHoursReadModel 從哪裏獲取數據呢?對於應用層來說,這完全是透明的,並不相關。這樣,我們就可以在未來添加性能優化功能,而這只是應用程序的一部分。

如果您不熟悉 ports 和 adapters 的概念,我強烈建議您閱讀我們關於 Go 中的 Clean Architecture 的文章

在實踐中,當前實現從我們的寫模型數據庫中獲取數據。您可以在 adapters 層中找到DatesFirestoreRepositoryAllTrainings讀取模型實現和測試用例。

當前我們查詢的數據是從存儲寫模型的同一數據庫中查詢的

如果您之前閱讀過 CQRS,通常建議使用由事件構建的單獨數據庫進行查詢。這可能是一個好主意,但在非常具體的情況下,我將在 “未來優化” 部分對此進行描述。在我們的例子中,只從寫模型數據庫中獲取數據就足夠了。

HourAvailabilityHandler 查詢

我們不需要爲每個查詢添加讀模型接口,使用 domain repository 並選擇我們需要的數據也是可以的。

// 完整源碼: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainer/app/query/hour_availability.go#L22
import (
   "context"
   "time"

   "github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainer/domain/hour"
)

type HourAvailabilityHandler struct {
   hourRepo hour.Repository
}

func (h HourAvailabilityHandler) Handle(ctx context.Context, time time.Time) (bool, error) {
   hour, err := h.hourRepo.GetHour(ctx, time)
   if err != nil {
      return false, err
   }

   return hour.IsAvailable(), nil
}

命名

命名是軟件開發中最具挑戰性也是最重要的部分之一。在《DDD Lite 簡介》一文中,我介紹了一條規則,即應堅持使用盡可能接近非技術人員(通常稱爲 "業務人員")交談方式的語言,這同樣適用於爲命令和查詢命名。

應避免使用 "Create training(創建培訓)" 或 "Delete training(刪除培訓)" 這樣的名稱。
這不是業務人員和用戶理解你領域的方式。您應該使用 "Schedule training(安排培訓)" 和 "Cancel training(取消培訓)" 來命名。

培訓服務的所有命令和查詢

我們將在一篇關於通用語言 (Ubiquitous Language) 的文章中更深入地討論這個話題。在那之前,只需要去找你的業務人員,聽聽他們是如何稱呼這些操作的。如果您的任何命令名真的需要以 “創建 / 刪除 / 更新” 開頭,請三思。

未來的優化

基本的 CQRS 有一些優點,如更好地組織代碼、解耦和簡化模型。還有一個更重要的優勢,那就是可以用更強大、更復雜的模式來擴展 CQRS

異步命令

有些命令天生就很慢,它們可能正在進行一些外部調用或一些繁重的計算。在這種情況下,我們可以引入異步命令總線 (Asynchronous Command Bus),讓它在後臺執行命令。

使用異步命令有一些額外的基礎設施要求,比如有一個隊列或 pub/sub。幸運的是,Watermill 庫 [4] 可以幫助您在 Go 中處理這個問題。您可以在 Watermill CQRS 文檔 [5] 中找到更多詳細信息。(順便說一句,我們也是 Watermill 的作者😉 如果有什麼不清楚的地方,請隨時聯繫我們!)

用於查詢的單獨數據庫

我們當前的實現使用同一個數據庫進行讀取(查詢)和寫入(命令)。如果我們需要提供更復雜的查詢或更快速的查詢,我們可以使用混合持久化 (polyglot persistence) 技術。其想法是在另一個數據庫中以更優的格式複製查詢的數據。例如,我們可以使用 ElasticSearch 對一些可以更容易地搜索和過濾的數據進行索引。

這種情況下,數據同步可以通過事件來完成。這種方法最重要的是最終一致性。你可以問問自己,這種方案在你的系統中是否是一個可以接受的折中方案。如果您不確定,您可以在沒有混合持久性的情況下開始,以後再進行遷移,推遲做出這樣的關鍵決定是件好事。

Watermill CQRS 文檔 [6] 中有一個示例實現。也許隨着時間的推移,我們也會在 Wild Workouts 中介紹它,誰知道呢?

原文鏈接 https://threedots.tech/post/basic-cqrs-in-go/

參考資料

[1]

wild-workouts-go-ddd-exampl: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/

[2]

Greg Young: https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf

[3]

loading fixtures: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainer/fixtures.go#L62

[4]

watermill 庫: https://github.com/ThreeDotsLabs/watermill

[5]

Watermill CQRS 文檔: https://watermill.io/docs/cqrs/?utm_source=introducing-cqrs-art

[6]

watermill 示例實現: https://watermill.io/docs/cqrs/?utm_source=cqrs-art#building-a-read-model-with-the-event-handler

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/HopsvrJUYqS-Xu0tKqh5Mg