Как стать автором
Обновить

Yii Validator — простой и мощный

Уровень сложностиПростой
Время на прочтение14 мин
Количество просмотров6.5K

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

Путь валидатора в Yii3 к релизу был долог. После множества мозговых штурмов, жарких обсуждений, а также нескольких глобальных рефакторингов (были даже публичные с Валентином Удальцовым и Леонидом Корсаковым), наконец, 22 февраля 2023 года состоялся релиз пакета Yii Validator.

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

Простой пример

DTO, где с помощью атрибутов указаны правила валидации:

final class Product  
{  
    #[Required]  
    #[Length(min: 3, max: 50)]  
    private ?string $name = null;  
  
    #[Required]  
    #[Number(min: 0)]  
    private ?float $price = null;  
  
    #[Number(min: 0, max: 24)]  
    private ?int $count = null;  

    // ...
}

Валидация:

use Yiisoft\Validator\Validator;

/** @var Product $product */
$result = (new Validator())->validate($product);

Результат валидации:

$result->isValid(); // Валидно ли значение?
$result->getErrorMessages(); // Массив с ошибками валидации

Как и написано в начале статьи — использовать валидатор очень просто:

  • создаём валидатор (все зависимости опциональны, достаточно new Validator());

  • проверяем значение с помощью метода validate();

  • всё, можно работать с результатами валидации.

Ну а теперь пойдём по порядку.

Валидатор и его интерфейс

Validator и реализуемый им интерфейс ValidatorInterface предоставляют всего один метод:

interface ValidatorInterface  
{
    public function validate(
        mixed $data,  
        callable|iterable|object|string|null $rules = null,  
        ?ValidationContext $context = null,
    ): Result;
}

$data — валидируемое значение, может быть любым. «Грязные» данные до валидации, как правило, получаются из не очень надёжных источников, где мы не можем гарантировать какую-либо типизацию.

$rules — правила валидации. Можно передать одно правило или массив правил, на соответствие которым требуется проверить входящие данные. Также поддерживается ещё несколько вариантов передачи правил, их мы рассмотрим чуть позже.

$context — контекст валидации. Позволяет при необходимости передать произвольные параметры в валидатор, которые в дальнейшем могут быть использованы некоторыми правилами в процессе валидации.

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

Валидируй это!

Валидатор позволяет проверить данные в любом формате. Рассмотрим популярные кейсы.

Валидация произвольного значения

Проверить значение можно с помощью одного правила валидации:

$value = 7;  
$result = (new Validator())->validate($value, new Number(min: 42));

Или нескольких правил:

$value = 'mrX';  
$result = (new Validator())->validate(
    $value, 
    [
        new Length(min: 4, max: 20),  
        new Regex('~^[a-z_\-]*$~i'),  
    ]
);

Валидация массива

При проверке массива данных мы можем проверить как в целом массив, так и значения конкретных элементов. Например:

$data = [  
    'name' => 'John',  
    'age' => 17,  
    'email' => 'jonh@example.com',  
    'phone' => null,  
];  
  
$result = (new Validator())->validate(  
    $data,  
    [  
        // В массиве должно быть 4 элемента  
        new Count(4),  
        
        // Хотя бы одно из полей "email" или "phone" должно быть заполнено  
        new AtLeast(['email', 'phone']),   
          
        'name' => [  
            new Required(), // Поле "name" обязательно  
            new Length(min: 2), // Длина имени должна быть не менее двух символов  
        ],
          
        'age' => new Number(min: 21), // Возраст должен быть не менее 21 года  
        
        'email' => new Email(), // Почта должна быть корректной  
    ]  
);

Валидация объекта

По аналогии с массивом, объект можно проверить как целиком, так и его отдельные свойства.

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

#[AtLeast(['email', 'phone'])]  
final class Member  
{  
    public function __construct(  
        #[Required]  
        #[Length(min: 2)]  
        public readonly ?string $name = null,  
  
        #[Number(min: 21)]  
        public readonly ?int $age = null,  
  
        #[Email]  
        public readonly ?string $email = null,  
  
        public readonly ?string $phone = null,  
    ) {  
    }  
}

$member = new Member('Jonh', 17, 'jonh@example.com', null);  
  
$result = (new Validator())->validate($member);

Правила валидации

В PHP 8 появились именованные аргументы, что позволило отказаться от вариантов с конфигурацией правил с помощью массива или множества методов с текучим интерфейсом. Параметры правил валидации задаются прямо в конструкторе, а с помощью имён это можно делать в произвольном порядке:

new Number(
    min: 7,
    max: 42,
    integerOnly: true,
);

Из коробки пакет предоставляет базовые правила Required, Number, Length, Regex, Email, Url и так далее, всего 27 правил. В целом в первой версии Yii Validator правил не так много, но основные кейсы они покрывают, а реализация недостающих — дело времени. Главное, что архитектура пакета позволяет легко добавлять новые правила.

Рассмотрим подробнее некоторые функциональные правила.

Callback — правило-обёртка над callable

Правило позволяет проверять значения с использованием функций обратного вызова (callback-функции). Например:

new Callback(
    static function (mixed $value): Result {  
        $result = new Result();  
        if ($value !== 42) {  
            $result->addError('Value should be 42!');  
        }  
        return $result;  
    }
);

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

final class MyNumber {
    public function __construct(
        #[Callback(method: 'validateNumber')]
        private int $number,
    ) {}

    private function validateNumber(mixed $value): Result 
    {
        $result = new Result();  
        if ($value !== 42) {  
            $result->addError('Value should be 42!');  
        }  
        return $result;  
    }
}

А можно проверить и целиком объект:

#[Callback(method: 'validate')]
final class MyNumber {
    public function __construct(        
        private int $number,
    ) {}

    private function validate(): Result 
    {
        $result = new Result();  
        if ($this->number !== 42) {  
            $result->addError('Value should be 42!');  
        }  
        return $result;  
    }
}

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

Composite — композитное правило

Композит позволяет объединить несколько правил под одной крышей. Это даёт возможность указать опции пропуска один раз для группы правил. Например, пропускать правила, если во время валидации уже были найдены ошибки:

new Composite(
    [
        new Length(max: 255),
        new Email(),
    ],
    skipOnError: true,
);

Условная валидация (в том числе и skipOnError) будет рассмотрена более детально позднее.

Composite — один из немногих классов в пакете, который не финализирован. Это значит, что от него можно наследоваться и переопределить метод getRules():

final class UsernameConstraint extends Composite
{
    public function getRules(): array
    {
        return [
            new Length(min: 2, max: 20),
            new Regex('~^[a-z_\-]*$~i'),
        ];
    }
}

И использовать группу правил как одно правило:

$result = (new Validator())->validate($username, new UsernameConstraint());

StopOnError — валидация до первой ошибки

Правило позволяет прекратить применение группы правил, как только в одном из них проверка прошла неудачно. Это может быть полезно в случаях, когда в процессе валидации есть «тяжёлые» проверки (например, нужно сходить в базу данных или выполнить какие-то сложные вычисления): такие проверки не будут выполняться, если в процессе валидации ранее были найдены ошибки.

Более «лёгкие» правила помещаем в списке выше, а «тяжёлые» — ниже. Например:

new StopOnError([  
    new Length(min: 3),
    new MyHeavyRule(), // «Тяжёлое» правило
]);

Each — валидация каждого элемента массива

Правило даёт возможность применить набор правил к каждому элементу проверяемого массива. Например, правило для проверки RGB-цвета:

$rules = [
    // В массиве должно быть 3 элемента
    new Count(exactly: 3),
    
    // Каждый элемент должен быть...
    new Each(
        // .. целым числом от 0 до 255
        new Number(min: 0, max: 255, integerOnly: true),
    ),
];

Nested — валидация вложенных структур

Правило используется для значений, представляющих собой какие-либо структуры, то есть массивов или объектов. Например:

final class Point {
    #[Nested([
        'latitude' => new Number(min: -90, max: 90), 
        'longitude' => new Number(min: -180, max: 180),
    ])]
    private array $coordinates;
    // ...
}

Если у значения-объекта есть свои атрибуты-правила, то для такого значения можно указать атрибут Nested без параметров, это покажет валидатору, что нужно проверить значение по правилам, которые в нём указаны:

final class Coordinates {
    #[Number(min: -90, max: 90)]
    private float $latitude;
    #[Number(min: -180, max: 180)]
    private float $longitude;
    // ...
}

final class Point {
    #[Nested]
    private Coordinates $coordinates;
    // ...
}

Условная валидация

Валидатор поддерживает несколько видов опций, указывающих ему нужно ли выполнять конкретное правило или нет. Почти все правила поддерживают следующие опции.

skipOnError

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

Можно, например, пропустить проверку корректности имени пользователя, если поле вообще не заполнено:

$result = (new Validator())->validate(  
    $username,  
    [  
        new Required(),  
        new Length(min: 4, max: 20, skipOnError: true),  
    ],  
);

skipOnEmpty

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

$result = (new Validator())->validate(  
    $language,  
    [  
        new Required(),  
        new In(['ru', 'en'], skipOnEmpty: true),  
    ],  
);

Можно использовать произвольный критерий определения «пустоты» значения, передав callback-функцию вместо true. Например, считать «пустыми» нули или отсутствие значения:

$result = (new Validator())->validate(  
    $data,  
    [  
        'age' =>  
            new Number(  
                min: 21,  
                skipOnEmpty: static function (mixed $value, bool $isAttributeMissing): bool {  
                    return $isAttributeMissing || $value === 0;  
                },  
            ),  
    ],  
);

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

  • WhenEmpty — пустые значения: null, пустая строка, пустой массив или отсутствие значения;

  • WhenNull — пустые значения: null или отсутствие значения;

  • WhenMissing — пустым считается только отсутствие значения;

  • NeverEmpty — все значения считаются НЕ «пустыми».

Например, использование класса WhenNull:

$result = (new Validator())->validate(  
    $value,  
    new Number(  
        min: 7,  
        skipOnEmpty: new WhenNull(),  
    ),  
);

when

Произвольное условие в виде callback-функции, определяющее нужно ли пропустить правило. Сигнатура функции:

function (mixed $value, ValidationContext $context): bool;

Как видим, в функцию передаются два аргумента:

  • $value — проверяемое значение;

  • $context — контекст валидации.

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

$result = (new Validator())->validate(  
    $data,  
    [  
        'country' => [  
            new Required(),  
            new Length(min: 2),  
        ],  
        'state' => new Required(  
            when: static function (mixed $value, ValidationContext $context): bool {  
                return $context->getDataSet()->getAttributeValue('country') === 'Brazil';  
            }  
        )  
    ],  
);

Наличие заполненного атрибута state будет проверяться только в случае, когда значение атрибута country будет равно Brazil. Метод $context->getDataSet()->getAttributeValue() позволяет получить значение любого атрибута в проверяемом наборе данных.

Результат валидации

Результат валидации представляет собой объект, содержащий всю информацию о возникших в процессе валидации ошибках.

Успешна ли валидация?

Ответ на самый главный вопрос "валидны ли данные" получаем следующим образом:

$result->isValid();

Можно также проверить, валиден ли какой-то конкретный атрибут:

$result->isAttributeValid('name');

Ошибки валидации

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

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

// [
//   'Value must be no less than 21.',
//   'This value is not a valid email address.',
// ]
$result->getErrorMessages();

Но на самом деле информации об ошибках немного больше. Внутри ошибки хранятся в виде специальных объектов Error, которые хранят в себе:

  • шаблон сообщения (например, Value must be no less than {min}.),

  • параметры сообщения (например, ['min' => 7]),

  • путь к значению в проверяемой структуре (например, ['user', 'name', 'firstName']).

Для получения всех объектов ошибок служит специальный метод:

$result->getErrors();

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

Сообщения, проиндексированные по атрибуту:

// [
//   'user' => [
//     'Value cannot be blank.',
//     'This value must contain at least 4 characters.',
//   ],
//   'email' => ['This value is not a valid email address.'],
// ]
$result->getErrorMessagesIndexedByAttribute();

Сообщения, не привязанные к какому-либо атрибуту:

$result->getCommonErrorMessages();

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

$result->getAttributeErrorMessages('attrubuteName');
$result->getAttributeErrorMessagesIndexedByPath('attrubuteName');
$result->getAttributeErrors('attrubuteName');

Сообщения об ошибках

Сообщения об ошибках можно переопределить при создании правила валидации. Как правило, за сообщение об ошибке отвечает параметр message:

new Required(
    message: '{attribute} is required.'
);

Некоторые правила имеют несколько сообщений об ошибках и, соответственно, несколько параметров:

new Length(  
    min: 4,  
    max: 10,  
    lessThanMinMessage: 'The {attribute} is too short.',  
    greaterThanMaxMessage: 'The {attribute} is too long.',  
);

Перевод сообщений об ошибках

Перевод сообщений реализован с помощью пакета Yii Translator, оригиналы сообщений на английском языке.

Вместе с валидатором идут переводы на русский, узбекский и польский языки (пока так, в будущем будут и другие) в виде массива в PHP-файле. Для их использования необходимо установить пакет для чтения переводов из PHP-файлов:

composer require yiisoft/translator-message-php

Если вы используете валидатор в Yii-окружении (приложение использует Yii Config, а сам валидатор получаете как зависимость через контейнер Yii DI), то переводы подключатся автоматически.

Если же валидатор используется отдельно, то объект переводчика нужно будет создать вручную. Например, так:

