Quartet 9: Allegro | Производительность

    Когда создавалась библиотека для валидации данных quartet были поставленны следующие цели-ориентиры:



    В этой статье я хотел бы рассмотреть производительность quartet и её причины.


    Статья на тему Краткости и простоты будет 4 апреля.


    Будем исследовать этот аспект в сравнении между quartet и другой намного более популярной ajv.


    Hello world


    Напишем простейшую проверку — является ли значение строкой "Hello World!".


    Для того, чтобы сравнить библиотеки валидации необходимы данные, которые мы будем валидировать. Соответственно для этой задачи имеем такие наборы валидных и не валидных данных.


    const valids = ["Hello World!"];
    const invalids = [null, false, undefined, "", 1, Infinity, "Hello World"];

    ajv


    Как всегда всё начинается с импорта:


    const Ajv = require("ajv");

    Создадим экземпляр «компилятора»:


    const ajv = new Ajv();

    Ajv на вход принимает описание валидируемого типа в виде JSON схемы.


    Давайте создадим соответствующую схему для нашей задачи


    const helloWorldSchema = {
      type: "string",
      enum: ["Hello World!"]
    };

    Далее необходимо "скомпилировать" функцию валидации, то есть из схемы — получить функцию, которая будет ожидать данные на вход, а на выходе возвращать true, если валидация прошла успешно, в ином случае будет возвращать false.


    const ajvValidator = ajv.compile(helloWorldSchema);

    Готово!


    Производительность компиляции этой схемы была измерена с помощью библиотеки benchmark.


    Проведя пять итераций замеров, на выходе имеем такие результаты:


    Ajv Build
    661,639 ops/sec
    354,725 ops/sec
    628,443 ops/sec
    659,900 ops/sec
    557,037 ops/sec
    
    Среднее: 572,349 ops/sec

    Теперь произведём замер производительности валидации:


    for (let i = 0; i < valids.length; i++) {
      ajvValidator(valids[i]);
    }
    for (let i = 0; i < invalids.length; i++) {
      ajvValidator(invalids[i]);
    }

    Пять замеров и результат:


    Ajv Validation
    
    21,452,228 ops/sec
     3,066,770 ops/sec
     4,522,850 ops/sec
     2,522,777 ops/sec
     2,741,310 ops/sec
    
    Среднее: 6,861,187 ops/sec

    Первый замер вышел довольно странно производительным — но что есть, то есть.


    quartet


    Импортируем єкземпляр «комплиятора»:


    const { v } = require("quartet");

    Скомпилируем функцию валидации:


    const quartetValidator = v("Hello World!");

    Пять замеров производительности компиляции:


    Quartet 9: Allegro Build
    
    6,019,078 ops/sec
    3,893,780 ops/sec
    2,712,363 ops/sec
    5,926,415 ops/sec
    2,729,369 ops/sec
    
    Среднее: 4,256,201 ops/sec

    Теперь произведём замер производительности валидации:


    for (let i = 0; i < valids.length; i++) {
      quartetValidator(valids[i]);
    }
    for (let i = 0; i < invalids.length; i++) {
      quartetValidator(invalids[i]);
    }

    Пять замеров:


    Quartet 9: Allegro Validation
    
    15,073,432 ops/sec
    13,711,573 ops/sec
    13,123,812 ops/sec
    25,617,225 ops/sec
    17,588,846 ops/sec
    
    Среднее: 17,022,977 ops/sec

    Имеем такие результаты сравнения:


    image


    image


    Причины


    Причина такой большой разницы становится ясна из следующего кода:


    console.log("Function");
    console.log(quartetValidator.toString());

    Результат:


    function (value) { return value === c; }

    Здесь c — это то значение, которое мы передали в параметр.


    Итоги


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


    Мы все живые люди


    Теперь приведём более реальный пример. Пусть мы получили данные про человека со стороннего API. Мы ожидаем, что эти данные будут следующего типа:


    interface Person {
      id: number; // положительное целое число
      name: string; // непустая строка
      phone: string | null; // null или 12 цифр номера
      phoneBook: {
        [name: string]: string; // 12 цифр номер
      };
      gender: "male" | "female";
    }

    Будем производить замеры на таких наборах данных


    Позитивные и негативные случаи для валидации
    const valids = [
      {
        id: 1,
        name: "andrew",
        phone: "380975003434",
        phoneBook: {
          andrew: "380975003434",
          bohdan: "380975003434",
          vasilina: "380975003434"
        },
        gender: "male"
      },
      {
        id: 2,
        name: "bohdan",
        phone: null,
        phoneBook: {},
        gender: "male"
      },
      {
        id: 3,
        name: "Elena",
        phone: null,
        phoneBook: {
          siroja: "380975003434"
        },
        gender: "female"
      }
    ];

    const invalids = [
      null, // не объект
      false, // не объект
      undefined, // не объект
      "", // не объект
      1, // не объект
      Infinity, // не объект
      "Hello World", // не объект
      {
        id: 0, // не положительное число
        name: "andrew",
        phone: "380975003434",
        phoneBook: {
          andrew: "380975003434",
          bohdan: "380975003434",
          vasilina: "380975003434"
        },
        gender: "male"
      },
      {
        // отсутствует id
        name: "andrew",
        phone: "380975003434",
        phoneBook: {
          andrew: "380975003434",
          bohdan: "380975003434",
          vasilina: "380975003434"
        },
        gender: "male"
      },
      {
        id: 1.5, // Не целое
        name: "andrew",
        phone: "380975003434",
        phoneBook: {
          andrew: "380975003434",
          bohdan: "380975003434",
          vasilina: "380975003434"
        },
        gender: "male"
      },
      {
        id: 1,
        name: "", // пустая строка
        phone: "380975003434",
        phoneBook: {
          andrew: "380975003434",
          bohdan: "380975003434",
          vasilina: "380975003434"
        },
        gender: "male"
      },
      {
        id: 1,
        // отсутствует name
        phone: "380975003434",
        phoneBook: {
          andrew: "380975003434",
          bohdan: "380975003434",
          vasilina: "380975003434"
        },
        gender: "male"
      },
      {
        id: 1,
        name: "andrew",
        phone: "38097500434", // 11 цифр
        phoneBook: {
          andrew: "380975003434",
          bohdan: "380975003434",
          vasilina: "380975003434"
        },
        gender: "male"
      },
      {
        id: 1,
        name: "andrew",
        // отсутствует phone
        phoneBook: {
          andrew: "380975003434",
          bohdan: "380975003434",
          vasilina: "380975003434"
        },
        gender: "male"
      },
      {
        id: 1,
        name: "andrew",
        phone: "380975003434",
        phoneBook: {
          andrew: "380975003434",
          bohdan: "380975003434",
          vasilina: "38097503434" // 11 цифр
        },
        gender: "male"
      },
      {
        id: 1,
        name: "andrew",
        phone: "380975003434",
        // phoneBook отсутствует
        gender: "male"
      },
      {
        id: 1,
        name: "andrew",
        phone: "380975003434",
        phoneBook: {
          andrew: "380975003434",
          bohdan: "380975003434",
          vasilina: "380975003434"
        },
        gender: "Male" // 'male'
      },
      {
        id: 1,
        name: "andrew",
        phone: "380975003434",
        phoneBook: {
          andrew: "380975003434",
          bohdan: "380975003434",
          vasilina: "380975003434"
        }
      }
    ];

    ajv


    Создадим схему:


    const personSchema = {
      type: "object",
      required: ["id", "name", "phone", "phoneBook", "gender"],
      properties: {
        id: {
          type: "integer",
          exclusiveMinimum: 0
        },
        name: {
          type: "string",
          minLength: 1
        },
        phone: {
          anyOf: [
            { type: "null" },
            {
              type: "string",
              pattern: "^\\d{12}$"
            }
          ]
        },
        phoneBook: {
          type: "object",
          additionalProperties: {
            type: "string",
            pattern: "^\\d{12}$"
          }
        },
        gender: {
          type: "string",
          enum: ["male", "female"]
        }
      }
    };

    Скомпилируем:


    const ajvCheckPerson = ajv.compile(personSchema);

    Проведя десять замеров имеем такую производительность:


    Ajv Build
    
    79,476 ops/sec
    78,334 ops/sec
    61,752 ops/sec
    77,395 ops/sec
    78,539 ops/sec
    51,922 ops/sec
    80,031 ops/sec
    77,687 ops/sec
    65,439 ops/sec
    79,805 ops/sec
    
    Среднее: 73,038 ops/sec

    Проведём замеры валидаций:


    for (let i = 0; i < valids.length; i++) {
      ajvCheckPerson(valids[i]);
    }
    for (let i = 0; i < invalids.length; i++) {
      ajvCheckPerson(invalids[i]);
    }

    Десять итераций замеров:


    Ajv Validation
    
    227,640 ops/sec
    301,134 ops/sec
    190,450 ops/sec
    195,595 ops/sec
    384,380 ops/sec
    193,358 ops/sec
    385,280 ops/sec
    239,009 ops/sec
    193,832 ops/sec
    392,808 ops/sec
    
    Среднее: 270,349 ops/sec

    quartet


    Скомпилируем функцию валидации:


    const quartetCheckPerson = v({
      id: v.and(v.safeInteger, v.positive),
      name: v.and(v.string, v.minLength(1)),
      phone: [null, v.test(/^\d{12}$/)],
      phoneBook: {
        [v.rest]: v.test(/^\d{12}$/)
      },
      gender: ["male", "female"]
    });

    Десять замеров производительности:


    Quartet 9: Allegro Build
    
    35,564 ops/sec
    14,401 ops/sec
    15,438 ops/sec
    26,852 ops/sec
    33,935 ops/sec
    16,010 ops/sec
    34,550 ops/sec
    33,148 ops/sec
    16,037 ops/sec
    36,828 ops/sec
    
    Среднее: 26,276 ops/sec

    Проведём замеры производительности валидаций:


    for (let i = 0; i < valids.length; i++) {
      quartetCheckPerson(valids[i]);
    }
    for (let i = 0; i < invalids.length; i++) {
      quartetCheckPerson(invalids[i]);
    }

    Десять итераций, результат:


    Quartet 9: Allegro Validation
    
    237,059 ops/sec
    435,844 ops/sec
    248,021 ops/sec
    238,931 ops/sec
    416,993 ops/sec
    281,904 ops/sec
    439,975 ops/sec
    242,074 ops/sec
    330,487 ops/sec
    421,704 ops/sec
    
    Среднее: 329,299 ops/sec

    Сравним теперь результаты обоих библиотек:


    image
    image


    Причины


    Причины такой производительности валидации, и такого отставания во времени компиляции станут ясными, когда мы посмотрим на код функции quartetCheckPerson и её поля и методы.


    console.log(quartetCheckPerson.toString());
    console.log({ ...quartetCheckPerson });

    Результат


    function validator(value) {
      if (value == null) return false
      if (!Number.isSafeInteger(value.id)) return false
      if (value.id <= 0) return false
      if (typeof value.name !== 'string') return false
      if (value.name == null || value.name.length < 1) return false
      if (!validator["value.phone"](value.phone)) return false
      if (value.phoneBook == null) return false
      validator.keys = Object.keys(value.phoneBook)
      for (let i = 0; i < validator.keys.length; i++) {
        validator.elem = value.phoneBook[validator.keys[i]]
        if (!validator["tester-1"].test(validator.elem)) return false
      }
    
      if (!validator["value.gender"](value.gender)) return false
      return true
    };
    
    // Check person properties
    {
      'value.phone': function validator(value) {
        if (value === null) return true;
        if (validator.tester.test(value)) return true;
        return false
      }
      ['value.phone']['tester']: /^\d{12}$/,
      'tester-1': /^\d{12}$/,
      'value.gender': function validator(value) {
        if (validator.__validValuesDict[value] === true) return true
        return false
      },
      ['value.gender']['__validValuesDict']: {
        male: true,
        female: true
      }
    }

    Код сгенерированный алгоритмом — не самый легкий для чтения, но при медленном рассмотрении станет ясно — что он действительно проводит проверку типа и довольно еффективно.


    Но что насчёт объяснений невалидности:


    ajv


    Ничего менять не надо — так как ajv имеет свой собственный тип объяснений, которые хранятся в свойстве валидатора errors.


    quartet


    В quartet было решено по-умолчанию отключить сбор объяснений по ходу проверки.


    Есть два способа получить доступ к механизму сбора объяснений:


    1) Использовать второй параметр метода v.custom. Второй параметр — это то объяснение, которое нужно запушить в массив explanations внутри валидатора, если проверка с помощью кастомной функции не прошла. Если второй параметр — это функция — она будет вызвана с передачей в неё невалидного значения


    Схема будет иметь следующий вид:


    const checkId = v(v.and(v.safeInteger, v.positive));
    const checkName = v(v.and(v.string, v.minLength(1)));
    const checkPhone = v([null, v.test(/^\d{12}$/)]);
    const checkPhoneBookItem = v(v.test(/^\d{12}$/));
    const checkGender = v(["male", "female"]);
    
    const quartetCheckPerson = v({
        id: v.custom(checkId, "id"),
        name: v.custom(checkName, "name"),
        phone: v.custom(checkPhone, "phone"),
        phoneBook: {
          [v.rest]: v.custom(checkPhoneBookItem, "phoneBook")
        },
        gender: v.custom(checkGender, "gender")
      }); 
    
    // quartetCheckPerson({}) // false
    // quartetCheckPerson.explanations // ['id']

    Сделаем замеры:
    image
    image


    2) Второй способ более идеоматичный — использование кастомных объяснений по умолчанию. Функция errorBoundary будет вызвана как только один из валидаторов вернёт false.


    import { quartet } from 'quartet'
    const v = quartet({
      errorBoundary(explanations, { value, id, schema, innerExplanations }) {
        explanations.push(...innerExplanations, { value, id, schema })
      }
    })
    
    const checkPerson = v({
      id: v.and(v.safeInteger, v.positive),
      name: v.and(v.string, v.minLength(1)),
      phone: [null, v.test(/^\d{12}$/)],
      phoneBook: {
        [v.rest]: v.test(/^\d{12}$/)
      },
      gender: ["male", "female"]
    });
    
    // checkPerson(null) // false
    // checkPerson.explanations // [{ value: null, id: 'value', schema: { id: ... }]

    Сделаем замеры:


    image
    image


    Итоги


    Проведённое сравнение дало смешанные результаты. Для тех, кому нужны производительность и объяснения — выбирайте ajv. Для тех, кому объяснения невалидностей не нужны — берите quartet — и получите ещё большую производительность при более читаемой и выразительной схеме.
    Я воодушевлён таким результатом. Надеюсь читателю захочется опробовать quartet@9 на деле.


    Спасибо за внимание, интересно почитать комментарии.

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 4

      +1
      А сообщений об ошибках — в каких местах и какие именно — нет и не предвидится?
      Если так, то область применения данной библиотеки сильно-сильно сужается.
        0

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


        Что касается производительности — как только такая функциональность будет вынесена в интерфейс библиотеки — я проведу ещё замеры.

          0

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


          Что касается производительности — как только такая функциональность будет вынесена в интерфейс библиотеки — я проведу ещё замеры.

            0

            Добавил в документацию библиотеки конфигурацию кастомной ошибки валидации по умолчанию. Ею может быть что угодно.
            Также можно включать объяснения на некоторые части схем с помощью метода v.errorBoundary.
            Это замедлило код в 2 — 2.5 раз. Такая плата за кастомные ошибки валидации(
            Будем стараться оптимизировать это)

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое