Перейти к содержимому

Подсистемы ядра D7 - логирование, валидация, GeoIP, Stepper

В ядре D7 есть набор готовых сервисов, которые обычно пишут руками и плохо: логирование, валидация входных данных, определение страны по IP, генерация идентификаторов и номеров, длительные фоновые операции, временное хранилище с истечением срока. Разберём каждый и покажем, чем он лучше самописного варианта.

Как это работает

Логирование идёт по стандарту PSR-3. Интерфейс \Psr\Log\LoggerInterface с восемью уровнями от emergency до debug и сообщениями с плейсхолдерами вида {ключ}, которые заполняются из массива контекста. Готовые реализации: FileLogger пишет в файл, SysLogger в системный журнал, EventLogger в таблицу журнала событий. Уровень, приёмник и форматтер настраиваются декларативно в секции loggers файла /bitrix/.settings.php, а код зависит только от интерфейса.

Валидация задаётся атрибутами. Подсистема Bitrix\Main\Validation заменяет россыпь ручных проверок: на свойства класса или прямо на параметры действия вешаются атрибуты NotEmpty, Email, Range, Length, PhoneOrEmail и другие, а проверку выполняет ValidationService. Работает через рефлексию, поэтому видит и приватные свойства; сначала проверяются атрибуты свойств, потом атрибуты класса.

GeoIP - это цепочка обработчиков. Фасад Service\GeoIp\Manager определяет страну, город и координаты по IP. Обработчиков три: GeoIP2 работает на локальных файлах MaxMind без сети, MaxMind обращается к облачному API, Sypex Geo - к внешнему сервису. Приоритет настраивается в админке, результаты кешируются.

UUID вместо самодельных идентификаторов. UuidGenerator::generateV4() даёт 36-символьный непредсказуемый идентификатор на криптостойких случайных байтах. Нужен там, где числовой автоинкремент раскрывать нельзя: публичные ссылки, correlation id, opaque-токены.

Numerator генерирует номера по шаблону. Служебные слова {NUMBER}, {YEAR}, {RANDOM}, {PREFIX} обрабатывает цепочка генераторов. Нумератор гарантирует уникальность последовательных номеров даже при одновременных запросах и умеет держать независимые счётчики по хешу.

Stepper выполняет долгую работу порциями. Наследник Bitrix\Main\Update\Stepper реализует метод execute(), который обрабатывает очередную порцию и сохраняет состояние между запусками. Запускается как отложенный агент, прогресс показывает элемент «Градусник».

Хранилище с TTL по стандарту PSR-16. Для состояния, которое должно пережить запрос и само истечь, есть key-value-хранилище PersistentStorageInterface с обязательным TTL у метода set(). Доступно с main 25.1100.0.

Примеры

1. Логирование: три способа получить логгер

Основной путь для сервиса - зависимость от интерфейса в конструкторе:

use Psr\Log\LoggerInterface;
final class PortalSyncService
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public function sync(int $portalId): void
{
$this->logger->info('Portal sync started', ['portalId' => $portalId]);
// ...
}
}

Класс не знает, куда пишется лог, - это решает конфигурация. Значения из контекста подставляются в плейсхолдеры сообщения форматтером.

Если логгер нужен лениво и только на границе, берут его по константному id:

use Bitrix\Main\Diag\LoggerFactory;
$logger = (new LoggerFactory())->createById('vendor.example.queue_receiver');
$logger->warning('Message moved to retry queue', [
'messageId' => $messageId,
'queue' => 'retry',
]);

А настраивается этот id в /bitrix/.settings.php:

'loggers' => [
'value' => [
'vendor.example.queue_receiver' => [
'className' => \Bitrix\Main\Diag\FileLogger::class,
'constructorParams' => ['/var/log/php/queue.log', 1048576],
'level' => \Psr\Log\LogLevel::INFO,
],
],
'readonly' => true,
],

Уровень и приёмник меняются без правки бизнес-кода. Замыкания-конструкторы держите в .settings_extra.php: обычный .settings.php перезаписывается при сохранении настроек из админки.

Прямое создание уместно в инфраструктурном коде:

use Bitrix\Main\Diag\FileLogger;
use Psr\Log\LogLevel;
$logger = new FileLogger($_SERVER['DOCUMENT_ROOT'] . '/log.txt', 0); // 0 - без ротации
$logger->setLevel(LogLevel::ERROR);
$logger->error('Error creating user ID={USER_ID}.', ['USER_ID' => 100]); // запишет
$logger->debug('debug', ['x' => 1]); // не запишет: ниже уровня ERROR

Чего не стоит делать: логировать пароли, токены, идентификаторы сессий и полное тело запроса. Пишите безопасную сводку - длину токена или факт его наличия. Для значений, которые нужно хранить, а не просто не светить в логе, в ядре есть шифрованные поля и SecretField - они разобраны в статье «Шифрование и защита».

