摘要:命令模式可將請求轉換為一個包含與請求相關的所有資訊的物件, 它能將請求引數化、延遲執行、實現 Undo / Redo 操作等。
本文分享自華為雲社群《【Go實現】實踐GoF的23種設計模式:命令模式》,作者:元閏子。
簡介
現在的軟體系統往往是分層設計。在業務層執行一次請求時,我們很清楚請求的上下文,包括,請求是做什麼的、引數有哪些、請求的接收者是誰、返回值是怎樣的。相反,基礎設施層並不需要完全清楚業務上下文,它只需知道請求的接收者是誰即可,否則就耦合過深了。
因此,我們需要對請求進行抽象,將上下文資訊封裝到請求物件裡,這其實就是命令模式,而該請求物件就是 Command。
GoF 對命令模式(Command Pattern)的定義如下:
Encapsulate a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations.
也即,命令模式可將請求轉換為一個包含與請求相關的所有資訊的物件, 它能將請求引數化、延遲執行、實現 Undo / Redo 操作等。
上述的請求是廣義上的概念,可以是網路請求,也可以是函式呼叫,更通用地,指一個動作。
命令模式主要包含 3 種角色:
- Command,命令,是對請求的抽象。具體的命令實現時,通常會引用 Receiver。
- Invoker,請求的發起發起方,它並不清楚 Command 和 Receiver 的實現細節,只管呼叫命令的介面。
- Receiver,請求的接收方。
命令模式,一方面,能夠使得 Invoker 與 Receiver 消除彼此之間的耦合,讓物件之間的呼叫關係更加靈活;另一方面,能夠很方便地實現延遲執行、Undo、Redo 等操作,因此被廣泛應用在軟體設計中。
UML 結構
場景上下文
在 簡單的分散式應用系統(示例程式碼工程)中,db 模組用來儲存服務註冊資訊和系統監控資料。其中,服務註冊資訊拆成了 profiles 和 regions 兩個表,在服務發現的業務邏輯中,通常需要同時操作兩個表,為了避免兩個表資料不一致的問題,db 模組需要提供事務功能:
事務的核心功能之一是,當其中某個語句執行失敗時,之前已執行成功的語句能夠回滾,而使用命令模式能夠很方便地實現該功能。
程式碼實現
// demo/db/transaction.go package db // Command 執行資料庫操作的命令介面 // 關鍵點1: 定義命令抽象介面 type Command interface { // 關鍵點2: 命令抽象介面中宣告執行命令的方法 Exec() error // Exec 執行insert、update、delete命令 // 關鍵點3: 如果有撤銷功能,則需要定義Undo方法 Undo() // Undo 回滾命令 setDb(db Db) // SetDb 設定關聯的資料庫 } // Transaction Db事務實現,事務介面的呼叫順序為begin -> exec -> exec > ... -> commit // 關鍵點4: 定義Invoker物件 type Transaction struct { name string db Db // 關鍵點5: Invoker物件持有Command的引用 cmds []Command } // Begin 開啟一個事務 func (t *Transaction) Begin() { t.cmds = make([]Command, 0) } // Exec 在事務中執行命令,先快取到cmds佇列中,等commit時再執行 func (t *Transaction) Exec(cmd Command) error { if t.cmds == nil { return ErrTransactionNotBegin } cmd.setDb(t.db) t.cmds = append(t.cmds, cmd) return nil } // Commit 提交事務,執行佇列中的命令,如果有命令失敗,則回滾後返回錯誤 // 關鍵點6: 為Invoker物件定義Call方法,在方法內呼叫Command的執行方法Exec func (t *Transaction) Commit() error { history := &cmdHistory{history: make([]Command, 0, len(t.cmds))} for _, cmd := range t.cmds { if err := cmd.Exec(); err != nil { history.rollback() return err } history.add(cmd) } return nil } // cmdHistory 命令執行歷史 type cmdHistory struct { history []Command } func (c *cmdHistory) add(cmd Command) { c.history = append(c.history, cmd) } // 關鍵點7: 在回滾方法中,呼叫已執行命令的Undo方法 func (c *cmdHistory) rollback() { for i := len(c.history) - 1; i >= 0; i-- { c.history[i].Undo() } } // InsertCmd 插入命令 // 關鍵點8: 定義具體的命令類,實現Command介面 type InsertCmd struct { // 關鍵點9: 命令通常持有接收者的引用,以便在執行方法中與接收者互動 db Db tableName string primaryKey interface{} newRecord interface{} } // 關鍵點10: 命令物件執行方法中,呼叫Receiver的Action方法,這裡的Receiver為db物件,Action方法為Insert方法 func (i *InsertCmd) Exec() error { return i.db.Insert(i.tableName, i.primaryKey, i.newRecord) } func (i *InsertCmd) Undo() { i.db.Delete(i.tableName, i.primaryKey) } func (i *InsertCmd) setDb(db Db) { i.db = db } // UpdateCmd 更新命令 type UpdateCmd struct {...} // DeleteCmd 刪除命令 type DeleteCmd struct {...}
客戶端可以這麼使用:
func client() { transaction := db.CreateTransaction("register" + profile.Id) transaction.Begin() rcmd := db.NewUpdateCmd(regionTable).WithPrimaryKey(profile.Region.Id).WithRecord(profile.Region) transaction.Exec(rcmd) pcmd := db.NewUpdateCmd(profileTable).WithPrimaryKey(profile.Id).WithRecord(profile.ToTableRecord()) transaction.Exec(pcmd) if err := transaction.Commit(); err != nil { return ... } return ... }
總結實現命令模式的幾個關鍵點:
- 定義命令抽象介面,本例子中為 Command 介面。
- 在命令抽象介面中宣告執行命令的方法,本例子中為 Exec 方法。
- 如果要實現撤銷功能,還需要為命令物件定義 Undo 方法,在操作回滾時呼叫。
- 定義 Invoker 物件,本例子中為 Transaction 物件。
- 在 Invoker 物件持有 Command 的引用,本例子為 Command 的切片 cmds。
- 為 Invoker 物件定義 Call 方法,用於執行具體的命令,在方法內呼叫 Command 的執行方法 ,本例子中為 Transaction.Commit 方法。
- 如果要實現撤銷功能,還要在回滾方法中,呼叫已執行命令的 Undo 方法,本例子中為 cmdHistory.rollback 方法。
- 定義具體的命令類,實現 Command 介面,本例子中為 InsertCmd、UpdateCmd、DeleteCmd。
- 命令通常持有接收者的引用,以便在執行方法中與接收者互動。本例子中,Receiver 為 Db 物件。
- 最後,在命令物件執行方法中,呼叫 Receiver 的 Action 方法,本例子中, Receiver 的 Action 方法為 db.Insert 方法。
值得注意的是,本例子中 Transaction 物件在 Transaction.Exec 方法中只是將 Command 儲存在佇列中,只有當呼叫 Transaction.Commit 方法時才延遲執行相應的命令。
擴充套件
os/exec 中的命令模式
Go 標準庫的 os/exec 包也用到了命令模式。
package main import ( "os/exec" ) // 對應命令模式中的Invoker func main() { cmd := exec.Command("sleep", "1") err := cmd.Run() }
在上述例子中,我們透過 exec.Command 方法將一個 shell 命令轉換成一個命令物件 exec.Cmd,其中的 Cmd.Run() 方法即是命令執行方法;而 main() 函式,對應到命令模式中的 Invoker;Receiver 則是作業系統執行 shell 命令的具體程式,從 exec.Cmd 的原始碼中可以看到:
// src/os/exec/exec.go package exec // 對應命令模式中的Command type Cmd struct { ... // 對應命令模式中的Receiver Process *os.Process ... } // 對應命令模式中Command的執行方法 func (c *Cmd) Run() error { if err := c.Start(); err != nil { return err } return c.Wait() } func (c *Cmd) Start() error { ... // Command與Receiver的互動 c.Process, err = os.StartProcess(c.Path, c.argv(), &os.ProcAttr{...}) ... }
CQRS 架構
CQRS 架構,全稱為 Command Query Responsibility Segregation,命令查詢職責隔離架構。CQRS 架構是微服務架構模式中的一種,它利用事件(命令)來維護從多個服務複製資料的只讀檢視,透過讀寫分離思想,提升微服務架構下查詢的效能。
CQRS 架構可分為 命令端 和 查詢端,其中命令端負責資料的更新;查詢端負責資料的查詢。命令端的寫資料庫在資料更新時,會向查詢端的只讀資料庫傳送一個同步資料的事件,保證資料的最終一致性。
其中的命令端,就使用到了命令模式的思想,將資料更新請求封裝成命令,非同步更新到寫資料庫中。
典型應用場景
- 事務模式。事務模式下往往需要 Undo 操作,使用命令模式實現起來很方便。
- 遠端執行。Go 標準庫下的 exec.Cmd、http.Client 都屬於該型別,將請求封裝成命令來執行。
- CQRS 架構。微服務架構模式中的一種,透過命令模式來實現資料的非同步更新。
- 延遲執行。當你希望一個操作能夠延遲執行時,通常會將它封裝成命令,然後放到一個佇列中。
優缺點
優點
- 符合單一職責原則。在命令模式下,每個命令都是職責單一、松耦合的;當然也可以透過組合的方式,將多個簡單的命令組合成一個負責的命令。
- 可以很方便地實現操作的延遲執行、回滾、重做等。
- 在分散式架構下,命令模式能夠方便地實現非同步的資料更新、方法呼叫等,提升效能。
缺點
- 命令模式下,呼叫往往是非同步的,而非同步會導致系統變得複雜,問題出現時不好定位解決。
- 隨著業務越來越複雜,命令物件也會增多,程式碼會變得更難維護。
與其他模式的關聯
在實現 Undo/Redo 操作時,你通常需要同時使用 命令模式 和 備忘錄模式。
另外,命令模式 也常常和觀察者模式 一起出現,比如在 CQRS 架構中,當命令端更新資料庫後,寫資料庫就會透過事件將資料同步到讀資料庫上,這裡就用到了觀察者模式。
文章配圖
可以在 用Keynote畫出手繪風格的配圖 中找到文章的繪圖方法。
參考
[1] 【Go實現】實踐GoF的23種設計模式:SOLID原則, 元閏子
[2] 【Go實現】實踐GoF的23種設計模式:觀察者模式, 元閏子
[3] Design Patterns, Chapter 5. Behavioral Patterns, GoF
[4] 命令模式, refactoringguru.cn
[5] The command pattern in Go, rolandjitsu
[6] CQRS 模式, microsoft azure
[7] CQRS Design Pattern in Microservices Architectures, Mehmet Ozkaya