Clojure Spec: 不仅是验证, 更是强大的数据解析工具
很多人提到 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, 并在实践中发挥它的威力.