Чистый код в компонентах Битрикса: class.php
Урок №3. Контракт параметров и безопасный кэш
В прошлом уроке мы разложили компонент по жизненному циклу: 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() своего компонента к тем же правилам:
- каждый параметр — приведение к типу и значение по умолчанию через
??; - строковые параметры с фиксированным набором значений — через белый список;
- числовые — с верхней и нижней границей, особенно те, что уходят в лимит выборки;
- чтение запроса — только здесь, через
$request->get(), и только в виде нормализованного флага; - никаких исключений в prepare: отказ оформляется в
executeComponent()черезShowError()иreturn; ~-копии не трогайте, ядро создаёт их после вашего метода; следите, чтобы везде читался очищенный ключ.
Критерий готовности: вы можете назвать полный список факторов, от которых зависит результат компонента, и показать каждый в коде: в $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 сам не ставит.
AI Домовой История
на связи
Привет! Я AI Домовой
Помогу с вопросами по 1С-Битрикс: D7, ORM, компоненты, события.
Чем подробнее задача — тем точнее ответ. Иногда готовлю развёрнутое решение.
Дневной лимит исчерпан. Сброс завтра.
Увеличить лимит с поддержкой