Это вторая глава предполагаемой книги по Кложе на русском языке. См. первую главу про веб-разработку.

Содержание

В этой главе мы рассмотрим clojure.spec – библиотеку для проверки данных в Clojure. Это особенная библиотека, поэтому уделим пристальное внимание.

Внимание! Вы читаете черновик к книге “Clojure на производстве”. Для книги я переписывал его много раз, но в блоге осталась старая версия. Здесь она для истории, а вам я рекомендую купить книжку.

Spec это сокращение от specification, т.е. спецификация, описание. В общих словах, это набор функций и макросов, чтобы схематично описывать структуры данных. Например, из каких ключей состоит словарь и каких типов его значения. Такое описание называют спецификацией, или сокращенно спекой.

Особые функции принимают спеку, данные и проверяют, подходят ли данные под спеку. Если нет, то возвращают отчет: в каком узле данных произошла ошибка и почему.

Spec входит в стандартную поставку Clojure начиная с версии 1.9. Полное имя модуля clojure.spec.alpha. Пусть вас не смущает частичка alpha на конце имени. Она осталась по историческим причинам.

Появление spec стало важной вехой в развитии Clojure. Ключевое свойство spec в том, что она фундаментальна. Валидация данных это всего лишь малая часть ее возможностей. Spec не только проверяет данные, но и преобразует и анализирует их. Например, на spec легко написать преобразование данных или парсер.

Технически spec основана на абстракциях, которые предлагает Clojure. Формально это обычная библиотека. Но абстракции spec оказались настолько мощны, что Clojure переиспользует их в работе. Начиная с 1.10, Clojure анализирует собственный код с помощью spec. Так проекты дополняют друг друга.

Мы начнем описание spec с валидации данных. Но прежде чем браться за техническую часть, разберемся с теорией. Как между собой связаны классы, типы и валидация.

Типы и классы

Принято считать, что код на языке со статической типизацией безопаснее, чем с динамической. Действительно, компилятор не позволит сложить число и строку еще до того, как мы запустим программу. Но сторонники статической типизации забывают, что тип переменной — это всего лишь одно из многочисленных ограничений. Редко случается так, что тип переменной полностью определяет допустимые значения. Чаще всего, в дополнение к типу, учитывают максимальные и минимальные границы, длину, попадание в интервалы и перечисления. Бывает, по отдельности значение верно, но не может соседствовать в паре с другим значением.

Рассмотрим абстракцию сетевого порта. Это целое число от 0 до 2^16-1. Поскольку целочисленные типы, как правило, представлены степенями двойки, наверняка найдется unsigned int, который охватывает именно этот диапазон. Но нулевой порт считается ошибочным, поэтому правильно отсчитывать порт с единицы. Вероятность, что язык реализует такой целочисленный тип, крайне мала.

Лучше всего проблема видна на диапазоне дат. Единичная дата может быть сколь угодно разумной, но диапазон накладывает ограничение: дата начала строго меньше даты конца. Со стороны бизнеса приходят новые требования: разница в датах не больше недели и в рамках текущего месяца.

В мире ООП знают об этой проблеме и решают ее классами. Типичный Java-программист пишет классы UnixPort и DateRange. Условный com.project.net.UnixPort – это класс с одним конструктором. Он принимает целое число и выполняет проверки на диапазон. Если число отрицательное или выходит за рамки 1 — 2^16, то конструктор выбрасывает исключение. Программист уверен, что создал новый тип. Это неверно – классы и типы не тождественны.

Конструктор такого класса это обычный валидатор, проверка во времени исполнения. Он неявно срабатывает, когда мы пишем new UnixPort(8080). Возникает иллюзия, что это тип, но на самом деле это проверка в рантайме. Ее удобство обусловлено синтаксисом.

В промышленных языках невозможно объявить класс так, чтобы выражение new UnixPort(-42) приводило к ошибке компиляции. Это возможно только сторонними утилитами или плагинами к IDE.

Проверки в конструкторах трудно переиспользовать. Предположим, два разработчика написали классы com.somecompany.util.UnixPort и org.community.net.MyPort. Класс UnixPort проверяет порт на диапазон и выбрасывает исключение. Нам выгодно пользоваться этим классом, поскольку он совмещен с валидацией. Однако сторонняя библиотека принимает порт как экземпляр MyPort, который только оборачивает целочисленный тип. Невозможно вызвать конструктор UnixPort для MyPort.

В примере выше мы вынуждены создать экземпляр UnixPort, чтобы проверить данные. Это стадия валидации. Потом извлечь из класса целое число порта. Это данные. Затем создать экземпляр MyPort и передать в библиотеку.

Из сказанного выше определим хорошие практики валидации. Это независимость от данных и компоновка. Независимость означает, что данные не привязаны намертво к валидации. Нет ничего плохого в том, что порт – это целое число. Пусть библиотека принимает integer, а разработчик сам решит, как проверять такое число. Так у него появится выбор, насколько строгой должна быть проверка.

Компоновка означает, что хорошо иметь несколько простых проверок, из которых легко составить сложные. Бывает, по отдельности реализованы проверки “это” и “то”, и теперь нужны их комбинации “это и то”, “это или то”. В идеале мы не должны тратить более одной-двух строк на столь тривиальные вещи.

Оба тезиса ложатся на функции. Вспомним, что функция это объект с операцией вызова. Договоримся, что для верного значения функция-валидатор вернет не ложное значение (отличное от false и nil), а для ошибочного — ложное. Поскольку функция это объект высшего порядка, другие функции принимают валидаторы как аргументы и возвращают их комбинированные версии.

Основы spec

С багажом этих рассуждений мы подошли к тому, как работает clojure.spec.

Импортируем главный модуль spec в текущее пространство:

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

Синоним s нужен, чтобы избежать конфликтов имен с модулем clojure.core. Модуль spec несет определения s/and, s/or и другие, которые не имеют ничего общего со стандартными and и or. Считается плохой практикой, когда определения одного модуля заменяют другие. Это называется затенением имен, и мы рассмотрим проблему в отдельной главе. Пока что мы будем обращаться к spec через синоним s.

Базовая операция в spec – определить новую спецификацию:

(s/def ::string string?)

Макрос s/def принимает ключ и предикат. Он создал объект спеки из функции string?. Затем поместил спеку в глобальное хранилище под ключом ::string.

Важно понимать, что ::string – это не спека, а только ее псевдоним. Все макросы spec устроены так, что принимают не объект спеки, а ключ, и сами выводят по нему спеку. Это удобно, потому что ключи глобальны. В любом месте можно сослаться на ::string без нужды что-то импортировать.

Ключ спеки рассматривают как указатель или псевдоним. Легче передать псевдоним, чем реальный объект.

Вторым аргументом мы передали предикат string?. Предикат – это функция, которая возвращает истину или ложь.

Технически возможно получить объект спеки. Функция s/get-spec по ключу спеки возвращает ее Java-объект. Однако, на практике он мало чем полезен.

(s/get-spec ::string)

#object[clojure.spec.alpha$spec_impl$reify__2059 0x3e9dde1d ...]

Спеки хранятся в глобальном регистре под своими ключами. Макрос s/def не проверяет, была ли уже зарегистрирована такая спека. Если под этим ключом уже была спека, она будет потеряна.

Spec не разрешает использовать ключи без пространства, например, просто :error или :message. Это повышает риск конфликта ключей. Чтобы избавиться от конфликтов, используйте ключи с текущим пространством: ::error, ::message.

Самое простое, что можно сделать с новой спекой – проверить, подходит ли под нее значение. Функция s/valid? принимает ключ спеки, значение и возвращает true или false.

(s/valid? ::string 1)
false

(s/valid? ::string "test")
true

Заметим, пустая строка тоже будет валидной, однако чаще всего это не имеет смысла. Пустая строка в поле “имя пользователя” или “название проекта” означает ошибку. Объявим спеку, которая в дополнение к строке проверяет, что она не пустая.

Наивный способ сделать это – передать специальный предикат:

(s/def ::ne-string
  (fn [val]
    (and (string? val)
         (not (empty? val)))))

Быстрая проверка:

(s/valid? ::ne-string "test")
true

(s/valid? ::ne-string "")
false

Ключ ::ne-string это сокращение от ::non-empty-string. Эта спека встречается так часто, что логично сэкономить на ее упоминании.

Более изящный способ объявить ту же спеку – объединить предикаты через every-pred. Это функция, которая принимает предикаты и возвращает супер-предикат. Он вернет истину только если истинны все переданные предикаты.

