Главная мысль урока: в динамическом языке форму данных проверяют на границе, там, где они входят в программу. 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}
Три вызова закрывают всю историю с испорченным сохранением:
m/validateотвечает «да или нет», какs/valid?.m/explainсобирает ошибки, аme/humanizeпревращает их в мапу «поле → список сообщений». Её можно сразу отдать в ответ HTTP.m/decodeс трансформером приводит значения к схеме: строка"18"становится числом18. Для JSON естьmt/json-transformer, он ещё и строки превращает в ключевые слова там, где схема ждёт:keyword.
Генератор у malli тоже есть, в неймспейсе malli.generator: (mg/generate Hero) вернёт случайного героя по схеме.
spec или malli
| Что | clojure.spec | malli |
|---|---|---|
| Где живёт схема | глобальный реестр под ключом | значение, вектор |
| Поставка | в составе Clojure | библиотека metosin/malli |
| Хранить и передавать схему | неудобно, это макросы | EDN как есть |
| Разбор данных | s/conform, регулярки над последовательностями | m/parse для того же |
| Сообщения для людей | строка в стиле отладки | me/humanize, переводы сообщений |
| Приведение типов | нет, только проверка | m/decode с трансформерами |
| Генераторы | s/gen | mg/generate |
На практике spec чаще встречается в коде, который проверяет сам себя: спеки функций, макросов, внутренние инварианты. malli чаще стоит на границе: тело HTTP-запроса, настройки, данные из очереди. Там важны понятные сообщения и приведение строк к числам.
Если ты проходил урок про Effect Schema, последняя строка таблицы покажется знакомой. Effect Schema тоже описывает форму данных значением, умеет декодировать и кодировать и выводит из схемы тип TypeScript. В Clojure статического типа нет, поэтому схема остаётся проверкой во время работы программы. Зато одну и ту же схему можно проверять, печатать, хранить и строить на лету.
Проверь себя
Задание
Дальше
Схемы проверяют данные, которые пришли извне. Но Clojure и сам живёт внутри чужой платформы: JVM или JavaScript. Как вызывать их классы и методы и как превращать их исключения в данные, разберём в уроке про интероп с хостом.
домашка