一个服务启动时要连接数据库、初始化连接池、建立 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. 三者对比与选型
| 维度 | Component | Integrant | Mount |
|---|---|---|---|
| 拓扑定义 | Clojure 代码构建 map | EDN 数据 | 定义顺序隐式 |
| 依赖声明 | 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 与生产环境的行为就会保持一致。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。