(s/def ::ne-string
  (every-pred string? not-empty))

(s/valid? ::ne-string "test")
true

(s/valid? ::ne-string "")
false

Этот способ хорош, потому что декларативно собирает новую сущность из старых. Но еще лучше компоновать не предикаты, а спеки. Особый макрос s/and объединяет предикаты и объявленные ранее спеки в новую спеку:

(s/def ::ne-string
  (s/and ::string not-empty))

По такому принципу в Clojure строят сложные спеки. Объявляют примитивы и постепенно строят их усложненные версии.

Во время проверки Spec не перехватывает возможные исключения. Это остается на усмотрение разработчика. Рассмотрим пример – спеку для проверки строки на URL. Проще всего это сделать регулярным выражением:

(s/def ::url
  (partial re-matches #"(?i)^http(s?)://.*"))

(s/valid? ::url "test")
false

(s/valid? ::url "http://test.com")
true

Но не строковое значение вызовет ошибку:

(s/valid? ::url nil)
Execution error (NullPointerException) at java.util.regex.Matcher...

Это потому, что nil был передан в функцию re-matches. Функция трактует аргумент как строку, что приводит к NPE.

Считается хорошей практикой писать спеки, которые не выбрасывают исключения. В примере с ::url будет правильно сначала проверить, что значение строка, и только потом передавать ее в регулярное выражение.

(s/def ::url
  (s/and
   ::ne-string
   (partial re-matches #"(?i)^http(s?)://.*")))

(s/valid? ::url nil)
false

Теперь проверка nil не выбрасывает исключение.

По аналогии напишем проверку возраста пользователя. Это комбинация двух проверок: на число и диапазон.

(s/def ::age
  (s/and int? #(<= 0 % 150)))

(s/valid? ::age nil)
false

(s/valid? ::age -1)
false

(s/valid? ::age 42)
true

Спеки-коллекции

Выше мы проверяли примитивные типы или скаляры. Это удобно для демонстрации, но редко встречается в практике. Гораздо чаще проверяют не скаляры, а коллекции. Spec предлагает набор макросов, чтобы определить спеки-коллекции из примитивов.

Макрос s/coll-of принимает предикат или ключ и возвращает спеку-коллекцию. Она проверяет, что каждый элемент проходит валидацию. Вот так мы определим список адресов URL:

(s/def ::url-list (s/coll-of ::url))

Быстрая проверка:

(s/valid? ::url-list
          ["http://test.com" "http://ya.ru"])
true

(s/valid? ::url-list
          ["http://test.com" "dunno.com"])
false

Макрос s/map-of проверяет ключи и значения словаря. Вспомним поле запроса :params запроса из главы про веб-разработку. Его ключи кейворды, а значения строки.

(s/def ::params
  (s/map-of keyword? string?))

(s/valid? ::params {:foo "test"})
true

(s/valid? ::params {"foo" "test"})
false

Словари заслуживают отдельного упоминания. Проверка s/map-of достаточно слабая, чтобы покрыть все варианты. Факт того, что все значения строки не несет полезной информации. Гораздо важнее знать, что в словаре именно те ключи, что мы ожидаем. К тому же редко бывает так, что значения словаря одного типа. Наоборот, чаще всего словарь объединяет поля разных типов в рамках одной сущности. Например, имя, возраст и статус пользователя.

Для таких проверок используют макрос s/keys. Он выглядит как список спек. Ключи спек совпадают с ключами словаря. Значения таких ключей проверяются одноименными спеками.

Пусть объект страницы задан словарем с двумя полями. Это будут address, строка URL и description, текстовое описание. Объявим поля-примитивы:

(s/def :page/address ::url)
(s/def :page/description ::ne-string)

обратите внимание на пространство ключей :page. Адрес и описание относятся к сущности страницы, поэтому логично задать им особое пространство. Одноименные поля могут быть у пользователя или товара. Пространства гарантируют, что спеки :page/address и :user/address не затронут друг друга.

Составим спеку страницы.

(s/def ::page
  (s/keys :req-un [:page/address
                   :page/description]))

Параметр :req-un содержит вектор спек. Для каждой из них s/keys ищет ключ с таким же именем в словаре и проверяет значение. Рассмотрим, что именно означает :req-un и какие еще параметры принимает s/keys.

Ключ :req-un состоит из двух логических частей: req и un. Это признаки наличия ключа и его типа. Req означает, что перечисленные ключи обязаны быть в словаре. Если хотя бы один ключ отсутствует, спека не проходит проверку. Противоположный по смыслу параметр называется opt. В нем указаны ключи, которых может не быть. Валидация таких ключей случается только если они присутствуют в словаре.

Частичка un означает unqualified, то есть неполные ключи. При проверке un-ключей используются только их имена. Например, если спека :page/address указана в списке -un, то в словаре ищется ключ :address, а не :page/address.

Неполные (краткие) ключи — довольно частое явление в s/keys. В основном мы получаем данные из JSON-документов и баз данных. Такие системы не знают о пространствах имен, поэтому мы игнорируем их. Исключения бывают, когда весь технологический стек фирмы построен на Clojure.

Различают следующие комбинации req, opt и un:

  • :req — необходимые полные ключи,
  • :req-un — необходимые краткие ключи,
  • :opt — опциональные полные ключи,
  • :opt-un — опциональные краткие ключи.

В примере со спекой ::page все ключи обязательны и не учитывают пространство.

Приведем пример ошибочных данных. Это может быть невалидный URL, пустое описание, отсутствующее поле. Если каждый из этих словарей подставить в выражение (s/valid? ::params <data>), результат будет false.

{:address "clojure.org"
 :description "Clojure Language"}

{:address "https://clojure.org/"
 :description ""}

{:address "https://clojure.org/"}

{:page/address "https://clojure.org/"
 :page/description "Clojure Language"}

Обратите внимание на последний случай. Значения полей верны, но ключи содержат пространства. Это потому, что мы поместили спеки адреса и описания под ключом req-un, что значит отбрасывать пространства при поиске в словаре. Чтобы последний пример сработал, измените :req-un на :req в объявлении ::page.

(s/def ::page-fq
  (s/keys :req [:page/address
                :page/description]))

(s/valid? ::page-fq
          {:page/address "https://clojure.org/"
           :page/description "Clojure Language"})

Примечание: частичка -fq означает fully qualified, т.е. полный, разрешенный.

Комбинированный пример. Предположим, у объекта страницы может быть поле HTTP-статуса, который мы получили при последнем обращении к ней. Если к странице еще не обращались, в поле нечего записать. Вот как выглядит спека в таком случае:

(s/def :page/status int?)

(s/def ::page-status
  (s/keys :req-un [:page/address
                   :page/description]
          :opt-un [:page/status]))

Словари без статуса и с правильным статусом проходят валидацию:

(s/valid? ::page-status
          {:address "https://clojure.org/"
           :description "Clojure Language"})

(s/valid? ::page-status
          {:address "https://clojure.org/"
           :description "Clojure Language"
           :status 200})

Заметим, что s/keys различает nil и наличие ключа в словаре. Если статус nil, это значит, что он состоит в словаре. Срабатывает проверка nil на int?, что приводит к ошибке валидации.

(s/valid? ::page-status
          {:address "https://clojure.org/"
           :description "Clojure Language"
           :status nil})
false

Вывод значений

До сих пор мы рассматривали проверку данных с помощью s/valid?. Эта функция возвращает истину или ложь, что значит данные верны или нет. Но одной проверки недостаточно. Часто бывает так, что данные корректны, но требуется привести их к нужному типу.

Например, мы получили число в виде строки. Валидация показывает, что строка состоит только из цифр и не превышает допустимой длины. Но и после валидации значение остается строкой. Это склоняет нас к ручному парсингу, увеличивает код и вообще неудобно. Хотелось бы, чтобы вывод значений происходил автоматически.

Spec предлагает такие возможности. Это функции s/conformer и s/conform (от анг. conform – подчиняться, приспосабливаться).

S/conformer строит спеку особого типа из функции-конформера. Такая функция принимает исходное значение и возвращает либо новое значение, либо ключ :clojure.spec.alpha/invalid.

S/conform принимает ключ спеки-конформера и данные. Если вывод значений прошел без ошибок, результатом будет новое значение. Если с ошибками, то вернется все тот же ключ invalid.

Рассмотрим пример с выводом числа из строки. Чтобы различать спеку-конформер от валидатора, к ее имени добавляют стрелку, что означает вывод, приведение.

(s/def ::->int
  (s/conformer
   (fn [value]
   (try
     (Integer/parseInt value)
     (catch Exception e
       ::s/invalid)))))

Такую спеку передают в s/conform вместе с данными:

(s/conform ::->int "42")
42

(s/conform ::->int "dunno")
:clojure.spec.alpha/invalid

Как и s/valid?, s/conform не перехватывает исключения в процессе вывода. Java устроена так, что вывод данных часто выбрасывает исключения. Хорошей практикой будет перехватывать их на уровне функции-конформера и возвращать ::s/invalid, как в примере выше.

Оба типа спек — валидатор и конформер – можно объединить через s/and. Например, добавить предварительные проверки перед выводом значения. В нашем случае логично убедиться, что значение – строка, чтобы не передать в parseInt значение nil или что-то еще:

(s/def ::->int+
  (s/and ::ne-string
       ::->int))

(s/conform ::->int+ nil)
:clojure.spec.alpha/invalid

Рассмотрим случай с восстановлением дат из строк. С этой проблемой часто сталкиваются веб-разработчики, когда получают от браузера JSON-документ. Формат JSON не поддерживает даты, поэтому их передают строкой или числом секунд.

Нам понадобится функция разбора такой строки и минимальная обвязка, чтобы подружить ее со спекой. Функция read-instant-date из модуля clojure.instant читает строку в дату. Она очень лояльна к входным данным: например, датой может быть только год.

(require '[clojure.instant
         :refer [read-instant-date]])

(read-instant-date "2019")
#inst "2019-01-01T00:00:00.000-00:00"

Обернем функцию в спеку:

(s/def ::->date
  (s/and
   ::ne-string
   (s/conformer
  (fn [value]
    (try
      (read-instant-date value)
      (catch Exception e
        ::s/invalid))))))

Как и в случае с числом, перед разбором значения мы делаем минимальные проверки. Убеждаемся, что это непустая строка, чтобы отсечь nil и другие не имеющие смысла значения.

Строка даты:

(s/conform ::->date "2019-12-31")
#inst "2019-12-31T00:00:00.000-00:00"

Дата и время:

(s/conform ::->date "2019-12-31T23:59:59")
#inst "2019-12-31T23:59:59.000-00:00"

Спеки-перечисления

Интересен вариант, когда возможные значения известны заранее. Например, при вызове определенного API клиент передает архитектуру программы – 32 или 64 бита. Ради двух значений не обязательно парсить число. Достаточно оператора выбора или подбора по словарю.

Рассмотрим вариант с перебором. Оператор case пробегает по вариантам и возвращает аналогичные числовые значения. Если ничего не найдено, возвращаем ::s/invalid.

(s/def ::->bits
  (s/conformer
   (fn [value]
   (case value
     "32" 32
     "64" 64
     ::s/invalid))))

(s/conform ::->bits "32")
32

(s/conform ::->bits "42")
:clojure.spec.alpha/invalid

Вариант со словарем. По заранее определенному словарю ищем результат. Если ключ не найден, возвращаем тег invalid.

(def bits-map {"32" 32 "64" 64})

(s/def ::->bits
  (s/conformer
   (fn [value]
     (get bits-map value ::s/invalid))))

Вариант выше хорош еще тем, что его опорная точка – словарь соответствий – вынесен в отдельную переменную. При желании его легко дополнить новыми значениями без изменения логики. Или даже вынести в конфигурацию.

Подобным способом восстанавливают логические значения из строк. Нет единого соглашения о том, как передавать истину и ложь в тексте. Это может быть True, TRUE, 1, on, yes для истины и их противоположности: FALSE, no, off… При разборе таких значений важно приводить их к одному регистру. В Clojure FALSE и false – это разные строки, хотя отправитель имел в виду одно и то же.

Сценарий вывода выглядит так:

  • убедиться, что значение это строка;
  • привести ее к нижнему регистру;
  • проверить значение словарем или перебором.

Конформер из реального проекта:

(s/def ::->bool
  (s/and
    ::ne-string
    (s/conformer clojure.string/lower-case)
    (s/conformer
      (fn [value]
        (case value
          ("true" "1" "on" "yes") true
          ("false" "0" "off" "no") false
          ::s/invalid)))))

Примеры его работы:

(s/conform ::->bool "True") ;; true
(s/conform ::->bool "yes")  ;; true
(s/conform ::->bool "off")  ;; false

Продвинутые техники

К этому времени мы написали достаточно кода, чтобы увидеть одинаковые участки – паттерны. Перечислим несколько техник, которые ускоряют работу со спекой.

Множества

Когда значения известны заранее, спекой может выступить множество. Вспомним, что множество ведет себя как функция одного аргумента. Если такой аргумент есть в множестве, функция просто вернет его. Если нет, результат будет nil. Предположим, статус сущности может быть "todo", "in_progress" и "done". Тогда спека будет множеством этих значений:

(s/def ::status #{"todo" "in_progres" "done"})

(s/valid? ::status "todo")
true

Перечисления

Множество не подходит в случаях, когда мы считаем false и nil верными значениями. S/valid? трактует их как неудачную проверку. Если nil или false входят в множество значений, проверять проверять следует функцией contains?.

(contains? #{1 :a nil} nil)
true

Чтобы свести повторы кода к минимуму, напишем функцию enum. Она принимает значения и возвращает другую функцию-предикат. Этот предикат принимает один аргумент и проверяет, есть ли такой среди исходных значений.

(defn enum [& args]
  (let [arg-set (set args)]
    (fn [value]
      (contains? arg-set value))))

Внутренняя функция замкнута на переменной arg-set. Это множество, полученное из списка аргументов. Мы создаем его один раз, чтобы не делать это постоянно в теле функции. Теперь регистрировать спеки-перечисления гораздо удобней:

(s/def ::status
  (enum "todo" "in_progres" "done"))

With-conformer

Спеки-конформеры требуют особого внимания. В них легко допустить ошибку: не перехватить исключение, забыть обернуть функцию в s/conformer. Чтобы свести код к минимуму, воспользуйтесь макросом with-conformer. Он принимает символ для переменной и произвольное тело. Результатом макроса будет спека-конформер.

Ее внутренняя функция принимает параметр, заданный символом. Функция исполняет тело в блоке try-catch. Если исключения не было, результатом будет последнее выражение тела. Если было, то вернется тег invalid.

(defmacro with-conformer
  [bind & body]
  `(s/conformer
     (fn [~bind]
       (try
         ~@body
         (catch Exception e#
           ::s/invalid)))))

Примеры из реального проекта. Вывод числа:

(s/def ::->int
  (s/and
   ::ne-string
   (with-conformer val
     (Integer/parseInt val))))

Вывод логического значения:

(s/def ::->bool
  (s/and
   ->lower
   (with-conformer val
     (case val
       ("true"  "1" "on"  "yes") true
       ("false" "0" "off" "no" ) false))))

, где ->lower это тоже обертка для приведения регистра:

(def ->lower
  (s/and
    string?
    (s/conformer clojure.string/lower-case)))

в примере выше не обязательно указывать invalid для значения по умолчанию. Вспомним, что case, если не нашел соответствия и не задан вариант по умолчанию, выбрасывает исключение. With-conformer перехватывает его и возвращает invalid.

Логические пути

Результат s/conform не всегда то, что мы ожидаем. Некоторые спеки оборачивают результат в вектор, где первый элемент – часть логического пути. Такой путь возникает в тех местах, где проверка ветвится.

До сих пор мы объединяли спеки через s/and. Такая супер-спека последовательно проходит дочерние и выполняет проверки. Это удобно, но недостаточно. Бывает, требуется спека-развилка, которая работает по условию. Например, если значение число, то оставить его как есть, а если строка, то попытаться привести к числу. Такие спеки называют условными.

Макрос s/or — одна из условных спек. Он принимает набор других спек и тегов. Макрос применяет значение к спекам до первого положительного случая. Результатом становится пара из нового значения и тега той спеки, что дала положительный результат.

Этот тег становится частью т.н. логического пути, по которому шла проверка. Логический путь помогает расследовать, в каком именно сценарии произошла ошибка. Для простых спек типа это не проблема. Но в реальных проектах бывает так, что условная спека вложена в другую условную, та тоже и т.д. Выявить ошибку без логического пути в таких случаях трудно.

Если валидация не прошла, то логический путь получают из отладочной информации. Эту информацию возвращают функции семейства s/explain*. Мы рассмотрим эти функции ниже.

Вот как выглядит спека сетевого порта. Она принимает число или строку, которую пытается преобразовать в число.

(s/def ::smart-port
  (s/or :string ::->int :num int?))

Теперь s/conform вернет не просто выведенное значение, а пару тег-значение:

(s/conform ::smart-port 8080)
[:num 8080]

(s/conform ::smart-port "8080")
[:string 8080]

Важно помнить, что если в спеке был условный узел (s/or, s/alt), то структура s/conform отличается от входных данных. Например, на месте скалярного значения появится вектор.

Покажем это на более сложном примере. Пусть порт — одно из полей параметров подключения к базе данных.

(s/def :conn/port ::smart-port)

(s/def ::conn
  (s/keys :req-un [:conn/port]))

Топология выходного словаря отличается от входных данных:

(s/conform ::conn {:port "9090"})
{:port [:string 9090]}

Анализ ошибок

Мы уже выяснили, что в случае неудачи s/valid? и s/conform возвращают false и invalid. Этих данных недостаточно, чтобы понять причину ошибки. Представьте, что у вас спека пользователя системы. У пользователя набор адресов, в каждом адресе несколько строк… и проверка вернула false. Поиск ошибки вручную в таком дереве займет день.

Функции семейства s/explain* принимают спеку и данные. Если ошибок не было, результат будет пустым. Если были, мы получим информацию о том, что пошло не так. Разница между функциями в том, как они обрабатывают результат.

  • s/explain печатает текст ошибки в стандартный поток (на экран);
  • s/explain-str возвращает эту же информацию в виде строки;
  • s/explain-data возвращает словарь с данными. Это самый полный отчет об ошибке.

Рассмотрим s/explain и s/explain-str. Результат их работы одинаковый, разница лишь в том, куда приходит текст — в консоль или переменную.

Подготовим простую спеку:

(s/def :sample/username ::ne-string)

(s/def ::sample
  (s/keys :req-un [:sample/username]))

На корректных данных explain никак не проявляет себя, разве что первый вариант печатает Success!:

(s/explain ::sample {:username "some user"})
Success!
nil

(s/explain-data ::sample {:username "some user"})
nil

Случай с ошибкой, вместо строки передано число:

(s/explain ::sample {:username 42})
42 - failed: string? in: [:username] at: [:username] spec: ::string

Отчет следует читать так: значение 42 не прошло проверку на предикате string?. Путь к значению внутри структуры [:username]. Ключ спеки, на которой произошла ошибка – ::string.

Отчет показывает самые вложенные спеки и предикаты. Вспомним, что ::ne-string это комбинация ::string и not-empty. Ошибка случилась на этапе ::string, что отчет и показывает.

Для пустой строки вывод будет другим. На этот раз проверка оборвется на этапе not-empty. Проверим это:

(s/explain ::sample {:username ""})
"" - failed: not-empty in: [:username] at: [:username] spec: ::ne-string

Сообщения такого рода допустимы для разработчиков, например, при проверке конфигурации на старте приложения. Но чем сложнее структура данных, тем менее понятен отчет explain. И уж точно такое сообщение нельзя показывать пользователям, если они ошиблись в данных. В следующем разделе мы рассмотрим эти проблемы.

Понятные сообщения об ошибках

Когда мы проверяем данные, важно не только зафиксировать факт ошибки. Еще важнее доступно объяснить клиенту, что именно неверно в его данных. Под клиентом не обязательно подразумевают человека. Даже если клиент это другая программа, будет правильно снабдить ответ понятным описанием. Скорее всего, клиент запишет результат в лог, и его прочитает человек.

Многие из нас сталкивались с сообщениями вроде “Ошибка 0x00ffff” без каких-либо деталей. Или красной надписью “проверьте данные” над формой в два экрана. Этих глупостей можно было избежать, умей программисты переводить системные сообщения в человеческие.

Очевидно, фраза "" - failed: not-empty in: [:username] не только ничего не скажет пользователю, но и отпугнет его машинной природой. Возникнет ощущение, что в интерфейсе образовалась брешь, и пользователь видит то, чего не должен. Это резко снижает доверие к системе в целом.

Чтобы сформировать понятное сообщение об ошибке, воспользуемся s/explain-data. Эта функция возвращает словарь со всей необходимой информацией. Вот как он выглядит:

(s/explain-data ::sample {:username ""})

#:clojure.spec.alpha
{:problems
 ({:path [:username]
   :pred clojure.core/not-empty
   :val ""
   :via [::sample ::ne-string]
   :in [:username]})
 :spec ::sample
 :value {:username ""}}

На первый взгляд непонятно, что делать с этой структурой. К сожалению, многие разработчики пасуют перед проблемой и говорят, что spec не подходит для ошибок. На самом деле, это отличная структура данных, нужно только правильно ее обработать.

Вопрос, который задают новички – почему бы не сделать понятные сообщения сразу на уровне библиотеки? Например, назначить спеке дополнительное поле с текстом “введите правильный адрес”? Почему не взять пример с многочисленных фреймворков для Python или JavaScript?

Ответ на этот вопрос не устраивает новичков. Вспомним тезис из начала главы. Spec – это фундаментальная библиотека. Она не предназначена для обработки ввода пользователя. Это набор абстракций и примитивов. То, что мы проверяем спекой поля HTML-формы – всего лишь частный случай. Ниже мы убедимся, что у спеки самые разные области применения. Поэтому структура ошибки тоже фундаментальна.

Во-вторых, трудно создать систему ошибок, которая устроит всех. В каждом проекте свои правила о том, как показывать ошибки. Рассмотрим поле возраста. Где-то пишут “укажите правильный возраст”. Это фиксированное сообщение, которое не меняется. Но в другом проекте ошибку выводят с текущим значением: “999 не подходит под критерии возраста”. Такое сообщение уже не фиксированный текст, а шаблон. Это значит, на одном из этапов срабатывает код, который извлекает ошибочное значение, шаблон, форматирует текст… А теперь добавим локализацию. В зависимости от локали браузера будем формировать сообщения на английском, русском, французском. Это очень, очень сложные сценарии.

Если бы разработчики Spec занялись выводом ошибок по принципу других фреймворков, их фокус был бы смещен с главной цели. И тогда вместо spec мы бы получили валидаторы по типу тех, что пишут десятками для JavaScript. Они скучны, не гибки и без концепции.

Словарь explain-data содержит ключи :spec, :value и :problems (с пространством clojure.spec.alpha). Первые два это спека и значение, которые принимали участие в проверке. Нас интересует поле problems. Это список словарей. Каждый словарь описывает конкретную ошибку валидации. Перечислим его поля и семантику.

  • :path – Логический путь валидации. Вектор ключей, где спеки чередуются с тегами-развилками. Условные спеки типа s/or записывают в этот вектор метки дочерних спек.

  • :pred – полный символ предиката, например clojure.core/string?.

  • :val – конкретное значение, которое не прошло проверку на предикат. Например, один из элементов исходного словаря.

  • :via – цепочка спек, по которым успело пройти значение от верхнего уровня к нижнему.

  • :in – физический путь к значению. Вектор ключей и индексов, который передают в функцию get-in. Если выполнить (get-in <исходные-данные> <путь>), то получим значение, которое вызвало ошибку.

Видим, что отчет содержит все данные, чтобы собрать понятное сообщение. Из :val возьмем конкретное неверное значение. Спека, на которой прервалась валидация это последний элемента вектора :via.

Составим словарь сообщений, где ключ — спека, а значение — понятный текст или шаблон. Зная спеку, в которой произошла ошибка, получим из словаря текст. Так, последним элементом вектора :via была спека ::ne-string. Логичное сопоставить ей сообщение “Строка не должна быть пустой” или что-то похожее.

(def spec-errors
  {::ne-string "Строка не должна быть пустой"})

Напишем наивную функцию, которая принимает словарь ошибки (один из элементов ::s/problems) и возвращает понятное сообщение:

(defn get-message
  [problem]
  (let [{:keys [via]} problem
        spec (last via)]
    (get spec-errors spec)))

(get-message {:via [::sample ::ne-string]})
"Строка не должна быть пустой"

Рассмотрим, как это работает на других полях. Добавим спеку электронной почты и новое поле в ::sample.

(s/def ::email
  (s/and
   ::ne-string
   (partial re-matches #"(.+?)@(.+?)\.(.+?)")))

(s/def :sample/email ::email)

(s/def ::sample
  (s/keys :req-un [:sample/username
                   :sample/email]))

Спека ::email проверяет строку по наивному шаблону электронного адреса. Регулярное выражение из примера выше читают как <что-угодно>@<что-угодно>.<что-угодно>.

Если передать в email пустую строку, последним элементом via будет ::ne-string. Для экономии места слегка сократим вывод explain-data:

(s/explain-data ::sample {:username "test" :email ""})

{:path [:email]
 :pred clojure.core/not-empty
 :val ""
 :via [::sample ::email ::ne-string]
 :in [:email]}

Если передать эту ошибку в get-message, она вернет прежнее сообщение о пустой строке. Но если email был непустой строкой, которая не попала под шаблон регулярного выражения, то последним элементом :via будет :sample/email. Полный словарь ошибки выглядит так:

{:path [:email]
 :pred
 (clojure.core/partial
  clojure.core/re-matches
  #"(.+?)@(.+?)\.(.+?)")
 :val "test"
 :via [::sample ::email]
 :in [:email]}

Чтобы get-message вернул другое сообщение, добавим в словарь ошибок ключ ::email:

(def spec-errors
  {::ne-string "Строка не должна быть пустой"
   ::email "Введите правильный почтовый адрес"})

Остается наращивать словарь все новыми спеками и сообщениями, пока не закроем все возможные ошибки. Но это линейный подход. Существует несколько способов улучшить поиск сообщений.

Например, что случится, если нужного перевода не окажется в словаре? В этом случае вернем нейтральное “исправьте ошибки в поле”. Заодно зафиксируем факт того, что перевод не был найден. Проще всего это сделать записью в лог.

Нормально, что команды разработки и локализации иногда не согласованы. Команда локализаторов время от времени просматривает этот лог и добавляет переводы.

Рассмотрим, как упростить поиск поля в словаре. Поле email может встречаться в разных спеках: :account/email, :patient/email, :client/email. Линейный подход из примера выше требует, чтобы для каждого такого ключа было сообщение об ошибке. Это склоняет нас к повторам в коде.

Чтобы не засорять словарь переводов, пойдем на хитрость. Пусть функция поиска пытается найти сообщение по полному ключу, а в случае неудачи — по его имени. Тогда достаточно ключа :email, и все емейлы будут сходиться на этот перевод. Если для одного конкретного емейла нужен особый перевод, добавим его полную версию в словарь:

(def spec-errors
  {::ne-string "Строка не должна быть пустой"
   :email "Введите правильный почтовый адрес"
   :account/email "Особое сообщение для адреса отправителя"})

Объединим вышесказанное в одну функцию. Вот как выглядит поиск в словаре с учетом неполного ключа и ошибки по умолчанию.

(def default-message
  "Исправьте ошибки в поле")

(defn get-better-message
  [problem]
  (let [{:keys [via]} problem
        spec (last via)]
    (or
     (get spec-errors spec)
     (get spec-errors
          (-> spec name keyword))
     default-message)))

Система, которую мы построили, достаточно проста. Ее легко тестировать и изменять под нужды конкретного проекта. Автор использовал доработанные версии такой системы в реальных проектах. В одном из случаев формы полностью проверялись на стороне клиента до их отправки на сервер. Это возможно, поскольку код на Clojure компилируется в JavaScript. Мощь clojure.spec в полной мере доступна на клиенте.

До сих пор мы использовали фиксированные сообщения. Но легко сделать их шаблонами, куда подставляют текущее значение или имя поля. В этом случае исправления в коде минимальны.

Получим имя поля как последний отличный от цифры элемент вектора :in. Ключ :val хранит текущее ошибочное значение. В тексте сообщения расставим %s для имени поля и значения. Функция (format <шаблон> <поле> <значение>) вернет что-то вроде “Поле email содержит неверное значение test.com”.

За рамками главы остались несколько вопросов. Первый — что делать, если требуется локализация сообщения, то есть вывод на русском или английском в зависимости от состояния? Очевидно, наша структура станет словарем словарей, где на первом уровне будет код локали (ru, en), а на втором — переводы для спек.

Теперь на первом шаге мы получаем по локали словарь переводов, затем переводим как описано выше. С кодом локали тоже можно схитрить, чтобы облегчить поиск. Например, иногда требуется различные написания для локалей en_US и en_GB. Реализуем функцию поиска так, что сперва она ищет по младшей локали (en_US), а затем по старшей (en).

Вопрос откуда приходит локаль остается на усмотрение разработчика. Ее можно хранить в сессии, параметрах адресной строки, базе данных, глобальной переменной, словом — как это удобно в текущем проекте.

Второй вопрос — как связать вывод ошибок с интерфейсом пользователя. Это тоже зависит от конкретного проекта. Хорошей практикой считается отделение модели от ее представления. Это верно и для форм. Удобно, когда форма — это структура данных с набором функций. Тогда все операции над ней будут чистыми функциями, которые легко поддерживать.

Представим форму как дерево, в листьях которого структура виджета. Виджет знает тип поля, текущее значение и ошибку. Специальный react-компонент подписан на этот лист дерева. На каждое его изменения компонент рисует HTML-элемент, например, поле ввода с текущим текстом. Если поле ошибки не nil, то оно выводится над полем.

Функция валидации принимает дерево формы. Она строит дерево значений. Это структура с той же топологией, но вместо листьев-виджетов на их местах значения. С помощью спеки мы проверяем значения (s/valid?) или выводим правильные типы из текста. В случае ошибки мы получаем отчет (s/explain-data). Для каждого элемента из поле problems находим путь, спеку и сообщение об ошибке. Это сообщение добавляем в соответствующий виджет в поле :error. Компонент, который отрисовывает этот виджет, уведомит пользователя об ошибке.

Парсинг

До сих пор мы обсуждали проверку данных и вывод значений. Теперь рассмотрим операцию более высокого уровня — парсинг. Под парсингом понимают разбор данных на части, выделение структуры там, где прежде ее не было.

Читателю наверняка приходилось парсить текст регулярными выражениями. Это особые шаблоны, которые описывают структуру фрагмента. Специальные функции принимают исходный текст и регулярное выражение. Структура результата известна заранее. Например, это список фрагментов, которые совпали с шаблоном.

Простейший пример регулярного выражения — IP-адрес. Это четыре группы, разделенные точками. Каждая группа состоит из числа от 0 до 255.

\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}

В примере выше мы ставим косую черту перед точкой. В регулярных выражениях точка это служебный символ. Мы экранируем его, чтобы описать именно символ точки.

В регулярных выражениях применяют операторы +, ?, * и другие. Они указывают, сколько раз встречается данный шаблон. Например, один и более раз, ни одного или один, произвольное число или ни одного. В зависимости от модификатора, шаблон захватывает разные части текста.

Можно представить, что регулярные выражения откусывают текст частями. Та часть, что легла на шаблон, наполняет структурированный результат. Оставшийся текст передается следующим шаблонам, и так далее.

Регулярные выражения подводят нас к regex-спекам. Это особый тип спек для разбора данных по шаблону. Разница в том, что и шаблон, и исходные данные это структуры, а не текст.

Предположим, мы читаем из текстового файла список пользователей. Каждый пользователь это кортеж вида <номер, емейл, статус>. Все значения в виде текста. Для каждого пользователя требуется:

  • убедиться, что в кортеже именно три элемента;
  • привести номер к числу;
  • проверить емейл на минимальные критерии;
  • привести статус к системному перечислению (одной из констант).

В идеале получить словарь с верными значениями.

Мы уже знакомы с s/conformer. Мы могли бы написать функцию, которая принимает кортеж пользователя и выполняет описанные выше преобразования. Технически это несложно. Но такая функция будет монолитной со слишком большим скоупом. Это плохая практика при работе с clojure.spec.

Для разбиения коллекций подходит s/cat. Эта спека принимает последовательность тегов и спек. На вход s/cat подают коллекцию значений, например список или вектор. Спека накладывает элементы коллекции на спеки. Если они совпали, результатом будет словарь. Ключи этого словаря — теги спек, значения — результат применения спеки к соответствующему значению.

Составим спеку для разбора кортежа. У нас уже есть парсинг чисел и проверка мейла. Опишем вывод статуса и соберем композицию спек:

(s/def :user/status
  (s/and
   ->lower
   (with-conformer val
     (case val
       "active" :USER_ACTIVE
       "pending" :USER_PENDING))))

(s/def ::user
  (s/cat :id ::->int
         :email ::email
         :status :user/status))

Проверим положительный случай:

(s/conform ::user ["1" "test@test.com" "active"])
{:id 1
 :email "test@test.com"
 :status :USER_ACTIVE}

Варианты с плохим номером, почтой или не тем статусом не проходят преобразование. Примеры ниже вернут ::s/invalid:

(s/conform ::user ["" "test@test.com" "active"])
(s/conform ::user ["1" "@test.com" "active"])
(s/conform ::user ["1" "test@test.com" "unknown"])

В примере с пользователем число полей в кортеже фиксировано. На практике так бывает не всегда. Мы все еще сталкиваемся с устаревшими форматами данных. В таких форматах бывают условия вроде “если перед номером строка BLOCKED, то пользователь заблокирован.” Это осложняет задачу, ведь теперь в кортеж состоит или из трех, или четырех элементов. Кроме того, сдвигается семантика полей. Первый элемент теперь не только номер, но и флаг блокировки. Встречаются и более сложные условия.

В императивных языка типа Python и Java такие требования порождают каскад if/else с перезаписью переменных. В Clojure эту проблему решают декларативно. Объявим спеку для флага блокировки:

(s/def ::blocked
  (s/and
   ->lower
   (s/conformer
    #(= % "blocked"))))

Добавим ее в итоговую s/cat, но укажем, что она встречается ноль или один раз:

(s/def ::user
  (s/cat :blocked (s/? ::blocked)
         :id ::->int
         :email ::email
         :status :user/status))

Теперь оба типа кортежа попадают под действие спеки. Если пользователь заблокирован, в итоговом словаре будет поле :blocked:

(s/conform ::user ["1" "test@test.com" "active"])
{:id 1 :email "test@test.com" :status :USER_ACTIVE}

(s/conform ::user ["Blocked" "1" "test@test.com" "active"])
{:blocked true :id 1 :email "test@test.com" :status :USER_ACTIVE}

Представим теперь, что на входе коллекция кортежей. Чтобы не утруждать себя ручной итерацией, объявим спеку-коллекцию:

(s/def ::users
  (s/coll-of ::user))

(def user-data
  [["1" "test@test.com" "active"]
   ["Blocked" "2" "joe@doe.com" "pending"]])

(s/conform ::users user-data)

[{:id 1 :email "test@test.com" :status :USER_ACTIVE}
 {:blocked true :id 2 :email "joe@doe.com" :status :USER_PENDING}]

Отсеять заблокированных пользователей можно функцией filter с предикатом (complement :blocked).

С помощью regex-спек можно парсить не только данные, но и текст. Рассмотрим, как распарсить INI-файл в словарь данных. Напомним, INI — это старый текстовый формат для конфигурации приложений. Он состоит из секций в квадратных скобках и пар поле=значение.

# config.ini

[database]
host=localhost
port=5432
user=test

[server]
host=127.0.0.1
port=8080

Хотелось бы получить из файла вложенный словарь вида:

{:database {:host "localhost"
            :port 5432}
 :server {:host "127.0.0.1"}}

Если отбросить пустые и закомментированные строки, то структура файла сводится к грамматике (<section>, <key=value>*)*, где звездочка означает сколько угодно раз, в т.ч. ничего.

Сперва прочитаем строки из файла обычной функцией. Эта функция не должна быть частью спеки. Спеки не должны иметь побочных эффектов, они только преобразовывают данные.

(require '[clojure.java.io :as io])

(defn get-ini-lines
  [path]
  (with-open [src (io/reader path)]
    (doall (line-seq src))))

Теперь составим спеку-парсер. Решим, что такая спека принимает список строк из ini-файла. Требуется выполнить следующие шаги:

  • удалить пустые строки и комментарии;
  • оставшиеся строки сгруппировать по заголовкам, распарсить пары поле=значение;
  • реструктурировать данные во вложенный словарь;
  • вывести типы и все проверить.

Выразим это в коде:

(s/def ::->ini-config
  (s/and
   (s/conformer clear-ini-lines)
   (s/* (s/cat :title :ini/title :fields (s/* :ini/field))) ;; 4
   (s/conformer remap-ini-data)
   ::ini-config))

Убедитесь, что поняли смысл четвертой строки. Мы считаем, что INI-файл это ноль и более блоков. Каждый блок состоит из заголовка и ноль и более пар ключ-значение.

Реализуем недостающие элементы. Функция clear-ini-lines выбрасывает незначащие строки:

(require '[clojure.string :as str])

(defn comment?
  [line]
  (str/starts-with? line "#"))

(defn clear-ini-lines
  [lines]
  (->> lines
       (filter (complement str/blank?))
       (filter (complement comment?))))

Объявим спеку :ini/title. Она проверяет, заголовок ли текущая строка или нет. Заголовок определяют квадратные скобки на границах строки. Если условие выполняется, вернем текст заголовка без скобок:

(s/def :ini/title
  (s/and
   #(str/starts-with? % "[")
   #(str/ends-with? % "]")
   (with-conformer val
     (subs val 1 (dec (count val))))))

Спека :ini/field парсит поле и значение. Просто разбиваем строку по знаку равенства. Цифра 2 означает, что в итоговом списке должно быть не более двух элементов: ключ и значение. Это полезно, когда значение содержит знак равенства (например, base64 строка).

(s/def :ini/field
  (with-conformer val
    (let [[key val :as pair] (str/split val #"=" 2)]
      (if (and key val)
        pair
        ::s/invalid))))

В примере выше мы убеждаемся, что действительно получили ключ и значение. Так мы исключим строки, в которых нет знака равенства.

На текущий момент запустим черновую, урезанную версию спеки:

(s/def ::->ini-config
  (s/and
   (s/conformer clear-ini-lines)
   (s/* (s/cat :title :ini/title :fields (s/* :ini/field)))))

(defn parse
  [path]
  (let [lines (get-ini-lines path)]
    (s/conform ::->ini-config lines)))

(parse "config.ini")

Результат:

[{:title "database"
  :fields [["host" "localhost"]
           ["port" "5432"]
           ["user" "test"]]}
 {:title "server"
  :fields [["host" "127.0.0.1"]
           ["port" "8080"]]}]

Разбор файла прошел удачно. Читатель заметит, что структура словаря отличается от той, что мы предложили вначале. Это неважно. Главное, нам удалось привести набор строк к определенному формату. Не составит труда обработать словарь функцией remap-ini-data:

(defn remap-ini-data
  [data-old]
  (reduce
   (fn [data-new entry]
     (let [{:keys [title fields]} entry]
       (assoc data-new title (into {} fields))))
   {}
   data-old))

Если передать в эту функцию вектор из предыдущего шага, результат будет тем, что мы ожидали:

{"database" {"host" "localhost" "port" "5432" "user" "test"}
 "server" {"host" "127.0.0.1" "port" "8080"}}

Напишем спеку, чтобы проверить конфигурацию и вывести типы. Например, чтобы номера портов были числами:

(s/def :db/host ::ne-string)
(s/def :db/port ::->int)
(s/def :db/user ::ne-string)

(s/def ::database
  (s/keys :req-un [:db/host
                   :db/port
                   :db/user]))

(s/def :server/host ::ne-string)
(s/def :server/port ::->int)

(s/def ::server
  (s/keys :req-un [:server/host
                   :server/port]))

(s/def ::ini-config
  (s/keys :req-un [::database
                   ::server]))

Перед тем, как применять словарь к этой спеке, следует перевести его ключи из строк в кейворды. Вот как выглядит итоговая спека:

(require '[clojure.walk :as walk])

(s/def ::->ini-config
  (s/and
   (s/conformer clear-ini-lines)
   (s/* (s/cat :title :ini/title :fields (s/* :ini/field)))
   (s/conformer remap-ini-data)
   (s/conformer walk/keywordize-keys)
   ::ini-config))

И результат:

(parse "config.ini")

{:database {:host "localhost"
            :port 5432
            :user "test"}
 :server {:host "127.0.0.1"
          :port 8080}}

Упражнение: устраните мелкие недоработки в коде выше. Пусть пара "foo=" становится {:foo nil}, а не {:foo ""}. Удалите пустые символы, которые могли остаться по краям ключа и значения. Опробуйте парсинг на больших ini-файлах.

Разбор Clojure-кода (теория)

В завершении темы немного поговорим о том, как парсить код. Мы уже видели, что clojure.spec подходит для разбора структур данных — в основном последовательностей. Вспомним, что код на Clojure состоит из списков. Это приводит к неожиданному решению: оказывается, исходный код на Clojure можно проверить спекой и вернуть ошибку еще до того, как он запущен.

В начале главы мы упоминали, что Clojure и Spec неразрывно связаны. Объясним эту связь на примере макросов. Большинство форм в Clojure представлены макросами. Это особые функции, которые срабатывают на этапе компиляции кода. Макрос принимает код как список символов. Задача макроса, как правило, в том, чтобы перестроить этот список в другой и вернуть его. Компилятор заменяет макрос на список-результат и выполняет его.

Макросы это отдельная веха в изучении Clojure. Мы поговорим о них в другой главе. Пока что заострим внимание на том, как проверить тело макроса.

Каждый макрос это по сути мини-язык с соглашением о том, что и в каком порядке передавать. Иногда один и тот же макрос допускает разные формы записи. По аналогии с языком, требуется разобрать грамматику кода. В случае ошибки доступно объяснить программисту, где он ошибся.

Хорошим примером служит defn, макрос определения функции. Кроме обязательных параметров он принимает несколько второстепенных: строку документации, пре- и пост-проверки. Справедлива форма записи с несколькими телами:

(defn my-inc
  [x]
  (+ x 1))

(defn my-inc
  "Increase a number"
  [x]
  {:pre [(int? x)]
   :post [(int? %)]}
  (+ x 1))

(defn my-inc
  ([x]
   (my-inc x 1))
  ([x delta]
   (+ x delta)))

Все это одна и та же функция, записанная по-разному. Очевидно, ручной разбор всех вариантов трудоемок. До версии Clojure 1.10 каждый макрос разбирал код по собственным правилам. Это было неорганизованно и неконсистентно. Начиная с 1.10 большинство макросов используют спеку для проверки и вывода ошибок. Так образовался общий подход к проблеме, который легко контролировать.

Разберем устно, как бы мы построили спеку для разбора defn. Очевидно, это список, поэтому на верхнем уровне спеки поместим s/cat. Первый его элемент — символ defn. Второй — символ с именем. После имени следует опциональный параметр строки документации. Затем тело или список тел.

Тело начинается с вектор параметров. После него идет опциональный словарь пре- и пост- проверок. Затем произвольное количество форм, которые составляют тело функции.

Грубая версия:

(s/def ::defn
  (s/cat :tag (partial = 'defn)
         :name symbol?
         :docstring string?
         :body (s/+ :defn/body)))

, где тело функции это

(s/def :defn/body
  (s/cat :args :defn/args
         :prepost (s/? map?)
         :code :defn/code))

В свободное время напишите такую спеку. Передайте в нее замороженную версию defn:

(s/conform
 ::defn
 '(defn my-inc
    "Increase a number"
    [x]
    {:pre [(int? x)]
     :post [(int? %)]}
    (+ x 1)))

Результатом будет что-то отдаленно напоминающее:

{:name my-inc
  :docstring "Increase a number"
  :bodies
  [{:params [x]
    :declaration [(+ x 1)]
    ;; other fields}]}

Каждый следующий уровень можно расширить вглубь. Выше мы упомянули вектор параметров. Будет здорово разобрать их на обязательные и необязательные. Например, чтобы параметры [x y & other] предстали в виде словаря:

{:required [x y] :rest other}

Усложните спеку так, чтобы она различала параметры функции. По аналогии выполните разбор словаря пре- и пост- проверок.

Спецификация функций

В последнем разделе мы поговорим о том, как clojure.spec связана с функциями. Мы уже упоминали проблему с проверкой входных данных. Даже если параметры нужного типа, это не гарантирует, что значения верны. Вспомним функцию, которая принимает диапазон дат. Случай, когда ее вызвали с параметрами start=2010.01.01 и end=2009.01.01, не имеет смысла.

Логично описать параметры этой функции спекой:

(s/def ::date-range-args
  (s/and
   (s/cat :start inst? :end inst?)
   (fn [{:keys [start end]}]
     (<= (compare start end) 0))))

Вторая функция из s/and принимает результат первого s/cat, то есть словарь с ключами :start и :end. Для сравнения дат используют специальную функцию compare, которая возвращает -1, 0 и 1 для случаев меньше, равно и больше. Быстрая проверка:

(s/valid? ::date-range-args [#inst "2019" #inst "2020"]) ;; true
(s/valid? ::date-range-args [#inst "2020" #inst "2019"]) ;; false

Возникает идея написать декоратор, который принимает целевую функцию и спеку ее параметров. Перед тем, как запускать функцию, он проверит параметры и в случае ошибки выкинет исключение. То же самое можно проделать для результата функции.

Нам не придется писать декоратор, потому что его включили в поставку clojure.spec. Речь идет о функции clojure.spec.test.alpha/instrument. Глагол instrument дословно означает оснастить, оборудовать.

Функция принимает символ другой функции, которую мы хотим оснастить проверкой. Вместе с тем она ищет особую функциональную спеку, зарегистрированную под этим же символом. Когда обе сущности найдены, instrument подменяет функцию на такую же, но с проверками. Это своего рода monkey patch, когда один модуль изменяет поведение другого.

Функциональную спеку объявляют макросом s/fdef. Сначала передают символ функции, которую хотели бы оснастить. Затем отдельные спеки для проверки входящих параметров, результата и их композиции.

В качестве примера напишем функцию и спеку к ней. Пусть это будет функция, которая возвращает разницу между двумя датами в секундах. В отличие от примера выше, мы допускаем случай, когда первая дата болье второй. В этом случае разница в секундах будет отрицательной.

(import 'java.util.Date)

(defn date-range-sec
  "Return a difference between two dates in seconds."
  [^Date date1 ^Date date2]
  (quot (- (.getTime date2)
           (.getTime date1))
        1000))

Теги ^Date нужны, чтобы компилятор знал тип объектов date1 и date2. В противном случае компилятор выполнит рефлексию, чтобы узнать тип. Это съедает машинное время. Мы поговорим о типах в отдельной главе.

Посчитаем разницу в сутках:

(date-range-sec
 #inst "2019-01-01" #inst "2019-01-02")
86400

Если поменять даты местами, результат будет отрицательным.

Опишем функциональную спеку. Ее символ будет date-range-sec. Под ключом :args указывают спеку входящих параметров. Поскольку параметры это список, на верхнем уровне почти всегда s/cat. Его задача разбить список на словарь, чтобы спекам ниже было удобно работать с отдельными значениями.

Под :ret указана спека выходного значения. Чаще всего это проверка на число или строку. Например, int?, string? или их nilable-версии: (s/nilable int) и так далее.

Ключ :fn особый. Это спека, которая будет вызвана в контексте входных параметров и результата. Бывает, что результат зависит от входных параметров по определенным правилам. Например, если функция возвращает число из диапазона, то проверка результата на int? недостаточна. Следует убедиться, что результат действительно не выходит за границы аргументов.

Спеке :fn передают словарь с ключами :args и :ret. Значение :args содержит не исходный список параметров, а результат s/conform от :args. Задача спеки — проверить, удовлетворяет ли результат входным аргументам. Если нет, вернуть false для предиката или ::s/invalid для s/conformer.

Напомним, что в ключи :args, :ret, :fn можно передавать объявленные ранее спеки. Это хорошая практика по переиспользованию кода. Например, у вас может быть семейство функций для работы с диапазонами чисел. Будет правильно объявить спеку параметров отдельно и затем ссылаться на нее в каждой из s/fdef.

Опишем спеку для функции date-range-sec. Ограничимся проверкой входных параметров и результата:

(s/fdef date-range-sec
  :args (s/cat :start inst? :end inst?)
  :ret int?)

Объявление функциональной спеки еще не меняет целевую функцию. Это правильно, потому что спека только декларирует проверки, но не запускает их. Чтобы подменить целевую функцию на ее оснащенную версию, используют instrument из модуля clojure.spec.test.alpha:

(require '[clojure.spec.test.alpha
           :refer [instrument]])

(instrument `date-range-sec)

Важно, что символ функции должен быть полным, то есть с пространством. Чтобы подставить в символ текущее пространство, перед ним ставят обратную кавычку ```.

Теперь date-range-sec проверяет аргументы и результат. Попробуем передать nil вместо одной из дат. Получим исключение класса clojure.lang.ExceptionInfo.

(date-range-sec nil #inst "2019")

Его текстовое сообщение и тело уже вам знакомы. Поле message содержит текст, аналогичный s/explain-str:

Execution error - invalid arguments to date-range-sec
nil - failed: inst? at: [:start]

В поле data структура, аналогичная результату s/explain-data. Чтобы получить эти данные из пойманного сообщения, используют функцию (ex-data exception).

Обратите внимание, что instrument расположен в отдельном модуле с доменом “test”. Это потому, что instrument предназначен для тестирования функций, но не продакшена. Разработчики пишут спеки для наиболее важных функций в отдельном модуле. Во время тестов проект стартует с особыми параметрами, где указаны дополнительные модули, которые нужно загрузить. Один из таких тестовых модулей оснащает функции их спеками. Если при прогоне тестов функция вернула неверный результат, это сразу станет заметно.

Instrument не подходит для боевого режима, потому что значительно снижает быстродействие функции. Замерим десять тысяч прогонов оснащенной функции:

(time
 (dotimes [n 10000]
   (date-range-sec #inst "2019" #inst "2020")))
"Elapsed time: 116.984496 msecs"

Получили десятую долю секунды на 10К вызовов. Пока что трудно сказать, быстро это или нет. Посчитаем время для исходной функции. Поскольку date-range-sec уже оснащена, объявим функцию с таким же телом, но другим именем, например date-range-sec-orig. Посчитаем стоимость ее вызова:

(time
 (dotimes [n 10000]
   (date-range-sec-orig
     #inst "2019" #inst "2020")))
"Elapsed time: 1.783962 msecs"

Разница в сто раз, или два порядка! Очевидно, что проверка в рантайме существенно замедляет приложение. По этой причине instrument не претендует на то, чтобы его использовали в продакшене. Замедление кода в десятки раз — слишком дорогая цена за детальный вывод ошибок.

Наоборот, во время тестов быстродействие нас не интересует. Мы стремимся покрыть код как можно большим числом проверок, чтобы отловить необычные сценарии.

В примере выше мы проигнорировали ключ :fn. Напомним, это комплексная проверка, в которой одновременно доступны аргументы и результат. Для функции date-range-sec справедливо правило: если первая дата больше второй, то результат отрицательный. Напишите спеку :fn, которая проверяет это условие. Тем самым вы предотвратите случай, когда кто-то решит, что результат должен быть по модулю.

Наличие спеки для функции улучшает документацию у ней. Специальная функция doc из модуля clojure.repl выводит на экран справку о запрошенной функции. С появлением clojure.spec ее поведение изменилось. Теперь, кроме строки документации, она выводит спеку функции.

Вот как выглядит справка для date-range-sec:

(require '[clojure.repl :refer [doc]])
-------------------------
date-range-sec
([date1 date2])
  Return a difference between two dates in seconds.
Spec
  args: (cat :start inst? :end inst?)
  ret: int?

Функцию doc активно используют различные IDE и редакторы, чтобы показывать сигнатуру по мере написания кода. Даже если вы не пользуетесь instrument для тестирования, спека помогает поддерживать проект.

Переиспользование спек

Писать спеки порой долго и утомительно. В Clojure-сообществе принято снабжать библиотеки спеками, чтобы облегчить труд другим разработчикам. Если библиотека использует какую-то структуру данных, будет правильно описать ее спекой.

Хорошим примером служит clojure.jdbc. Это легковесная Clojure-обертка над реляционными базами данных. Почти каждое веб-приложение использует БД для хранения данных. JDBC-подключение описано словарем с ключами :host, :port, :user и так далее.

Считается правильным проверить конфигурацию базы перед тем, как подключаться к ней. В противном случае вы рискуете получить странное поведение, например NullPointerException при попытке соединения.

Сlojure.jdbc несет на борту семейство спек для всех своих подсистем. Достаточно импортировать модуль clojure.java.jdbc.spec, чтобы описанные в нем спеки попали в глобальный реестр.

Предположим, ключ :db в конфигурации описывает подключение. Пусть это будет edn-файл:

{:db {:dbtype "mysql"
      :host "127.0.0.1"
      :port 3306
      :dbname "project"
      :user "user"
      :password "********"
      :useSSL true}}

Прочитаем файл комбинацией read-string и slurp:

(read-string (slurp "config.edn"))

Спека для этого файла выглядит так:

(require '[clojure.java.jdbc.spec :as jdbc])

(s/def ::db ::jdbc/db-spec)

(s/def ::config
  (s/keys :req-un [::db]))

Нормально, если спеку поставляют в отдельной библиотеке как дополнение. Так поступили разработчики alia — Clojure-клиента для Кассандры. Базовый пакет qbits.alia несет базовую функциональность, а сторонний cc.qbits/alia-spec содержит спеку кластера и основных функций.

Дополнения к spec (обзор)

Spec входит в поставку Clojure и потому не меняется так радикально, как предлагают некоторые разработчики. Дополнения к spec выпускают отдельными библиотеками. Среди прочих заслуживают внимания два проекта: expound и spec.tools. В этом разделе мы коротко опишем возможности каждого.

Библиотека expound улучшает сообщения об ошибках, делает их понятней для человека. Сигнатура функции expound аналогична s/explain. Она тоже принимает спеку и данные. Сообщение об ошибке выглядит примерно так:

(expound/expound string? 1)
-- Spec failed --------------------
  1
should satisfy
  string?
-------------------------
Detected 1 error

Такой отчет все еще выглядит машинным, и мы не можем показать его пользователю. Все же он лучше, чем сырой s/explain. Например, его могут прочитать коллеги из команды Ops, которые не знакомы с Clojure. Особенно хорошо expound подходит для проверки конфигурации на старте приложения. Иногда код приложения не меняется месяцами, но конфигурацию обновляют часто, поэтому отчет о проблемах на старте важен.

Разработчики Metosin собрали ряд улучшений к clojure.spec в проекте spec.tools. В сердце этой библиотеки особый объект Spec. Он оборачивает стандартную спеку и дополняет ее различными методами. С помощью spec.tools удобно формировать JSON-схему или описывать REST-проект по стандарту Swagger. Библиотеку используют в основном как промежуточный слой между REST-фреймворком и спекой.

Мы не будем останавливаться подробно на этих проектах. Они просты в техническом плане и требуют больше кода, чем объяснения. Читателю не составит труда разобраться с ними, когда на то возникнет потребность.

Будущее спеки

На сегодняшний день пакет clojure.spec все еще не избавился от частички “alpha” в названии. Авторы все еще экспериментируют со спекой, пытаются найти лучший способ валидировать данные. Это смущает некоторых разработчиков. Опасаясь, что по окончании эксперимента от spec избавятся, они предпочитают альтернативные библиотеки: schema, bouncer.

Отдельные группы пишут обертки над спекой, чтобы расширить ее возможности. Например, подружить ее с JSON-схемами и популярными инструментами вроде Swagger.

В недавнем докладе Maybe Not Рич Хикки анонсировал вторую версию спеки. Ожидается, что разработчики упростят проверку сложных типов данных. Например, когда значением может быть и строка, и число. Разработка ведется в открытом режиме, но еще рано говорить о конкретных результатах. Обсуждение новой спеки выходит за рамки этой главы.

Итог

Как мы выяснили, clojure.spec — это набор функций и макросов. Ими описывают правила, которым должны удовлетворять данные. Правила это предикаты, т.е. Функции, которые возвращают истину или ложь.

Предикаты гибче и мощнее типов. Если о значении известно, что оно верного типа, это еще не гарантирует его корректность. Значение -1 не может быть Unix-портом. Пользовательские классы вроде UnixPort в конструкторе это не типы, а валидация в рантайме. Она привязана к вызову класса синтаксическим сахаром.

В отличие от классов, предикаты компонуются друг с другом. Легко написать супер-предикат с логикой “все из”, “любой из” и так далее.

Мы рассмотрели основные возможности из пакета clojure.spec. Это далеко не все, о чем еще можно рассказать. В обсуждение не попали различные спеки-комбинаторы вроде s/alt, полезные для тонких проверок. Другая обширная тема по spec — генераторы из модуля clojure.spec.gen.alpha. Мы коснемся их в отдельной главе про тесты.

Код этой главы доступен в одном модуле на Гитхабе.