Раздел 28 · Clojure

Схемы: spec и malli

middle~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Главная мысль урока: в динамическом языке форму данных проверяют на границе, там, где они входят в программу. clojure.spec описывает форму предикатами в общем реестре, malli описывает её вектором, который сам по себе данные. Обе дают ответ «да или нет», объяснение ошибки и генератор примеров.

Игрок открыл файл сохранения из прошлого урока и поправил здоровье Ирмы руками: было 18, стало "18" в кавычках. EDN прочитал файл без единой жалобы, ведь строка это законное значение. Ошибка всплывёт через десять минут игры, когда герой выпьет первое зелье:

Ирма выпила зелье и вылечилась до «187». В браузере + от строки и числа склеивает их, как в JavaScript, и ошибки нет совсем. На JVM будет ClassCastException, но тоже в глубине игры, далеко от места, где данные испортились. Нужен контролёр на входе: проверить героя сразу после чтения и сказать, что с ним не так.

Предикат отвечает, но не объясняет

Первая мысль: написать функцию-проверку.

Ответ есть, объяснения нет. Какое поле не прошло? Какое значение там лежит? Чтобы сказать игроку «в файле у Ирмы здоровье записано строкой», придётся переписать функцию так, чтобы она собирала ошибки, и делать это для каждой сущности. Библиотеки схем делают эту работу за тебя: из одного описания получаются и проверка, и объяснение, и генератор.

clojure.spec: предикаты в реестре

clojure.spec входит в поставку Clojure, его неймспейс clojure.spec.alpha. Ты описываешь каждый ключ отдельно и кладёшь описание в реестр под тем же квалифицированным ключом, что и в данных. Вот зачем в уроке про мапы мы писали :hero/hp, а не :hp: имя ключа в данных совпадает с именем спеки.

В браузерной песочнице spec нет, поэтому код этого раздела идёт листингами и проверяется настоящим Clojure в babashka:

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

