Когда создавалась библиотека для валидации данных quartet были поставленны следующие цели-ориентиры:
- TypeScript
- Краткость и простота
- Производительность
В этой статье я хотел бы рассмотреть производительность 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
Имеем такие результаты сравнения:


Причины
Причина такой большой разницы становится ясна из следующего кода:
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
Сравним теперь результаты обоих библиотек:


Причины
Причины такой производительности валидации, и такого отставания во времени компиляции станут ясными, когда мы посмотрим на код функции 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']
Сделаем замеры:


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: ... }]
Сделаем замеры:


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