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

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

Урок №3. Контракт параметров и безопасный кэш

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

В прошлом уроке мы разложили компонент по жизненному циклу: onPrepareComponentParams() приводит параметры к типам, executeComponent() управляет порядком, startResultCache() заменил ручную обвязку CPHPCache. Осталось две дыры. $arParams приходят снаружи, и компонент принимает их на веру: к int их привели, а проверки «это вообще рабочее значение» нет. А ключ кэша ядро строит само, и ни права пользователя, ни его группы в него не попадают. Сегодня параметры станут контрактом, а в ключ кэша попадёт всё, от чего зависит результат, и ничего лишнего.

Код урока — в снапшоте lesson-03-params-cache: компонент в class.php, шаблон в templates/.default/template.php. У фрагментов из репозитория путь к файлу стоит в первой строке листинга. Начнём с кэша: он ломается тише и обходится дороже.

Что на самом деле лежит в ключе кэша

Вызов $this->startResultCache() выглядит как «включить кэширование», но за ним стоит конкретная строка ключа. CBitrixComponent::getCacheID() собирает её из:

  • ID сайта, ID языка и ID шаблона сайта;
  • имени компонента и имени его шаблона;
  • всех ключей $this->arParams, кроме начинающихся с ~, — значения проходят через serialize();
  • смещения таймзоны;
  • второго аргумента startResultCache() — в ядре он называется $additionalCacheID.

Прочитайте список ещё раз и найдите в нём пользователя. Его там нет: ни ID, ни групп, ни прав на инфоблок, ни признака «авторизован». Ядро не может знать, зависит ли результат вашего компонента от того, кто на него смотрит. Это решаете вы.

ℹ️ Под капотом значения параметров уходят в ключ через serialize(), поэтому '1' и 1 дают два разных ключа, а ' 1' — третий. Параметры с префиксом ~ в ключ не включаются: это единственная штатная лазейка, через которую значение проходит мимо кэша.

Сломать такой кэш можно двумя способами.

Два способа сломать кэш

Сценарий А отравление кэша (cache poisoning)

Компонент кладёт в $arResult что-то, зависящее от пользователя, а пользователь в ключе не участвует. Первый, кто открыл страницу, записывает свой вариант, все следующие получают его. Фрагмент из class.php прошлого урока, внутри цикла выборки:

        // lesson-02-class-basics/step-2-lifecycle/class.php

if ($GLOBALS['USER']->IsAdmin()) {
    $arItem['CAN_EDIT'] = true;
    $arItem['EDIT_LINK'] = '/bitrix/admin/iblock_element_edit.php?ID=' . $arItem['ID'] . '&IBLOCK_ID=' . $this->arParams['IBLOCK_ID'];
}

    

Цикл выполняется внутри блока if ($this->startResultCache()), а startResultCache() вызван без аргументов. Значит, если первым на страницу зашёл администратор, ссылки на редактирование лягут в кэш и следующие CACHE_TIME секунд будут отдаваться каждому гостю. Шаблон урока 2 их не печатает, поэтому на экране ничего не видно, но чужие данные в кэше уже лежат. Бывает и наоборот: первым зашёл гость, и администратор час ищет «пропавший код», хотя код на месте.

Здесь одна аудитория получает данные другой. В реальных компонентах на месте кнопки редактирования оказывается цена по договору, персональная скидка или имя пользователя в шапке. Будет это мелкий баг или инцидент с персональными данными, зависит только от того, что попало в $arResult.

Сценарий Б взрыв кэша

Обратная крайность. В ключ попадает то, от чего результат не зависит, или то, что меняется на каждом запросе. Кэш формально включён, но не срабатывает никогда: каждый хит пишет новую запись, старые лежат мёртвым грузом, а сервер делает всю работу и вдобавок serialize(). Частые источники:

  • нестабильные значения в $arParams — время, rand(), объект запроса, метка сессии;
  • сырые GET-параметры: ?page=1 и ?PAGE=1 дают разные ключи при одном результате;
  • крупные структуры — массив на сотни элементов сериализуется в ключ целиком;
  • избыточный $additionalCacheID — ID пользователя там, где хватает набора групп: на сайте с 50 000 зарегистрированных это 50 000 копий одного списка.
        // Ключ будет новым на каждом запросе — кэш не попадёт ни разу
