数据校验:spec 与 Malli 对比

对比 clojure.spec 与 Malli 两大数据校验方案:schema 表达力与组合能力、开放与封闭 map、递归与条件依赖、生成式测试集成、错误信息可读性、解释与编译模式的运行时开销,并给出 coercion、JSON Schema 导出与选型决策依据。

在 Clojure 里,数据的形状是「隐式契约」:map 可以随便加键,函数可以收任意值。这种灵活是生产力,也是 bug 温床——直到你在边界上加了 schema 层,才把「隐式约定」变成「显式契约」。官方 clojure.spec 和社区 Malli 是两条主流路线,前者历史悠久、生态官方,后者数据驱动、表达力与工具链更强。本文不做「哪个更好」的口水战,而是从表达力、组合、生成式测试、错误信息、运行时开销、生态六个维度逐项对比,给出可落地的选型依据。

1. 为什么需要 schema 层

1.1 隐式契约的代价

(defn create-order [order]
  (assoc order :total (* (:qty order) (:price order))))

这个函数假设了 :qty 和 :price 存在且是数字。一旦上游传了字符串或缺失字段,错误会延迟到深处才爆发,堆栈里也看不出「哪个字段不对」。

1.2 schema 层的三个职责

职责说明典型场景
校验判断数据是否符合契约API 入参、消息消费
解释(coerce)把外部数据转成内部形状JSON 字符串 → 关键字
生成由 schema 反向生成样本数据测试、文档示例

这三件事要分开看:一个库校验强不代表 coerce 强,生成强也不代表错误信息好。对比时必须逐项拆开。

1.3 两条路线的哲学差异

  • spec:宏驱动、注册表(registry)为中心,schema 用宏写成「代码」。优势是编译期检查与惯用语法;代价是 schema 本身不是数据,难以序列化与程序化处理。
  • Malli:纯数据描述 schema,[:map [:id :int]] 就是一个 vector。优势是 schema 即数据,可序列化、可组合、可导出 JSON Schema;代价是失去宏的语法便利。

这个差异决定了很多下游能力,是后面所有对比的根因。

2. 表达力对比

2.1 基础类型

