clojure.spec庫入門學習

此文已由做者張佃鵬受權網易雲社區發佈。
html

歡迎訪問網易雲社區,瞭解更多網易技術產品運營經驗。java


clojure是一門動態類型的語言,在類型檢查方面並無c++/java這種靜態類型語言好用,因此多個模塊之間進行接口參數傳遞時,因爲接口文檔設計不嚴謹等緣由,總會發生接口參數類型錯誤,參數個數不正確等問題,給代碼調試帶來很大的挑戰,所以在clojure中,對接口參數的進行類型和範圍的檢查是很是必要的。爲此,咱們找到了clojure.spec這個庫( https://clojure.github.io/clojure/branch-master/clojure.spec-api.html ),正好能夠解決以上問題。c++


1.clojure.spec庫介紹:git

spec庫主要是用來定義數據的結構和類型,並對其數據類型進行校驗,而且能夠根據spec生成對應的校驗數據。要使用clojure.spec庫,必須依賴最新的alpha版本的clojure:程序員

    [org.clojure/clojure "1.9.0-alpha10"]

由於clojure.spec庫中有不少函數與clojure.core庫中的函數重名(+、melt、def),因此不要使用use將整個庫中的函數導入到當前命名空間下,而是要使用require,以下:github

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


2.specification的定義:正則表達式

可使用s/def函數來給一個specification定義名字,由s/def定義的specification必須是namespace keyword(其中關於命名空間的關鍵字在這篇博客中有介紹:http://blog.csdn.net/zdplife/article/details/52304258 )。最簡單定義spec的方式就是:一個普通的謂詞函數(接收一個參數,返回boolean類型)就能夠做爲一個specification:數據庫

    ;;定義一個specification,只有偶數才能知足條件
    (s/def ::even-val even?)
    ;;=> :my-clj.core/even-val

specification也能夠是多個specification的組合,最簡單的組合函數是:s/and和s/or。s/and必須知足and下的全部specification的條件,組合的specification纔會校驗成功,以下:api

    ;; ::and-test表示大於10的偶數
    (s/def ::and-test (s/and 
                        ::even-val
                        #(> % 10)
                        ))
    ;;=> :my-clj.core/and-test

s/or函數表示只要知足其中一個條件則成立,可是s/or函數必須在每個條件前面加一個:tag,這主要是爲了在使用s/conform解析數據時,讓數據更容易理解,便於肯定是知足那個條件。s/or的用法以下:數組

    ;;以下在謂詞int?和string?前加了tag,tag主要是爲了告訴咱們是知足哪一個條件
    (s/def ::or-test
      (s/or :int-number int?            :string-number string?))
    ;;=> :my-clj.core/or-test


3.經常使用解析spec的函數:

使用spec來解析和判斷數據的函數主要有:s/valid? ,s/conform ,s/explain ,s/explain-str ,s/explain-data:

  • s/valid?函數:

s/valid?用來判斷給定數據是否知足當前的specification,若是知足則返回true,不然返回false:

    (s/valid? ::even-val 10)
    ;;=> true
    (s/valid? ::even-val 11)
    ;;=> false
  • s/conform函數:

s/conform函數根據給定的specification和data參數,若是data符合specification校驗條件,則根據specification的格式返回解析後的data,不然返回s/invalid:

    ;;若是知足條件則返回對應的解析數據,不然返回s/invalid
    (s/conform ::even-val 10)
    ;;=> 10
    (s/conform ::even-val 11)
    ;;=> :clojure.spec/invalid

    ;;conform並不是必定會返回與原有數據如出一轍的數據,它會根據對應specification會有所變化,好比or-test有:tag標誌,因此返回的conform-data是一個vector,告訴咱們「hello world」是知足string條件,而不是int條件。
    (s/conform ::or-test "hello world")
    ;;=> [:string-number "hello world"]
  • s/explain系列函數:

    根據explain的字面意思,很容易理解其做用是用來解釋匹配失敗的緣由,共有三個相關函數:s/explain函數將錯誤信息的緣由打印出來,並返回nil;s/explain-str會將s/explian打印出來的內容轉換爲字符串並返回;s/explain-data會將錯誤信息做爲一個map數據結構返回。若是匹配成功,三個函數則都返回對應的success。

    ;;val=11不知足偶數的判斷條件,根據返回結果,咱們很容易找到數據校驗錯誤的位置
    (s/explain ::even-val 11)
    ;;打印結果:val: 11 fails spec: :my-clj.core/even-val predicate: even?
    ;;=> nil

    (s/explain-str ::even-val 11)
    ;;=> "val: 11 fails spec: :my-clj.core/even-val predicate: even?\r\n"

    ;;explain-data會把錯誤信息做爲一個map返回
    (s/explain-data ::even-val 11)
    ;;=> #:clojure.spec{:problems [{:path [], :pred even?, :val 11, :via [:my-clj.core/even-val], :in []}]}


4.集合spec的生成:

咱們常常會遇到某個類型字段是屬於某個集合中的任意一個元素,好比咱們定義一個聚合字段類型,該字段類型多是SUM/MAX/MIN/AVG/COUNT中的一個。爲此咱們能夠定義該字段的spec以下:

    ;;由於clojure中的set能夠看成謂詞使用,因此咱們很容易實現該字段spec的定義
    (s/def ::aggregator #{"SUM","AVG","CNT","CNTD","MAX","MIN"})

    (s/valid? ::aggregator "SUM")
    ;;=> true
    (s/valid? ::aggregator "SUM1")
    ;;=> false


5.數組spec的生成:

對於數組spec常用的生成函數主要有s/coll-of ,s/every ,s/tuple這三個函數:

  • s/coll-of函數:

    coll-of函數生成的數組spec的特色是:該數組中的全部元素必須知足同一個條件。

     (s/def ::even-val even?)
     ;;=> :my-clj.core/even-val
     ;;定義一個數組的spec,該數組中的全部元素都必須是偶數
     (s/def ::coll-of-test (s/coll-of ::even-val))
     ;;=> :my-clj.core/coll-of-test
    
     (s/valid? ::coll-of-test [2 4 6])
     ;;=> true
     (s/valid? ::coll-of-test [1 4 6])
     ;;=> false
     ;;其中數組爲空也知足條件
     (s/valid? ::coll-of-test [])
     ;;=> true

    coll-of函數還接收可選的參數,用來對數組中的元素進行限制,可選參數有以下:

   (1):kind- - - -能夠指定數組的類型,vector,set,list等;

   (2):count- - - -能夠限定數組中元素的個數;

   (3):min-count- - - -限定數組中元素個數的最小值

   (4):max-count- - - -限定數組中元素個數的最大值

   (5):distinct- - - -數組沒有重複的元素

   (6):into- - - -能夠將數組的元素插入到[],(),{},#{}這些其中之一,主要是爲了改變conform函數的返回結果

  關於以上可選參數使用舉例以下:

    ;;定義一個數組的spec,其中全部元素是偶數,這個數組是vector類型,共有4個元素,元素不能重複,conform結果放在set中:
    (s/def ::coll-of-test (s/coll-of even? :kind vector? :count 4 :distinct true :into #{}))
    ;;=> :my-clj.core/coll-of-test

    ;;知足條件
    (s/valid? ::coll-of-test [2 4 6 8])
    ;;=> true

    ;;用explain函數解釋錯誤的緣由
    (s/explain ::coll-of-test [2 4 6 7])
    ;;In: [3] val: 7 fails spec: :my-clj.core/coll-of-test predicate: even?

    ;;list的數組不知足條件
    (s/explain ::coll-of-test '(2 4 6 8))
    ;;val: (2 4 6 8) fails spec: :my-clj.core/coll-of-test predicate: vector?

    ;;conform解析結果放在了一個set中
    (s/conform ::coll-of-test [2 4 6 8])
    ;;=> #{4 6 2 8}
  • s/every函數:

s/every函數與s/coll-of函數的做用相同,而且參數類型也相同。不一樣的是:在有不知足條件元素的狀況下,s/coll-of函數依然會檢查每個元素並返回錯誤信息,而s/every函數只檢查部分元素。因此s/every函數更適合數據量比較大的狀況:

    ;;用s/coll-of和s/every函數定義做用相同的spec,都是檢查數組是否所有爲string類型的元素
    (s/def ::coll-of-test (s/coll-of string?))
    (s/def ::every-test (s/every string?))

    ;;在校驗錯誤的狀況下,every-test只返回前20個元素的錯誤信息
    (s/explain ::every-test (range 50))
    ;;In: [0] val: 0 fails spec: :my-clj.core/every-test predicate: string?
    ;;In: [1] val: 1 fails spec: :my-clj.core/every-test predicate: string?
    ;;...
    ;;In: [18] val: 18 fails spec: :my-clj.core/every-test predicate: string?
    ;;In: [19] val: 19 fails spec: :my-clj.core/every-test predicate: string?

    ;;而coll-of-test會返回全部不知足元素的校驗錯誤信息
    (s/explain ::coll-of-test (range 50))
    ;;In: [0] val: 0 fails spec: :my-clj.core/coll-of-test predicate: string?
    ;;In: [1] val: 1 fails spec: :my-clj.core/coll-of-test predicate: string?
    ;;...
    ;;In: [48] val: 48 fails spec: :my-clj.core/coll-of-test predicate: string?
    ;;In: [49] val: 49 fails spec: :my-clj.core/coll-of-test predicate: string?

因此在須要肯定全部元素錯誤信息的狀況下使用coll-of,而在大量數據的狀況下,爲了保證效率,使用every函數效果會更好。

  • s/tuple函數:

tuple函數與前兩個函數不一樣,它能夠指定數組中每一個元素的類型,檢查要求更嚴格苛刻,適合數組元素比較少且元素類型不是所有相同的校驗:

    ;;能夠指定一個數組中三個元素,類型分別爲int,string,vector
    (s/def ::tuple-test (s/tuple int? string? vector?))

    (s/valid? ::tuple-test [1 "hello" [1 2 3]])
    ;;=> true
    (s/valid? ::tuple-test [1 "hello" 3])
    ;;=> false


6.map的spec生成:

  對於map的spec生成函數主要有s/map-of, s/every-kv ,s/keys ,s/keys*

  • s/map-of函數:

    s/map-of函數與s/coll-of函數做用相似,前兩個參數分別是對key和value進行校驗的spec,後面還能夠跟着可選參數,與coll-of的可選參數同樣,有:kind/count/min-count/max-count/distinct/into,其中kind默認狀況下是map,若是是list和vector,則校驗數據是[k v]的數組,map-of的用法以下:

    ;;用map-of定義一個spec,類型是[[k v]],k是關鍵字,v是字符串
    (s/def ::map-of-test (s/map-of keyword? string? :kind vector?))

    ;;由於不是vector形式的map,因此校驗失敗
    (s/explain ::map-of-test {:a 1 :b 2})    val: {:a 1, :b 2} fails spec: :my-clj.core/map-of-test predicate: vector?

    ;;知足條件返回success
    (s/explain ::map-of-test [[:a "hello"] [:b "world"]])
    ;;Success!
  • s/every-kv函數:

s/every-kv函數與s/map-of的區別就像s/every與s/coll-of函數的區別,s/every-kv函數適合數據量比較大的狀況下,其參數格式與map-of函數的參數格式徹底同樣,用法以下:

    ;;用every-kv定義一個spec,校驗key爲關鍵字,value爲數字的map
    (s/def ::every-kv-test (s/every-kv keyword? number?))

    (s/explain ::every-kv-test {:a 1 :b 3.14})
    ;;Success!
    (s/explain ::every-kv-test {:a "hello" :b 3.14})
    ;;In: [:a 1] val: "hello" fails spec: :my-clj.core/every-kv-test at: [1] predicate: number?
  • s/keys函數:

s/keys函數比map-of函數和every-kv函數的限制更增強烈,能夠分別對map中的每個value的進行限制,可是map的key必須是關鍵字。 假設咱們定義一個person的數據結構以下:

    person:
        {            :name       string類型            :age        int類型且知足(< 0 age 100)            :gender     boolean類型            :spouse     (optional)string類型        
        }

以上定義的person數據結構有3個必選的字段(:name :age :gender)和1個可選擇的字段(:spouse),爲了用keys定義person數據結構的spec,首先咱們須要根據person的key(key必須是關鍵字)來定義其對應value的校驗spec,以下:

    ;;所定義的spec的名字必須與person中key的名字的一致
    (s/def ::name string?)
    (s/def ::age (s/and int? #(<= 0 % 100)))
    (s/def ::gender boolean?)
    (s/def ::spouse string?)

接下來咱們就可使用keys來定義person的spec了,用keys定義spec時,須要使用:req和:opt來指定哪些字段是必須的,哪些字段可選的,以下:

    ;;全部必選字段和可選字段的spec都必須在[]中列出來
    (s/def ::person1 
      (s/keys :req [::name ::age ::gender]
              :opt [::spouse]
              ))

這時若是使用::person1去校驗任意一個知足條件的person數據結構都會出錯:

    (s/explain ::person1 {:name "xiao ming"
                          :age 25
                          :gender false
                          :spouse "xiao hua"
                          })
    ;;val: {:name "xiao ming", :age 25, :gender false, :spouse "xiao hua"} fails spec: :my-clj.core/person1 predicate: (contains? % :my-clj.core/name)

根據以上錯誤信息咱們能夠找到緣由,是由於該數據結構中不包含:my-clj.core/name字段。這是由於使用:req和:opt指定的字段要求被校驗的map的key必須是namespace keyword,也就是說person數據結構中的key必須是namespace keyword。以下若是改爲namespace keyword,返回結果則會成功:

    (s/explain ::person1 {::name "xiao ming"
                          ::age 25
                          ::gender false
                          ::spouse "xiao hua"
                          })
    ;;Success!

若是必須使用namespace keyword作map中的key會給咱們書寫代碼時帶來不少困惑,幸虧keys函數還提供了另外兩個字段:req-un/opt-un用來替換對應的req/opt,這樣數據結構中的key就能夠是全局的keyword了,以下:

    ;;使用標誌req-un/opt-un來區別必須字段和可選字段
    (s/def ::person2 
      (s/keys :req-un [::name ::age ::gender]              :opt-un [::spouse]
              ))

    ;;這種狀況下,普通的keyword就能夠校驗成功了。
    (s/explain ::person2 {:name "xiao ming"
                          :age 25
                          :gender false
                          :spouse "xiao hua"
                          })
    ;;Success!
    ;;由於:spouse字段是可選的,沒有:spouse字段,依然會成功
    (s/explain ::person2 {:name "xiao ming"
                          :age 25
                          :gender false
                          })
    ;;Success!
    ;; :gender字段是必須有的,若是沒有則會失敗
    (s/explain ::person2 {:name "xiao ming"
                          :age 25
                          :spouse "xiao hua"
                          })
    ;;val: {:name "xiao ming", :age 25, :spouse "xiao hua"} fails spec: :my-clj.core/person2 predicate: (contains? % :gender)

須要注意的是,s/keys函數並無對不在req和opt中字段做限制。也就是說,若是person數據中多了一些其它不必的字段,校驗也會成功:

    ;;雖然person的數據結構中多了一個color字段依然會成功,keys函數只會校驗存在req和opt中的字段,會無視其餘字段
    (s/explain ::person2 {:name "xiao ming"
                          :age 25
                          :gender false
                          :spouse "xiao hua"
                          :color "yellow"
                          })
    ;;Success!

這樣設計的目的多是給予程序員更多選擇的機會吧,可是在項目中,多餘的字段老是會給咱們帶來困惑和bug,因此爲了將其字段限制在必定範圍內,咱們能夠定義一個對其keys進行限制的謂詞函數以下:

    ;;定義一個謂詞函數,mmap中的每一個key都必須在數組mkeys中
    (defn keys-validator? [mmap mkeys]
      (clojure.set/subset? (set (keys mmap)) (set mkeys)))

這樣咱們就可使用keys-validator函數和s/keys一塊兒對一個map數據結構的spec做更加嚴格的限制:

    ;;從新定義一個::person3,有三個必選字段和一個可選字段,以及全部字段必須在[:name :age :gender :spouse]中
    (s/def ::person3 
      (s/and
        (s/keys :req-un [::name ::age ::gender]                :opt-un [::spouse]
                )
        (fn [x] (keys-validator? x [:name :age :gender :spouse]))
        ))

    ;;成功校驗用例
    (s/explain ::person3 {:name "xiao ming"
                          :age 25
                          :gender false
                          :spouse "xiao hua"
                          })
    ;;Success!

    ;;若是多了一個color字段會失敗,提示不知足keys-validator這個條件
    (s/explain ::person3 {:name "xiao ming"
                          :age 25
                          :gender false
                          :spouse "xiao hua"
                          :color "yellow"
                          })
    ;;val: {:name "xiao ming", :age 25, :gender false, :spouse "xiao hua", :color "yellow"} fails spec: :my-clj.core/person3 predicate: (fn [x] (keys-validator? x [:name :age :gender :spouse]))
  • s/keys*函數:

s/keys函數與s/keys函數功能基本徹底一致,只是驗證的數據格式不同而已,s/keys驗證的是以{key value}形式表示的map,而s/keys驗證的是數組形式[key value],s/keys*的使用以下:

    ;;以keys*定義一個::person4.格式與::person2一致,只是keys換成keys*
    (s/def ::person4 
      (s/keys* :req-un [::name ::age ::gender]              :opt-un [::spouse]
              ))

    ;;這時候若是去校驗一個{}形式的map會報錯
    (s/explain ::person4
               {:name "xiao ming"
                :age 25
                :gender false
                :spouse "xiao hua"
                })

    ;;In: [0] val: [:name "xiao ming"] fails spec: :my-clj.core/person4 at: [:clojure.spec/k] predicate: keyword?

    ;;若是解釋數組形式的key value則返回success!
    (s/explain ::person4
               [:name "xiao ming"
                :age 25
                :gender false
                :spouse "xiao hua"]
               )
    ;;Success!


7.spec中空值(null)的處理:

  在定義數據接口時,常常遇到某個字段無值的特殊狀況,在clojure空值用nil表示,對於某個可能爲nil的字段的校驗,clojure.spec中提供了s/nilable這個函數,接收一個spec參數,返回一個spec,表示該spec能夠接收空值特殊狀況:

    ;;若是咱們給person2中name字段爲nil,校驗則會出錯
    (s/explain ::person2
               {:name nil
                :age 25
                :gender false
                :spouse "xiao hua"
                })
    ;;In: [:name] val: nil fails spec: :my-clj.core/name at: [:name] predicate: string?

    ;;當咱們把定義::name時,允許其爲nilable,
    (s/def ::name (s/nilable string?))
    (s/def ::person5 
      (s/keys :req-un [::name ::age ::gender]              :opt-un [::spouse]
              ))

    ;;此時,即便name爲nil,也會返回success
    (s/explain ::person2
               {:name nil
                :age 25
                :gender false
                :spouse "xiao hua"
                })
    ;;Success!

因此咱們能夠根據接口定義要求合理的使用s/nilable來知足咱們的需求。


8.spec中命名衝突的處理:

當咱們使用"::"定義spec時,若是兩個不一樣的數據結構下某個字段名字相同,就會發生命名衝突的問題,好比咱們在定義person數據結構的基礎上又定義了一個dog結構:

    dog:
    {
        :name string?
        :age int?  (< 0 age 20)
    }

對於dog中的:name字段,能夠和person中的:name字段用同一個spec,由於他們描述一致,可是對於兩個結構中age字段的範圍不一致,這樣就會致使命名衝突的問題。由於clojure.spec庫中每定義一個spec的名字必須是namespace keyword,而這正是使用namespace keyword而不使用keyword的緣由,由於咱們能夠在定義spec時選擇爲其指定命名空間的方式定義(http://blog.csdn.net/zdplife/article/details/52304258 ),其命名空間能夠隨意指定,該命名空間不必定存在也能夠,所以就能夠很好的解決該問題了:

    ;; ::person的定義
    (s/def :person/name string?)
    (s/def :person/age (s/and int? #(<= 0 % 100)))
    (s/def :person/gender boolean?)
    (s/def :person/spouse string?)
    (s/def ::person
      (s/keys :req-un [:person/name :person/age :person/gender]              :opt-un [:person/spouse]
              ))

    ;; ::dog的定義
    (s/def :dog/name string?)
    (s/def :dog/age (s/and int? #(<= 0 % 20)))
    (s/def ::dog
      (s/keys :req-un [:dog/name :dog/age]
              ))

    ;; 成功解決命名衝突,校驗成功
    (s/explain ::person   {:name "xiao ming"
                           :age 25
                           :gender false
                           :spouse "xiao hua"
                           })
    ;; Success!
    (s/explain ::dog   {:name "du du"
                           :age 19
                           })
    ;; Success!


9.正則表達式在spec中的使用:

正則表達式在校驗數據格式方面,有着其獨特的優點。正好clojure中也有相關正則表達式的處理函數,常用的re-matches函數,能夠構造一個正則表達式適配器(http://blog.csdn.net/zdplife/article/details/51868499),而該正則表達式適配器正好能夠看成clojure.spec使用:

    ;;定義一個匹配移動電話號碼的正則表達式
    (def reg-phone-num #"^1(3[0-9]|4[57]|5[0-35-9]|7[01678]|8[0-9])\d{8}$")

    ;;使用上述定義的正則表達式來定義一個spec
    (s/def ::phone-num #(re-matches reg-phone-num %))

    ;;號碼正確,返回success
    (s/explain ::phone-num "15977765765")
    ;;Success!

    ;;號碼錯誤,錯誤信息返回
    (s/explain ::phone-num "14988865765")
    ;;val: "14988865765" fails spec: :my-clj.core/phone-num predicate: (re-matches reg-phone-num %)


10.在接口函數中使用spec:

在函數中使用spec,最好的方式是利用在定義函數時的的輸入輸出條件檢查(pre-condition/post-condition),假設咱們定義一個接口函數transform-person,函數輸入是一個person數據結構,輸出是一個字符串,定義以下:

    ;;定義一個函數,用s/valid?函數對其輸入和輸出參數進行校驗
    (defn transform-person [person]
      {:pre [(s/valid? ::person person)]       :post [(s/valid? string? %)]
       }
      (let [gender-str (if (:gender person) "a boy" "a girl")
            his-or-her (if (:gender person) "his" "her")
            ]
        (str (:name person) " is " gender-str ",and " his-or-her " age is " (:age person))
        ))
    ;;定義一個知足條件的person1
    (def person1
      {:name "xiao ming", :age 25, :gender false, :spouse "xiao hua"}
      )
    ;;定義一個不知足條件的person2
    (def person2
      {:name "liu wei", :age -10, :gender false, :spouse "xiao hua"}
      )

    ;;調用接口函數成功
    (transform-person person1)
    ;;=> "xiao ming is a girl,and her age is 25"

    ;;接口參數校驗失敗,拋出異常
    (transform-person person2)
    ;;AssertionError Assert failed: (s/valid? :my-clj.core/person person)  my-clj.core/transform-person (form-init1838625064402666216.clj:1)


11.總結:

本文主要介紹了clojure.spec庫的一些基本函數的用法,其實這些函數對於普通的接口參數檢測已經足夠了,clojure.spec庫中還有一些其它函數:alt函數/cat函數/melt函數,還可使用正則中的*/+/?等,這些函數也蠻好玩的,有興趣的能夠去試着玩一下,這邊有介紹:http://clojure.org/guides/spec


免費體驗雲安全(易盾)內容安全、驗證碼等服務

更多網易技術、產品、運營經驗分享請點擊




相關文章:
【推薦】 數據庫路由中間件MyCat - 源代碼篇(9)
【推薦】 BRVAH(讓RecyclerView變得更高效) (3)

相關文章
相關標籤/搜索