Pull to refresh

Как строить и построить

Reading time 11 min
Views 3.5K

Предыстория


Встретившись в многочисленных местах разработки на Javascript с ситуациями, где необходимо было проводить валидацию значений, стало понятно, что необходимо как-то решить этот вопрос. С этой целью была поставлена следующая задача:
Разработать библиотеку, которая будет давать возможность:


  • валидировать типы данных;
  • задавать дефолтные значения вместо невалидных полей или элементов;
  • удалять невалидные части объекта или массива;
  • получать сообщение об ошибке;

В основе которой будет:


  • Легкость в освоении
  • Читабельность получаемого кода.
  • Легкость модификации кода

Для достижения этих целей была разработана библиотека валидации quartet.


Основные кирпичи валидации


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


Валидатор


В основе библиотеки quartet — лежит понятие валидатора. Валидаторами в данной библиотеке являются функциями следующего вида


function validator(
  value: any,
  { key: string|int, parent: any },
  { key: string|int, parent: any },
  ...
): boolean

В данном определении есть несколько вещей, которые стоит описать подробнее:


function(...): boolean — говорит о том, что валидатор — вычисляет результат валидации, и результатом валидации является булевое значение — истинно или ложно, соответственно валидно или не валидно


value: any — говорит о том, что валидатор — вычисляет результат валидации значения, которое может быть любым значением javascript'a. Валидатор либо относит данное валидируемое значение к валидным или либо к невалидным.


{ key: string|int, parent: any }, ... — говорит о том, валидируемое значение может быть в разных контекстах в зависимости от того, на каком уровне вложенности находится значение. Покажем это на примерах


Пример значения без какого-либо контекста


const value = 4; 
// Это значение не находится в контексте другой структуры данных.
// Чтобы валидатор его провалидировал он вызывается на самом значении:
const isValueValid = validator(4)

Пример значения в контексте массива


//   ключи   0  1  2  3      4
const arr = [1, 2, 3, value, 5] 
// В массиве данное значение находится под индексом(kеу): 3
// Родителем в данном контексте является массив: [1, 2, 3, value, 5] 
// Поэтому при валидации value - валидатор вызывается с такими параметрами
const isValueValid = validator(4, { key: 3, parent: [1,2,3,4,5] })

Пример значения в контексте объекта


const obj = {
  a: 1,
  b: 2,
  c: value,
  d: 8
}
// В данном обьекте значение имеет ключ равный 'c'
// Родителем в данном контексте является весь объект: { a: 1, b: 2, c: 4, d: 8 }
// Поэтому при валидации value - валидатор вызывается
// с такими параметрами:
const isValueValid = validator(4, { key: 'c', parent: { a: 1, b: 2, c: 4, d: 8 } })

Так как структуры в объекте могут иметь бо́льшую вложенность, то имеет смысл говорить и о множестве контекстов


const arrOfObj = [{
  a: 1,
  b: 2,
  c: value,
  d: 8
},
// ...
]
// В данном cлуче значение имеет ключ равный 'c'
// Первым родителем является объект: { a: 1, b: 2, c: 4, d: 8 }
// В свою очередь родителем родителя является массив arrOfObj,
// в котором объект находится под индексом 0.
// Поэтому при валидации value - валидатор вызывается с такими параметрами
const isValueValid = validator(
  4,
  { key: 'c', parent: { a: 1, b: 2, c: 4, d: 8 } }
  { key: 0, parent: [{ a: 1, b: 2, c: 4, d: 8 }] }
)

И так далее.


О сходстве с методами массивов

Данное определение валидатора должно вам напомнить определение функций, которые передаются в качестве аргумента в методы массивов, такие как: map, filter, some, every и тд.


  • Первым аргументом этих функций является элемент массива
  • Вторым аргументом — индекс элемента
  • Третьим аргументом — сам массив

Валидатор в данном случае является более обобщённой функцией — он принимает не только индекс элемента в массиве и массив, но и индекс массива — в его родителе и его родителя и так далее.


Что нам стоит дом построить?


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


Как построить небоскрёб валидации объектов?


Согласитесь, было бы удобно валидировать объекты таким образом, чтобы само описание валидации совпадало с описанием объекта. Для этого мы будем использовать объектную композицию валидаторов. Она выглядит следующим образом:


// Подключаем библиотеку валидации quartet
const quartet = require('quartet')
// Создаём композитор валидаторов (v - значит validator)
const v = quartet()
// Опишем схему объекта в контексте валидаторов,
// используемых для конкретных полей
const objectSchema = {
  a: a => typeof a ==='string', // Валидатор типа 'string'
  b: b => typeof b === 'number', // Валидатор типа 'number'
 // ...
}
const compositeObjValidator = v(objectSchema)
const obj = {
  a: 'some text',
  b: 2
}
const isObjValid = compositeObjValidator(obj)
console.log(isObjValid) // => true