$params['NOW'] = time();
$params['REQUEST'] = Application::getInstance()->getContext()->getRequest();

// Стабильный флаг: два возможных значения, два варианта кэша
$params['SHOW_FULL_LIST'] = ($request->get('SHOW_FULL_LIST') === 'Y') ? 'Y' : 'N';

    

В ключ идёт то, от чего результат зависит, и ничего сверх. Каждый лишний фактор дробит кэш, каждый недостающий его отравляет.

Воспроизводим отравление кэша на стенде

Компонент лежит на стенде Omut в ~/Omut/components.bitrix/www/local/components/project/news.list/, страница с ним — http://components.bitrix/news/. Из урока 2 вы пришли с компонентом, где утечка уже есть, но её не видно: шаблон не печатает админские ссылки.

Шаг 1. Сделайте утечку видимой. В class.php компонента на стенде уберите проверку роли из цикла и посчитайте её один раз, до подключения шаблона:

        $this->arResult['IS_ADMIN'] = CurrentUser::get()->isAdmin();

    

Вверху файла добавьте use Bitrix\Main\Engine\CurrentUser;, а в templates/.default/template.php внутри цикла по элементам — блок:

        <?php if ($arResult['IS_ADMIN']): ?>
    <div class="news-item__admin">
        <a href="/bitrix/admin/iblock_element_edit.php?ID=<?= $arItem['ID'] ?>&IBLOCK_ID=<?= $arParams['IBLOCK_ID'] ?>">
            [Редактировать]
        </a>
    </div>
<?php endif; ?>

    

В снапшоте урока этот блок уже стоит в template.php. Вызов startResultCache() пока не трогайте, он остаётся без аргументов. Это ровно та ошибка, которую мы сейчас поймаем.

Шаг 2. Очистите кэш (Настройки → Настройки продукта → Автокеширование, вкладка «Очистка файлов кеша», «Все», «Очистить»).

Шаг 3. Зайдите первым под администратором. Откройте http://components.bitrix/news/ в окне, где вы авторизованы. Под каждой новостью появится [Редактировать], и этот HTML только что записан в кэш.

Шаг 4. Откройте ту же страницу гостем. Откройте http://components.bitrix/news/ в приватном окне браузера (инкогнито): там вы не авторизованы.

Проверяем: утечка воспроизвелась

У гостя ссылок «[Редактировать]» быть не должно. А они есть, под каждой новостью, как у администратора.

Гость забрал разметку администратора вместе со ссылками в админку. Кэш отравлен.

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

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

  • В приватном окне ссылок нет, а под администратором есть. Кэш не записался. Проверьте, что автокеширование включено и что в вызове компонента нет 'CACHE_TYPE' => 'N': с ним startResultCache() вернёт true и ничего не запишет.
  • Ссылок нет ни в приватном окне, ни под администратором. Утечку ещё не сделали видимой: шаблон урока 2 админский блок не печатает. Вернитесь к шагу 1 и добавьте вывод [Редактировать] в template.php.
  • Удалили папку www/bitrix/cache/, а вывод не изменился. Если в Omut для кэша включён Redis, файлов там нет вовсе. Очищайте кэш через админку или вызовом BXClearCache(true); из кода: они работают с любым хранилищем.

Контракт параметров: единственная дверь внутрь компонента

Ядро вызывает onPrepareComponentParams() до executeComponent() и до того, как будет посчитан ключ кэша. Это единственное место, где произвольный вход превращается в контракт: дальше по коду вы вправе считать, что IBLOCK_ID — целое число, SORT_ORDER — одно из двух допустимых значений, а NEWS_COUNT не больше сотни. Так метод выглядит в class.php снапшота:

        // lesson-03-params-cache/class.php

