组件与依赖注入:Component、Integrant 与 Mount

深入 Clojure 应用的组件化与依赖注入:从 Component 的 record 生命周期协议、Integrant 的数据驱动配置与合并机制,到 Mount 的 REPL 友好状态管理,覆盖依赖顺序、启动关闭钩子、测试隔离与三种方案的选型权衡。

一个服务启动时要连接数据库、初始化连接池、建立 Kafka 消费者、启动 HTTP 服务器——这些有状态的资源之间存在启动顺序与依赖关系。用全局 atom 和 (def db ...) 硬编码,会让测试寸步难行:每个测试都要自己搭一套,还得保证关得干净。组件化(Componentization)把「有状态的资源」抽象成带生命周期(Lifecycle)的对象,由框架按依赖顺序启动与关闭。

Clojure 生态里有三种主流方案:Stuart Sierra 的 Component、weavejester 的 Integrant、以及 tolitius 的 Mount。它们解决的问题相同,取舍却截然不同。本文逐一拆解三者的机制、配置写法与测试策略,最后给出选型矩阵。

1. 为什么需要组件管理

1.1 全局状态的三个痛点

一个典型的不组件化写法:

(ns myapp.core
  (:require [next.jdbc :as jdbc]))

(def db (atom nil))          ;; 全局可变状态
(def kafka-consumer (atom nil))

(defn start! []
  (reset! db (jdbc/get-datasource {:dbtype "postgresql"
                                   :dbname "app"}))
  (reset! kafka-consumer (start-consumer!)))

问题在三个地方暴露:

痛点表现后果
启动顺序隐式依赖靠调用顺序保证改一行顺序就崩
关闭缺失没有 stop! 对称操作连接泄漏、测试串味
测试难隔离每个测试共享同一 db用例互相污染

1.2 组件的定义

组件(Component)是「持有资源、有明确启动与关闭语义」的对象。它的核心契约只有两条:

  • 启动(start)返回一个可用的、替换了自身状态的新对象;
  • 关闭(stop)释放资源,且必须幂等、可重复调用。

依赖注入(Dependency Injection)在这里的含义很朴素:一个组件不自己去 (new Database),而是由框架把已经启动好的依赖「递」给它。

2. Component:协议驱动的生命周期

2.1 定义组件

Component 用 defrecord + component/Lifecycle 协议实现:

(ns myapp.db
  (:require [com.stuartsierra.component :as component]
            [next.jdbc :as jdbc]))

(defrecord Database [config datasource]
  component/Lifecycle
  (start [this]
    (if datasource
      this                                  ;; 已启动,幂等返回
      (let [ds (jdbc/get-datasource (:db config))]
        (assoc this :datasource ds))))
  (stop [this]
    (when-let [ds datasource]
      (.close ds))
    (assoc this :datasource nil)))

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

关键点:start 返回新对象而非原地修改,组件实例是不可变数据。(if datasource this ...) 保证重复启动安全。

2.2 用 map 组装依赖

Component 的依赖图就是一个嵌套 map,键名即组件名:

(defn system [config]
  (component/system-map
    :config   config
    :db       (myapp.db/new-database config)
    :cache    (myapp.cache/new-cache config)
    :handler  (component/using
                (myapp.web/new-handler)
                [:db :cache])          ;; 声明依赖
    :server   (component/using
                (myapp.web/new-server)
                [:handler])))

component/using 的作用是:在 start 之前,把被依赖组件注入进当前组件的 map。new-handler 返回的 map 里不需要预先有 :db 键,框架会补上。

2.3 启动与关闭

