Netflix 的六邊形架構(gòu)實踐
本文闡述了Netflix是如何基于六邊形架構(gòu)去開發(fā)一款全新應用的。
隨著 Netflix 原創(chuàng)內(nèi)容的逐年增長,我們要構(gòu)建一些可提升整個創(chuàng)作過程效率的應用。我們的一個大型部門,Studio 工程團隊已經(jīng)構(gòu)建眾多應用,去幫助從劇本制作到內(nèi)容播出的全套流程,涉及的環(huán)節(jié)涵蓋劇本內(nèi)容獲取、交易談判和供應商管理,以及日程安排、簡化生產(chǎn)流程等。
1. 從開始就高度集成
大約一年前,我們的 Studio 流程團隊開始開發(fā)一款跨多個業(yè)務領域的全新應用。
當時,我們面臨一項有意思的挑戰(zhàn):
一方面我們需要從頭開始構(gòu)建應用的核心,另一方面我們所需的數(shù)據(jù)分布在眾多不同的系統(tǒng)之中。
我們所需要的一些數(shù)據(jù),比如有關影片信息、制作日期、員工和拍攝地點的數(shù)據(jù),分布于許多服務中。并且,它們使用的協(xié)議也各有不同,包括 gRPC、JSON API、GraphQL 等等。
對我們應用程序的行為和業(yè)務邏輯而言,已有數(shù)據(jù)非常重要。我們從一開始就需要高度集成。
2. 可切換數(shù)據(jù)源
早期的一款應用程序用來為我們的產(chǎn)品引入可見性,它被設計為單體架構(gòu)。在領域知識體系尚未建立的情況下,單體架構(gòu)可以實現(xiàn)快速開發(fā)和快速變更。后來,使用它的開發(fā)人員超過 30 人,有超過 300 個數(shù)據(jù)庫表。
隨著時間流逝,應用程序從涉及面廣泛的服務演變成高度專業(yè)化的產(chǎn)品。在這樣的背景下,團隊決定將單體架構(gòu)解構(gòu)為一系列專用服務。
做出這一決策并非性能問題,而是要對所有這些領域設置界限,并讓各個專屬團隊能獨立開發(fā)針對每個特定領域的服務。
我們的新應用所需的大量數(shù)據(jù)依舊是之前的單體提供的,但我們知道這個單體將在某一天分解開來。我們不能確定具體時間,但知道這一時刻不可避免,所以需要做好準備。
這樣的話,我們能在一開始利用某些來自單體的數(shù)據(jù),因為它們?nèi)匀皇强尚艁碓?;但我們也要做好準備,在新的微服務上線后立刻切換到這些數(shù)據(jù)源上。
3. 利用六邊形架構(gòu)
我們需要有在不影響業(yè)務邏輯的前提下切換數(shù)據(jù)源的能力,因此我們需要讓它們保持解耦狀態(tài)。
我們決定基于六邊形架構(gòu)的原則來構(gòu)建應用。
六邊形架構(gòu)的思想是將輸入和輸出都放在設計的邊緣部分。不管我們公開的是 REST 還是 GraphQL API,也不管我們從何處獲取數(shù)據(jù)——是通過數(shù)據(jù)庫、通過 gRPC 還是 REST 公開的微服務 API,或者僅僅是一個簡單的 CSV 文件——都不應該影響業(yè)務邏輯。
這種模式讓我們能將應用程序的核心邏輯與外部的關注點隔離開來。核心邏輯隔離后,意味著我們可以輕松更改數(shù)據(jù)源的細節(jié),而不會造成重大影響或需要在代碼庫重寫大量代碼。
我們還看到,在應用中具有清晰邊界的另一大優(yōu)勢就是測試策略——我們的大多數(shù)測試在驗證業(yè)務邏輯時,都不需要依賴那些很容易變化的協(xié)議。
4. 定義核心概念
借鑒六邊形架構(gòu),定義我們業(yè)務邏輯的三大概念分別是實體、存儲庫和交互器。
實體(Entities)指的是域?qū)ο螅ɡ缫徊坑捌蛞粋€拍攝地點),它們不知道自身的存儲位置(不像是 Ruby on Rails 中的 Active Record 或者 Java Persistence API 那樣)。
存儲庫(Repositories)是獲取實體及創(chuàng)建和更改實體的接口。它們保存一系列方法,用來與數(shù)據(jù)源通信并返回單個實體或?qū)嶓w列表。(例如 UserRepository)
交互器(Interactors)是用來編排和執(zhí)行域動作(domain action)的類——可以考慮服務對象或用例對象。它們實現(xiàn)復雜的業(yè)務規(guī)則和針對特定域動作(例如上線一部節(jié)目)的驗證邏輯。
有了這三大類對象,我們就可以在定義業(yè)務邏輯時無需知曉或者關心數(shù)據(jù)的存儲位置,也不用理會業(yè)務邏輯是怎樣觸發(fā)的。業(yè)務邏輯之外是數(shù)據(jù)源和傳輸層:
數(shù)據(jù)源(Data Sources)是針對不同存儲實現(xiàn)的適配器(Adaptor)。數(shù)據(jù)源可能是 SQL 數(shù)據(jù)庫的適配器(Rails 中的 Active Record 類或 Java 中的 JPA)、彈性搜索適配器、REST API,甚至是諸如 CSV 文件或 Hash 之類的簡單適配器。數(shù)據(jù)源實現(xiàn)在存儲庫上定義的方法,并存儲獲取和推送數(shù)據(jù)的實現(xiàn)。
傳輸層(Transport Layer)可以觸發(fā)交互器來執(zhí)行業(yè)務邏輯。我們將其視為系統(tǒng)的輸入。微服務最常見的傳輸層是 HTTP API 層和一組用來處理請求的控制器(Controller)。將業(yè)務邏輯提取到交互器后,我們就不會耦合到特定的傳輸層或控制器實現(xiàn)上。交互器不僅可以由控制器觸發(fā),還能由事件、cron 作業(yè)或從命令行觸發(fā)。
六邊形架構(gòu)的依賴圖向內(nèi)收縮
在傳統(tǒng)的分層架構(gòu)中,我們所有的依賴項都會指向一個方向,上面的每一層都會依賴自己下面的層。傳輸層會依賴交互器,而交互器會依賴持久存儲層。
在六邊形架構(gòu)中,所有依賴項都指向中心方向。我們的核心業(yè)務邏輯對傳輸層或數(shù)據(jù)源一無所知。但傳輸層仍然知道如何使用交互器,數(shù)據(jù)源也知道如何對接存儲庫接口。
這樣,我們就可以為將來切換到其他 Studio 系統(tǒng)的更改做好準備,并且當需要邁出這一步時,我們很容易就能完成切換數(shù)據(jù)源的任務。
5. 切換數(shù)據(jù)源
切換數(shù)據(jù)源的需求比我們預期來得更早一些——我們的單體架構(gòu)突然遇到一個讀取瓶頸,并且需要將某個實體的特定讀取切換到一個在 GraphQL 聚合層上公開的新版微服務上。這個微服務和單體保持同步,數(shù)據(jù)相同,并且它們從各個服務中讀取時產(chǎn)生的結(jié)果也是一致。
我們設法在 2 小時內(nèi)就將數(shù)據(jù)讀取從一個 JSON API 切換到一個 GraphQL 數(shù)據(jù)源上。
我們之所以能如此快地完成這一操作,主要歸功于六邊形架構(gòu)。我們沒有讓任何持久存儲細節(jié)泄漏到業(yè)務邏輯中。我們創(chuàng)建了一個實現(xiàn)存儲庫接口的 GraphQL 數(shù)據(jù)源。因此,只需要做簡單的一行代碼更改,即可開始從新的數(shù)據(jù)源讀取數(shù)據(jù)。
通過適當?shù)某橄?,很容易更改?shù)據(jù)源
到這個時候,我們就知道使用六邊形架構(gòu)沒錯了。
單行代碼更改有一大優(yōu)勢,那就是它可以減小發(fā)布風險。如果下游微服務在初始部署時失敗,回滾也會非常容易。這也讓我們能解耦部署和激活作業(yè),因為可以通過配置來決定使用哪個數(shù)據(jù)源。
6. 隱藏數(shù)據(jù)源細節(jié)
這種架構(gòu)的一大優(yōu)勢是讓我們能封裝數(shù)據(jù)源的實現(xiàn)細節(jié)。
我們遇到這樣一種情況:有一次,我們需要一個尚不存在的 API 調(diào)用——有一個服務用一個 API 來獲取單個資源,但沒有實現(xiàn)批量獲取。與提供該 API 的團隊交流后,我們得知這個批量獲取端點需要一些時間才能交付。因此,我們決定在這個端點構(gòu)建的同時,使用另一種方案來解決這個問題。
我們定義了一個存儲庫方法,該方法可以在給定多個記錄標識符的情況下獲取多個資源——并且該方法在數(shù)據(jù)源的初始實現(xiàn)會向下游服務發(fā)送多個并發(fā)調(diào)用。我們知道這是一個臨時的解決方案,數(shù)據(jù)源實現(xiàn)的下一步改進是在批量 API 構(gòu)建完畢后切換到新 API 上。
我們的業(yè)務邏輯不需要了解特定的數(shù)據(jù)源限制
這樣的設計讓我們能繼續(xù)開發(fā)以滿足業(yè)務需求,同時不會積累太多技術債,也無需事后更改任何業(yè)務邏輯。
7. 測試策略
當我們開始嘗試六邊形架構(gòu)時,就知道需要提出一種測試策略。要提升開發(fā)速度的先決條件就是擁有可靠且非??斓臏y試套件。我們不認為這是錦上添花,而是必要條件。
我們決定在三個不同的層上測試應用:
我們測試了交互器,業(yè)務邏輯的核心存在于此,但與任何類型的持久層或傳輸層無關。我們用上了依賴注入,并 mock 任意類型的存儲庫交互。在這里我們詳細測試業(yè)務邏輯,大部分測試都位于此處。
我們測試數(shù)據(jù)源,以確定它們是否與其他服務正確集成,它們是否對接上存儲庫接口,并檢查它們在出現(xiàn)錯誤時的行為。我們試著盡量減少這些測試的數(shù)量。
我們具有遍及整個棧的集成規(guī)范,從我們的 Transport/API 層到交互器、存儲庫、數(shù)據(jù)源以及重要的下游服務全部包含在內(nèi)。這些規(guī)范測試的是我們是否正確“布線”了一切。如果一個數(shù)據(jù)源是一個外部 API,我們將命中該端點并記錄響應(并將其存儲在 git 中),從而讓我們的測試套件可以在每次后續(xù)調(diào)用時快速運行。我們不會在這一層進行廣泛測試,通常每個域動作只有一個成功場景和一個失敗場景。
我們不會測試存儲庫,因為它們是數(shù)據(jù)源實現(xiàn)的簡單接口;并且我們很少測試實體,因為它們是定義了屬性的普通對象。我們會測試實體是否有其他方法(這里不涉及持久層)。
我們還有改進空間,比如我們將來可以不 ping 所依賴的任何服務,而是 100%依賴合同測試。有了上述方式編寫的測試套件,我們可以 100 秒內(nèi)在單個過程中運行大約 3000 個 specs。
能輕松在任何機器上運行的測試套件,它用起來非常棒,我們的開發(fā)團隊可以在不中斷的前提下做日常功能測試。
8. 延遲決策
現(xiàn)在我們可以輕松將數(shù)據(jù)源切換到不同的微服務上。關鍵的一大好處是,我們能延遲一些關于是否以及如何存儲應用程序內(nèi)部數(shù)據(jù)的決策。根據(jù)功能用例,我們甚至可以靈活確定數(shù)據(jù)存儲的類型——可以是關系型也可以是文檔型。
當這個項目開始時,我們對正在構(gòu)建的這個系統(tǒng)的了解是非常少的。我們不應該將自己鎖定在一個會導致項目悖論和不明智決策的架構(gòu)中。
我們現(xiàn)在做的決策符合我們需求,并且讓我們能快速行動。六邊形架構(gòu)的最大優(yōu)點在于,它可以讓我們的應用程序靈活適應未來需求。