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 包装过。

幂等

你可能发现了把 startstop 方法定义成幂等的是很有用的。例如,仅仅当 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-systemstop-system。这两个函数只是其它两个函数的特例,
update-systemupdate-system-reverse。 (在0.2.0中添加)

例如,您可以将自己的生命周期函数定义为新的协议。你甚至不必使用协议和记录;多方法和普通的map也可以。

update-systemupdate-system-reverse都是将函数作为参数,并在system的每个 component 上调用它。遵循这种方式,他们会把更新后的依赖关联到每个 component 上。

update-system函数按照 component 依赖顺序进行更新:每个 component 将在其依赖之后被调用。
update-system-reverse函数按反向依赖顺序排列:每个 component 将在其依赖项之前调用。

使用identity函数调用update-system相当于只使用 Component 的依赖注入部分而不使用Lifecycle。。

参考,更多信息



于 2018-10-08

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343

推荐阅读更多精彩内容