Выпуск 5. Июль 2013

От редактора | Содержание | Введение в разработку web-приложений на PSGI/Plack. Часть 4. Асинхронность

LIVR (Language Independent Validation Rules) — независимые от языка правила валидации

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

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

Также, если вы занимаетесь веб-разработкой, то часто хочется иметь возможность описывать валидацию на сервере и на клиенте одинаковым способом.

В нашей компании мы в основном используем 3 языка для разработки веб-приложений: Perl, Javascript, PHP. И хотелось бы при переключении между проектами иметь возможность использовать одинаковый подход к валидации.

В связи с этим мы решили написать универсальный валидатор.

Требования к валидатору

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

  1. Правила валидации должны быть декларативными и не зависеть от языка програмирования, который используется. Под этим подразумевается, что правила не должны содержать код, ссылки на функции, вызовы методов. Правила должны быть просто структурой данных, которую можно передать по сети или сохранить на диске в виде файла.
  2. Для любого значения должна быть возможность описать любое количество правил валидации.
  3. При валидации данных должна быть возможность получить ошибки для всех невалидных значений. Это означает, что валидация не останавливается на первом невалидном значении, а всегда продолжает проверять оставшиеся значения. Например, если есть форма регистрации, то это позволит подсветить все поля с ошибками, а не только первое.
  4. Все данные, для которых не описаны правила валидации, должны быть исключены. Это вопрос безопасности. То есть, валидатор после валидации должен возращать очищенную структуру.
  5. Должна быть возможность описывать правила валидации не только для простых структур данных, но и для сложных иерархических.
  6. Описание правил должно быть понятным. Смотря на правила валидации, должно быть ясно, какая структура будет им соответствовать. То есть, правила могут выступать неким описанием формата/спецификацией данных.
  7. Правила должны возвращать понятные коды ошибок. Было решено не использовать сообщение об ошибках, а использовать строковые коды ошибок. Строковые коды предназначены для обработки кодом и понятны человеку более, чем числовые. Примеры, кодов ошибок: “REQUIRED”, “NOT_POSITIVE_INTGER”, “WRONG_EMAIL”.
  8. Расширяемость. Стандартного набора правил часто бывает недостаточно, и должна быть возможность создавать свои правила валидации. Для валидатора все правила должны быть равнозначными. Например, валидатор не делает различия между “required” (праверяет, что значение присутствует) и “nested_object”(описание валидации вложенного объекта).
  9. Универсальность. Валидатор должен быть универсальным и не быть завязан на валидациию только пользовательских форм. Должна быть возможность использовать его для валидации пользовательского ввода, для валидации конфигурационных файлов, для контрактного программирования. По сути, валидатор должен отвечать исключительно за валидацию данных.
  10. Поддержка Unicode.

Спецификация

Поскольку изначально валидатор задумывался как многоязычный инструмент (Perl, PHP, JS), то было решено начать со спецификации.

Спецификация должна:

  1. Описывать формат правил валидации и коды ошибок.
  2. Описывать набор правил, которые должны поддерживаться каждой реализацией.
  3. Иметь набор тест-кейсов для проверки реализации на соответствие спецификации.

Формат правил валидации

Мы какое-то время использовали Validate::Tiny. Это очень неплохой модуль и многие идеи по описанию мы взяли с него.

Вот так может выглядеть валидация регистрационной формы:

{
    name      => 'required',
    email     => ['required', 'email'],
    gender    => {one_of => [['male', 'female']]},
    phone     => {max_length => 10},
    password  => ['required',    {min_length => 10}],
    password2 => {equal_to_field => 'password'},
    address   => {
        nested_object => {
            city => 'required',
            zip  => ['required', 'positive_integer']
        }
    }
}

Как это интерпретировать? Описываются пары ключ-значение для каждого поля. Ключ — это имя поля, значения — это правила валидации: ПОЛЕ => ПРАВИЛО_ВАЛИДАЦИИ.

Правило валидации — это просто имя функции со списком аргументов. Общая структура правила такая: { ФУНКЦИЯ => [ АРГУМЕНТ1, АРГУМЕНТ2, … ] }.

