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