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

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

Урок №6. Настройки в админке и маршрут рефакторинга

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

В прошлом уроке у компонента появился родитель: BaseComponent из модуля project.core забрал проверку модулей, разбор параметров кэша, getAdditionalCacheId() и abort(), а трейт WithSeo — мета-теги. Логика ужалась до пяти методов. Компонент на странице http://components.bitrix/news/ делает семь запросов, и это число не растёт вместе со списком, а legacy из первого урока делал 32 на десяти новостях и полторы сотни на пятидесяти. Осталось одно: компонент невозможно настроить, не открывая PHP. Чтобы вывести три новости вместо десяти, контент-менеджер идёт к разработчику, а в дереве компонентов визуального редактора компонента нет вовсе.

Сегодня закрываем эту дыру: .description.php и .parameters.php превращают набор файлов в продукт, которым пользуются без вас. Потом собираем финальный class.php и разбираем, какое решение в нём из какого урока пришло. В конце шесть уроков сворачиваются в маршрут рефакторинга с критериями готовности на каждом шаге.

Что видит ядро, а что видит человек

Пока компонент вызывают из PHP-кода страницы, движку хватает папки компонента и файла class.php. Два файла, имя которых начинается с точки, нужны людям: .description.php — визитка компонента в дереве визуального редактора, .parameters.php — его форма настроек.

В выполнении компонента ни один из них не участвует, поэтому их легко не написать. Расплачиваться за это будете не вы. Контент-менеджер не может изменить количество элементов и заводит задачу. Второй разработчик не знает, какие параметры компонент понимает, и читает onPrepareComponentParams построчно или копирует вызов с соседней страницы вместе с чужими параметрами.

Визитка: .description.php

.description.php целиком, после защитной строки с B_PROLOG_INCLUDED:

        // lesson-06-final/.description.php

$arComponentDescription = [
    'NAME' => 'Список новостей (курс)',
    'DESCRIPTION' => 'Учебный компонент курса «Чистый код в компонентах»',
    'PATH' => [
        'ID' => 'project',
        'NAME' => 'Project',
    ],
];

    

NAME — подпись в дереве и в заголовке окна настроек. DESCRIPTION — подсказка под названием: сюда пишут то, чего не видно по имени, а не «выводит список». PATH — раздел дерева: компоненты проекта с одинаковым PATH собираются в одну ветку и не теряются среди компонентов ядра.

ℹ️ Если это в новинку дерево компонентов — левая панель окна «Добавить компонент» в режиме правки. Ядро строит его, обходя /local/components/ и /bitrix/components/ и читая .description.php. Нет файла — компонент по имени работает, но в дереве его нет.

Форма настроек: .parameters.php

Второй файл — контракт компонента в форме, понятной редактору. Вот .parameters.php:

        // lesson-06-final/.parameters.php

$arComponentParameters = [
    'GROUPS' => [],
    'PARAMETERS' => [
        'NEWS_COUNT' => [
            'PARENT' => 'BASE',
            'NAME' => 'Количество элементов',
            'TYPE' => 'STRING',
            'DEFAULT' => '10',
        ],
        'SORT_ORDER' => [
            'PARENT' => 'BASE',
            'NAME' => 'Направление сортировки по дате',
            'TYPE' => 'LIST',
            'VALUES' => [
                'DESC' => 'DESC',
                'ASC' => 'ASC',
            ],
            'DEFAULT' => 'DESC',
        ],
        // Ядро достроит группу «Настройки кеширования» и поле CACHE_TYPE само
        'CACHE_TIME' => ['DEFAULT' => 3600],
        'CACHE_GROUPS' => [
            'PARENT' => 'CACHE_SETTINGS',
            'NAME' => 'Учитывать права доступа',
            'TYPE' => 'CHECKBOX',
            'DEFAULT' => 'Y',
        ],
    ],
];

    

Ключ массива — имя параметра, ровно то, которое компонент читает из $arParams. NAME — подпись поля в форме. Её увидит человек, который никогда не откроет class.php, поэтому «Количество элементов» лучше, чем NEWS_COUNT. TYPE — тип поля: STRING даёт текстовый ввод, LIST — выпадающий список из VALUES, CHECKBOX — галочку, которая отдаёт 'Y' или 'N'.