public function onPrepareComponentParams($params): array
{
    $request = Application::getInstance()->getContext()->getRequest();

    $params['IBLOCK_ID'] = (int)($params['IBLOCK_ID'] ?? 0);
    $params['NEWS_COUNT'] = (int)($params['NEWS_COUNT'] ?? 10);
    $params['CACHE_TIME'] = (int)($params['CACHE_TIME'] ?? 3600);
    // CACHE_TYPE ядро уже привело к A, Y или N до вызова этого метода
    $params['CACHE_GROUPS'] = $params['CACHE_GROUPS'] ?? 'Y';
    $params['SHOW_FULL_LIST'] = ($request->get('SHOW_FULL_LIST') === 'Y') ? 'Y' : 'N';

    $params['SORT_ORDER'] = in_array($params['SORT_ORDER'] ?? '', ['ASC', 'DESC'], true)
        ? $params['SORT_ORDER']
        : 'DESC';

    if ($params['NEWS_COUNT'] > 100) {
        $params['NEWS_COUNT'] = 100;
    }

    if ($params['NEWS_COUNT'] < 1) {
        $params['NEWS_COUNT'] = 10;
    }

    return $params;
}

    

Здесь четыре приёма, каждый закрывает свой класс ошибок.

Типы и значения по умолчанию. (int)($params['IBLOCK_ID'] ?? 0) решает две задачи: отсутствующий ключ не роняет код, а строка не уходит в SQL-фильтр. Заодно это лечит дробление кэша: '12' и 12 после приведения дают одно значение и один ключ.

Белый список. SORT_ORDER может быть только ASC или DESC, всё прочее превращается в значение по умолчанию. Значение подставляется прямо в сортировку запроса, так что белый список здесь нужен для безопасности.

Границы числовых значений. NEWS_COUNT ограничен сверху и снизу: без верхней границы в nTopCount уедет любое число, какое попросят.

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

Параметры кэша метод почти не трогает. CACHE_TYPE ядро приводит к A, Y или N ещё до вызова, это было в уроке 2. У CACHE_GROUPS только значение по умолчанию 'Y', а убрать группы из ключа может лишь явное 'N': так проверяет getAdditionalCacheId() ниже. Опечатка в этом параметре оставит группы в ключе, и кэш останется безопасным.

ℹ️ Если это в новинку $request->get() — метод Bitrix\Main\HttpRequest, штатная замена $_GET. Читать запрос нужно в onPrepareComponentParams(): отсюда значение попадает в $arParams, а значит — в ключ кэша.

Копии параметров с ~ в этом методе удалять бесполезно: на момент его вызова их ещё нет. Сразу после onPrepareComponentParams() ядро вызывает __prepareComponentParams(), который заводит ~-копию для каждого ключа, а в самом ключе экранирует строки с символами ; & < > ". Читайте везде очищенный ключ, а ~-версию берите только там, где нужен неэкранированный текст. На кэш копии не влияют: getCacheID() пропускает ключи, начинающиеся с ~.

Почему здесь нет throw

Бросить исключение при IBLOCK_ID = 0 прямо в prepare хочется, но ловить его будет некому: try/catch живёт внутри executeComponent(), а prepare отрабатывает раньше. Вместо аккуратного сообщения вы получите необработанное исключение: на стенде с debug это трассировка на пол-экрана, на бою белый экран. Поэтому prepare только нормализует, а решение «работать или отказать» принимается в точке входа:

        if ($this->arParams['IBLOCK_ID'] <= 0) {
    ShowError('Укажите корректный IBLOCK_ID');
    return;
}

    

Чего стоит один невалидный параметр

NEWS_COUNT без верхней границы уходит в nTopCount как есть. Запрос на 100 000 элементов вытащит их в память, сложит в $arResult и попробует сериализовать в кэш. А внутри цикла до сих пор живёт CUser::GetByID() — тот самый N+1, то есть ещё до ста тысяч запросов. Одно значение параметра, и сервер ложится. Тот же NEWS_COUNT из GET вдобавок взрывает кэш: каждое новое число становится отдельным ключом.

