D7-роутинг в 1С-Битриксе: отказ от физических index.php

D7-роутинг в 1С-Битриксе: отказ от физических index.php

Зачем уходить от физических файлов

В Битриксе часто устроено так: каждому адресу страницы соответствует свой PHP-файл. Адресу /blog/ — файл blog/index.php, адресу /blog/hello-world/ — файл blog/hello-world/index.php. Внутри каждого файла одно и то же: подключается шаблон сайта, вызывается компонент, прописываются заголовок и описание. Такой подход работает, но у него есть слабые места:

  • Адрес страницы жёстко привязан к файловой структуре. Переименовали папку — сломались ссылки.
  • На каждую новую страницу приходится создавать ещё один файл с повторяющимся кодом.
  • Заголовок, описание и ссылку на «каноническую» версию страницы (canonical) приходится прописывать в каждом файле отдельно.
  • Нет единой точки входа — каждый файл живёт сам по себе.

D7-роутинг эти проблемы убирает: правила для всех страниц лежат в одном файле, вместо множества одинаковых файлов — несколько методов, а заголовки и описания задаются в одном месте.

Как это работает по умолчанию

При стандартной схеме запрос проходит такой путь:

  1. Веб-сервер получает адрес страницы.
  2. Файл urlrewrite.php сравнивает адрес с правилами и ищет для него физический файл.
  3. Выполняется blog/index.php (или другой найденный файл).
  4. Файл подключает шаблон сайта и вызывает $APPLICATION->IncludeComponent().

Чем больше страниц, тем больше однотипных файлов, которые отличаются только названием компонента и парой параметров. Поддерживать это с каждым разом тяжелее.

Переключаемся на D7-роутинг

Включить роутинг — это одна настройка в файле bitrix/.settings.php:

'routing' => [
    'config' => ['web.php'],
],

Ядро читает эту настройку, когда инициализирует роутер. Теперь запрос, для которого не нашлось физического файла, попадает в routing_index.php. Там адрес сравнивается с правилами из web.php, и управление передаётся нужному контроллеру.

Файл urlrewrite.php в корне сайта остаётся с ЧПУ-правилами. Если ЧПУ-правил нет (что типично при переводе всего на D7-роутинг), в нём будет пустой массив:

<?php
$arUrlRewrite = [];

Важно: очищать urlrewrite.php можно только тогда, когда в нём и так нет правил. Если правила есть (например, для инфоблоков, каталога или лендингов) — их трогать нельзя, по этим адресам запрос перехватывать не нужно. D7-роутинг работает как «fallback»: сначала запрос подходит к ЧПУ-правилам из urlrewrite.php, и только если ни одно правило не сработало и физического файла нет — управление передаётся роутеру (routing_index.php).

Структура файла маршрутов

Все правила страниц хранятся в local/routes/web.php. Вот наглядный пример для блога:

<?php

use Bitrix\Main\Loader;
use Bitrix\Main\Routing\RoutingConfigurator;

return static function (RoutingConfigurator $routes) {
    // Главная страница
    $routes
        ->get('/', [SiteController::class, 'home'])
        ->name('site.home');

    // Блог: список, пагинация и детальная запись
    $routes
        ->prefix('blog')
        ->name('blog.')
        ->group(static function (RoutingConfigurator $routes) {
            // Список записей (первая страница)
            $routes
                ->get('', [BlogController::class, 'list'])
                ->name('list');

            // Пагинация — {page} только цифры
            $routes
                ->get('page/{page}/', [BlogController::class, 'page'])
                ->where('page', '\d+')
                ->name('page');

            // Детальная запись — {code} символьный код
            $routes
                ->get('{code}/', [BlogController::class, 'view'])
                ->where('code', '[\w\-]+')
                ->name('post');
        });
};

Пройдёмся по ключевым строкам:

  • ->get('/blog/', ...) — правило, которое срабатывает, когда адрес начинается с /blog/ и запрос выполнен методом GET.
  • ->prefix('blog') — общая приставка для группы: все правила внутри автоматически получают /blog/ в начале.
  • ->name('blog.post') — даёт правилу имя. По имени потом удобно строить ссылки (об этом ниже).
  • {page} и {code} — части адреса, которые меняются. В фигурных скобках — их названия, роутер вытащит реальные значения из адреса.
  • ->where('page', '\d+') — ограничение: {page} должен быть только цифрами. Это нужно, чтобы правило пагинации не спорило с правилом детальной записи: цифра — это номер страницы, а не код записи.

