Чистый код в компонентах Битрикса: class.php
Урок №6. Настройки в админке и маршрут рефакторинга
В прошлом уроке у компонента появился родитель: 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.
Значения по умолчанию объявлены дважды и в разных ролях. Второе место — 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–6 по одному, с отдельным коммитом на каждый, сверяя страницу с эталоном, снятым на шаге 1.
- Заполните приёмку до и после: сколько SQL-запросов (под своей учёткой и под другим пользователем); сколько строк в
executeComponent; что ушло вBaseComponentи трейты; что осталось специфичным. - Последний пункт самый показательный. Если специфичны только выборка и форматирование, маршрут пройден целиком. Если специфичного больше половины файла, посмотрите, что из этого повторяется в соседних компонентах.
- Проверка на человеке: попросите коллегу за две минуты найти, где выборка, где форматирование и где ставится заголовок. Не уложился — декомпозиция не закончена.
- Сравните результат с эталоном «до/после»: lesson-01-legacy и lesson-06-final. Сравнивайте не строки, а границы: что где лежит и кто за что отвечает.
Шесть уроков назад у нас был component.php на сто с лишним строк, от тридцати до сорока запросов на десять новостей, ссылка «Редактировать» в кэше у анонимного посетителя и файл шаблона, который тихо переписывал заголовок страницы. Теперь у компонента контракт параметров в двух местах, кэш, который знает о группах пользователя и сбрасывается при правке новости, выборка с постоянным числом запросов, инфраструктура в модуле и форма настроек, для которой не нужен разработчик.
Финальный class.php про новости, а у вас каталог и отзывы, так что копировать его смысла мало. Пригодится другое: маршрут из шести шагов с критериями готовности; понимание, что class.php сам по себе ничего не чинит, а чинят декомпозиция, контракт и явные зависимости; привычка проверять кэш и число запросов от лица другого пользователя.
Компонент — небольшая граница. Как только появляются платежи, интеграции, фоновые задачи или API для мобильного приложения, начинается уровень модуля: сервисный слой и ServiceLocator, контроллеры и маршрутизация, ORM-таблеты и события.
Начните с одного компонента на этой неделе и покажите diff команде: общий BaseComponent окупается тем быстрее, чем больше людей им пользуется.
AI Домовой История
на связи
Привет! Я AI Домовой
Помогу с вопросами по 1С-Битрикс: D7, ORM, компоненты, события.
Чем подробнее задача — тем точнее ответ. Иногда готовлю развёрнутое решение.
Дневной лимит исчерпан. Сброс завтра.
Увеличить лимит с поддержкой