在动态类型的 Clojure 世界中,确保数据的形状正确是一个核心工程挑战。clojure.spec 是 Clojure 官方于 1.9 版本引入的数据描述与验证库,它不仅可以进行运行时的结构检查,还能为属性驱动测试(Property-Based Testing)自动生成海量测试数据。本文将深入 spec 的完整功能谱系,从基础验证到高级生成器定制,并通过实战展示如何用 spec 守护生产系统的数据边界。
1. 为什么 Clojure 需要 spec
1.1 动态语言的数据保障
Clojure 作为动态类型语言,编译期不强制类型检查。这在提高开发效率的同时,也增加了运行时因数据结构错误导致的崩溃风险。spec 的定位是「可选的、渐进式的结构化验证」:
- 开发期:用 spec 文档化函数期望的数据结构
- 测试期:自动生成边界数据进行属性测试
- 生产期:选择性开启符合性检查,捕获非法输入
1.2 spec 与其他验证方案对比
| 方案 | 实现方式 | 运行时开销 | 生成测试 | 学习成本 |
|---|---|---|---|---|
| clojure.spec | 官方库,S-表达式描述 | 可控,可选启用 | 原生支持 | 中 |
| Prismatic Schema | 第三方库,类型描述 | 中等 | 需额外工具 | 低 |
| Malli | 数据驱动模式 | 描述性模式 | 原生支持 | 中低 |
| core.typed | 静态类型检查 | 编译期(无运⾏期开销) | 不支持 | 高 |
spec 的优势在于它是官方库,与 Clojure 深度集成,且支持「代码即数据」的设计哲学。
2. spec 基础定义
2.1 核心概念:s/def、conform、valid?
(require '[clojure.spec.alpha :as s])
;; 定义 spec(全局注册)
(s/def ::name string?)
(s/def ::age pos-int?)
(s/def ::email (s/and string? #(re-matches #".+@.+\..+" %)))
;; 验证数据
(s/valid? ::name "Alice") ; => true
(s/valid? ::name 123) ; => false
(s/valid? ::age 25) ; => true
(s/valid? ::age -1) ; => false
;; conform:验证并变形数据
(s/conform ::email "alice@example.com") ; => "alice@example.com"
(s/conform ::email "not-an-email") ; => :clojure.spec.alpha/invalid
2.2 数据结构的 spec
(s/def ::person
(s/keys :req [::name ::age]
:opt [::email ::phone]))
;; 验证 map
(s/valid? ::person {::name "Bob" ::age 30}) ; => true
(s/valid? ::person {::name "Bob"}) ; => false(缺 ::age)
(s/valid? ::person {::name "Bob" ::age -5}) ; => false(::age 不符合)
;; 使用非命名空间键的 spec
(s/def :person/name string?)
(s/def :person/age pos-int?)
(s/def :person/data (s/keys :req-un [:person/name :person/age]))
(s/valid? :person/data {:name "Carol" :age 28}) ; => true
2.3 explain:友好的错误信息
(s/explain ::person {::name "Dave"})
;; 输出:
;; In: [:user/age] val: nil fails spec: :user/age predicate: pos-int?
(s/explain-data ::person {::name "Dave"})
;; => 返回结构化错误数据,便于程序化解析
3. 组合 spec:and、or、tuple、coll-of
3.1 s/and 与 s/or
;; 同时满足多个条件
(s/def ::port
(s/and pos-int? #(<= 1 % 65535)))
(s/valid? ::port 8080) ; => true
(s/valid? ::port 70000) ; => false
;; 多选分支(带标签)
(s/def ::http-method
(s/or :get #{:get}
:post #{:post}
:put #{:put}
:delete #{:delete}
:patch #{:patch}))
(s/conform ::http-method :put)
;; => [:put :put]
3.2 集合 spec
;; 列表集合
(s/def ::tags (s/coll-of keyword?))
(s/valid? ::tags [:clojure :macro :functional]) ; => true
;; 集合:去重
(s/def ::unique-ids (s/coll-of pos-int? :kind set?))
;; 元组(固定长度、固定位置类型)
(s/def ::point (s/tuple double? double?))
(s/conform ::point [1.5 2.5]) ; => [1.5 2.5]
;; 映射 every
(s/def ::string-map (s/map-of string? pos-int?))
(s/valid? ::string-map {"a" 1 "b" 2}) ; => true
3.3 cat 与 alt:序列结构
;; cat:有序的规格序列(用于解析命令或 DSL)
(s/def ::cli-command
(s/cat :cmd #{'move 'copy 'delete}
:src string?
:dest (s/? string?)))
(s/conform ::cli-command '(move "a.txt" "b.txt"))
;; => {:cmd move, :src "a.txt", :dest "b.txt"}
;; alt:选择分支规格
(s/def ::token
(s/alt :number number?
:string string?
:keyword keyword?))
4. 函数 spec:fdef
4.1 基本用法
;; 定义函数及其 spec
(defn calculate-tax [amount rate]
(* amount rate))
(s/fdef calculate-tax
:args (s/cat :amount pos? :rate (s/and number? #(<= 0 % 1)))
:ret pos?
:fn #(<= (:ret %) (-> % :args :amount)))
4.2 启用运行时检查
(require '[clojure.spec.test.alpha :as stest])
;; instrument:启用 args 检查
(stest/instrument `calculate-tax)
;; 现在调用会检查参数
(calculate-tax 100 0.08) ; => 8.0(正常)
(calculate-tax -100 0.08) ; => ExceptionInfo: Call to calculate-tax did not conform to spec.
;; check:自动生成测试
(stest/check `calculate-tax)
4.3 高阶函数 spec
(defn map-vals [f m]
(into {} (for [[k v] m] [k (f v)])))
(s/fdef map-vals
:args (s/cat :f ifn? :m (s/map-of any? any?))
:ret (s/map-of any? any?)
:fn #(= (set (keys (:ret %))) (set (keys (-> % :args :m)))))
5. 属性驱动测试:test.check
5.1 生成器基础
(require '[clojure.spec.gen.alpha :as gen])
;; 从 spec 生成数据
(gen/generate (s/gen string?))
;; => "7&bQ*1m"
(gen/generate (s/gen ::age))
;; => 42
;; 生成样本集合
(gen/sample (s/gen ::person))
;; => ({:user/name "a" :user/age 1} ...)
5.2 自定义生成器
;; 为复杂 spec 编写生成器
(s/def ::email-domain
#"@(gmail|yahoo|outlook)\.com")
(s/def ::email-address
(s/with-gen
(s/and string? #(re-matches #"[a-z]+@.+\..+" %))
#(gen/fmap
(fn [name] (str name "@example.com"))
(gen/string-alphanumeric))))
(gen/sample (s/gen ::email-address) 3)
;; => ("K@example.com" "g5@example.com" "p@example.com")
5.3 属性测试实战
(require '[clojure.test.check :as tc])
(require '[clojure.test.check.properties :as prop])
;; 验证 reverse 两次等于原序列
(def reverse-property
(prop/for-all [v (gen/vector gen/int)]
(= v (reverse (reverse v)))))
(tc/quick-check 100 reverse-property)
;; => {:result true, :num-tests 100, :seed 12345}
;; 验证排序后递增
(def sorted-property
(prop/for-all [v (gen/vector gen/int)]
(let [sorted (sort v)]
(every? (fn [[a b]] (<= a b))
(partition 2 1 sorted)))))
(tc/quick-check 1000 sorted-property)
;; => {:result true, :num-tests 1000}
5.4 结合 spec 的自动测试
;; 通过 spec 自动生成边界测试
(stest/check `calculate-tax)
;; => ({:sym user/calculate-tax,
;; :result {:result true, :num-tests 1000, :seed 67890}})
;; 连续测试多个函数
(stest/check `(calculate-tax map-vals parse-int))
6. 实战:API 请求参数校验
(ns myapp.api.validation
(:require [clojure.spec.alpha :as s]
[clojure.spec.test.alpha :as stest]))
;; 定义业务 spec
(s/def :api/offset (s/and int? #(>= % 0)))
(s/def :api/limit (s/and int? #(<= 1 % 100)))
(s/def :api/sort-field #{:created_at :updated_at :name})
(s/def :api/sort-order #{:asc :desc})
(s/def :api/page-params
(s/keys :req-un [:api/offset :api/limit]
:opt-un [:api/sort-field :api/sort-order]))
(s/def :api/user-query
(s/keys :req-un [:api/page-params]
:opt-un [:api/filter-name :api/filter-email]))
;; 统一的校验入口
(defn validate-request [spec data]
(if (s/valid? spec data)
{:valid true :data (s/conform spec data)}
{:valid false :errors (s/explain-str spec data)}))
;; Ring 中间件集成
(defn wrap-spec-validation [handler spec]
(fn [request]
(let [body (:body-params request)
result (validate-request spec body)]
(if (:valid result)
(handler (assoc request :conformed-params (:data result)))
{:status 400
:body {:error "Invalid request parameters"
:details (:errors result)}}))))
;; 使用
(def app
(-> api-routes
(wrap-spec-validation :api/user-query)))
7. Malli:现代替代方案简介
对于新项目,Malli 作为社区驱动的 Spec 演进方案越来越受欢迎:
(require '[malli.core :as m])
;; Malli 语法更紧凑
(def User
[:map
[:name string?]
[:age [:and int? [:> 0]]]
[:email {:optional true} [:re #".+@.+\\..+"]]])
;; 验证
(m/validate User {:name "Alice" :age 30}) ; => true
;; 错误信息
(m/explain User {:name "Bob" :age -5})
;; => 详细路径错误信息
;; 生成器
(require '[malli.generator :as mg])
(mg/generate User)
Malli 的优势是纯数据描述(无宏)、更小体积(不含 test.check 依赖)、更好的性能。但 spec 作为官方库,生态兼容性更强。
8. 总结
clojure.spec 将「代码即数据」的哲学延伸到了验证领域。通过 spec 描述数据形状,你不仅获得了灵活的运行时检查能力,还解锁了强大的属性驱动测试。建议在以下场景优先引入 spec:
- 系统边界的数据输入校验(API、配置)
- 核心业务逻辑的契约定义(fdef)
- 测试策略升级(从示例测试到属性驱动测试)
要深入理解 Clojure 的数据驱动设计,建议继续阅读 [Clojure 多方法与协议]({{< relref “clojure-multimethods-protocols” >}}) 中 Record 的 spec 验证实践。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。