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-namespace | require 了没用 | warning |
| shadowed-var | 变量遮蔽 | warning |
| redundant-do | 多余的 do | warning |
| missing-else-branch | if 缺 else | warning |
心法:规则要「分级别」——能导致运行失败的(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 两者对比
| 维度 | cljfmt | zprint |
|---|---|---|
| 定位 | 社区约定风格 | 高度可配置 |
| 宽度感知 | 弱 | 强(自动折行) |
| 配置复杂度 | 低 | 高 |
| 生态集成 | 广(编辑器内置) | 广 |
| 学习成本 | 低 | 中 |
心法:格式化工具的价值是「消灭争论」而非「好看」——团队选定一个、写进 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-namespace | require 后没用 | 用 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 |
| LSP | clojure-lsp diagnostics |
| 清理 ns | clojure-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 测试与质量保障 — 测试策略与质量体系
- Clojure REPL 驱动开发 — 交互式开发工作流
- Clojure 工具链与 tools.deps — 依赖与构建工具
- Clojure 基础语法 — 语法与命名空间
- Clojure 宏编程 — 理解宏与 lint 的关系
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。