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

В 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

Комментарии (19)


  1. AlexXYZ
    16.03.2024 18:11

    Думаю, что вы двигаетесь в верном направлении, но считаю, что для повышения производительности стоит кэшировать признак валидации, тогда перед проверкой каждого следующего правила вы будете проверять параметры валидности из кэша, а не вызывать функции валидации повторно. Поэтому вы можете проверять весь диапазон бизнес-правил программы и это будет весьма производительно. Пользователю будет более комфортно работать, если программа сообщает ему все ошибки, а не только первую.

    Чисто философски - параметр валидации должен отвечать на один вопрос - «валидна ли вся модель или нет?». Это на случай, если возникнет желание использовать для одних случаев валидации true, а для другого false - везде использовать true для валидных параметров, а false для невалидных параметров.


    1. nowm
      16.03.2024 18:11
      +1

      Если немного развить идею: на клиентской стороне лучше показывать все ошибки (потому что это улучшает восприятие), а на серверной стороне лучше останавливаться после первой (потому что это избавляет от лишней работы). Стратегии валидации в этих случаях получаются разные. На клиентской стороне важнее пользователю всё подсказать и рассказать, а на серверной стороне важнее безопасность и потребление ресурсов.

      Ещё, кэш может иногда добавить сюрпризов, а в некоторых случаях — даже снизить производительность, потому что код, который сериализует данные для кэша и проверяет его наличие, может в итоге потреблять больше ресурсов, чем сами проверки.


      1. AlexXYZ
        16.03.2024 18:11

        Всё зависит от рода ошибок. Есть ошибки общего характера - опечатка в числе и очевидно, что сервер это не примет, поэтому нужно не отправлять на сервер запрос, если заранее известно, что значение невалидно. Это экономит ещё лучше. Но есть особенности серверного расчёта, которые клиент не сможет реализовать. Например специфический числовой расчёт. Тут лучше предоставить полную картину, поэтому считать на сервере лучше всё, т.к. есть расчёты, независимые по параметрам.

        А про кэш - проблем быть не должно, если он одноразовый - закончилась обработка запроса - кэш забыт.


        1. nowm
          16.03.2024 18:11

          А про кэш - проблем быть не должно, если он одноразовый - закончилась обработка запроса - кэш забыт.

          Я примерно это имел ввиду:

          Без кэша
          1. Проверить валидность.

          С кэшем
          1. Сформировать ключ для кэша. Так как библиотека абстрактная, в имени ключа будет присутствовать вся-вся информация, которая доступна: и само значение, которое, нужно перегнать в строку, если это не строка (проверка/сериализация добавит ещё несколько операций и отожрёт ещё немного больше вычислительных ресурсов), и функция-валидатор, которую придётся тоже сериализовать для участия в ключе кэша, иначе значение «привет», которое проверяется на длину строки перепишет кэш проверки для значения «привет», которое проверяется на наличие русских букв.

          2. Проверить наличие ключа в кэше.

          3. Не нашлось — проверить валидность.

          4. Записать в кэш.


          1. AlexXYZ
            16.03.2024 18:11

            в имени ключа будет присутствовать вся-вся информация

            Можно попросить вас привести пример? Я не понял, как это может выглядеть.

            А так в целом выглядит неплохо.


            1. nowm
              16.03.2024 18:11

              Встречный вопрос: как будет выглядеть запись в кэше валидации для проверки слова «привет» на то, что оно длинее 1-го символа и короче 10-ти — по вашему мнению?


              1. AlexXYZ
                16.03.2024 18:11

                Если данное валидно, то ничего не записано, а если не валидно, то строка с текстом, например: «Параметр (такой-то) должен быть длиннее 1 символа и короче 10. В данный момент его длина: (Len)»

                в имени ключа будет присутствовать вся-вся информация, которая доступна

                Но мой вопрос был не о тексте сообщения. Вы сказали слово «ключ» в который вы собрались записать «всё-всё». Хотелось бы увидеть пример этого «всего-всего» в ключе?


                1. nowm
                  16.03.2024 18:11

                  Строка с текстом, которую вы описали — это то, что я называл ключом кэша. Вы как раз описали ситуацию, когда для генерации этой строки используется вся-вся доступная информация: и параметр и то, как он валидируется. К слову, я с самого начала перечислял, что я имел ввиду под «вся-вся» (если перечитаете ещё раз, то увидите). Так что, мой пример «ключа» от вашего примера «строки с текстом» не отличается.

                  Теперь представьте, сколько новой логики должно добавиться в библиотеку-валидатор, чтобы она умела генерировать такие ключи кэша валидации. Кроме одной операции вадидации она делает дополнительно ещё три операции, связанные с обслуживанием кэша, что может снизить производительность, а не повысить (тут я без фанатизма — валидация бывает разная; в одних случаях кэш помогает, в других — вредит).


      1. utftufutukgyftryidytftuv Автор
        16.03.2024 18:11

        согласен, при клиентской валидации обязательно валидировать все данные, иначе это будет как минимум странно выглядеть. Но на мой взгляд для этого не требуется добавлять полную валидацию в библиотеку, можно обработать это на уровне библиотеки для работы с формами. Примерно так:

        type Test = (value: any) => string;
        
        class Field {
          test: Test;
        }
        
        class Form {
          fields: Record<string, Field>
        
          constructor(testsObj: Record<string, >) {
            for (const key in testsObj) {
              this.fields[key] = new Field(testsObj[key])
            }
          }
        }
        
        const form = new Form(convertSchemaToTestFuncs({
          name: d.string()
        }))
        

        Это избавит от необходимости в добавлении лишнего функционала и заставит думать модульно, разбивая данные на полноценные сущности

        Пример convertSchemaToTestFuncs для desy, для yup


        1. AlexXYZ
          16.03.2024 18:11

          В этом примере у вас сильная привязка к представлению данных через формы HTML и к TypeScript. У меня есть иерархичные данные с кириллицей в ключах. По поводу иерархии, например, название ключей:

          {
          круг: {радиус:5, материал: "анаптаниум"},
          прямоугольник: {длина, ширина, плотность},
          производитель: "ооо пандора"
          }

          Это, чтобы не вводить частности, если это описание какой-то детали.

          Я пытаюсь понять границы применимости вашей библиотеки. (По поводу кириллицы и иерархии сразу встречный ответ - вот, что дали)


          1. utftufutukgyftryidytftuv Автор
            16.03.2024 18:11

            зависит от того как мы получаем эти данные.

            Если используем библиотеку для работы с формами - то вариант выше, это работает и для вложенных данных. На мой взгляд это самый приоритетный вариант и частый случай использования библиотеки валидации на клиента

            Если каким-то другим образом(например из пользовательского JSON) то такой функциональности сейчас не предусмотрено(да и вряд ли кому-то понадобиться). И самое простое что приходит в голову это следующий код:

            const data = getData()
            
            const mySchema = {
              'круг': d.object({
                 'радиус': d.number(),
                 'материал': d.string()
               })
            }
            
            const errors = Object.entries(mySchema).reduce((errors, [key, schema]) => {
              const error = schema.validate(data[key])
              if (error !== '') {
                errors[key] = error
              }
              return errors
            }, {})
            

            Подчеркну что в таком случае валидация будет для каждого поля отдельно, но это не очень красивый вариант и стоит по возможности его избежать


  1. nin-jin
    16.03.2024 18:11

    1. utftufutukgyftryidytftuv Автор
      16.03.2024 18:11
      +2

      Привет. Действительное очень быстрая библиотека, что видно по твоим и моим результатам замеров.

      Но выделю несколько пунктов которых в ней не хватает лично мне:

      1. Производительности при нахождении ошибок. $mol_data выбрасывает исключение, что сказывается на его performance. Ну и как я написал выше это не совсем правильно с моей точки зрения - валидация данных ожидаемая нами операция, а такие действия не должны выбрасывать исключения за исключением(извини за повторение) непредусмотренных проблем

      2. Не хватает строгости проверок по умолчанию. Я бы хотел видеть максимально строгие проверки, что пока не реализовано в mol. Например в desy по умолчанию строка проверяется на то что она не пустая, а в $mol_data нет, desy в объекте могут быть только известные ключи, а в $mol_data нет

      3. Не хвататет удобств к которым привык в yup и zod. Например проверки что значение всегда true или расширяемости кастомными проверками(возможно $mol_data_pipe можно использовать для этого, но мне показалось что он про преобразование типов)

      4. Наверно самая большая проблема для меня - понятность кода. В моей desy эта цель стояла на первом месте. Скажу честно код $mol_data выглядит немного космически со всеми фишками из mol-а. При этом mol кажется для меня довольно сложной экосистемой, а эта библиотека связана с ней довольно сильно.


      1. bevertax
        16.03.2024 18:11
        +1

        Существует вот такой обширный замер производительности большинства известных библиотек для валидации. Самые быстрые (например typebox) используют кодогенерацию. Насколько я понимаю к $mol он не имеет никакого отношения, кроме схожести названий https://moltar.github.io/typescript-runtime-type-benchmarks/

        Так же вот тут можно найти ссылку на дипломную работу, в которой описывается создание библиотеки для валидации https://valibot.dev/guides/introduction/


      1. nin-jin
        16.03.2024 18:11

        1. Самая быстрая всё же suretype, но она тяжёлая и делает eval.

        2. Ошибка выскакивает один раз - производительность тут не имеет значения. И это не "валидация" данных, а "парсинг". То есть функция принимает любую кашу, а выдаёт строго типизированный результат или падает.

        3. Довольно странно требовать от всех строк быть не пустыми. Тем более по умолчанию. А какая разница что там в неизвестных ключах? Ключей может быть 100500, а приложение работает только с 5 из них. Зачем ему падать, когда меняется состав ключей, которые ему не интересны?

        4. Кастомный парсер - это любая унарная функция ($mol_data_value). Вот прям совсем любая, хоть String, хоть parseFloat, хоть даже Date. $mol_data_pipe нужен, чтобы собрать один строготипизированный парсер из цепочки парсеров, где выход одного парсера идёт на вход следующего.

        5. Хз, что там за сильная связь такая. В npm либа на 2кб без зависимостей.


  1. Zukomux
    16.03.2024 18:11
    +1

    Понятно, что вы стремитесь улучшить производительность, но при этом упускаете очень много нюансов. Мы уже очень долгое время работаем с yup и вот с какими кейсами мы столкнулись:

    1. Асинхронная валидация, да та самая, которая редко используется. Рано или поздно в больших приложениях вы с ней столкнетесь

    2. Трансформация входящих/исходящих данных - это очень важный момент, особенно для дат. Поскольку даты передаются строкой, важно указывать правильную функцию преобразования.

    3. Комбинированные схемы - обычно это касается исходящих данных. На сложных формах, зачастую, требуются схемы, которые должны собираться по условию. Из коробки yup такое делать корректно не умеет

    4. Валидация одного значения в зависимости от других других


    1. utftufutukgyftryidytftuv Автор
      16.03.2024 18:11

      1. Можете привести пример когда может потребоваться асинхронная валидация? В голову приходит только работа с DB при серверном сценарии, но такую проверку обычно делают на отдельном уровне бизнес логики, а не при первоначальной валидации данных. Пока кажется что если потребовался асинхронный запрос - то это явный сигнал что проверка связана с логикой, а не форматом данных(например занято ли имя), а такие проверки я бы делал не через библиотеку

      2. Для меня неявная трансформация данных скорее минус чем плюс. Это чуть упрощает жизнь разработчику, но добавляет неочевидность потоку данных. Если мы оперируем изначальной сущностью userData то будет странно увидеть там тип данных который пользователь не отправлял, а если на основе изначальных данных строим свои сущности - тогда это то место где можно преобразовать

      3. Могли бы вы привести пример какой бы вы хотели видеть комбинацию данных? В моей голове пример приведенный ниже закрывает эту потребность, но возможно я что-то не учел

      const addressSchema = d.object({
        street: d.string()
      })
      const user = d.object({
        name: d.string()
        adress: addressSchema
      })
      
      1. Напишите пожалуйста каким-бы вы хотели это видеть? Для себя подобного рода задачи я решаю через кастомные правила c помощью .test()


      1. Zukomux
        16.03.2024 18:11

        1. Пользователь выбирает необходимую ему дату. Требуется проверить является ли выбранный день рабочим, т.е не выходной, и не приходится на праздники. За это отвечает отдельный микросервис, которым пользуются все формы.

        2. Это не неявное преобразование, если вы работаете с нормальным моделями данных, то у вас даты должны лежать не строками, а объектом Date. С Бека приходит строка, формат строки задаётся спекой. На входные данные конечно можно закрыть глаза(нет), а вот выходные данные придется форматировать

        3. Пользователь выбрал на форме опцию, которая предполагает обязательное заполнение дополнительных полей. В этот момент должна быть другая схема, где эти поля помечены как и обязательные


    1. AlexXYZ
      16.03.2024 18:11

      1. Валидация одного значения в зависимости от других других

      А как при это можно поступить: все участвующие параметры становятся/признаются невалидными или невалидным признаётся только исходный проверяемый параметр? И требуется ли дополнительная валидация участвующих параметров перед такой многопараметровой проверкой?