剖析Haskell應用架構
第一次翻譯, 錯漏的地方多多包涵
寫於2015年11月16日
這是我想就在Capital Macth擔任CTO領導開發的經歷寫的系列文章里的第一篇, Capital Match是一個位於新加坡, 為中小企業, 私人和企業投資者提供P2P借貸服務的創業公司.
這篇文章主要關於系統自身的設計和架構, 在選型時候的權衡利弊. 最後在總結部分嘗試去評估現狀和反思這些選擇.
基礎設計選型
Haskell
儘管在這之前我並沒有太多專業從事Haskell開發的經驗, 但從一開始使用Haskell作為Capital Match的技術基礎是個顯然的選擇:
- Haskell是一個有著先進編譯器實現, 十分成熟的語言, 而且有一群極其聰明的人長期專註去改進它
- Haskell用來構建健壯Web應用的工具沒有Java或.net世界那樣成熟, 但是這個平台得益於充滿活力的社區和一些小眾但樂於貢獻的私人群體的努力, 進步神速
- Haskell開發者鳳毛麟角, 但數量持續在增長而且他們時常很有激情和天賦
- 我曾在2002年前用Haskell寫過一個業餘項目, 總是很想知道用Haskell構建系統是怎麼個感覺. 如今得償所願
- 因為Haskell嚴格地強制分離純和帶副作用的代碼, 所以Haskell鼓勵以又被稱為插口和適配器架構(一個通過適配器交互和外部世界交互的純領域核心)的六邊形架構來寫代碼
Event Sourcing
整個系統最初就被設計為一個Event Sourcing式的應用: 系統里事實(truth)的源頭是一系列的事件, 事件被定義為兩個狀態間的轉換. 在任意時間點上, 系統的當前狀態就是當前序列的領頭事件指向的任意狀態. 使用Event Sourcing背後的緣由是:
- 為了好玩以及探索架構設計的荒蕪之地, 而不是落入基於關係式資料庫的Web應用的窠臼
- 能夠審核和跟蹤所有影響平台數據的行為, 這個屬性非常適用於類銀行業務的系統. 之前我聽說過一些內幕說金融軟體最終都會實現用於跟蹤用戶行為和數據變動的日誌系統
- 不希望在系統中維護一個關係型資料庫. 我們也可以用SaaS里的關係式(或非關係式)資料庫去移除這個負擔, 但這意味著會帶來另一套新工具, 新技術的學習成本, 還有一系列帶有bug的驅動和依賴
- 個人傾向反對使用關係式資料庫作為運行時存儲[1]
- 至少在不需要負載均衡, 分區容錯和更通用的容錯的情況下簡化實現, 將所有事件追加在單個文件上就已經足夠, 我們的做法的確如此
- 避免語言的阻抗失配(impedance mismatch). 儘管有些爭議, 我們通常認為對象-關係模型阻抗失配確實存在. 在後面的論述中我認為SQL的問題是: SQL擅長寫出複雜的查詢[2]但是並不適合單純插入數據
架構
系統主要的介面是能夠獲取多種資源和操作這些資源的類RESTful API. 大多數使用JSON, 還有一些用CSV讓資源跟外部世界交換. 用戶界面只不過是調用這些API的單頁應用的客戶端. 同時也有一個可以訪問所有API的命令行客戶端用作管理.
模型
應用的核心是純函數式的並且由幾個松耦合的BusinessModel實例(想像在DDD裡面聚合這個用語)組成, 每個代表一個子領域: Accounting管理賬務和事務, Facility管理貸款的生命周期, Investor和Borrower 管理用戶信息和角色依賴數據, User管理用戶的註冊, 授權和設置.
BusinessModel的定義如下:
class BusinessModel a where data Event a :: * data Command a :: * init :: a act :: Command a -> a -> Event a apply :: Event a -> a -> a
- 模型生成事件的類型
- 模型能夠處理的命令的類型
- 模型的初始值
- 一對用來描述生成事件的命令如何作用在模型上和應用事件後模型如何變化的函數
每個BusinessModel實例的狀態由在應用啟動的時候載入和應用所有已經存下來的事件到初始(init)的模型上計算出來. 模型一直在內存里, 事件都被持久化存下來. 初始化的過程在我們運作的這種小規模的情況下需要好幾秒.
每一個模型劃分事務間的界限以及作為系統一致性的單元. 命令和事件會相繼地出現在模型上.
服務
在BusinessModels之上的服務為提供提供介面. 服務編排與一個或多個模型之間的交互. 在最簡單的層次里, 一個服務單純由單個指令和一個BusinessModels構成, 複雜一點, 在不同的幾個模型之間同步指令. 基於 Life Beyond Distributed Transactions 里的構想, 一個服務表示系統與單個用戶交互的狀態, 例如一個請求或者系統在維護若干數據.
因為服務作為外部世界和系統的交互的代理, 它需要在不純的環境下操作, 因此專門給它定義了一個叫WebM的Monad. 當服務去執行查詢, 或者一個事件指向輸出的請求的時候, 通常會返回一些可表示的類型. WebM是實際上是Monad Transformer WebStateM 在IO之上的實例, 因此它是不純的. 它能夠訪問兩部分的狀態.
以下是WebStateM的定義
newtype WebStateM shared local m a = WebStateM { runWebM :: TVar shared -> local -> m a }type WebM a = forall s . EventStore s => WebStateM SharedState LocalState s atype WebM a = forall s . EventStore s => WebStateM SharedState LocalState s a
這裡簡化為一個帶有兩塊不同數據的Reader monad:
- LocalState 裝有單個查詢相關的信息(例如user id, request id, 時間等等)
- ShareState 是一個在所有請求里共享的TVar(在STM monad內寄存的事務變數)
- EventStore 約束意味著我們需要一個下層的monad來提供訪問持久化存儲
不少主要的服務使用作為系統臨界區的通用applyCommnad函數, 這個函數的主要職責是:
- 把指令應用和更新到已經保存的模型
- 把事件持久化到"資料庫"
- 把事件分派到相關的組件
Web
REST介面由一個簡單的基於WAI和warp[3]的框架: scotty 提供. 大多數action的handler相當簡單:
- 從請求的body裡面提取參數或者JSON
- 調用服務
- 響應HTTP請求:
- 將查詢結果序列化為JSON或者其他請求類型
- action返回對客戶端來說有意義的事件
- 加入Request-Id 頭
- 鑒權和授權
- 健全檢查(例如: 負載的大小)
- 打日誌
- 緩存和靜態數據服務
迷失於事務
執行一個用戶觸發的動作在某種意義上是一系列發生在不同風格層次間的事務:
- 用REST到WebM使用
inWeb :: WebStateM CapitalMatchState LocalState m a -> ActionT e (WebStateM CapitalMatchState LocalState m) a
- 用WebM到STM Model使用liftIO . atomically
- 最終在Model會落到純的業務域里
概念上有這些層次的Monad, 類型如下:
model :: Command -> StateT STM Model (Event Model)service :: WebM (Event Model)web :: ActionT ()
Monad的層次輪廓看起來有著相當分明, 有著不同的表達方式:
- 給模型表達原子性變更的風格(例如: CRUD范), 例如RegisterTransaction, UpdateProfile or CloseFacility這些操作
- 給服務表達直接變更模型或者依賴多個指令的交互才能完成的複雜交互(像addPledge或 acceptFacility)的風格
- 給Web表達HTTP請求響應, 構造JSON, 委託工作給服務的風格. 這種表達方式用來表述某些東西
橫向關注點
並發
並發主要在Warp和Scotty之上的REST層來處理, 每個請求並發地由GHC/Haskell的輕量級線程來處理. 在這之上有一系列的線程在應用里:
- 每個handler有一個(現在有兩個)處理日誌信息的線程
- 一個處理底層讀寫請求到時間文件的線程
- 事件存儲驅動線程[4]
- 用來周期性檢測和通知其他線程死活的心跳線程
開頭的目標是強制嚴格地分離不同業務模型, 著眼於讓不同的服務可以單獨部署通訊. 但這原則再三被違背, 幾個月後不得放棄轉而去構建一個有著不受控制的跨層次和領域依賴的單體應用. 之後著手去做一些必要的重構, 重回到清晰的狀態: 讓STM事務在單個指令的層次通過applyCommand函數來操作.
持久化和存儲
持久化通過一個專門的事件匯流排來管理: 事件首先被打包到一個透明的存儲事件對象, 對象里包含一些可讓系統跟蹤的元數據:
- 事件版本(見下面的章節)
- 一個把具體事件類型編碼到值層面的事件類型
- 時間戳(UTC)
- 記錄用戶生成時間的ID
- 原始請求ID
- git提交的SHA1值, 例如是代碼的版本 (感謝Geoffroy Couprie的建議)
- 一個ByteSting類型序列化後的事件
然後StoredEvent被推送到一個將事件存在文件驅動線程. 物理存儲是一個簡易只追加的文件,文件里包含一系列已經應用過並被序列化為二進位格式(類Kafka)的事件. 現在遷移到一個更加健壯的解決方案:
- 遷移數據存儲到外部的進程或者機器
- 增加冗餘來提高容錯性
- 通過分散式共識演算法提供強一致性
版本化事件
事件存起來的時候會帶有一個遞增的版本號. 在事件結構被改變的時候版本號都會改變: 例如在record里增加欄位, 改變欄位的類型等等. 當事件被持久化的時候, 把當前在應用中的版本號也存在來. 當事件被讀回來(例如序列化)的時候重現系統的狀態, 這個版本號通常用來選擇正確的讀取函數.
因此修改事件的結構需要在開發的時候引入這些步驟:
- 編寫反序列當前版本號的測試[5]
- 提高版本號
- 寫新代碼
- 寫將舊版本的事件遷移到新的事件的代碼
這個機製為底層的存儲增添了一些有趣的性質:
- 存儲的事件都是不可變的, 因為存儲系統是只追加的, 從來不需要重寫過去的事件
- 在任何時間點都可以重建整個系統(代碼和數據)
前端
前端的代碼部分放在服務端另外一部分是全客戶端代碼:
- HTML通過標準介面請求里的Accept頭由服務端生成和託管, 使用 blaze-html 的組合子來用Haskell描述頁面
- 靜態資源由 wai-middleware-static 託管
但是不少的UI工作用 Om 在客戶端完成. Om是一個 React[6] 的 Clojurscript 介面. 後端UI代碼和前端代碼的交互方法是: UI維護自己的狀態和通過Ajax調用來更新服務端那邊相應的狀態. 在用戶看來這個是個單頁應用.
日誌和監控
日誌的構造是用對應的函數來消耗隊列里的日誌事件. 所有查詢, 執行過的指令和出現在系統里的其他事件 -- 應用啟動關閉, 心跳, I/O錯誤,存儲都會被記錄. 為了避免敏感數據在寫日誌的過程中泄露, 有一個叫redact的函數會在數據傳給日誌系統前將指令改寫.
現在分別有兩個不同的日誌後端:
- 輸出JSON格式的事件到stdout的
- 用riemann-hs 輸出部分事件到Riemann的. 這些事件之後被用作監控基礎設施的推送(會在下篇詳細說明)
當啟動的時候把配置郵件給開發組, 這是個對生產環境很有用的檢查, 郵件里會包含應用的版本和用來啟動的指令.
反思
從我開始開發Capital Match平台已經一年有多. 我跟大家都犯過錯, 並不是所有事情都像大家想像中的順利, 而且這僅僅是千里之行的第一步. 一年過後是個合適時間去停下或者放慢腳步來反思做到, 做好和做錯的事. 下一節會嘗試對落地的架構給出一個更加主觀的評價, 還有下一步需要做的事.
好的, 壞的, 挫的
我們從2015年3月開始上線, ...處理金額到三百萬加幣這期間為在沒有中斷過主要的服務的情況下給新加坡的中小企業提供貸款...這是個非常激動人心的結果: 整個平台以平滑的方式持續改進[7].
下面是我覺得我們的方法里做的好的地方, 包含我們用到的一些技術(Haskell, Om/ClojureScript)和架構:
- 強大和表達力豐富的類型大大提高代碼的可信任度[8]. 在GHC提供的眾多特性裡面, 有一些簡單明了的特性已經被用到我們的日常開發裡面:
- newtype在Haskell里很輕量, 會在運行時被解包, 但可以在編譯期用來實現很直白的類型簽名, 不再有基於字元串的代碼, 例如: UserId, PassportNt 或 EMail,
- Phantom types 是用來區分不同但有相同表現的對象的簡單方法, 例如編碼到BytesString的時候
- 用Tyep-Class來定義介面很實用, 而且可以帶默認實現. 多數時候會用MultiParamTypeClasses來表達系統不同部分間的關係
- Existential types在打包通用透明類型的時候會很有用, 例如寫日誌或者做序列化這些無需關心實現細節的情況
- 但大多數時間用簡單的類型加上一堆構造器就很清晰了
- 使用-Wall -Werror編譯選項來找出沒有用到的變數(死代碼), 重載變數名(可能出現問題), 沒有匹配完整的模式匹配(會出現運行時錯誤) 這種東西
- Event Souring極大簡化了存儲管理和減少與關係資料庫類型映射的干擾, 就不說版本間遷移的管理和DBMS維護的負擔了
- 因為帶版本號的事件存在大文件里, 查詢可以直接用Haskell來做, 你可以檢索事件流, 用它來構建在內存里的狀態, 然後在GHCi或者Emacs REPL里操作這個狀態[9]. 漸漸地我們寫了不少小型的Haskell腳本來實現複雜的查詢(例如過濾用戶)或者常規的一次性數據轉換
- Scotty, WAI和Warp讓快速開發和維護REST介面變得簡單, 無論是在介面還是中間件方面
- 經常使用像fromJust甚至像head這樣的partial functions, 這會讓事情一開始變得簡單但是會導致在運行的時候炸了. 得到的教訓是: 總是使用total functions
- 太多使用typeclass, 這可能因為我Java的背景認為用介面來抽象時間好事. 漸漸地, 我會用新的typeclass來對應新的行為, 這會導致類型很混亂
- 對保持短的編譯時間不上心, 整個應用變得越來越大, 我們沒有足夠在意把它拆分避免它膨脹. 編譯時間越來越長現在已經成了問題. 得到的教訓: 儘早激進地拆分代碼以及不用顧慮一個包里只有一兩個文件
- 太信賴基於DeriveGeneric的JSON序列化實現, 生成龐大類型的通用實例超出想像地增加編譯時間. 教訓: 使用更多 TemplateHaskell導出或者靈活性更強的自定義 ToJSON/FromJSON實例[10]
- 只能用Haskell訪問數據意味著非技術人員要麼去學習Haskell要麼通過技術人員來訪問數據. 這在小團隊裡面不成問題但隨著公司增長很快會成為一個問題. 這就是 CQRS 能很好補充Event Sourcing式系統的地方, 從我們的數據構建若干關係模型和保持RDBMS定期更新是很簡單的事
- 在不同的業務模型間分離關注點做得越來越馬虎, 導致很多模型耦合在一起
- 事件不可逆意味著在事件流裡面通過前進或者後退來輕易選到想要的狀態
- 服務端生成HTML. 在項目開始的時候我們有靜態的HTML文件. 我們轉而用Blaze 在服務端生成HTML因為我們想要控制HTML的結構:
- 處理前端的開發/生產環境: 代碼在不同的模式里有不同的優化程度和引用不用的腳本
- 管理特性開關(feature toggles), 從而使不用的用戶可以在賬戶設置里有不用的UI. 這在處理UI遷移的時候也很有用
- 但是這個策略也引入了很多問題:
- 有時候改UI需要重新編譯, 例如修改頁面結構來實現feature的時候
- 前端設計師和開發很難合作
- 讓前端和後端過於耦合
- REST介面的數量似乎增長得太快了. 應該需要用query參數來把之前對外的介面抽象起來
下一步?
在這一年的時間裡Haskell社區發生了很多變化, 很多實驗性或者不方便的技術變得足夠成熟可以在生產環境里使用: 因為有stack, ghcjs構建和使用起來容易多了, 還有像reflex這樣成熟的前端解決方案, GHC 7.10帶來很多改進(和像TAP proposal這種有爭議性的向後不兼容) . Gabriel Gonzalez維護了一個Haskell生態系統現狀的網頁提供有意思的概覽: Haskell什麼火起來了, 什麼沒有.
這些是接下來改進這個系統的挑戰:
- 服務方面: 儘管一開始是個良好的傾向, 我們依然構建的一個monolith的代碼庫, 儘管後面一個接一個地拆出來不是件困難的事. 我們需要通過下面的方式將monolith架構拆分成組件服務來增加開發流程和系統的健壯性, 應變性和可拓展性
- 用膠水代碼來將整個應用連起來
- 編寫支持性代碼
- 每個相關的服務為一個組件
- 每個模型一個組件, 儘可能集群化
- 理想的情況下每個組件應該可以獨立部署或者同個進程內關聯的組件在所需的粒度和冗餘度里. 這很容易根據配置拓撲在膠合代碼的配置里達成
- 性能方面: 我嘗試遵循"Make it, make it right, make it fast." 這個簡單的開發宗旨. 大多時候我會在第一步里完成大部分內容然後再更近一步, 所以讓它變得跟快是一個隨著用戶規模和數據量增長的挑戰. 在跟前有幾個方面是可以改進的: 緩存計算結果, 更好的數據結構, 改進在些關鍵部分的嚴肅求值...當然最先一步需要衡量和設立這些性能優化的目標
- 健壯性方面: 沒有系統可以從故障中倖免, 但這也要看這個故障到底影響到什麼程度. 我們絕對有些可以用標準化副本和冗餘技術改進的地方, 將系統拆分成合適的粒度是邁向這個目標的第一步, 但是需要特定的組件在當前的故障中保持一致性
總結
這篇文章已經變得很長, 但這只是對整個系統的概觀, 畢竟把一年緊密的工作概括起來太難了!在未來的系列博文里, 打算闡釋這篇文章沒有包含的方面: 開發和生產環境的基礎設施, 從後端開發者的角度來看前端的開發, 以及開發流程.
最後要說的是, 我十分感謝以下這些人, 沒有他們的幫助這個歷程將不會存在: Pawel Kuznicki, Chun Dong Chau, Pete Bonee, Willem van den Ende, Carlos Cunha, Guo Liang 「Sark」 Oon, Amar Potghan, Konrad Tomaszewski 以及在Capital Match的所有人. 我還要感謝 Corentin Roux-dit-Buisson, Neil Mitchell, Joey Hess 他們給予我的支持和反饋.
* * *
腳註:
- 我從90年代開始用RDBMS, 用來Access來開發POS應用, 在從98年用了幾個版本的PostgreSQL, 最近在講資料庫遷移過程集成到一個龐大的系統里. 我並不是專家但是我有好幾年相當大量的經驗在關係資料庫里以及我總是發現快速寫入到資料庫是件痛苦的事.
- 儘管有人曾經吐槽說已經有一個語言: "Excel", 可以讓不用SQL以精妙的方式寫複雜查詢和探索數據
- Servant 肯定在我們計劃的路線里
- 目前這個線程對於存儲線程來說比較多餘的, 原本是用計劃用來序列化模型上的applyCommand的指令
- 我們使用 QuickCheck 給利息類型生成多種事件
- 看上去Om很快會被將要發布的Om.next取代
- 但是強類型系統不能取代密集的測試, 因為明顯的有些bug是出現在系統的邊界部分, 例如調用REST API的時候
- 顯然, 這隻要數據能夠放進內存里. 我賭好久之後還會這樣. 這應該會成為一個問題, 我們很可能處於可以解決它的位置
- 這種方法已經用在 hdo 裡面, 因為外部的表示已經定義好了, 在最後讓編碼更加明顯和更加容易去處理
推薦閱讀:
※有沒有比較實用、成熟的 Haskell 應用?
※Haskell等語言中的模式匹配在C++中如何實現?
※ZJU Lambda2017秋納筆試
※Haskell中Monad與Applicative的關係?
※該如何理解Monad?