(s/def :hero/name (s/and string? seq))
(s/def :hero/hp nat-int?)
(s/def :hero/class #{:warrior :mage :scout})
(s/def :game/hero (s/keys :req [:hero/name :hero/hp]
                          :opt [:hero/class]))

(assert (s/valid? :game/hero {:hero/name "Ирма" :hero/hp 18}))
(assert (not (s/valid? :game/hero {:hero/name "Ирма" :hero/hp "18"})))

(print (s/explain-str :game/hero {:hero/name "Ирма" :hero/hp "18"}))
;; "18" - failed: nat-int? in: [:hero/hp] at: [:hero/hp] spec: :hero/hp
(print (s/explain-str :game/hero {:hero/name "Ирма"}))
;; #:hero{:name "Ирма"} - failed: (contains? % :hero/hp) spec: :game/hero

Прочитай, что получилось:

  • спека это обычный предикат (nat-int?), множество допустимых значений (#{:warrior :mage :scout}) или их комбинация через s/and и s/or;
  • s/keys перечисляет обязательные ключи в :req и необязательные в :opt. Значения по этим ключам проверяются спеками из реестра, отдельно описывать их внутри героя не нужно;
  • s/explain-str называет путь до плохого поля, само значение и предикат, который не прошёл.

Строка годится для лога, а для программы есть s/explain-data: та же информация мапой. Из неё собирают ответ клиенту с ошибкой по каждому полю:

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

(s/def :hero/name (s/and string? seq))
(s/def :hero/hp nat-int?)
(s/def :game/hero (s/keys :req [:hero/name :hero/hp]))

(def problems
  (::s/problems (s/explain-data :game/hero {:hero/name "" :hero/hp -1})))

(assert (= [[:hero/name] [:hero/hp]] (mapv :path problems)))
(assert (= [-1] (keep #(when (= [:hero/hp] (:path %)) (:val %)) problems)))

Пространства имён ключей здесь работают на тебя. :hero/hp описан один раз и проверяется одинаково везде, где встречается: в герое, в событии лога, в ответе сервера.

conform: разобрать, а не только проверить

Кроме «да или нет», spec умеет разбирать данные на части. Команда игрока приходит вектором: [:attack :goblin 3] значит «атаковать гоблина три раза», число ударов можно опустить. Спека s/cat описывает последовательность как регулярное выражение, только над элементами вектора, а s/conform возвращает разобранную мапу:

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

(s/def :game/command
  (s/cat :verb #{:attack :heal}
         :target keyword?
         :times (s/? pos-int?)))

(assert (= {:verb :attack :target :goblin :times 3}
           (s/conform :game/command [:attack :goblin 3])))
(assert (= {:verb :heal :target :irma}
           (s/conform :game/command [:heal :irma])))
(assert (s/invalid? (s/conform :game/command [:dance])))

s/? значит «ноль или один раз», рядом живут s/* и s/+. Тем же способом в самом Clojure описаны аргументы defn и let: если ошибиться в форме макроса, сообщение собирает spec.

Генераторы: схема придумывает примеры

Раз спека знает форму данных, она может придумать данные этой формы. Это открывает дорогу к тестированию свойствами, которое подробно разобрано в уроке про инструменты проверки. Для функций есть s/fdef: спека аргументов и результата.

(require '[clojure.spec.alpha :as s]
         '[clojure.spec.gen.alpha :as gen]
         '[clojure.spec.test.alpha :as stest])

(s/def :hero/class #{:warrior :mage :scout})
(println (gen/sample (s/gen :hero/class) 5))   ; например (:mage :warrior :mage :scout :mage)

(defn heal [hp amount] (+ hp amount))
(s/fdef heal
  :args (s/cat :hp nat-int? :amount pos-int?)
  :ret nat-int?)

;; check сам генерирует аргументы по :args и проверяет :ret.
(def report (first (stest/check `heal {:clojure.spec.test.check/opts {:num-tests 50}})))
(assert (true? (-> report :clojure.spec.test.check/ret :result)))

;; instrument проверяет аргументы при каждом вызове.
(stest/instrument `heal)
(assert (= "Call to user/heal did not conform to spec."
           (try (heal 10 -5) (catch Exception e (ex-message e)))))

stest/instrument включают в разработке и тестах, а не в бою: проверка на каждом вызове стоит времени.

malli: схема это вектор

malli, библиотека схем от компании Metosin, решает ту же задачу с другой стороны. В spec схема живёт в реестре и собрана из макросов. В malli схема это обычный вектор в духе Hiccup: первым элементом тип, дальше свойства и вложенные схемы. Раз это данные, всё из урока про EDN работает и здесь:

Это ещё не malli, а только вектор, и с ним работает весь clojure.core: ключи схемы достаются mapv, схема печатается в EDN и читается обратно. Её можно хранить в базе, отдать фронтенду или собрать функцией из настроек.

Самой библиотеки в babashka и в песочнице нет, она подключается зависимостью metosin/malli в deps.edn. Поэтому дальше листинг без запуска, с выводом в комментариях:

(require '[malli.core :as m]
         '[malli.error :as me]
         '[malli.transform :as mt])

(def Hero
  [:map
   [:hero/name :string]
   [:hero/hp [:int {:min 0}]]
   [:hero/class {:optional true} [:enum :warrior :mage :scout]]])

(m/validate Hero {:hero/name "Ирма" :hero/hp 18})
;; => true

(-> (m/explain Hero {:hero/name "Ирма" :hero/hp -3})
    me/humanize)
;; => {:hero/hp ["should be at least 0"]}

(-> (m/explain Hero {:hero/hp "18"})
    me/humanize)
;; => {:hero/name ["missing required key"], :hero/hp ["should be an integer"]}

;; Строка из формы или query-параметра превращается в число по схеме.
(m/decode Hero {:hero/name "Ирма" :hero/hp "18"} mt/string-transformer)
;; => {:hero/name "Ирма", :hero/hp 18}

Три вызова закрывают всю историю с испорченным сохранением:

  1. m/validate отвечает «да или нет», как s/valid?.
  2. m/explain собирает ошибки, а me/humanize превращает их в мапу «поле → список сообщений». Её можно сразу отдать в ответ HTTP.
  3. m/decode с трансформером приводит значения к схеме: строка "18" становится числом 18. Для JSON есть mt/json-transformer, он ещё и строки превращает в ключевые слова там, где схема ждёт :keyword.

Генератор у malli тоже есть, в неймспейсе malli.generator: (mg/generate Hero) вернёт случайного героя по схеме.

spec или malli

Чтоclojure.specmalli
Где живёт схемаглобальный реестр под ключомзначение, вектор
Поставкав составе Clojureбиблиотека metosin/malli
Хранить и передавать схемунеудобно, это макросыEDN как есть
Разбор данныхs/conform, регулярки над последовательностямиm/parse для того же
Сообщения для людейстрока в стиле отладкиme/humanize, переводы сообщений
Приведение типовнет, только проверкаm/decode с трансформерами
Генераторыs/genmg/generate

На практике spec чаще встречается в коде, который проверяет сам себя: спеки функций, макросов, внутренние инварианты. malli чаще стоит на границе: тело HTTP-запроса, настройки, данные из очереди. Там важны понятные сообщения и приведение строк к числам.

Если ты проходил урок про Effect Schema, последняя строка таблицы покажется знакомой. Effect Schema тоже описывает форму данных значением, умеет декодировать и кодировать и выводит из схемы тип TypeScript. В Clojure статического типа нет, поэтому схема остаётся проверкой во время работы программы. Зато одну и ту же схему можно проверять, печатать, хранить и строить на лету.

Проверь себя

Задание

Дальше

Схемы проверяют данные, которые пришли извне. Но Clojure и сам живёт внутри чужой платформы: JVM или JavaScript. Как вызывать их классы и методы и как превращать их исключения в данные, разберём в уроке про интероп с хостом.

домашка

Домашка