SORT_ORDER показывает, зачем нужен LIST: пока направление сортировки набирают руками, рано или поздно в параметрах окажется desc или DES.

CACHE_TIME объявлен одной строкой, и это сделано намеренно. Увидев этот ключ, ядро само создаёт группу «Настройки кеширования», поле «Тип кеширования» с вариантами «Авто + Управляемое», «Кешировать», «Не кешировать» и подсказку про состояние автокеширования. Расписывать CACHE_TYPE руками бесполезно: я проверил на стенде, CComponentUtil::GetComponentProps() молча заменяет такое поле своим.

CACHE_GROUPS — рабочая галочка. Именно она в BaseComponent::getAdditionalCacheId() подмешивает группы пользователя в ключ кэша — тот механизм, которым в третьем уроке мы закрыли отравление кэша. Теперь это решение видно в интерфейсе.

ℹ️ Если это в новинку PARENT — группа, в которую попадёт поле. BASE («Основные параметры») и CACHE_SETTINGS («Настройки кеширования») ядро знает само, поэтому массив GROUPS остался пустым: свои группы объявляют, когда стандартных не хватает.

Два места, где живут значения по умолчанию

DEFAULT из .parameters.php — значение, которым редактор заполнит поле формы. До компонента оно доезжает не напрямую: сохранив настройки, редактор переписывает вызов IncludeComponent в коде страницы, и компонент получает те же $arParams.

ℹ️ Под капотом параметры компонента хранятся не в базе, а в тексте страницы. Поэтому один и тот же компонент на двух страницах настроен независимо, а правки через редактор видны в git как обычный diff.

Значения по умолчанию объявлены дважды и в разных ролях. Второе место — onPrepareComponentParams() в class.php:

        // lesson-06-final/class.php

$params['NEWS_COUNT'] = min(max((int)($params['NEWS_COUNT'] ?? 10), 1), 100);
$params['SORT_ORDER'] = in_array($params['SORT_ORDER'] ?? '', ['ASC', 'DESC'], true)
    ? $params['SORT_ORDER']
    : 'DESC';

    

.parameters.php — витрина: показывает человеку, какие параметры можно изменять, и подставляет разумное начальное значение. onPrepareComponentParams — охрана: работает с тем, что реально пришло. Через форму значение приезжает из списка, а через IncludeComponent может прийти что угодно: строка, ноль, сто тысяч. От второго случая защищают min(max(..., 1), 100) и белый список для SORT_ORDER.

⚠️ Важно
Ядро не сверяет форму с кодом. Если поле есть в .parameters.php, а компонент его не читает, контент-менеджер меняет настройку, и ничего не происходит. Обратный случай заметить сложнее: компонент читает параметр, а в форме его нет. Например, SORT_ORDER без поля в .parameters.php работает, но поменять его можно только правкой вызова в коде страницы, и знает о нём только тот, кто читал class.php.

Проверяем: форма настроек открывается в редакторе

Положите оба файла рядом с class.php в www/local/components/project/news.list/, откройте http://components.bitrix/news/ под администратором и включите режим правки. Должно получиться так:

  • окно настроек компонента открывается с заголовком «Список новостей (курс)»;
  • в «Основных параметрах» — «Количество элементов» со значением 10 и «Направление сортировки по дате» — список с DESC и ASC;
  • в «Настройках кеширования» — «Тип кеширования» со значением «Авто + Управляемое», «Время кеширования (сек.)» — 3600, «Учитывать права доступа» — включённая галочка;
  • в окне «Добавить компонент» он виден в разделе «Project».

Теперь измените «Количество элементов» на 3 и сохраните. На странице останется три новости и подпись «Всего новостей: 3», а в вызове IncludeComponent появится строка 'NEWS_COUNT' => '3' — редактор вписал её сам.