Как видим, из разных кирпичей-валидаторов, определённых для конкретных полей, мы можем собрать валидатор объекта — некоторое "маленькое здание", в котором ещё довольно тесно — но уже лучше чем без него. Для этого мы используем композитор валидаторов v. Всякий раз, встречая объектный литерал v на месте валидатора, он будет рассматривать его как объектную композицию, превращая его в валидатор объекта по его полям.


Иногда мы не можем описать все поля. Например, когда объект является словарём данных:


const quartet = require('quartet')
const v = quartet()

const isStringValidator = name => typeof name === 'string'

const keyValueValidator = (value, { key }) =>
  value.length === 1 && key.length === 1

const dictionarySchema= {
  dictionaryName: isStringValidator,
  ...v.rest(keyValueValidator)
}

const compositeObjValidator = v(dictionarySchema)

const obj = {
  dictionaryName: 'next letter',
  b: 'c',
  c: 'd'
}

const isObjValid = compositeObjValidator(obj)
console.log(isObjValid) // => true

const obj2 = {
  dictionaryName: 'next letter',
  b: 'a',
  a: 'invalid value',
  notValidKey: 'a'
}
const isObj2Valid = compositeObjValidator(obj2)
console.log(isObj2Valid) // => false

Как переиспользовать строительные решения?


Как мы увидели выше, существует необходимость переиспользования простых валидаторов. В данных примерах нам уже пришлось использовать "валидатор типа строки" уже два раза.


Для того, чтобы укоротить запись и повысить её читаемость в библиотеке quartet используются строковые синонимы валидаторов. Всякий раз, когда композитор валидаторов встречает строку на месте, где должен быть валидатор, он ищет в словаре соответствующий ей валидатор и использует его.


По умолчанию в библиотеке уже определены самые распространённые валидаторы.


Рассмотрим примеры:


v('number')(1) // => true
v('number')('1') // => false
v('string')('1') // => true
v('string')(null) // => false
v('null')(null) // => true
v('object')(null) // => true
v('object!')(null) // => false
// ...

и множество других описаных в документации.


Каждой арке — свой вид кирпичей?


Композитор валидаторов(функция v) также является фабрикой валидаторов. В том смысле, что содержит множество полезных методов, которые возвращают


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

Например, посмотрим на валидацию массива: чаще всего она состоит из проверки типа массива и проверки всех его элементов. Воспользуемся для этого методом v.arrayOf(elementValidator). Для примера возьмём массив точек с именами.


  const a = [
    {x: 1, y: 1, name: 'A'},
    {x: 2, y: 1, name: 'B'},
    {x: -1, y: 2, name: 'C'},
    {x: 1, y: 3, name: 'D'},
  ]

Так как массив точек — это массив объектов, то имеет смысл использовать объектную композицию для валидации элементов массива.


const namedPointSchema = {
  x: 'number', // number - один из именованных по умолчанию валидаторов
  y: 'number',
  name: 'string' // string - один из именованных по умолчанию валидаторов
}

Теперь, с помощью фабричного метода v.arrayOf, создадим валидатор всего массива.


const isArrayValid = v.arrayOf({
  x: 'number',
  y: 'number',
  name: 'string'
})

Посмотрим как работает данный валидатор:


isArrayValid(0) // => false
isArrayValid(null) // => false
isArrayValid([]) // => true
isArrayValid([1, 2, 3]) // => false
isArrayValid([
    {x: 1, y: 1, name: 'A'},
    {x: 2, y: 1, name: 'B'},
    {x: -1, y: 2, name: 'C'},
    {x: 1, y: 3, name: 'D'},
  ]) // => true

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


Как вы видели выше, v.rest также является фабричным методом, который возвращает объектную композицию, которая проверяет все поля не указанные в объектной композиции. А значит, может быть встроен в другую объектную композицию с помощью spread-operator.


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


// Подключаем библиотеку валидации quartet
const quartet = require('quartet')
// Создаём композитор валидаторов (v - значит validator)
const v = quartet()
// Рассмотрим такой объект, который описывает персонажа
const max = {
  name: 'Maxim',
  sex: 'male',
  age: 34,
  status: 'grandpa',
  friends: [
    { name: 'Dima', friendDuration: '1 year'},
    { name: 'Semen', friendDuration: '3 months'}
  ],
  workExperience: 2
}
// имя валидно, когда является "и" строкой,
// "и" не пустой, "и" первая буква - большая
const nameSchema = v.and(
  'not-empty', 'string', // именованные валидаторы
  name => name[0].toUpperCase() === name[0] // валидатор-функция
)
const maxSchema  = {
   name: nameSchema,
  // Валидатор принадлежности к задданному множеству значений
  sex: v.enum('male', 'female'),
  // Возраст - положительное целое число.
  // Используем именнованые валидаторы и фабричный метод "и"
  age: v.and('non-negative', 'safe-integer'),
  status: v.enum('grandpa', 'non-grandpa'),
  friends: v.arrayOf({
    name: nameSchema,
    // валидируем строку по регулярному выражению
    friendDuration: v.regex(/^[1-9]\d? (years?|months?)$/)
  }),
  workExperience: v.and('non-negative', 'safe-integer')
}
console.log(v(maxSchema)(max)) // => true