Второй пример — IBLOCK_ID, пришедший строкой из настроек модуля, свойства раздела или GET: '1' или символьный код 'news'. Контракт держат две строки: (int)($params['IBLOCK_ID'] ?? 0) в prepare и проверка IBLOCK_ID <= 0 в начале executeComponent(). Первая сводит '1' и 1 к одному ключу кэша, а 'news' превращает в 0. Вторая ловит этот 0 и останавливает компонент через ShowError(). Без них вы получите пустой список без всякого сообщения.

Чиним ключ: additionalCacheID

Второй аргумент startResultCache() — то, что вы дописываете в ключ от себя: ядро сериализует переданное значение и приклеивает к ключу. Значение собирает отдельный метод в class.php:

        // lesson-03-params-cache/class.php

protected function getAdditionalCacheId(): array
{
    $currentUser = CurrentUser::get();
    $id = [
        'is_authorized' => $currentUser->getId() > 0,
    ];

    if ($this->arParams['CACHE_GROUPS'] !== 'N') {
        $id['user_groups'] = $currentUser->getUserGroups();
    }

    return $id;
}

    

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

ℹ️ Если это в новинку CurrentUser::get() — D7-обёртка над глобальным $USER. getUserGroups() возвращает массив ID групп посетителя: у гостя это группа «Все пользователи», у администратора ещё и группа администраторов.
⚠️ Важно
Не называйте этот метод getCacheId(). Имена методов в PHP регистронезависимы, а у CBitrixComponent уже есть публичный getCacheID($additionalCacheID = false), который собирает весь ключ. Объявите свой метод protected — PHP остановится с фатальной ошибкой при подключении class.php. Объявите public с совместимой сигнатурой — молча подмените сборку ключа целиком.

Точка входа после правки, в сокращении (целиком — в class.php):

        // lesson-03-params-cache/class.php

public function executeComponent(): void
{
    try {
        if ($this->arParams['IBLOCK_ID'] <= 0) {
            ShowError('Укажите корректный IBLOCK_ID');
            return;
        }

        $this->checkModules();

        if ($this->startResultCache($this->arParams['CACHE_TIME'], $this->getAdditionalCacheId())) {
            // выборка и форматирование пока без изменений

            $this->arResult['IS_ADMIN'] = CurrentUser::get()->isAdmin();

            // setResultCacheKeys — в следующем разделе

            $this->includeComponentTemplate();
        }

        $GLOBALS['APPLICATION']->SetTitle('Новости компании');
    } catch (\Exception $e) {
        $this->abortResultCache();
        ShowError($e->getMessage());
    }
}

    

Два места здесь стоит рассмотреть отдельно.

IS_ADMIN считается внутри блока кэша и до includeComponentTemplate(). При попадании в кэш template.php не выполняется, ядро отдаёт сохранённый HTML. Значит, всё, что влияет на разметку, должно быть посчитано до шаблона и учтено в ключе. Роль учтена: группы лежат в getAdditionalCacheId().

SetTitle(), наоборот, стоит вне блока. Заголовок страницы не входит в HTML компонента, при попадании в кэш его надо ставить заново, а код после if выполняется на каждом хите. Туда же идут свойства страницы и хлебные крошки, но не ключи $arResult, которые читает шаблон.

Проверяем: кэш перестал течь

Передайте getAdditionalCacheId() вторым аргументом startResultCache(), очистите кэш и повторите шаги 3 и 4. В приватном окне ссылок «[Редактировать]» больше нет, а под администратором они на месте. Один URL, два закэшированных ответа, и каждый видит свой.

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

  • Вывод по-прежнему одинаковый. $additionalCacheID не попал в вызов: это второй аргумент, startResultCache($this->arParams['CACHE_TIME'], $this->getAdditionalCacheId()). Проверьте, что в файле не остался второй вызов startResultCache() без аргументов.
  • Ссылок нет ни у гостя, ни у администратора. IS_ADMIN посчитан после includeComponentTemplate() или вне блока кэша: шаблон отработал раньше, чем появился ключ. Перенесите строку внутрь if и выше вызова шаблона.
  • Держится старый вывод. Кэш не очищен, старый ключ жив до истечения CACHE_TIME. Очистите кэш через админку или BXClearCache(true);.