// Путь к папке с переводами
$translationsPath = '/app/vendor/yiisoft/validator/messages';  
  
$categorySource = new CategorySource(  
    Validator::DEFAULT_TRANSLATION_CATEGORY,  
    new MessageSource($translationsPath),  
    new SimpleMessageFormatter(),  
);  
  
$translator = new Translator(locale: 'ru');  
$translator->addCategorySources($categorySource);  
  
$validator = new Validator(translator: $translator);

Перевод имён атрибутов

Практически во всех шаблонах сообщений ошибок можно использовать переменную {attribute}, которая заменяется на название атрибута.

По умолчанию имя атрибута используется в сообщении как есть. На английском языке это выглядит хоть как-то читаемо (например, "currentPassword is required."), а вот если использовать переводы сообщений, то будет совсем плохо ("currentPassword обязателен.").

Чтобы решить задачу, мы сделали отдельный интерфейс AttributeTranslatorInterface, который предназначен специально для перевода атрибутов. Из коробки предоставляется три реализации:

  • ArrayAttributeTranslator — для перевода используется массив переводов, где ключи являются оригинальными именами атрибутов, а значения — переводами;

  • TranslatorAttributeTranslator — для перевода используется переводчик Yii Translator;

  • NullAttributeTranslator — ничего не переводит, возвращает имя атрибута как есть.

Есть несколько способов использовать переводчик атрибутов.

Переводчик атрибутов в валидаторе

Тут всё просто, создаём переводчик атрибутов и передаём его через конструктор валидатора:

$attributeTranslator = new ArrayAttributeTranslator([  
    'currentPassword' => 'Текущий пароль',  
    'newPassword' => 'Новый пароль',  
]);  
  
$validator = new Validator(  
    defaultAttributeTranslator: $attributeTranslator,  
);

Переводчик атрибутов в объекте

Вариант с переводчиком атрибутов прямо в валидируемом объекте более интересный и может быть использован, например, для создания классов форм.

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

final class PasswordForm implements AttributeTranslatorProviderInterface  
{  
    public function __construct(  
        #[Required(  
            message: '{attribute} обязателен для ввода.'  
        )]  
        public string $currentPassword = '',  
  
        #[Length(  
            min: 8,  
            skipOnEmpty: false,  
            lessThanMinMessage: '{attribute} должен быть сложный, не менее 8 символов.'  
        )]  
        public string $newPassword = '',  
    ) {  
    }  
  
    public function getAttributeTranslator(): ?AttributeTranslatorInterface  
    {  
        return new ArrayAttributeTranslator([  
            'currentPassword' => 'Текущий пароль',  
            'newPassword' => 'Новый пароль',  
        ]);  
    }  
}

$form = new PasswordForm();  
  
$result = (new Validator())->validate($form);

Валидация на стороне клиента

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

$rules = [  
    'name' => [  
        new Length(min: 4, max: 10),  
    ],  
];  

//  [  
//      'name' => [  
//          [  
//              'length',  
//              'min' => 4,  
//              'max' => 10,  
//              'exactly' => null,  
//              'lessThanMinMessage' => [  
//                  'template' => 'This value must contain at least {min, number} {min, plural, one{character} other{characters}}.',  
//                  'parameters' => ['min' => 4],  
//              ],  
//              'greaterThanMaxMessage' => [  
//                  'template' => 'This value must contain at most {max, number} {max, plural, one{character} other{characters}}.',  
//                  'parameters' => ['max' => 10],  
//              ],  
//              'notExactlyMessage' => [  
//                  'template' => 'This value must contain exactly {exactly, number} {exactly, plural, one{character} other{characters}}.',  
//                  'parameters' => ['exactly' => null],  
//              ],  
//              'incorrectInputMessage' => [  
//                  'template' => 'The value must be a string.',  
//                  'parameters' => [],  
//              ],  
//              'encoding' => 'UTF-8',  
//              'skipOnEmpty' => false,  
//              'skipOnError' => false,  
//          ]  
//      ],  
//  ],
$rulesAsArray = RulesDumper::asArray($rules);

Какую-либо обработку правил валидации на стороне клиента пакет не предоставляет. Это пространство для разработки в дальнейшем в рамках отдельного пакета.

Создание правил валидации

Ключевая особенность правил — разделение правила на две части:

  • конфигурация (класс, реализующий RuleInterface),

  • обработчик (класс, реализующий RuleHandlerInterface).

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

Попробуем создать правило, которое проверяет, что значение является RGB-цветом (массив, 3 элемента, каждый из которых число от 0 до 255).