Быть, или не быть?


Часто бывает так, что валидные данные принимают различные формы, например:


  • id может быть числом, а может быть строкой.
  • Объект point может содержать, а может не содержать некоторые координаты, в зависимости от размерности.
  • И множество других случаев.

Для организации валидации вариантов предусмотрен отдельный вид композиции — вариантная композиция. Она представляется массивом валидаторов возможных вариантов. Валидным считается объект, когда хоть один из валидаторов сообщил о его валидности.


Рассмотрим пример c валидацией идентификаторов:


  const isValidId = v([
   v.and('not-empty', 'string'), // Идентификатор может быть либо непустой строкой
   v.and('positive', 'safe-integer') // Либо положительным числом
 ])

 isValidId('') // => false
 isValidId('asdba32bas321ab321adb321abds546ba98s7') // => true
 isValidId(0) // => false
 isValidId(1) // => true
 isValidId(1123124) // => true

Пример с валидацией точек:


const isPointValid = v([

  {
   // для первой размерности - должна быть только x координата
   dimension: v.enum(1),
   x: 'number',
   // v.rest с функцией возвращающей false
   // Означает, что дополнительные поля - невалидны
   ...v.rest(() => false)
 },
 // для второй - х и у
 {
   dimension: v.enum(2),
   x: 'number',
   y: 'number',
   ...v.rest(() => false)
 },
 // Для третьей - x, y и z
 {
   dimension: v.enum(3),
   x: 'number',
   y: 'number',
   z: 'number',
   ...v.rest(() => false)
 },
])
// Итого, валидной точкой считается та, у которой размерность не выше третьей, и для каждой размерности - соответствующее кол-во полей для координат
isPointValid(1) // => false
isPointValid(null) // => false
isPointValid({
  dimension: 1,
  x: 2
}) // => true
isPointValid({
  dimension: 1,
  x: 2,
  y: 3 // лишнее поле
}) // => false
isPointValid({
  dimension: 2,
  x: 2,
  y: 3
}) // => true
isPointValid({
  dimension: 3,
  x: 2,
  y: 3,
  z: 4
}) // => true
// ...

Таким образом всякий раз, когда композитор видит массив, он будет считать его композицией валидаторов-элементов этого массива таким образом, что когда один из них посчитает значение валидным — расчёт валидации остановится — и значение будет признано валидным.


Как видим композитор считает валидатором не только функцию валидатор, но и всё, что может привести к функции валидатору.


Тип валидатора Пример Как воспринимается композитором
функция валидации x => typeof x === 'bigint' просто вызывается на необходимых значениях
объектная композиция { a: 'number' } создает функцию валидатор для объекта на основании заданных валидаторов полей
Вариантная композиция ['number', 'string'] Создаёт функцию валидатор для валидации значения минимум одним из вариантов
Результаты вызова фабричных методов v.enum('male', 'female') Большинство фабричных методов возвращают функции валидации (за исключением v.rest, который возвращает объектную композицию), поэтому они трактуются как обычные функции валидации

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


В итоге схема работы всегда такая: v(schema) возвращает функцию валидации. Далее эта функция валидации вызывается на конкретных значениях:
v(schema)(value[, ...parents])


У вас аварии на стройке были?


— Нет пока ещё не одной
— Будут!


Бывает так, что данные невалидны и нам нужно уметь определить причину невалидности.


Для этого в библиотеке quartet предусмотрен механизм объяснений. Он состоит в том, что в случае, когда валидатор, будь-то внутренний или внешний, обнаружит невалидность проверяемых данных — он должен отправить пояснительную записку.


Для этих целей используется второй аргумент композитора валидаторов v. Он добавляет сайд-еффект отправки пояснительной записки в массив v.explanation в случае невалидности данных.


Пример, пусть мы валидируем массив, и хотим узнать номера всех элементов, которые невалидны и их значение:


  // Данная функция - будет вызвана при невалидности
 // элемента массива
 const getExplanation = (value, { key: index }) => ({
   invalidValue: value,
   index
 })
 // Видим, что её параметры совпадают с параметрами валидаторов.
 // Результат же этой функции будет помещён в массив v.explanation

 // Зададим валидатор массива
