02.09.2026 10 мин чтения

Feature-флаги в main 26.700.0: флаг — это класс, а не опция

Кирилл Новожилов

Кирилл Новожилов

Автор

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() идёт по шести шагам и останавливается на первом, который дал ответ:

  1. Класс флага не найден — true. В коде так и написано: «feature was enabled and removed from codebase». Удалили класс — фича доехала до всех.
  2. getRequirements() — массив callable. Любой вернул falsefalse.
  3. Правила с политикой deny. Сработало хоть одно — false.
  4. Правила с политикой allow. Сработало хоть одно — true.
  5. Значение из b_feature_flag, если оно там есть.
  6. 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.

Вывод: где это нужно, а где нет

💬 Мнение
Флаги нужны для того, что имеет дату смерти. Новый чекаут, переписанный компонент, миграция на другой API — включили, раскатали, удалили класс. Настройка модуля, которая живёт годами и меняется из админки, — это по-прежнему Option, и тащить её во флаги не надо.

Если у вас один сайт, два разработчика и релиз раз в квартал, вся подсистема вам не нужна: Option::get() с проверкой в if решает ту же задачу без двух таблиц. Флаги окупаются, когда релизов много, а откатывать деплой дороже, чем выключить строку.

Открытый вопрос — что Битрикс сделает с этим дальше. Подсистема пришла с сервисом в .settings.php, логгером main.config.feature и агентом, но без единого собственного флага в ядре: ни один из модулей на моей копии ещё не наследуется от AbstractFlag. Похоже на фундамент, интерфейс к которому ещё едет.

Опубликовано 7 часов назад

Комментарии (0)

Пожалуйста, войдите в аккаунт, чтобы оставить комментарий

Оставить комментарий

Похожие статьи

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

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

на связи

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

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

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

Войти