Не сработало?

  • Форма открывается, но своих полей в ней нет. Массив в .parameters.php называется не $arComponentParameters, частая опечатка — $arComponentParams. Ядро ищет переменную по имени и молча ничего не находит. Синтаксическая ошибка, наоборот, видна сразу: окно настроек не откроется, а в ответе будет ParseError с номером строки. Проверить синтаксис заранее можно так: php -l www/local/components/project/news.list/.parameters.php.
  • Компонент пропал из дерева. Та же история с .description.php: переменная называется не $arComponentDescription или в ней нет PATH. Вызов по имени при этом продолжает работать, и поломку легко не замечать неделю.
  • Значение по умолчанию не применяется. Параметр объявлен в .parameters.php, но не обработан в onPrepareComponentParams. DEFAULT заполняет поле формы; если параметр не пришёл в вызове, его задаёт только код.
  • Настройки сохраняются, а страница не меняется. Редактор пишет параметры в файл, где стоит вызов: если вызов в шаблоне сайта или во включаемой области, правка уйдёт туда.

Финальный class.php: откуда что пришло

Файл целиком — lesson-06-final/class.php; здесь места, в которых видно решение конкретного урока.

        // lesson-06-final/class.php

Loader::includeModule('project.core');

class NewsListComponent extends BaseComponent
{
    use WithSeo;

    

Loader::includeModule('project.core') стоит до объявления класса намеренно: пока модуль не подключён, автозагрузка его классов не работает и PHP не найдёт родителя. Строка с трейтом заодно документирует компонент: видно, что он умеет ставить мета-теги.

getRawData() — единственное место, где компонент ходит в базу за элементами:

        // lesson-06-final/class.php, getRawData()

        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();

    

Это четвёртый урок: D7 ORM вместо CIBlockElement::GetList, перечисленные поля вместо всех подряд, шаблон детальной страницы одним JOIN через IBLOCK.DETAIL_PAGE_URL, а в адрес он превращается в formatItems(). Соседний loadAuthors() берёт авторов одним обращением к UserTable вместо вызова CUser::GetByID на каждой новости.

executeComponent() помещается на экран и читается как оглавление:

        // lesson-06-final/class.php

    public function executeComponent(): void
    {
        try {
            $this->checkModules(['iblock']);

            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' => $this->isAdmin(),
                ];

                $this->setResultCacheKeys(['ITEMS', 'TOTAL_COUNT', 'IS_ADMIN']);
                $this->includeComponentTemplate();
            }

            $this->setSeo('Новости компании', 'Последние новости нашей компании');
        } catch (\Exception $e) {
            $this->abort($e->getMessage());
        }
    }

    

checkModules(), getAdditionalCacheId(), isAdmin() и abort() — методы родителя. IS_ADMIN считается до includeComponentTemplate() и попадает в setResultCacheKeys(), а группы и признак авторизации учтены в ключе кэша — это третий урок с анонимным посетителем и ссылкой «Редактировать». Тег инфоблока — четвёртый урок: без него ORM оставил бы на странице старую новость до истечения CACHE_TIME. setSeo() стоит вне блока кэша, потому что заголовок нужен на каждом хите.

Между уроком 5 и финалом исчезла одна строка — $this->addBreadcrumb('Новости', '/news/'). Поставьте компонент на страницу «Пресс-центр», и в крошках появятся чужие «Новости»: адрес раздела компоненту знать неоткуда. Метод в WithSeo остался, вызывать его стоит там, где адрес известен.

result_modifier.php в финальном шаблоне нет: пустой файл ядру не нужен, без него шаблон работает так же. Всё, что он делал в первом уроке, либо переехало в класс, либо удалено как ошибка: перезапись NAME, SetTitle поверх компонента, чтение $_GET мимо контракта, запрос свойства на каждой новости.

До и после на цифрах

Метрики снапшотов lesson-01-legacy и lesson-06-final. Запросы я считал на стенде курса (main 26.800.0, iblock 26.0.100, PHP 8.4) с выключенным кэшем компонента, по строкам с трассировкой на файлы компонента:

Legacy Final
Строк в файле компонента 114 128
Самый длинный кусок логики 97 строк (весь файл — один поток) 26 строк (executeComponent())
SQL на 10 новостях, автор — вы 32 7
SQL на 10 новостях, автор — другой редактор 41 7
SQL на 50 новостях, автор — другой редактор 201 7
Глобальные переменные $USER и $APPLICATION в трёх файлах $APPLICATION в WithSeo
Типизация нет да

