天天看點

Clojure component 設計哲學

這是 Clojure component 架構的簡介,裡面涉及了關于狀态管理和依賴注入的設計思路,值得借鑒。

Component 是一個微型的 Clojure 架構用于管理那些包含運作時狀态的軟體元件的生命周期和依賴。

這主要是一種用幾個輔助函數實作的設計模式。可以被看成是使用不可變資料結構的依賴注入風格。

觀看 Clojure/West 2014 年的視訊 (YouTube, 40 minutes)(YouTube, 40分鐘)

釋出和依賴資訊

  • 釋出 release 版本到Clojars
  • 最新的穩定釋出版本是0.3.2
  • 全部釋出版本

[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 庫

讨論

請在 Clojure Mailling List 提問。

介紹

顧名思義,一個 component 就是一組共享運作時某些狀态的函數或過程。

一些 component 的例子:

  • 資料庫通路:共享資料庫連接配接的查詢、插入函數
  • 外部的 API 服務:共享一個 HTTP 連接配接池的資料發送和接收函數
  • Web 伺服器:共享所有應用程式運作時狀态,比如 session store,的函數,用于處理不同的路由。
  • 記憶體式緩存:在一個共享的可變引用當中擷取或者設定資料的函數,比如 Clojure 中的 Atom 或 Ref。

Component 和面向對象程式設計裡的對象定義在理念上很類似。但這并不會動搖 Clojure 這門程式設計語言中純函數和不可變資料結構的地位。大部分函數依然是函數,大多數資料也還是資料。而 Component 嘗試在函數式程式設計範式中輔助管理有狀态的資源。

Component 模型的優點

大型應用經常由多個有狀态的程序構成,這些程序必須以特定的順序啟動和關閉。Component 模型讓這些關系變得比指令式代碼更直覺且表意。

Component 為建構 Clojure 應用提供了一些基本的指導,包括系統不同部分間的邊界。Component 提供了一些封裝以便将相關的實體聚合。每個 component 僅僅持有它所需的引用,拒絕不必要的共享狀态。有别于周遊深層嵌套的 map,component 至多需要查找一個 map 就能擷取任何東西。

與将可變的狀态分散到不同的命名空間的做法不同,應用的所有有狀态的部分都可以被聚合到一起。某些情況下,使用 component 可以不需要共享可變引用。舉個例子,存儲目前的資料庫資源連結。與此同時,通過單個 system 對象維護所有可達狀态,可以更加容易地從REPL 檢視任意部分的應用狀态。

出于測試目的,我們需要來回切換 stub 和 mock。Component 依賴模型讓 這種實作方式變得容易,因為不需要依賴與時間相關的構造了,比如

with-redefs

或者

binding

,它們在多線程的代碼中經常會導緻競争條件。

對于和應用相關聯的狀态,如果能連貫地建立并清除這些狀态,就能夠保證無需啟動 JVM 就能快速建構出開發環境,這也可以讓單元測試變得更快更獨立,由于建立和啟動一個 system 的開銷很小,是以每個測試都能夠建立一個新的 system 執行個體。

Component 模型的缺點

首先特别重要地,當應用的所有部件都遵循相同的模式,那麼這個架構會工作得很好。不過,對于一個遺留系統,除非進行大量重構,否則很難設施 Component 模型。

Component 假設所有的應用狀态都是通過參數的形式傳遞給使用到它的函數中的。這樣會導緻很難應用到那些依賴全局或者單例引用的代碼。

對于小型的應用,在 component 之間聲明依賴關系可能比手工按序啟動所有 component 來的麻煩。不過即便如此,你也可以單獨使用 Lifecycle protocol 而不去使用依賴注入特性,隻不過 component 的附加價值就變小了。

架構産生的 system 對象是一個巨大并且有很多重複的複雜 map。同樣的 component 可能會在 map 的多個地方出現。盡管這種因為持久化的資料結構導緻的重複産生的記憶體開銷可以忽略不計,但是 system map 一般都因為太大而沒法可視化出來以友善檢測。

你必須顯式地在 component 之間指定依賴關系,代碼本身不能自動發現這些關系。

最後,component 之間不允許有環依賴。我相信環形依賴通常都暗示架構有瑕疵,可以通過重新構造應用得以消除。在極少數的情況下,環形依賴無法避免,那麼你可以使用可變的引用來管理它,不過這就超出了 component 的範圍。

使用

(ns com.example.your-application
  (:require [com.stuartsierra.component :as component]))           

複制

建立 component

通過定義實作了

Lifecycle

協定的 Clojure record 建立一個 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)))           

