Clojure 静态检查与格式化工具链:clj-kondo、cljfmt、zprint 与 CI

深入 Clojure 的静态检查与格式化工具链:clj-kondo 的 lint 原理、规则配置与自定义 hook、cljfmt 与 zprint 的格式化差异、编辑器与 LSP 协同、CI 集成与增量检查、常见告警治理与团队规范落地,帮你搭出一条「提交即检查、保存即格式化」的高质量代码流水线。

Clojure 的灵活性是把双刃剑:宏能造 DSL,也能让「未定义符号」「参数错位」这类错误拖到运行时才爆。clj-kondo 用静态分析把这些问题提前到编辑器里,cljfmt 与 zprint 则负责「格式不争论」——让团队把精力从缩进之争挪到逻辑上。本文覆盖 lint 原理、规则与自定义 hook、格式化工具对比、LSP 协同、CI 集成与告警治理,帮你搭出一条「提交即检查、保存即格式化」的流水线。

1. 工具链全景

1.1 三类工具的职责

clj-kondo   静态检查:未定义符号、未使用绑定、参数数量、反射告警
cljfmt      格式化:缩进、空白、对齐(社区约定)
zprint      格式化:可配置性更强,支持多种风格
LSP         把上面三者接进编辑器的桥梁

1.2 为什么 Clojure 更需要 lint

问题在 Clojure 里的表现
动态类型类型错误只在运行时暴露
宏参数错位编译期不报
命名空间require 漏写导致运行时找不到
反射隐式反射拖慢性能且不易察觉

心智:Clojure 的「动态」换来了表达力,也把一部分正确性检查推迟到了运行时。lint 工具就是把这部分检查尽量「左移」回编辑期。

1.3 流水线位置

编辑器保存 -> cljfmt 格式化 + clj-kondo 即时提示
提交前     -> pre-commit hook 跑 lint
CI         -> clj-kondo 全量 + 格式化校验

心法:三处(编辑器、提交、CI)用同一套配置,才不会出现「本地绿、CI 红」。配置集中存放、单一来源是关键。

2. clj-kondo 入门

2.1 安装与运行

# 作为 CLI 安装(推荐)
brew install clj-kondo
# 或下载二进制
# 或用 Clojure CLI
clojure -Sdeps '{:deps {clj-kondo/clj-kondo {:mvn/version "2024.08.01"}}}' \
  -M -m clj-kondo.main --lint src
# 检查源码
clj-kondo --lint src
clj-kondo --lint src --config '{:output {:format :json}}'

2.2 基本输出

src/app/core.clj:12:3: warning: unused binding x
src/app/core.clj:20:1: error: unresolved symbol foo

2.3 为什么它比编辑器快

clj-kondo 只做语法与符号级分析,不执行代码、不加载宏展开的真实语义(它用「hook」模拟宏行为),因此能在毫秒级完成整库检查。

心法:clj-kondo 的定位是「快速、可缓存的语法级检查」,不是完整编译器。它牺牲了一部分精确度,换来「保存即反馈」的体验——这正是日常开发最需要的。

3. 规则配置

3.1 .clj-kondo/config.edn

{:linters
 {:unused-binding        {:level :warning}
  :unresolved-symbol     {:level :error}
  :unused-namespace      {:level :warning}
  :missing-else-branch   {:level :warning}
  :redundant-do          {:level :warning}
  :shadowed-var          {:level :warning}}

 ;; 忽略某些路径
 :output {:exclude-files ["target/**" "resources/**"]}

 ;; 项目自定义:忽略特定符号
 :skip-lint-namespaces [user]}

3.2 局部忽略

;; 单行忽略
#_{:clj-kondo/ignore [:unused-binding]}
(let [x 1] nil)

;; 整段忽略
#_{:clj-kondo/ignore [:unresolved-symbol]}
(defmacro custom-thing [& body] ...)

;; 配置里排除整个文件
{:linters {:unresolved-symbol {:exclude [(my.ns/known-macro)]}}}

3.3 常用规则说明

规则含义建议级别
unresolved-symbol找不到的符号error
unused-binding未使用绑定warning
unused-namespacerequire 了没用warning
shadowed-var变量遮蔽warning
redundant-do多余的 dowarning
missing-else-branchif 缺 elsewarning