;; spec
(s/def ::id pos-int?)
(s/def ::name (s/and string? #(<= 1 (count %) 64)))

;; Malli
(def Id [:int {:min 1}])
(def Name [:string {:min 1 :max 64}])

Malli 的 :int、:string 自带约束属性,比 spec 的 s/and 组合更紧凑。spec 的 int? 是谓词,范围约束要靠 s/int-in 或自定义。

2.2 集合与 map 约束

;; spec:map 约束较松散
(s/def ::user (s/keys :req-un [::id ::name] :opt-un [::email]))

;; Malli:逐键声明,可嵌套约束
(def User
  [:map
   [:id Id]
   [:name Name]
   [:email {:optional true} [:string {:format :email}]]
   [:roles [:vector [:enum :admin :user]]]])

这是 Malli 最明显的优势:map 的每个键都能挂自己的 schema,还能带 :optional 属性。spec 的 s/keys 只能声明「哪些键必填」,键的具体 schema 要靠另一个 s/def 关联,读起来是跳跃的。

2.3 开放与封闭 map

;; spec:默认允许额外键,要封闭需要额外手段
(s/def ::strict (s/keys :req-un [::id]))

;; Malli::closed 一行搞定
(def Strict [:map {:closed true} [:id :int]])

封闭 map 在边界校验上很重要:多传一个键就报错,能挡住拼写错误(:emial 之类)。spec 要实现同样效果得自己写谓词,很别扭。

2.4 条件依赖与互斥

;; Malli::and 组合条件
(def Payment
  [:and
   [:map [:method [:enum :card :wire]]]
   [:multi {:dispatch :method}
    [:card [:map [:card-no :string]]]
    [:wire [:map [:iban :string]]]]])

;; spec:用 s/and + 谓词实现条件分支
(s/def ::payment
  (s/and (s/keys :req-un [::method])
         #(case (:method %)
            :card (contains? % :card-no)
            :wire (contains? % :iban)
            true)))

Malli 的 :multi 把「按字段分派」表达成结构化数据;spec 只能落到手写谓词,可读性和可生成性都差一截。

2.5 递归与自引用

;; Malli:递归用 :schema 引用自身
(def Tree
  [:schema {:registry {::node [:map
                               [:value :int]
                               [:children [:vector [:ref ::node]]]]}}
   [:ref ::node]])

;; spec:天然支持,因为 registry 是全局的
(s/def ::node (s/keys :req-un [::value ::children]))
(s/def ::children (s/coll-of ::node))

两者都能表达递归。spec 更自然(注册表本来就是全局的),Malli 需要显式 :registry + :ref,但换来的是局部自包含的 schema。

3. 组合与复用

3.1 组合原语

需求specMalli
与s/and:and
或s/or:or
合并 maps/merge:merge
集合s/coll-of[:vector x] / [:set x]
分派s/multi-spec:multi
空值s/nilable:maybe
枚举s/def + 集合谓词[:enum a b c]

表达力上两者接近,差别在写法是否统一。Malli 全是 [:type opts ...] 的 vector 形式,一致性好;spec 是各种宏,形式各异。

3.2 注册表与命名空间

;; spec:全局注册表,用命名空间关键字避免冲突
(s/def ::app.user/id pos-int?)

;; Malli:默认无全局状态,可显式注册
(m/def! ::id :int)          ; 注册到全局(慎用)
(def local-schema [:map [:id :int]])   ; 局部,不污染全局

Malli 的「默认无全局状态」是重要优势:库作者可以导出 schema 而不污染使用者命名空间。spec 的全局注册表在大型项目里容易出现「谁定义了 ::id」的困惑。

3.3 复用与继承

;; Malli::merge 做 schema 继承
(def Base [:map [:id :int] [:created-at :inst]])
(def User (m/merge Base [:map [:name :string]]))

spec 对应的 s/merge 只对 map spec 生效,且要求被合并的都是 s/keys,灵活性差一些。

4. 生成式测试集成

4.1 由 schema 生成数据

;; spec + test.check
(require '[clojure.spec.gen.alpha :as gen])
(gen/sample (s/gen ::user) 3)

;; Malli
(require '[malli.generator :as mg])
(mg/sample User 3)

两者都能从 schema 生成样本。Malli 的生成器不需要额外依赖 test.check(内置了自己的生成引擎,也可桥接到 test.check),spec 必须引入 org.clojure/test.check。

4.2 生成器定制

;; Malli:生成器作为 schema 属性
(def Age [:int {:min 0 :max 150
                :gen/gen (gen/choose 0 150)}])

;; spec:用 s/with-gen 包装
(s/def ::age (s/with-gen (s/int-in 0 150) #(gen/choose 0 150)))

Malli 的写法更内聚——生成器就在 schema 旁边;spec 需要 s/with-gen 包裹整个 spec。

4.3 属性测试的结合

生成式测试的真正威力在于属性断言:

(defprop encode-decode-roundtrip
  [user (mg/generator User)]
  (is (= user (decode (encode user)))))

即「任意合法输入,经过编码再解码应当还原」。这类性质用 schema 生成器写起来极快,能覆盖大量手工想不到的边界。属性测试的方法论可参考 Clojure 属性测试实战 。

关键差异:Malli 的 schema 是数据,可以程序化组合生成器(比如「生成一个所有字段都合法的 map,再把某个字段改成非法值」);spec 的宏 schema 很难在运行时拆解重组。

5. 错误信息可读性

5.1 原始输出

;; spec
(s/explain-data ::user {:id -1 :name ""})
;; => {:clojure.spec.alpha/problems [{:path [:id] :pred pos-int? :val -1} ...]}

;; Malli
(m/explain User {:id -1 :name ""})
;; => {:schema ... :value {...} :errors [{:path [:id] :in [:id]
;;      :schema [:int {:min 1}] :value -1}]}

两者都返回结构化错误,Malli 的 :errors 更规整,且带 :in 路径(可用于定位到具体位置)。

5.2 面向开发者的可读性

;; Malli:humanize 输出人类可读文本
(require '[malli.error :as me])
(me/humanize (m/explain User {:id -1 :name ""}))
;; => {:id ["should be at least 1"], :name ["should be at least 1 character"]}

malli.error/humanize 是 Malli 的杀手锏:一行把结构化错误转成可直接返回给用户的文案。spec 要做同样的事得自己写遍历器,工作量不小。

5.3 面向用户 vs 面向开发者

场景需求推荐
API 入参错误返回字段级、可本地化文案Malli humanize
内部断言精确 path 与谓词两者皆可
日志记录结构化、可检索Malli explain

如果系统有对外 API,Malli 在错误信息上的优势几乎是决定性的。

5.4 错误信息的陷阱

无论用哪个,都要注意:

  • 不要在错误信息里泄漏内部结构:返回给用户的文案不应暴露 schema 细节或内部字段名;
  • 错误路径要稳定:客户端可能依赖 :path 做字段高亮,schema 重构会破坏它;
  • 一次报全部错误还是首个错误:explain 报全部,适合表单;快速失败场景可只取第一个。

6. 运行时开销与性能

6.1 解释 vs 编译

;; spec:本身是解释执行,无编译概念
(s/valid? ::user data)

;; Malli:可编译成函数,避开解释开销
(def validate-user (m/compiler User))
(validate-user data)

这是 Malli 另一个关键优势:m/compiler 把 schema 编译成普通 Clojure 函数,之后每次校验就是一次函数调用,没有 schema 遍历的解释开销。spec 只能解释执行。

6.2 粗略基准

同一份中等复杂度 schema 的校验吞吐(量级示意,具体数值随 schema 与 JDK 变化):

方式相对吞吐说明
spec s/valid?1x解释执行
Malli m/validate~1.5x解释执行,已略快
Malli 编译后~3x+编译成函数
手写谓词~5x+无 schema 抽象

结论:热点路径用 Malli 编译版,非热点用解释版即可。spec 的性能在多数业务场景够用,只有在高 QPS 边界校验时才成为问题。

6.3 何时不该做运行时校验

  • 纯内部调用:同一进程内、类型已由构造保证,可只在边界校验;
  • 超大集合:逐元素校验成本高,可校验形状 + 抽样校验元素;
  • 启动期配置:只校验一次,成本可忽略,应该做。

一个实用原则:校验放在系统边界(HTTP、消息、文件、配置),内部用构造保证。

7. 生态与选型

7.1 coercion 集成

;; reitit + Malli coercion
(require '[reitit.coercion.malli :as malli])
["/orders/:id" {:get {:parameters {:path [:map [:id :int]]}
                      :coercion malli/coercion
                      :handler get-order}}]

reitit 同时支持 spec 与 Malli coercion,Malli 的写法更直接(参数本身是数据)。API 层的完整实践见 Clojure REST API 设计实战 。

7.2 JSON Schema 导出

(require '[malli.json-schema :as json-schema])
(json-schema/transform User)
;; => {"type" "object" "properties" {...} "required" [...]}

因为 schema 是数据,Malli 能直接导出 JSON Schema、OpenAPI、Swagger。spec 做不到这一点——这是数据驱动路线最实际的回报,也是它在前端契约、跨语言协作场景胜出的原因。

7.3 选型决策表

维度clojure.specMalli胜出
语法便利宏,惯用vector,稍啰嗦spec
map 键级约束弱强Malli
封闭 map需自实现:closedMalli
条件分派手写谓词:multiMalli
全局状态强依赖 registry默认无Malli
编译加速不支持m/compilerMalli
错误 humanize需自建内置Malli
JSON Schema 导出不支持支持Malli
官方/生态稳定官方社区spec
生成式测试test.check 必需内置Malli

7.4 实践建议

  • 新项目、对外 API、需要契约导出:选 Malli;
  • 已有 spec 体系、依赖官方库、需要与 s/fdef 深度集成:继续用 spec;
  • 两者并存:不推荐在同一模块混用,但可以在「新模块 Malli、老模块 spec」的边界上做一次性转换。

已有 spec 深度实践(s/fdef、instrument、conform)可参考 clojure.spec 数据验证框架 ;跨语言对比可见 TypeScript Zod 运行时校验 ,能对照出「数据驱动 schema」在各语言里的共同趋势。

8. 小结

spec 与 Malli 的分野,本质是**「schema 是代码」还是「schema 是数据」**:

  • spec 用宏写 schema,语法自然、与官方工具链(fdef、instrument)贴合,但 schema 不可序列化、不可程序化处理;
  • Malli 用数据写 schema,换来 map 键级约束、封闭 map、:multi 分派、编译加速、humanize 错误、JSON Schema 导出等一整条下游能力。

落到工程决策:

  1. 边界校验优先 Malli,错误信息与契约导出收益最大;
  2. 热点路径用 m/compiler,避免解释开销;
  3. 测试用生成器 + 属性断言,让 schema 同时承担「契约」与「测试数据源」两个职责;
  4. 校验只放在边界,内部靠构造保证,避免无谓的运行时成本。

把 schema 当成系统边界上的契约文档,而不是「到处加一层校验」,它才会真正降低而不是增加复杂度。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「clojure」更多文章

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