Валидация в Битрикс: как упростить рутину
Предисловие
Привет! Меня зовут Никита, я разработчик в компании Битрикс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)LengthMaxMinNotEmptyPhonePhoneOrEmail— свойство является либо телефоном, либо почтойPositiveNumberRangeRegExpUrl
Классы:
AtLeastOnePropertyNotEmpty— проверяет, что хотя бы одно свойство из заданных не пустое (названия свойств прокидываются в конструктор)
Валидаторы:
AtLeastOnePropertyNotEmpty— проверяет, что хотя бы одно свойство из заданных не пустое (названия свойств прокидываются в конструктор)Email;InArray— значение свойства является одним из элементов массива (для случаев, когда по какой-то причине не удалось использовать Enum)LengthMaxMinNotEmptyPhoneRegExpUrl
Создание валидаторов
Валидаторы
Если атрибуты представляют собой классы, который могут содержать различную логику, то валидаторы содержат одну элементарную операцию.
Каждый валидатор реализует \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.
