10 December 2019

Еще один DSL для валидаций

RubyProgrammingDesigning and refactoringRuby on RailsFunctional Programming

Недавно я написал небольшой гем для валидаций и хотел бы поделиться с вами его реализацией.


Идеи, которые преследовались при создании библиотеки:


  • Простота
  • Отсутствие магии
  • Легкость в освоении
  • Возможность кастомизации и минимум ограничений.

Почти все эти пункты завязаны на первом — простоте. Итоговая реализация невероятно маленькая, поэтому я не отниму у вас много времени.


С исходным кодом можно ознакомиться здесь.


Архитектура


Вместо использования привычного DSL с помощью методов класса и блоков я решил, что буду использовать данные.
Таким образом, вместо привычного декларативно-императивного (хаха, ну вы поняли, да? "декларативно-императивный") DSL как, например, в Dry, мой DSL просто преобразует некоторый набор данных в валидатор. Так же это означает, что данная библиотека может быть реализована (теоретически) и на других динамических языках (например, питоне), не обязательно даже объектно-ориентированных.


Читаю я последний параграф и понимаю, что написал какую-то кашу. Прошу прощения. Для начала я дам несколько определений и потом приведу пример.


Определения


Вся библиотека построена на трех простых концептах: валидатор, схема (blueprint) и преобразование (transformation).


  • Валидатор — то, ради чего библиотека и нужна. Объект, который проверяет, удовлетворяет ли нечто нашим требованиям.
  • Схема — это просто произвольные данные, описывающие другие данные (цель нашей валидации).
  • Преобразование — функция t(b, f), принимающая схему и объект, вызывающий эту функцию (фабрика), и возвращает она либо другую схему, либо валидатор.
    Кстати, слово "преобразование" контекстуально в математике является синонимом слова "функция" (во всяком случае, в книжке, которую я читал в универе).

Фабрика, формально, делает следующее:


  • Для набора преобразований T1, T2, ..., Tn создается композиция Ta(Tb(Tc(...))) (порядок произвольный).
  • Полученная композиция применяется к схеме циклично, пока результат отличается от аргумента.

Мне это чем-то напоминает машину Тьюринга. На выходе мы должны получить валидатор (или анонимную функцию). Что-либо иное означает, что схема и(ли) трансформации неверны.


Пример


На реддите человек привел пример в Dry:


user_schema = Dry::Schema.Params do
  required(:id).value(:integer)
  required(:name).value(:string)
  required(:age).value(:integer, included_in?: 0..150)
  required(:favourite_food).value(array[:string])
  required(:dog).maybe do
    hash do
      required(:name).value(:string)
      required(:age).value(:integer)
      optional(:breed).maybe(:string)
    end
  end
end

user_schema.call(id: 123, name: "John", age: 18, ...).success?

Как видите, используется магия в виде required(..).value и методы вроде #array.


Сравните с моим примером:


is_valid_user = StValidation.build(
  id: Integer,
  name: String,
  age: ->(x) { x.is_a?(Integer) && (0..150).cover?(x) },
  favourite_food: [String],
  dog: Set[NilClass, { name: String, age: Integer, breed: Set[NilClass, String] }]
)

