Clojure spec 和訳
※まだ途中です。間違いや分かりにくいところなどがありましたら、ご指摘いただけると嬉しいです。
Getting started
specライブラリはデータ構造を明示し、その構造を検証・破棄し、specに基づいたデータを生成することができます。
specを使う際は、最新のアルファ版(または下記以降の)のClojureを使用してください。
[org.clojure/clojure "1.9.0-alpha16"]
まずはじめに、clojure.spec.alpha
をrequireしてREPLにspecの名前空間を作成します。
(require '[clojure.spec.alpha :as s])
または、自分が作成した名前空間でrequireすることでもspecを使用可能です。
(ns my.ns
(:require [clojure.spec.alpha :as s]))
Predicates
各specは、許可できる値のsetを表しています。specを構築する方法はいくつかありますが、何れも、より洗練されたspecを生成することができます。
引数が一つで真理値を返すような既存のClojureの関数は、何れも検証用の 述語
となります。
それでは、ある値がspecと一致(conform)しているのかどうかを、 conform
を使って確かめてみましょう。
(s/conform even? 1000)
;;=> 1000
conform
関数は、specへと変換可能な何かと、値を引数とします(この例では、even?が「specへと変換可能な何か」で、値が「1000」)。
述語(even?)は暗黙的にspecへと変換され、”specに一致(conformed)した”値が返り値として返却されます。今回の例では、一致した値は元の値と同じです。
では、specに一致しない場合はどうでしょうか。
値がspecを満たさなかったときには特別な返り値、 :clojure.spec.alpha/invalid
が返されます。
もしも、”specに一致(conformed)した”値や :clojure.spec.alpha/invalid
を使わないで、単に一致しているか否かの確認をしたいだけというときには、返り値がbooleanになる valid?
を代わりに使うと良いかもしれません。
(s/valid? even? 10)
;;=> true
繰り返しますが、ここで valid?
は暗黙的に述語からspecへと変換されていることに注意してください。specライブラリは、上記のようにClojureの関数全てを活用可能にしています(述語の特別な辞書などはありません)。さらにいくつかの例を見ていきましょう。
(s/valid? nil? nil) ;; true
(s/valid? string? "abc") ;; true
(s/valid? #(> % 5) 10) ;; true
(s/valid? #(> % 5) 0) ;; false
(import java.util.Date)
(s/valid? inst? (Date.)) ;; true
セットもまた、”値が含まれるか否か”をチェックする述語として使われます。
(s/valid? #{:club :diamond :heart :spade} :club) ;; true
(s/valid? #{:club :diamond :heart :spade} 42) ;; false
(s/valid? #{42} 42) ;; true
Registry
ここまでは、specを直接使ってきました。しかしspecライブラリは、よく使うspecをグローバルに宣言できるよう、セントラルレジストリというものを提供しています。このレジストリは名前空間に登録されたキーワードと登録した仕様を関連付けてくれます。この名前空間を使用することによって、「ライブラリやアプリケーションを横断しても、再利用可能でコンフリクトを起こさないspecを定義できる」ということが保証できます。
specの登録には def
を使います。
分かりやすい名前空間へ仕様を登録するか否かはあなた次第です(あなたが管理している名前空間においては尚更です)。
(s/def ::date inst?)
(s/def ::suit #{:club :diamond :heart :spade})
登録されたspecの識別子は、私たちがここまでに見てきた conform
や valid?
のように、specを定義する際に使用することができます。
(s/valid? ::date (Date.))
;;=> true
(s/conform ::suit :club)
;;=> :club
後述しますが、登録されたspecは私たちがspecを構築したところであれば、どこでも使うことができます。
Spec Names
このガイドではしばしば、
::date
のように、自動的に名前解決されたキーワードが使われます。Clojureリーダーは現在の名前空間を使って、完全修飾されたキーワードへと名前解決しています。また、:animal/dog
のように完全修飾されたキーワードを使ってspecを指定しているケースもあります。一般的にClojureのコードは、他のspec利用者とコンフリクトが起こらないよう、十分にユニークなキーワードの名前空間を使うべきです。もしもあなたが公で使用されるためのライブラリを書いているのなら、仕様の名前空間にはコンフリクトを起こさないような単語(プロジェクト名、URL、または組織名など)を含めるべきでしょう。
反対にプライベートな環境で開発をするのであれば、より短い名前でも良いでしょう(大切なのは、名称が十分にユニークでコンフリクトを起こさないことです)。
Composing predicates
specを構築するもっともシンプルな方法は、 and
と or
を使う方法です。
s/and
を使って、いくつかの述語を結合させ、specをつくってみましょう。
(s/def ::big-even (s/and int? even? #(> % 1000)))
(s/valid? ::big-even :foo) ;; false
(s/valid? ::big-even 10) ;; false
(s/valid? ::big-even 100000) ;; true
s/or
を使って、二つの選択肢を指定することもできます。
(s/def ::name-or-id (s/or :name string?
:id int?))
(s/valid? ::name-or-id "abc") ;; true
(s/valid? ::name-or-id 100) ;; true
(s/valid? ::name-or-id :foo) ;; false
この or
は、妥当性のチェックのなかに選択肢を含む初めてのケースです。
各選択肢にはタグによって注釈(ここでは:nameと:id)が付けられています。
これらのタグは、conform
および他のspec関数から返されたデータを理解、または充実させる際に使用するブランチ名を与えます
or
の条件を満たしたときにspecは、そのタグ名と一致した実際の値が入ったvectorを返します。
(s/conform ::name-or-id "abc")
;;=> [:name "abc"]
(s/conform ::name-or-id 100)
;;=> [:id 100]
string?
number?
keyword?
などのように、インスタンスの型をチェックする述語の多くは、nilを有効な値として許可していません。
そのため、nilを有効な値に含めたい場合は nilable
関数を使ってspecを作成します。
(s/valid? string? nil)
;;=> false
(s/valid? (s/nilable string?) nil)
;;=> true
Explain
explain
は、「なぜ値がspecを満たさないのか」を(out へ)レポートしてくれる、もう一つの高度なオペレーションです。
それでは前述の例を使って、explain
がspecと適合しなかった場合に何と言ってくれるのかを見てみましょう。
(s/explain ::suit 42)
;; val: 42 fails spec: ::suit predicate: #{:spade :heart :diamond :club}
(s/explain ::big-even 5)
;; val: 5 fails spec: ::big-even predicate: even?
(s/explain ::name-or-id :foo)
;; val: :foo fails spec: ::name-or-id at: [:name] predicate: string?
;; val: :foo fails spec: ::name-or-id at: [:id] predicate: int?
最後の出力結果をより詳しく調べてみましょう。
最初に、2つのエラーが報告されていることに注意してください。
specは可能な選択肢の全てを評価し、すべてのパスでのエラーをレポートします。
エラー内容の詳細:
- val - specに適合しなかった入力値
- spec - 評価の対象となったspec
- at - エラーが発生したスペック内の位置を示すパス(vectorのキーワード)。パス内のタグは、spec内で任意の箇所にタグ付けされた部分と対応しています(
or
やalt
の選択肢や、cat
の一部、mapのkeysなど)。 - predicate - 条件を満たすことができなかった述語
- in - 条件を満たすことができなかった値へのパス(データがネストされていた場合に)。この例ではトップレベルの値で不適合で空のパスとなるため、省略されている。
最初のエラーでは、::name-or-id
という名のspecの :name
というパスにおいて、:foo
が string?
を満たさなかったということが分かります。
二つ目のエラーも似ていますが、:id
というパスにおいて失敗しています。:foo
はキーワードですので、どちらも一致しません。
加えて、explain-str
を使用してエラーメッセージを文字列または explain-data
として、エラーをデータとして受け取ることができます。
(s/explain-data ::name-or-id :foo)
;;=> #:clojure.spec.alpha{
;; :problems ({:path [:name],
;; :pred string?,
;; :val :foo,
;; :via [:spec.examples.guide/name-or-id],
;; :in []}
;; {:path [:id],
;; :pred int?,
;; :val :foo,
;; :via [:spec.examples.guide/name-or-id],
;; :in []})}
この結果は1.9.0-alpha8で追加された新しい名前空間、マップリテラル構文も説明しています。
Mapには、Map内すべてのキーにデフォルトの名前空間を指定するため、接頭辞として#:
か#::
(こちらは自動解決用) が付きます。
この例では、{:clojure.spec.alpha/problems …}に相当します。
Entity Maps
Clojureプログラムは、データマップを順に渡すことに大変依存しています。
他のライブラリにおいてよく取られるアプローチは、各エンティティの型や、そこに含まれるキーとキーを組み合わせ、そして値の構造の説明です。
エンティティ(Map)のスコープ内の属性(key+value)を定義するのではなく、specは個々の属性に意味を持たせた、それらを集めてセマンティックなsetを使っているMapへまとめます。このアプローチは、ライブラリとアプリケーションにおける属性レベルでの、セマンティクスの割り当てや共有を可能にします。
例えば、ほとんどのRingミドルウェア関数は、リクエストやレスポンスのmapを修飾されていないキーで変更します。
しかし、各ミドルウェアは代わりに、登録されたセマンティクスを持つ名前空間のキーを使うことができます。
そのキーは適合性のチェック、より素晴らしい共同制作の機会を増やすような、一貫性の高いシステム構築を可能にしてくれます。
specにおけるエンティティマップは、keys
を使って定義します。
(def email-regex #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$")
(s/def ::email-type (s/and string? #(re-matches email-regex %)))
(s/def ::acctid int?)
(s/def ::first-name string?)
(s/def ::last-name string?)
(s/def ::email ::email-type)
(s/def ::person (s/keys :req [::first-name ::last-name ::email]
:opt [::phone]))
ここでは、必須のキーとして ::first-name
、 ::last-name
、 ::email
を持ち、任意のキーとして ::phone
を持つ、::person
というspecの登録を行っています。mapのspecでは、属性が必須なのか任意なのかだけを指定し、値のspecは決して指定しません。
map上での適合性チェックの際は、「必須の属性が含まれているのか」「登録された何れのキーも、適合する値を保持しているか」の2点が検査されます。
任意の属性が役に立つケースについては、後で説明します。
また、:req
と :opt
キーに載っている属性だけではなく、「全ての」属性は keys
を通じてチェックされる点に注意してください。
つまり、そのままの (s/keys)
は有効で、どのキーが必須か任意かのチェックに関係なく、map内すべての属性をチェックするということです。
(s/valid? ::person
{::first-name "Elon"
::last-name "Musk"
::email "elon@example.com"})
;;=> true
;; Fails required key check
(s/explain ::person
{::first-name "Elon"})
;; val: #:my.domain{:first-name "Elon"} fails spec: :my.domain/person
;; predicate: (contains? % :my.domain/last-name)
;; val: #:my.domain{:first-name "Elon"} fails spec: :my.domain/person
;; predicate: (contains? % :my.domain/email)
;; Fails attribute conformance
(s/explain ::person
{::first-name "Elon"
::last-name "Musk"
::email "n/a"})
;; In: [:my.domain/email] val: "n/a" fails spec: :my.domain/email-type
;; at: [:my.domain/email] predicate: (re-matches email-regex %)
ここで少し、最後の例についての、explainのエラー内容を調べてみましょう。
- in - 評価が失敗した値へのデータ内のパス。ここでは、personインスタンス内のキー。
- val - 失敗した値。ここでは
"n/a"
。 - spec - 失敗したspec。ここでは、
:my.domain/email
。 - at - 失敗した値が配置された、spec内でのパス。
- predicate - 失敗した述語。ここでは
(re-matches email-regex %)
。
既存のClojureコードの多くは、名前空間のキーを持つマップを使用していません。そのためkeys
では、非修飾のキーにおける必須と任意についても :req-un
と :opt-un
を使って指定することができます。
これらのバリアントは、仕様を検索する際に使われる、名前空間のキーを指定します。しかし、マップはキーの非修飾バージョンのみをチェックします。
下記のpersonマップは非修飾のキーを使っていますが、名前空間にあるspecに適合するかのチェックを行うことができます。
このことについて、以前登録したspecを使って考察してみましょう。
(s/def :unq/person
(s/keys :req-un [::first-name ::last-name ::email]
:opt-un [::phone]))
(s/conform :unq/person
{:first-name "Elon"
:last-name "Musk"
:email "elon@example.com"})
;;=> {:first-name "Elon", :last-name "Musk", :email "elon@example.com"}
(s/explain :unq/person
{:first-name "Elon"
:last-name "Musk"
:email "n/a"})
;; In: [:email] val: "n/a" fails spec: :my.domain/email-type at: [:email]
;; predicate: (re-matches email-regex %)
(s/explain :unq/person
{:first-name "Elon"})
;; val: {:first-name "Elon"} fails spec: :unq/person
;; predicate: (contains? % :last-name)
;; val: {:first-name "Elon"} fails spec: :unq/person
;; predicate: (contains? % :email)
レコードの属性を検証するために、非修飾のキーを使うこともできます。
(defrecord Person [first-name last-name email phone])
(s/explain :unq/person
(->Person "Elon" nil nil nil))
;; In: [:last-name] val: nil fails spec: :my.domain/last-name at: [:last-name] predicate: string?
;; In: [:email] val: nil fails spec: :my.domain/email-type at: [:email] predicate: string?
(s/conform :unq/person
(->Person "Elon" "Musk" "elon@example.com" nil))
;;=> #my.domain.Person{:first-name "Elon", :last-name "Musk",
;;=> :email "elon@example.com", :phone nil}
任意の連続的なデータ構造内で、キーワードのキーと値が渡ってくる場合にClojureでよく起こる出来事は、"keyword args"の使用です。
specはこのようなときのために特別なサポート、正規表現オプションである keys*
を提供しています。
keys*
は keys
と同じシンタックスやセマンティクスを持っていますが、連続的な正規表現構造の中に埋め込むこともできます。
(s/def ::port number?)
(s/def ::host string?)
(s/def ::id keyword?)
(s/def ::server (s/keys* :req [::id ::host] :opt [::port]))
(s/conform ::server [::id :s1 ::host "example.com" ::port 5555])
;;=> {:my.domain/id :s1, :my.domain/host "example.com", :my.domain/port 5555}
部分的にエンティティマップを宣言しておくと便利な場合もあります。
エンティティマップの要件のソースが様々であったり、共通するキーのセットとバリアント固有の部分がある場合などです。
s/merge
specは、複数の s/keys
specを組み合わせ、要件を一つのspecにまとめるのに使用できます。
例として、動物共通の属性と犬固有のいくつかの属性を定義する keys
specを考えてみましょう。
犬エンティティそれ自体は、その2つの属性のセットをマージしたものとして記述することができます。
(s/def :animal/kind string?)
(s/def :animal/says string?)
(s/def :animal/common (s/keys :req [:animal/kind :animal/says]))
(s/def :dog/tail? boolean?)
(s/def :dog/breed string?)
(s/def :animal/dog (s/merge :animal/common
(s/keys :req [:dog/tail? :dog/breed])))
(s/valid? :animal/dog
{:animal/kind "dog"
:animal/says "woof"
:dog/tail? true
:dog/breed "retriever"})
;;=> true
〜 続く(時間を見つけて更新していきます)〜