心法:规则要「分级别」——能导致运行失败的(unresolved-symbol)设 error 拦住 CI;风格类的(redundant-do)设 warning 提醒即可。一上来全开 error 会让人放弃 lint。

4. 自定义 lint 与 hooks

4.1 宏是 lint 的盲区

clj-kondo 不认识你的自定义宏——它会以为宏体里的符号「没定义」。解决办法是写 hook,告诉 clj-kondo 这个宏「展开后长什么样」。

4.2 用 hook 声明宏语义

;; .clj-kondo/config.edn
{:lint-as {my.ns/defhandler clojure.core/defn
           my.ns/with-metrics clojure.core/let}}

lint-as 是最简单的方式:把自定义宏当作已知宏来 lint。

4.3 编写分析型 hook

更复杂的宏需要写分析函数:

;; .clj-kondo/hooks/my_hooks.clj
(ns hooks.my-hooks
  (:require [clj-kondo.hooks-api :as api]))

(defn defhandler
  [{:keys [node]}]
  ;; 把 (defhandler name [req] body) 当作 (defn name [req] body)
  (let [[_ name args & body] (:children node)
        new-node (api/list-node
                   (list (api/token-node 'defn)
                         name args
                         (api/list-node (cons (api/token-node 'do) body))))]
    {:node new-node}))
;; 注册
{:hooks {:analyze-call {my.ns/defhandler hooks.my-hooks/defhandler}}}

心法:「宏无法 lint」是团队自建 DSL 后最常见的痛点——写 lint-as 或 hook 的成本很低,收益却是「自定义宏也能被静态检查」。DSL 越多,hook 越值钱。

5. cljfmt 与 zprint 格式化

5.1 cljfmt

clojure -Sdeps '{:deps {cljfmt/cljfmt {:mvn/version "0.13.0"}}}' \
  -M -m cljfmt.main check src
clojure -M -m cljfmt.main fix src

配置 .cljfmt.edn:

{:indents {my.ns/defhandler [[:block 1]]}
 :remove-surrounding-whitespace? true
 :insert-missing-whitespace? true
 :remove-trailing-whitespace? true}

5.2 zprint

zprint 的可配置性更强,能按宽度自动换行:

;; .zprintrc
{:style :community
 :width 100
 :map {:comma? false}
 :binding {:indent 2}}
clojure -Sdeps '{:deps {zprint/zprint {:mvn/version "1.2.9"}}}' \
  -M -m zprint.main -w src

5.3 两者对比

维度cljfmtzprint
定位社区约定风格高度可配置
宽度感知弱强(自动折行)
配置复杂度低高
生态集成广(编辑器内置)广
学习成本低中

心法:格式化工具的价值是「消灭争论」而非「好看」——团队选定一个、写进 CI,从此 PR 不再有缩进评论。cljfmt 够用就别折腾 zprint;真需要宽度感知和复杂对齐时再上。

6. 编辑器与 LSP 协同

6.1 clojure-lsp

clojure-lsp 把 clj-kondo(诊断)、cljfmt(格式化)、补全、跳转、重构整合成一个语言服务器:

brew install clojure-lsp/brew/clojure-lsp-native
clojure-lsp diagnostics
clojure-lsp format
clojure-lsp clean-ns

6.2 .lsp/config.edn

{:lint-as {my.ns/defhandler clojure.core/defn}
 :formatting {:indents {my.ns/defhandler [[:block 1]]}}
 :clean {:automatically-remove-unused-imports true}}

6.3 保存即格式化

VS Code: 设置 editor.formatOnSave = true,格式化器选 clojure-lsp
Emacs (lsp-mode): 绑定 clojure-lsp/format 到 before-save-hook
Vim/Neovim: 通过 lspconfig 接 clojure-lsp

心法:把 lint 和格式化交给 LSP,编辑器里就能「边写边修」——诊断实时浮现、保存自动格式化、命名空间自动清理。这比事后跑 CI 才发现问题高效得多。

7. CI 集成

7.1 GitHub Actions

name: lint
on: [push, pull_request]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: DeLaGuardo/setup-clojure@12.5
        with:
          clj-kondo: '2024.08.01'
          cljfmt: '0.13.0'
      - run: clj-kondo --lint src test
      - run: cljfmt check src test

7.2 用 bb 统一入口

;; bb.edn
{:tasks
 {lint   {:task (shell "clj-kondo --lint src test")}
  fmt    {:task (shell "cljfmt fix src test")}
  fmt-check {:task (shell "cljfmt check src test")}
  ci     {:depends [lint fmt-check]}}}

7.3 增量检查与缓存

优化策略:
  只检查改动文件 —— git diff 取变更路径喂给 clj-kondo
  缓存分析结果   —— clj-kondo 的 .clj-kondo/.cache 可复用
  并行 lint      —— clj-kondo 天然多线程

心法:CI 里的 lint 要「快而确定」——用同一份 config、把 lint 与 fmt-check 都设成必过门禁。团队规模变大后,增量检查(只查改动文件)能显著缩短反馈时间。

8. 常见告警治理

8.1 高频告警与修法

告警常见原因修法
unused-binding写了没用的 let 绑定删掉或前缀下划线
unresolved-symbol漏 require 或自定义宏补 require 或写 hook
unused-namespacerequire 后没用用 clean-ns 清理
shadowed-var局部名遮蔽了核心名改名
redundant-do多余的 do删除

8.2 反射告警

;; 隐式反射:性能隐患
(set! *warn-on-reflection* true)
;; => Reflection warning: call to java.lang.String.length can't be resolved

;; 修法:加类型提示
(defn len [^String s] (.length s))

8.3 循序渐进

治理节奏:
  第 1 周:只开 unresolved-symbol(拦致命错误)
  第 2 周:加 unused-binding(清噪音)
  第 3 周:加反射告警(性能)
  之后:  加风格类规则

心法:遗留代码库一次性全开规则会淹没在告警里——按「致命 → 噪音 → 性能 → 风格」的顺序分批开,每批清干净再加下一批,团队才有正反馈。

9. 团队规范落地

9.1 单一配置来源

config 位置:
  .clj-kondo/config.edn   —— lint 规则(提交进 git)
  .cljfmt.edn             —— 格式化(提交进 git)
  .lsp/config.edn         —— LSP 复用上面两份
原则:CI 与编辑器读同一份,不各自维护

9.2 pre-commit hook

#!/usr/bin/env bash
# .git/hooks/pre-commit
set -e
cljfmt fix src test
git add -u
clj-kondo --lint src test

9.3 新项目模板

新项目开箱即用:
  1. 复制 .clj-kondo/config.edn(规则分级)
  2. 复制 .cljfmt.edn(缩进约定)
  3. bb.edn 加 lint/fmt/ci 任务
  4. CI 加 lint job

心法:规范要「零决策成本」——开发者不该思考「该不该格式化」。保存即格式化、提交即 lint、CI 兜底,三处一致,规范就自动执行了。

10. 速查表与一句话记忆

需求命令或配置
lint 源码clj-kondo --lint src
JSON 输出--config '{:output {:format :json}}'
自定义宏lint-as 或 hook
局部忽略#_{:clj-kondo/ignore [...]}
格式化cljfmt fix src
格式化校验cljfmt check src
宽度感知zprint
LSPclojure-lsp diagnostics
清理 nsclojure-lsp clean-ns
反射告警(set! *warn-on-reflection* true)
CI 门禁clj-kondo + cljfmt check
提交钩子pre-commit 跑 lint

一句话记忆:Clojure 质量工具链 = clj-kondo(语法级快速 lint、规则分级、hook 让自定义宏也可检查)→ cljfmt 与 zprint(消灭缩进争论,CI 校验)→ clojure-lsp(保存即格式化、诊断实时浮现)→ 编辑器、pre-commit、CI 三处同一份配置 → 规则按「致命/噪音/性能/风格」分批开 → 规范零决策成本才落得下去——把正确性检查左移回编辑期,是动态语言最划算的投资。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「clojure」更多文章

  1. Datalog 查询与 Datomic/Xtdb:数据即事实、pull、时间旅行与架构
  2. Clojure 认证授权与安全实践:Ring 安全链、JWT、密码哈希与审计
  3. Clojure 日志、指标与可观测性:Timbre、OpenTelemetry 与采样