Чистый код в компонентах Битрикса: class.php
Урок №1. Аудит legacy-компонента и подготовка стенда
В каждом проекте старше трёх лет есть такой файл. component.php на сотню с лишним строк выводит список новостей, а написал его человек, который давно уволился. Компонент работает: страница открывается, новости на месте, заказчик доволен. Проблемы начинаются, когда прилетает задача «покажите рядом с новостью источник». Правка на одну строку занимает полдня. Сначала вы ищете, где формируются данные. Потом выясняется, что половина полей появляется в result_modifier.php, о котором в компоненте нет ни слова. А после выкладки анонимные посетители видят ссылку «Редактировать».
За шесть уроков мы проведём один такой компонент от процедурного «списка новостей» до class.php с контрактом параметров, безопасным кэшем, D7 ORM и общей логикой в модуле проекта. Сегодня диагностика и подготовка. Разберём код по строкам, для каждого антипаттерна назовём, что и когда сломается, потом поднимем стенд и поставим на него этого пациента.
Пациент: три файла, которые работают
Исходный код лежит в снапшоте lesson-01-legacy. Это component.php (параметры, кэш, выборка, права, SEO) и два файла шаблона: result_modifier.php, который дописали через полгода, и template.php с выводом и ещё немного логики. Всего около 250 строк, на выходе десять последних новостей с картинкой, датой и автором.
God Object: шесть обязанностей в одном файле
Пройдитесь по component.php и отметьте, что он делает:
- валидирует параметры и раздаёт значения по умолчанию;
- подключает модули;
- управляет кэшем;
- ходит в базу за элементами;
- форматирует данные — картинки, даты, имя автора;
- принимает решения по правам (
IsAdmin) и проставляет SEO страницы.
Шесть зон ответственности в одном скрипте без границ между ними — это God Object. На практике это значит, что ни одну из шести частей нельзя изменить, не прочитав остальные пять. Понадобилась та же выборка в рассылке или в JSON для мобильного приложения — переиспользовать нечего, выборка спаяна с выводом. Остаётся копипаст.
Отдельно посмотрите на валидацию в первых строках файла. intval("abc") вернёт 0, а условие $arParams["NEWS_COUNT"] ? ... : 10 считает строку "abc" заполненным значением. В GetList уедет nTopCount => 0, и ядро молча переключится в ветку постраничной навигации: сначала COUNT(*) по инфоблоку, потом выборка страницы. Такой режим этот компонент никто не проверял. И CModule::IncludeModule("iblock") вызван без проверки результата: если модуль выключен, страница упадёт ниже по коду на неизвестном классе.
Глобальное состояние: зависимости, которых нет в контракте
Сразу после защитной строки с B_PROLOG_INCLUDED в файле стоит global $USER, $APPLICATION;. Внутри цикла выборки на этой основе решается вопрос прав:
// lesson-01-legacy/component.php
if ($USER->IsAdmin()) {
$arItem["CAN_EDIT"] = true;
$arItem["EDIT_LINK"] = "/bitrix/admin/iblock_element_edit.php?ID=" . $arItem["ID"] . "&IBLOCK_ID=" . $arParams["IBLOCK_ID"];
}
Входные параметры компонента — $arParams. Но ведёт он себя ещё в зависимости от двух вещей вне этого контракта: кто сейчас в сессии и в каком состоянии объект страницы.
От этого ломаются три вещи. Результат зависит от пользователя, а кэш — нет; об этом ниже, и это самое дорогое. Компонент нельзя выполнить вне HTTP-запроса: соберите тот же список в консольной команде или агенте, и $USER там окажется не тем, кого вы ждёте, а то и вовсе не будет объектом. И IsAdmin() отвечает на вопрос «админ или нет», а не «может ли этот человек редактировать эту новость»: контент-менеджер с правами на инфоблок кнопок редактирования не увидит.
С $APPLICATION похожая история. В конце файла компонент вызывает SetTitle("Новости компании"), SetPageProperty("description", …) и AddChainItem("Новости", "/news/"), то есть сам решает, как называется страница и что показать в хлебных крошках, причём значения захардкожены. Поставьте его на страницу «Акции» — получите заголовок «Новости компании». Поставьте два таких блока на одну страницу — второй перетрёт заголовок первого. Такой компонент годится ровно для одного места в проекте.
N+1: тридцать запросов там, где хватило бы двух
Основной цикл выборки:
// lesson-01-legacy/component.php
while ($obElement = $rsElements->GetNextElement()) {
$arItem = $obElement->GetFields();
$arItem["PROPERTIES"] = $obElement->GetProperties();
if ($arItem["CREATED_BY"]) {
$rsUser = CUser::GetByID($arItem["CREATED_BY"]);
if ($arUser = $rsUser->Fetch()) {
$arItem["AUTHOR_NAME"] = $arUser["NAME"] . " " . $arUser["LAST_NAME"];
}
}
Один запрос за списком, и внутри цикла по запросу на свойства и на автора для каждого элемента. Это N+1: число обращений к базе растёт вместе с числом элементов.
GetNextElement() возвращает объект-обёртку над строкой выборки. GetFields() отдаёт уже полученные данные, а GetProperties() делает отдельный запрос в таблицу значений свойств: выглядит как чтение поля, стоит как запрос.Цикл в component.php — не единственное место, где компонент ходит в базу. В result_modifier.php дописан ещё один цикл, то самое «покажите источник новости»:
// lesson-01-legacy/templates/.default/result_modifier.php
foreach ($arResult["ITEMS"] as &$arItem) {
$dbProp = CIBlockElement::GetProperty(
$arItem["IBLOCK_ID"],
$arItem["ID"],
array("sort" => "asc"),
Array("CODE" => "SOURCE")
);
И ещё два — в шаблоне: CUser::GetByID() (та же логика автора, продублированная на случай, если поля не окажется) и CIBlockSection::GetByID() за названием раздела. Второй стоит внутри блока if ($USER->IsAdmin() || $arItem["CAN_EDIT"]), рядом со ссылкой «Редактировать».
Я прогнал этот компонент на стенде с десятью новостями и посчитал запросы с привязкой к файлам. Под администратором вышло 32: 12 из component.php (список, десять раз свойства и один раз автор), 10 из result_modifier.php и 10 из шаблона за разделами. Гостю блок с разделом не выводится, но из-за сломанного ключа кэша в файл уезжает именно версия администратора, и дальше она отдаётся гостю вместе с этими десятью запросами. На пятидесяти новостях счёт идёт уже на полторы сотни, и ни одну из этих цифр не видно, если читать один файл.
Почему автор стоил один запрос, а не десять? Все новости на стенде завёл администратор, и он же смотрит страницу. CUser::GetByID() запоминает строку текущего пользователя и при повторном вызове с тем же ID в базу не ходит. Стоило поменять автора новостей на другого пользователя, и component.php дал 21 запрос, а все три файла вместе — 41. На живом сайте новости пишут редакторы, а читают гости, так что считайте по второй цифре.
Кэш без ключа: чужие права в чужом браузере
Компонент кэшируется вручную:
// lesson-01-legacy/component.php
$obCache = new CPHPCache();
$cacheTime = $arParams["CACHE_TIME"];
$cacheId = serialize($arParams);
$cachePath = "/" . SITE_ID . "/news_list/";
if ($obCache->InitCache($cacheTime, $cacheId, $cachePath)) {
Ключ кэша — serialize($arParams), то есть результат считается зависящим только от параметров вызова. А выше по коду в $arResult уехали CAN_EDIT и EDIT_LINK, посчитанные по текущему пользователю. Администратор открывает страницу первым, его результат ложится в файл кэша, и следующий анонимный посетитель получает массив с CAN_EDIT => true и ссылками в админку. Это отравление кэша (cache poisoning), и минут через двадцать вы увидите его на своём стенде. Замените ссылку на редактирование ценой для оптовиков, и это уже инцидент.
У того же ключа есть вторая беда: он ничего не знает о данных. Редактор поправил заголовок новости, а страница ещё час показывает старый, потому что тегированного кэша здесь нет, только время жизни. И штатные настройки кэширования на такой компонент не действуют: передайте CACHE_TYPE => "N" или выключите автокеширование в админке, он всё равно прочитает свой файл.
CPHPCache сохраняет только то, что передали в EndDataCache(), здесь это $arResult. При попадании в кэш шаблон исполняется заново, поэтому запросы из result_modifier.php и template.php кэш не убирает. На стенде «горячая» страница под гостем всё равно сделала 20 запросов.Теневая логика: файл, про который забудут
result_modifier.php в этой тройке самый опасный: из кода компонента не следует, что он вообще существует.
result_modifier.php — необязательный файл шаблона. Если он есть, ядро подключает его перед template.php и отдаёт ему $arResult на изменение. Связь с компонентом держится только на имени файла и его месте: ни вызова, ни подключения в component.php нет.Что он делает с данными:
// lesson-01-legacy/templates/.default/result_modifier.php
if ($_GET["SHOW_FULL_LIST"] === "Y") {
$arResult["SHOW_ALL"] = true;
}
foreach ($arResult["ITEMS"] as &$arItem) {
$arItem["NAME"] = mb_strtoupper($arItem["NAME"]);
$arItem["FORMATTED_NAME"] = "<b>" . $arItem["NAME"] . "</b>";
}
unset($arItem);
if ($arResult["TOTAL_COUNT"] > 5) {
$GLOBALS["APPLICATION"]->SetTitle("Очень много новостей (" . $arResult["TOTAL_COUNT"] . ")");
}
У компонента появился новый входной параметр SHOW_FULL_LIST, прямо из $_GET. Его нет ни в описании параметров, ни в вызове на странице, и через год кто-то будет выяснять, почему список ведёт себя по-разному на двух похожих URL.
Поле NAME перезаписано на месте. Шаблон выводит заглавные буквы, в базе лежит обычный текст, а разработчик, открывший template.php, увидит $arItem["NAME"] и не найдёт, кто его испортил. Соседняя строка собирает HTML прямо в PHP-массиве.
Дороже всего обходится последний блок: SetTitle из модификатора перетирает SetTitle из компонента. В component.php написано «Новости компании», а на странице отображается «Очень много новостей (10)». Это и есть теневая логика: поведение системы определяет файл, о котором основной код ничего не знает.
К этому добавьте вывод в шаблоне без экранирования — <?= $arItem["NAME"] ?>. GetFields() отдаёт данные из базы как есть, и тег <script>, вставленный контент-менеджером в заголовок новости, доедет до публичной страницы рабочим XSS.
Миф: class.php сам по себе ничего не чинит
Самый частый способ «отрефакторить» компонент выглядит так. Создали class.php, объявили класс, скопировали в executeComponent() весь старый код, заменили $arResult на $this->arResult. Файл называется правильно, в резюме можно написать «перевёл компоненты на D7». При этом не изменилось ничего: было 150 строк в одном файле, стало 150 строк в одном методе. N+1 на месте, кэш по-прежнему не знает о пользователе, result_modifier.php живёт своей жизнью. Процедурная лапша от ООП-лапши отличается расширением файла, и вторая, по-моему, хуже: у неё есть видимость архитектуры, поэтому её реже приходят чинить.
Разницу делает не ключевое слово class, а три вещи: декомпозиция (каждый шаг в своём методе с понятным именем), контракт (известно, какие ключи есть в $arResult и кто за них отвечает) и явные зависимости (компонент не лезет в глобальные переменные за тем, что должно приходить снаружи). Им посвящены уроки со второго по шестой. class.php такое разделение позволяет, но сам его не делает.
Стенд: components.bitrix в Omut
Дальше практика: нужен работающий Битрикс, инфоблок с новостями и legacy-компонент на нём. Все команды и адреса в курсе даны для Omut — локального окружения под 1С-Битрикс: nginx, PHP и MySQL нативными бинарниками, без Docker.
- Создайте сайт в Omut, способ установки — «Под ключ»: окружение само поставит актуальный Битрикс и заведёт базу. Домен —
components.bitrix. - Корень сайта —
~/Omut/components.bitrix/. Публичная часть лежит вwww/, это и естьDOCUMENT_ROOT. Composer иvendor/лежат рядом сwww/, вне веба. Наш код живёт в~/Omut/components.bitrix/www/local/, дальше в тексте эта папка называется/local/. - Публичная часть —
http://components.bitrix, админка —http://components.bitrix/bitrix/.
Терминал сайта открывается кнопкой Терминал на карточке components.bitrix, сразу в корне сайта (~/Omut/components.bitrix/, не в www/). php, composer и node там уже в PATH.
php www/bitrix/bitrix.php list
В ответ придёт список команд ядра. Если вместо него Symfony Console is not installed, в корне сайта не выполнен composer install: без зависимостей Composer консоль ядра не запускается.
http://components.bitrix:8765. Актуальный адрес всегда показан на карточке сайта.Работаете не в Omut? Всё, что дальше, работает в любом окружении — меняется только путь до консоли. В Omut
DOCUMENT_ROOT— этоwww/, поэтому команды выглядят какphp www/bitrix/bitrix.php. В Docker, OSPanel или на VDS, где вы запускаете команды из корня сайта Битрикса, пишитеphp bitrix/bitrix.php. Пути внутри/local/одинаковые везде.
Код курса прогнан на ядре main 26.800.0 и модуле iblock 26.0.100, PHP 8.4. Нужен только модуль iblock, а он есть в любой редакции, включая «Старт».
Инфоблок новостей и API-код
Заведите инфоблок, из которого компонент будет читать данные.
- Админка → Контент → Инфоблоки → Типы инфоблоков, выберите тип (если типов нет, создайте, например,
content) → добавить инфоблок. - Название — «Новости», символьный код —
news. - В карточке найдите поле «Символьный код API» и впишите
News. Сохраните. - Запомните
IDинфоблока: он виден в списке инфоблоков и в адресе карточки.
News это \Bitrix\Iblock\Elements\ElementNewsTable. В уроке 4 мы перейдём на него вместо CIBlockElement::GetList, а задать код удобнее сразу.Наполните инфоблок: Контент → Новости → добавьте десяток элементов с названием, датой начала активности и анонсом. Десять нужны для проверки ниже: модификатор меняет заголовок страницы, только когда элементов больше пяти.
Ставим legacy-компонент
Возьмите три файла из снапшота lesson-01-legacy и разложите их так:
~/Omut/components.bitrix/www/local/components/project/news.list/
├── component.php
└── templates/.default/
├── result_modifier.php
└── template.php
project здесь — пространство имён компонента, по нему Битрикс найдёт его при вызове project:news.list. Каталог /local/components/ приоритетнее /bitrix/components/, поэтому ядро мы не трогаем.
Создайте страницу www/news/index.php с вызовом компонента:
<?php
require $_SERVER["DOCUMENT_ROOT"] . "/bitrix/header.php";
$APPLICATION->IncludeComponent(
"project:news.list",
"",
[
"IBLOCK_ID" => 1,
"NEWS_COUNT" => 10,
"CACHE_TYPE" => "A",
"CACHE_TIME" => 3600,
"CACHE_GROUPS" => "Y",
]
);
require $_SERVER["DOCUMENT_ROOT"] . "/bitrix/footer.php";
Подставьте свой IBLOCK_ID. CACHE_TYPE и CACHE_GROUPS переданы намеренно: компонент их не читает, потому что кэширует себя вручную. Они понадобятся со второго урока.
Проверяем: legacy-компонент на стенде
Откройте http://components.bitrix/news/ под администратором. Должно получиться так:
- десять карточек новостей в рамках, с датой и подписью «| Автор: ...»;
- заголовки выведены ЗАГЛАВНЫМИ БУКВАМИ, хотя в базе записаны обычным текстом, — это
mb_strtoupperизresult_modifier.php; - под каждой новостью ссылка «[Редактировать]» в админку;
- внизу списка «Всего новостей: 10»;
- в заголовке вкладки браузера «Очень много новостей (10)», а не «Новости компании», хотя
SetTitle("Новости компании")вcomponent.phpстоит.
Последние два пункта — теневая логика в чистом виде: вы читаете компонент, а видите работу файла шаблона.
Теперь главное. Откройте ту же страницу в приватном окне браузера, где вы не авторизованы. Ссылка «[Редактировать]» с адресом /bitrix/admin/iblock_element_edit.php?ID=… останется на месте: результат вашего $USER->IsAdmin() уехал в файл кэша и отдаётся всем подряд.
Не сработало?
- Сообщение о ненайденном компоненте или пустое место вместо списка — Битрикс не нашёл файлы. Путь должен быть
www/local/components/project/news.list/component.php, а в вызове —"project:news.list"через двоеточие: слева пространство имён (папкаproject), справа имя компонента. - Открылось, но выводится «Новости не найдены» — в вызове не тот
IBLOCK_IDлибо в инфоблоке нет активных элементов: в фильтре стоит"ACTIVE" => "Y". - Фатальная ошибка на классе
CIBlockElement— не установлен или отключён модуль «Информационные блоки» (Настройки → Настройки продукта → Модули).CModule::IncludeModule("iblock")вызван без проверки результата, поэтому вместо понятного сообщения вы получаете падение в неожиданном месте. - В приватном окне ссылок нет — первым страницу открыл гость, и в кэш легла его версия. Очистите кэш (Настройки → Настройки продукта → Автокеширование, вкладка «Очистка файлов кеша», вариант «Все», кнопка «Очистить»), откройте страницу под администратором и только потом в приватном окне.
- Страница не открывается вообще — сверьте адрес и порт на карточке сайта в Omut.
Стенд поднят, эталонный пациент под рукой. Теперь то же самое на вашем коде. Найдите в своём проекте самый страшный компонент: тот, который открывают с опаской, или тот, что чаще всего ломается после правок. Он может быть и на component.php, и на class.php, методика одна.
- Объём. Сколько строк в главном файле или главном методе? Больше 200 — перед вами God Object.
- Обязанности. Сколько пунктов из шести (валидация, модули, кэш, выборка, форматирование, права и SEO) живут в одном месте? Выпишите номера строк.
- Глобальное состояние. Найдите все
global,$GLOBALS,$_GET,$_POST,$_SESSION,$USER. Для каждого ответьте: это должно приходить в параметрах? - N+1. Найдите обращения к базе внутри циклов — в компоненте, в
result_modifier.phpи в шаблоне. Посчитайте SQL-запросы на загрузку страницы и запишите цифру. Если новости на стенде заводили вы сами, помните проCUser::GetByID(): под своей учёткой вы увидите меньше запросов, чем получит гость. - Кэш. От чего зависит результат компонента и что из этого попало в ключ кэша? Отдельно проверьте зависимость от прав и группы пользователя.
- Теневая логика. Выберите любое поле, которое выводит шаблон, и за 60 секунд найдите все места, где оно меняется. Не уложились — поле неуправляемое.
- Контракт. Есть ли
.parameters.phpи совпадает ли он с тем, что компонент читает из$arParams?
На выходе — список из 5–10 пунктов с номерами строк и цифрой запросов. В шестом уроке вы отрефакторите этот компонент по маршруту курса и сравните обе версии.
У вас есть стенд components.bitrix с инфоблоком новостей и работающий legacy-компонент на нём, а главное — диагноз по строкам. На стенде он стоит 32 запроса на десять новостей, а на сайте, где новости пишут редакторы, — 41.
Урок можно считать пройденным, если вы за минуту объясните коллеге, почему анонимный посетитель видит ссылку «Редактировать», и объяснение будет про ключ кэша, а не «там плохой код».
Первое, что делают с таким компонентом, — переносят в class.php. Шаг правильный, в следующем уроке мы его сделаем: компонент заработает на классе, а кэш вместо ручного CPHPCache возьмёт на себя ядро. Только сам перенос не убирает ни одной проблемы из этого урока, он собирает их в один метод на полторы сотни строк. Поэтому в уроке 2 «Переносим компонент в class.php» два шага: сначала миграция «в лоб», потом раскладка по жизненному циклу компонента.
AI Домовой История
на связи
Привет! Я AI Домовой
Помогу с вопросами по 1С-Битрикс: D7, ORM, компоненты, события.
Чем подробнее задача — тем точнее ответ. Иногда готовлю развёрнутое решение.
Дневной лимит исчерпан. Сброс завтра.
Увеличить лимит с поддержкой