2. Валидация: атрибуты вместо ручных проверок

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;
}
$validation = \Bitrix\Main\DI\ServiceLocator::getInstance()->get('main.validation.service');
$result = $validation->validate($user);
if (!$result->isSuccess()) {
foreach ($result->getErrors() as $error) {
echo $error->getMessage();
}
}

В контроллере проверку можно вынести на уровень сигнатуры - тогда действие вообще не выполнится с невалидными данными:

use Bitrix\Main\Validation\Rule\{Email, InArray, Range, Length, NotEmpty, PositiveNumber};
final class ContactController extends \Bitrix\Main\Engine\Controller
{
public function createAction(
#[PositiveNumber] int $userId,
#[NotEmpty] #[Length(min: 3, max: 100)] string $name,
#[Email(strict: true, domainCheck: true)] string $email,
#[InArray(['lead', 'client', 'partner'], strict: true)] string $type,
#[Range(0, 100)] int $progress,
): array
{
return ['userId' => $userId, 'name' => $name];
}
}

Для сложного входа собирают DTO и подставляют его через ValidationParameter в getAutoWiredParameters() - объект будет собран из запроса и проверен до вызова действия, а при ошибке клиент получит стандартный ответ со списком ошибок.

Две тонкости: для диапазона берите один атрибут Range(min, max), а не пару Min и Max; и помните, что проверка типа элементов массива не проверяет его непустоту - для этого нужен отдельный NotEmpty.

3. GeoIP

use Bitrix\Main\Service\GeoIp\Manager;
$geoResult = Manager::getDataResult(
'92.50.195.50',
'ru',
['countryName', 'cityName', 'latitude', 'longitude'] // обязательные поля
);
if ($geoResult && $geoResult->isSuccess()) {
$data = $geoResult->getGeoData();
echo $data->cityName; // Калининград
echo $data->countryName; // Россия
echo $data->latitude; // 54.70649
} else {
echo 'Нет данных для этого IP';
}

getDataResult() возвращает данные первого обработчика, который отдал все запрошенные поля, иначе null - проверка обязательна. Когда нужен один атрибут и пустая строка при промахе допустима, проще взять Manager::getCountryCode().

Важный нюанс для фоновых задач и вебхуков: если не передать IP явно, менеджер определит адрес текущего клиента через getRealIp() - и вы получите геоданные не того, кого ожидали.

4. UUID и Numerator

use Bitrix\Main\UuidGenerator;
$publicCode = UuidGenerator::generateV4(); // f4dd98e2-5bc4-4fce-b04a-95d37e6554dc
$publicLink = 'https://mysite.ru/share/' . $publicCode;

Внутренний числовой идентификатор и структура базы остаются скрытыми, а ссылку нельзя подобрать перебором. Функции uniqid() и mt_rand() для этого не годятся: они не дают криптографической непредсказуемости.

use Bitrix\Main\Numerator\Numerator;
use Bitrix\Main\Numerator\Generator;
$numerator = Numerator::create();
$numerator->setConfig([
Numerator::getType() => [
'name' => 'Мой нумератор',
'template' => '{PREFIX}__{YEAR}/{NUMBER}--{RANDOM}',
],
Generator\RandomNumberGenerator::getType() => ['length' => 6],
Generator\SequentNumberGenerator::getType() => ['start' => 3, 'step' => 2],
Generator\PrefixNumberGenerator::getType() => ['prefix' => 'DOC'],
]);
$result = $numerator->save();
$numerator = Numerator::load($result->getId());
echo $numerator->getNext(); // DOC__2025/003--A3F9K2

Один нумератор умеет вести несколько независимых счётчиков по хешу: getNext('A') вернёт 1, getNext('B') тоже 1, а повторный getNext('A') уже 2. Удобно для схемы «свой счётчик на менеджера». Существующий нумератор правят методом update(), а не повторным save() - иначе при параллельных запросах будут конфликты.

5. Stepper: обработка порциями

namespace MyCompany\MyModule\Stepper;
use Bitrix\Main\Update\Stepper;
class ExampleStepper extends Stepper
{
protected static $moduleId = 'mycompany.mymodule';
public function execute(array &$option)
{
if (empty($option)) {
$option['steps'] = 0; // сделано
$option['count'] = $this->calculateTotal(); // всего
$option['last_processed_id'] = 0;
}
$processed = $this->processNextBatch((int)$option['last_processed_id'], 50);
$option['last_processed_id'] = $processed['last_id'];
$option['steps'] += $processed['count'];
return ($option['steps'] < $option['count'])
? self::CONTINUE_EXECUTION
: self::FINISH_EXECUTION;
}
}
ExampleStepper::bind(120); // запуск через 120 секунд

За один вызов обрабатывается порция в 50 элементов, а в состоянии сохраняется только то, что нужно для продолжения: последний обработанный идентификатор и счётчики. Складывать в состояние объёмные данные нельзя - массив сериализуется при каждом сохранении.

