В своем предыдущем посте я описал основные моменты при разработке другой opensource библиотеки. Забыл упомянуть еще один: если никому не рассказывать про библиотеку, какая бы нужная она ни была, скорее всего никто про нее так и не узнает.
Итак, встречайте trava.js — сочная валидация на пользу проекту. К слову траву мы используем уже больше полугода, и я подумал, пришло время рассказать вам о преимуществах ее использования. Уже даже подсушили, так что задержите дыхание. И вперед.
Концепция
С первого взгляда кажется что валидация — тема тривиальная, не требующая к себе отдельного внимания. Значение либо верно, либо нет, что может быть проще:
function validate (value) {
// any checking...
if (!check(value)) return false;
return true;
}
Но обычно было бы неплохо знать что именно пошло не так:
function validate (value) {
if (!check1(value)) return 'ERROR_1';
if (!check2(value)) return 'ERROR_2';
}
Собственно и все, задача решена.
Если бы не одно «но».
По опыту разработки реальных приложений было замечено, что на валидации дело не заканчивается. Обычно эти данные также нужно преобразовать к определенному формату, по каким-либо причинам не поддерживаемым сериализатором из коробки, например, даты, множества или другие пользовательские типы данных. Учитывая, что это в основном JSON, на практике получается, что обход входной структуры данных приходится делать дважды при валидации и при трансформации. Возникла идея, почему бы не объединить эти два этапа в один. Возможным плюсом будет также наличие явной декларативной схемы данных.
Для поддержки приведения значения к определенному формату валидатор должен уметь возвращать не только ошибку, но и приведенное значение. В мире js достаточно распространены несколько вариантов интерфейсов при возможном возвращении ошибок.
- Наверно самый распространенный, это возврат кортежа [error, data]:
function validate (value) { if (!check1(value)) return ['ERROR_1']; if (!check2(value)) return ['ERROR_2']; return [null, value]; }
Также есть похожий вариант где возвращается не массив, а объект { error, data }, но принципиально различий нет. Плюсом такого подхода является очевидность, минусом — то, что теперь везде нужно поддерживать этот контракт. Для валидации это не вызывает неудобств, но для трансформаций это явно лишнее.
- Использовать исключения. Хотя на мой взгляд ошибка валидации — стандартная ситуация в приложении, ничего исключительного. Честно говоря я считаю, что исключения лучше использовать только там, где действительно что-то пошло не так. Также исключения могут случайно вызываться в самих валидаторах, и тогда можно вообще не узнать, что это была ошибка в коде, а не в значении. Плюсом подхода является упрощение интерфейса — теперь всегда значение возвращается обычным образом, а ошибка выбрасывается как исключение.
- Есть вариант положить ошибку в глобальную переменную. Но я бы не стал тянуть состояние без необходимости.
- Использовать отдельный тип для ошибок. Похоже на вариант с исключениями, если взять от них тип ошибки, но не выбрасывать.
function validate (value) { if (!check1(value)) return new Trava.ValidationError({ code: 401 }); if (!check2(value)) return new Trava.ValidationError({ code: 405 }); return parseOrTransform(value); // apply some parse or transform }
Подводя итог, можно сказать, что валидатор — это чистая, синхронная функция, которая помимо значения может вернуть ошибку. Предельно просто. И эта теория и без библиотек неплохо работает. На практике валидаторы объединяют в цепочки и иерархии, и здесь трава точно пригодится.
Композиция
Пожалуй композиция — самый распространенный случай работы с валидаторами. Реализация композиции может быть различной. Например в известных библиотеках joi и v8n это сделано через объект и цепочку методов:
Joi.string().alphanum().min(0).max(255)
Хотя с первого взгляда выглядит красиво, но у такого подхода есть несколько недостатков, и один фатальный. И вот в чем дело. По моему опыту валидатор — это штука всегда заточенная под конкретное приложение, поэтому основной упор в библиотеке должен быть направлен на удобство расширения валидаторов и интеграции с существующим подходом, а не на количество базовых примитивов, которые, на мой взгляд, только добавляют веса библиотеке, но в большинстве своем не будут использованы. Взяли к примеру тот же валидатор для строки. Потом оказывается, что надо обрезать пробелы с концов, затем вдруг надо в одном единственном случае допускать использование специальных символов, а где-то нужно приводить к нижнему регистру и т.д. На самом деле подобных примитивов может быть бесконечно много, и я просто не вижу смысла даже начинать добавлять их в библиотеку. На мой взгляд использование объектов также избыточно и приводит к увеличению сложности при расширении, хотя на первый взгляд кажется должны облегчить жизнь. Например c joi не так то просто написать свой валидатор.
Функциональный подход и трава здесь может помочь. Тот же пример валидации числа, заданного в интервале от 0 до 255:
// существующая функция проверки
const isNumber = n => typeof n == 'number' && !isNaN(n);
// валидаторы
const numberValidator = Trava.Check(isNumber);
const byteValidator = Trava.Compose([
numberValidator,
Trava.Check(n => 0 <= n && n < 256),
]);
byteValidator(-1); // ошибка!
Оператор Check делает из проверки на истинность (значение => true/false) валидатор. А Compose объединяет валидаторы в цепочку. При выполнении цепочка прерывается после первой ошибки. Важно то, что везде используются обычные функции, которые очень просто расширять и использовать. Именно эта простота расширения, на мой взгляд, является ключевой особенностью правильной библиотеки валидации.
Традиционно отдельное место в валидации занимает проверка на null и undefined. В траве для этого есть вспомогательные операторы:
// проверка на null и undefined
const requiredNumberValidator = Trava.Required(numberValidator);
requiredNumberValidator(undefined); // ошибка!
const optNumberValidator = Trava.Optional(numberValidator, 2); // 2 is default
optNumberValidator(undefined); // 2
optNumberValidator(null); // null
const nullNumberValidator = Trava.Nullable(numberValidator, 3); // 3 is default
nullNumberValidator(undefined); // 3
nullNumberValidator(null); // 3
В траве есть еще несколько вспомогательных операторов, и они все прекрасно композируются и удивительно просто расширяются. Как обыкновенные функции :)
Иерархия
Простые типы данных объединяются в иерархию. Наиболее частыми случаями являются объекты и массивы. В траве есть операторы, облегчающие работу с ними:
// валидация массива
const byteArrayValidator = Trava.Each(byteValidator);
byteArrayValidator([1, -1, 2, -3]);
// ValidationError: {"1":"Incorrect value","3":"Incorrect value"}
// валидация объекта
const pointValidator = Trava.Keys({
x: numberValidator,
y: numberValidator,
});
pointValidator({x: 1, y: 'a'}); // ValidationError: {"y":"Incorrect value"}
При валидировании объектов было решено сделать упор на строгость определения: все ключи по умолчанию являются обязательными (обернуты в Required). Не указанные в валидаторе ключи отбрасываются.
Некоторые решения jsonschema, quartet предпочитают описывать валидаторы в виде данных, например { x: 'number', y: 'number' }, но это приводит к тем же самым сложностям при расширении. Весомым плюсом такого подхода является возможность сериализации и обмена схемами, которая невозможна с функциями. Тем не менее это можно легко реализовать поверх функционального интерфейса. Не нужно скрывать функции за строками! У функций уже есть имена и это все что необходимо.
Для удобства использования внутри валидаторов операторы Compose и Keys могут быть опущены, также удобно заворачивать корневой валидатор в Trava:
const pointValidator = Trava({ // объект -> Keys
x: [numberValidator, Trava.Check(v => v > 180)], // массив -> Compose
y: [numberValidator, Trava.Check(v => v < 180)],
});
Если вызвать Trava со вторым аргументом, то возвращаемым значением будет результат применения валидатора:
const point = Trava({
x: [numberValidator, Trava.Check(v => v > 180)],
y: [numberValidator, Trava.Check(v => v < 180)],
},
// сразу передаем значение для валидации
{
x: 200,
y: 100,
}); // { x: 200, y: 100 }
Пока реализована поддержка только для массивов и объектов, т.к. в основном травим JSON и этого вполне достаточно. Пулл реквесты велкам!
Контекст
При использовании валидатора последним параметром можно передать контекст, который будет доступен из всех вызываемых валидаторов также последним параметром. Лично мне эта возможность еще не пригодилась, но можно.
Для некоторых валидаторов, которые могут вернуть ошибку, есть возможность определить сообщение об ошибке на разных уровнях. Пример:
const pos = Trava.Check(v => v > 0);
pos(-1); // ValidationError: "Incorrect value" (by default)
Переопределение для единственного случая:
const pos = Trava.Check(v => v > 0, "Значение должно быть больше нуля");
pos(-1); // ValidationError: "Значение должно быть больше нуля"
Переопределение для всех случаев:
Trava.Check.ErrorMessage = "Неверное значение";
pos(-1); // ValidationError: "Неверное значение"
Также для более детальной настройки можно на месте ошибки передать функцию, которая должна вернуть ошибку и будет вызвана с параметрами валидатора.
Вариант использования
В основном мы травим JSON на бэкенде вместе с koa. Фронтенд подсаживается тоже потихоньку. Удобно иметь общие валидаторы на обоих концах. А сейчас я покажу почти реальный пример использования. Допустим необходимо реализовать API для создания и обновления данных пациентов.
// validators.js
const trava = require('trava');
const { isFilledString, isDate, isNumber } = require('../common/validators');
const patientSchema = {
name: isFilledString,
dateOfBirth: isDate,
height: isNumber,
}
// используем общую схему для создания и редактирования
// но для создания поля обязательные
const patientNew = trava(patientSchema);
// а для редактирования делаем опциональными
const patientPatch = trava(mapValues(patientSchema, trava.Optional));
module.exports = {
patientNew,
patientPatch,
};
// controllers.js
const validate = require('./validators');
const { ValidationError } = require('../common/errors');
function create (ctx) {
const patientData = validate.patientNew(ctx.request.body);
// удобно что ошибка валидации наследуется от Error, в большинстве случаев можно обойтись без лишнего импорта и проверять по типу Error
if (patientData instanceof Error) return ValidationError(ctx, patientData);
// ...create new patient
}
function update (ctx) {
const patientData = validate.patientPatch(ctx.request.body);
if (patientData instanceof Error) return ValidationError(ctx, patientData);
// ...update patient data
}
function ValidationError (ctx, params) {
if (params instanceof Error) {
params = trava.ValidationError.extractData(params);
}
ctx.body = {
code: 'VALIDATION_ERROR',
params,
};
ctx.status = HttpStatus.BAD_REQUEST;
}
Несмотря на то, что пример очень простой, его нельзя назвать упрощенным. В реальном приложении только валидаторы будут усложнены. Также можно вынести валидацию в middleware — валидатор применяется целиком на контекст или на тело запроса.
В процессе работы и использования валидации мы пришли к тому, что вполне достаточно простых синхронных валидаторов и простых сообщений об ошибке. По факту мы пришли к тому, что используем всего два сообщения «REQUIRED» и «INVALID», которые локализуются на фронтенде вместе с подсказками к полям. Другие проверки, требующие дополнительных действий (например на регистрации проверить что такая почта уже существует), выносятся за рамки валидации. Во всяком случае трава не про этот случай.
В заключении
В этой небольшой статье я описал практически весь функционал библиотеки, за рамками статьи осталось несколько упрощающих жизнь хелперов. За подробностями прошу на github github.com/uNmAnNeR/travajs.
Нам был нужен инструмент, который можно максимально настраивать, в котором нет ничего лишнего, но при этом есть все необходимое для повседневной работы. И я думаю в целом этого удалось достичь, надеюсь кому-то тоже облегчит жизнь. Буду рад пожеланиям и предложениям.
На здоровье.
Комментарии (7)
AlexZaharow
02.08.2019 12:00У меня после прочтения остались вопросы:
1. Вся валидация работает только на клиенте?
2. Не понял, есть ли возможность задавать правила валидации только для одного параметра, или, например, для двух и более? Например, если у проверяемого объекта есть длина и ширина, то можно ли поставить правило валидации, что ширина должна быть не менее, чем в два раза меньше длины? И в догонку к этому вопросу, если неизвестно, какой именно из параметров нарушает условие, то их надо, вроде как оба отметить, что они не валидны.unma Автор
02.08.2019 12:461. Работает везде, где есть javascript.
2. В том то и дело, что стратегия обработки таких ошибок может быть разная. В случае с травой валидаторы все независимые, но есть варианты как можно передать параметры.
1) использовать контекст (последний параметр) — он будет передан во все валидаторы, т.е. можно извлечь любые данные.
2) можно вначале проверить данные отдельно друг от друга, а затем проверить объект целиком. например:
const pointV = Trava([ { width: isNumber, height: isNumber, }, Check((size, ...args) => size.width < size.height), // здесь ошибка вылетит на весь объект, последний элемент в args - контекст // или например кастомная ошибка на одно из полей (при желании может быть что угодно): Check(size => size.width < size.height, { height: 'должно быть больше width' }), ]); const point = { width: 1, height: 2 }; pointV(point, { point }); // передали значение и контекст если нужно
Я больше за 2 вариант наверно. В траве ошибки — любые js примитивы, а валидаторы — обычные функции, поэтому довольно просто сделать любую обработку.
SbWereWolf
При проверке лучше возвращать массив с ошибками, что бы вызывающий код мог проанализировать ошибки (коды ошибок? ) и решить надо бросать исключение или нет. И кроме того, удобно когда пользователь может узнать о всех ошибках сразу, а не так что исправил одну, а там следующая «появляется».
Спасибо за доклад по библиотеке.
unma Автор
Все так и есть, но не для Compose, а для Keys и Each — там естественно возвращаются ошибки по всем полям/индексам. Но Compose — это последовательная цепочка валидаторов. Например
Вторая проверка зависит от первой, поэтому имеет смысл прерываться именно после первой. Compose можно рассматривать как and (&&), а Some как or (||).
SbWereWolf
про Some в статье не слова :)
Вообще круто конечно.