複制

可以選擇提供一個構造函數,接收 component 的初始化配置參數,讓運作時狀态為空。

(defn new-database [host port]
  (map->Database {:host host :port port}))           

複制

定義實作了 component 行為的函數,并接收一個 component 的執行個體作為參數。

(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 的依賴傳入構造函數

System 負責把運作時依賴注入到其中的 Component,下個章節會提到:

(defn example-component [config-options]
  (map->ExampleComponent {:options config-options
                          :cache (atom {})}))           

複制

System

component 被組合到 system 中。一個 system 就是一個知道如果啟停其他 component 的 component。它也負責将依賴注入到 component 中。

建立 system 最簡單的方式就是使用

system-map

函數,就像

hash-map

或者

array-map

構造方法一樣,接收一系列的 key/value 對。Key 在 system map 中都是 keyword,Value 在其中則是 Component 的執行個體,一般是 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

函數在 component 之間指定依賴關系。

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 協定的實作,使用依賴資訊(存儲在每個 component 的中繼資料)以正确的順序啟動 component。

在開始啟動每個 component 之前,System 會基于

using

提供的中繼資料

assoc

它的依賴。

還是用上面的例子,ExampleComponent 将會像下面那樣啟動起來。

(-> example-component
    (assoc :database (:db system))
    (assoc :scheduler (:sched system))
    (start))           

複制

調用

stop

方法關停 System,這會逆序地關閉每個 component,然後重新關聯每個 component 的依賴。

什麼時間給 component 關聯上依賴是無關緊要的,隻要發生在調用

start

方法之前。如果你事先知道 system 中所有 component 的名字,你就可以選擇添加中繼資料到 component 的構造方法中:

(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]))           

複制

作為可選項,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}}))))           

複制

生産環境的入口

component 并沒有規定你如何存儲 system map 或者使用包含其中的 component,這完全看你個人。

通常差別開發和生産的方法是:

在生産環境下,system map 是生命短暫的,它被用于啟動所有 component,然後就銷毀了。

當你的應用啟動後,例如在

main

函數中,構造了一個system的執行個體并且在其上調用了

component/start

方法,之後就無法控制在你的應用中代表“入口點”的一個或多個 component 了。

舉個例子,你有個 web server component 開始監聽 HTTP 請求,或者是一個事件輪訓的 component 在等待輸入。這些 component 每個都可以在它生命周期的

start

方法中建立一個或者多個線程。那麼

main

函數可以是這樣的:

(defn main [] (component/start (new-system)))           

複制

注意:你還是得保證應用的主線程一直運作着以免JVM關閉了。一種方法就是阻塞主線程,等待關閉的信号;另一種方法就是使用

Thread/join

(轉讓)主線程給你的 component 線程。

該方式也能配合類似 Apache Commons Daemon 的指令行驅動一起很好地工作。

開發環境的入口

開發過程中,一般引用一個 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 模闆擷取更詳細的例子

Web Applications

很多 Clojure 的 web 架構和教程都圍繞一個假設,即 handler 會作為全局的

defn

存在,而無需任何上下文。在這個假設底下,如果不把 handler 中的任意應用級别的上下文變成全局的

def

,就很難去使用它。

component 傾向于假設任意 handler 函數都會接收 state/context 作為其參數,而不依賴任何全局的狀态。

為了調和這兩種方法,就得建立一種 handler 方法作為 Lifecycle

start

方法的包含一個或多個 component 的閉包。然後把這個閉包作為 handler 傳遞給 web 架構。

大部分 web 架構或者類庫都會提供一個靜态的

defroutes

或者類似的宏會提供一個相等的非靜态的

routes

方法來建立一個閉包。

看上去像這樣:

