Feature-флаги в main 26.700.0: флаг — это класс, а не опция
Кирилл Новожилов
Автор
Содержание
Задача, которую каждый решал по-своему: новая панель избранного готова, но пускать в неё сразу всех страшно. Хочется включить её себе, потом отделу тестирования, потом каждому десятому, а если что-то пойдёт не так — выключить одной строкой, не откатывая деплой. Обычно это заканчивалось опцией в Option плюс списком ID в init.php, у каждого проекта своим.
В main 26.700.0 для этого появилась штатная подсистема — Bitrix\Main\Config\Feature, двенадцать файлов в main/lib/Config/Feature* и две таблицы. Документации на неё я пока не видел, поэтому я прогнал её на живой копии и собрал, что она умеет и где кусается.
Всё, что ниже, запускалось на окружении: main 26.700.0, PHP 8.4, MySQL 8.4, кеш в файлах.
Минимум: класс и один вызов
Главная идея подсистемы: флаг — не строка в b_option, а PHP-класс. Его полное имя и есть код флага, под этим кодом он лежит в b_feature_flag.
<?php declare(strict_types=1);
namespace Vendor\Favorites\Feature;
use Bitrix\Main\Config\Feature\AbstractFlag;
final class NewFavoritesPanelFlag extends AbstractFlag
{
public function enabledByDefault(): bool
{
return false;
}
}
И проверка там, где ветвится поведение — в сервисе или в class.php компонента:
use Bitrix\Main\Config\Feature;
use Vendor\Favorites\Feature\NewFavoritesPanelFlag;
if (Feature::isEnabled(NewFavoritesPanelFlag::class)) {
$this->arResult['PANEL'] = $panelService->buildNew();
} else {
$this->arResult['PANEL'] = $panelService->buildLegacy();
}
Включить для всех — Feature::enable(NewFavoritesPanelFlag::class), выключить — disable(), вернуть к значению по умолчанию — resetToDefault(). Запись в b_feature_flag идёт через MergeByDefaultTrait, то есть add() работает как upsert, повторные переключения не плодят строк.
Админского интерфейса и консольной команды у флагов нет. Ни в main/admin, ни в main/lib/Cli про них ничего. Переключать придётся из кода: своей админ-страницей, своей командой или разовым скриптом. Это первое, что стоит знать, прежде чем обещать менеджеру «галочку».
Как флаг решает, включён ли он
FeatureManager::isEnabled() идёт по шести шагам и останавливается на первом, который дал ответ:
- Класс флага не найден — true. В коде так и написано: «feature was enabled and removed from codebase». Удалили класс — фича доехала до всех.
getRequirements()— массив callable. Любой вернулfalse— false.- Правила с политикой
deny. Сработало хоть одно — false. - Правила с политикой
allow. Сработало хоть одно — true. - Значение из
b_feature_flag, если оно там есть. enabledByDefault().
Пункт 1 мне нравится и не нравится одновременно. Нравится, потому что задача «убрать флаг после раскатки» решается удалением класса, а не миграцией. Не нравится, потому что опечатка в строке превращается в «включено». Спасает только привычка передавать ::class, а не строку.
Requirements — это условия, которые не хранятся в БД и которые нельзя перебить правилом. Флаг можно намертво привязать к окружению:
use Bitrix\Main\Config\Feature\Context;
use Bitrix\Main\ModuleManager;
public function getRequirements(): array
{
return [
static fn (Context $context): bool => ModuleManager::isModuleInstalled('iblock'),
];
}
Раскатка на людей: правила
Правило — тоже класс, наследник AbstractRule с двумя методами: статический createFromConfig(array $config) и check(Context $context). Конфиг сериализуется в RULE_ARGS как JSON, так что в него кладём только скаляры и массивы.
В ядре готовых правил два: UserIdRule (ключ userIds) и ModuleMinVersionRule (ключи module и minVersion). Первое покрывает «включить себе и тестировщикам»:
use Bitrix\Main\Config\Feature;
use Bitrix\Main\Config\Feature\Context;
use Bitrix\Main\Config\Feature\Rules\UserIdRule;
Feature::allow(NewFavoritesPanelFlag::class, UserIdRule::class, ['userIds' => [1, 42]]);
Feature::isEnabled(NewFavoritesPanelFlag::class, new Context(1)); // true
Feature::isEnabled(NewFavoritesPanelFlag::class, new Context(2)); // false
Без второго аргумента берётся Context::getCurrent() — текущий авторизованный пользователь.
Процентной раскатки в ядре нет, но своё правило пишется за пять минут:
<?php declare(strict_types=1);
namespace Vendor\Favorites\Feature\Rules;
use Bitrix\Main\Config\Feature\AbstractRule;
use Bitrix\Main\Config\Feature\Context;
final class PercentRule extends AbstractRule
{
public function __construct(private readonly int $percent) {}
public static function createFromConfig(array $config = []): static
{
return new static(max(0, min(100, (int)($config['percent'] ?? 0))));
}
public function check(Context $context): bool
{
if ($context->userId === null) {
return false;
}
return ($context->userId % 100) < $this->percent;
}
}
Feature::allow(NewFavoritesPanelFlag::class, PercentRule::class, ['percent' => 10]);
На 1000 последовательных ID это дало ровно 100 включённых. Остаток от деления по ID даёт стабильное распределение: один и тот же пользователь либо всегда в группе, либо всегда нет, что для канареечной раскатки важнее равномерности.
Второе поле контекста — params, произвольный массив. Через него правило узнаёт то, чего нет в userId: сайт или регион. Правило по сайту:
public function check(Context $context): bool
{
return in_array($context->params['siteId'] ?? SITE_ID, $this->siteIds, true);
}
Feature::allow(NewFavoritesPanelFlag::class, SiteRule::class, ['siteIds' => ['s2']]);
Feature::isEnabled(NewFavoritesPanelFlag::class, new Context(5, ['siteId' => 's1'])); // false
Feature::isEnabled(NewFavoritesPanelFlag::class, new Context(5, ['siteId' => 's2'])); // true
И про deny. Оно проверяется раньше allow и раньше значения в базе, так что «включено всем, кроме» тоже одна строка:
Feature::enable(NewFavoritesPanelFlag::class);
Feature::deny(NewFavoritesPanelFlag::class, UserIdRule::class, ['userIds' => [7]]);
Feature::isEnabled(NewFavoritesPanelFlag::class, new Context(7)); // false
Feature::isEnabled(NewFavoritesPanelFlag::class, new Context(8)); // true
Ловушка 1. Флаг вне модуля включается только правилами
Первое, что я попробовал, — положить флаг в /local/php_interface с неймспейсом Local\Flags, без модуля. isEnabled() вернул false. Feature::enable() отработал без ошибок. isEnabled() снова вернул false. В b_feature_flag — пусто.
Причина в Factory::resolveModule(): он берёт первые два сегмента неймспейса и собирает из них ID модуля. Vendor\Favorites\... → vendor.favorites, Bitrix\Main\... → main. Если такого модуля нет в ModuleManager::isModuleInstalled(), toggle() делает continue — без исключения и без записи в лог. Правила при этом работают: allow() с UserIdRule для того же флага включил его пользователю 1.
Вывод простой: флаги живут в lib/ установленного модуля. Хотите флаг «на проект» — заводите под него модуль-контейнер, всё равно он нужен под сервисы и роуты.
Ловушка 2. Анонимы и кешированный контекст
Context приводит userId < 1 к null. UserIdRule сравнивает через in_array(..., true), поэтому анонима обычным списком ID не поймать. Но createFromConfig() пропускает null в списке как есть:
Feature::allow(NewFavoritesPanelFlag::class, UserIdRule::class, ['userIds' => [null]]);
Feature::isEnabled(NewFavoritesPanelFlag::class, new Context(0)); // true
Не уверен, что это задумано, а не просто так получилось, но на сегодняшнем коде это рабочий способ включить фичу «всем неавторизованным».
Второе: Context::getCurrent() создаётся один раз на процесс и хранится в статическом свойстве. Если после первого isEnabled() в этом же запросе сменился пользователь — авторизация или Authorize() в тесте — контекст останется старым. В CLI getCurrent()->userId всегда null. Там, где пользователь важен, контекст лучше передавать явно.
Ловушка 3. resetToDefault() не трогает правила
Метод удаляет только строку из b_feature_flag. Правила в b_feature_flag_rule остаются: после resetToDefault() у меня в таблице лежали те же две записи, что и до него. Чтобы флаг стал «чистым», нужна пара:
Feature::resetToDefault(NewFavoritesPanelFlag::class);
Feature::clearRules(NewFavoritesPanelFlag::class);
clearRules() принимает и список классов правил, если удалить надо не всё.
Ловушка 4. Агент чистки есть в коде, но не в b_agent
Feature\CleanupAgent раз в сутки должен вычищать из обеих таблиц записи, чьи классы флагов или правил больше не существуют. На моей копии, обновлённой до 26.700.0, в b_agent его нет, и в main/install/index.php он не упоминается. Регистрировать придётся руками:
\CAgent::AddAgent(
'Bitrix\Main\Config\Feature\CleanupAgent::run();',
'main',
'N',
86400,
);
Без него после удаления класса флага строки просто лежат мёртвым грузом. Работать это не мешает: неизвестный класс и так считается включённым, а его правила никто не читает.
Хуки и кеш
AbstractFlag даёт два защищённых хука — onEnable() и onDisable(). Вызываются они только когда значение в базе реально сменилось: два enable() подряд дали одну запись в лог, а не две. Хорошее место, чтобы сбросить тегированный кеш компонента или отправить уведомление в чат. MODIFIED_BY при этом пишется из CurrentUser, в CLI туда попадёт 0.
Кеш двухслойный: ORM-запрос к обеим таблицам с TTL 86400 и мемоизация внутри FeatureManager. За межпроцессную согласованность я переживал зря: add() и deleteByFilter() у DataManager чистят кеш сущности, и в тесте с двумя процессами второй процесс увидел enable(), resetToDefault(), allow() и clearRules() первого сразу, без ожидания.
С мемоизацией внутри процесса сложнее. FeatureManager — синглтон в ServiceLocator, и его $flagValues сбрасывается только при переключении из этого же процесса. По коду выходит, что долгоживущий воркер messenger:consume увидит флаг, включённый из админки, только после Feature::clearCache() или перезапуска. Я это не воспроизводил, но обходить буду заранее: очищать кеш в начале обработки сообщения или ограничивать воркер по --time-limit.
Вывод: где это нужно, а где нет
Option, и тащить её во флаги не надо.Если у вас один сайт, два разработчика и релиз раз в квартал, вся подсистема вам не нужна: Option::get() с проверкой в if решает ту же задачу без двух таблиц. Флаги окупаются, когда релизов много, а откатывать деплой дороже, чем выключить строку.
Открытый вопрос — что Битрикс сделает с этим дальше. Подсистема пришла с сервисом в .settings.php, логгером main.config.feature и агентом, но без единого собственного флага в ядре: ни один из модулей на моей копии ещё не наследуется от AbstractFlag. Похоже на фундамент, интерфейс к которому ещё едет.
Теги:
Комментарии (0)
Пожалуйста, войдите в аккаунт, чтобы оставить комментарий
Оставить комментарийЗагрузка...
Пока нет ни одного комментария. Будьте первым!
Похожие статьи