Предыстория
Я работаю фронтенд разработчиком уже на протяжение одного года. На моём первом проекте был «вражеский» бэкенд. Бывает так, что это не составляет больших проблем, когда налажена коммуникация.
Но в нашем случае было не так.
Мы разрабатывали код, который опирался на то, что бэкенд отправляет нам определённые данные, определённой структуры и определённого формата. В то время как бэкенд считал нормальным изменять содержимое ответов – без предупреждения. В итоге у нас тратились часы на то, чтобы определить почему определённая часть сайта перестала работать.
Мы осознали, что нам необходимо проверять то, что возвращает бэкенд, прежде чем полагаться на данные, которые он нам прислал. Мы создали таску на исследования вопроса валидации данных со стороны фронтенда.
Это исследование было поручено мне.
Примерно составил список того, что хочу, чтобы было в том инструменте, который хотел бы использовать для валидации данных.
Самыми важными пунктами отбора были следующие пункты:
- декларативное описание(схема) валидации, которая трансформируется в функцию-валидатор, которая возвращает true/false (валидно, не валидно)
- низкий порог входа;
- схожесть валидируемых данных с описанием валидации;
- легкость интеграции кастомных валидаций;
- легкость интеграции кастомных сообщений об ошибках.
Как результат, нашел множество библиотек валидации, рассмотрев ТОП-5(ajv,joi, roi…). Все они очень хороши. Но мне показалось, что ради решения 5% сложных случаев – они обрекали 95% самых частых случаев быть довольно многословными и громоздкими.
Поэтому подумал: почему бы самому не разработать что-то, что меня бы устроило.
Четыре месяца спустя, вышла седьмая версия моей библиотеки валидации quartet.
Это была стабильная версия, полностью оттестированная, 11к скачиваний на npm. Мы использовали её на трёх проектах в кампании на протяжение трёх месяцев.
Эти три месяца сыграли очень полезную роль. quartet продемонстрировал все свои преимущества. Проблем с данными от бэкенда не осталось. Каждый раз, когда они меняли ответ – у нас это сразу же кидало ошибку. Время нахождение причин багов сократилось разительно. Багов с данными практически не осталось.
Но также были выявлены и недостатки.
Поэтому принял решение проанализировать их и выпустить новую версию с исправлениями всех ошибок, которые были допущены во время разработки.
Об этих архитектурных ошибках и их решениях расскажу далее.
Архитектурные грабли
«Строко»-типизированный язык схемы
Приведу пример старого варианта схемы, для объекта персоны.
const personSchema = {
name: 'string',
age: 'number',
linkedin: ['string', 'null']
}
Эта схема валидирует объект с тремя свойствами: имя – должно быть строкой, возраст – должен быть числом, ссылка на акканут в LinkedIn – должен либо быть null (если аккаунта нет) либо строкой(если аккаунт есть).
Эта схема отвечает моим требованиям по читаемости, схожестью с валидируемыми данными и думаю порог входа к обучению написанию таких схем – не высок. Более того, такую схему можно легко написать имея определение типа на typescript:
type Person = {
name: string
age: number
linkedin: string | null
}
(Как видим – изменения скорее косметические)
Когда принимал решение, что должно использоваться для наиболее частых вариантов валидаций(например используемых выше). Выбрал использование – строк, как-бы имён валидаторов.
Но проблема строк в том, что они недоступны для компилятора или анализатора ошибок. Строка ‘number’ для них не сильно отличается от ‘numder’.
Решение
В новой версии quartet 8.0.0. Решил убрать из квартета – использование строк как наименований валидаторов внутри схемы.
Схема выглядит теперь так:
const personSchema = {
name: v.string
age: v.number,
linkedin: [v.string, null]
}
Такое изменение имеет два больших преимуществ:
- компиляторы или анализаторы ошибок – смогут обнаружить то, что имя метода написано с ошибкой.
- Строки – больше используются как элемент схемы. Это значит, что для них можно выделить новую функционал в библотеке, который будет описан ниже.
Поддержка TypeScript
В общем-то первые семь версий разрабатывались на чистом Javаscript. При переходе на проект с Typescript возникла необходимость как-то приспособить библиотеку для него. Поэтому были написаны декларации типов для библиотеки.
Но в этом был минус – при добавлении функционала, или при изменении каких-то элементов библиотеки всегда было легко забыть обновить объявления типов.
Также были просто незначительные неудобства такого рода:
const checkPerson = v(personSchema) // (0)
// ...
const person: any = await axios.get('https://myapi.com/person/42')
if (!checkPerson(person)) {// (1)
throw new TypeError('Invalid person response')
}
console.log(person.name) // (2)
Когда мы создали валидатор объекта на строке (0). Мы хотели бы, чтобы после проверки реального ответа с бэкенда на строке (1) и обработки ошибки. На строке (2) чтобы person
имела тип Person. Но так не происходило. К сожалению такая проверка – не была type guard’ом.
Решение
Принял решение переписать всю библиотеку quartet на Typescript, чтобы проверкой соответствия библиотеки типам – занимался компилятор. Попутно добавим к функции, которая возвращает скомпилированный валидатор – параметр типа, который бы определял type guard’ом какого типа является этот валидатор.
Пример выглядит так:
const checkPerson = v<Person>(personSchema) // (0)
// ...
const person: any = await axios.get('https://myapi.com/person/42')
if (!checkPerson(person)) {// (1)
throw new TypeError('Invalid person response')
}
console.log(person.name) // (2)
Теперь на строке (2) person
имеет тип Person
.
Читаемость
Также было два случая, где код плохо читался: проверка на соответствие определённому множеству значений(проверка enum’ов) и проверка остальных свойств объекта.
a) Проверка enum’ов
Изначально была идея, на мой взгляд хорошая. Продемонстрируем её добавлением поля «пол» к нашему объекту.
Старый вариант схемы выглядел так:
const personSchema = {
name: 'string',
age: 'number',
linkedin: ['null', 'string'],
sex: v.enum('male', 'female')
}
Вариант очень читаемый. Но как обычно бывает, всё пошло немного не по плану.
Имея при себе объявленный enum в программе, например такой:
enum Sex {
Male = 'male',
Female = 'female'
}
Естественно хочется использовать его внутри схемы. Чтобы при изменении какого-то из значений (Например ‘male’ -> ‘m’, ‘female’ -> ‘f’) изменилась и схема валидации.
Поэтому почти всегда валидация enum’ов записывалась так:
const personSchema = {
name: 'string',
age: 'number',
linkedin: ['null', 'string'],
sex: v.enum(...Object.values(Sex))
}
Что выглядит довольно громоздко.
b) Валидация остаточных свойств объекта
Предположим к нашему объекту мы прибавим ещё такую характеристику – у него могут быть дополнительные поля, но все они должны быть ссылками на соцсети – значит должны быть равны либо null
либо быть строкой.
Старая схема выглядела бы так:
const personSchema = {
name: 'string',
age: 'number',
linkedin: ['null', 'string'],
sex: v.enum(...Object.values(Sex)),
...v.rest(['null', 'string']) // Rest props are string | null
}
Данная запись выделяла остальные свойства – от уже перечисленных. Использования spread-оператора – скорее путает человека, который хочет понять эту схему.
Решение
Так как было описано выше – строки перестали быть частью схем валидации. Схемой валидации остались только три вида значений Javascript. Объект – для описания схемы валидации объекта. Массив для описания – нескольких вариантов валидности. Функция(сгенерированная библиотекой или кастомная) – для всех остальных вариантов валидации.
Данное положение позволило добавить функционал, который позволил в разы увеличить читаемость схемы.
В самом деле, что если мы хотим сравнить значение со строкой ‘male’. Неужели нам нужно знать что-то ещё кроме самого значения и строки ‘male’.
Поэтому было принято решение – добавить значения примитивных типов – как элемент схемы. Стало быть там, где вы встречаете примитивное значение в схеме, это значит, что это то валидное значение, которое должен проверить валидатор созданный по данной схеме. Лучше приведу пример:
Если нам нужно проверить число на равенство 42-ум. То мы запишем это так:
const check42 = v(42)
check42(42) // => true
check42(41) // => false
check42(43) // => false
check42('42') // => false
Посмотрим как это отразится на схеме person(без учёта дополнительных свойств):
const personSchema = {
name: v.string,
age: v.number,
linkedin: [null, v.string], // null is primitive value
sex: ['male', 'female'] // 'male', 'female' are primitive values
}
С использованием заранее определённых еnum’ов мы можем переписать так:
const personSchema = {
name: v.string,
age: v.number,
linkedin: [null, v.string],
sex: Object.values(Sex) // same as ['male', 'female']
}
В данном случае убралась лишняя церемониальность в виде использования метода enum и использования spread-оператора для вставки валидных значений из объекта как параметров в этот метод.
Что считается примитивным значением: числа, строки, символы, true
, false
, null
и undefined
.
То есть если нам нужно сравнить значение с ними – мы просто используем сами эти значения. И библиотека валидации – создаст валидатор, который строго сравнит значение с указанными в схеме.
Для валидации остаточных свойств было выбрано использовать особое свойство для всех остальных полей объекта:
const personSchema = {
name: v.string,
age: v.number,
linkedin: [null, v.string],
sex: Object.values(Sex),
[v.rest]: [null, v.string]
}
Таким образом схема выглядит более читаемой. И более похожей на объявления из Typescript.
Связанность валидатора с функцией, которая создала его
В старых версиях объяснения ошибок – не были частью валидатора. Они складывались в массив внутри функции v
.
Раньше, чтобы получить объяснения ошибок валидации необходимо было иметь при себе валидатор(чтобы провести проверку) и функцию v(чтобы получить объяснения невалидности). Всё это выглядело следующим образом:
?
a) Мы добавляем объяснения в схему
const checkPerson = v({
name: v('string', 'wrong name')
age: v('number', 'wrong age'),
linkedin: v(['null', 'string'], 'wrong linkedin'),
sex: v(
v.enum(...Object.values(Sex)),
'wrong sex value'
),
...v.rest(
v(
['null', 'string'],
'wrong social networks link'
)
) // Rest props are string | null
})
К любому элементу схемы – можно добавить объяснение ошибки используя второй аргумент функции-компилятора v.
b) Очистить массив объяснений
Перед валидацией нужно было очищать этот глобальный массив в который записывались все объяснения во время валидации.
v.clearContext() // same as v.explanations = []
c) Провести валидацию
const isPersonValid = checkPerson(person)
Во время этой проверки, если невалидность была обнаружена, и на этапе создания схемы – ей была указано объяснение, то это объяснение помещается в глобальный массив v.explanation
.
d) Обработка ошибки
if (!isPersonValid) {
throw new TypeError('Invalid person response: ' + v.explanation.join('; '))
} // ex. Throws ‘Invalid person response: wrong name; wrong age’
Как видим здесь есть большая проблема. Потому что если мы захотим использовать валидатор не в месте его создания. Нам необходимо будет передавать в параметры не только его, но и функцию, которая его создало. Потому что именно в ней находится массив в который будут сложены объяснения.
Решение
Эта проблема была решена так: объяснения стали частью самой функции валидации. Что можно понять из его типа:
type Validator = (value: any, explanations?: any[]) => boolean
Теперь если вам необходимы объяснения ошибки – вы передаёте массив, в который вы хотите сложить объяснения.
Таким образом валидатор становится независимой единицей. Также добавлен метод, который может трансформировать функцию валидации в функцию, которая возвращает null, если значение валидно, и возвращает массив объяснений, если значение не валидно.
Теперь валидация с объяснениями выглядит так:
const checkPerson = v<Person>({
name: v(v.string, 'wrong name'),
age: v(v.number, 'wrong age'),
linkedin: v([null, v.string], 'wrong linkedin')
sex: v(Object.values(Sex), 'wrong sex')
[v.rest]: v([null, v.string], 'wrong social network')
})
// ...
const explanations = []
if (!checkPerson(person, explanation)) {
throw new TypeError('Wrong person: ' + explanations.join('; '))
}
// OR
const getExplanation = v.explain(checkPerson)
const explanations = getExplanation(person)
if (explanations) {
throw new TypeError('Wrong person: ' + explanations.join('; '))
}
Послесловие
Я выделил три предпосылки из-за которых пришлось всё переписывать:
- Надежда на то, что люди не ошибаются при написании строк
- Использование глобальных переменных (в данном случае массива v.explanation)
- Проверка на маленьких примерах во время разработки – не показала проблем, которые возникают при использовании в реальных больших случаях.
Но я рад, что провел анализ этих проблем, и выпущенная версия – уже используется в нашем проекте. И надеюсь она нам будет полезна не меньше чем предыдущая.
Всем спасибо за прочтение, надеюсь мой опыт будет вам полезен.
Комментарии (4)
DmitryKoterov
26.05.2019 19:39Реквестую симбиоз с подходом вот из этой библиотеки: https://www.npmjs.com/package/ts-optchain
А именно, иметь не только валидаторы, но еще и возможность доступаться к свойствам через цепочку, которая может содержать undefined-ы. Чтобы не писать
obj.a && obj.a[0] && obj.a[0].b && obj.a[0].b.c || “default”
везде, а писать что-то типа obj.a[0].b.c(“default”) и obj.a[0].b.c() (бросает исключения с детальным описанием, что именно и где не хватало) и иметь это все TS-строготипизированно.
Еще было бы круто генерировать детальные отладочные сообщения при исключениях автоматически, чтобы в них включался исходный json, а не только имя поля (или путь к полю), где произошла ошибка. Чтобы не писать везде if-ы.
kila_vat Автор
27.05.2019 01:07Спассибо за комментарий и за предложения. Что-то на подобие
pathOr
изramda
.
На самом деле на практике никто не добавляет сообщения об ошибках в таком виде. Это просто была демонстрация, того, куда идут эти сообщения об ошибках после валидации(раньше в глобальный объект, а теперь в локальный переданный через параметр). Просто данный способ(для каждой валидации своё объяснения) легче всего понять, поэтому именно он и был использован.
Данная статья концентрировалась на архитектурных ошибках и их исправлениях.
В самой библиотеке есть возможность определить объяснения для всех ошибок с помощью поля
defaultExplanation
в функции quartet(settings). Но данная функция была определена изначально, именно поэтому она не попала в статью про ошибки архитектуры.
Чтобы не писать объяснения для всех валидаций — можно написать одну функцию, которая будет применена ко всем ошибкам — и возвращает уникальное описание. В библиотеке предусмотрены два дефолтных конструктора валидаторов (full и obj).
import { obj as v } from 'quartet' // obj has object explanation of the errors by default const checkPerson = v({ name: v.string, brothers: v.arrayOf(v.string), sisters: v.arrayOf(v.string), age: v.number }) v.explain(checkPerson)({ name: 'Andrew', brothers: ['Bohdan', 'Taras', 'Yuri', 'Alex'], sisters: null, // invalid value age: 22 }) /* returns [ { value: null, schema: { type: 'ArrayOf', innerShema: { type: 'String' } }, parents: [ { key: 'sisters', parent: { name: 'Andrew', brothers: ['Bohdan', 'Taras', 'Yuri', 'Alex'], sisters: null, // invalid value age: 22 }, schema: { name: v.string, brothers: v.arrayOf(v.string), sisters: v.arrayOf(v.string), age: v.number } } ] } ] */
JustDont
Если б вы еще колхоз в именованиях убрали («JustValidator», «JustMethod» — really?), то вообще цены бы не было. Зависимостей нет, концепция простая — штука довольно хорошая у вас вышла.
kila_vat Автор
Да проблема нейминга в этих штуках очевидна. Проблема выражения той идеи что происходит «только проверка, без объяснения причин невалидности», а так как имя Валидатор — занято. Не смог придумать что-то более точное чем «только валидатор».
Спасибо большое за коментарий, и ваш ник прям как никогда подходит к содержанию комментария.
Постараюсь придумать названия получше