Если только один аргумент, то можно использовать сокращенную запись — { ФУНКЦИЯ => АРГУМЕНТ1 }, если вообще нет аргументов, то можно просто написать имя функции — “ФУНКЦИЯ”.

Например,

{ required => [] }                 # required()
{ max_length => ['5'] }            # max_lenght(5)
{ max_length => '5' }              # max_lenght(5)
{ one_of => [['male', 'female']] } # one_of(['male', 'female'])

Также было решено, что только “required” должен проверять наличие значения. Остальные правила валидируют значение только если оно присутствует и не отвечает за проверку его присутствия.

Правила могут не только проверять значение, но и изменять его.

Возврат ошибок

Одним из требований к валидатору является возможность получать ошибки для всех значений. Также нужно учитывать, что валидатор может валидировать иерархические структуры. Было решено, что в случае возникновения ошибок мы получим структуру, аналогичную входящей, но вместо значений в ней будут находится коды ошибок (только для полей с ошибками).

Например для вышеописанной структуры при передаче

{
    name      => '',
    gender    => 'male',
    phone     => '1234567890123',
    password  => 'password12345',
    password2 => 'password12345',
    address   => {
        city => 'Kiev',
        zip  => 'FK12321'
    }
}

Мы получим следующую структуру, описывающую ошибку:

{
    name    => 'REQUIRED',
    email   => 'REQUIRED',
    phone   => 'TOO_LONG',
    address => {
        zip => 'NOT_POSITIVE_INTEGER'
    }
}

Стандартный набор правил

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

На данный момент стандартный (должны поддерживаться каждой реализацией) набор правил включает следующие:

  • Базовые правила
    • required
    • not_empty
  • Правила для валидации строк
    • one_of
    • max_length
    • min_length
    • length_between
    • length_equal
    • like
  • Правила для валидации чисел
    • integer
    • positive_integer
    • decimal
    • positive_decimal
    • max_number
    • min_number
    • number_between
  • Специальные правила
    • email
    • equal_to_field
  • Метаправила (для создания правил на базе других правил)
    • nested_object
    • list_of
    • list_of_objects
    • list_of_different_objects

Примеры и коды ошибок для всех правил описаны в LIVR-спецификации, остановимся только на метаправилах.

Метаправила (для создания правил на базе других правил)

Метаправила — это правила, которые позволяет скомбинировать простые правила в более сложные для валидации сложных иерархических структур данных. Важно понимать, что валидатор не делает различия между правилами и метаправилами. Метаправила ничем не отличаются от того же “required”.

nested_object

Позволяет описывать правила валидации для вложенных объектов.

Код ошибки зависит от вложенных правил. Если вложенный объект не является хешом, то поле будет содержать ошибку: “FORMAT_ERROR”.

Пример использования:

address: { 'nested_object': [{
    city: 'required',
    zip: ['required', 'positive_integer']
}]}
list_of

Позволяет описать правила валидации для списка значений. Каждое правило будет применяться для каждого элемента списка.

Код ошибки зависит от вложенных правил.

Пример использования:

{ product_ids: { 'list_of': [[ 'required',  'positive_integer' ]] }}
list_of_objects

Позволяет описать правила валидации для массива хешей. Правила применяются для каждого элемента в массиве.

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

Пример использования:

products: ['required', { 'list_of_objects': [{
    product_id: ['required','positive_integer'],
    quantity: ['required', 'positive_integer']
}]}]
list_of_different_objects

Позволяет описать правила для списка разного вида объектов. Правила валидации будут применяться к каждому объекту.

Код ошибки зависит от вложенных правил валидации. Если вложенных объект не является хешом, то поле будет содержать ошибку “FORMAT_ERROR”.

Пример использования:

"products": ["required", { "list_of_different_objects": [
    "product_type", {
        "material": {
            "product_type": "required",
            "material_id": ["required", "positive_integer"],
            "quantity": ["required", {"min_number": 1} ],
            "warehouse_id": "positive_integer"
        },
        "service": {
            "product_type": "required",
            "name": ["required", {"max_length": 10} ]
        }
    }
]}]

В этом примере валидатор будут смотреть на “product_type” в каждом хеше и, в завимости от значения этого поля, будет использовать соответствующие правила валидации.

