Подсистемы ядра 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 версии 4 | 36 символов, криптостойкие случайные байты |
Numerator::create() / load() / getNext() | номера по шаблону | update() для существующего, не save() |
Update\Stepper | фоновая обработка порциями | execute() возвращает CONTINUE_EXECUTION или FINISH_EXECUTION |
Stepper::bind($seconds) | постановка отложенного агента | запуск не мгновенный |
PersistentStorageInterface | key-value-хранилище с TTL | PSR-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: данные переживают запрос и исчезают сами. Именно туда стоит класть прогресс, чекпоинты и временные токены.
Связанные темы
- Ядро D7 - Application, контроллеры, события, Result
- D7 ORM - работа с базой
- Шифрование и защита - шифрование секретов, JWT, CAPTCHA
- Раздел Ядро D7
- Настройки проекта - файлы, опции и разделение по стендам