(require '[com.stuartsierra.component :as component])

(def sys (component/start (system config)))
;; 依赖顺序自动拓扑排序:config -> db -> cache -> handler -> server

(component/stop sys)
;; 逆序关闭:server -> handler -> cache -> db

component/start-system 内部对依赖图做拓扑排序,环依赖会直接抛异常。关闭时严格逆序,保证「先关依赖者、再关被依赖者」。

2.4 生命周期之外:reload

开发期用 clojure.tools.namespace 的 refresh 配合组件重载:

(ns user
  (:require [clojure.tools.namespace.repl :refer [refresh]]
            [com.stuartsierra.component.repl :refer [set-init! start stop reset]]))

(set-init! #(myapp.system/system config))

(reset)   ;; stop -> refresh 命名空间 -> start

这条 reset 是 REPL 驱动开发的核心循环,与 REPL 驱动开发工作流 里讲的 jack-in、求值、重载一脉相承。

3. Integrant:数据驱动的配置

3.1 配置即数据

Integrant 的核心主张是:系统拓扑应该是纯数据(EDN),而不是由函数调用构建的对象图。配置写成:

;; resources/config.edn
{:myapp/db
 {:jdbc-url #env JDBC_URL
  :pool-size 10}

 :myapp/cache
 {:ttl-seconds 300}

 :myapp/handler
 {:db #ig/ref :myapp/db
  :cache #ig/ref :myapp/cache}

 :myapp/server
 {:handler #ig/ref :myapp/handler
  :port 8080}}

#ig/ref 是 Integrant 的引用标记,声明依赖关系。整个依赖图一眼可读,还可以用 aero 或 #env reader tag 注入环境变量。

3.2 实现 init-key / halt-key!

每个 key 对应一组 multimethod:

(ns myapp.system
  (:require [integrant.core :as ig]
            [next.jdbc :as jdbc]))

(defmethod ig/init-key :myapp/db [_ {:keys [jdbc-url pool-size]}]
  (let [ds (jdbc/get-datasource {:jdbcUrl jdbc-url})]
    {:datasource ds :pool-size pool-size}))

(defmethod ig/halt-key! :myapp/db [_ {:keys [datasource]}]
  (.close datasource))

(defmethod ig/init-key :myapp/server [_ {:keys [handler port]}]
  (http/start-server handler {:port port}))

依赖被 Integrant 预先解析并作为 map 传入——:myapp/handler 的 init-key 收到的 {:db ... :cache ...} 里已经是启动好的实例。

3.3 启动与重载

(require '[integrant.core :as ig]
         '[integrant.repl :as ir])

;; 一次性启动
(def system (ig/init (ig/read-string (slurp "resources/config.edn"))))

;; 开发期用 integrant.repl
(ir/set-prep! #(load-config))
(ir/go)      ;; 启动
(ir/reset)   ;; 停止 -> 重载配置 -> 启动

integrant.repl 的 reset 会对比前后配置,只重启发生变化的 key(及其下游),而不是全量重启。大系统里这一点能省下大量时间。

3.4 配置合并

Integrant 支持把多份配置合并,典型做法是「基础配置 + 环境覆盖」:

(defn load-config []
  (ig/read-string
    (slurp (io/resource "config.edn"))))

合并用 merge-configs,环境特定值从 config.local.edn 覆盖。密钥、数据库口令这类敏感项不要进仓库,交给 配置与密钥管理 中介绍的 environ / aero + 环境变量方案。

4. Mount:REPL 友好的轻量方案

4.1 defstate

Mount 用宏 defstate 定义全局状态,写法最接近「有生命周期的 def」:

(ns myapp.state
  (:require [mount.core :refer [defstate]]
            [next.jdbc :as jdbc]))

(defstate config
  :start (load-config)
  :stop  nil)

(defstate db
  :start (jdbc/get-datasource (:jdbc-url config))
  :stop  (.close db))

(defstate server
  :start (http/start-server handler {:port (:port config)})
  :stop  (.stop server))

依赖顺序由定义顺序隐式决定:db 的 :start 里引用 config,Mount 会在启动 db 前先启动 config。

4.2 启动与停止

(require '[mount.core :as mount])

(mount/start)          ;; 按依赖顺序启动全部
(mount/start #'myapp.state/db)   ;; 只启动 db 及其依赖
(mount/stop)           ;; 逆序停止
(mount/stop #'myapp.state/db)    ;; 只停 db 及其下游

4.3 测试隔离:start-with

Mount 在测试里提供了便捷的重置:

(use-fixtures :each
  (fn [f]
    (mount/start-with {#'myapp.state/db (test-datasource)})
    (f)
    (mount/stop)))

start-with 允许用桩(stub)替换某个 state,非常适合集成测试。相比 Component 需要重建整个 system,Mount 的按需启动在测试里更轻。

4.4 Mount 的代价

Mount 的全局状态是真的全局(alter-var-root 修改 var),所以:

  • 同一个 JVM 里无法并存两套 system(多租户/多环境测试困难);
  • 依赖关系藏在 :start 表达式里,不如 Integrant 的 #ig/ref 显式;
  • 隐式顺序在大型系统里容易出错。

5. 三者对比与选型

维度ComponentIntegrantMount
拓扑定义Clojure 代码构建 mapEDN 数据定义顺序隐式
依赖声明component/using#ig/ref:start 表达式
配置分离弱(配置进代码)强(纯数据)弱
局部重启全量支持 diff 增量支持按需
多实例并存支持支持不支持
学习曲线中中低
社区热度高(经典)高(现代)中

选型建议:

  • 新项目、配置驱动、需要增量重启 → Integrant;
  • 团队熟悉经典方案、想要显式对象图 → Component;
  • 小服务、脚本、REPL 快速迭代 → Mount。

Integrant 与 Component 可以混用——Integrant 的 init-key 里返回一个 Component 实例,兼顾两者的表达力。

6. 生命周期中的常见陷阱

6.1 启动顺序与健康检查

HTTP 服务器必须最后启动,否则请求可能在数据库就绪前到达。Integrant 里把 server 的 #ig/ref 指向所有下游依赖即可保证顺序。

6.2 关闭要幂等

stop / halt-key! 可能被调用多次(异常路径、重复 reset)。用 when-let 或 if 守卫资源释放:

(defmethod ig/halt-key! :myapp/kafka [_ {:keys [consumer]}]
  (when consumer
    (try
      (.close consumer)
      (catch Exception e
        (log/warn e "关闭消费者失败")))))   ;; 关闭失败不阻断其他组件

6.3 测试里的 system 隔离

Component 与 Integrant 的测试模式是「每个测试用例构建一个 system」:

(use-fixtures :each
  (fn [f]
    (let [sys (ig/init (test-config))]
      (try (f)
           (finally (ig/halt! sys))))))

test-config 把 :myapp/db 指向 Testcontainers 启的临时 PostgreSQL,:myapp/server 指向随机端口。这样测试之间零共享、可并行。

6.4 微服务拆分时的组件边界

组件边界往往就是未来微服务的拆分线。把「订单」「库存」做成独立组件、只通过接口交互,日后拆服务时改动最小——这与 Clojure 微服务架构 里强调的模块边界一致。集成测试可用 测试与质量保障 中的 Testcontainers + fixtures 组合。

7. 小结

组件化解决的是「有状态资源如何有序启动、干净关闭、独立测试」这一工程问题。三套方案的核心差异只有一句话:Component 用代码表达拓扑,Integrant 用数据表达拓扑,Mount 用顺序表达拓扑。

  • 想要配置与代码分离、支持增量重启:选 Integrant;
  • 想要显式、可编程的依赖图:选 Component;
  • 想要最少的样板与最快的 REPL 循环:选 Mount。

无论选哪个,务必守住三条底线:启动顺序显式化、关闭幂等化、测试隔离化。做到这三点,你的服务在本地、CI 与生产环境的行为就会保持一致。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「clojure」更多文章

  1. Clojure 桌面 UI:cljfx 与 JavaFX 实战
  2. JSON/EDN 序列化与数据格式互操作
  3. JVM 调优与容器化部署:GC、JFR 与 Docker