Стандартный набор правил планируется расширить. Одним из кандидатов является “not_empty_list” - аналог “not_empty”, но для массива. И “required_if”.

Набор тест-кейсов

Для тестирования реализации был создан набор тест-кейсов. Это набор файлов в формате JSON, который позволяет протестировать реализацию на соответствие требованиям спецификации. Каждый позитивный тест, это 3 файла:

  • rules.json — описание правил валидации;
  • input.json — структура, которая передается валидатору на проверку;
  • output.json — очищенная структура, которая получается после валидации.

Каждый негативный тест вместо output.json содержит errors.json с описанием ошибки, которая должна возникнуть в результате валидации.

Реализация

Perl-реализация находится на CPAN — это модуль Validator::LIVR. Также имеется LIVR::Contract, который позволяет использовать LIVR в контрактном программировании.

Validator::LIVR

Использовать валидатор достаточно просто, а как это делать, должно быть понятно из примера:

use Validator::LIVR;
Validator::LIVR->default_auto_trim(1);

my $validator = Validator::LIVR->new(
    {
        name   => 'required',
        email  => ['required', 'email'],
        gender => {one_of => [['male', 'female']]},
        phone => {max_length => 10},
        password  => ['required',    {min_length => 10}],
        password2 => {equal_to_field => 'password'}
    }
);

if (my $valid_data = $validator->validate($user_data)) {
    save_user($valid_data);
}
else {
    my $errors = $validator->get_errors();
    ...;
}

Регистрация правил

Для валидации используются функции обратного вызова, которые осуществляют проверку значений, это очень похоже на формат Validate::Tiny. Попробуем описать новое правило под названием “strong_password”. Будем проверять, что значение больше 8 символов и содержит цифры и буквы в верхнем и нижнем регистрах.

my $livr = {password => ['required', 'strong_password']};

my $validator = Validator::LIVR->new($livr);

$validator->register_rule(
    strong_password => sub {
        return sub {
            my $val = shift;

            return if !defined($value) || $value eq '';

            return "WEAK_PASSWORD"
              if length($value) < 8
              || $value !~ m/[0-9]/
              || $value !~ m/[a-z]/
              || $value !~ m/[A-Z]/;

            return;
          }
    }
);

Теперь добавим возможность задавать минимальное количество символов в пароле и зарегистрируем это правило как глобальное (доступное во всех экземплярах валидатора).

my $livr = {password => ['required', {'strong_password' => 10}]};

sub strong_password {
    my ($min_length) = @_;

    return sub {
        my $val = shift;

        return if !defined($value) || $value eq '';

        return "WEAK_PASSWORD"
          if length($value) < $min_length
          || $value !~ m/[0-9]/
          || $value !~ m/[a-z]/
          || $value !~ m/[A-Z]/;

        return;
    };
}

Validator::LIVR->register_default_rule(
    'strong_password' => \&strong_password);

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

Есть возможность регистрации правил, которые будут не только валидировать значение, но и изменять его. Например, приводить к верхнему регистру или удалять лишние пробелы.

LIVR::Contract

LIVR::Contract — это экспериментальная реализация контрактного програмирования при помощи LIVR. Модуль позволяет описать контракты для классов при помощи LIVR. На данный момент контракты можно описывать только в классе, в котором находится реализация, но в планах добавить возможность описывать контракты в отдельных подключаемых файлах, используя для этого механизм “ролей”. Этот модуль пока еще не готов к “продакшн”-применению.

Планы на будущее

В ходе использования LIVR в реальных проектах возник ряд идей по улучшению:

  1. Планируется добавить новые правила в спецификацию. NOT_EMPTY_LIST — будет проверять, что список не является пустым.
  2. Планируется добавить дополнительную логику для обработки null-значений.
  3. Будет описан механизм добавления правил-фильтров (поддержка уже есть). Это позволит делать следующие записи: [ email => ['required', 'email', 'to\_lower\_case' ].
  4. Планируется сделать полноценную реализацию валидатора на JavaScript.

Виктор Турский, технический директор компании WebbyLab


От редактора | Содержание | Введение в разработку web-приложений на PSGI/Plack. Часть 4. Асинхронность
Нас уже 1393. Больше подписчиков — лучше выпуски!

Комментарии к статье