JS. Валидация данных. Пишем свой YUP

от автора

Для чего нужна валидация при разработке и когда ее применять?

В 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *