Предисловие
Привет! Меня зовут Никита, я разработчик в компании Битрикс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.
Комментарии (9)
ddruganov
29.10.2024 13:08А зачем заниматься собственным велосипедом, если можно было взять симфонийский валидатор и адаптировать?
rpsv
29.10.2024 13:08А чем Битрикс хуже чем Yii или Laravel? Тоже хочется велосипед :)
SerafimArts
29.10.2024 13:08Чем хуже конкретно данный валидатор?
Да дофига чем:
Атрибуты, которые семантически должны представлять из себя классы метадаты отвечают ещё за валидацию. Смешение логики и правил маппингов.
В примерах кода не вижу вообще возможностей вынести правила валидации отдельно от DTO, например в yaml, php, json, etc. Чтобы правила можно было указывать во внешних файлах и изолировать от DTO.
Не вижу зависимых правил и сложных выражений, типа
When X then (Y or Z)
. И даже реализовать подобное невозможно, кажется, учитывая то что в правила валидации приходит значение только одного поля.Способа внедрить в конструктор в валидаторы тоже не вижу, т.к. см. п.1. Ну типа есть, например, сервис, предоставляющий анализ текста на "мат", как его всунуть в валидатор, чтоб сказать:
#[DoesNotContainSwearWords(criteria: 0.2)]
, который уже делегируется нужному валидатору, который содержит инфу и локализации, и нужные коннекты к сервису и прочее.Контекста и прочей инфы тоже нет, т.к. см. п.1
А в виде пакета как это поставить и посмотреть? Ну вот я хочу использовать у себя этот пакет отдельно в проекте, например...
Могу ещё накидать чем это хуже)
Ну т.е. если сравнивать с типичным кодом Битрикса, то это огромный шаг вперёд и выглядит на первый взгляд даже годно. Тут можно только поздравить, что у разработчиков наконец удалось написать код, от которого не хочется сразу вырвать себе глаза. Но это касается исключительно неймспейса
Bitrix\Main\Validation\Xxx
...А если же сравнивать с тем же Symfony, то, например у меня, сразу возникает такой же вопрос "зачем": Т.к. фактически это копипаста существующего решения, но с худшей реализацией и наличием критичных (в т.ч. архитектурных) косяков, которые не позволяют нормально использовать этот компонент. Ну помимо того, что использовать его просто невозможно ввиду отсутствия в пакагисте)))
Отсюда, с точки зрения здравого смысла и этот вопрос: Зачем делать то, что и так существует, только архитектурно неправильно и в целом хуже? И варианта кроме как "потому что могут" тут, боюсь что нет.
rpsv
29.10.2024 13:08Чем хуже конкретно данный валидатор?
Подкол был в том, что у вас возникли вопросы к Битрикс, и не возникли к Yii и Laravel, которые тоже свои валидаторы сделали, а не заиспользовали Symfony ;)
1. Атрибуты, которые семантически должны представлять из себя классы метадаты отвечают ещё за валидацию. Смешение логики и правил маппингов.
А почему вы так решили? Валидацией занимаются валидаторы, атрибуты привязываются к полю и поставляют валидаторы, которыми это поле нужно проверить. В самой статье есть отдельный абзац и пример про использование валидаторов без атрибутов.
2. В примерах кода не вижу вообще возможностей вынести правила валидации отдельно от DTO, например в yaml, php, json, etc. Чтобы правила можно было указывать во внешних файлах и изолировать от DTO.
Как мне кажется, нигде кроме symfony вы и не найдёте вынос правил валидации в yaml файл :D
Нужно ли валидацию насколько далеко уносить от кода, который её использует? Очень сомнительно.
3. Не вижу зависимых правил и сложных выражений, типа
When X then (Y or Z)
. И даже реализовать подобное невозможно, кажется, учитывая то что в правила валидации приходит значение только одного поля.Если речь про атрибут свойства - то да, валидироваться будет только значение одного свойства.
Если сделать атрибут класса - то он принимает весь объект и может формировать любые правила и условия. В статье проверка нескольких полей отражена в примере
AtLeastOnePropertyNotEmpty
Напишите пример как это должно работать по вашему мнению, желательно на примере валидаторов симфони, раз уж вы начали сравнивать с ним ;)
4.Способа внедрить в конструктор в валидаторы тоже не вижу, т.к. см. п.1. Ну типа есть, например, сервис, предоставляющий анализ текста на "мат", как его всунуть в валидатор, чтоб сказать:
#[DoesNotContainSwearWords(criteria: 0.2)]
, который уже делегируется нужному валидатору, который содержит инфу и локализации, и нужные коннекты к сервису и прочее.А в чем проблема? Создаете атрибут
DoesNotContainSwearWords
, который принимает все настройки и поставляет нужный валидатор, создаете собственно этот валидаторDoesNotContainSwearWordsValidator
, который реализует необходимую вам логику.5.Контекста и прочей инфы тоже нет, т.к. см. п.1
А прочей это какой? И насколько это надо для операции валидации конкретного поля?
Опять же если сопроводите примерами из symfony, будет невероятно великолепно ;)
6.А в виде пакета как это поставить и посмотреть? Ну вот я хочу использовать у себя этот пакет отдельно в проекте, например...
А вы в целом работали с Битрикс? Знаете что он не через composer устанавливается? :)
А если же сравнивать с тем же Symfony, то, например у меня, сразу возникает такой же вопрос "зачем": Т.к. фактически это копипаста существующего решения, но с худшей реализацией и наличием критичных (в т.ч. архитектурных) косяков, которые не позволяют нормально использовать этот компонент. Ну помимо того, что использовать его просто невозможно ввиду отсутствия в пакагисте)))
Потому что Битрикс это не библиотечка, которая собирается из packagist-a . Внедрять сторонний компонент, с кучей сторонних зависимостей, в коммерческий проект, который затем неизбежно будет зависеть от этого стороннего решения - крайне плохая мысль. Тем более если речь про конкретный функционал.
Адаптация и интеграция того самого великолепного решения, которое лучше в тыщу раз, под используемые в Битрикс классы и сущности также не задача из разряда "просто сделай composer require".
Ну и вам уже надо определиться с вашим отношением к валидации в Битрикс: это всё-таки "копи-паста" или же это "худшая реализация" ;)
Отсюда, с точки зрения здравого смысла и этот вопрос: Зачем делать то, что и так существует, только архитектурно неправильно и в целом хуже? И варианта кроме как "потому что могут" тут, боюсь что нет.
Я тоже с точки зрения здравого смысла не понимаю зачем нужны еще какие-то решения и фреймворки, если есть symfony ;)
gomoloff
29.10.2024 13:08Чем рутина написания валидации для конкретной формы на РНР или Джаваскрипте или подключением какого нибудь "симфонийского валидатора" отличается от рутины написания классов с атрибутами и функциями?
И как это работает на фронте?
Adgh
Для 1С-Битрикс: Управление сайтом тоже будет доступно?
callStatic Автор
Да, потому что функционал находится в модуле main