這是 Clojure component 框架的簡介揩晴,里面涉及了關(guān)于狀態(tài)管理和依賴注入的設(shè)計(jì)思路蔫巩,值得借鑒髓梅。
Component 是一個微型的 Clojure 框架用于管理那些包含運(yùn)行時狀態(tài)的軟件組件的生命周期和依賴褒脯。
這主要是一種用幾個輔助函數(shù)實(shí)現(xiàn)的設(shè)計(jì)模式追他》啬迹可以被看成是使用不可變數(shù)據(jù)結(jié)構(gòu)的依賴注入風(fēng)格。
觀看 Clojure/West 2014 年的視頻 (YouTube, 40 minutes)(YouTube, 40分鐘)
發(fā)布和依賴信息
- 發(fā)布 release 版本到Clojars
- 最新的穩(wěn)定發(fā)布版本是0.3.2
- 全部發(fā)布版本
[Leingen] 依賴信息邑狸;
[com.stuartsierra/component "0.3.2"]
Maven 依賴信息
<dependency>
<groupId>com.stuartsierra</groupId>
<artifactId>component</artifactId>
<version>0.3.2</version>
</dependency>
Gradle 依賴信息:
compile "com.stuartsierra:component:0.3.2"
依賴和兼容性
從 0.3.0 版本的 Component 開始懈糯,需要 1.7.0 及其以上版本的 Clojure 或 ClojureScript 以便提供 Conditional Read 支持。
0.2.3 版本的 Component 兼容 Clojure 1.4.0 及其以上版本单雾。
Component 需要依賴我的 dependency 庫
討論
請?jiān)?Clojure Mailling List 提問赚哗。
介紹
顧名思義,一個 component 就是一組共享運(yùn)行時某些狀態(tài)的函數(shù)或過程硅堆。
一些 component 的例子:
- 數(shù)據(jù)庫訪問:共享數(shù)據(jù)庫連接的查詢屿储、插入函數(shù)
- 外部的 API 服務(wù):共享一個 HTTP 連接池的數(shù)據(jù)發(fā)送和接收函數(shù)
- Web 服務(wù)器:共享所有應(yīng)用程序運(yùn)行時狀態(tài)枝冀,比如 session store挥唠,的函數(shù),用于處理不同的路由试疙。
- 內(nèi)存式緩存:在一個共享的可變引用當(dāng)中獲取或者設(shè)置數(shù)據(jù)的函數(shù)茄菊,比如 Clojure 中的 Atom 或 Ref疯潭。
Component 和面向?qū)ο缶幊汤锏膶ο蠖x在理念上很類似赊堪。但這并不會動搖 Clojure 這門編程語言中純函數(shù)和不可變數(shù)據(jù)結(jié)構(gòu)的地位。大部分函數(shù)依然是函數(shù)竖哩,大多數(shù)數(shù)據(jù)也還是數(shù)據(jù)哭廉。而 Component 嘗試在函數(shù)式編程范式中輔助管理有狀態(tài)的資源。
Component 模型的優(yōu)點(diǎn)
大型應(yīng)用經(jīng)常由多個有狀態(tài)的進(jìn)程構(gòu)成相叁,這些進(jìn)程必須以特定的順序啟動和關(guān)閉遵绰。Component 模型讓這些關(guān)系變得比命令式代碼更直觀且表意。
Component 為構(gòu)建 Clojure 應(yīng)用提供了一些基本的指導(dǎo)钝荡,包括系統(tǒng)不同部分間的邊界街立。Component 提供了一些封裝以便將相關(guān)的實(shí)體聚合。每個 component 僅僅持有它所需的引用埠通,拒絕不必要的共享狀態(tài)赎离。有別于遍歷深層嵌套的 map,component 至多需要查找一個 map 就能獲取任何東西端辱。
與將可變的狀態(tài)分散到不同的命名空間的做法不同梁剔,應(yīng)用的所有有狀態(tài)的部分都可以被聚合到一起。某些情況下舞蔽,使用 component 可以不需要共享可變引用荣病。舉個例子,存儲當(dāng)前的數(shù)據(jù)庫資源鏈接渗柿。與此同時个盆,通過單個 system 對象維護(hù)所有可達(dá)狀態(tài),可以更加容易地從REPL 查看任意部分的應(yīng)用狀態(tài)朵栖。
出于測試目的颊亮,我們需要來回切換 stub 和 mock。Component 依賴模型讓 這種實(shí)現(xiàn)方式變得容易陨溅,因?yàn)椴恍枰蕾嚺c時間相關(guān)的構(gòu)造了终惑,比如with-redefs
或者 binding
,它們在多線程的代碼中經(jīng)常會導(dǎo)致競爭條件门扇。
對于和應(yīng)用相關(guān)聯(lián)的狀態(tài)雹有,如果能連貫地創(chuàng)建并清除這些狀態(tài),就能夠保證無需啟動 JVM 就能快速構(gòu)建出開發(fā)環(huán)境臼寄,這也可以讓單元測試變得更快更獨(dú)立霸奕,由于創(chuàng)建和啟動一個 system 的開銷很小,所以每個測試都能夠創(chuàng)建一個新的 system 實(shí)例吉拳。
Component 模型的缺點(diǎn)
首先特別重要地质帅,當(dāng)應(yīng)用的所有部件都遵循相同的模式,那么這個框架會工作得很好。不過临梗,對于一個遺留系統(tǒng),除非進(jìn)行大量重構(gòu)稼跳,否則很難設(shè)施 Component 模型盟庞。
Component 假設(shè)所有的應(yīng)用狀態(tài)都是通過參數(shù)的形式傳遞給使用到它的函數(shù)中的。這樣會導(dǎo)致很難應(yīng)用到那些依賴全局或者單例引用的代碼汤善。
對于小型的應(yīng)用什猖,在 component 之間聲明依賴關(guān)系可能比手工按序啟動所有 component 來的麻煩。不過即便如此红淡,你也可以單獨(dú)使用 Lifecycle protocol 而不去使用依賴注入特性不狮,只不過 component 的附加價值就變小了。
框架產(chǎn)生的 system 對象是一個巨大并且有很多重復(fù)的復(fù)雜 map在旱。同樣的 component 可能會在 map 的多個地方出現(xiàn)摇零。盡管這種因?yàn)槌志没臄?shù)據(jù)結(jié)構(gòu)導(dǎo)致的重復(fù)產(chǎn)生的內(nèi)存開銷可以忽略不計(jì),但是 system map 一般都因?yàn)樘蠖鴽]法可視化出來以方便檢測桶蝎。
你必須顯式地在 component 之間指定依賴關(guān)系驻仅,代碼本身不能自動發(fā)現(xiàn)這些關(guān)系。
最后登渣,component 之間不允許有環(huán)依賴噪服。我相信環(huán)形依賴通常都暗示架構(gòu)有瑕疵,可以通過重新構(gòu)造應(yīng)用得以消除胜茧。在極少數(shù)的情況下粘优,環(huán)形依賴無法避免,那么你可以使用可變的引用來管理它呻顽,不過這就超出了 component 的范圍雹顺。
使用
(ns com.example.your-application
(:require [com.stuartsierra.component :as component]))
創(chuàng)建 component
通過定義實(shí)現(xiàn)了Lifecycle
協(xié)議的 Clojure record 創(chuàng)建一個 component。
(defrecord Database [host port connection]
;; Implement the Lifecycle protocol
component/Lifecycle
(start [component]
(println ";; Starting database")
;; In the 'start' method, initialize this component
;; and start it running. For example, connect to a
;; database, create thread pools, or initialize shared
;; state.
(let [conn (connect-to-database host port)]
;; Return an updated version of the component with
;; the run-time state assoc'd in.
(assoc component :connection conn)))
(stop [component]
(println ";; Stopping database")
;; In the 'stop' method, shut down the running
;; component and release any external resources it has
;; acquired.
(.close connection)
;; Return the component, optionally modified. Remember that if you
;; dissoc one of a record's base fields, you get a plain map.
(assoc component :connection nil)))
可以選擇提供一個構(gòu)造函數(shù)芬位,接收 component 的初始化配置參數(shù)无拗,讓運(yùn)行時狀態(tài)為空。
(defn new-database [host port]
(map->Database {:host host :port port}))
定義實(shí)現(xiàn)了 component 行為的函數(shù)昧碉,并接收一個 component 的實(shí)例作為參數(shù)英染。
(defn get-user [database username]
(execute-query (:connection database)
"SELECT * FROM users WHERE username = ?"
username))
(defn add-user [database username favorite-color]
(execute-insert (:connection database)
"INSERT INTO users (username, favorite_color)"
username favorite-color))
定義該 component 所依賴的其他 component。
(defrecord ExampleComponent [options cache database scheduler]
component/Lifecycle
(start [this]
(println ";; Starting ExampleComponent")
;; In the 'start' method, a component may assume that its
;; dependencies are available and have already been started.
(assoc this :admin (get-user database "admin")))
(stop [this]
(println ";; Stopping ExampleComponent")
;; Likewise, in the 'stop' method, a component may assume that its
;; dependencies will not be stopped until AFTER it is stopped.
this))
不用把 Component 的依賴傳入構(gòu)造函數(shù)
System 負(fù)責(zé)把運(yùn)行時依賴注入到其中的 Component被饿,下個章節(jié)會提到:
(defn example-component [config-options]
(map->ExampleComponent {:options config-options
:cache (atom {})}))
System
component 被組合到 system 中四康。一個 system 就是一個知道如果啟停其他 component 的 component。它也負(fù)責(zé)將依賴注入到 component 中狭握。
創(chuàng)建 system 最簡單的方式就是使用system-map
函數(shù)闪金,就像hash-map
或者array-map
構(gòu)造方法一樣,接收一系列的 key/value 對。Key 在 system map 中都是 keyword哎垦,Value 在其中則是 Component 的實(shí)例囱嫩,一般是 record 或者 map。
(defn example-system [config-options]
(let [{:keys [host port]} config-options]
(component/system-map
:db (new-database host port)
:scheduler (new-scheduler)
:app (component/using
(example-component config-options)
{:database :db
:scheduler :scheduler}))))
使用using
函數(shù)在 component 之間指定依賴關(guān)系漏设。using
接收一個component 和一組描述依賴的 key墨闲。
如果 component 和 system 使用了相同的 key,那么你可以用一個 vector 的 key 指定依賴郑口。
(component/system-map
:database (new-database host port)
:scheduler (new-scheduler)
:app (component/using
(example-component config-options)
[:database :scheduler]))
;; Both ExampleComponent and the system have
;; keys :database and :scheduler
如果 component 和 system 使用不同的 key鸳碧,那么得以 {:component-key :system-key}
的方式指定依賴,也就是犬性,using
的 key 和 component 中的 key 匹配瞻离,而 value 則和 System 中的 key 匹配。
(component/system-map
:db (new-database host port)
:sched (new-scheduler)
:app (component/using
(example-component config-options)
{:database :db
:scheduler :sched}))
;; ^ ^
;; | |
;; | \- Keys in the system map
;; |
;; \- Keys in the ExampleComponent record
system map 提供了自己對于 Lifecycle 協(xié)議的實(shí)現(xiàn)乒裆,使用依賴信息(存儲在每個 component 的元數(shù)據(jù))以正確的順序啟動 component套利。
在開始啟動每個 component 之前,System 會基于 using
提供的元數(shù)據(jù) assoc
它的依賴缸兔。
還是用上面的例子日裙,ExampleComponent 將會像下面那樣啟動起來。
(-> example-component
(assoc :database (:db system))
(assoc :scheduler (:sched system))
(start))
調(diào)用stop
方法關(guān)停 System惰蜜,這會逆序地關(guān)閉每個 component昂拂,然后重新關(guān)聯(lián)每個 component 的依賴。
什么時間給 component 關(guān)聯(lián)上依賴是無關(guān)緊要的抛猖,只要發(fā)生在調(diào)用start
方法之前格侯。如果你事先知道 system 中所有 component 的名字,你就可以選擇添加元數(shù)據(jù)到 component 的構(gòu)造方法中:
(defrecord AnotherComponent [component-a component-b])
(defrecord AnotherSystem [component-a component-b component-c])
(defn another-component [] ; constructor
(component/using
(map->AnotherComponent {})
[:component-a :component-b]))
作為可選項(xiàng)财著,component 依賴可以通過 system-using
方法給所有 component 一次性指定联四,接收一個從 component 名稱指向其依賴的 map。
(defn example-system [config-options]
(let [{:keys [host port]} config-options]
(-> (component/system-map
:config-options config-options
:db (new-database host port)
:sched (new-scheduler)
:app (example-component config-options))
(component/system-using
{:app {:database :db
:scheduler :sched}}))))
生產(chǎn)環(huán)境的入口
component 并沒有規(guī)定你如何存儲 system map 或者使用包含其中的 component撑教,這完全看你個人朝墩。
通常區(qū)別開發(fā)和生產(chǎn)的方法是:
在生產(chǎn)環(huán)境下,system map 是生命短暫的伟姐,它被用于啟動所有 component收苏,然后就銷毀了。
當(dāng)你的應(yīng)用啟動后愤兵,例如在main
函數(shù)中鹿霸,構(gòu)造了一個system的實(shí)例并且在其上調(diào)用了component/start
方法,之后就無法控制在你的應(yīng)用中代表“入口點(diǎn)”的一個或多個 component 了秆乳。
舉個例子懦鼠,你有個 web server component 開始監(jiān)聽 HTTP 請求钻哩,或者是一個事件輪訓(xùn)的 component 在等待輸入。這些 component 每個都可以在它生命周期的start
方法中創(chuàng)建一個或者多個線程肛冶。那么main
函數(shù)可以是這樣的:
(defn main [] (component/start (new-system)))
注意:你還是得保證應(yīng)用的主線程一直運(yùn)行著以免JVM關(guān)閉了街氢。一種方法就是阻塞主線程,等待關(guān)閉的信號睦袖;另一種方法就是使用Thread/join
(轉(zhuǎn)讓)主線程給你的 component 線程阳仔。
該方式也能配合類似 Apache Commons Daemon 的命令行驅(qū)動一起很好地工作。
開發(fā)環(huán)境的入口
開發(fā)過程中扣泊,一般引用一個 system map 然后在 REPL 中測試它是很有用的。
最簡單的方式就是在 development 命名空間中使用def
定義一個持有 system map 的 Var嘶摊。使用alter-var-root
啟停延蟹。
RELP 會話的例子:
(def system (example-system {:host "dbhost.com" :port 123}))
;;=> #'examples/system
(alter-var-root #'system component/start)
;; Starting database
;; Opening database connection
;; Starting scheduler
;; Starting ExampleComponent
;; execute-query
;;=> #examples.ExampleSystem{ ... }
(alter-var-root #'system component/stop)
;; Stopping ExampleComponent
;; Stopping scheduler
;; Stopping database
;; Closing database connection
;;=> #examples.ExampleSystem{ ... }
查看 reloaded 模板獲取更詳細(xì)的例子
Web Applications
很多 Clojure 的 web 框架和教程都圍繞一個假設(shè),即 handler 會作為全局的 defn
存在叶堆,而無需任何上下文阱飘。在這個假設(shè)底下,如果不把 handler 中的任意應(yīng)用級別的上下文變成全局的def
虱颗,就很難去使用它沥匈。
component 傾向于假設(shè)任意 handler 函數(shù)都會接收 state/context 作為其參數(shù),而不依賴任何全局的狀態(tài)忘渔。
為了調(diào)和這兩種方法高帖,就得創(chuàng)建一種 handler 方法作為 Lifecycle start
方法的包含一個或多個 component 的閉包。然后把這個閉包作為 handler 傳遞給 web 框架畦粮。
大部分 web 框架或者類庫都會提供一個靜態(tài)的defroutes
或者類似的宏會提供一個相等的非靜態(tài)的routes
方法來創(chuàng)建一個閉包散址。
看上去像這樣:
(defn app-routes
"Returns the web handler function as a closure over the
application component."
[app-component]
;; Instead of static 'defroutes':
(web-framework/routes
(GET "/" request (home-page app-component request))
(POST "/foo" request (foo-page app-component request))
(not-found "Not Found")))
(defrecord WebServer [http-server app-component]
component/Lifecycle
(start [this]
(assoc this :http-server
(web-framework/start-http-server
(app-routes app-component))))
(stop [this]
(stop-http-server http-server)
this))
(defn web-server
"Returns a new instance of the web server component which
creates its handler dynamically."
[]
(component/using (map->WebServer {})
[:app-component]))
更多高級使用方式
錯誤
在啟停 system 的時候,如果任何 component 的 start
或者 stop
方法拋出了異常宣赔,start-system
或者 stop-system
方法就會捕獲并把它包裝成 ex-info
異常和一個包含下列 key 的 ex-data
map预麸。
:system
是當(dāng)前的 system,包含所有已經(jīng)啟動的 component儒将。:component
是導(dǎo)致該異常的 component 及其已經(jīng)注入的依賴吏祸。
這個 component 拋出的原始異常,可以調(diào)用該異常的 .getCause
方法獲取钩蚊。
Component 不會對 component 進(jìn)行從錯誤中恢復(fù)的嘗試贡翘,不過你可以使用 :system
附著到這個 exception 然后清除任何部分構(gòu)造的var
由于 component map 可能很大且有許多的重復(fù),你最好不要記日志或者打印出異常两疚。這個 ex-without-components
幫助方法會從 exception 中去除大對象床估。
ex-component?
幫助方法可以告訴你一個異常是否來源于 component 或者被一個 component 包裝過。
冪等
你可能發(fā)現(xiàn)了把 start
和 stop
方法定義成冪等的是很有用的诱渤。例如丐巫,僅僅當(dāng) component 沒有啟動或者沒有關(guān)閉時才進(jìn)行操作。
(defrecord IdempotentDatabaseExample [host port connection]
component/Lifecycle
(start [this]
(if connection ; already started
this
(assoc this :connection (connect host port))))
(stop [this]
(if (not connection) ; already stopped
this
(do (.close connection)
(assoc this :connection nil)))))
Component 沒有要求 stop/start 是冪等的,但是在發(fā)生錯誤后递胧,冪等會易于清除狀態(tài)碑韵。由于你可以隨意地在任何東西上調(diào)用 stop
方法。
除此之外缎脾,你可以把 stop 包在 try/catch 中從而忽略所有異常祝闻。這種方式下,導(dǎo)致一個 component 停止工作的錯誤并不能保證其他 component 完全關(guān)閉遗菠。
(try (.close connection)
(catch Throwable t
(log/warn t "Error when stopping component")))
無狀態(tài)的 Component
Lifecycle
的默認(rèn)實(shí)現(xiàn)是個空操作联喘。如果一個 component 省略了 Lifecycle
的協(xié)議,它還是能參與到依賴注入的過程中辙纬。
無需 lifecycle 的 component 可以是一個普通的 Clojure map豁遭。
對于任何實(shí)現(xiàn)了 Lifecycle
的 component,你不能忽略 start
或者 stop
贺拣,必須都提供蓖谢。
Reloading
我開發(fā)了這種結(jié)合我的"reloaded"工作流的 workflow 模式,為了進(jìn)行開發(fā)譬涡,我會創(chuàng)建一個 user
的命名空間如下:
(ns user
(:require [com.stuartsierra.component :as component]
[clojure.tools.namespace.repl :refer (refresh)]
[examples :as app]))
(def system nil)
(defn init []
(alter-var-root #'system
(constantly (app/example-system {:host "dbhost.com" :port 123}))))
(defn start []
(alter-var-root #'system component/start))
(defn stop []
(alter-var-root #'system
(fn [s] (when s (component/stop s)))))
(defn go []
(init)
(start))
(defn reset []
(stop)
(refresh :after 'user/go))
使用說明
不要把 system 到處亂傳
頂級的system記錄只是用來啟停其它 component 的闪幽,主要是為了交互開發(fā)時比較方便。
上面的 “xx入口”有詳細(xì)介紹涡匀。
任何函數(shù)都不應(yīng)該接收 system 作為參數(shù)
應(yīng)用層的函數(shù)絕對不該接收 system 作為參數(shù)盯腌,因?yàn)楣蚕砣譅顟B(tài)是沒有道理的。
除此之外陨瘩,每個函數(shù)都應(yīng)該依據(jù)至多依賴一個 component 的原則來定義自己腊嗡。
如果一個函數(shù)依賴了幾個 component,那么它應(yīng)該有一個自己的 component拾酝,在這個 component 里包含對其它 component 的依賴燕少。
任何 component 都不應(yīng)該知曉包含自己的 system
每個 component 只能接受它所依賴 component 的引用。
不要嵌套 system
在技術(shù)上蒿囤,嵌套system-map
是可能的客们。但是,這種依賴的影響是微妙的材诽,并且也容易迷惑人底挫。
你應(yīng)該給每個 component 唯一的鍵,然后把他們合并到同一個 system 中脸侥。
其它類型的 component
應(yīng)用或者業(yè)務(wù)邏輯可能需要一個或多個 component 來表達(dá)建邓。
當(dāng)然,component 記錄除了Lifecycle
睁枕,可能還實(shí)現(xiàn)了其它的協(xié)議官边。
除了map和record沸手,任何類型的對象都可以是 component,除非它擁有生命周期和依賴注簿。舉個例子契吉,你可以把一個簡單的Atom或者core.async Channel放到 system map 中讓其它 component 依賴。
測試替身
component 的不同實(shí)現(xiàn)(舉個例子诡渴,測試樁)可以在調(diào)用start
之前捐晶,通過assoc
注入到system當(dāng)中。
寫給庫作者的注意事項(xiàng)
Component旨在作為一個工具提供給應(yīng)用程序妄辩,而不是可復(fù)用的庫惑灵。我不希望通用庫在使用它的應(yīng)用程序上強(qiáng)加任何特定的框架。
也就是說眼耀,庫作者可以通過遵循下面的指導(dǎo)原則輕松地讓應(yīng)用程序?qū)⑵鋷旌虲omponent 模式結(jié)合起來使用:
絕對不要創(chuàng)建全局的可變狀態(tài)(舉個例子泣棋,用
def
定義的Atom或者Ref)絕對不要依賴動態(tài)綁定來傳達(dá)狀態(tài)(例如,當(dāng)前數(shù)據(jù)庫的鏈接)畔塔,除非該狀態(tài)有必要局限于單個線程。
絕對不要頂級的源代碼文件上操作副作用鸯屿。
用單個數(shù)據(jù)結(jié)構(gòu)封裝庫依賴的運(yùn)行時狀態(tài)澈吨。
提供構(gòu)建和銷毀數(shù)據(jù)結(jié)構(gòu)的函數(shù)。
把任何庫函數(shù)依賴的封裝好的運(yùn)行時狀態(tài)作為參數(shù)傳進(jìn)來寄摆。
定制化
system map 只是實(shí)現(xiàn)Lifecycle協(xié)議的記錄谅辣,通過兩個公共函數(shù),start-system
和stop-system
婶恼。這兩個函數(shù)只是其它兩個函數(shù)的特例桑阶,
update-system
和update-system-reverse
。 (在0.2.0中添加)
例如勾邦,您可以將自己的生命周期函數(shù)定義為新的協(xié)議蚣录。你甚至不必使用協(xié)議和記錄;多方法和普通的map也可以。
update-system
和update-system-reverse
都是將函數(shù)作為參數(shù)眷篇,并在system的每個 component 上調(diào)用它萎河。遵循這種方式,他們會把更新后的依賴關(guān)聯(lián)到每個 component 上蕉饼。
update-system
函數(shù)按照 component 依賴順序進(jìn)行更新:每個 component 將在其依賴之后被調(diào)用虐杯。
update-system-reverse
函數(shù)按反向依賴順序排列:每個 component 將在其依賴項(xiàng)之前調(diào)用。
使用identity
函數(shù)調(diào)用update-system
相當(dāng)于只使用 Component 的依賴注入部分而不使用Lifecycle
昧港。擎椰。
參考,更多信息
- video from Clojure/West 2014 (YouTube, 40 minutes)
- tools.namespace
- On the Perils of Dynamic Scope
- Clojure in the Large (video)
- Relevance Podcast Episode 32 (audio)
- My Clojure Workflow, Reloaded
- reloaded Leiningen template
- Lifecycle Composition
于 2018-10-08