大型單塊系統總是讓工程師們敬而遠之,其中的複雜度更是沒有多少人能夠完全理解,因此有段時期 Microservice 相當盛行。但在小型公司人力不足的情況下,並不適合使用 Microservice,反而 Monolithic 是最佳選擇,但又擔心走回老路。難道就沒有中間方案了嗎?有的,那就是 Modular Monoliths。
仔細觀察,大型單塊系統會變得難以維護,根本上的問題是在開發初期沒有明確定義架構、或是概念上的功能區塊架構圖與 code base 中的結構根本對不起來,導致開發時沒有一個明確的暗示讓工程師們往同樣的方向邁進,反而充斥著各種 workaround 或者複雜的依賴,久而久之 Model Code Gap
就越來越大,更難讓人理解系統的意圖。
為了解決這些問題,取經於 Microservice 的 Modular Monoliths 的觀念就出現了!此篇文章內容取自於 Modular Monoliths • Simon Brown • GOTO 2018 ,並提供一點我個人觀後心得,就讓我們開始吧!
影片一開始講者認為我們需要讓程式碼結構反應出架構意圖
The code structure should reflect the architecture intent
為了一步步找出理想的結構,講者依序介紹了以下四種打包方法:Package By Layer 、Package By Feature、Port and Adapter、Package By Component
目錄
Package By Layer
這種分法可能是最常見的,把最外層的 Web Layer 通通打成一包,中間 Business Layer 也通通打成一包,最後 Data Layer 打成一包,用 Horizontal Slicing 的方式切割,呈現出我們的技術架構。
這樣分也不是不行,但講者認為這種架構不太能「抵禦人性」,久了程式容易有怪味道。
比如說,今天如果一個 feature 非常緊急,PM 找 RD 來問,能不能明天出?RD 看了看,這個 feature 要改入目前的業務邏輯需要不少時間,但發現有一個捷徑,只要讓 Web Layer 直接 access Data layer 中對應的 Repository 就可以了!
可能有人會說,這作法本來就不對,本來就不能讓 Web Layer 直接去碰 Data Layer,是 RD 問題,和打包方法無關。
沒錯!但人天性會走最短路徑,尤其在時間壓力下,除非我們將這條路(Web Layer → Data Layer)弄的很難走,不然最終走到此路的機率極高!
以目前打包法,Data Layer 介面是 public 的,很難避免 RD 透過不正常方式去呼叫。再嚴重一點,可能 Data Layer 還反向呼叫另外 module 的 Service Layer,最終整個 code base 變得錯綜複雜。
因此此架構的最大弱點,就是沒辦法抵禦人性,導致未來變髒的機率較高。
另外的弱點是,當我們修改某一段 feature 時,可能會需要在不同的 Layer Folder 中穿梭。因為當我們在 Service Layer 改一段邏輯,可能需要修改 Web Layer 呼叫他的方法,也可能要改 Data Layer 儲存的方法。講者認為同一個 module 的檔案分散在不同的資料夾,容易造成認知上的負擔,增加修改所需的時間。
Package By Feature
後來有人認為,要改善 Package By Layer 中 module 彼此亂呼叫的問題(如上圖的紅色虛線),以及同一 Feature (Module) 檔案分散的問題,根本原因在於我們應該要依照不同的 Feature (Module) 作隔離打包,避免發生不受限的跨模組呼叫。我們應該要 Vertical Slicing 成 Package By Feature 如下圖:
這樣有改善問題了嗎?講者認為,的確同一 Module 的檔案打包在一起,找起來會方便取多,但如果要跨模組呼叫呢?這種打包方式沒有明確的定義或規範,開發時必須非常小心避免發生模組間不正確的依賴。
這邊講者講的很模糊,一句話就帶過沒有多作解說。我的想法是,假設每個 Module 只能暴露最表層,也就是 Web Layer,或者說 Controller 那一層。當如果 A 模組需要 B 模組提供資料時(比如說 Order Module 需要 User Module 提供使用者資料),難道還要走 Controller 那邊進去嗎?
感覺很繞,且若是從 Service Layer 呼叫,就會變成 A Module Service
Layer 依賴 B Module Web
Layer,變成核心層依賴表層,不符合由外向內依賴的原則。
若每個 Module 可以選擇性暴露他自己的各個 Layer,某方面來說又變成 Package By Layer 的情境。
因此若以以上兩點來看,Package By Feature 雖然在檔案歸位上表現不錯,但其他問題可能沒有顯著的幫助。
Screaming Architecture
但如果以 Package By Feature,比較容易做到 Screaming Architecture 的概念。也就是說,你光看前幾層的「資料夾結構」,就能大概知道他是一個怎麼樣的系統。相對 Package By Layer,除非很熟悉系統的人,不然很難一眼看出他功能上的特性。
這概念其實對程式碼的架構
上沒有什麼太大的幫助,最主要的還是讓身為人類
的 RD 們能夠快速且清楚知道這個系統在幹嘛,出問題時可以最快定位到錯誤的地方。
Port and Adapter
前面提到依賴方向應該要從外到內,這個想法出自於 Clean Architecture,又稱 Hexagonal Architecture 等等,其實他們都是 Port and Adapter。
Port and Adapter 概念就是,Service Layer (Domain) 是最重要的核心,他不依賴任何人,而是外圍的人要來依賴他,因此 Service Layer 會定義好 Interface,其他人依賴他定義的 Interface 實作 Adapter。Domain 只需要知道業務邏輯,其他外層如何實現不必知道。
他和 Package By Layer 最大的差異在於把 Data Layer 的 Interface 放到 Service Layer 中,運用依賴反轉的技巧巧妙的讓 Data Layer 改為依賴 Service Layer,達成同心圓結構。
初衷是好的,但講者認為這個結構可能會引導 RD 過度設計。舉例來說,某個 Web framework 使用 MVC 架構,RD 為了讓 Domain 不依賴 framework 的 MVC 架構,因此在 Domain 和 MVC 間又包一層 Adapter。又或者是為一百個頁面設計了一百的 UseCase 的 Adapter,也寫了一百個 Interface,但他其實很多地方可以共用,造成過度設計。
舉例來說,曾經看過有人作 User 相關的模組,光 Service Layer (Domain) 就拆成 AddUserService
、DestroyUserService
、FindUserService
、ListUserService
、UpdateUserService
。本以為每個 service 的邏輯會很複雜,結果點進去看竟然都只有一個 method
!然後每個 service 都還要 開對應的 interface 讓他 implement!也就是說,以當下的複雜度而言,可能只需要一個 UserService
就能搞定的事情,卻開了五個 service 和五個 interface 共十個檔案!
這還沒完,因為 Service 需要 Data Layer 作 CRUD,因此也對應開了五種情境的 Port Interface,然後實作的 Adapter 就需要 implement 五個 interface…(能說幸好他不是寫五個 Adapter 嗎?XD)
結果,只是一個小小的 User 相關業務邏輯,就需要開 5 + 5 + 5 + 1 共十六個檔案來作,有必要這樣嗎?這也就是講者說的 Cargo Culted
貨物崇拜,過度設計,反而造成 RD 的開發效率下降。
但必須說,Port and Adapter 概念是好的,他可以讓依賴都往內,避免亂七八糟的依賴問題。但如果沒有搞清楚他的核心目的,看到 Port and Adapter 就開始瘋狂的開 interface,反而會引入不必要的複雜度。
另外 Port and Adapter 沒有定義(或規範)外圈彼此的互動,因此可能會讓概念上不同 module 的「外圍」發生互動(比如說 OrderRepository 直接去 call UserRepository),引入不好的跨模組依賴,這部份需要 RD 開發時刻意注意的地方。
Package By Component
講了這麼多,我們到底需要架構幫助我們達成什麼事呢?先來梳理一下:
- 希望可以避免人性
的懶惰,發生不正確的依賴 - 希望可以達成高內聚,低耦合,同一 Module 相關的檔案可以集合在一起
講白了,其實目標就是 Modulize!到此,講者提出 Package By Component。
我們從「Service」為出發點,將同一 Domain 相關的邏輯、包含底層資料永久層都打包在一起成為 Module,只暴露足夠用的 API 出來給外界呼叫。外界呼叫不需要知道底層實作,也無法輕易的去碰觸底層,因而避免了人為疏失的不正常依賴。
而我們常用的 Web Layer,從「Service」的角度上來看並不屬於同一 Module,因為他是系統以 Web (Restful)
與外界互動
的一種方式
,因此另外打包。
這邊也很合理,畢竟一隻 API 可能同時需要與多個 Domain 的 Service 合作才能取得所需資料(如 Order API 可能需要同時與 Order Service、User Service 等等要資料),能夠知道此時需要哪些資料、要怎麼調用服務、通常會發生在表層,也就是 Web Layer。此種需要「多服務」合作的情況非常看應用情境,也就是 UseCase,和 Domain 較無關,因此另外打一包則會是好的作法。
因此可說 Package By Component 是從 「component-base
」,或是說「service-oriented
」的思維下去對單塊系統作模組化
。
仔細一看,這不是和我們在 Microservice 中作一樣的事情嗎?
Microservice 將同一 Domain 的邏輯集合在一起成為服務,並透過暴露 API,可能是 Restful、gRPC 等等,隱藏內部實作,讓外部不能碰觸他的底層。同理,Package By Component 也是從一樣的理念出發,使是透過 Public Method 來暴露他的 API!
從概念上來看,Package By Component 和 Microservice 根本一樣,他們想要達成幾乎相同的事情,只是實作上呈現的手法不同。
同時,因為架構相似的特性,Package By Component 未來也很容易遷移至 Microservice,算是額外的好處。
懂得利用規則,而不是被規則限制
以上四種 Package 方法,看起來好像大不相同,但仔細一看,如果把 Package 的界線塗掉,並假設所有 Method 都是 Public,會發現其實骨子裡根本一樣!
說到底,去除掉打包後,依賴的方式完全一樣,這邊首先告訴我們:依賴方向很重要
!
如果依賴方向搞不清楚(依賴不該依賴的),沒控制好 Public Method(公開了不該公開的),怎麼打包最終還是一潭混水。
基礎的依賴方向清楚後,再來往上來看怎麼打包最適合維持
這個依賴關係!講者認為以最小攻擊面
為原則,只暴露該暴露的方法(API),能隱藏的儘量隱藏,避免人為的疏失。
意味著我們應該要想清楚我們要達成的目標,而不是看到一個方法或規則就死命的遵守,卻不知道他所要達成的可能與你的目標並不完全相同,甚至引入的不必要的複雜度。有時看情況調整規範作個融合或許更好!也就是守破離
的概念!
舉個例子,圖中都是以 Java 作為範例,一個編譯性的語言,為了能夠抽換實作,Interface 是個必要的存在。那 Python 或 JavaScript 這種直譯式的呢?
有人說:「要啊!因為以此概念,我們要固定下來介面,寫好 Interface 能夠強迫 RD 實作時都有作這些出來!」
但,如果真的漏寫了,程式還跑的起來嗎?IDE 難道不會 highlight 嗎?Test 不會報錯嗎?
既然直譯式語言抽換實作只要提供必須的 Public Method
即可,那我們為何還要多此一舉再寫個 Interface?除非他能給我們帶來更多好處?
「但如果沒作 Interface,依賴方向就不對了!」
Python 為何要有 Interface 才能有正確的
依賴方向?到頭來 Interface 定義的是「必須的 Public Method」,打包時放在 Domain Layer 意味著從 Domain 角度出發做的定義
。但如果我們兩個物件都有實作出來給 Domain Layer 使用,可以算有概念上的 Interface 了嗎?
不同語言有不同的特性,並不是我們看到模組化的圖都有畫 Interface,我們就要跟著作。應該要考量當下情境,調整我們的規範。
因此我覺得,先參透依賴方向、職責、意圖、模組化、高內聚低耦合、領域、服務等等核心概念,融會貫通後再來思考不同的打包方式可以怎麼幫助
我們更容易達成上述事項,甚或是最優先重視的事項,往 Modular Monoliths 方向走,並且能夠抵禦人為疏失,減少引入的複雜度,這樣才能懂著利用規則,而不是被規則限制
!
結語
有時候我們不能預期系統未來會長成怎樣,未來會由誰來負責維護,我們只能以正確的基礎觀念做出當下足夠好的策略
,並且在程式碼、結構上留下足夠清楚的意圖,讓後人能夠被你留下的線索引導到一致的方向,並在他的當下
做出新的足夠好的策略再繼續前進,某方面來說,這才叫做程式「設計」XD。
希望這篇文章能給你帶來更多關於架構上的思考,也建議大家抽空去看講者影片,接收最純淨的 Raw Data!