Clojure component 设计哲学
这是 Clojure component 框架的简介,里面涉及了关于状态管理和依赖注入的设计思路,值得借鉴。
Component 是一个微型的 Clojure 框架用于管理那些包含运行时状态的软件组件的生命周期和依赖。
这主要是一种用几个辅助函数实现的设计模式。可以被看成是使用不可变数据结构的依赖注入风格。
观看 Clojure/West 2014 年的视频 (YouTube, 40 minutes)(YouTube, 40分钟)
发布和依赖信息
[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
于 2018-10-08