1. 程式人生 > >為什麼微服務架構需要聚合

為什麼微服務架構需要聚合

## 為什麼微服務架構需要聚合 學習架構不僅僅是為了成為一名合格的架構師,同時也可以在設計、開發、部署一個系統、甚至一個模組時能夠更合理地考慮到其內部的權衡取捨,以及與周邊系統的耦合和隔離問題。當然在自己能力不足的情況下,"抄",絕對是個捷徑。偉大的明代著名科學家徐光啟就曾說過:"欲求超勝,必先會通。會通之前,必先翻譯"。 譯自:[Why Your Microservices Architecture Needs Aggregates](https://betterprogramming.pub/why-your-microservices-architecture-needs-aggregates-342b16dd9b6d)。 [TOC] 微服務可以將我們的東西組織成一個考慮周到且定義明確的單元。 一體式架構通常意味著組織中的每個工程師都會涉及到應用的每一部分,且業務體與其他實體緊密耦合,微服務讓我們朝著不同的方向邁進。工程師團隊應該專注於自身的業務領域,業務實體應該只和同領域的實體相耦合。 對領域的描述總是說起來容易,做起來難。例如[有界上下文](https://martinfowler.com/bliki/BoundedContext.html)就是一個最近流行的模式,可以幫助我們組織工程師團隊,並在更高層面對業務領域進行劃分。 類似地,[聚合模式](https://martinfowler.com/bliki/DDD_Aggregate.html)可以幫助我們在更低的層面聚合資料。最初將這種模式定義為按照事務對相關實體進行分組的方式。 此外,它還為我們提供了分解一體式資料架構的藍圖,本質上是將高內聚的實體劃分為單一的、原子性的組。 當然好處還遠不止此。有趣的是,聚合模式似乎不像其他分散式軟體設計模式那樣廣為人知,被廣泛討論或普遍實現。但它是構建微服務的基本單元。 預先進行聚合設計可以幫助我們避免各種問題,如例如實體之間的偶然依賴關係或引用洩漏,這些問題通常會妨礙對系統的擴充套件。下面看下什麼是聚合。 ### 聚合 聚合是Eric Evans在他的書中[*Domain-Driven Design*](https://domainlanguage.com/ddd/)提出的一種設計模式,儘管書中沒有明確地討論微服務體系結構或分散式系統,但已經對這些話題進行了闡述。 一個聚合定義為一個自包含的實體組,作為一個獨立的原子的單元。對任意實體的修改都可能會影響到整個聚合。每個聚合的構成如下: - 邊界。這是實體之間的界限,界定了哪些實體屬於聚合,哪些不屬於。 - 實體。組中包含的業務物件實體。 - 根。每個聚合會向外部暴露一個實體。聚合外部的物件僅可以引用聚合根,不能直接訪問其他聚合內部的實體。 示意圖如下: 上圖中最外層的橢圓表示聚合的邊界,裡面是聚合根(紫色圓形)以及其他實體(綠色圓形)。 由於外部只能通過根來訪問聚合,因此在聚合內部,**只有根才能引用其他實體**(*非根實體之間不能相互引用*)。 #### 聚合根 換句話說,根服務是聚合與外界互動的代表,因此應該選擇最合適的實體作為根。幸運的是,實體的選擇通常比較簡單。很多聚合都擁有一個清晰的、主要的實體,該實體上附加了很多其他實體。 下面展示一個簡化的例子:*使用者聚合*。 注意我們的聚合及其根的名稱都叫"User"。User實體可能包含的屬性,如名和姓,性別,出生日期,可能還會包括國民身份以及其他少量標量欄位。 `User`和它關聯的資訊(`Email` (address), `Phone` (number), 和(mailing) `Address`)是一對多的關係。除了上面描述的內容外,在外面的聚合中可能還會包含其他用於代表使用者偏好的實體。 很顯然,`User`實體作為了聚合的根。除了名稱相同外,User實體包含了有關使用者的核心資訊。此外,它還是聚合中產生其他實體的實體。即,如果移除了`Phone`,則聚合本身會被保留下來。這種場景下,脫離了`User`上下文的`Phone`是毫無意義的。但如果移除了`User`實體,那麼聚合中的其他實體就會變得沒有意義,成為微服務架構中沒有目的性的孤兒實體。 User實體是可以從外部直接訪問聚合的唯一實體。以ReST為例,意味著我們可以提供如下路徑: ``` /users/{user-identifier} ``` 但不能提供如下路徑(*不能直接訪問電話實體*): ``` /users/phones/{phone-identifier} ``` 其他聚合可以儲存到`User`的引用,如`Order`聚合可能會儲存每個發起`Order`的`User`,每個`User`必須分配一個[全域性唯一識別符號](https://en.wikipedia.org/wiki/Universally_unique_identifier)。 #### 值物件 相比之下,其他實體僅需要本地識別符號,聚合可以通過識別符號消除其自身的歧義。如可以使用`1`,`2`,`3`來標識`User`的`Phone`。 這是因為Phone對外並無意義,其他任何聚合都不會單純地請求`Phone` `2`,僅會檢索使用者`b4664e12–2b5b-47c8-b349–41e81848758f`使用的`Phone` `2`。 但即使這樣,也應該限制發生的範圍,其他聚合不能永久儲存到使用者手機的引用。 回到ReST的例子,我們認為對一個手機的可以接受的引用如下(通過使用者來訪問其手機): ``` /users/{user-identifier}/phones/{phone-identifier} ``` 但很多支援的實體其實都是[值物件](https://martinfowler.com/bliki/ValueObject.html),即基於它們的值,而不是引用來標識物件。 比如`Email`,我們可能考慮給每個郵件地址分配一個數字ID,但實際上[email protected]本身就可以作為一個實體物件,如果該字串發生了變化,則它就變成了一個全新的郵件地址。 上述方式也同樣適用於`Phone`(由未格式化的陣列構成)以及(郵寄)`Address`,但由於一個(郵寄)地址可以有多種表示形式(例如,34 N. Main St. 和34 North Main Street),這種情況可能會有些棘手。實際上,為了使用`Address`來表示一個值物件,我們需要用某種規範化的地址元件格式來作為其標識。 再回到ReST示例中,我們可能完全不需要聯絡資訊實體的ID,而是像這樣簡單地將它們作為一個組來進行訪問: ``` /users/{user-identifier}/phones ``` 注意此處並沒有統一的答案,具體取決於對實體的處理行為。 > 本節展示瞭如何使用值物件來檢索實體,值物件可以使用單獨的識別符號體系,也可以根據實體的性質,使用其名稱作為識別符號。甚至可以在索引時忽略識別符號,具體情況具體解決。同時注意**非根實體之間不能相互引用** #### 聚合,事務邊界以及不變數(invariants) 早先我們提到,應該將聚合視為一個原子單元。對任何包含的實體的改動,都可能會影響到整個聚合。因此,聚合定義了對包含的實體進行更改的事務邊界。 這意味著什麼?通常我們會建立規則來管理在修改一個實體時發生的事情。在很多場景下,如果以某種特定的方式修改某種型別的某個實體,則必須同時修改另一個實體。或者,可能只能在特定環境下才能修改某個給定的實體。我們將這種規則稱為*不變數*。不變數必須獨立存在於一個聚合的上下文中。如果修改實體X需要同時修改實體Y,則實體X和實體Y必須包含在相同的聚合中。 類似地,如果基於實體Y和Z的運算結果可能會導致拒絕對實體X進行編輯,則這三個實體必須包含到相同的聚合中。 或者更準確地說,如果將一個不變數散佈到多個聚合中,那麼我們將無法保證不變數執行的一致性。 以前面的`User`聚合為例,假設我們允許使用者選擇一種首選的溝通方式:可能是特定的郵件地址,電話號碼或郵寄地址。 這樣,我們就可以給三種實體型別新增"best-contact"的布林欄位。如果一個使用者一開始將郵件地址作為最佳聯絡方式,並在後續將電話號碼作為最佳聯絡方式,此時會發生兩件事: - 郵件地址的`best-contact`設定為`false`。 - 電話號碼的`best-contact`設定為`true`。 顯然,`Email`和`Phone`實體必須歸屬於`User`聚合。如果它們分別屬於不同的聚合,那麼"更新最佳聯絡方式"的操作就不能在一條事務中完成(相反,會涉及兩個聚合,兩條呼叫) 注意術語"事務",它並不指代資料庫事務。很多場景中,會通過資料庫來對實體進行變更,但也可以通過記憶體或其他機制。同時所有必需的更改都是通過對聚合執行**單次**呼叫而發生的。因此,這裡隱含的是我們已經定義了相應的API。 在上述例子中,我們不期望呼叫者顯示地更新best-contact欄位,因此不能使用如下ReST路徑: ``` PUT /users/{user-identifier}/phones/{id}/isBestContact // boolean passed in the body ``` 而應該使用如下路徑: ``` PUT /users/{user-identifier}/bestContact // ID passed in the body ``` 通過這種方式,我們可以認為聚合和不變數體現了高內聚的概念:將可能會同時變動的元素分為一組。 ### 如何定義聚合 正確定義聚合可以幫助我們拆分歷史資料模型,界定邊界為灰色(最好情況)或根本不存在邊界的主要實體,以及組合那些需要一前一後發生變更的實體。 但如何定義自己的聚合呢?有一些可以採用的方法,但都遵循如下基本步驟: #### 確定系統中的主要實體 首先需要結合業務知識和常識來確定高階實體,這些高階實體是我們業務領域的基本組成部分。在我們的系統中,使用者是主要實體,而不是電話號碼。其他例子如: - 訂單 - 產品 - 分類賬簿 - 庫存 如果無法確定一個給定的實體否是足夠"高階"來代表一個聚合,則可以思考一下:是否需要確保該實體的**全域性身份**;是否需要全域性地將該實體的例項與所有其他例項進行區分(甚至在例項具有相同值的情況下)?或者僅僅關心實體的值。 一旦確定了系統中的關鍵實體,就可以確定聚合中其他可能的候選者,再確認與根實體緊密關聯的實體。 為了實現上述目的,需要牢記如下內容: - 如果沒有根實體,其他實體將沒有任何意義。 - 此外,其他實體通常都是值物件 - 在確定屬於聚合的實體時,應該查詢不變數(管理不同實體互動的規則)。我們應該儘量將涉及相同不變數的實體歸為一組。 一些聚合比較明顯,可以很容易通過實體形成聚合,其他則不那麼直接。例如兩個參與者:`Order` 和`Order Item`。`Order`s 代表客戶在線上的採購總數,而`Order Item`(代表訂單中的特定產品的採購)又構成了`Order` 。毫無疑問,我們會將`Order`s 作為聚合,以此跟蹤發生的`Order`,並通過請求該聚合隨時對元件進行檢查。 那麼是否可以將`Order Item`作為聚合呢?這取決於我們的設計,`Order Item`可能會將許多其他實體組合在一起,且其他聚合可能會儲存到`Order Item`的引用。 一個`Order` 可能會具有與`Order Item`相關的不變數,即當新增一條`Order Item`時,可能需要重新計算訂單的總價。 或者必須限制採購專案的數目或型別,這表明`Order` 應該是一個包含`OrderItem`s的聚合。 對聚合的劃分取決於具體的業務,通常在確定聚合根之前會進行幾次迭代,遍歷各種場景。 > 對根實體的確認是比較難的,本節提供了一種確認思路,即:是否需要保證某個實體是全域性性地,意味著該實體需要與外部進行互動。但有些情況取決於具體的業務,通過不斷的迭代和嘗試來確定一個聚合是否合理。 ### 為什麼聚合 下面讓我們更深刻地理解什麼是聚合,以及探索確定聚合的方式。顯然,在設計聚合前需要做一些期工作。 那麼,為什麼要關心這些準備動作呢? 當定義領域驅動設計模型時,埃文斯(Evans)幾乎完全聚焦於聚合,並將其作為不變數事務的執行機制。但這種模式(使用一個外部可訪問的引用來標識實體的原子集合)也適用於微服務架構的其他方面。 除了提供不變數的執行,聚合還可以幫助我們避免如下問題: - 實體間不必要的依賴 - 物件的引用洩露 - 資料組之間缺少明顯的邊界 下面看下這些問題對應的例子,以及如何使用聚合來解決這些問題。 #### 微服務和資料模式設計 首先看下典型的一體式資料庫。過去很多年中,我們開發了一個大型的資料庫模式,且到處都是外來鍵引用。 從任意表開始跟蹤所有的外來鍵引用,都可能會遍歷整個模式。
*A small but very monolithic database schema* 即使使用單一的程式碼庫,這樣做也是不對的。 例如,當通過資料庫呼叫檢索一個`Order`時,應該返回多少資料?顯然,`Order`詳情包含狀態、ID和下單日期。那麼是否需要返回所有的`Order`物品?物品從哪裡寄出以及寄到哪裡?是否需要`User`物件來表示下單者和接收者?如果是,那麼應該需要與`User`一同返回多少資料? 在轉向微服務的過程中,我們將對程式碼庫和資料模式一併進行拆分,這將是面臨的最困難的一步。幸運的是,聚合思維為我們設計資料微服務和關聯的資料庫模型提供了藍圖和堅實的指導方針,相比漫無目的地對服務進行組合,聚合模式可以幫助我們確認: - 根實體 - 附加到根實體的值物件 - 用於跨實體維護資料一致性的不變數 雖然後續仍然有很多工作要做,且通常需要很多次迭代才能確定聚合,但為我們提供了一個很好的指導方針,一旦形成聚合,就可以更加自信地做到這一點(微服務化)。 #### 共享 大多數資料庫都支援大流量處理。但即使是最高效能的資料庫,其處理能力也是有限的。當資料庫中的資料流太多時,可以有如下選擇: 一個常見的方式是[分片](https://www.digitalocean.com/community/tutorials/understanding-database-sharding),描述了一種水平擴充套件資料庫的方法。當對資料庫進行分片時,會建立多個數據庫模式副本,並將資料切分到這些副本中。 例如,如果建立了4個分片,則每個分配大概會儲存四分之一的資料。所有分配的模式都是相同的,即包含相同的表,外來鍵以及其他約束等。
*With sharding, we horizontally scale by splitting a large schema into multiple smaller, identical schemas* 高效分片的關鍵是分片鍵。分片鍵是一個通用識別符號,通過雜湊或模數函式來確定其歸屬於哪個分片。 例如,如果我們嘗試更新一個使用者,我們可以對使用者的ID進行雜湊,然後對4取模(假設有4個分片)來確定從哪個分片來查詢該使用者。 如果對一個典型的一體式資料庫模式進行分片,這將是一個幾乎不可能的任務。為什麼?是因為在我們的一體式模式中包含大量關聯的外來鍵。例如,我們可能有一個從`ORDER`表到`USER`表的外來鍵(代表下訂單的使用者)。 現在我們使用一個`User` `ID` `12345`來確定從哪個分片查詢該使用者,12345 % 4 = 1, 因此可以在Shard 1中查詢`User`。但如果`ORDER`記錄(ID為`6543`)儲存了的到該`USER`記錄的外來鍵,6543 % 4 = 3,因此會在Shard 3中查詢該`ORDER`記錄。由於存在外來鍵,因此不可能使用這種方式實現(*會訂單和下訂單的使用者不在同一個分片中*)。 上面是一個一體式資料庫示例,我們可以使用微服務的資料模式來將我們從中解放出來。 假設我們建立了一個`User`服務(類似之前的例子),一個User實體關聯了0..n個郵件地址,郵寄地址以及電話號碼。底層資料模式如下:
現在,假設我們沒有采用聚合的概念,直接提供了訪問所有實體的方法: ``` GET /users/{user-id} GET /users/phones/{phone-id} GET /users/emails/{email-id} GET /users/emails/{email-id} ``` 一年之後,由於資料庫中的資料過多,我們需要對其進行分片。那麼可以嗎? 下例展示了我們的4個`USER`分片,一個ID為`12345`的`USER`記錄(12345 % 4 = Shard 1),以及關聯的`PHONE_NUMBER`記錄(ID為`235`,235 % 4 = Shard 3) 我們遇到了與一體式資料模式相同的問題(*本應在同一個分片中進行查詢的使用者和使用者的手機號,被分散到了分片1和3中*)。 > 由於沒有提供一個根,並將根作為對外暴露的唯一實體,導致可能在後續資料庫分片後出現數據不一致的問題。使用聚合時,可以看作聚合中所有的實體使用了同一個ID,後續資料庫分片後,聚合中的實體也會存在相同的資料庫中。 如果我們正確定義了User 聚合,就可以保證每個請求會經過根實體,這樣根實體的ID就決定了每個實體的位置(包括電話號碼)。 在我們上面的例子中,與user ID `12345`關聯的所有的實體(郵件地址,郵寄地址,電話號碼和根實體本身)都儲存到了分片1。 #### 訊息傳遞 現在討論一下[有界上下文](https://medium.com/datadriveninvestor/if-youre-building-microservices-you-need-to-understand-what-a-bounded-context-is-30cbe51d5085),它是域驅動設計中另一個非常有用的模式。此外,它可以幫助我們理解如何在微服務架構使用訊息傳遞(而不是同步API呼叫)。 在有界上下文中任意時間發生的事件將會被髮布到像Kafka這樣的事件匯流排中,然後由其他有界上下文中的服務消費。 那麼問題來了,"訊息中應該包含哪些內容"?例如,一個`User`添加了一個電話號碼。一旦該修改提交到了資料庫,我們將會把這次編輯作為一個訊息進行釋出。 但什麼才會被髮布呢?通常,我們會發布被修改的資料的狀態。因此僅需要簡單地釋出新的電話號碼即可: 上述可能就夠了,但很難判斷訊息的消費者可能還需要哪些資訊。例如有些消費則可能會需要了解是否新的電話號碼是`User`的主電話號碼。 但如果已經給出了主電話號碼為*false*,但消費者又需要知道哪個才是主電話號碼?我們可能會發送所有的電話號碼,但如果另一個消費者需要通過電子郵件通知該`User`已經對該修改進行了處理,那麼是否應該傳送`User`的所有電子郵件? 如果這樣的化,處理將永遠不會結束,且永遠不會得到正確的處理方式。 一種可選方式是簡單地在訊息中傳送被修改的實體的ID。任何消費者可以呼叫事件傳送者來獲取具體的事件內容。 不幸的是,這種方式有兩個問題: - 有時會導致檢索到錯誤的資料。假設修改了實體123,併發布了對應的訊息,然後又對該實體進行了修改。之後,某個消費者消費了第一個事件,並請求實體123。該消費者將不會獲得首次修改。如果消費者僅關心最新的修改,則這麼實現可能是沒有問題的。但作為生產者事件,我們無法知道消費者是否需要(在現在和未來)跟蹤單個變更。 - 更糟糕的是,它使得已解耦的事件驅動架構(因為跨有界上下文的呼叫而)變為了一個強耦合的系統。 那麼應該如何傳遞我們的訊息呢? 事實證明,如果我們接受了聚合,就會有明確的答案。 每當更改聚合時,都應將該聚合作為訊息傳遞。由於聚合作為一個原子單元,任何對聚合的一部分的修改都會被認為對整個聚合進行了修改。 訊息中是如何表示聚合的,具體取決於所在的組織。可能是一個簡單的JSON結構,或可能使用[Avro](https://avro.apache.org/)模式表達。聚合的資料可能是加密的。不管資料格式如何,在“聚合”的思考和設計中都會遇到諸如此類的問題。 > 總的思路就是將"聚合"作為一個原子單元進行傳遞。如果僅僅使用全域性識別符號來傳遞訊息(本質上類似一個指標),則可能會遇到讀寫不一致的問題。 #### 重試 訊息傳遞的概念通常會涉及重試。基於訊息的事件驅動架構的一個亮點就是恢復能力(以自動重試的方式)。 這意味著什麼?當釋出訊息到如Kafka這樣的事件匯流排時,就可以被下游消費者所消費。大多數情況下會順利進行。但有些情況下,消費者可能會遇到訊息消費的問題: - 可能是因為消費者的資料庫暫時不可用,導致消費者無法正確處理事件。 - 或者可能是因為暫時無法使用安全裝置,導致消費者無法解密訊息。 這類情況下,消費者在當前訊息處理完之前將無法繼續處理下一個訊息,且消費者能夠對處理的訊息進行確認。這些行為預設會發生在Kafka等系統上。 實際上,消費者將繼續嘗試,直到成功為止。 通常這是期望的行為,一般也能夠相對快速地解決相應的問題。同時,對下一條訊息進行處理是沒有意義的,因為該訊息也很可能會發生相同的問題。 但還是會存在第二類問題:當訊息本身存在問題時(可能是因為訊息在傳遞中出現了損壞,或包含一個特殊的字元,或沒能通過某些有效性校驗)。這種情況下,消費者會多次嘗試消費訊息,但永遠不會成功。 當檢測到這類問題時,消費者可能會把當前訊息放到一邊,例如將其放到一個特殊的佇列中,並繼續處理後續的訊息。 但這種方式也存在問題。我們期望確保最終能夠處理掉"壞的"訊息,即使需要一些手動操作。但如果在消費者處理一個訊息的同時,訊息中的資料發生了變化,新的變更將會因為重新處理"壞的"訊息而被覆蓋掉。 下圖展示了這個問題: Bounded Context 1中實體123的"foo"的值變為了"bar",然後釋出了一個表示此次變更的訊息,由於Bounded Context 2中的消費者無法解析該訊息,因此將其放到了一個特殊的佇列中。 後來Bounded Context 1中的實體123的"foo"的值變為了"baz",然後釋出一個從"bar"變為"baz"的訊息,此時Bounded Context 2消費的訊息中的實體123的值為"baz"。 再後來修復了初始的訊息(如移除了一個錯誤字元),然後重新發送到Bounded Context 2,該訊息中的實體123的值為"bar"。 這是一個處理順序的問題。通常,我們需要保證按照事件傳送的順序進行處理。但在上述場景下,則無法按序處理事件。 如果我們圍繞聚合來定義資料,則可以知道知道消費者可能收到的訊息的變更範圍。換句話說,接收到的任何訊息都描述了一個新版本的聚合。且可以通過根實體的全域性唯一識別符號(GUID)來確認聚合。因此,如果消費者在確認無法在沒有人工介入的情況下無法處理某個訊息時,就可以將該訊息放到一個獨立的佇列中,它可以使用該GUID來表示被擱置的訊息。如果碰到了更多包含相同聚合的訊息,則可以將這些訊息放到相同的佇列中。然後可以在原始問題解決(例如可能需要更新消費者來處理奇怪的Microsoft Word特殊字元)前繼續按照上述邏輯處理訊息。如果問題解決,消費者就可以處理這些被擱置的訊息。 可以肯定地說,構建這些重試機制並不容易,但使用聚合,最起碼是可行的。 > 本節展示瞭如何使用聚合的GUID作為全域性唯一識別符號來快取來自特定聚合的(無法繼續處理的)訊息。這樣就可以繼續處理來自其他聚合的訊息。在聚合的問題解決之後,就可以繼續處理該聚合之前被擱置的訊息。 #### 快取 如果沒有很好地定義有界資料結構,快取可能會因此變得笨重。大多數快取操作,如雜湊對映,它們允許使用一個識別符號來關聯一堆資料,並通過傳遞該識別符號來對這些資料進行檢索。 如果我們沒有圍繞聚合來定義資料結構,則可能會很難確定需要快取的資料型別。假設一個經常被訪問,但很少被修改的系統,在這種系統中,我們可能會期望快取請求結果來最大程度地減少對資料庫的訪問次數,但應該快取哪些內容呢? 我們可能會簡單地對每次請求的結果進行快取。回到User的例子,這意味著我們會快取如下結果: - 對特定使用者的查詢 - 對特定電話號碼的查詢 - 對一組郵件地址的查詢 - 對特定使用者的婚姻狀況的查詢 注意快取會複製資料。假設我們快取了一個使用者物件,但同時也快取了獨立的聯絡資訊和聯絡資訊組,以及使用者獨立的物件欄位。最終會需要大量記憶體來儲存這些資料。當快取了無效的資料時,可能會出現嚴重問題。 例如快取的電話號碼發生了變化,如假設在先前的例子中,"best contact"標誌從*false*變為了*true*,此時需要校驗快取的電話號碼。但是否需要校驗快取的使用者物件,以及其他聯絡方式的"best contact"是否由*true*變為了*fasle*。 如果我們使用聚合,則不需要擔心這些問題。使用聚合,我們只需要快取一個快取key:聚合的GUID。當檢索聚合時,我們會對其進行快取。當聚合的任何屬性發生變化時,對整個聚合進行校驗即可。*(此時快取的不是內容,而是索引方式,當然也可以快取整個聚合*) #### 服務授權 在我之前所在的公司向微服務邁進時,我領導了一個團隊,負責實施服務到服務的資料級別的授權。換句話說,我們已經解決了"是否允許服務A允許訪問服務B"的問題,還需要解決"是否允許服務A從服務B請求實體123"的問題。 這意味著我們需要了解當前的使用者代理(例如,哪個客戶發起的請求),像[JWTs](https://jwt.io/)這類認證代理就是這麼做的。我們可以在執行服務到服務的呼叫時,在一個token中傳入使用者ID。 同時我們也需要了解是否允許該使用者代理檢視特定的實體。在我們的場景中,可能存在大量潛在的實體。此外,一個使用者可能需要檢視他們擁有的文件,或可能通過其他使用者的授權來訪問文件(例如,通過第三方授權方式)。 我們的目的是提供一個通用的、外掛化的解決方案,同時需要避免通過重複(同步)呼叫某個獨立的服務來確定一個使用者是否有許可權訪問某個特定的實體。 出於上述原因,我們決定在啟動過程中,對允許給定使用者訪問的專案做一次確定,並在使用者token中包含這些商品的ID。 對於上述情況來說,如果不圍繞聚合來設計我們的微服務,則有可能是行不通的(有可能無法訪問潛在的實體列表)。 但是由於我們已經在使用聚合方面進行了前期規劃,因此我們通過聚合根的ID來約束可以查詢任何實體。這樣我們僅需要授權給特定使用者的聚合。 > 上例使用userId作為GUID,聚合了與使用者相關的所有資訊。並以此來檢索該使用者的其他資訊(如可以訪問的文件)。 #### 跟蹤變更 有時候,我們需要對變更的資料進行跟蹤。過去,我們通過實現資料庫活動觸發的變更資料捕獲(CDC)系統來記錄資料的變更。最近,組織傾向於捕獲業務實體的變更,而不是資料庫行的變更。此時我們面臨著一個問題:"哪些資料需要快照,以及以後如何使用"? 你可能已經猜到了,答案是圍繞聚合來設計資料。任何時間對任何實體進行變更時,都會記錄一個新版本的聚合,這個過程並不簡單,但更加準確。 回想一下,聚合的最初目的是在事務上強制執行不變數(invariants)。因此聚合的每個快照都表示此類事務的執行結果。 後續對變更的檢索也更直接。如果需要查詢歷史`User`的聯絡方式,我們不需要跨多CDS表來收集變更。相反,只需要訪問聚合表,各個聚合之間的差異也變得無關緊要。 我們只是將一個版本的聚合與另一個版本進行比較。 #### 其他方面 上述並沒有詳盡地列出圍繞聚合設計實體可以幫助我們解決的各類挑戰。毫無疑問, 應用聚合模式會使我們以系統的方式預先思考哪些實體屬於同一實體。最終,我們會將操作約束到具有單個訪問點的,定義明確的原子組。 我們不會因實體之間的偶然依賴關係而感到厭煩,也不會各種引用洩漏而妨礙我們實施擴充套件方案。 > 需要注意的一點就是,聚合是與業務息息相關的,且對一個聚合的確認也不是一蹴而就的,有時需要進行多次協商和迭代才能達到一個滿意的結果。但架構就是一個軟體的骨架,不好的架構將可能後患無窮。 ### 引用 - [DDD_Aggregate](https://martinfowler.com/bliki/DDD_Aggregate.html) - [Understanding Database Sharding](https://www.digitalocean.com/community/tutorials/understanding-database-sharding) - [Value Object](https://martinfowler.com/bliki/ValueObject.html) - [Kafka Consumers](https://www.oreilly.com/library/view/kafka-the-definitive/9781491936153/ch