來自亞馬遜的高級工程師 James Hood 以簡單明了的例子說明了為什么要用 DDD 替代 CRUD 來設計 REST API。他提到“DDD 與 REST API 近乎天然地合拍,因為 REST 的資源可以很好地與 DDD 的實體映射起來”。
REST 以資源為中心,這些資源以 URI 的形式呈現。在調用 HTTP 時,通過指定一個 HTTP 動詞和一個資源 URI 對某個特定的資源進行操作。大部分 REST 框架都提供了生成器,你只要指定一個資源的名字,框架就會為你生成腳手架(scaffold)。
不過,這些生成器默認使用的是 CRUD 模型(Create、Read、Update、Delete),它們把資源看成是一系列屬性的集合,使用 JSON 或與特定語言相關的數據對象來表示資源,并生成用于對資源進行創建、讀取、更新和刪除操作的方法。
雖然這給開發者帶來了便利,但我覺得這樣是有問題的。我不喜歡 CRUD 這樣的說法,尤其不喜歡當中的 U。
問題:CRUD 中的 U
一般的更新操作允許客戶端更新資源的任何一個字段,并使用新版本覆蓋已有的版本。但如果你允許客戶端這么做,那么你的服務 API 就失去了應有的價值。
服務層的一個關鍵價值在于為底層的數據增加業務約束,因此,資源最終都需要帶上業務約束。
那么,難道我們就不能給更新操作增加業務約束嗎?讓我們以最簡單的銀行賬戶為例。首先,不能讓客戶通過調用 API 來隨意更新他們的賬戶余額。另外,賬戶或許需要最小余額的限制。
你在更新操作里做了一些檢查,賬戶余額的變動必須發生在一個指定的范圍內。那么這樣問題就解決了嗎?當然沒有。任何一次余額的調整都需要與某種事務相對應,不是嗎?是存入、取出,還是轉賬?如果客戶要更改賬戶該怎么辦?這樣做是被允許的嗎?這樣做會不會破壞與其他數據之間的關系?
不難看出,你的更新操作很快會讓這一切變得像意大利面條一樣混亂不堪。我曾經看著一些團隊走上了這條不歸路,他們試圖從更新的字段里去推測客戶的意圖,結果代碼變得像團亂麻。
解決方法:DDD
那么該如何解決這個問題,有其他更好的方案嗎?我個人更喜歡基于領域驅動設計(DDD)來設計 API。DDD 的基本思想是說,軟件的建模應該發生在真實世界的問題得到解決之后。
DDD 使用實體(Entity)和聚合(Aggregate)來描述業務對象,還定義了服務(Service)、值對象(Value Object)和倉庫(Repository)等術語,用以解決業務領域或 DDD 邊界上下文問題。
DDD 不一定非要與 REST 綁定在一起,不過我發現 DDD 與 REST API 近乎天然地合拍,因為 REST 的資源可以很好地與 DDD 的實體映射起來。
那么這意味著什么呢?這意味著,你的 API 應該要以 領域對象 以及這些對象所提供的 業務操作為中心。業務操作是對常規更新操作最好的替代品。我們繼續以之前的銀行賬戶為例。
對于銀行的 API 來說,賬戶就是一個領域對象(DDD 里的實體)。這次我們不再使用 CRUD 來為賬戶建模,而是為賬戶定義一組業務操作。以下是一系列寫入操作:
- 開戶(Open)——新開一個賬戶。
- 銷戶(Close)——注銷一個已有的賬戶。
- 取出(Debit)——從賬戶里扣掉一些錢。
- 存入(Credit)——往賬戶里存入一些錢。
這些操作都帶有一定的 業務約束。例如,往一個已經注銷的賬戶里存錢是不被允許的,而在取錢的時候要強制檢查最小余額。至于讀取操作,我們可以為客戶提供一些有用的查詢:
- 加載——通過賬戶 ID 加載相應的賬戶信息。
- 交易歷史——列出賬戶的交易歷史。
- 客戶的賬戶列表——列出指定客戶的所有賬戶。
在定義好業務操作之后,就可以將它們與 REST API 映射起來:
- POST /account ——新開一個賬戶。
- PUT /account//close ——注銷一個已有的賬戶。
- PUT /account//debit ——從賬戶里扣掉一些錢。
- PUT /account//credit ——往賬戶里存入一些錢。
- GET /account/——通過賬戶 ID 加載相應的賬戶信息。
- GET /account//transactions ——列出賬戶的交易歷史。
- GET /accounts/query/customerId/——列出指定客戶的所有賬戶。
這些看起來與一般的 CRUD API 非常不一樣,關鍵在于這些操作具有良好的定義。不管對于 服務提供方 還是 客戶端 來說,這樣的體驗都更好。
服務提供方不再需要根據更新字段來推測業務操作的意圖,業務操作清晰明了,這樣的代碼更簡單,也更容易維護。
而對于客戶端來說,它們能執行或不能執行哪些操作也是一目了然的。如果 API 具有良好的文檔化,比如使用了 Swagger,那么就可以很清楚地了解到 API 都具有哪些約束。
定義這樣的 API 需要做一些前期思考,這不同于使用簡單的 CRUD 生成器。如果你打算將 API 暴露成公共端點,就需要在很長的一段時間內為 API 提供支持,最好還是把它看成是一個永久性的事項。
我總是建議人們在前期多花一點時間,因為有些東西到了后面就很難修改,而 API 就是一個很好的例子。
所以,在進行 API(REST 或其他)設計時,請停止使用 CRUD 模型。相反,可以通過 DDD 來定義 API,包括領域對象和它們的業務操作。
如果你想看到更多關于領域對象的例子,可以參考 Amazon Web Services 的 API。在 AWS API 開發者指南里,每一個服務都有對應的“關鍵概念”一節,用以描述領域對象。
例如,S3 里定義了 Bucket、Object 和 Permission 等領域對象,Kinesis 里定義了流(stream)和分片(shard)。先了解一個服務的領域對象,再查看 API 參考,然后瀏覽服務的 API 清單。你會發現,基于這些領域對象構建的 API 在理解和使用上都更加直觀。
英文原文傳送:
http://jlhood.com/there-is-no-u-in-crud/