Валидация в Битрикс: как упростить рутину

от автора

Предисловие

Привет! Меня зовут Никита, я разработчик в компании Битрикс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 &amp; 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *