Недавно я написал небольшой гем для валидаций и хотел бы поделиться с вами его реализацией.
Идеи, которые преследовались при создании библиотеки:
- Простота
- Отсутствие магии
- Легкость в освоении
- Возможность кастомизации и минимум ограничений.
Почти все эти пункты завязаны на первом — простоте. Итоговая реализация невероятно маленькая, поэтому я не отниму у вас много времени.
С исходным кодом можно ознакомиться здесь.
Архитектура
Вместо использования привычного 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, ...)
- Для описания хеша используется… хеш. Для описания значений используются значения (классы, массивы, множества, анонимные функции). Никаких магических методов (
#buildне считается, т.к. является просто сокращением). - Итоговым значением валидации является не сложный объект, а просто true/false, о чем мы в конечном итоге и волнуемся. Это не является преимуществом, но упрощением.
- В Dry внешний хеш определен немного отлично от внутреннего. На внешнем уровне используется метод
Schema.Params, а внутри#hash. - (бонус) в моем случае валидируемый объект не обязан быть хешем и при этом не требуется никакого особенного синтаксиса:
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
Простите за немного запутанный код. По сути, массив в данном случае проверяет соответствие по индексу.
Итог
А нет его. Просто я горжусь данным техническим решением и хотел его продемонстрировать 🙂
ссылка на оригинал статьи https://habr.com/ru/post/479600/
Добавить комментарий