Clojure 在 JVM 上很美,但写「跑一次就完」的脚本时,JVM 的启动开销、依赖分发和冷启动就成了负担。Babashka(bb)把 Clojure 解释器(SCI)用 GraalVM native-image 编译成原生二进制,启动只需几毫秒,却保留了 Clojure 的语法与大部分核心库。本文从原生镜像原理讲到 CI 集成,覆盖 bb.edn 任务、脚本组织、内置库与 pods、文件/进程/HTTP 操作、自动化实战,以及与 JVM Clojure 的取舍边界,帮你把 Babashka 用成一套轻量、快速、可移植的脚本运行器。
1. Babashka 是什么
1.1 脚本场景的痛点
在 JVM 上跑 Clojure 脚本,三个绕不开的负担:
- 启动慢:
clojure -M script.clj要先起 JVM、加载类、解析依赖,冷启动常在数百毫秒到数秒。 - 分发难:脚本要跑在同事机器、CI runner、容器里,都得先装 JDK 与 Clojure CLI。
- 依赖解析等待:
deps.edn首次解析要联网下载,离线环境直接卡住。
Babashka 的答案是:把 Clojure 解释器编译成一个独立的原生二进制。
1.2 原生镜像原理
Babashka 由三块组成:
SCI(Small Clojure Interpreter)——纯 Clojure 写的解释器
↓ 编译期
GraalVM native-image —— 把 JVM 字节码 AOT 成机器码
↓
bb 二进制 —— 单文件、无 JVM 依赖、毫秒启动
关键点:Babashka 并不是把「你的脚本」编译成原生镜像(那是 native-image 干的事),而是预先把解释器本身编译好;你的脚本运行时仍由 SCI 解释执行。
1.3 启动对比
| 运行方式 | 冷启动 | 依赖 | 分发体积 |
|---|---|---|---|
clojure -M | 500ms~3s | JDK + CLI | 数百 MB |
clojure -M 首次带 deps | 数秒~数十秒 | 需联网 | 同上 |
Babashka bb | 5~20ms | 无 | 单文件数十 MB |
bb 热启动 | 1~5ms | 无 | 同上 |
心智:Babashka 把「Clojure 的语法」和「JVM 的启动成本」解耦了——你得到熟悉的语言,却付出接近 shell 的启动代价。
2. 安装与 bb 任务
2.1 安装
# macOS
brew install borkdude/brew/babashka
# 或官方安装脚本
curl -sLO https://raw.githubusercontent.com/babashka/babashka/master/install
chmod +x install && ./install
# 验证
bb --version
2.2 bb.edn 任务定义
bb.edn 是项目的任务与依赖清单:
{:paths ["src" "script"]
:deps {org.clojure/data.json {:mvn/version "2.5.0"}}
:tasks
{;; 简单任务:调用外部命令
test {:doc "运行测试"
:task (shell "clojure -M:test")}
;; 带额外依赖的任务
lint {:extra-deps {clj-kondo/clj-kondo {:mvn/version "2024.08.01"}}
:requires ([clj-kondo.main])
:task (clj-kondo.main/main "--lint" "src")}
;; 组合任务:依赖其他任务
ci {:doc "CI 流水线"
:depends [lint test]}
;; 默认任务(直接 bb 时执行)
default {:task (println "用法: bb <task>")}}}
运行:
bb lint # 跑 lint 任务
bb ci # 先 lint 再 test
bb # 跑 default
bb tasks # 列出所有任务
2.3 任务依赖与并行
bb.edn 的任务依赖是串行的;需要并行可在任务体里用 future:
{:tasks
{build {:task (do (println "compile") nil)}
lint {:task (do (println "lint") nil)}
parallel {:task (do (future (shell "bb build"))
(future (shell "bb lint"))
nil)}}}
心法:bb.edn 是「Makefile 的 Clojure 版」——任务用数据描述、依赖显式声明、可引用 Clojure 函数。把 lint/test/build/release 都收进 bb.edn,团队只需记一个命令
bb。
3. 脚本与命令行
3.1 直接执行脚本
bb -e '(println (+ 1 2))' # 执行表达式
bb script.clj # 执行文件
bb -f script.clj # 同 -f
cat script.clj | bb - # 从 stdin
脚本就是一个普通 Clojure 文件:
;;; script.clj
(require '[babashka.fs :as fs]
'[babashka.process :refer [shell]])
(let [files (fs/glob "." "**/*.clj")]
(println "找到" (count files) "个文件")
(shell "wc" "-l" (map str files)))
3.2 参数解析
Babashka 内置 babashka.cli,无需额外依赖:
(require '[babashka.cli :as cli])
(def opts
(cli/parse-opts *command-line-args*
{:spec {:port {:coerce :long :default 8080}
:env {:coerce :keyword}
:debug {:coerce :boolean}}}))
(println "端口" (:port opts) "环境" (:env opts))
bb script.clj --port 9000 --env prod --debug
# => 端口 9000 环境 :prod
3.3 shebang 让脚本可执行
#!/usr/bin/env bb
(require '[babashka.fs :as fs])
(println (fs/absolutize "."))
chmod +x deploy.clj
./deploy.clj
心法:shebang + bb 等于「Clojure 版 Python 脚本」——一个文件、可执行、无需编译,直接进 git 当运维脚本。这是 Babashka 最被低估的用法。
4. 内置库与 pods
4.1 内置库清单
Babashka 预置了一批常用库,require 即用,无需声明依赖:
| 命名空间 | 用途 |
|---|---|
babashka.fs | 文件系统(跨平台) |
babashka.process | 子进程与 shell |
babashka.http-client | HTTP 客户端 |
babashka.cli | 命令行参数解析 |
babashka.json | JSON 读写 |
cheshire.core | JSON(兼容别名) |
clojure.core.async | 异步通道 |
clojure.data.csv | CSV 读写 |
clojure.set | 集合操作 |
clojure.string | 字符串处理 |
4.2 pods 突破内置库边界
pods 是用其他语言(Go/Rust/Clojure)写的外部进程,通过 stdio 上的 EDN 协议与 bb 通信,把「Babashka 没内置的能力」接进来:
bb 进程 <── EDN 消息 ──> pod 进程(Go 写的二进制)
常见 pods:pod-babashka-go-sqlite3(SQLite)、pod-babashka-postgresql、pod-babashka-lanterna(终端 UI)。
4.3 使用 pod
;; bb.edn 声明 pod
{:pods {org.babashka/go-sqlite3 {:version "0.3.5"}}}
;; 代码里加载
(require '[babashka.pods :as pods])
(pods/load-pod 'org.babashka/go-sqlite3 "0.3.5")
(require '[pod.babashka.go-sqlite3 :as sqlite])
(def db (sqlite/open "app.db"))
(sqlite/execute! db ["create table t (id integer)"])
心法:内置库覆盖 80% 场景,pods 补剩下 20%——pods 有进程启动与序列化开销,别把它当 JVM 依赖用;能用内置库就用内置库,真需要 SQLite/Postgres 这类重能力时才上 pod。
5. 文件与进程操作
5.1 文件读写
(require '[babashka.fs :as fs]
'[clojure.string :as str])
;; 读
(slurp "config.edn")
(str/split-lines (slurp "data.txt"))
;; 写
(spit "out.txt" "hello\n")
;; 复制/移动/删除
(fs/copy "a.txt" "b.txt")
(fs/move "b.txt" "sub/b.txt" {:replace-existing true})
(fs/delete "tmp.txt")
5.2 目录遍历与匹配
;; 递归找所有 .clj 文件
(fs/glob "." "**/*.clj")
;; 过滤大文件
(->> (fs/glob "." "**/*")
(filter fs/regular-file?)
(filter #(> (fs/size %) (* 10 1024 1024)))
(map str))
;; 创建临时目录
(let [tmp (fs/create-temp-dir)]
(spit (fs/file tmp "x.txt") "data"))
5.3 子进程
(require '[babashka.process :as p :refer [shell process]])
;; 简单执行(继承 stdout)
(shell "git" "status")
;; 捕获输出
(-> (shell {:out :string} "git" "rev-parse" "HEAD")
:out
str/trim)
;; 流式管道
(-> (process {:out :string} "cat" "big.txt")
:out
(str/split-lines)
count)
;; 管道串联
(->> (process "find . -name '*.clj'")
(p/process "wc -l" {:in (:out *1)}))
心法:
babashka.process是 shell 脚本的 Clojure 替身——shell处理同步执行,process处理流式管道,pipeline串联多进程。用它替代脆弱的 bash 管道,脚本立刻可读、可测、可移植。
6. HTTP 与网络
6.1 HTTP 客户端
(require '[babashka.http-client :as http]
'[babashka.json :as json])
;; GET
(def resp (http/get "https://api.github.com/repos/babashka/babashka"))
(:status resp) ;; 200
(json/read-str (:body resp) :key-fn keyword) ;; 解析 JSON
;; POST
(http/post "https://httpbin.org/post"
{:headers {"Content-Type" "application/json"}
:body (json/write-str {:name "bb" :ver "1.0"})})
;; 超时
(http/get "https://example.com" {:timeout 3000})
6.2 起一个 HTTP 服务
Babashka 内置 org.httpkit.server,可直接起服务:
(require '[org.httpkit.server :as server])
(defn handler [req]
{:status 200
:headers {"Content-Type" "application/json"}
:body "{\"ok\":true}"})
(server/run-server handler {:port 8080})
@(promise) ;; 阻塞不退
6.3 处理 JSON 与 EDN
(require '[babashka.json :as json])
(json/write-str {:a 1 :b [1 2 3]}) ;; => "{\"a\":1,\"b\":[1,2,3]}"
(json/read-str "{\"x\":1}" :key-fn keyword) ;; => {:x 1}
心法:Babashka 内置 HTTP 客户端与服务端——小工具、健康检查、webhook 接收器、内网 API 代理都能用几十行搞定,不必拉整个 JVM web 栈。
7. 任务自动化实战
7.1 构建与发布脚本
#!/usr/bin/env bb
;;; release.clj —— 打 tag、推送、发布
(require '[babashka.process :refer [shell]]
'[babashka.cli :as cli]
'[clojure.string :as str])
(def {:keys [version dry-run]}
(cli/parse-opts *command-line-args*
{:spec {:version {:coerce :string :require true}
:dry-run {:coerce :boolean}}}))
(defn run! [& args]
(println "执行" (str/join " " args))
(when-not dry-run (apply shell args)))
(run! "git" "tag" (str "v" version))
(run! "git" "push" "origin" (str "v" version))
(println "发布" version (if dry-run "(演练)" "完成"))
7.2 文件监听与热重载
;; 简易文件监听:变更即执行任务
(require '[babashka.fs :as fs]
'[babashka.process :refer [shell]])
(defn watch-loop [dir f]
(let [seen (atom {})]
(loop []
(doseq [file (fs/glob dir "**/*.clj")]
(let [m (fs/last-modified-time file)]
(when (not= m (get @seen file))
(swap! seen assoc file m)
(f file))))
(Thread/sleep 1000)
(recur))))
(watch-loop "src" (fn [f] (println "变更:" f) (shell "bb test")))
7.3 数据库迁移
;;; migrate.clj —— 顺序执行迁移文件
(require '[babashka.fs :as fs]
'[babashka.pods :as pods]
'[clojure.string :as str])
(pods/load-pod 'org.babashka/go-sqlite3 "0.3.5")
(require '[pod.babashka.go-sqlite3 :as sqlite])
(def db (sqlite/open "app.db"))
(sqlite/execute! db ["create table if not exists schema_version (v text)"])
(doseq [f (sort (fs/glob "migrations" "*.sql"))]
(println "执行" (str f))
(doseq [stmt (str/split (slurp f) #";")]
(when (seq (str/trim stmt))
(sqlite/execute! db [stmt]))))
心法:自动化脚本的三件套是「参数化 + 幂等 + 干跑」——用
babashka.cli收参数、重复执行不出错、--dry-run先演练。Babashka 让这三件套几十行搞定,不必为此起一个 Gradle 项目。
8. CI 集成
8.1 GitHub Actions 安装
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: DeLaGuardo/setup-clojure@12.5
with:
clojure: '1.12'
- name: 安装 Babashka
uses: turtlequeue/setup-babashka@v1.7.0
with:
babashka-version: 1.12.196
- run: bb ci
8.2 用 bb 做 CI 胶水
Babashka 最适合当 CI 里的「胶水层」——把多步逻辑写成一个脚本:
;;; ci.clj
(require '[babashka.process :refer [shell]])
(defn step [name f]
(println (str "== " name " =="))
(let [r (f)]
(when-not (zero? (:exit r))
(println "失败:" name)
(System/exit 1))))
(step "lint" #(shell "clojure -M:clj-kondo --lint src"))
(step "test" #(shell "clojure -X:test"))
(step "build" #(shell "clojure -T:build jar"))
(println "全部通过")
8.3 常见陷阱
| 陷阱 | 现象 | 规避 |
|---|---|---|
| 用了 JVM 专属类 | ClassNotFoundException | 查内置库清单 |
| 依赖 mvn 版本太重 | 启动变慢 | 优先内置库 |
| 假设有反射/动态类加载 | 运行时报错 | 改纯 Clojure 写法 |
| 忽略 pod 启动开销 | 循环里反复 load | pod 只加载一次 |
| 用 System/exit 无清理 | 临时文件残留 | 用 try/finally |
心法:CI 里把「环境准备」交给 action,把「业务步骤」交给 bb 脚本——这样本地和 CI 跑的是同一段 Clojure,避免「在我机器上能过」的经典问题。
9. 与 JVM Clojure 的取舍
9.1 能力边界
Babashka 并不是完整 Clojure:
- 不能加载任意 JVM 库(只有内置 + pods + 部分纯 Clojure 库)。
- 无
gen-class、无动态类加载、无 JNI、无反射。 - STM(
ref/dosync)不可用,多线程原语受限。 defrecord/deftype语义有差异。
9.2 选型对比
| 维度 | Babashka | JVM Clojure |
|---|---|---|
| 启动 | 毫秒 | 秒级 |
| 吞吐与长期运行 | 不适合 | 强 |
| 任意 JVM 库 | 否 | 是 |
| 脚本分发 | 单文件 | 需 JVM |
| 并发原语 | 有限 | 完整 |
| 适用场景 | 脚本/任务/CLI/CI | 服务/大数据/长驻进程 |
9.3 混合策略
最实用的组合是「bb 管外围,JVM 管核心」:
开发期:bb lint / bb test(毫秒反馈)
构建期:bb 调 clojure -T:build(重活交给 JVM)
运行期:JVM 服务常驻,bb 做运维脚本/健康检查/日志巡检
心法:选型不看「哪个更强」,看「这段代码活多久」——跑一次就退的脚本用 bb,常驻服务用 JVM;两者不是替代关系,而是同一条工具链上的不同齿轮。
10. 速查表与一句话记忆
| 需求 | 命令或写法 |
|---|---|
| 跑表达式 | bb -e '(println 1)' |
| 跑脚本 | bb script.clj |
| 定义任务 | bb.edn 的 :tasks |
| 任务依赖 | :depends [a b] |
| 参数解析 | babashka.cli/parse-opts |
| 文件系统 | babashka.fs |
| 子进程 | babashka.process/shell |
| HTTP | babashka.http-client |
| 起服务 | org.httpkit.server |
| JSON | babashka.json |
| 扩展能力 | pods |
| shebang | #!/usr/bin/env bb |
一句话记忆:Babashka = GraalVM 原生镜像 + SCI 解释器(毫秒启动、单文件分发)→ bb.edn 任务化(Makefile 的 Clojure 版)→ 内置库覆盖八成场景、pods 补两成 → fs/process/http-client 替代 shell 管道 → CLI 参数化 + 幂等 + 干跑 → CI 里当胶水层 → 跑一次就退用 bb、常驻服务用 JVM——它不取代 Clojure,而是把 Clojure 装进了 shell 脚本的位置。
延伸阅读
- Clojure 工具链与 tools.deps — deps.edn 与依赖管理
- Clojure REPL 驱动开发 — 交互式开发工作流
- Clojure 性能优化与 GraalVM 原生镜像 — 原生编译原理
- Clojure 数据管道与流处理 — 脚本化数据处理
- Clojure 基础语法 — 快速回顾语法
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。