Когда перед нами стоит выбор инструмента для валидации пользовательских данных, то речь чаще идет о интерфейсе задания правил. Сегодня таких инструментов превеликое множество от декларативных до объектных. Каждый валидатор пытается быть выразительным и простым в использовании. Но я хочу обратить ваше внимание на результат работы валидатора – отчеты. Каждый разработчик норовит сделать свое решение и если для интерфейсов от разнообразия лишь польза, то для получаемого результата наоборот. В общем, давайте взглянем на проблему.


Осторожно! После прочтения статьи вы, возможно, захотите выкинуть ваш любимый валидатор.

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


test.go:16:1: invalid character U+0023 '#'

Компилятор указывает место возникновения и причину ошибки. А теперь представьте, что компилятор заменит его на сообщение:


test.go: file should contain valid code

Как вам такое!? Почему мы ждем от инструмента подробного отчета, а пользователю возвращаем клочок информации. Чем исходный код отличается от значения логина "в глазах" программы?


Текущее положение дел


Вот список самых распространненных отчетов об ошибках:


  1. Валидатор возвращает строку, массив/объект строк.
  2. Валидатор возвращает true/false (npm validator).
  3. Валидатор выбрасывает исключение.
  4. Валидатор выводит отчет в консоль (npm prop-types).

Такие данные непригодны для дальнейшего использования, например для интернационализации или интерпретации, а значит и бесполезны. Как следствие, библиотеки не взаимозаменяемы, а компоненты системы завязываются на уникальное представление. Чтобы передать отчет на клиент, приходится писать собственные обертки.


Давайте попробуем это исправить и сформируем общие требования к представлению отчета.


Требования


Забегая вперед скажу, что этот вариант уже несколько лет успешно работает в продакшене.

Вот требования к отчету на которые опирался я:


  1. Удобная программная обработка: значения вместо сообщений.
  2. Представление объектов любой структуры: храним полные пути.
  3. Удобная интернационализация: использование ID для правил.
  4. Сохранение читаемости: использование человекопонятных кодов.
  5. Переносимость: отчет не привязан к среде исполнения или конкретному языку.

ValidationReport


Так появился ValidationReport – массив состоящий из объектов Issue. Каждый Issue – это объект, содержащий поля path, rule и details.


  • path – массив строк или чисел. Путь поля внутри объекта. Может быть пустым, в случае, если валидируемое значение – примитив.
  • rule – строка. Код ошибки.
  • details – объект. Объект произвольного вида, содержащий данные о причине ошибки.

JavaScript:


[
    {
        path: ['login'],
        rule: 'syntax',
        details: {
            pos: 1,
            expect: ['LETTER', 'NUMBER'],
            is: '$',
        },
    },
]

Go:


type Details map[string]interface{};

type Issue struct {
    Path []string `json:"path"`
    Rule string `json:"rule"`
    Details Details `json:"details"`
}

type Report []Issue;

//...
issue:= Issue{
    Path: []string{"login"},
    Rule: "syntax",
    Details: Details{
        "pos": 1,
        "expect": []string{"LETTER", "NUMBER"},
        "is": "$",
    },
}

report := Report{issue}

Такой отчет легко конвертируется в любое другое представление, он подробен и нагляден. Теперь вместо логин должен содержать цифры или буквы становится возможным вывести: Логин содержит недопустимый символ '$': позиция 1. При валидации вложенных структур, легко управлять путями.


Специфические коды ошибок могут быть представлены в виде URI.


Пример


В виде примера реализуем некоторые библиотечные функции, валидатор для логина и имплементацию на JavaScript в функциональном стиле. Готовый код на jsbin.


Библиотечные функции


Здесь будут реализованы два метода для создания Issue (createIssue) и для добавления префикса к значению Issue.path (pathRefixer):


function createIssue(path, rule, details = {}) {
    return {path, rule, details};
}

function pathPrefixer(...prefix) {
    return ({path, rule, details}) => ({
        path: [...prefix, ...path],
        rule,
        details,
    });
}

Валидатор логина


Собственно тот самый валидатор логина.