Правило, из опций только текст сообщения об ошибке:

final class RgbColor implements RuleInterface  
{  
    public function __construct(  
        public readonly string $message = 'Invalid RGB color value.',  
    ) {  
    }  
  
    public function getName(): string  
    {  
        return 'rgbColor';  
    }  
  
    public function getHandler(): string|RuleHandlerInterface  
    {  
        return RgbColorHandler::class;  
    }  
}

Обработчик правила:

final class RgbColorHandler implements RuleHandlerInterface  
{  
    public function validate(
        mixed $value,
        object $rule,
        ValidationContext $context
    ): Result {  
        /** @var RgbColor $rule */  
  
        if (!$this->isValid($value)) {  
            return (new Result())->addError($rule->message);  
        }  
  
        return new Result();  
    }  
  
    private function isValid(mixed $color): bool  
    {  
        if (  
            !is_array($color) ||  
            array_keys($color) !== [0, 1, 2]  
        ) {  
            return false;  
        }  
  
        foreach ($color as $item) {  
            if (!is_int($item) || $item < 0 || $item > 255) {  
                return false;  
            }  
        }  
  
        return true;  
    }  
}

Это пример простейшего правила, к нему можно добавить опции для условной валидации, возможность использования в качестве атрибута и другие «фишки». В общем, создание правил валидации — тема отдельной статьи.

Расширения Yii Validator

Архитектура валидатора позволяет писать довольно функциональные расширения для него. Уже есть несколько неофициальных дополнений.

Сценарии валидации

Пакет Yii Validator Scenarios (vjik/yii-validator-scenarios) предоставляет специальное правило On, которое позволяет использовать сценарии валидации.

Пример класса, использующего сценарии:

final class UserDto
{
    public function __construct(
        #[On(
            'register',
            [new Required(), new Length(min: 7, max: 10)]
        )]
        public string $name,

        #[Required]
        #[Email]
        public string $email,

        #[On(
            ['login', 'register'],
            [new Required(), new Length(min: 8)],
        )]
        public string $password,
    ) {
    }
}

Указание валидатору по какому сценарию выполнять валидацию осуществляется с помощью параметра в контексте валидации:

$result = (new Validator())->validate(
    $userDto, 
    context: new ValidationContext([
        On::SCENARIO_PARAMETER => $scenario,
    ]),
);

С точки зрения архитектуры приложения использовать сценарии в валидации настоятельно НЕ рекомендуется. Как показывает практика, использование сценариев приводит к коду крайне сложному для восприятия и внесения изменений, а переписывание выливается в довольно трудозатратную задачу. Именно по этой причине не стали включать данный функционал в официальный пакет валидатора. Но, как говорится, если очень хочется, то можно 😉

Обёртка для Symfony-правил

Пакет Yii Validator Symfony Rule (vjik/yii-validator-symfony-rule) позволяет использовать правила ("constraints") из валидатора Symfony в валидаторе Yii3.

Использовать Symfony-правила очень просто, достаточно обернуть правило или массив правил валидации из Symfony в предоставляемое пакетом правило SymfonyRule. Пример:

use Symfony\Component\Validator\Constraints\{CssColor, NotEqualTo, Positive};
use Vjik\Yii\ValidatorSymfonyRule\SymfonyRule;
use Yiisoft\Validator\Rule\Length;
use Yiisoft\Validator\Rule\Required;

final class Car
{
    #[Required]
    #[Length(min: 3, skipOnEmpty: true)]
    public string $name = '';

    #[Required]
    #[SymfonyRule(
        new CssColor(CssColor::RGB),
        skipOnEmpty: true,
    )]
    public string $cssColor = '#1123';

    #[SymfonyRule([
        new Positive(),
        new NotEqualTo(13),
    ])]
    public int $number = 13;
}

Вместо заключения

Целью написания данной статьи была презентация нового валидатора в Yii3. Хотелось показать всё многообразие его возможностей, которое при этом не мешает простоте и удобству использования. И если с "простотой", надеюсь, получилось, то с "многообразием" вышло лишь частично.

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

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

  • покрытие модульными тестами — 100%,

  • уровень типизации — 99%,

  • индекс мутационного тестирования — 98%,

  • используется статическое тестирование (psalm 1 уровня).

Большой релиз Yii3 близок. Выпуск валидатора — важный шаг на этом пути 😎

PS Поддержать разработку Yii3 можно на Boosty или Open Collective.

Теги:
Хабы:
Всего голосов 26: ↑26 и ↓0+26
Комментарии15

Публикации

Истории

Работа

PHP программист
148 вакансий

Ближайшие события