setResultCacheKeys: что остаётся в файле кэша

Метод помечает, какие ключи $arResult сохранить. После того как шаблон отработал, ядро оставляет в $this->arResult только перечисленные ключи, и в кэш уходит урезанный массив. На следующем хите восстанавливается именно он.

Отсюда два следствия. Ключи, которых нет в списке, после первого прохода пропадают из $arResult: в component_epilog.php и в коде после блока кэша на следующих хитах их уже нет. Это классическое «работает при первом открытии, ломается при втором». И всё, что не нужно после шаблона, в кэш не попадает вовсе, файл кэша становится меньше.

ℹ️ Под капотом фильтрация выполняется в showComponentTemplate() после того, как шаблон отработал, но до записи кэша. Поэтому шаблон видит полный $arResult, а кэш — только список из setResultCacheKeys().

В class.php вызов стоит прямо перед шаблоном:

        // lesson-03-params-cache/class.php

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

    

setResultCacheKeys() урезает $arResult и с выключенным кэшем: фильтрация в showComponentTemplate() на CACHE_TYPE не смотрит. Так даже лучше: компонент ведёт себя одинаково в обоих режимах, и ошибка с забытым в списке ключом проявится уже на стенде с 'N', а не только на проде.

Почему правка новости видна сразу

Прежде чем идти дальше, проверьте одну вещь. Откройте http://components.bitrix/news/, поменяйте в админке заголовок любой новости из списка и обновите страницу. Новый заголовок виден сразу, хотя CACHE_TIME — час. С ручным CPHPCache из урока 1 так не было: там правка ждала, пока истечёт время жизни файла.

Сработало это без единой строки вашего кода. Пока включён управляемый кеш компонентов, startResultCache() открывает вместе с блоком кэша тегированный кэш. CIBlockElement::GetList() возвращает CIBlockResult, а тот, перебирая строки внутри этого блока, сам регистрирует тег iblock_id_N. Когда элемент инфоблока добавляют, меняют или удаляют, ядро сбрасывает все файлы кэша с этим тегом.

Тег ставится и на пустую выборку: если строк нет, Fetch() регистрирует его по IBLOCK_ID из фильтра. Поэтому пустой список можно кэшировать: первая же добавленная новость сбросит такой кэш. Отмена кэша при пустом ITEMS, которая стояла в уроке 2, здесь лишняя, и в снапшоте урока её нет. С ней, пока новостей нет, каждый хит шёл бы в базу.

Запомните, что эта привязка неявная: в коде компонента нет ничего, что бы о ней напоминало. В уроке 4 мы уйдём со старого API на D7 ORM, и она молча пропадёт.

Проверяем: правка новости видна без очистки кэша

После правки заголовка новости и обновления страницы в списке новый заголовок, а в приватном окне по-прежнему нет ссылок «Редактировать».

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

  • Новый заголовок появляется только после очистки кэша. Выключен управляемый кеш компонентов. Проверьте вкладку «Управляемый кеш» на странице «Автокеширование»: без него теги не пишутся, и кэш живёт только по времени.
  • Правка не видна, хотя управляемый кеш включён. Поправили новость, которой нет на странице: в список попадают первые NEWS_COUNT активных элементов по дате начала активности.
  • Изменения не видны ни после правки, ни после очистки кэша компонентов. Включён композитный сайт: он отдаёт сохранённую страницу целиком, мимо кэша компонента. На время проверки выключите композит или очистите на той же вкладке вариант «Все страницы HTML кеша».

CACHE_TYPE, CACHE_GROUPS и очистка кэша

CACHE_TYPE — стандартный параметр компонентов Битрикса. 'N' — не кэшировать: startResultCache() сразу возвращает true и ничего не пишет. 'Y' — кэшировать всегда. 'A' — авто: ядро дополнительно смотрит опцию автокеширования модуля main, общий рубильник в админке.

