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

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

Урок №5. Общий код в модуль: базовый класс и трейты

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

В уроке 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; читается как способность компонента, и её можно убрать, не трогая иерархию.

ℹ️ Если это в новинку трейт подмешивается в класс на этапе компиляции — с точки зрения PHP его методы становятся методами самого класса. Отсюда доступ к $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 и пройдём тот же маршрут на компоненте из вашего проекта.

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

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

на связи

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

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

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

Войти