Clojure spec 与测试:数据验证、生成测试与属性驱动

系统掌握 clojure.spec 数据验证框架:spec 定义与符合性检查(conform)、生成测试(test.check)、函数规格(fdef)、多规格组合与自定义生成器,对比 schema 与 malli 的类型验证方案,附 API 请求参数校验完整实战。

在动态类型的 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 验证实践。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「clojure」更多文章

  1. Clojure 并发设计模式:STM、core.async 与 Agent 实战
  2. Clojure 现代 Web 全栈开发:Ring、reitit 与数据库集成
  3. Clojure 工具链演进:Leiningen、Clojure CLI 与 deps.edn