CACHE_GROUPS — тоже стандартный, в штатных компонентах подписан как учёт прав доступа. 'Y' кладёт группы в ключ, 'N' отдаёт всем один вариант. Ставить 'N' стоит, только если вы можете доказать, что разметка от прав не зависит.

Проверять CACHE_TYPE в своём коде не нужно. startResultCache() возвращает true без всякого кэша и при 'N', и при 'A' с выключенным автокешированием. Своя проверка CACHE_TYPE !== 'N' повторила бы первый случай и пропустила второй.

Про очистку. Совет из старых статей «удалите папку bitrix/cache/» работает, только пока кэш лежит в файлах. В Omut для кэша можно включить Redis, на боевых серверах часто стоит memcached, и тогда папка пустая или её нет вовсе. Рабочие способы: админка (Автокеширование → «Очистка файлов кеша» → «Все»), BXClearCache(true); из кода, точечная чистка через $GLOBALS['CACHE_MANAGER'].

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

Часть 1: аудит кэша своего компонента

Возьмите рабочий компонент из своего проекта и выпишите два списка.

Список А — от чего результат зависит на самом деле: права и группы пользователя; признак авторизации; параметры вызова; язык и сайт; GET-фильтры и номер страницы пагинации; регион или валюта из cookie; персональные данные вроде корзины или цены; содержимое инфоблоков, которые компонент читает.

Список Б — что реально попадает в ключ: сайт, язык, шаблон сайта, имя компонента, имя шаблона, все $arParams без ~, смещение таймзоны и ваш $additionalCacheID, если вы его передали. Отдельно — какие теги регистрирует компонент.

Сравните. Строка из А, которой нет в Б, — потенциальная утечка или устаревшие данные: оцените, что именно утечёт и при каком порядке заходов. Строка из Б, которой нет в А, — лишнее дробление кэша. Отдельно поищите в $arParams нестабильные значения: время, случайные числа, объекты, метки сессии.

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

Часть 2: контракт параметров

Приведите onPrepareComponentParams() своего компонента к тем же правилам:

  1. каждый параметр — приведение к типу и значение по умолчанию через ??;
  2. строковые параметры с фиксированным набором значений — через белый список;
  3. числовые — с верхней и нижней границей, особенно те, что уходят в лимит выборки;
  4. чтение запроса — только здесь, через $request->get(), и только в виде нормализованного флага;
  5. никаких исключений в prepare: отказ оформляется в executeComponent() через ShowError() и return;
  6. ~-копии не трогайте, ядро создаёт их после вашего метода; следите, чтобы везде читался очищенный ключ.

Критерий готовности: вы можете назвать полный список факторов, от которых зависит результат компонента, и показать каждый в коде: в $arParams, в getAdditionalCacheId() или в теге кэша.

Готовый код для сверки — class.php и templates/.default/ в снапшоте lesson-03-params-cache.

Заключение

Компонент перестал верить входным данным и путать пользователей. Всё, что приходит снаружи, проходит через onPrepareComponentParams() до того, как ядро посчитает ключ. Ключ теперь описывает реальную зависимость результата: группы и авторизация идут через getAdditionalCacheId(), объём сохраняемого задаёт setResultCacheKeys(), режим — CACHE_TYPE и CACHE_GROUPS. Изменения новостей сбрасывают кэш через тег инфоблока, пока работает старый API.

Включить кэш в Битриксе — одна строка. Решить, от чего зависит ваш $arResult, за вас не сможет ни одна настройка, и записывается это решение во втором аргументе startResultCache().

Компонент при этом по-прежнему один большой метод: выборка, форматирование дат и вывод перемешаны в executeComponent(), а внутри крутится цикл, который на каждой новости ходит в базу за автором. В уроке 4 «Декомпозиция и D7 ORM: убираем N+1» мы разберём точку входа на отдельные методы, заменим CIBlockElement::GetList на D7 ORM, уберём N+1 и вернём тег кэша, который ORM сам не ставит.

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

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

на связи

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

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

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

Войти