Например, /blog/page/2/ попадёт в метод page, а /blog/hello-world/ — в view. Роутер сам разберёт параметры и передаст их в метод контроллера.

Важный момент: Loader::includeModule()

Если ваш сайт построен на собственном модуле (классы контроллеров лежат в local/modules/...), этот модуль нужно подключить до того, как будут объявлены маршруты:

Loader::includeModule('my.custom.module');

return static function (RoutingConfigurator $routes) {
    // правила страниц...
};

Почему важен порядок? Когда ядро разбирает маршруты, оно загружает класс контроллера автоматически — по имени класса. Автозагрузка классов модуля включается именно в момент вызова Loader::includeModule(). Если модуль подключить внутри замыкания (или вообще забыть это сделать), автозагрузчик не успеет заработать, и ядро не найдёт класс:

Class 'MyNamespace\Controller\BlogController' not found

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

Контроллер: замена физической страницы

Вместо отдельных файлов используются классы-контроллеры. Контроллер возвращает специальный объект, и Битрикс сам подставляет его содержимое внутрь шаблона сайта — рядом с шапкой, меню и подвалом. Вот пример:

<?php

namespace MyNamespace\Controller;

use Bitrix\Main\Engine\Controller;
use Bitrix\Main\Engine\Response\Render\Component;

final class BlogController extends Controller
{
    public function configureActions(): array
    {
        return [
            'list' => ['prefilters' => []],
            'page' => ['prefilters' => []],
            'view' => ['prefilters' => []],
        ];
    }

    public function listAction(): Component
    {
        // Заголовок и описание страницы ставим ДО renderComponent
        global $APPLICATION;
        $APPLICATION->SetTitle('Блог');
        $APPLICATION->SetPageProperty('description', 'Описание страницы блога');

        return $this->renderComponent('my:blog.list', '', [
            'PAGE_SIZE' => 5,
            'CACHE_TYPE' => 'A',
            'CACHE_TIME' => 3600,
        ]);
    }
}

Обратите внимание на две вещи:

1. Метод configureActions(). Здесь настраиваются так называемые префильтры — проверки, которые Битрикс выполняет перед запуском действия. На публичной части по умолчанию жёсткой проверки авторизации и сессии нет — она включается в административном разделе. Для открытых GET-страниц можно оставить пустой список ['prefilters' => []], и никакой сессии не потребуется. Но не сбрасывайте префильтры «вслепую»: если на странице появятся POST-формы, отключайте фильтры точечно (например, только проверку авторизации), разобравшись, за что отвечает каждый.

2. Метод listAction(). Он возвращает объект Response\Render\Component (через renderComponent()). Это не готовая строка HTML, а описание того, что нужно отрисовать. Ядро берёт компонент my:blog.list и рендерит его внутри шаблона сайта: шапка → компонент → подвал.

Почему заголовок страницы ставится в контроллере

Тут есть один нюанс, о котором легко забыть. Когда контроллер вызывает renderComponent(), шапка страницы — с тегом <title>, описанием и canonical — собирается сразу, прямо в момент этого вызова. То есть шапка готова раньше, чем выполнится код самого компонента.

Поэтому, если вы вызовете SetTitle() или SetPageProperty() внутри компонента, они попадут в шапку слишком поздно — она уже собрана.

Правильный порядок: метаданные (заголовок, описание, canonical, хлебные крошки) задаются в методе контроллера, до вызова renderComponent(). Компонент отвечает только за содержимое страницы, а всё «окружение» страницы — за контроллером.

Ссылки через именованные маршруты

При D7-роутинге ссылки удобно строить через роутер, а не склеивать из строк:

$router = \Bitrix\Main\Application::getInstance()->getRouter();

// В шаблоне или контроллере:
$url = $router->route('blog.post', ['code' => $elementCode]);

В чём выгода? Правило страницы имеет имя (мы задали его через ->name('blog.post')). Если позже адрес поменяется — например, /blog/{code}/ станет /articles/{code}/ — все ссылки, построенные через route(), обновятся сами, потому что адрес берётся из правила, а не из строки. При склейке руками пришлось бы править каждый файл.