is_valid_user.call(id: 123, name: 'John', age: 18, ...)

  1. Для описания хеша используется… хеш. Для описания значений используются значения (классы, массивы, множества, анонимные функции). Никаких магических методов (#build не считается, т.к. является просто сокращением).
  2. Итоговым значением валидации является не сложный объект, а просто true/false, о чем мы в конечном итоге и волнуемся. Это не является преимуществом, но упрощением.
  3. В Dry внешний хеш определен немного отлично от внутреннего. На внешнем уровне используется метод Schema.Params, а внутри #hash.
  4. (бонус) в моем случае валидируемый объект не обязан быть хешем и при этом не требуется никакого особенного синтаксиса: is_int = StValidation.build(Integer).
    Каждый элемент схемы сам является схемой. Хеш — пример сложной схемы (т.е. схемой, которая состоит из других схем).

Структура


Весь гем состоит из небольшого количества частей:


  • Главное пространство имен (модуль) StValidation
  • Фабрика, которая и отвечает за генерацию валидаторов, StValidation::ValidatorFactory.
  • Абстрактный валидатор StValidation::AbstractValidator, являющийся, по сути, интерфейсом.
  • Набор базовых валидаторов, которые я включил в базовый "синтаксис" в модуле StValidation::Validators
  • Два метода главного модуля для удобства и объединения всех остальных элементов:
    • StValidation.build — использующий стандартный набор трансформаций
    • StValidation.with_extra_transformations — использующий стандартный набор трансформаций, но расширяющий его.

Стандартный DSL


В свой собственный DSL я включил следующие элементы:


  • Класс — проверяет тип объекта (например, Integer).
    Простейший валидатор в моем синтаксисе, не считая анонимной функции и наследников AbstractValidator, которые являются примитивами генератора.
  • Множество — объединение схем. Пример: Set[Integer, ->(x) { x.nil? }].
    Проверяет, что объект соответствует хотя бы одной из схем. Даже сам класс называется UnionValidator.
    Простейший пример композитного валидатора.
  • Массив — пример: [Integer].
    Проверяет, что объект является массивом и все его элементы удовлетворяют определенной схеме.
  • Хеш — то же самое, но для хешей. Дополнительные ключи не позволяются.

Набор трансормаций выглядит так:


def basic_transformations
  [
    ->(bp, _factory) { bp.is_a?(Class) ? class_validator(bp) : bp },
    ->(bp, factory) { bp.is_a?(Set) ? union_validator(bp, factory) : bp },
    ->(bp, factory) { bp.is_a?(Hash) ? hash_validator(bp, factory) : bp },
    ->(bp, factory) { bp.is_a?(Array) && bp.size == 1 ? array_validator(bp[0], factory) : bp }
  ]
end

def class_validator(klass)
  Validators::ClassValidator.new(klass)
end

def union_validator(blueprint, factory)
  Validators::UnionValidator.new(blueprint, factory)
end

# ...

Проще некуда, не правда ли?


Ошибки и #explain


Лично для меня основной целью валидаций является проверка, валиден ли объект. Почему он не валиден — уже побочный вопрос.
Однако полезно понимать, почему что-то не валидно. Для этого я добавил в интерфейс валидатора метод #explain.


По сути, он должен делать то же самое, что и валидация, но возвращать, что конкретно не так.
В целом, саму валидацию (#call) можно было бы определить как частный случай #explain, просто проверив, пустой ли результат explain.


Такая валидация, однако, будет медленнее (но это не важно).


Т.к. анонимные функции-предикаты оборачиваются в наследника AbstractValidator, они тоже имеют метод #explain и просто указывают, где функция определена.


При написании собственных валидаторов, #explain может быть сколь угодно сложным и умным.


Кастомизация


Мой "синтаксис" не встроен в сердце библиотеки и, соответственно, не обязателен к использованию. (см. StValidation.build).


Давайте попробуем более простой DSL, который будет включать только числа, строки и массивы:


validator_factory = StValidation::ValidatorFactory.new(
  [
    -> (blueprint, _) { blueprint == :int ? ->(x) { x.is_a?(Integer) } : blueprint },
    -> (blueprint, _) { blueprint == :str ? ->(x) { x.is_a?(String) } : blueprint },
    lambda do |blueprint, factory|
      return blueprint unless blueprint.is_a?(Array)

      inner_validators = blueprint.map { |b| factory.build(b) }
      ->(x) { x.is_a?(Array) && inner_validators.zip(x).all? { |v, e| v.call(e) } }
    end
  ]
)

is_int = validator_factory.build(:int)
is_int.call('123') # ==> false

is_int_pair = validator_factory.build([:int, :int])
is_int_pair.call([1, 2]) # ==> true
is_int_pair.call([1, '2']) # ==> false

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


Итог


А нет его. Просто я горжусь данным техническим решением и хотел его продемонстрировать :)

Tags:dslвалидациигем
Hubs: Ruby Programming Designing and refactoring Ruby on Rails Functional Programming
+6
1.3k 17
Leave a comment
Top of the last 24 hours