Строки. Финальный файл длиннее legacy, и в таблице нет ошибки. Прибавились declare(strict_types=1), типы аргументов и результатов, контракт параметров и разбиение одного потока на пять методов. Часть кода ушла в BaseComponent и трейты, часть исчезла вместе с ручным CPHPCache, но в сумме компонент не сжался, и это нормально.

Мерить здесь стоит не объём (он вырос ещё и на целый модуль), а сколько строк нужно прочитать, чтобы ответить на вопрос «где формируется дата». В legacy это весь файл и два файла шаблона, в финале — formatItems(). О том же вторая строка таблицы: в legacy логика идёт одним потоком от $arParams до IncludeComponentTemplate() и читается только сверху вниз, а в финале самый длинный метод — executeComponent() на 26 строк.

SQL. Из семи запросов финала наших два: новости и авторы. Ещё пять ядро тратит на сборку класса ElementNewsTable: ищет инфоблок по коду API, читает свойства и языки. Под своей учёткой на десяти новостях разница 32 против 7, а дальше legacy прибавляет по три запроса на каждую новость, финал — ни одного. Общий счётчик внизу страницы будет больше: рядом считаются запросы ядра.

Глобальные переменные. $USER заменён на CurrentUser::get() в BaseComponent::isAdmin(), $GLOBALS['APPLICATION'] из модификатора удалён вместе с логикой, которая перетирала заголовок. Осталась одна: global $APPLICATION в WithSeo, потому что SetTitle() и SetPageProperty() — штатный API Битрикса без D7-замены. Раньше $APPLICATION был размазан по трём файлам, теперь заперт в одном трейте.

Типизация. Сигнатура formatItems(array $items, array $authors): array сообщает, что метод получает всё нужное аргументами и в базу не ходит. Этот контракт проверяет IDE, а не ваша память.

Маршрут рефакторинга: шесть шагов

Шаг соответствует уроку, каждый заканчивается работающей страницей и отдельным коммитом. Порядок важен: декомпозиция до контракта параметров превращается в перекладывание одного кода дважды, а базовый класс до декомпозиции нечем наполнять.

Шаг 1: Аудит и точка отката

Чек-лист первого урока: обязанности, глобальное состояние, запросы в циклах, ключ кэша, теневая логика в шаблоне.

Критерий готовности: есть список из 5–10 пунктов с номерами строк и число SQL-запросов, снятое не только под своей учёткой; состояние закоммичено; сохранён HTML страницы, с которым вы будете сверяться дальше.

Если стало хуже: ломать здесь нечего. Но если компонент не меняли два года и в планах его нет, дальше можно не идти — см. следующий раздел.

Шаг 2: Перенос в class.php

Код переезжает «в лоб» в executeComponent(), $arParams и $arResult — в свойства объекта. Оптимизаций на этом шаге нет.

Критерий готовности: страница выдаёт тот же результат. Проверяется сравнением сохранённого HTML, а не ощущением «вроде так же».

Если стало хуже: пока component.php лежит рядом, откат — переименование class.php. Частая причина расхождений — код, который раньше выполнялся в глобальной области и полагался на переменные страницы.

Шаг 3: Контракт параметров и кэш

onPrepareComponentParams с типами, значениями по умолчанию и белыми списками. Ручной CPHPCache меняется на startResultCache() с дополнительным ключом, персональные данные — на явный флаг в $arResult.

Критерий готовности: зайдите на страницу администратором, затем откройте её же в приватном окне. Кнопок редактирования и персональных данных там быть не должно. Эту проверку не пропускайте: она ловит утечку между пользователями.

Если стало хуже: данные перестали обновляться или показываются чужие — временно выставьте CACHE_TYPE в N и разбирайтесь с составом ключа и тегами, а не со временем жизни кэша.

Шаг 4: Декомпозиция и ORM

executeComponent делится на выборку, дозагрузку связанных сущностей и форматирование; запросы уходят из циклов, выборка — на ORM, тег инфоблока регистрируется явно.

