Недавно я написал небольшой гем для валидаций и хотел бы поделиться с вами его реализацией.
Идеи, которые преследовались при создании библиотеки:
- Простота
- Отсутствие магии
- Легкость в освоении
- Возможность кастомизации и минимум ограничений.
Почти все эти пункты завязаны на первом — простоте. Итоговая реализация невероятно маленькая, поэтому я не отниму у вас много времени.
С исходным кодом можно ознакомиться здесь.
Архитектура
Вместо использования привычного 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
Простите за немного запутанный код. По сути, массив в данном случае проверяет соответствие по индексу.
Итог
А нет его. Просто я горжусь данным техническим решением и хотел его продемонстрировать :)