今まで何度も「使ってみたら面白そうだな~」と思いつつ、目先の忙しさで放置して身につかずだったライブラリ、それが私にとっての Schema の位置付けです。Advent Calendar を書くのは、勉強するには絶好の機会だと思い、解説がてら試したこと等整理してみたいと思います(ほとんど自分用メモかもしれません...)
Schema is 何?
Clojure(Script)は Lisp なので、関数の引数、戻り値、変数等々の型宣言は不要です。これはLisperにとっては普通なことではありますが、逆に仕様変更で修正が漏れたことに気づかないまま動いてしまう「動的言語あるある」を誘発してしまうことにもつながります。
Schema は、そんな Clojure に「緩い型」をもたらしてくれます。型の定義を宣言的に記述でき、また与えられたデータがその型定義に合致するかの検証をしてくれます。ただし宣言したからといってコンパイルエラーで死ぬというわけではなく、実行時のデータの検証ですので、Clojure の assertion を補完する位置付けとみればよいかと思います。
まずは初歩から
前置きはこのくらいにしておいて、手を動かしながら学んでいきます。
自分はまだ leiningen 派なので、プロジェクトを lein new schema-study
で作りました。
;;; dependencies に以下を追加(2015/12/12 現在)
[prismatic/schema "1.0.4"]
(ns schema-study.core
(:require [schema.core :as s]))
s/validate
schema を理解するとっかかりとして、validation を実行する関数 s/validate
の使い方を見ていきましょう。
(s/validate s/Bool true)
;; => true
(s/validate s/Bool nil)
;; => ExceptionInfo Value does not match schema: (not (instance? java.lang.Boolean nil)) schema.core/validator/fn--611 (core.clj:151)
validate は、第1引数に指定された schema
に対して、第2引数の値が合致するか、を検証します。検証で合致した場合には第2引数の値をそのままスルーして返します。上記例では、schema/Bool
を指定しているので、true
or false
以外を指定した2番目の例は、例外を出してしまいます。
単一のスカラ値に対する schema としては、s/Any
、s/Bool
、s/Num
、s/Keyword
、s/Symbol
、s/Int
、s/Str
があります。これらは Schema ライブラリの中で定義済の cross platform な schema
ですが、(ClojureScript ではない)Clojure の場合、s/validate
の第1引数には JVM の class を指定することができますので、例えば
(s/validate double 1.0)
;; => 1.0
(s/validate java.math.BigDecimal 99999999999999.999M)
;; => 99999999999999.999M
等も可能です。正規表現で簡単にチェックしたい時も大丈夫。
(s/validate #"^\d{4}$" "0123")
;; => "0123"
(s/validate #"^\d{4}$" "01234")
;; => ExceptionInfo Value does not match schema: (not (re-find #"^\d{4}$" "01234")) schema.core/validator/fn--611 (core.clj:151)
また、よく使いそうな感じなものとして s/enum
もあります。
(def hoge-code "01") ; データベースに格納されていそうなコード値(とり得る値は01,02,03)
(s/validate (s/enum "01" "02" "03") hoge-code)
;; => 01
(def bad-code "04")
(s/validate (s/enum "01" "02" "03") bad-code) ;; ダメなコード
;; => ExceptionInfo Value does not match schema: (not (#{"03" "02" "01"} "04")) schema.core/validator/fn--611 (core.clj:151)
次に collection も見ていきましょう。
;; array
(s/validate [s/Str] ["a" "b" "c"])
;; => ["a" "b" "c"]
;; map
(s/validate {s/Keyword s/Str} {:k "v"})
;; => {:k "v"}
;; set
(s/validate #{s/Str} #{"v1" "v2"})
;; => #{"v1" "v2"}
;; :
collection の場合は、その要素の schema
もちゃんと見てくれます。以下は例外を出す例です。
(s/validate #{s/Str} #{1 "v2"})
;; => ExceptionInfo Value does not match schema: #{(not (instance? java.lang.String 1))} schema.core/validator/fn--611 (core.clj:151)
(s/validate {s/Keyword s/Num} {:a 1 :b 2 :c "3"})
;; => ExceptionInfo Value does not match schema: {:c (not (instance? java.lang.Number "3"))} schema.core/validator/fn--611 (core.clj:151)
組み合わせてみたい
ところで schema
の実体は、というとただの Clojure のデータだったりします。そこで schema
に名前をつけて 定義 したいときには、普通に def
してしまえばよいことがわかります。
(def KeywordNumber {s/Keyword s/Num})
KeywordNumber
;; => {Keyword java.lang.Number}
(s/explain KeywordNumber)
;; => {Keyword Num}
;; 定義した KeywordNumber を使ってみる
(s/validate KeywordNumber {:a 1 :b 2 :c 3})
;; => {:a 1, :b 2, :c 3}
clojure のデータ型なので気軽に定義したり利用したりできますね。さらに、schema
を組み合わせて利用するための小道具もいろいろあります。まずは s/optional-key
とs/required-key
。
(def OptionalAandB {(s/optional-key :a) s/Num :b s/Str})
(s/validate OptionalAandB {:b "b"}) ; :a がない
;; => {:b "b"}
(s/validate OptionalAandB {:a 1 :b "b"}) ; :a があり値がNum
;; => {:a 1, :b "b"}
(s/validate OptionalAandB {:a 1 :b "b" :c 2}) ; :c ?知らない子ですね
;; => ExceptionInfo Value does not match schema: {:c disallowed-key} schema.core/validator/fn--611 (core.clj:151)
(s/validate OptionalAandB {:a "str" :b "b"}) ; :a があり値が文字列
;; => ExceptionInfo Value does not match schema: {:a (not (instance? java.lang.Number "str"))} schema.core/validator/fn--611 (core.clj:151)
(def RequiredA {(s/required-key :a) s/Num s/Keyword s/Num})
(s/validate RequiredA {:a 1}) ; :a は必須
;; => {:a 1}
(s/validate RequiredA {:b 1}) ; 必須の :a がない
;; => ExceptionInfo Value does not match schema: {:a missing-required-key} schema.core/validator/fn--611 (core.clj:151)
(s/validate RequiredA {:a 1 :b 2}) ; :a 以外に s/Keyword s/Num を満たすものがある
;; => {:a 1, :b 2}
(s/validate RequiredA {:a 1 :b "2"}) ; :a 以外がダメ
;; => ExceptionInfo Value does not match schema: {:b (not (instance? java.lang.Number "2"))} schema.core/validator/fn--611 (core.clj:151)
schema
を組み合わせる系を以下に少し紹介しておきます。
;;; maybe
(s/validate (s/maybe s/Keyword) :a)
;; => :a
(s/validate (s/maybe s/Keyword) nil)
;; => nil
;;; pred
(s/validate (s/pred pos?) -123)
;; => ExceptionInfo Value does not match schema: (not (pos? -123)) schema.core/validator/fn--611 (core.clj:151)
(s/validate (s/pred pos?) 123)
;; => 123
;; その他 s/optional, s/conditional, s/if, ... だんだん疲れてきたので省略
s/defrecord
実際に大規模のアプリケーションを作る上で最も schema の恩恵を受けるのは、record
ではないかと思います。clojure.core/defrecord
の代わりに、s/defrecord
を使うことで、record
の各メンバーの型(schema)を(:-
の直後に)指定することができます。
(s/defrecord User
[id :- Long
name :- s/Str])
(s/explain User)
;; => (record schema_study.core.User {:id java.lang.Long, :name Str})
(s/validate User (map->User {:id 1 :name "a"}))
;; => #schema_study.core.User{:id 1, :name "a"}
(s/validate User (map->User {:id "a" :name 1}))
;; => ExceptionInfo Value does not match schema: {:id (not (instance? java.lang.Long "a")), :name (not (instance? java.lang.String 1))} schema.core/validator/fn--611 (core.clj:151)
レコードの各メンバー毎に固有の性質を与える(上記でいうLong
とかs/Str
)には、普通に:-
でschemaを設定することで対応できます。しかし用途によっては設定したい制約条件が一つのメンバーのみで決められない場合もあるかと思います。こういったケースだとs/defrecord
の extra-validator-fn
で実現できるようです(ayato_pさんの記事が詳しくてわかりやすくてオススメです)。
関数に対する schema 指定
schema では、関数定義時に schema
指定をする s/defn
というものがあります。関数名の直後に関数の戻り値の schema
を、[]の引数各々の後に各引数の schema
を :-
に続けて書くことで指定します。
(def PositiveDouble (s/constrained double pos?)) ;; positive number
(s/defn sqrt :- PositiveDouble
[v :- PositiveDouble]
(Math/sqrt v))
;; s/fn-schema で定義を確認してみる
(s/explain (s/fn-schema sqrt))
;; => (=> (constrained double pos?) (constrained double pos?))
;; validation なしで実行
(sqrt -4.0)
;; => NaN
;; validation ありで実行
(s/with-fn-validation (sqrt -4.0))
;; => ExceptionInfo Input to sqrt does not match schema: [(named (not (pos? -4.0)) v)] schema-study.core/eval14321/sqrt--14326 (form-init3371089079384472141.clj:233)
(s/with-fn-validation (sqrt 4.0))
;; => 2.0
validation の実行とテスト
Schema では validation は上記のように s/with-fn-validation
マクロの中で実行されますが、逆に言えば s/with-fn-validation
を使わないと validation が実行されません。実際の開発では validation を厳密に行い素早くバグを取り、実運用に入る前にパフォーマンスを上げるため validation はとっぱらいたい、といったケースに対応するには、テストコードに細工を入れる、という手があるようです。
(use-fixtures :once schema.test/validate-schemas)
おわりに
Schema のドキュメントを見るとまだまだ試していないことがたくさんあります。が、手を広げすぎると一冊の本ができあがりそうな感じがしました(単に自分がまとめるのが下手なだけ、かもしれませんが)。浅いながらも大体の感じはつかめたので、今後自分の業務のなかで応用していけたらなぁ、と思います。