Help us understand the problem. What is going on with this article?

Prismatic Schema について勉強してみる

More than 3 years have passed since last update.

今まで何度も「使ってみたら面白そうだな~」と思いつつ、目先の忙しさで放置して身につかずだったライブラリ、それが私にとっての Schema の位置付けです。Advent Calendar を書くのは、勉強するには絶好の機会だと思い、解説がてら試したこと等整理してみたいと思います(ほとんど自分用メモかもしれません...)


Schema is 何?

Clojure(Script)は Lisp なので、関数の引数、戻り値、変数等々の型宣言は不要です。これはLisperにとっては普通なことではありますが、逆に仕様変更で修正が漏れたことに気づかないまま動いてしまう「動的言語あるある」を誘発してしまうことにもつながります。

Schema は、そんな Clojure に「緩い型」をもたらしてくれます。型の定義を宣言的に記述でき、また与えられたデータがその型定義に合致するかの検証をしてくれます。ただし宣言したからといってコンパイルエラーで死ぬというわけではなく、実行時のデータの検証ですので、Clojure の assertion を補完する位置付けとみればよいかと思います。

まずは初歩から

前置きはこのくらいにしておいて、手を動かしながら学んでいきます。
自分はまだ leiningen 派なので、プロジェクトを lein new schema-study で作りました。

project.clj
;;; dependencies に以下を追加(2015/12/12 現在)
  [prismatic/schema "1.0.4"]
src/schema_study/core.clj
(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/Anys/Bools/Nums/Keywords/Symbols/Ints/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-keys/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/defrecordextra-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 のドキュメントを見るとまだまだ試していないことがたくさんあります。が、手を広げすぎると一冊の本ができあがりそうな感じがしました(単に自分がまとめるのが下手なだけ、かもしれませんが)。浅いながらも大体の感じはつかめたので、今後自分の業務のなかで応用していけたらなぁ、と思います。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away