Чистый код в компонентах Битрикса: class.php
Урок №4. Декомпозиция и D7 ORM: убираем N+1
В прошлом уроке компонент получил контракт параметров и правильный ключ кэша: onPrepareComponentParams() приводит вход к типам и белым спискам, getAdditionalCacheId() кладёт в ключ группы пользователя, setResultCacheKeys() решает, что уедет в файл кэша. Всё остальное по-прежнему в одной куче: выборка, форматирование дат, пути к картинкам и получение авторов живут в executeComponent(), а внутри его цикла на каждой новости вызывается CUser::GetByID(). Сегодня мы разложим точку входа на отдельные методы, заменим CIBlockElement::GetList() на сгенерированный ORM-класс ElementNewsTable и уберём N+1 — сначала в компоненте, потом в result_modifier.php шаблона.
Компонент урока 3 делает на стенде с десятью новостями двенадцать запросов: два из class.php и десять из result_modifier.php. На живом сайте больше, и ниже станет понятно почему.
Код урока — в снапшоте lesson-04-decomposition: компонент в class.php, шаблон в templates/.default/. У фрагментов из репозитория путь к файлу стоит в первой строке листинга. Начнём с замера: «стало быстрее» без цифры «было» — ощущение, а не результат.
Замер: сколько запросов делает страница сейчас
Компонент лежит на стенде Omut в ~/Omut/components.bitrix/www/local/components/project/news.list/, страница с ним — http://components.bitrix/news/.
Шаг 1. Отключите кэш компонента на время замера. В вызове компонента поставьте 'CACHE_TYPE' => 'N'. Иначе второй заход отдаст готовый HTML из кэша: executeComponent() не выполнится вовсе, и вы измерите не тот код, который чините.
Шаг 2. Включите статистику SQL. Авторизуйтесь администратором и откройте http://components.bitrix/news/?show_sql_stat=Y. Режим запоминается в cookie и держится на всех страницах, пока вы не откроете адрес с ?show_sql_stat=N. То же самое включается в админ-панели: «Отладка» → «Статистика SQL-запросов».
Проверяем: счётчик запросов появился внизу страницы
Внизу страницы появляется служебная полоса со временем создания страницы и строкой вида:
Всего SQL запросов: NN
Рядом ссылка «Статистика SQL-запросов». За ней список всех запросов с текстом, временем выполнения и трассировкой «Откуда вызван:».
Общее NN у каждого стенда своё: часть запросов ядро делает само — за опциями, сайтом, сессией и пользователем. Смысл имеет сравнение «до и после» на одной странице, а искать надо свои строки. На стенде курса их двенадцать: десять с трассировкой на result_modifier.php (свойство SOURCE для каждой новости) и два на class.php — список и автор. Запишите оба числа: общее и количество строк компонента.
Автор всего один, хотя новостей десять, по той же причине, что и в уроке 1. Все новости на стенде завели вы и смотрите их тоже вы, а CUser::GetByID() запоминает текущего пользователя и второй раз за ним в базу не ходит. Гость или другой редактор такой поблажки не получают. Если хотите увидеть N+1 своими глазами, заведите второго администратора и снимите замер под ним: строк с class.php станет одиннадцать, а на пятидесяти новостях — пятьдесят одна.
Не сработало?
- Полосы внизу страницы нет. Статистику видит только пользователь с правом на изменение PHP-кода, то есть администратор. Проверьте, что вы авторизованы в той же вкладке.
- Число не меняется от перезагрузки, запросов компонента в списке нет. Страница отдаётся из кэша. Верните
'CACHE_TYPE' => 'N'и очистите кэш. - Число есть, а графа «Откуда вызван:» пустая. В конфигурации определена константа
BX_NO_SQL_BACKTRACE: с ней ядро не собирает трассировку, и привязать запрос к файлу не получится. Уберите её на время замера.
Почему один длинный метод дорого стоит
В executeComponent() сейчас шесть занятий: проверка параметра, подключение модуля, открытие блока кэша, построение фильтра, обход выборки, форматирование элементов. «Так короче» работает до первого дня сопровождения.
Ответственность. Когда в списке пропали даты, вы читаете весь метод, потому что дата собирается там же, где идёт запрос. Разделив выборку и форматирование, вы получаете адрес для каждого класса ошибок: пустой список — getRawData(), кривая дата или ссылка в никуда — formatItems().
Тестируемость. Речь о возможности выполнить кусок кода отдельно, а не о тестах «когда-нибудь». Метод, который только преобразует массив, проверяется подставленным вручную массивом, без базы, инфоблока и HTTP-запроса. Пока обе задачи крутятся в одном цикле, формат даты не проверить без живого инфоблока.
Отсюда критерий деления. Метод отвечает за одну вещь, если его вход и выход можно назвать без слова «и»:
| Метод | Вход → выход | Чего внутри быть не должно |
|---|---|---|
getRawData() |
параметры → сырые строки из базы | форматирования, путей к файлам, дат |
loadAuthors() |
элементы → словарь ID → имя |
циклов с запросом на итерацию |
formatItems() |
элементы и авторы → данные шаблона | любых обращений к базе |
setSeo() |
ничего → заголовок и мета-теги | выборки, кэша |
И правило, которое стоит запомнить дословно: запрос к базе внутри цикла — сигнал вынести batch-метод. У нас этот сигнал подаёт \CUser::GetByID($arItem['CREATED_BY']): десять новостей одного редактора дают гостю десять одинаковых запросов за одним и тем же пользователем.
D7 ORM: откуда берётся ElementNewsTable
Прежде чем разбирать компонент на методы, поменяем источник данных. \Bitrix\Iblock\ElementTable — универсальная таблица b_iblock_element: она знает стандартные поля вроде NAME и ACTIVE_FROM, но не знает свойств конкретного инфоблока. Нам нужен другой класс — \Bitrix\Iblock\Elements\ElementNewsTable.
Он появляется, когда у инфоблока задан символьный код API News: админка → Контент → Инфоблоки → карточка инфоблока → поле «Символьный код API». Вы задали его в уроке 1. Имя класса строится по шаблону Element + код + Table: код News даёт ElementNewsTable.
iblock регистрирует обработчик автозагрузки Bitrix\Iblock\ORM\Loader::autoLoad(): он вырезает из имени класса News, ищет инфоблок с таким API_CODE и вызывает IblockTable::compileEntity(), сущность собирается на лету. Сборка не бесплатная и ядро её не кэширует: на стенде курса это пять запросов (инфоблок, его свойства, языки) в каждом PHP-процессе, где класс понадобился впервые. Платите вы за них только на промахе кэша компонента: при попадании getRawData() не вызывается, класс не загружается, и этих запросов нет. Обработчик приходит с модулем, поэтому checkModules() из урока 2 нужен до первого обращения к сущности.Отсюда следствие: сущность намертво привязана к своему инфоблоку. Условие по IBLOCK_ID она дописывает сама, и параметр IBLOCK_ID компоненту больше не нужен. ID инфоблока знает сама сущность: ElementNewsTable::getEntity()->getIblock()->getId() отдаёт его без запроса к базе. Из контракта параметр уходит вместе с проверкой в executeComponent(), из вызова на странице его тоже можно убрать.
Ловушка 1: DETAIL_PAGE_URL из ORM приходит шаблоном, а не ссылкой
CIBlockElement::GetList() в паре с GetNext() подставлял готовый URL детальной страницы. ORM так не делает: шаблон URL вида #SITE_DIR#/news/#ELEMENT_CODE#/ хранится у инфоблока, а не у элемента. Брать его надо по связи, строкой 'DETAIL_PAGE_URL' => 'IBLOCK.DETAIL_PAGE_URL' в select, а превращать в адрес уже при форматировании, тем же методом ядра, которым это делает GetNext(): \CIBlock::ReplaceDetailUrl($template, $item, false, 'E'). Четвёртый аргумент — тип сущности: 'E' для элемента, 'S' для раздела. Пропустите этот шаг, и в разметку уйдут строки с решётками. Рецепт целиком — в заметке «Как получить DETAIL_PAGE_URL элемента инфоблока в D7».
IBLOCK — поле-ссылка (Reference) у сущности элемента, поэтому IBLOCK.DETAIL_PAGE_URL в select превращается в JOIN внутри того же запроса, отдельного обращения к базе за шаблоном не будет.Ловушка 2: свойство приходит тем же запросом, но не под своим именем
У сгенерированного класса свойства инфоблока — поля сущности, и их можно выбирать тем же запросом, что и элемент. У универсального ElementTable так нельзя: там за свойствами ходят отдельно, тем самым CIBlockElement::GetProperty(), который сидит в цикле нашего result_modifier.php. Ради этого на сгенерированный класс и стоит переходить, типизация в IDE тут вторична.
Есть подвох. Свойство в сущности — не строка, а ссылка на запись значения. Если написать в select только 'SOURCE', fetchAll() вернёт пачку ключей вида IBLOCK_ELEMENTS_ELEMENT_NEWS_SOURCE_VALUE, а ключа SOURCE не будет. Значение берут по связи и сразу дают ему имя:
'select' => [
'ID', 'NAME',
'SOURCE_VALUE' => 'SOURCE.VALUE',
],
Если у инфоблока свойства с кодом SOURCE нет, запрос упадёт с Unknown field definition — так ORM сообщает, что поля нет в сущности. И оговорка на будущее: множественное свойство добавляет JOIN и размножает строки выборки, по строке на каждое значение, а limit считает именно строки. Для таких делайте отдельную выборку.
Ловушка 3: ORM не ставит тег кэша
В уроке 3 правка новости сбрасывала кэш сама: CIBlockResult регистрировал тег iblock_id_N, пока мы перебирали выборку. ElementNewsTable::getList() возвращает D7-результат, а он ничего такого не делает. На стенде это видно сразу: после перехода на ORM правка новости кэш больше не сбрасывает, файл лежит до истечения CACHE_TIME. Тег надо зарегистрировать явно, внутри блока кэша. В class.php это первая строка блока, её видно в executeComponent() ниже:
\CIBlock::registerWithTagCache(ElementNewsTable::getEntity()->getIblock()->getId());
Шаг 1. Слой данных: getRawData()
Все методы ниже лежат в class.php снапшота. Первый отвечает за одно — достать строки. Ни FormatDate, ни CFile, ни проверок прав:
// lesson-04-decomposition/class.php
use Bitrix\Iblock\Elements\ElementNewsTable;
protected function getRawData(): array
{
return ElementNewsTable::getList([
'select' => [
'ID', 'NAME', 'PREVIEW_TEXT', 'PREVIEW_PICTURE',
'ACTIVE_FROM', 'CREATED_BY', 'CODE', 'IBLOCK_ID', 'IBLOCK_SECTION_ID',
'DETAIL_PAGE_URL' => 'IBLOCK.DETAIL_PAGE_URL',
],
'filter' => [
'=ACTIVE' => 'Y',
],
'order' => [
'ACTIVE_FROM' => $this->arParams['SORT_ORDER'],
'ID' => 'DESC',
],
'limit' => $this->arParams['NEWS_COUNT'],
])->fetchAll();
}
Помимо смены API здесь важно вот что. select перечисляет поля явно, и следующий разработчик видит состав $arResult['ITEMS'] на одном экране. CODE, IBLOCK_ID и IBLOCK_SECTION_ID нужны ReplaceDetailUrl(), чтобы подставить их в шаблон адреса, а IBLOCK_ID ещё и шаблону — для ссылки «Редактировать». IBLOCK_ID в фильтре нет, сущность и так знает свой инфоблок. SORT_ORDER и NEWS_COUNT приходят из $arParams уже проверенными в onPrepareComponentParams(), поэтому их можно подставлять в order и limit без дополнительных проверок.
fetchAll() возвращает обычный массив массивов, как и ждёт шаблон. Но типы полей в нём уже D7: ACTIVE_FROM приходит объектом Bitrix\Main\Type\DateTime, а не строкой, поэтому дальше у него вызывается getTimestamp(), а не MakeTimeStamp().Шаг 2. Batch-выборка авторов: loadAuthors()
Второй метод закрывает N+1: сначала собрать все нужные ID, потом сходить за ними один раз.
// lesson-04-decomposition/class.php
use Bitrix\Main\UserTable;
protected function loadAuthors(array $items): array
{
$authorIds = array_unique(array_filter(array_column($items, 'CREATED_BY')));
if ($authorIds === []) {
return [];
}
$result = UserTable::getList([
'select' => ['ID', 'NAME', 'LAST_NAME'],
'filter' => ['=ID' => $authorIds],
]);
$authors = [];
while ($user = $result->fetch()) {
$authors[$user['ID']] = trim($user['NAME'] . ' ' . $user['LAST_NAME']);
}
return $authors;
}
array_column($items, 'CREATED_BY') вытаскивает столбец ID, array_filter убирает нули, array_unique — повторы: десять новостей одного редактора дают один ID в фильтре. Массив в =ID ORM превращает в IN (...). Ранний выход при пустом списке экономит запрос.
Результат — словарь ID → имя, сами элементы метод не трогает, соединение произойдёт уровнем выше. Сколько бы ни было новостей и авторов, за авторами идёт один запрос.
Шаг 3. Слой представления: formatItems()
Третий метод — чистое преобразование: на входе элементы и словарь авторов, на выходе массив для шаблона.
// lesson-04-decomposition/class.php
protected function formatItems(array $items, array $authors): array
{
return array_map(function (array $item) use ($authors) {
if (!empty($item['PREVIEW_PICTURE'])) {
$item['PREVIEW_PICTURE_SRC'] = \CFile::GetPath($item['PREVIEW_PICTURE']);
}
if (!empty($item['ACTIVE_FROM'])) {
$item['ACTIVE_FROM_FORMATTED'] = FormatDate(
'd F Y',
$item['ACTIVE_FROM']->getTimestamp()
);
}
if (!empty($item['CREATED_BY']) && isset($authors[$item['CREATED_BY']])) {
$item['AUTHOR_NAME'] = $authors[$item['CREATED_BY']];
}
$item['DETAIL_PAGE_URL'] = \CIBlock::ReplaceDetailUrl(
(string)($item['DETAIL_PAGE_URL'] ?? ''),
$item,
false,
'E'
);
return $item;
}, $items);
}
Методу всё равно, откуда пришли данные: его можно вызвать с массивом, собранным руками, без инфоблока, авторизации и HTTP-запроса. Он же пригодится, если завтра новости понадобится отдать JSON-ом из контроллера.
Одно исключение: \CFile::GetPath() читает b_file. Ядро держит эту таблицу в кэше (CACHED_b_file), так что в запрос на каждую картинку это обычно не превращается.
Шаг 4. executeComponent() как оглавление
Точка входа больше ничего не делает сама. Она решает, работать или отказать, открывает блок кэша и расставляет вызовы по порядку:
// lesson-04-decomposition/class.php
public function executeComponent(): void
{
try {
$this->checkModules();
if ($this->startResultCache($this->arParams['CACHE_TIME'], $this->getAdditionalCacheId())) {
\CIBlock::registerWithTagCache(ElementNewsTable::getEntity()->getIblock()->getId());
$rawItems = $this->getRawData();
$items = $this->formatItems($rawItems, $this->loadAuthors($rawItems));
$this->arResult = [
'ITEMS' => $items,
'TOTAL_COUNT' => count($items),
'IS_ADMIN' => CurrentUser::get()->isAdmin(),
];
$this->setResultCacheKeys(['ITEMS', 'TOTAL_COUNT', 'IS_ADMIN']);
$this->includeComponentTemplate();
}
$this->setSeo();
} catch (\Exception $e) {
$this->abortResultCache();
ShowError($e->getMessage());
}
}
Метод читается как оглавление: пометить кэш тегом, взять данные, догрузить авторов, отформатировать, посчитать, отдать шаблону. Он не знает, из какой таблицы приходят новости и в каком формате выводится дата. Поменяйте источник на HL-блок или внешний API, и меняться будет один метод из четырёх.
Расстановка вызовов относительно блока кэша осталась из прошлого урока. IS_ADMIN считается внутри блока и до шаблона, потому что от него зависит разметка, а группы и признак авторизации учтены в ключе через getAdditionalCacheId(). setSeo() вызывается снаружи: заголовок страницы не входит в HTML компонента, и при попадании в кэш его надо ставить заново.
SEO-код переехал из хвоста executeComponent() в отдельный метод setSeo(): global $APPLICATION, затем SetTitle() и SetPageProperty('description', …). $APPLICATION здесь остался потому, что у этого API нет штатного D7-аналога. Зато теперь глобальная переменная упомянута в одном методе из шести, и её видно.
Проверяем: компонент на ORM
Верните 'CACHE_TYPE' => 'A', очистите кэш и откройте http://components.bitrix/news/. Список выглядит так же, как до рефакторинга: те же новости в том же порядке, даты вида 20 октября 2026 (FormatDate('d F Y', …) в русской локали отдаёт месяц в родительном падеже со строчной буквы), имя автора под каждой, заголовки и ссылка «Подробнее» ведут на детальную страницу. Под администратором под каждой новостью есть «Редактировать», а в приватном окне их по-прежнему нет.
Теперь поменяйте заголовок любой новости в админке и обновите страницу: новый заголовок виден сразу, это работает тег из ловушки 3.
Снимите замер ещё раз: 'CACHE_TYPE' => 'N', страница с ?show_sql_stat=Y. После рефакторинга строк с class.php стало не меньше, а больше: семь вместо двух. Пять из них — сборка ElementNewsTable из ловушек выше (инфоблок по коду API, свойства, языки), шестой — сами новости одним запросом с JOIN на b_iblock, седьмой — авторы со списком ID в IN (...). Десять строк с result_modifier.php на месте, их мы ещё не трогали.
Под вашей учёткой компонент стал дороже на пять запросов. Выигрыш в другом: число больше не зависит от длины списка. Поставьте NEWS_COUNT равным 50 — строк с class.php по-прежнему семь, хотя под вторым администратором код урока 3 давал на тех же пятидесяти новостях пятьдесят одну. Если у вас список из пяти новостей, который смотрит только администратор, ORM не сэкономит ни одного запроса. Переходить на него стоит ради свойств в одном запросе и ради того, что счёт не растёт вместе с контентом.
Не сработало?
Class "Bitrix\Iblock\Elements\ElementNewsTable" not found. У инфоблока не задан символьный код API или задан другой: нужен именноNews(карточка инфоблока → «Символьный код API» → сохранить). Вторая причина той же ошибки — не подключён модульiblock: обработчик автозагрузки приходит вместе с ним, поэтомуcheckModules()вызывается до первого обращения к сущности.- Ссылки ведут в никуда, в адресе видны решётки вроде
#SITE_DIR#. Вselectне добавлен'DETAIL_PAGE_URL' => 'IBLOCK.DETAIL_PAGE_URL'либо вformatItems()не вызван\CIBlock::ReplaceDetailUrl(): без первого подставлять нечего, без второго шаблон уходит в разметку как есть. - Правка новости видна только после очистки кэша. Нет строки
\CIBlock::registerWithTagCache()или она стоит вне блокаif. - Поле приходит пустым. В выборку попадает только то, что перечислено в
select: у D7 набор ровно такой, как вы написали. Добавьте поле вselect, свойство — в виде'КОД_VALUE' => 'КОД.VALUE'.
Второй N+1: цикл в result_modifier.php
Компонент вычищен, а в замере всё ещё десять лишних запросов. Они из result_modifier.php, доставшегося от урока 1: foreach по $arResult['ITEMS'] и внутри CIBlockElement::GetProperty() за свойством SOURCE.
Этот N+1 живёт в шаблоне, а не в компоненте, поэтому его не находят, читая class.php. С переходом на сгенерированную сущность он лечится двумя правками: добавьте 'SOURCE_VALUE' => 'SOURCE.VALUE' в select метода getRawData(), а цикл из модификатора удалите. В снапшоте урока select без свойства, потому что шаблон его не выводит, а result_modifier.php оставлен как есть, чтобы вы увидели эти запросы в замере. С урока 5 его в шаблоне нет.
В том же файле есть вторая поломка, и она тише первой. Сверьте onPrepareComponentParams() в снапшотах уроков 3 и 4: в уроке 3 там разбирается SHOW_FULL_LIST, в уроке 4 этого параметра в контракте уже нет, а модификатор в первых же строках читает $arParams['SHOW_FULL_LIST']. PHP выдаёт на это предупреждение Undefined array key, но Битрикс по умолчанию предупреждения не показывает: main/include.php убирает E_WARNING из error_reporting. Вы ничего не увидите, а флаг SHOW_ALL никогда не выставится. Я нашёл это, только когда собрал предупреждения отдельным обработчиком. «Показать весь список» решает, какие данные выбрать, вёрстка тут ни при чём, поэтому параметр вместе с логикой должен жить в компоненте. Лечится той же чисткой модификатора; если режим «весь список» вам нужен, объявите параметр в onPrepareComponentParams() и учтите его в getRawData().
Проверяем: модификатор больше не ходит в базу
Удалите цикл с GetProperty() и блок с SHOW_FULL_LIST из templates/.default/result_modifier.php своего компонента, очистите кэш и снимите замер ещё раз. Строк с трассировкой на result_modifier.php в списке не остаётся, разметка не меняется. На стенде курса от компонента остаётся семь запросов вместо двенадцати в начале урока.
Не сработало?
- Запросы никуда не делись. Отредактирован не тот файл: у компонента может быть несколько шаблонов. Смотрите полный путь в трассировке «Откуда вызван:».
Unknown field definitionпосле добавления свойства вselect. У инфоблока нет свойства с таким кодом. Проверьте код в настройках свойств инфоблока, регистр тоже имеет значение.- Изменения не видны. Страница отдаётся из кэша:
result_modifier.phpвыполняется внутри блока кэша и при попадании в кэш не запускается вовсе. Очистите кэш.
Работаем с компонентом, который вы разбирали в аудите урока 1.
Шаг 1. Замер до. Включите статистику SQL, отключите кэш компонента и запишите два числа: общее по странице и количество запросов, у которых в трассировке «Откуда вызван:» стоят файлы вашего компонента. Если данные на стенде заводили вы сами, снимите замер ещё и под вторым администратором: CUser::GetByID() прячет N+1 от автора.
Шаг 2. Найдите цикл с обращением к базе. Он может быть в class.php, в result_modifier.php, в template.php. Признаки — GetByID, GetProperty, GetList или getList внутри foreach или while. Перепишите на batch-выборку по схеме из урока: собрать ID, отсеять пустые и повторы, сходить одним запросом, разложить в словарь ID → значение и подставить при форматировании.
Шаг 3. Разберите точку входа. Вынесите из executeComponent() минимум два метода с одной ответственностью каждый: один только достаёт данные, другой только преобразует их для шаблона. Названия ваши, критерий общий: вход и выход метода описываются без слова «и».
Шаг 4. Замер после. Повторите замер и сравните с записанными числами. Затем удвойте лимит выборки: было 10 элементов — поставьте 20. Число запросов компонента не должно измениться. Если вы перешли на ORM, проверьте и тег: поправьте элемент и убедитесь, что страница обновилась без очистки кэша.
Критерий готовности. Метод форматирования можно вызвать с массивом, собранным руками, и он не пойдёт в базу; удвоение числа элементов не меняет число запросов. Если второе не выполняется, остался цикл с обращением к базе — ищите его по трассировке.
Готовый код для сверки — class.php и templates/.default/ в снапшоте lesson-04-decomposition.
Компонент перестал быть одним методом. Выборка живёт в getRawData(), авторы — в loadAuthors(), подготовка данных для шаблона — в formatItems(), мета-теги — в setSeo(), а executeComponent() читается как оглавление. Источником данных стала сгенерированная сущность ElementNewsTable. Она стоит пять запросов на сборку и не ставит тег кэша сама, зато отдаёт свойства и шаблон адреса тем же запросом, а число запросов больше не растёт со списком.
Декомпозиция даёт две вещи: у каждой ошибки появляется адрес, и любой кусок кода можно выполнить отдельно. Метод, который делает одну вещь, можно проверить, переиспользовать и заменить целиком. Метод, который делает шесть, можно только каждый раз перечитывать сверху вниз.
Теперь посмотрите на компонент глазами человека, который завтра будет писать второй такой же. checkModules(), try/catch с abortResultCache(), getAdditionalCacheId(), нормализация CACHE_TIME и CACHE_GROUPS никак не связаны с новостями. Этот код придётся дословно повторить в каждом следующем компоненте, а потом править во всех местах сразу.
В уроке 5 «Общий код в модуль: базовый класс и трейты» этот каркас уедет в собственный модуль project.core, и компоненты будут собираться из BaseComponent и трейтов.
AI Домовой История
на связи
Привет! Я AI Домовой
Помогу с вопросами по 1С-Битрикс: D7, ORM, компоненты, события.
Чем подробнее задача — тем точнее ответ. Иногда готовлю развёрнутое решение.
Дневной лимит исчерпан. Сброс завтра.
Увеличить лимит с поддержкой