← Вернуться к курсу

Чистый код в компонентах Битрикса: class.php

Урок №1. Аудит legacy-компонента и подготовка стенда

Текст • 30 мин
Введение

В каждом проекте старше трёх лет есть такой файл. 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 и отметьте, что он делает:

  1. валидирует параметры и раздаёт значения по умолчанию;
  2. подключает модули;
  3. управляет кэшем;
  4. ходит в базу за элементами;
  5. форматирует данные — картинки, даты, имя автора;
  6. принимает решения по правам (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.

  1. Создайте сайт в Omut, способ установки — «Под ключ»: окружение само поставит актуальный Битрикс и заведёт базу. Домен — components.bitrix.
  2. Корень сайта — ~/Omut/components.bitrix/. Публичная часть лежит в www/, это и есть DOCUMENT_ROOT. Composer и vendor/ лежат рядом с www/, вне веба. Наш код живёт в ~/Omut/components.bitrix/www/local/, дальше в тексте эта папка называется /local/.
  3. Публичная часть — 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 консоль ядра не запускается.

⚠️ Важно
Omut занимает порты 80 и 443, если они свободны, иначе поднимается на 8765 и 8443. Тогда во всех примерах добавляйте порт: 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-код

Заведите инфоблок, из которого компонент будет читать данные.

  1. Админка → Контент → Инфоблоки → Типы инфоблоков, выберите тип (если типов нет, создайте, например, content) → добавить инфоблок.
  2. Название — «Новости», символьный код — news.
  3. В карточке найдите поле «Символьный код API» и впишите News. Сохраните.
  4. Запомните ID инфоблока: он виден в списке инфоблоков и в адресе карточки.
ℹ️ Если это в новинку по символьному коду API ядро генерирует для инфоблока собственный ORM-класс: для кода 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, методика одна.

  1. Объём. Сколько строк в главном файле или главном методе? Больше 200 — перед вами God Object.
  2. Обязанности. Сколько пунктов из шести (валидация, модули, кэш, выборка, форматирование, права и SEO) живут в одном месте? Выпишите номера строк.
  3. Глобальное состояние. Найдите все global, $GLOBALS, $_GET, $_POST, $_SESSION, $USER. Для каждого ответьте: это должно приходить в параметрах?
  4. N+1. Найдите обращения к базе внутри циклов — в компоненте, в result_modifier.php и в шаблоне. Посчитайте SQL-запросы на загрузку страницы и запишите цифру. Если новости на стенде заводили вы сами, помните про CUser::GetByID(): под своей учёткой вы увидите меньше запросов, чем получит гость.
  5. Кэш. От чего зависит результат компонента и что из этого попало в ключ кэша? Отдельно проверьте зависимость от прав и группы пользователя.
  6. Теневая логика. Выберите любое поле, которое выводит шаблон, и за 60 секунд найдите все места, где оно меняется. Не уложились — поле неуправляемое.
  7. Контракт. Есть ли .parameters.php и совпадает ли он с тем, что компонент читает из $arParams?

На выходе — список из 5–10 пунктов с номерами строк и цифрой запросов. В шестом уроке вы отрефакторите этот компонент по маршруту курса и сравните обе версии.

Заключение

У вас есть стенд components.bitrix с инфоблоком новостей и работающий legacy-компонент на нём, а главное — диагноз по строкам. На стенде он стоит 32 запроса на десять новостей, а на сайте, где новости пишут редакторы, — 41.

Урок можно считать пройденным, если вы за минуту объясните коллеге, почему анонимный посетитель видит ссылку «Редактировать», и объяснение будет про ключ кэша, а не «там плохой код».

Первое, что делают с таким компонентом, — переносят в class.php. Шаг правильный, в следующем уроке мы его сделаем: компонент заработает на классе, а кэш вместо ручного CPHPCache возьмёт на себя ядро. Только сам перенос не убирает ни одной проблемы из этого урока, он собирает их в один метод на полторы сотни строк. Поэтому в уроке 2 «Переносим компонент в class.php» два шага: сначала миграция «в лоб», потом раскладка по жизненному циклу компонента.

Мы используем файлы cookie для улучшения работы сайта. Продолжая использовать сайт, вы соглашаетесь с нашей политикой конфиденциальности.
AI Домовой

AI Домовой История

на связи

пишет…
Нет истории чатов
AI Домовой

Нужна авторизация

Войдите, чтобы задавать вопросы AI Домовому.

Войти