Ожидать мгновенного старта не стоит: это отложенный агент, он выполняется при обращениях к сайту и не чаще раза в секунду, а «Градусник» опрашивает сервер раз в пять минут.

Справочник API

APIНазначениеОсобенности
Psr\Log\LoggerInterfaceконтракт логированиявосемь уровней, плейсхолдеры из контекста
Diag\LoggerFactory::createById()логгер по строковому idсовременный путь; Logger::create() помечен deprecated
Diag\FileLogger / SysLogger / EventLoggerприёмники логафайл, системный журнал, таблица журнала событий
секция loggers в .settings.phpконфигурация логгеровуровень, приёмник, форматтер; замыкания - в .settings_extra.php
Validation\Rule\*правила как атрибутыNotEmpty, Email, Phone, Range, Length, InArray, PositiveNumber
main.validation.serviceсервис валидацииберётся из ServiceLocator, возвращает ValidationResult
ValidationParameterвалидируемый DTO в контроллереобъект собирается и проверяется до вызова действия
Service\GeoIp\Manager::getDataResult()набор геополей с проверкойвернёт null, если ни один обработчик не отдал все поля
Manager::getCountryCode() и другие геттерыодно полепри промахе возвращают пустую строку
UuidGenerator::generateV4()UUID версии 436 символов, криптостойкие случайные байты
Numerator::create() / load() / getNext()номера по шаблонуupdate() для существующего, не save()
Update\Stepperфоновая обработка порциямиexecute() возвращает CONTINUE_EXECUTION или FINISH_EXECUTION
Stepper::bind($seconds)постановка отложенного агентазапуск не мгновенный
PersistentStorageInterfacekey-value-хранилище с TTLPSR-16; у set() TTL обязателен; с main 25.1100.0

Частые ошибки

Логирование через AddMessage2Log() в новом коде. Симптом: логи есть, но уровнями и приёмниками не управлять. Используйте PSR-3: зависимость от LoggerInterface или фабрику по id, а настройку выносите в .settings.php.

Замыкание в конфигурации логгера пропадает. Причина: настройки сохранили из админки, и .settings.php перезаписался. Замыкания-конструкторы должны лежать в .settings_extra.php.

Результат GeoIP разыменовывают без проверки. Симптом: обращение к методу на null. getDataResult() возвращает null, если ни один обработчик не отдал все запрошенные поля. И не путайте семантику промаха: удобные геттеры возвращают пустую строку, а позиция - null.

Пустой IP там, где нужен не текущий клиент. Симптом: у всех записей в фоновой обработке один и тот же город. Без явного адреса менеджер берёт IP текущего запроса.

uniqid() вместо UUID. Симптом: идентификаторы предсказуемы, ссылки подбираются. Для непубличных opaque-идентификаторов нужен generateV4(). И обратное: если идентификатор обязан быть одинаковым для одних и тех же входных данных, UUID v4 не подходит вовсе - стройте его хешем от доменных данных.

Временное состояние кладут в Option. Симптом: настройки модуля распухают, записи не истекают, каждое сохранение дорого обходится. Прогресс, чекпоинты и токены - в хранилище с TTL, а не в настройках.

Частые вопросы

Чем PSR-3 логирование лучше AddMessage2Log?

Кодом, который не привязан к способу записи. Класс зависит только от интерфейса, а уровень, приёмник и форматтер настраиваются в .settings.php по идентификатору логгера - их можно поменять, не трогая бизнес-логику. Плюс структурированный контекст: значения передаются массивом и подставляются в плейсхолдеры, вместо склейки дампов в одну строку.

Валидация атрибутами заменяет проверки в коде?

Она заменяет рутинные проверки формата: непустота, длина, диапазон, email, телефон, вхождение в список. Бизнес-правила, которым нужны запросы к базе или сложный контекст, остаются в прикладном коде. Удобная схема - атрибуты в сигнатуре действия для простых скаляров и DTO с ValidationParameter для сложного входа.

Когда нужен Stepper, а когда обычный агент?

Агент - это периодическая задача, которая каждый раз делает работу целиком. Stepper нужен, когда работа слишком велика для одного запуска: он обрабатывает порцию, сохраняет состояние между вызовами и показывает прогресс. Правило по состоянию: храните в нём минимум - счётчики и последний обработанный идентификатор, потому что массив сериализуется при каждом сохранении.

Чем PersistentStorage отличается от кеша и от настроек модуля?

Кеш можно потерять в любой момент - это ускорение, а не хранилище. Настройки модуля не истекают, и запись в них дорогая: сбрасывается кеш модуля и рассылается событие. PersistentStorage - это key-value с обязательным TTL: данные переживают запрос и исчезают сами. Именно туда стоит класть прогресс, чекпоинты и временные токены.

Связанные темы

Первоисточники