Для чего нужна валидация при разработке и когда ее применять?
В 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
ссылка на оригинал статьи https://habr.com/ru/articles/800713/
Добавить комментарий