通過重構 Go 項目來介紹基本的 CQRS
您很可能接觸過其中某種項目:
-
有一個難以理解和更改的、無法維護的巨大數據模型
-
並行開發新的功能時受到重重限制
-
項目很難進行優化擴展
但通常往往是三件壞事一起發生的,有這些問題的服務並不少見。
要解決這些問題,首先想到的是什麼?讓我們把它拆分成更多的微服務!
遺憾的是,如果不進行適當的研究和規劃,盲目重構後的情況可能會比之前更糟:
-
業務邏輯和流程可能變得更加難以理解 -- 複雜的邏輯如果集中在一處,往往更容易理解
-
分佈式事務 -- 有時某些東西放在一起是有原因的;在一個數據庫中進行大事務處理比在多個服務中進行分佈式事務處理要快得多,也不那麼複雜
-
在進行新的更改時可能需要額外的協調,如果其中一個服務屬於另一個團隊的話
先說好,我不是微服務的敵人。我只是反對盲目地應用微服務,因爲這樣會帶來不必要的複雜性和混亂,而不是讓我們的生活更輕鬆。
另一種方法是將 CQRS(Command Query Responsibility Segregation) 與之前介紹的 Clean Architecture 和 DDD 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) 的概念,並將應用服務分成兩種不同的類型:命令處理程序和查詢處理程序。
命令和查詢 (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 成爲團隊中構建應用程序的標準方法,它還能加快不瞭解該服務的團隊成員學習該服務的速度。您只需要一個可用命令和查詢的列表,並快速瞭解其執行方式。不需要在代碼中隨意跳轉。
這就是我團隊中最複雜的一個服務的情況:
您可能會問——難道不應該將它們拆分到多個服務裏嗎?實際上,這是一個糟糕的想法,這裏的很多操作都需要事務。如果將其分割成不同的服務,就會涉及到一些分佈式事務(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) (d []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 層中找到DatesFirestoreRepository
的AllTrainings
讀取模型實現和測試用例。
如果您之前閱讀過 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