Clojure China

尝试 Clojure Spec 的笔记

#1

原文 https://segmentfault.com/a/1190000019390308

工具当中需要检测数据格式, 试着用了一下 Clojure Spec.
如果英文好, 直接看文档就行了, 也不用这篇笔记, 太琐碎了, 也缺失例子…
https://clojure.org/guides/spec#_entity_maps

例子我整理在了 spec-examples 仓库, 可以用 Lumo 直接跑.

首先添加依赖, 因为我在 ClojureScript 当中用, 所以用了 cljs.spec 这个代码.
expound 是一个用于美化 Spec 输出的类库, 直接引用进来.

[cljs.spec.alpha :as s]
[expound.alpha :refer [expound]]

首先是一个很简单觉得例子, 有 s/valid? 判断数据是否符合格式.
首先用 s/def 定义好一个校验的规则, 其中 ::example 会按照命名空间展开.

(s/def ::example boolean?)
(println (s/valid? ::example 1)) ; false
(println (s/valid? ::example true)) ; true

基础的校验用的是函数, 也可以是 string?.

s/conform 表示返回输出的值… 当然这个是正确的情况, 返回了匹配到的字符串,

(s/def ::example string?)
(println (s/conform ::example "DEMO")) ; DEMO

如果不匹配, 返回值就是 invalid,

(println (s/conform number? ""))
:cljs.spec.alpha/invalid

可以通过 s/explain 来打印失败的原因,

(s/def ::example string?)
(println (s/explain ::example 1))
; 1 - failed: string? spec: :app.main/example

可以看到这个原因比较精确, 但是可读性不怎么样, 就可以用 expound 替换了, 可读性会好很多,

(s/def ::example string?)
(println (expound ::example 1))
-- Spec failed --------------------

  1

should satisfy

  string?

-- Relevant specs -------

:app.main/example:
  cljs.core/string?

-------------------------
Detected 1 error

既然校验规则是函数, 也可以写成,

(s/def ::example #(and (> % 6) (< % 20)))
(println (s/valid? ::example 1)) ; false
(println (s/valid? ::example 10)) ; true
(println (s/valid? ::example 20)) ; false

校验规则也可以组合使用, 最简单就是 s/or, 注意参数中奇数位置都用的 keyword,

(s/def ::example (s/or :as-number number? :as-boolean boolean?))

(let [data 0]
  (if (s/valid? ::example data)
    (println (s/conform ::example data))
    (println (expound ::example data)))))

打印的结果是,

[:as-number 0]

s/or 里直接用函数式简写了, 可以专门定义两个规则出来, 然后再使用,

(s/def ::boolean boolean?)

(s/def ::number number?)

(s/def ::example (s/or :as-number ::number :as-boolean ::boolean))

(if (s/valid? ::example 20)
  (println (s/conform ::example 20))
  (println (expound ::example 20)))

返回依然得到数据,

[:as-number 20]

对于数组的结构的数据, 用 s/coll-of 来判别,

(s/def ::number number?)

(s/def ::example (s/coll-of ::number))

(let [data [1]]
  (if (s/valid? ::example data)
    (println (s/conform ::example data))
    (println (expound ::example data))))

得到,

[1]

s/coll-of 还支持比如 :count 这样的校验, 具体可以再看文档,

(s/def ::example (s/coll-of number? :count 2))

(defn task! []
 (let [data [1]]
   (if (s/valid? ::example data)
     (println (s/conform ::example data))
     (println (expound ::example data)))))
-- Spec failed --------------------

  [1]

should satisfy

  (= 2 (count %))

-- Relevant specs -------

:app.main/example:
  (cljs.spec.alpha/coll-of cljs.core/number? :count 2)

-------------------------
Detected 1 error

对于 Map, 用 s/keys 来判断, :req-un 表示必选项, opt-un 是可选项,

(s/def ::age number?)

(s/def ::name string?)

(s/def ::example (s/keys :req-un [::age] :opt-un [::name]))

(let [data {:age 1, :name "a"}]
  (if (s/valid? ::example data)
      (println (s/conform ::example data))
      (println (expound ::example data))))

得到,

{:age 1, :name a}

如果不满足校验规则, 会准确提示出来, 比如可选项的规则不满足,

(s/def ::age number?)

(s/def ::name string?)

(s/def ::example (s/keys :req-un [::age] :opt-un [::name]))

(let [data {:age 1, :name 1}]
  (if (s/valid? ::example data)
    (println (s/conform ::example data))
    (println (expound ::example data))))
-- Spec failed --------------------

  {:age ..., :name 1}
                   ^

should satisfy

  string?

-- Relevant specs -------

:app.main/name:
  cljs.core/string?
:app.main/example:
  (cljs.spec.alpha/keys :req-un [:app.main/age] :opt-un [:app.main/name])

-------------------------
Detected 1 error

上面用到的 -un 的后缀表示 “unqualified”, 如果没有后缀, 意味着 keyword 要根据命名空间展开,


(s/def ::age number?)

(s/def ::name string?)

(s/def ::example (s/keys :req [::age] :opt [::name]))

(let [data {:age 1, :name 1}]
  (if (s/valid? ::example data)
    (println (s/conform ::example data))
    (println (expound ::example data))))

于是就不满足了,

-- Spec failed --------------------

  {:age 1, :name 1}

should contain key: :app.main/age

|           key |    spec |
|---------------+---------|
| :app.main/age | number? |

-- Relevant specs -------

:app.main/example:
  (cljs.spec.alpha/keys :req [:app.main/age] :opt [:app.main/name])

-------------------------
Detected 1 error

就需要改写一下 key, 也用 ::x 的语法带上命名空间,

(s/def ::age number?)

(s/def ::name string?)

(s/def ::example (s/keys :req [::age] :opt [::name]))

(let [data {::age 1, ::name "a"}]
  (if (s/valid? ::example data)
    (println (s/conform ::example data))
    (println (expound ::example data))))

得到,

{:app.main/age 1, :app.main/name a}

Spec 也可以对字符串进行校验, 同时也可以解析得到数据,
其中需要用到 (s/conformer seq) 来对字符串进行转化…
这个写法目前我也不够清晰, 参考了一下例子,

(s/def ::left-paren #{"("})

(s/def ::right-paren #{")"})

(s/def ::space (s/and string? (s/conformer seq) (s/+ #{" "})))

(s/def ::token (s/and string? (s/conformer seq) (s/+ #{"a" "b" "c"})))

(s/def
 ::example
 (s/cat
  :left-paren
  ::left-paren
  :expr
  (s/+ (s/or :token ::token :space ::space))
  :right-paren
  ::right-paren))

(let [data (seq "(a b)")]
  (if (s/valid? ::example data)
    (println (pr-str (s/conform ::example data)))
    (println (s/explain ::example data))))

最终得到,

{:left-paren "(", :expr [[:token ["a"]] [:space [" "]] [:token ["b"]]], :right-paren ")"}

更多

另外关于 multi-spec 的例子, 还有生成代码的例子, 我在 GitHub 上整理了,


代码比较成就不复制了.

另外细节的功能没有记录, 具体要看官方文档. https://clojure.org/guides/spec#_entity_maps