Предисловие
Привет! Меня зовут Никита, я разработчик в компании Битрикс24. В разработке мы давно стремимся к единообразию. Это позволяет нам уменьшить количество типовых ошибок, снизить затраты на производство и повысить качество.
Валидация входных данных — это как раз один из тех механизмов, который мы привели к единообразному виду. Мы ставили перед собой задачу привести всю валидацию к одной точке входа, но при этом сохранить гибкость и оставить возможность разработчикам писать свои правила валидации.
Часто случается, что необходимо проверить сущность на «правильность», при этом не привязываясь к бизнес‑логике. К примеру, если свойство класса представляет собой id пользователя, то становится очевидным, что значение этого свойства не может быть меньше, чем 1.
Проверки вида
public function __construct(int $userId) { if ($userId <= 0) { throw new \Exception(); } $this->userId = $userId; }
или
public function setEmail(string $email) { if (!check_email($email)) { throw new \Exception(); } $this->email = $email; }
Давно расползлись по разным модулям, увеличивая объем кода. Чтобы избежать этого, была сделана валидация, построенная на атрибутах.
Задаем нужные правила
Проще всего рассмотреть на примере. Давайте создадим класс, описывающий пользователя:
final class User { private ?int $id; private ?string $email; private ?string $phone; // getters & setters ... }
У сущности есть ряд ограничений:
-
id должен быть больше 0;
-
email должен быть валидным адресом;
-
phone должен быть валидным телефоном;
-
обязательно должен быть заполнен email или phone;
Добавим атрибуты валидации, чтобы эти требования удовлетворить:
use Bitrix\Main\Validation\Rule\AtLeastOnePropertyNotEmpty; use Bitrix\Main\Validation\Rule\Email; use Bitrix\Main\Validation\Rule\Phone; use Bitrix\Main\Validation\Rule\PositiveNumber; #[AtLeastOnePropertyNotEmpty(['email', 'phone'])] final class User { #[PositiveNumber] private ?int $id; #[Email] private ?string $email; #[Phone] private ?string $phone; // getters & setters ... }
Теперь мы можем осуществить валидацию через \Bitrix\Main\Validation\ValidationService
, который можно достать через локатор по ключу main.validation.service
.
Подход через сервис позволяет валидировать класс в том месте, где это нужно. К примеру, если объект собирается пошагово и должен быть «собран», к примеру, при сохранении его в базу данных:
use Bitrix\Main\DI\ServiceLocator; use Bitrix\Main\Validation\ValidationService; class UserService { private ValidationService $validation; public function __construct() { $this->validation = ServiceLocator::getInstance()->get('main.validation.service'); } public function create(?string $email, ?string $phone): Result { $user = new User(); $user->setEmail($email); $user->setPhone($phone); $result = $this->validation->validate($user); if (!$result->isSuccess()) { return $result; } // save logic ... } }
Давайте подробнее пройдемся по коду. Главный герой здесь — \Bitrix\Main\Validation\ValidationService
. Он предоставляет
1 метод — validate()
, возвращающий \Bitrix\Main\Validation\ValidationResult
.
Результат валидации внутри будет содержать ошибки всех сработавших валидаторов.
Результат валидации хранит в себе \Bitrix\Main\Validation\ValidationError
.
ВАЖНО:
-
модификаторы доступа у свойств не учитываются в процессе проверки, валидация происходит через рефлексию
-
если атрибут отмечен как nullable и его значение не установлено, то он будет пропущен при валидации
Валидация вложенных объектов
Часто случается, что объект сложный, и в качестве свойств имеет вложенные объекты. Для того чтобы эти объекты также были отвалидированы, необходимо к такому свойству добавить атрибут \Bitrix\Main\Validation\Rule\Recursive\Validatable
. Это будет служить указанием к тому, что такой объект также должен быть провалидирован при валидации объекта, который его содержит.
Объект-свойство будет провалидирован согласно всем правилам, описанным выше.
В этом случае код ошибки будет строиться исходя из названия свойства и его вложенности.
Пример:
use Bitrix\Main\Validation\Rule\Composite\Validatable; use Bitrix\Main\Validation\Rule\NotEmpty; use Bitrix\Main\Validation\Rule\PositiveNumber; class Buyer { #[PositiveNumber] public ?int $id; #[Validatable] public ?Order $order; } class Order { #[PositiveNumber] public int $id; #[Validatable] public ?Payment $payment; } class Payment { #[NotEmpty] public string $status; #[NotEmpty(errorMessage: 'Custom message error')] public string $systemCode; } // validation /** @var \Bitrix\Main\Validation\ValidationService $validationService */ $validationService = \Bitrix\Main\DI\ServiceLocator::getInstance()->get('main.validation.service'); $buyer = new Buyer(); $buyer->id = 0; $result1 = $validationService->validate($buyer); // "id: Значение поля меньше допустимого" foreach ($result1->getErrors() as $error) { echo $error->getCode() . ': ' . $error->getMessage(). PHP_EOL; } echo PHP_EOL; $buyer->id = 1; $order = new Order(); $order->id = -1; $buyer->order = $order; $result2 = $validationService->validate($buyer); // "order.id: Значение поля меньше допустимого" foreach ($result2->getErrors() as $error) { echo $error->getCode() . ': ' . $error->getMessage(). PHP_EOL; } echo PHP_EOL; $buyer->order->id = 123; $payment = new Payment(); $payment->status = ''; $payment->systemCode = ''; $buyer->order->payment = $payment; $result3 = $validationService->validate($buyer); // "order.payment.status: Значение поля не может быть пустым" // "order.payment.systemCode: Custom message error" foreach ($result3->getErrors() as $error) { echo $error->getCode() . ': ' . $error->getMessage(). PHP_EOL; }
Валидация в контроллерах
Контроллеры — это часть MVC архитектуры, которая отвечает за обработку запроса и генерирование ответа. Это та часть нашего фреймворка, которая практически всегда взаимодействует с пользовательскими данными. Поэтому крайне важно было внедрить в них созданный механизм валидации, что позволило разработчикам избавиться от рутины при проверке данных.
Рассмотрим пример валидации в контроллере.
Допустим, у нас есть DTO и контроллер:
use Bitrix\Main\Validation\Rule\NotEmpty; use Bitrix\Main\Validation\Rule\PhoneOrEmail; final class CreateUserDto { public function __construct( #[PhoneOrEmail] public ?string $login, #[NotEmpty] public ?string $password, #[NotEmpty] public ?string $passwordRepeat, ) {} }
Использование этого класса в коде будет выглядеть так:
class UserController extends Controller { private ValidationService $validation; protected function init() { parent::init(); $this->validation = ServiceLocator::getInstance()->get('main.validation.service'); } public function createAction(): Result { $dto = new CreateUserDto(); $dto->login = (string)$this->getRequest()->get('login'); $dto->password = (string)$this->getRequest()->get('password'); $dto->passwordRepeat = (string)$this->getRequest()->get('passwordRepeat'); $result = $this->validation->validate($dto); if (!$result->isSuccess()) { $this->addErrors($result->getErrors()); return false; } // create logic ... } }
Кусок кода с инициализацией и валидацией будет повторяться от метода к методу.
Чтобы этого избежать и облегчить код, для начала создадим фабричный метод в DTO:
final class CreateUserDto { public function __construct( #[PhoneOrEmail] public ?string $login = null, #[NotEmpty] public ?string $password = null, #[NotEmpty] public ?string $passwordRepeat = null, ) {} public static function createFromRequest(\Bitrix\Main\HttpRequest $request): self { return new static( login: (string)$request->getRequest()->get('login'), password: (string)$request->getRequest()->get('password'), passwordRepeat: (string)$request->getRequest()->get('passwordRepeat'), ); } }
И воспользуемся автоварингом контроллера и специальным классом Bitrix\Main\Validation\Engine\AutoWire\ValidationParameter
, который спрячет повторяющуюся логику валидации:
class UserController extends Controller { public function getAutoWiredParameters() { return [ new \Bitrix\Main\Validation\Engine\AutoWire\ValidationParameter( CreateUserDto::class, fn() => CreateUserDto::createFromRequest($this->getRequest()), ), ]; } public function createAction(CreateUserDto $dto): Result { // create logic ... } }
В случае, если объект CreateUserDto
будет не валиден, до метода createAction
код не дойдёт, а контроллер вернёт ответ с ошибкой:
{ data: null, errors: [ { code: "name", customData: null, message: "Значение поля не должно быть пустым", }, ], status: "error" }
Использование валидаторов без атрибутов
Валидаторы, разумеется, можно использовать и без атрибутов:
use Bitrix\Main\Validation\Validator\EmailValidator; $email = 'bitrix@bitrix.com'; $validator = new EmailValidator(); $result = $validator->validate($email); if (!$result->isSuccess()) { // ... }
Кастомные ошибки
Есть возможность указания своего текста ошибки, который будет возвращен после валидации.
Вот пример валидации с указанием кастомной ошибки:
use Bitrix\Main\Validation\Rule\PositiveNumber; class User { public function __construct( #[PositiveNumber(errorMessage: 'Invalid ID!')] public readonly int $id ) { } } $user = new User(-150); /** @var \Bitrix\Main\Validation\ValidationService $service */ $result = $service->validate($user); foreach ($result->getErrors() as $error) { echo $error->getMessage(); } // output: 'Invalid ID!'
И без кастомной ошибки (используется стандартная ошибка валидатора):
use Bitrix\Main\Validation\Rule\PositiveNumber; class User { public function __construct( #[PositiveNumber] public readonly int $id ) { } } $user = new User(-150); /** @var \Bitrix\Main\Validation\ValidationService $service */ $result = $service->validate($user); foreach ($result->getErrors() as $error) { echo $error->getMessage(); } // output: 'Значение поля меньше допустимого'
Получить сработавший валидатор
Как говорилось ранее, результат валидации хранит в себе ошибки — \Bitrix\Main\Validation\ValidationError
.
Вообще говоря, это наследник нашей любимой \Bitrix\Main\Error
, но с одним «но» — у нашей ошибки есть свойство $this->failedValidator
. Это свойство обычно содержит упавший валидатор, так как метафизическая связь валидатора и ошибки — это 1 к 1. Мы говорим «обычно содержит», потому что в общем случае атрибут может не использовать внутри себя валидаторы.
$errors = $service->validate($dto)->getErrors(); foreach ($errors as $error) { $failedValidator = $error->getFailedValidator(); // ... }
Список атрибутов и валидаторов в поставке
Атрибуты:
Свойства:
-
ElementsType
— все элементы перечисляемого свойства должны быть заданного типа; -
Email
-
InArray
— значение свойства является одним из элементов массива (для случаев, когда по какой‑то причине не удалось использовать Enum) -
Length
-
Max
-
Min
-
NotEmpty
-
Phone
-
PhoneOrEmail
— свойство является либо телефоном, либо почтой -
PositiveNumber
-
Range
-
RegExp
-
Url
Классы:
-
AtLeastOnePropertyNotEmpty
— проверяет, что хотя бы одно свойство из заданных не пустое (названия свойств прокидываются в конструктор)
Валидаторы:
-
AtLeastOnePropertyNotEmpty
— проверяет, что хотя бы одно свойство из заданных не пустое (названия свойств прокидываются в конструктор) -
Email
; -
InArray
— значение свойства является одним из элементов массива (для случаев, когда по какой‑то причине не удалось использовать Enum) -
Length
-
Max
-
Min
-
NotEmpty
-
Phone
-
RegExp
-
Url
Создание валидаторов
Валидаторы
Если атрибуты представляют собой классы, которые могут содержать различную логику, то валидаторы содержат одну элементарную операцию.
Каждый валидатор реализует \Bitrix\Main\Validation\Validator\ValidatorInterface
с методом public function validate(mixed $value): ValidationResult
.
Как можно заметить из сигнатуры, валидатор просто валидирует значение. У него нет контекста, является ли это значение свойством или классом. Он даже не знает, что он привязан к атрибуту. Он просто мелкий «кубик», из которого строится «башня» валидации.
Давайте рассмотрим пример валидатора Min
:
namespace Bitrix\Main\Validation\Validator; use Bitrix\Main\Localization\Loc; use Bitrix\Main\Validation\ValidationError; use Bitrix\Main\Validation\ValidationResult; use Bitrix\Main\Validation\Validator\ValidatorInterface; final class Min implements ValidatorInterface { public function __construct( private readonly int $min ) { } public function validate(mixed $value): ValidationResult { $result = new ValidationResult(); if (!is_numeric($value)) { $result->addError( new ValidationError( Loc::getMessage('MAIN_VALIDATION_MIN_NOT_A_NUMBER'), failedValidator: $this ) ); return $result; } if ($value < $this->min) { $result->addError( new ValidationError( Loc::getMessage('MAIN_VALIDATION_MIN_LESS_THAN_MIN'), failedValidator: $this) ); } return $result; } }
Создание атрибутов валидации
Атрибуты
Атрибуты делятся на 2 типа: атрибуты свойств (на примере выше) и атрибуты класса.
В общем случае, класс атрибута свойства должен реализовывать интерфейс \Bitrix\Main\Validation\Rule\PropertyValidationAttributeInterface
и его метод public function validateProperty(mixed $propertyValue): ValidationResult;
, чтобы сервис воспринял этот класс как атрибут для валидации свойства.
Тут мы и предоставляем гибкость, о которой я говорил вначале. Вам достаточно создать класс атрибута, реализовать нужный интерфейс, пометить этим атрибутом нужное свойство или класс, и он будет отвалидирован согласно тем правилам, которые вы описали в своем атрибуте.
Кастомные ошибки
Наследуясь от абстрактных классов AbstractClassValidationAttribute
, AbstractPropertyValidationAttribute
мы получаем возможность задать в конструкторе атрибута свойство $errorMessage
. Это строка. Если они передана, то вместо ошибок валидаторов, вернётся единственная ошибка с указанным в $errorMessage
текстом.
Атрибуты свойства
Давайте напишем простой атрибут для валидации свойства:
use Bitrix\Main\Validation\Rule\PropertyValidationAttributeInterface; use Bitrix\Main\Validation\ValidationError; use Bitrix\Main\Validation\ValidationResult; #[Attribute(Attribute::TARGET_PROPERTY)] class NotOne implements PropertyValidationAttributeInterface { public function validateProperty(mixed $propertyValue): ValidationResult { $result = new ValidationResult(); if ($propertyValue === 1) { $result->addError(new ValidationError('Not one')); } return $result; } }
Всё просто — мы принимаем значение свойства, а возвращаем результат, в который складываем ValidationError
.
Но часто валидация, подобно конструктору, строится из более мелких и часто встречающихся «кубиков» — что будет с нашим атрибутом NotOne
, если мы, к примеру, захотим, чтобы значение свойства было обязательно больше, чем -2? Не делать же нам еще один атрибут…
Поэтому в архитектуре атрибутов есть возможность не реализовывать интерфейс PropertyValidationAttributeInterface
, а отнаследоваться от абстрактного класса \Bitrix\Main\Validation\Rule\AbstractPropertyValidationAttribute
, который позволяет создавать атрибут из «кубиков» — валидаторов.
Абстрактный класс берет на себя ответственность за детали реализации валидации, а с нас просит реализовать абстрактный метод abstract protected function getValidators(): array
.
Чтобы стало понятнее, давайте посмотрим на реализацию атрибута Range
:
Он содержит в себе 2 пазла — Min
, Max
. А механизм абстрактного класса просто реализуют метод validateProperty
, вызывая валидаторы по очереди.
use Attribute; use Bitrix\Main\Validation\Rule\AbstractPropertyValidationAttribute; use Bitrix\Main\Validation\Validator\Implementation\Max; use Bitrix\Main\Validation\Validator\Implementation\Min; #[Attribute(Attribute::TARGET_PROPERTY)] final class Range extends AbstractPropertyValidationAttribute { public function __construct( private readonly int $min, private readonly int $max, protected ?string $errorMessage = null ) { } protected function getValidators(): array { return [ (new Min($this->min)), (new Max($this->max)), ]; } }
Атрибуты класса
И снова вернемся к атрибутам, но на этот раз к атрибутам класса. Иерархия наследования и реализации практически параллельная валидации свойств. Есть интерфейс \Bitrix\Main\Validation\Rule\ClassValidationAttributeInterface
, который нужно реализовать. Конкретно — метод public function validateObject(object $object): ValidationResult
, в который приходит валидируемый объект. Тут, к сожалению, нельзя установить какой-то общий сценарий валидации, как со свойствами — с классом всегда по-разному.
Также есть абстрактный класс \Bitrix\Main\Validation\Rule\AbstractClassValidationAttribute
, но он содержит в себе лишь возможность определения кастомных ошибок, в которых чуть позже.
Вот пример реализации:
use Bitrix\Main\Validation\ValidationResult; use Bitrix\Main\Validation\ValidationError; use Bitrix\Main\Validation\Rule\AbstractClassValidationAttribute; use ReflectionClass; #[Attribute(Attribute::TARGET_CLASS)] class NotOne extends AbstractClassValidationAttribute { public function validateObject(object $object): ValidationResult { $result = new ValidationResult(); $properties = (new ReflectionClass($object))->getProperties(); if (count($properties) > 2) { $result->addError(new ValidationError('error...')); } return $result; } }
Диаграмма классов
Для тех, кому интересно, как выглядит архитектура этого пакета, прилагаю диаграмму классов.
Этот пакет уже готовится к выпуску внутри модуля main и совсем скоро (в этом релизе) его можно будет использовать в ваших проектах на базе Bitrix Framework.
ссылка на оригинал статьи https://habr.com/ru/articles/854370/
Добавить комментарий