(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

    是目前的 system,包含所有已經啟動的 component。
  • :component

    是導緻該異常的 component 及其已經注入的依賴。

這個 component 抛出的原始異常,可以調用該異常的

.getCause

方法擷取。

Component 不會對 component 進行從錯誤中恢複的嘗試,不過你可以使用

:system

附着到這個 exception 然後清除任何部分構造的var

由于 component map 可能很大且有許多的重複,你最好不要記日志或者列印出異常。這個

ex-without-components

幫助方法會從 exception 中去除大對象。

ex-component?

幫助方法可以告訴你一個異常是否來源于 component 或者被一個 component 包裝過。

幂等

你可能發現了把

start

stop

方法定義成幂等的是很有用的。例如,僅僅當 component 沒有啟動或者沒有關閉時才進行操作。

(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 是幂等的,但是在發生錯誤後,幂等會易于清除狀态。由于你可以随意地在任何東西上調用

stop

方法。

除此之外,你可以把 stop 包在 try/catch 中進而忽略所有異常。這種方式下,導緻一個 component 停止工作的錯誤并不能保證其他 component 完全關閉。

(try (.close connection)
  (catch Throwable t
    (log/warn t "Error when stopping component")))           

複制

無狀态的 Component

Lifecycle

的預設實作是個空操作。如果一個 component 省略了

Lifecycle

的協定,它還是能參與到依賴注入的過程中。

無需 lifecycle 的 component 可以是一個普通的 Clojure map。

對于任何實作了

Lifecycle

的 component,你不能忽略

start

或者

stop

,必須都提供。

Reloading

我開發了這種結合我的"reloaded"工作流的 workflow 模式,為了進行開發,我會建立一個

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 的,主要是為了互動開發時比較友善。

上面的 “xx入口”有詳細介紹。

任何函數都不應該接收 system 作為參數

應用層的函數絕對不該接收 system 作為參數,因為共享全局狀态是沒有道理的。

除此之外,每個函數都應該依據至多依賴一個 component 的原則來定義自己。

如果一個函數依賴了幾個 component,那麼它應該有一個自己的 component,在這個 component 裡包含對其它 component 的依賴。

任何 component 都不應該知曉包含自己的 system

每個 component 隻能接受它所依賴 component 的引用。

不要嵌套 system

在技術上,嵌套

system-map

是可能的。但是,這種依賴的影響是微妙的,并且也容易迷惑人。

你應該給每個 component 唯一的鍵,然後把他們合并到同一個 system 中。

其它類型的 component

應用或者業務邏輯可能需要一個或多個 component 來表達。

當然,component 記錄除了

Lifecycle

,可能還實作了其它的協定。

除了map和record,任何類型的對象都可以是 component,除非它擁有生命周期和依賴。舉個例子,你可以把一個簡單的Atom或者core.async Channel放到 system map 中讓其它 component 依賴。

測試替身

component 的不同實作(舉個例子,測試樁)可以在調用

start

之前,通過

assoc

注入到system當中。

寫給庫作者的注意事項

Component旨在作為一個工具提供給應用程式,而不是可複用的庫。我不希望通用庫在使用它的應用程式上強加任何特定的架構。

也就是說,庫作者可以通過遵循下面的指導原則輕松地讓應用程式将其庫和Component 模式結合起來使用:

  • 絕對不要建立全局的可變狀态(舉個例子,用

    def

    定義的Atom或者Ref)
  • 絕對不要依賴動态綁定來傳達狀态(例如,目前資料庫的連結),除非該狀态有必要局限于單個線程。
  • 絕對不要頂級的源代碼檔案上操作副作用。
  • 用單個資料結構封裝庫依賴的運作時狀态。
  • 提供建構和銷毀資料結構的函數。
  • 把任何庫函數依賴的封裝好的運作時狀态作為參數傳進來。

定制化

system map 隻是實作Lifecycle協定的記錄,通過兩個公共函數,

start-system

stop-system

。這兩個函數隻是其它兩個函數的特例,

update-system

update-system-reverse

。 (在0.2.0中添加)

例如,您可以将自己的生命周期函數定義為新的協定。你甚至不必使用協定和記錄;多方法和普通的map也可以。

update-system

update-system-reverse

都是将函數作為參數,并在system的每個 component 上調用它。遵循這種方式,他們會把更新後的依賴關聯到每個 component 上。

update-system

函數按照 component 依賴順序進行更新:每個 component 将在其依賴之後被調用。

update-system-reverse

函數按反向依賴順序排列:每個 component 将在其依賴項之前調用。

使用

identity

函數調用

update-system

相當于隻使用 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
  • Component 原文

于 2018-10-08