前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Clojure component 设计哲学

Clojure component 设计哲学

作者头像
lambeta
发布2018-10-11 15:36:25
9940
发布2018-10-11 15:36:25
举报
文章被收录于专栏:编舟记编舟记

这是 Clojure component 框架的简介,里面涉及了关于状态管理和依赖注入的设计思路,值得借鉴。

Component 是一个微型的 Clojure 框架用于管理那些包含运行时状态的软件组件的生命周期和依赖。

这主要是一种用几个辅助函数实现的设计模式。可以被看成是使用不可变数据结构的依赖注入风格。

观看 Clojure/West 2014 年的视频 (YouTube, 40 minutes)(YouTube, 40分钟)

发布和依赖信息

[Leingen] 依赖信息;

代码语言:javascript
复制
[com.stuartsierra/component "0.3.2"]

Maven 依赖信息

代码语言:javascript
复制
    <dependency>
      <groupId>com.stuartsierra</groupId>
      <artifactId>component</artifactId>
      <version>0.3.2</version>
    </dependency>

Gradle 依赖信息:

代码语言:javascript
复制
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 的范围。

使用

代码语言:javascript
复制
(ns com.example.your-application
  (:require [com.stuartsierra.component :as component]))

创建 component

通过定义实现了Lifecycle协议的 Clojure record 创建一个 component。

代码语言:javascript
复制
(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 的初始化配置参数,让运行时状态为空。

代码语言:javascript
复制
(defn new-database [host port]
  (map->Database {:host host :port port}))

定义实现了 component 行为的函数,并接收一个 component 的实例作为参数。

代码语言:javascript
复制
(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。

代码语言:javascript
复制
(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,下个章节会提到:

代码语言:javascript
复制
(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。

代码语言:javascript
复制
(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 指定依赖。

代码语言:javascript
复制
    (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 匹配。

代码语言:javascript
复制
    (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 将会像下面那样启动起来。

代码语言:javascript
复制
(-> example-component
    (assoc :database (:db system))
    (assoc :scheduler (:sched system))
    (start))

调用stop方法关停 System,这会逆序地关闭每个 component,然后重新关联每个 component 的依赖。

什么时间给 component 关联上依赖是无关紧要的,只要发生在调用start方法之前。如果你事先知道 system 中所有 component 的名字,你就可以选择添加元数据到 component 的构造方法中:

代码语言:javascript
复制
(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。

代码语言:javascript
复制
(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函数可以是这样的:

代码语言:javascript
复制
(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 会话的例子:

代码语言:javascript
复制
(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方法来创建一个闭包。

看上去像这样:

代码语言:javascript
复制
(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 没有启动或者没有关闭时才进行操作。

代码语言:javascript
复制
(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 完全关闭。

代码语言:javascript
复制
(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 的命名空间如下:

代码语言:javascript
复制
(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

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2018.10.08 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 发布和依赖信息
  • 依赖和兼容性
  • 讨论
  • 介绍
    • Component 模型的优点
      • Component 模型的缺点
      • 使用
        • 创建 component
          • System
            • 生产环境的入口
              • 开发环境的入口
                • Web Applications
                • 更多高级使用方式
                  • 错误
                    • 幂等
                      • 无状态的 Component
                        • Reloading
                        • 使用说明
                          • 不要把 system 到处乱传
                            • 任何函数都不应该接收 system 作为参数
                              • 任何 component 都不应该知晓包含自己的 system
                                • 不要嵌套 system
                                  • 其它类型的 component
                                    • 测试替身
                                      • 写给库作者的注意事项
                                        • 定制化
                                        • 参考,更多信息
                                        相关产品与服务
                                        API 网关
                                        腾讯云 API 网关(API Gateway)是腾讯云推出的一种 API 托管服务,能提供 API 的完整生命周期管理,包括创建、维护、发布、运行、下线等。
                                        领券
                                        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档