const arrValidator = v.arrayOf(
  v(
    'number', // валидатор числа
    getExplanation // функция возвращающая "записку", или сама "записка"
  )
)
// видим, что валидатором элемента является "объясняющий" валидатор
// Вторым параметром композитора является функция, которая возвращает объяснение ошибки

// Вторым параметром композитора может быть не только функция
// Но и значение, которое должно быть помещено как объяснение
const explainableArrValidator = v(arrValidator, 'this array is not valid')
const arr = [1, 2, 3, 4, '5', '6', 7, '8']
explainableArrValidator(arr) // => false
v.explanation
// [
//  { invalidValue: '5', index: 4 },
//  { invalidValue: '6', index: 5 },
//  { invalidValue: '8', index: 7 },
//  'this array is not valid'
// ]

Как видим, выбор объяснения зависит от задачи. Иногда оно даже не нужно.


Иногда нам необходимо что-то сделать с невалидными полями. В таких случаях имеет смысл использовать имя невалидного поля как объяснение:


const objSchema = {
  a: v('number', 'a'),
  b: v('number', 'b'),
  c: v('string', 'c')
}
const isObjValid = v(objSchema)
let invalidObj = {
 a: 1,
 b: '1',
 c: 3
}
isObjValid(invalidObj) // => false
v.explanation // ['b', 'c']
// Сообщаем о невалидных полях
console.error(`${v.explanation.join(', ')} is not valid`) // => b, c is not valid
// Удаляем невалидные и не проверенные поля (см. документацию)
invalidObj = v.omitInvalidProps(objSchema)(invalidObj)
console.log(invalidObj) // => { a: 1 }

Имея данный механизм объяснений, можно реализовать любое поведение, связанное с результатами валидации.


Пояснением может быть всё что угодно:


  • объект содержащий необходимую информацию;
  • функция, которая исправляет ошибку. (getExplanation => function(invalid): valid);
  • имя невалидного поля, или индекс невалидного элемента;
  • код ошибки;
  • и всё на что хватит вашей фантазии.

Что делать, когда дело не строится?


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


  • v.default(validator, value) — возвращает валидатор, который запоминает невалидное значение, и в момент вызова v.fix — устанавливает дефолтное значение
  • v.filter(validator) — возвращает валидатор, который запоминает невалидное значение, и в момент вызова v.fix — удаляет это значение из родителя
  • v.addFix(validator, fixFunc) — возвращает валидатор, который запоминает невалидное значение, и в момент вызова v.fix — вызывает fixFunc c параметрами (value, { key, parent }, ...). fixFunc — должна муттировать одного из парентов — для изменения значения

const toPositive = (negativeValue, { key, parent }) => {
  parent[key] = -negativeValue
}

const objSchema = {
  a: v.default('number', 1),
  b: v.filter('string', ''),
  c: v.default('array', []),
  d: v.default('number', invalidValue => Number(invalidValue)), // привести к числу
  pos: v.and(
     v.default('number', 0), // Если значение не число - установить 0
     v.addFix('non-negative', toPositive) // если значение не положительно - поменять знак
  )
 }
 const invalidObj = {
  a: 1,
  b: 2,
  c: 3,
  d: '4',
  pos: -3
 }
v.resetExplanation() // или синоним v()
v(objSchema)(invalidObj) // => false
 // v.hasFixes() => true
 const validObj = v.fix(invalidObj)
 console.log(validObj) // => { a: 1, b: '', c: [], d: 4 }

По хозяйству ещё пригодиться


В данной библиотеке также существуют утилитные методы для действий связанных с валидацией:


Метод Результат
v.throwError В случае невалидности бросает TypeError с заданным сообщением.
v.omitInvalidItems Возвращает новый массив(или объект-словарь) без невалидных элементов(полей).
v.omitInvalidProps Возвращает новый объект без невалидных полей, по заданному объектному валидатору.
v.validOr Возвращает значение, если оно валидно, иначе заменяет его на заданное дефолтное значение.
v.example Проверяет, подходят ли к схеме данные значения. Если не подходят, бросается ошибка. Служит документацией и тестированием схемы

Результаты


Заданные задачи были решены следующими способами:


Задача Решение
Валидация типов данных Дефолтные именованные валидаторы.
Дефолтные значения v.default
Удаление невалидных частей v.filter, v.omitInvalidItems и v.omitInvalidProps.
Легкость в освоении Простые валидаторы, простые способы композиции их в сложные валидаторы.
Читабельность кода Одной из целей библиотеки была уподобить схемы валидаций самим
валидируемым объектам.
Легкость модификации Освоив элементы композиций и используя собственные функции валидации — менять код довольно просто.
Сообщение об ошибке Пояснение, в виде сообщения об ошибки. Или расчёт кода ошибки на основе пояснений.

Послесловие


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

Tags:
Hubs:
+6
Comments 6
Comments Comments 6

Articles