Критерий готовности: число SQL-запросов не растёт при увеличении количества элементов; в форматировании нет обращений к базе; правка элемента видна без очистки кэша; executeComponent короче 40 строк.

Если стало хуже: пропали поля в шаблоне — сверьте select с тем, что читает template.php. В legacy половина полей приезжала неявно, потому что выбиралось всё подряд.

Шаг 5: Общее в модуль

В BaseComponent переезжает то, что повторяется, опциональные способности — в трейты.

Критерий готовности: второй компонент проекта переведён на BaseComponent и потерял обвязку, ничего не сломав. Одним компонентом базовый класс не проверяется.

Если стало хуже: в базовый класс уехало то, что нужно одному компоненту, и наследники начали обходить родителя. В базу переезжает то, что повторилось трижды, а не то, что «наверняка понадобится».

Шаг 6: Витрина и чистка

.description.php, .parameters.php, шаблон без result_modifier.php, только с выводом и экранированием.

Критерий готовности: контент-менеджер меняет настройки без разработчика; в шаблоне нет обращений к базе; набор полей формы совпадает с тем, что обрабатывает onPrepareComponentParams.

Если стало хуже: параметры не появились или компонент исчез из дерева — разбор в блоке «Не сработало?» выше.

Когда рефакторить не надо

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

Стоит остановиться, если:

  • компонент не меняется. Нет задач в бэклоге, нет жалоб, нет планов на раздел — вернитесь, когда появится первая правка;
  • нечем проверить результат. Нет стенда с боевыми данными, нет возможности открыть страницу под анонимом, нет времени сравнить HTML до и после;
  • код живёт до конца месяца. Лендинг под акцию, временный виджет, компонент под разовую выгрузку;
  • нужна срочная правка в проде. Фикс и рефакторинг в одном коммите — способ потом не найти причину регресса. Сначала фикс, рефакторинг — следующей задачей.

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

🛠 Практическая работа

Диплом курса. Возьмите компонент из аудита первого урока и проведите его по всем шести шагам маршрута, на своём коде и своей странице.

  1. Пройдите шаги 1–6 по одному, с отдельным коммитом на каждый, сверяя страницу с эталоном, снятым на шаге 1.
  2. Заполните приёмку до и после: сколько SQL-запросов (под своей учёткой и под другим пользователем); сколько строк в executeComponent; что ушло в BaseComponent и трейты; что осталось специфичным.
  3. Последний пункт самый показательный. Если специфичны только выборка и форматирование, маршрут пройден целиком. Если специфичного больше половины файла, посмотрите, что из этого повторяется в соседних компонентах.
  4. Проверка на человеке: попросите коллегу за две минуты найти, где выборка, где форматирование и где ставится заголовок. Не уложился — декомпозиция не закончена.
  5. Сравните результат с эталоном «до/после»: lesson-01-legacy и lesson-06-final. Сравнивайте не строки, а границы: что где лежит и кто за что отвечает.
Заключение

Шесть уроков назад у нас был component.php на сто с лишним строк, от тридцати до сорока запросов на десять новостей, ссылка «Редактировать» в кэше у анонимного посетителя и файл шаблона, который тихо переписывал заголовок страницы. Теперь у компонента контракт параметров в двух местах, кэш, который знает о группах пользователя и сбрасывается при правке новости, выборка с постоянным числом запросов, инфраструктура в модуле и форма настроек, для которой не нужен разработчик.

Финальный class.php про новости, а у вас каталог и отзывы, так что копировать его смысла мало. Пригодится другое: маршрут из шести шагов с критериями готовности; понимание, что class.php сам по себе ничего не чинит, а чинят декомпозиция, контракт и явные зависимости; привычка проверять кэш и число запросов от лица другого пользователя.

Компонент — небольшая граница. Как только появляются платежи, интеграции, фоновые задачи или API для мобильного приложения, начинается уровень модуля: сервисный слой и ServiceLocator, контроллеры и маршрутизация, ORM-таблеты и события.

Начните с одного компонента на этой неделе и покажите diff команде: общий BaseComponent окупается тем быстрее, чем больше людей им пользуется.

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

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

на связи

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

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

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

Войти