Учтите нюанс: route() возвращает относительный путь (например, /blog/hello-world/). Для тега canonical или других мест, где нужен абсолютный адрес с доменом, этого мало. Соберите полный URL через тот же роутер и класс \Bitrix\Main\Web\Uri:

$router = \Bitrix\Main\Application::getInstance()->getRouter();

// Относительный путь
$path = $router->route('blog.post', ['code' => $code]);

// Полный адрес для canonical
$absolute = (new \Bitrix\Main\Web\Uri($path))->getLocator();

Запросы через AJAX: рендер без шаблона

Частая задача — пагинация без перезагрузки страницы, когда сервер должен вернуть только фрагмент (например, список записей), а не всю страницу целиком. Для этого у renderComponent() есть четвёртый параметр:

if ($this->getRequest()->isAjaxRequest()) {
    // Только содержимое компонента, без шапки и подвала
    return $this->renderComponent('my:blog.list', '', $params, false);
}

// Обычный запрос — полная страница
return $this->renderComponent('my:blog.list', '', $params);

Метод isAjaxRequest() определяет, что запрос пришёл через AJAX, по служебному заголовку X-Requested-With: XMLHttpRequest, который браузер шлёт при таких запросах. Если в четвёртый параметр передано false, компонент рендерится без шаблона сайта — отдаётся только HTML-фрагмент. Дальше скрипт на странице подставляет этот фрагмент в нужное место (например, в контейнер со списком записей).

Пагинация: совместная работа c классическим компонентом

У Битрикса есть готовый компонент постраничной навигации bitrix:system.pagenavigation. Но он умеет работать только с классическим объектом CDBResult, а не с более новой D7-навигацией PageNavigation. Чтобы использовать готовую навигацию вместе с D7-запросами, приходится вручную собрать нужный объект:

$navObject = new CDBResult();
$navObject->NavRecordCount = $total;   // всего записей
$navObject->NavPageSize = $pageSize;   // записей на странице
$navObject->NavPageNomer = $page;      // текущая страница
$navObject->NavPageCount = max(1, (int)ceil($total / $pageSize));
$navObject->NavNum = 1;
$navObject->nPageWindow = 5;
$navObject->bShowAll = false;
$navObject->bDescPageNumbering = false;
$navObject->bSavePage = false;

$this->arResult['NAV_OBJECT'] = $navObject;

Стоит знать и об альтернативе: компонент bitrix:main.pagenavigation умеет работать напрямую с D7-объектом \Bitrix\Main\UI\PageNavigation, без ручного CDBResult. Он удобен, когда достаточно штатных постраничных ссылок. Здесь выбран bitrix:system.pagenavigation с кастомным шаблоном: он строит аккуратные SEF-ссылки на маршрут пагинации (например, /blog/page/2/) вместо ?PAGEN_1=2.

Страница 404 и кэширование: abortResultCache()

Распространённая ошибка при работе с кэшем. Битрикс может показать страницу «не найдено» и при этом сохранить в кэш пустой результат. Если запись появится чуть позже, пользователь до окончания срока кэша (TTL) всё равно будет видеть «не найдено», хотя страница уже существует.

Решение — отменить запись в кэш, когда данных нет:

if (!$row) {
    $this->abortResultCache();  // не сохраняем пустой результат
    $this->set404();
    return;
}

Метод abortResultCache() отменяет сохранение результата. Благодаря этому следующий запрос к странице снова обратится к базе данных и покажет актуальную информацию.

Итого: как добавить новую страницу

Добавление страницы при D7-роутинге — это три шага:

  1. Описать правило в local/routes/web.php — какой адрес и какому методу контроллера соответствует.
  2. Написать метод в контроллере — он задаёт метаданные страницы и возвращает результат renderComponent().
  3. Создать компонент — в нём логика и шаблон содержимого страницы.

Отдельный физический файл при этом не создаётся — страницу собирает роутер.

Короткий чек-лист перед запуском

  • Loader::includeModule() стоит до объявления правил, но после строк с use.
  • configureActions() отключает проверку авторизации и CSRF для публичных страниц.
  • Заголовок, описание и canonical задаются в контроллере, а не в компоненте.
  • Внутренние ссылки строятся через $router->route(), а не склейкой строк.
  • Для AJAX-запросов используется renderComponent(..., false).
  • При отсутствии записи вызывается abortResultCache() перед set404().
  • Файл urlrewrite.php пустой.