const LETTER = /[a-z]/;
const NUMBER = /[0-9]/;

function validCharsLength(login) {
    let i;
    for (i = 0; i < login.length; i++) {
        const char = login[i];

        if (i === 0) {
            if (! LETTER.test(char)) {
                break;
            }
        }
        else {
            if (! LETTER.test(char) && ! NUMBER.test(char)) {
                break;
            }
        }
    }

    return i;
}

function validateLogin(login) {
    const validLength = validCharsLength(login);

    if (validLength < login.length) {
        return [
            createIssue([], 'syntax', {
                pos: validLength,
                expect: validLength > 0 ? ['NUMBER', 'LETTER'] : ['LETTER'],
                is: login.slice(validLength, validLength + 1),
            }),
        ];
    }
    else {
        return [];
    }
}

function stringifySyntaxIssue({details}) {
  return `Invalid character "${details.is}" at postion ${details.pos}.`;
}

Имплементация


Реализация на уровне приложения. Добавляем функцию проверки модели и абстрактного запроса использующего модель:


function validateUser(user) {
    return validateSyntax(user.login)
    .map(pathPrefixer('login'));
}

function validateUsersRequest(request) {
    return request.users
    .reduce((reports, user, i) => {
        const report = validateUser(user)
        .map(pathPrefixer('users', i));

        return [...reports, ...report];
    }, []);
}

const usersRequest = {
    users: [
        {login: 'adm!n'},
        {login: 'u$er'},
        {login: '&@%#'},
    ],
};

function stringifyLoginSyntaxIssue(issue) {
    return `User #${issue.path[1]} login: ${stringifySyntaxIssue(issue)}`;
}

const report = validateUsersRequest(usersRequest);

const loginSyntaxIssues = report.filter(
  ({path, rule}) => path[2] === 'login' && rule === 'syntax'
);

console.log(report);
console.log(loginSyntaxIssues.map(stringifyLoginSyntaxIssue).join('\n'));

Заключение


Использование ValidationReport позволит комбинировать различные библиотеки для валидации и управлять процессом на свое усмотрение: например выполнить трудоемкие проверки параллельно, а затем конкатенировать результат. Отчеты от разных программ представляются однотипно и позволяют переиспользовать код их обработчиков.


Реализация


На сегодняшний день существует пакет для nodejs:


Комментарии (6)


  1. titulusdesiderio
    08.02.2018 08:52

    Простая валидация банально проще. Простите за тавтологию.
    Ваш ValidationReport клёвый, но не бесплатный. На его формирование нужно потратить много больше человекочасов чем на функцию с парой штук if (!someRule) return false.


    Кстати, никто не знает, случаем, валидатора с подобным цепочным синтаксисом:


    superValidator9000( string )
      .hasNumbers()
      .hasLetters()
      .hasUpperCase()
      .longerThan(5)

    С удовольствием использовал бы нечто подобное, особенно если бы оно умело генерировать ValidationReport


    1. rumkin Автор
      08.02.2018 10:33

      На его формирование нужно потратить много больше человекочасов чем на функцию с парой штук if (!someRule) return false.

      Это как сравнивать голый http.createServer и express. Использование if сохранит вам пару часов в простых случаях, но на реализации комплексных условий вы потратите больше времени.


      Кстати, никто не знает, случаем, валидатора с подобным цепочным синтаксисом:

      TypedProps как раз реализует подобный интерфейс и генерирует ValidationReport.


      1. titulusdesiderio
        08.02.2018 11:32

        Это как сравнивать голый http.createServer и express. Использование if сохранит вам пару часов в простых случаях, но на реализации комплексных условий вы потратите больше времени.

        ValidationReport никак не упрощает валидацию по комплексным условиям.


        1. rumkin Автор
          08.02.2018 11:37

          Задача VR не упрощать валидацию как таковую, это задача валидатора. VR упрощает создание отчетов, делая различные инструменты совместимыми между собой и как следствие взаимозаменяемыми.


    1. valichek
      08.02.2018 10:39

      это плагин к фреймворку, но именно об этом aurelia.io/docs/plugins/validation