Чистый код в компонентах Битрикса: class.php
Урок №5. Общий код в модуль: базовый класс и трейты
В уроке 4 мы разобрали executeComponent() на изолированные методы, перевели выборку на D7 ORM (\Bitrix\Iblock\Elements\ElementNewsTable) и убрали поход в базу за автором на каждой итерации. Компонент стал чище, но заметная часть его кода не про новости. Проверка модуля, разбор CACHE_TYPE, сборка ключа кэша, try/catch с abortResultCache() — каркас, который придётся дословно скопировать в каждый следующий компонент проекта. Сегодня мы вынесем его в модуль project.core (код урока — в снапшоте lesson-05-inheritance): базовый класс для всех компонентов и трейты для способностей, нужных не всем. Начнём с того, во сколько обходится этого не делать.
Одни и те же строки в каждом компоненте
Откройте class.php из урока 4 и отметьте всё, что не про новости.
| Фрагмент | Строк |
|---|---|
Нормализация CACHE_TIME и CACHE_GROUPS |
3 |
checkModules() |
6 |
getAdditionalCacheId() |
13 |
catch с abortResultCache() и ShowError() |
4 |
Двадцать шесть строк из ста восьмидесяти восьми. Возьмите обычный корпоративный сайт — новости, акции, вакансии, отзывы, документы, партнёры, услуги, сертификаты: десяток списочных компонентов, и в каждом те же двадцать шесть строк.
Пока они одинаковые, это терпимо. Стоимость появляется, когда они перестают быть одинаковыми: требование учесть в ключе кэша регион посетителя — правка на пять минут, умноженная на десять файлов. Девять поправили, про десятый забыли — он в разделе «Сертификаты», куда заходят раз в месяц. Через месяц оттуда приходит жалоба на чужие данные, и связать её с той правкой будет некому.
Дублирование плохо не лишними строками. Плохо то, что одна правка перестаёт быть одной правкой.
Почему модуль, а не init.php
Общий код можно положить в /local/php_interface/init.php, который ядро подключает на каждом хите. У модуля есть четыре вещи, которых у этого файла нет:
- Автозагрузка: классы из
lib/подхватываются по имени, без строкrequire. - Установка и удаление: модуль виден в админке, ставится и снимается кнопкой, а момент подключения контролируется явно — код, не вызвавший
Loader::includeModule('project.core'), упадёт сразу. - Версия:
install/version.phpотвечает, какой каркас стоит на этом сайте. - Переносимость: папка копируется на другой проект целиком — и это же признак границы: код, который переносится без правок, живёт в модуле.
Что ещё даёт проекту собственный модуль, кроме общих классов, разобрано в статье «Проектный модуль в Битриксе: зачем нужен и что даёт».
Собираем project.core
В репозитории модуль лежит в папке module/, компонент — в component/. На стенде Omut папка module/ становится ~/Omut/components.bitrix/www/local/modules/project.core/, поэтому в листингах ниже стоит путь на стенде. Имя папки обязано совпадать с ID модуля посимвольно, вместе с точкой. Внутри четыре файла: install/version.php, install/index.php и два класса в lib/ — Component/BaseComponent.php, Traits/WithSeo.php.
php www/bitrix/bitrix.php make:module project.core (аргумент с ID обязателен, папка всегда www/local/modules/). Набор файлов у генератора свой и шире нашего, поэтому структуру ниже мы разбираем руками.Шаг 1: version.php
Файл install/version.php:
<?php
// /local/modules/project.core/install/version.php
$arModuleVersion = [
'VERSION' => '1.0.0',
'VERSION_DATE' => '2026-06-20 00:00:00',
];
Файл ничего не возвращает: он объявляет переменную, которую читает подключивший. Формат даты жёсткий.
Шаг 2: install/index.php
Файл install/index.php, конструктор сокращён:
// /local/modules/project.core/install/index.php
use Bitrix\Main\ModuleManager;
class project_core extends CModule
{
public $MODULE_ID = 'project.core';
public $MODULE_VERSION;
public $MODULE_VERSION_DATE;
public $MODULE_NAME = 'Базовые классы проекта';
public $MODULE_DESCRIPTION = 'Базовые компоненты, трейты и хелперы';
// конструктор подключает version.php и заполняет
// MODULE_VERSION и MODULE_VERSION_DATE
public function DoInstall(): void
{
ModuleManager::registerModule($this->MODULE_ID);
}
public function DoUninstall(): void
{
ModuleManager::unRegisterModule($this->MODULE_ID);
}
}
Имя класса не произвольное: это ID модуля, где точка заменена на подчёркивание. Опечатка оборачивается модулем, которого нет в списке, без сообщения об ошибке. DoInstall() и DoUninstall() — две кнопки в интерфейсе: одна записывает модуль в таблицу установленных, вторая убирает. Когда появятся свои таблицы и обработчики событий, код пойдёт сюда же — и DoUninstall() обязан откатывать всё, что сделал DoInstall().
CModule — класс ядра, от которого наследуются все установщики, включая коробочные. Админка сканирует install/index.php в папках модулей, берёт из объекта имя и версию, а DoInstall() вызывает по кнопке «Установить».Шаг 3: классы в lib/
Папка lib/ — корень пространства имён модуля. ID project.core даёт префикс Project\Core\: точка становится разделителем, каждая часть пишется с заглавной буквы. Дальше путь читается буквально: Project\Core\Component\BaseComponent лежит в lib/Component/BaseComponent.php. Регистрация через Loader::registerAutoLoadClasses() не нужна. Загрузчик Битрикса пробует два пути: в регистре имени класса (lib/Traits/WithSeo.php) и целиком строчными (lib/traits/seotrait.php). Строчные имена остались в старом коде ядра, новый, как и наш модуль, пишется по PSR-4: путь повторяет пространство имён буква в букву. Смешанный вариант вроде lib/traits/WithSeo.php найдётся на macOS, где файловая система не различает регистр, и не найдётся на боевом Linux.
Файла include.php, который часто кладут в корень модуля, у нас нет. Loader::includeModule('project.core') сам проверяет, что модуль установлен и его папка на месте, и сам регистрирует автозагрузку Project\Core\ из lib/. include.php ядро подключает, только если файл есть: он нужен, когда при подключении модуля надо что-то выполнить, например объявить константы. Нам нечего туда положить.
Устанавливаем модуль на стенде
Скопируйте содержимое module/ из репозитория в ~/Omut/components.bitrix/www/local/modules/project.core/. Откройте админку http://components.bitrix/bitrix/ → Marketplace → Установленные решения (/bitrix/admin/partner_modules.php), найдите в списке «Базовые классы проекта» и нажмите «Установить».
Искать модуль в Настройки → Настройки продукта → Модули бесполезно. Та страница показывает только модули без точки в ID, то есть модули ядра: module_admin.php вызывает ModuleManager::getModulesFromDisk() с выключенными партнёрскими модулями. Всё, что с точкой, включая ваши модули из /local/modules/, живёт в «Установленных решениях».
Проверяем: модуль виден в админке
После перезагрузки «Базовые классы проекта» в списке со статусом «Установлен» и версией 1.0.0, а вместо «Установить» доступно «Удалить». Вторая проверка — что установка ничего не сломала: http://components.bitrix/news/ выводит список как в конце урока 4.
Не сработало?
- Модуля нет в списке. Сначала проверьте, что вы в Marketplace → Установленные решения, а не в списке модулей ядра. Дальше — имя папки: оно должно совпадать с ID,
project.core, неproject_coreи неcore. Третья причина — ошибка вinstall/index.php: админка молча пропускает файл, из которого не получается классproject_core. - Класс
Project\Core\Component\BaseComponentне найден. Либо не вызванLoader::includeModule('project.core')перед первым обращением к классу, либо структура папок вlib/не соответствует пространству имён, включая регистр.
BaseComponent: общий каркас
Файл lib/Component/BaseComponent.php:
// /local/modules/project.core/lib/Component/BaseComponent.php
declare(strict_types=1);
namespace Project\Core\Component;
use Bitrix\Main\Loader;
use Bitrix\Main\SystemException;
use Bitrix\Main\Engine\CurrentUser;
abstract class BaseComponent extends \CBitrixComponent
{
protected function checkModules(array $modules): void
{
foreach ($modules as $module) {
if (!Loader::includeModule($module)) {
throw new SystemException("Модуль {$module} не установлен");
}
}
}
protected function abort(string $message): void
{
$this->abortResultCache();
ShowError($message);
}
public function onPrepareComponentParams($params): array
{
$params['CACHE_TIME'] = (int)($params['CACHE_TIME'] ?? 3600);
// CACHE_TYPE ядро уже привело к A, Y или N до вызова этого метода
$params['CACHE_GROUPS'] = $params['CACHE_GROUPS'] ?? 'Y';
return $params;
}
protected function isAdmin(): bool
{
return CurrentUser::get()->isAdmin();
}
}
В листинге опущен getAdditionalCacheId(): он переехал из урока 4 дословно, вместе с признаком is_authorized в ключе. Терять при переезде защиту, которая у компонента уже была, нельзя: базовый класс, который кэширует хуже наследника, никто не захочет использовать.
Класс abstract: на страницу его не поставить, executeComponent() в нём нет, точку входа наследник пишет сам. checkModules() принимает массив, поэтому компоненту с двумя зависимостями хватает одного вызова; отказ — исключение, его подхватит try/catch наследника. В abort() важен порядок: сначала abortResultCache(), потом ShowError(), иначе текст ошибки уедет в кэш и будет показываться ещё CACHE_TIME секунд после того, как причину устранят.
onPrepareComponentParams() занимается только параметрами кэша — теми, что есть у любого компонента. Здесь у контракта слабое место: наследник обязан вызвать parent::onPrepareComponentParams($params) первой строкой. Забыл — нормализация не отработала, CACHE_TIME не существует, и компонент упадёт на первом же startResultCache(). Базовый класс не может заставить потомка себя вызвать, это плата за наследование.
Чего в базовом классе быть не должно
Наследование — самый дорогой способ переиспользования. База становится контрактом: когда на ней стоят десять компонентов, изменение сигнатуры метода — правка в десяти местах и проверка десяти страниц. Поэтому в базовый класс кладут стабильное, а не «пока что общее».
Три повторения, а не два. Два одинаковых фрагмента могут оказаться совпадением, и вынесенное на втором повторении часто приходится возвращать обратно.
Ничего доменного. checkModules() уместен, getIblockElements() — нет: компонент формы обратной связи унаследует знание об инфоблоках, которое не может отключить.
Ничего, что половина наследников переопределит. Симптом виден сразу: потомок объявляет пустую заглушку, чтобы выключить поведение базы.
Спорный кандидат у нас один — isAdmin(): роль нужна не каждому. Он остаётся как однострочная обёртка без состояния и зависимостей. setSeo() и работа с инфоблоками нужны не всем и тянут зависимости, поэтому в базе их нет.
Трейты вместо второго уровня наследования
Наследование в PHP одиночное, и это ограничение проектирует за вас. Положите SEO-методы в BaseComponent — их получат все, включая компоненты, которым нечего писать в <title>. Заведите SeoBaseComponent extends BaseComponent — и тому, кому нужны и SEO, и инфоблоки, понадобится IblockSeoBaseComponent. Дальше комбинаторика: на пять способностей — тридцать один базовый класс.
Трейт отвечает на другой вопрос: наследование говорит «кто я», трейт — «что я умею». Строка use WithSeo; читается как способность компонента, и её можно убрать, не трогая иерархию.
$this и то, что проверить трейт через instanceof нельзя: типом он не является.У трейтов свои минусы. Трейт не может потребовать контекста: если его методу нужен $this->arParams, выразить это требование нечем. Два трейта с одноимёнными методами дают фатальную ошибку при объявлении класса, и если вы дошли до разрешения конфликта через insteadof — стоит пересобрать разделение.
WithSeo и глобальный $APPLICATION
Файл lib/Traits/WithSeo.php без третьего метода:
// /local/modules/project.core/lib/Traits/WithSeo.php
namespace Project\Core\Traits;
trait WithSeo
{
protected function setSeo(string $title, string $description = ''): void
{
global $APPLICATION;
$APPLICATION->SetTitle($title);
if ($description !== '') {
$APPLICATION->SetPageProperty('description', $description);
}
}
protected function addBreadcrumb(string $title, string $link = ''): void
{
global $APPLICATION;
$APPLICATION->AddChainItem($title, $link);
}
}
Третий метод, setOgTags(), выставляет og:-свойства страницы, он есть в полной версии трейта.
В уроке 1 мы записали global $USER в антипаттерны, а здесь global $APPLICATION стоит в каждом методе. Противоречия нет, потому что это разные по природе вещи.
$USER — источник данных. У него есть D7-замена CurrentUser::get(), которую видно в сигнатуре, можно подменить в тесте и вызвать вне HTTP-запроса. Компонент, читающий global $USER, берёт зависимость мимо контракта: по коду метода не видно, что результат зависит от того, кто смотрит на страницу.
$APPLICATION — приёмник. SetTitle(), SetPageProperty() и AddChainItem() и есть штатный SEO API Битрикса для публичной части: другого способа положить строку в <title> ядро не предлагает, D7-обёртки нет. Вызов ничего не читает — он пишет в объект страницы, которого вне HTTP-запроса не существует.
Второе отличие важнее. В уроке 1 к $APPLICATION претензия была в захардкоженных строках, а не в самой глобальной переменной: компонент сам решал, что страница называется «Новости компании». В трейте решения нет — есть транспорт: setSeo() принимает значения снаружи, а в уроке 6 они станут параметрами компонента. Одно возражение остаётся: два таких компонента на одной странице перетрут заголовок друг друга — но это свойство API страницы.
Компонент после переезда
Файл component/class.php в сокращении. На стенде он лежит в www/local/components/project/news.list/:
// /local/components/project/news.list/class.php
namespace Project\Components;
use Bitrix\Main\Loader;
use Project\Core\Component\BaseComponent;
use Project\Core\Traits\WithSeo;
Loader::includeModule('project.core');
class NewsListComponent extends BaseComponent
{
use WithSeo;
public function onPrepareComponentParams($params): array
{
$params = parent::onPrepareComponentParams($params);
// NEWS_COUNT и SORT_ORDER — приведение типов, как в уроке 4
return $params;
}
}
Защита B_PROLOG_INCLUDED и импорты ORM опущены — с урока 4 они не изменились.
Первая новая строка здесь — namespace Project\Components;, и она закрывает настоящую проблему. В уроке 2 мы отметили, что имена классов компонентов глобальные: второй NewsListComponent на проекте валит страницу фатальной ошибкой при объявлении класса. Неймспейс закрывает ровно эту проблему — если вы ведёте свой компонент, заводите его сразу, а не когда конфликт уже случился. Ядру он не мешает: класс компонента оно находит по файлу class.php, а не по имени.
Loader::includeModule('project.core') стоит на уровне файла, до объявления класса: extends BaseComponent разрешается при подключении файла. Первая строка onPrepareComponentParams() — вызов родителя: параметры кэша приходят из базы, параметры новостей добавляются здесь.
Точка входа после переезда, из того же component/class.php:
// /local/components/project/news.list/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());
// выборка, авторы и $arResult — как в уроке 4, только IS_ADMIN теперь $this->isAdmin()
$this->includeComponentTemplate();
}
$this->setSeo('Новости компании', 'Последние новости нашей компании');
$this->addBreadcrumb('Новости', '/news/');
} catch (\Exception $e) {
$this->abort($e->getMessage());
}
}
Исчезли checkModules(), getAdditionalCacheId(), нормализация кэша и две строки в catch: файл сократился с 188 строк до 129, и при этом добавились хлебные крошки.
Проверяем: компонент работает от базового класса
Внесите правки в компонент на стенде или замените его файлы содержимым component/. Очистите кэш и откройте http://components.bitrix/news/. Список выводится ровно как в конце урока 4: те же элементы, тот же порядок, заголовок «Новости компании», под администратором ссылки «Редактировать», у гостя их нет.
Затем проверьте, что базовый класс берётся из модуля: переименуйте lib/Component/ в Component_off и обновите страницу — вы получите Class "Project\Core\Component\BaseComponent" not found. Верните имя обратно.
Не сработало?
BaseComponent not foundпри обычной загрузке. Не вызванLoader::includeModule('project.core')до объявления класса либо модуль не установлен.TypeError: Cannot assign null to property Bitrix\Main\Data\Cache::$ttl of type int. ВonPrepareComponentParams()наследника нет вызоваparent::onPrepareComponentParams($params):CACHE_TIMEне появился, и в кэш ушёлnullвместо времени жизни. Ошибка фатальная,catch (\Exception $e)её не ловит —TypeErrorне наследуется отException.- Страница отдаёт прежний HTML. Кэш не очищен: набор
$arParamsне изменился, значит и ключ прежний. Очистите кэш через админку.
Полный код — в lesson-05-inheritance.
Задача — завести project.core в своём проекте, а не в учебном.
Шаг 1. Соберите модуль. Папка /local/modules/project.core/, три файла: install/version.php, install/index.php и пока пустой lib/Component/BaseComponent.php. Установите через админку.
Шаг 2. Найдите повторы. Откройте три любых class.php из проекта рядом и выпишите фрагменты, которые совпадают дословно во всех трёх, — не «похожи», а совпадают. Обычно это проверка модулей, обработка ошибок и параметры кэша. Встретившееся дважды оставьте в покое.
Шаг 3. Наполните базовый класс. Перенесите найденное в BaseComponent, проверяя каждый метод вопросом: понадобится ли он компоненту формы обратной связи?
Шаг 4. Заведите трейты. То, что нужно части компонентов, оформите трейтом: WithSeo — отправная точка, но берите свои повторяющиеся способности.
Шаг 5. Переведите два компонента. Именно два: на одном не видно, работает ли обобщение. Проверьте оба на живых страницах, со сбросом кэша.
Критерий готовности: из обоих компонентов удалено всё, что теперь живёт в модуле, и обе страницы выводят то же, что до переезда.
Контрольный вопрос, важнее практики: назовите фрагмент, который повторяется в ваших компонентах, но который вы не стали выносить в базовый класс, и сформулируйте причину. Если такого не нашлось — скорее всего, вы вынесли лишнее.
У проекта появился модуль project.core, и каркас компонентов живёт в одном месте. BaseComponent держит то, что нужно всем: проверку модулей, параметры кэша, дополнительный ключ, обработку отказа. Трейт — то, что нужно части: SEO. Компонент новостей похудел на 59 строк.
Наследование и трейты здесь нужны ради одного: чтобы правка в общем коде оставалась правкой в одном файле. Всё остальное в уроке — о том, как это не потерять: выносить на третьем повторении, а не на втором, не класть в базовый класс ничего доменного, способности подключать трейтами, а не вторым уровнем наследования.
Чего у компонента до сих пор нет — настройки. Все значения приходят из вызова IncludeComponent() в файле страницы: контент-менеджер не может ни выбрать инфоблок, ни поменять количество новостей — в визуальном редакторе у компонента пустая форма, описания параметров для админки нет. В уроке 6 мы добавим .parameters.php и .description.php и пройдём тот же маршрут на компоненте из вашего проекта.
AI Домовой История
на связи
Привет! Я AI Домовой
Помогу с вопросами по 1С-Битрикс: D7, ORM, компоненты, события.
Чем подробнее задача — тем точнее ответ. Иногда готовлю развёрнутое решение.
Дневной лимит исчерпан. Сброс завтра.
Увеличить лимит с поддержкой