Для чего нужна валидация при разработке и когда ее применять?
В web разработке при работе с пользовательскими данными валидация должна применяться при получении данных сервисом. Условно можно разделить валидацию на:
Клиентскую. При вводе данных в формы важно провалидировать введенные данные и сообщить пользователю о их некорректности. Это дает понятный обратный отклик пользователю о его действиях и предотвращает дальнейшие некорректные действия в сервисе.
Серверную. Любой код, выполняемый на клиенте, а также запросы, поступающие от клиентского приложения, не могут считаться доверенными и должны быть провалидировано. Нельзя рассчитывать на то, что клиентское приложение гарантированно подготовит корректные данные, так как при разработке может возникнуть несоответствие логики работы с данными на сервере и клиенте. При этом мы также можем столкнуться со случаем, когда клиент вручную подготавливает данные, маскируясь под приложение.
В целом, данные следует валидировать как можно чаще, особенно в контексте полного цикла разработки как на сервере, так и на клиенте. Давайте рассмотрим, какие библиотеки существуют для этой цели в настоящее время.
Анализ существующих решений
Из популярных решений которые могут применять как на клиенте, так и на сервере можно выделить yup
и zod
. Рассмотрим их особенности и обратим внимание на их недостатки.
В целом обе библиотеки страдают от:
Излишнее многообразие функциональности. К этому можно отнести как и преобразование типов - обе библиотеки предоставляют функциональность преобразования типов при валидации, так и стремление предусмотреть все возможные случаи валидации. Это увеличивает размер кодовой базы и уменьшает понятность кода для других разработчиков, которые решаться залезть в исходники. Для примера метод getIn в yup и непроходимое поле regexp, методы которые обязаны предусматривать все варианты конфигурации в zod (Это не говоря уже о файлах размером в 6000 строк.).
Игнорирование вопросов производительности. Обе библиотеки делают упор скорее на расширении функциональности, чем на производительность того что у них есть. И это проявляется в мелочах, например в этих библиотеках добавление любого нового правила валидации приводит к полному копированию сущности yup, zod.
Архитектура библиотеки
Принципы
Попробуем создать свою библиотеку, избежав указанных выше проблем. Для этого сформулируем принципы которыми мы должно руководстоваться
Код должен быть простым
Код должен быть производительным на столько, на сколько это позволяет предыдущий пункт
Структура
Попробуем отталкиваться от кода который мы ожидаем видеть в готовой библиотеки. По аналогии с yup и zod выглядеть это должно примерно вот так:
const schema = string().min(2);
const value = 'hello';
schema.validate(value);
Нужно отметить что здесь присутствует две и более валидации
string()
- проверяет что value является строкой (по умолчанию строка также не должны быть пустой)min(2)
- проверяет что длина строки должна быть как минимум 2 символа
Эти условия мы могли бы добавлять и дальше, но мы уже видим главное,
структура, которую мы выберем для хранения правил, должна поддерживать неограниченный список правил
необходимо предусмотреть цепочку методов, чтобы можно было записать следующее:
string().min(2).max(4)
Выглядеть это может так:
type Checker = () => string;
class String {
conditions: Checker[] = [];
constructor() {
// Добавление правила валидации
this.conditions.push((value) => {
if (typeof value !== 'string') {
return 'Is not a string';
}
return '';
});
}
min(num: string) {
// Добавление правила валидации
this.conditions.push((value) => {
if (value.length < min) {
return 'Too short string';
}
return '';
});
// Возвращение всей сущности для возможности чейнинга
return this;
}
}
Теперь для того чтобы узнать и провалидировать передаваемые данные осталось узнать существует ли такой condition который вернет непустую строку при выполнении:
type Checker = () => string;
class String {
conditions: Checker[] = [];
// ...
validate(value: any) {
for (const condition of this.confiditons) {
const error = condition(value);
if (error !== '') {
return error;
}
}
return '';
}
}
Здесь можно заметить что мы останавливаемся на первой встреченной ошибке и завершаем цикл проверок. В реальном мире это добавит производительности нашему решению. Такому же подходу мы будет следовать при работе с другими данными, например, объектами - прерывать перебор при выявлении первое ошибки. Такое решение может кому-то показаться странным, но на мой взгляд оно является самым практичным:
Если нам интересна каждая ошибка в данных, например при валидации форм. Для каждой сущности(инпута) можно написать свою валидацию
Если нам интересно почему сервер не принял наши данные, и мы предполагаем что есть несколько причин. Сначала можно исправить уже указанную ошибку, а потом исправлять новые
Мы уже предусмотрели несколько проверок, но жизнь всегда многообразнее, и стоит позволить разработчику самому добавлять правила помимо стандартных
type Checker = () => string;
class String {
conditions: Checker[] = [];
test(checker: (value: any) => string) {
this.conditions.push(checker);
return this;
}
}
Сразу отметим что validate()
, test()
, conditions()
кажутся общими методами/свойствами, без которых не обойдется ни один тип валидации. Поэтому вынесем их в отдельный класс от которого будет наследовать все наши конкретные типы. Финальный код будет выглядеть так:
type Checker = (value: any) => string;
class Schema {
conditions: Checker[] = [];
validate(value: any) {
for (const condition of this.conditions) {
const error = condition(value);
if (error !== '') {
return error;
}
}
return '';
}
test(checker: Checker) {
this.conditions.push(checker);
return this;
}
}
class String extends Schema {
constructor() {
super();
this.conditions.push((value) => {
if (typeof value !== 'string') {
return 'Is not a string';
}
return '';
});
}
min(min: number) {
this.conditions.push((value) => {
if (value.length < min) {
return 'Too short string';
}
return '';
});
return this;
}
}
const checkUpperCase = (value: string) => {
if (value !== value.toUpperCase()) {
return 'NOT UPPER CASE';
}
return '';
};
const string = () => new String();
const schema = string().min(2).test(checkUpperCase);
const valueValid = 'HELLO';
const valueError = 'Hello';
console.log(schema.validate(valueValid)); // ''
console.log(schema.validate(valueError)); // 'NOT UPPER CASE'
Отмечу что реальный пример только немного сложнее, поскольку
conditions - должен содержать имена правил, чтобы в определенных случаях их можно было заменить или убрать. Поэтому вместо обычных функций стоит использовать объекты, которые содержат имена проверок и сами функции
сообщение об ошибке от checker хотелось бы видеть более информативным, при сложной вложенной структуре в тексте пригодилось бы название свойства в котором произошла ошибка
Вложенные структуры
Мы написали отличный код для примитива, а что делать с более сложными структурами? Например
const user = {
name: 'Aleksey',
age: 42,
};
Для этого нам понадобится отдельная сущность object
, которая позволит писать вложенные правила
const schema = object({
name: string(),
age: number(),
});
Её реализация:
class Object extends Schema {
constructor(objSchema) {
super();
this.conditions.push((obj) => {
for (const key in objSchema) {
const innerSchema = objSchema[key];
// innerSchema сама знает как провалидировать данные, нам остается только ее запустить
const error = innerSchema.validate(obj);
if (error !== '') {
return `${key} props has wrong type`;
}
}
return '';
});
}
}
Ts типы
Описывая схему, мы по сути уже указываем типы, которые должны быть в валидируемом объекте. Используя ts мы вполне можем избавить разработчика от необходимости описывать типы несколько раз. Для того чтобы это реализовать попробуем сделать немного магии ts
Простой пример
const schema = string();
const rawValue = 'hello';
const error = schema(rawValue);
if (error !== '') {
// do something
}
const value = rawValue as Infer<typeof schema>; // string type
Попробуем это реализовать. Как основу идею создадим внутреннее поле types, которое будет хранить тип сущности и откуда Infer сможет получить необходимый тип
class Schema<TValue> {
types!: TValue;
}
class String extends Schema<string> {}
type Infer<TType extends Schema<any>> = TType['types'];
Работает! Теперь перейдем к более сложному примеру:
const rawUser = {
name: 'Aleksey',
};
const schema = object({
name: string(),
});
const error = schema(rawUser);
if (error !== '') {
// do something
}
const user = rawUser as Infer<typeof schema>; // {name: string, age: number} type
Попробуем реализовать. Сейчас будет немного магии TypeScript, поэтому уберите детей и последователей Flow
type Infer<TType extends Schema<any>> = TType['types'];
class Schema<TValue> {
types!: TValue;
}
class String extends Schema<string> {}
const string = () => new String();
type ObjectValue = Record<string, Schema<any>>;
type PreparedTypes<TValue extends ObjectValue> = {
[K in keyof TValue]: Infer<TValue[K]>;
};
class ObjectVidator<
TValue extends ObjectValue,
TValueTypes = PreparedTypes<TValue>,
> extends Schema<TValueTypes> {
value: TValue;
constructor(value: TValue) {
super();
this.value = value;
}
}
function object<TValue extends ObjectValue>(value: TValue) {
return new ObjectVidator(value);
}
const schema = object({
name: string(),
});
type User = Infer<typeof schema>; // {name: string} type
Реальная библиотека
Подходы описанные выше верхнеуровнево описывают концепцию библиотеки которую можно реализовать. Теперь дело за добавлением конкретных типов для number, boolean и так далее. При этом создание реальной библиотеки потребует большее количество ресурсов. Путь описанный выше я проделал при написании своей библиотеки desy. В ней вы можете подсмотреть как выглядит указанный код на самом деле и если захотите использовать в своем проекте
desy - Dead Extraordinary Simple Yup
Мысли о производительности
После написание библиотеки меня удивило на сколько desy оказался более производительным чем другие решения. Я конечно ожидал лучших бенчмарков, но не такого бурного прироста который произошел в реальности. Как причину можно выделить
отказ от прокидывания ошибок
отказ от валидации при нахождении ошибок
отказ от иммутабельных структур и усложненного кода с глубоким ветвлением
Писать конкретные цифры всегда сомнительное дело, поэтому замеры можно изучить самостоятельно
Вопросы, которые могли остаться
Почему индикатор ошибки это строка? Строка является самым выразительным средством сообщения о деталях ошибки. Учитывая что мы отказались от пробрасывания ошибок, true/false нам тоже не подойдут
Почему не пробрасываем ошибки? Проброс ошибок является операцией которая должна сообщать о непредвиденной работе приложения. Несоответствие данных схеме, при том что это происходит внутри специально созданной для этого программы нельзя назвать непредвиденных ситуациях. Мы буквально просим программу сообщить о том являются ли данные валидными или нет. Для этого должны использоваться обычные способы работы с данными. +производительность
Почему все проверки синхронные? Поддержка асинхронных проверок потребовала бы увеличения кодовой базы и разветвления логики выполнения. При этом асинхронные проверки требуются крайне редко. Настолько редко что в этих случаях проще обойтись без готового решения
Заключение
Как итог хотелось бы сказать:
Многие библиотеки которые мы используем в повседневной жизни не являются ни производительными, ни понятными или расширяемыми. Мы просто привыкли к этим инструментам и часто воспринимаем как что-то глобальное и незыблемое. Иногда нужно писать свои велосипеды и возможно какой-то и из них окажется лучше оригинала. Не стоит забывать что многие популярные библиотеки это ответ автора на то, что ему что-то не понравилось в уже существующих
Валидируйте данные. Серьезно. Пользователю нельзя доверять. И лучше используйте для валидации desy