up: 📂Clojure Core Languages

Clojure specおよびJSON Schemaについて.

clojure.specとは

Clojureにおいて契約プログラミングを実施するためのライブラリ. Clojure 1.9から導入された.

clojure.spec Usages

Basics

  • s/def
    • 満たすべき条件を宣言
  • s/valid?
    • 条件を検証
  • s/coll-of
    • 条件を満たす集合を宣言
  • s/keys
    • 条件を満たす名前付きの値の集合(ie. Map)の条件を定義
    • :reqは必須のキーワード, :optは任意のキーワード.
  • s/explain
    • 検証が失敗した理由を出力.
  • s/conform
    • 条件を満たす場合のみ与えられた値を検証を通過したキーワードに束縛.
  • s/cat
    • 束縛されるキーワードとその条件をまとめるという宣言.

以下の記事を参考.

What Clojure spec is and what you can do with it - Pixelated Noise Blog

(require '[clojure.spec.alpha :as s])
 
;; basic
;; s/defで満たすべき条件を宣言
;; s/valid? で検証
(s/def ::username string?)
(s/valid? ::username "foo")
(s/valid? #(> % 5) 10)
 
;; collections
;; s/coll-ofで条件を満たす集合を宣言
(s/def ::usernames (s/coll-of ::username))
(s/valid? ::usernames ["foo" "bar" "baz"])
 
;; Maps
;; s/keysで条件を満たす名前付きの値の集合(ie. Map)の条件を定義
;; :reqは必須のキーワード, :optは任意のキーワード.
(s/def ::password string?)
(s/def ::last-login number?)
(s/def ::comment string?)
(s/def ::user
  (s/keys
   :req [::username ::password]
   :opt [::comment ::last-login]))
(s/valid?
 ::user
 {::username   "rich"
  ::password   "zegure"
  ::comment    "this is a user"
  ::last-login 11000})
 
;; Explain
;; s/explainで検証が失敗した理由を出力.
(s/explain
 ::user
 {::username "rich"
  ::comment  "this is a user"})

s/def: ルールを定義する

s/defで指定するkeywordは ::hogeか :foo/barである必要がある.

k must be namespaced keyword or resolvable symbol (c/and (ident? k) (namespace k))

言い換えると :fooのようなnamespaceから始まるslashを伴わない場合はエラーする.

clojure.spec conform

いわば正規表現のような, 分配束縛のような機能を提供する.

Clojure specの強力な機能のひとつ.

  • s/conform
    • 条件を満たす場合のみ与えられた値を検証を通過したキーワードに束縛.
  • s/cat
    • 束縛されるキーワードとその条件をまとめるという宣言.
(s/def ::ingredient (s/cat :quantity number? :unit keyword?))
(s/conform ::ingredient [2 :teaspoon])
;; => {:quantity 2, :unit :teaspoon}

s/defからMapのルールを構築する

s/defで定義したルールを組み合わせてMapやRecordのルールを構築することができる. 結局Clojureの世界ではassociated dataの受け渡しであらゆることを処理していく世界観なのでMapのチェックは大事.

s/def と s/keys :req-un/:opt-un をつかって構築する.

(s/def ::person (s/keys :req-un [::first-name ::last-name]
                        :opt-un [:email]))

s/conform を利用してチェックと生成を行う.

;; Map
(s/conform ::person {:first-name "Foo" :last-name "bar"})
;; Record
(s/conform ::person (map->Person
                    {:first-name "Foo" :last-name "bar"}))

qualified keywords vs unqualified keywords

なおここで qualified keywordsとunqualified keywordsという概念が登場する. qualified keywordsはnamespaceに属するキーワーで :foo/barのような表記. unqualified keywordsは :fooの表記.

s/keys で構築するとき :req/:req-unを選択できるが:reqならqualified keywordを指定する必要がある. たとえばintegrantはqualified keywordをつかって構成を定義している.

しかし大抵はmapといえばunqualifiedなので :req-unでいいだろう.

clojure.spec: s/fdef

関数というものが[:defn :name :doc :args :body]の5つのキーに束縛されたコードの集合とみなせばそれらに対する検証をすることで関数が検証できる, という考え方.

(s/def ::function (s/cat :defn #{'defn}
                         :name symbol?
                         :doc (s/? string?)
                         :args vector?
                         :body (s/+ list?)))

あるnamespace foos/def::foo-x と定義したものを別のnamespaceから使う場合は ::foo/foo-x となる.

(ns foo.core
  (:requre [clojure.spec.alpha :as s]))
(s/def ::foo-x pos?)
 
(ns bar.core
  (:requre [clojure.spec.alpha :as s]
           [foo.core :as foo]))
(s/valid? ::foo/foo-x 1)

clojure.spec.test.alpha

開発補助ツール(テスト)としてのclojure.spec.

clojure.spec.test.alpha/instrument

clojure.spec.test.alpha/instrumentという関数を実行するとその後にspecを定義してある関数を実行するたびにspec通りの引数や戻り値になっているかをチェックしてくれる.

ref: Instrument - spec Guide

protocolではinstrumentがきかない

protocolではinstrumentがきかないらしい. 関数に切り出す代替策.

💡開発時のReloaded Workflowにinstrumentを組み込む

ref. ミニマリストのためのClojure REST API開発入門2 〜リファクタリング編〜 - Qiita

Reloaded Workflowの応用で, integrant.repl/reset の直後に clojure.spec.test.alpha/instrument を呼び出す.


References

🔧metosin/malli

Data-driven Schemas for Clojure/Script and babashka.

clojure.specはパフォーマンスに影響しないように設計されているが, malliはさらに速いらし数十nano秒と数nano秒(もはやよくわからない).

しかし, これはスキーマに特化しているので, テストのためのツールではない(ようだ).

関数に対するスキーマ(Function Schemas)

def

:=> で定義する. :cat で引数を定義する.

(def =>plus [:=> [:cat :int :int] :int])
(m/validate =>plus plus)

metadata schema

m/=> が関数スキーマ定義マクロ. 関数のすぐ下に(上でもok)書くことで関数のメタデータに組み込むことができる. または, {:malli/schema… }でメタデータ定義.

(defn plus1 [x] (inc x))
(m/=> plus1 [:=> [:cat :int] small-int])
 
(defn minus
  {:malli/schema [:=> [:cat :int] small-int]}
  [x]
  (dec x))

malli/instrument!

いわゆるプロパティテストができる.

(require '[malli.instrument :as mi])
 
;; 検証対象のリスト
(m/function-schemas)
 
;; 検証実行
(mi/instrument!)

References

データ変換エンジン(Value Transformer)

変数の変換をする. string->doubleなど.

https://github.com/metosin/malli/blob/master/docs/value-transformation.md

vector syntax vs map syntax

ルールを記述する記法としてvectorとmapの両方が使える.

基本的にはvectorでよいとのこと(from README).

はじめはvectorしかなかったけどネストが深いvectorのルール定義でパフォーマンスが低下することがあったのでMapも用意した. しかしこれはまだexperimental.

エラー表示

エラー内容をログに残す時とかに利用.

  • malli/explain: エラー内容をMapで取得.
  • malli.error/humanize: エラーメッセージを取得.

静的解析ツールとしてのmalli with clj-kondo

clj-kondoとmalliの定義をさせると, エディタ上に型が異なる引数で関数を呼び出したときに警告を出すことができる. たとえば, 数値を引数に取るはずの関数に文字列が入っていると, 静的解析のように検出できる.

(malli.clj-kondo/emit!) を呼ぶと, .clj-kondo/configs/malli/config.edn に設定を吐き出してくれる. なので, emit!の関数をReloaded Workflowの要領で, 呼び出せばいい.

ref. malliとclj-kondoで変わる(?)Clojure開発体験

References

clojure.spec Topics

clojure.specによる防衛的プログラミング

ref: 📝Clojure Architecture

  • 防衛的プログラミング(Secure Programming)
  • コントラクトシステム(Contract System)

簡単に引数チェックをするらば, ClojureのSpecial Formの(:pre, :post)の利用もできる.

ref. Clojure - Special Forms

(defn constrained-sqr [x]
    {:pre  [(pos? x)]
     :post [(> % 16), (< % 225)]}
    (* x x))

もしくはpredicatesの部分だけclojure.specをつかう.

(s/def ::x pos?)
(defn constrained-sqr2 [x]
  {:pre [(s/valid? ::x x)]}
  (* x x))

clojure.orgにもあるように気軽な引数チェックならpreでもいいが, ガチりたい場合はsdefを導入すること.


💡validate結果がinvalid時の例外の上げ方

s/assert で中身をチェックした結果をletでbindしてもいい. このとき s/conform を利用すると例外が上がらずに :clojure.spec.alpha.invalid がbindingされて処理が継続するので注意.

詳しくはこちら -> Using spec for validateion - clojure.org

malliのvalidate結果の処理も同じやり方で処理できる. するわち, invalidだったらex-infoにmessageとexplain情報を付与してthrowする.

(let [parsed (s/conform :ex/config input)]
    (if (s/invalid? parsed)
      (throw
       (ex-info "Invalid input" (s/explain-data :ex/config input)))

💡clojure.specは Schemaではない

Eric Normandさんの以下の記事より.

5 Differences between clojure.spec and Schema

clojure.spceもSchemaも同じ課題を解決しようとした点で似ている. しかし両者は本質的に別のものである.

  • clojure.specは “Data DSL”ではない.
  • clojure.specは namespaced keywrodsを好む.
  • clojure.specは強力なシーケンス検証機能がある.
  • clojure.specは検証(checking)とパース(parsing)を兼ね備える(conform).
  • clojure.specはtest.checkとの強い連携がある.

clojure.specと型ヒントとの比較

ref: 📝Clojure: 型ヒント(Type Hinting)

clojure.specとマクロ

clojure.specは基本的に実行時にデータが仕様を満たしているかチェックする仕組み.

📝Clojure マクロと組み合わせると, マクロはプログラムを実行前に展開される. すると, コンパイラのような動作でエラーチェックできる. いいかえるとランタイムでのオーバーヘッドがない.

clojure.specがランタイムでチェックすべきなのはキケンな入力は外部からやってくるから

clojure - Is it ok to use spec/valid? for function input validation at runtime? - Stack Overflow

プロダクションではclojure.spec無効にする

テストのためにつかうとしてデプロイ時はパフォーマンスを優先して無効にする.

$ $ java -Dclojure.spec.compile-asserts="false"

ref. Clojure Spec: Instrumentation in Practice

Insights

🤔契約プログラミングか防衛的プログラミングかを区別する

わたしが混乱したのは, clojure.specが実行時の防衛的プログラミングとしての役割と, 開発やテスト時の, いわゆるプロパティテストのようなコンパイル代わりの開発補助ツールの2つができて, それを混同していたところにある.

clojure.specをなんのためにつかうのかということを軸に考える必要がある. Web上の実例記事を検索しても, 前提の視点がないと混乱する. そもそもテストをかかないとして, とりあえず開発でテストの代わりにコンパイルの代わりに使うのか. それともJSONスキーマ的に防衛プログラミングとしてつかうのか.

契約プログラミングはテストであり, 防衛的プログラミングはセキュリティ.

clojure.specは基本機能を提供するので, 応用でどちらもできるというところがややこしい.

🤔データ変換エンジンとしてのClojure.spec/malli

Runtime validate/transformationという言葉を良く見かける, とくにmalliでは.

ここから, スキーマというものを単なるデータの整合性チェックからさらに発展させて, JSONデータ変換エンジンとしての用途がある. Web開発ではこのtransformer/converter的な用途がより魅力的なんじゃないかな?

🔗References

clojure.spec

日本語記事