June 9, 2025
By: Kevin'

Clojure Spec: 不仅是验证, 更是强大的数据解析工具

  1. Spec 的基本用法: 验证
  2. Spec 的进阶用法: 组合
    1. s/def: 定义 Spec
    2. s/or: 或
    3. s/keys: Map
  3. Spec 的高级用法: 解析
    1. 解析 defn
    2. 自定义 defn
    3. 强制要求 Doc String
    4. 自动添加 Instrumentation
  4. Spec 的强大功能: 数据生成
    1. 基本数据生成
    2. 生成复合数据结构
    3. 自定义生成器
  5. 总结

很多人提到 Spec, 首先想到的是它的规范定义和验证功能. 没错, Spec 确实可以用来做数据验证. 但 Spec 的另一个强大之处在它可以用来解析代码和数据结构, 生成一个抽象语法树 (AST), 而不需要你手动编写解析器.

简单来说, Spec 既可以做验证, 也可以做解析. 这会带来一些非常有趣的可能性. 下面我将结合一些实例, 和大家一起探索 Spec 的妙用.

Spec 的基本用法: 验证

Spec 可以用来定义数据的规范, 并检查数据是否符合规范. 例如, 我们可以使用 s/valid? 函数来判断一个值是否是字符串:

(require '[clojure.spec.alpha :as s])

; 检查 "hello" 是否是字符串
(s/valid? string? "hello") ;=> true

; 检查 3 是否是字符串
(s/valid? string? 3) ;=> false

; 检查一个列表是否是列表
(s/valid? list? '(1 2 3)) ;=> true

除了内置的 string?list? 之外, 还可以自定义验证函数:

; 定义一个验证函数, 检查数字是否大于 0
(defn positive? [x] (and (number? x) (> x 0)))

; 检查 5 是否是正数
(s/valid? positive? 5) ;=> true

; 检查 -3 是否是正数
(s/valid? positive? -3) ;=> false

如果验证失败, 我们可以使用 s/explain 函数来查看失败的原因:

; 解释为什么 3 不是字符串
(s/explain string? 3)
;=> 3 - failed: string? in: :clojure.spec.alpha/value

; 获取详细的解释数据
(s/explain-data string? 3)
;=> {:problems [{:path [:clojure.spec.alpha/value], :pred string?, :val 3, :via [], :in []}], :spec string?, :value 3}

s/explain-data 会返回一个包含详细信息的 Map, 包括失败的路径, 谓词 (predicate), 值等等.

Spec 的进阶用法: 组合

除了基本的验证之外, Spec 还提供了一些组合器 (combinator), 可以将多个 Spec 组合成更复杂的 Spec.

s/def: 定义 Spec

s/def 用于定义一个 Spec, 并将其注册到 Spec 的注册表中. 为了避免命名冲突, 我们通常会使用带命名空间的关键字 (namespaced keyword) 作为 Spec 的名称.

(ns spec-demo
  (:require [clojure.spec.alpha :as s]))

; 定义一个 Spec, 表示非空字符串
(s/def ::non-blank-string (s/and string? (complement clojure.string/blank?)))

; 检查 " hello " 是否是非空字符串
(s/valid? ::non-blank-string " hello ") ;=> true

; 检查 "   " 是否是非空字符串
(s/valid? ::non-blank-string "   ") ;=> false

s/or: 或

s/or 用于定义一个 Spec, 表示一个值必须满足多个 Spec 中的至少一个. s/or 需要为每个 Spec 指定一个标签 (tag), 用于标识匹配的分支.

(ns spec-demo
  (:require [clojure.spec.alpha :as s]))

;; 定义一个 Spec, 表示非空字符串或数字
(s/def ::string-or-number (s/or :string ::non-blank-string :number number?))

;; 检查 "hello" 是否满足 Spec
(s/valid? ::string-or-number "hello") ;=> true

;; 检查 123 是否满足 Spec
(s/valid? ::string-or-number 123) ;=> true

;; 检查一个向量是否满足 Spec
(s/valid? ::string-or-number [1 2 3]) ;=> false

;; 查看失败原因
(s/explain-data ::string-or-number [1 2 3])
;=> {:problems
;    [{:path [:string], :pred (s/and string? (complement clojure.string/blank?)), :val [1 2 3], :via [], :in []}
;     {:path [:number], :pred number?, :val [1 2 3], :via [], :in []}],
;    :spec :spec-demo/string-or-number,
;    :value [1 2 3]}

s/keys: Map

s/keys 用于定义一个 Spec, 表示一个 Map 必须包含指定的键 (key). Clojure Spec 对 Map 的处理方式比较宽松, 它只会验证特定的键, 忽略其他的键.

(ns spec-demo
  (:require [clojure.spec.alpha :as s]))

;; 定义一个 Spec, 表示包含 :my-vector 键 (值满足 ::my-vector Spec) 的 Map
(s/def ::my-map (s/keys :req-un [::my-vector]))

;; 检查 {:my-vector ["foo" 123 4]} 是否满足 Spec
(s/valid? ::my-map {:my-vector ["foo" 123 4]}) ;=> true

;; 检查 {:my-vector ["foo" 123 "bar"]} 是否满足 Spec
(s/valid? ::my-map {:my-vector ["foo" 123 "bar"]}) ;=> false

;; 检查 {:my-vector ["foo" 123 4] :other-key "hello"} 是否满足 Spec (忽略 :other-key)
(s/valid? ::my-map {:my-vector ["foo" 123 4] :other-key "hello"}) ;=> true

Spec 的高级用法: 解析

Spec 最强大的地方在于, 它可以用来解析数据结构, 生成一个抽象语法树 (AST). 可以使用 s/conform 函数将数据结构转换为 AST, 使用 s/unform 函数将 AST 转换为原始的数据结构.

解析 defn

Clojure 的 defn 宏用于定义函数. 我们可以使用 Clojure Core 提供的 Spec 来解析 defn 宏的结构.

(require '[clojure.spec.alpha :as s]
         '[clojure.core.specs.alpha :as core-specs])

;; 定义一个简单的函数
(defn foo [x] (+ x x))

;; 使用 core-specs/defn-args Spec 解析函数
(s/conform ::core-specs/defn-args '(foo [x] (+ x x)))
(s/explain ::core-specs/defn-args '(defn foo "ba" [x] (+ x x)))

s/conform 返回一个 Map, 其中包含了函数的名称, 参数列表和函数体等信息.

自定义 defn

我们可以使用 Spec 来自定义 defn 宏, 例如, 添加一个 public-api 元数据 (metadata), 用于标识公共 API.

; 定义一个 Spec, 用于解析函数参数
(s/def ::defn-args (s/cat :name symbol?
                          :doc (s/? string?)
                          :meta (s/? map?)
                          :fn-tail (s/* any?)))

;; 定义一个宏, 用于添加 public-api 元数据
(defmacro def-api [name & args]
  (let [conformed-args (s/conform ::defn-args (apply list name  args))
        meta-data (or (:meta conformed-args) {})
        updated-meta (assoc meta-data :public-api true)
        updated-args (assoc conformed-args :meta updated-meta)
        unformed-args (s/unform ::defn-args updated-args)]
    `(defn ~@unformed-args)))

; 使用 def-api 宏定义一个函数
(def-api my-function
  "This is a public API."
  [x]
  (* x 2))

; 查看函数的元数据
(meta #'my-function)
;;=> {:doc "This is a public API.", :public-api true, :line 63, :column 1, :file "clj_test/user.clj", :name my-function, :ns #object[clojure.lang.Namespace 0x17f2e50d user]}

在这个例子中, 我们首先定义了一个 Spec ::defn-args, 用于解析函数的参数. 然后, 我们定义了一个宏 def-api, 它首先使用 s/conform 将函数的参数转换为 AST, 然后向 AST 中添加 public-api 元数据, 最后使用 s/unform 将 AST 转换为原始的函数定义.

强制要求 Doc String

在上面的例子中, Doc String 是可选的. 我们可以使用 Spec 来强制要求 Doc String.

(ns spec-demo
  (:require [clojure.spec.alpha :as s]))

;; 定义一个 Spec, 用于解析函数参数 (Doc String 必须是非空字符串)
(s/def ::defn-api-args (s/cat :name symbol?
                              :doc ::non-blank-string
                              :meta (s/? map?)
                              :fn-tail (s/* any?)))

; 定义一个宏, 用于添加 public-api 元数据 (Doc String 必须是非空字符串)
(defmacro def-api-v2 [name & args]
  (let [conformed-args (s/conform ::defn-api-args (apply list name  args))
        meta-data (or (:meta conformed-args) {})
        updated-meta (assoc meta-data :public-api true)
        updated-args (assoc conformed-args :meta updated-meta)
        unformed-args (s/unform ::defn-api-args updated-args)]
    `(defn ~@unformed-args)))

; 使用 def-api-v2 宏定义一个函数 (Doc String 必须是非空字符串)
(def-api-v2 my-function-v2
  "This is a public API."
  [x]
  (* x 2))


;; 使用 def-api-v2 宏定义一个函数 (Doc String 为空字符串, 会报错)
;;(def-api-v2 my-function-v3
;;  ""
;;  [x]
;;  (* x 2))
;; CompilerException clojure.lang.ExceptionInfo: Spec assertion failed

自动添加 Instrumentation

我们还可以使用 Spec 来自动为函数添加 instrumentation 代码, 例如, 在函数执行前后打印日志.

(ns spec-demo
  (:require [clojure.spec.alpha :as s]))

;; 定义一个函数, 用于包装函数体, 添加 instrumentation 代码
(defn wrap-enter-leave [name arg-list body]
  `(do
     (println (str "Entering function " '~name " with arguments " '~arg-list))
     (let [result# ~body]
       (println (str "Leaving function " '~name " with result " result#))
       result#)))

; 定义一个宏, 用于添加 public-api 元数据, 并自动添加 instrumentation 代码
(defmacro def-api-v3 [name & args]
  (let [conformed-args (s/conform ::defn-api-args (apply list name  args))
        meta-data (or (:meta conformed-args) {})
        updated-meta (assoc meta-data :public-api true)
        updated-args (assoc conformed-args :meta updated-meta)
        fn-tail (:fn-tail conformed-args)
        arg-list (first fn-tail)
        body (second fn-tail)
        wrapped-body (wrap-enter-leave name arg-list body)
        updated-fn-tail (list arg-list wrapped-body)
        updated-args (assoc updated-args :fn-tail updated-fn-tail)
        unformed-args (s/unform ::defn-api-args updated-args)]
    `(defn ~@unformed-args)))

; 使用 def-api-v3 宏定义一个函数
(def-api-v3 my-function-v4
  "This is a public API."
  [x]
  (* x 2))

;; 调用函数
(my-function-v4 3)
;;=> Entering function my-function-v4 with arguments [x]
;;=> Leaving function my-function-v4 with result 6
;;=> 6

Spec 的强大功能: 数据生成

基本数据生成

(require '[clojure.spec.alpha :as s]
         '[clojure.spec.gen.alpha :as gen])

;; 生成随机字符串
(gen/generate (s/gen string?))
;;=> "aB3"

;; 生成多个样本
(gen/sample (s/gen int?))
;;=> (0 1 -1 0 -2 4 5 -3 7 9)

生成复合数据结构

;; 定义用户规范
(s/def ::username string?)
(s/def ::age pos-int?)
(s/def ::user (s/keys :req [::username ::age]))

;; 生成用户数据
(gen/generate (s/gen ::user))
;;=> {:spec-demo/username "a", :spec-demo/age 1}

自定义生成器

当默认生成器不满足需求时.可以自定义:

(s/def ::status
  (s/with-gen
    #{"active" "inactive"}
    #(gen/elements ["active" "inactive"])))

(gen/sample (s/gen ::status))
;;=> ("active" "inactive" "active" "inactive" ...)
#+end_src

总结

Clojure Spec 不仅仅是一个验证工具, 更是一个强大的数据解析工具. 通过 Spec, 我们可以定义数据的规范, 并将数据转换为 AST, 然后对 AST 进行各种操作, 例如添加元数据, 添加 instrumentation 代码等等. 这使得我们可以更加灵活地操作代码, 实现各种高级功能.

希望这篇文章能够帮助大家更好地理解 Clojure Spec, 并在实践中发挥它的威力.

Tags: clojure spec