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

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

Урок №4. Декомпозиция и D7 ORM: убираем N+1

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

В прошлом уроке компонент получил контракт параметров и правильный ключ кэша: onPrepareComponentParams() приводит вход к типам и белым спискам, getAdditionalCacheId() кладёт в ключ группы пользователя, setResultCacheKeys() решает, что уедет в файл кэша. Всё остальное по-прежнему в одной куче: выборка, форматирование дат, пути к картинкам и получение авторов живут в executeComponent(), а внутри его цикла на каждой новости вызывается CUser::GetByID(). Сегодня мы разложим точку входа на отдельные методы, заменим CIBlockElement::GetList() на сгенерированный ORM-класс ElementNewsTable и уберём N+1 — сначала в компоненте, потом в result_modifier.php шаблона.

Компонент урока 3 делает на стенде с десятью новостями двенадцать запросов: два из class.php и десять из result_modifier.php. На живом сайте больше, и ниже станет понятно почему.

Код урока — в снапшоте lesson-04-decomposition: компонент в class.php, шаблон в templates/.default/. У фрагментов из репозитория путь к файлу стоит в первой строке листинга. Начнём с замера: «стало быстрее» без цифры «было» — ощущение, а не результат.

Замер: сколько запросов делает страница сейчас

Компонент лежит на стенде Omut в ~/Omut/components.bitrix/www/local/components/project/news.list/, страница с ним — http://components.bitrix/news/.

Шаг 1. Отключите кэш компонента на время замера. В вызове компонента поставьте 'CACHE_TYPE' => 'N'. Иначе второй заход отдаст готовый HTML из кэша: executeComponent() не выполнится вовсе, и вы измерите не тот код, который чините.

Шаг 2. Включите статистику SQL. Авторизуйтесь администратором и откройте http://components.bitrix/news/?show_sql_stat=Y. Режим запоминается в cookie и держится на всех страницах, пока вы не откроете адрес с ?show_sql_stat=N. То же самое включается в админ-панели: «Отладка» → «Статистика SQL-запросов».

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

Внизу страницы появляется служебная полоса со временем создания страницы и строкой вида:

        Всего SQL запросов: NN

    

Рядом ссылка «Статистика SQL-запросов». За ней список всех запросов с текстом, временем выполнения и трассировкой «Откуда вызван:».

Общее NN у каждого стенда своё: часть запросов ядро делает само — за опциями, сайтом, сессией и пользователем. Смысл имеет сравнение «до и после» на одной странице, а искать надо свои строки. На стенде курса их двенадцать: десять с трассировкой на result_modifier.php (свойство SOURCE для каждой новости) и два на class.php — список и автор. Запишите оба числа: общее и количество строк компонента.

Автор всего один, хотя новостей десять, по той же причине, что и в уроке 1. Все новости на стенде завели вы и смотрите их тоже вы, а CUser::GetByID() запоминает текущего пользователя и второй раз за ним в базу не ходит. Гость или другой редактор такой поблажки не получают. Если хотите увидеть N+1 своими глазами, заведите второго администратора и снимите замер под ним: строк с class.php станет одиннадцать, а на пятидесяти новостях — пятьдесят одна.

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

  • Полосы внизу страницы нет. Статистику видит только пользователь с правом на изменение PHP-кода, то есть администратор. Проверьте, что вы авторизованы в той же вкладке.
  • Число не меняется от перезагрузки, запросов компонента в списке нет. Страница отдаётся из кэша. Верните 'CACHE_TYPE' => 'N' и очистите кэш.
  • Число есть, а графа «Откуда вызван:» пустая. В конфигурации определена константа BX_NO_SQL_BACKTRACE: с ней ядро не собирает трассировку, и привязать запрос к файлу не получится. Уберите её на время замера.

Почему один длинный метод дорого стоит

В executeComponent() сейчас шесть занятий: проверка параметра, подключение модуля, открытие блока кэша, построение фильтра, обход выборки, форматирование элементов. «Так короче» работает до первого дня сопровождения.

Ответственность. Когда в списке пропали даты, вы читаете весь метод, потому что дата собирается там же, где идёт запрос. Разделив выборку и форматирование, вы получаете адрес для каждого класса ошибок: пустой список — getRawData(), кривая дата или ссылка в никуда — formatItems().

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

Отсюда критерий деления. Метод отвечает за одну вещь, если его вход и выход можно назвать без слова «и»:

Метод Вход → выход Чего внутри быть не должно
getRawData() параметры → сырые строки из базы форматирования, путей к файлам, дат
loadAuthors() элементы → словарь ID → имя циклов с запросом на итерацию
formatItems() элементы и авторы → данные шаблона любых обращений к базе
setSeo() ничего → заголовок и мета-теги выборки, кэша

И правило, которое стоит запомнить дословно: запрос к базе внутри цикла — сигнал вынести batch-метод. У нас этот сигнал подаёт \CUser::GetByID($arItem['CREATED_BY']): десять новостей одного редактора дают гостю десять одинаковых запросов за одним и тем же пользователем.

D7 ORM: откуда берётся ElementNewsTable

Прежде чем разбирать компонент на методы, поменяем источник данных. \Bitrix\Iblock\ElementTable — универсальная таблица b_iblock_element: она знает стандартные поля вроде NAME и ACTIVE_FROM, но не знает свойств конкретного инфоблока. Нам нужен другой класс — \Bitrix\Iblock\Elements\ElementNewsTable.

Он появляется, когда у инфоблока задан символьный код API News: админка → Контент → Инфоблоки → карточка инфоблока → поле «Символьный код API». Вы задали его в уроке 1. Имя класса строится по шаблону Element + код + Table: код News даёт ElementNewsTable.

ℹ️ Под капотом файла такого класса на диске нет. Модуль iblock регистрирует обработчик автозагрузки Bitrix\Iblock\ORM\Loader::autoLoad(): он вырезает из имени класса News, ищет инфоблок с таким API_CODE и вызывает IblockTable::compileEntity(), сущность собирается на лету. Сборка не бесплатная и ядро её не кэширует: на стенде курса это пять запросов (инфоблок, его свойства, языки) в каждом PHP-процессе, где класс понадобился впервые. Платите вы за них только на промахе кэша компонента: при попадании getRawData() не вызывается, класс не загружается, и этих запросов нет. Обработчик приходит с модулем, поэтому checkModules() из урока 2 нужен до первого обращения к сущности.

Отсюда следствие: сущность намертво привязана к своему инфоблоку. Условие по IBLOCK_ID она дописывает сама, и параметр IBLOCK_ID компоненту больше не нужен. ID инфоблока знает сама сущность: ElementNewsTable::getEntity()->getIblock()->getId() отдаёт его без запроса к базе. Из контракта параметр уходит вместе с проверкой в executeComponent(), из вызова на странице его тоже можно убрать.

Ловушка 1: DETAIL_PAGE_URL из ORM приходит шаблоном, а не ссылкой

CIBlockElement::GetList() в паре с GetNext() подставлял готовый URL детальной страницы. ORM так не делает: шаблон URL вида #SITE_DIR#/news/#ELEMENT_CODE#/ хранится у инфоблока, а не у элемента. Брать его надо по связи, строкой 'DETAIL_PAGE_URL' => 'IBLOCK.DETAIL_PAGE_URL' в select, а превращать в адрес уже при форматировании, тем же методом ядра, которым это делает GetNext(): \CIBlock::ReplaceDetailUrl($template, $item, false, 'E'). Четвёртый аргумент — тип сущности: 'E' для элемента, 'S' для раздела. Пропустите этот шаг, и в разметку уйдут строки с решётками. Рецепт целиком — в заметке «Как получить DETAIL_PAGE_URL элемента инфоблока в D7».

ℹ️ Под капотом IBLOCK — поле-ссылка (Reference) у сущности элемента, поэтому IBLOCK.DETAIL_PAGE_URL в select превращается в JOIN внутри того же запроса, отдельного обращения к базе за шаблоном не будет.

Ловушка 2: свойство приходит тем же запросом, но не под своим именем

У сгенерированного класса свойства инфоблока — поля сущности, и их можно выбирать тем же запросом, что и элемент. У универсального ElementTable так нельзя: там за свойствами ходят отдельно, тем самым CIBlockElement::GetProperty(), который сидит в цикле нашего result_modifier.php. Ради этого на сгенерированный класс и стоит переходить, типизация в IDE тут вторична.

Есть подвох. Свойство в сущности — не строка, а ссылка на запись значения. Если написать в select только 'SOURCE', fetchAll() вернёт пачку ключей вида IBLOCK_ELEMENTS_ELEMENT_NEWS_SOURCE_VALUE, а ключа SOURCE не будет. Значение берут по связи и сразу дают ему имя:

        'select' => [
    'ID', 'NAME',
    'SOURCE_VALUE' => 'SOURCE.VALUE',
],

    

Если у инфоблока свойства с кодом SOURCE нет, запрос упадёт с Unknown field definition — так ORM сообщает, что поля нет в сущности. И оговорка на будущее: множественное свойство добавляет JOIN и размножает строки выборки, по строке на каждое значение, а limit считает именно строки. Для таких делайте отдельную выборку.

Ловушка 3: ORM не ставит тег кэша

В уроке 3 правка новости сбрасывала кэш сама: CIBlockResult регистрировал тег iblock_id_N, пока мы перебирали выборку. ElementNewsTable::getList() возвращает D7-результат, а он ничего такого не делает. На стенде это видно сразу: после перехода на ORM правка новости кэш больше не сбрасывает, файл лежит до истечения CACHE_TIME. Тег надо зарегистрировать явно, внутри блока кэша. В class.php это первая строка блока, её видно в executeComponent() ниже:

        \CIBlock::registerWithTagCache(ElementNewsTable::getEntity()->getIblock()->getId());

    

Шаг 1. Слой данных: getRawData()

Все методы ниже лежат в class.php снапшота. Первый отвечает за одно — достать строки. Ни FormatDate, ни CFile, ни проверок прав:

        // lesson-04-decomposition/class.php

use Bitrix\Iblock\Elements\ElementNewsTable;

protected function getRawData(): array
{
    return ElementNewsTable::getList([
        'select' => [
            'ID', 'NAME', 'PREVIEW_TEXT', 'PREVIEW_PICTURE',
            'ACTIVE_FROM', 'CREATED_BY', 'CODE', 'IBLOCK_ID', 'IBLOCK_SECTION_ID',
            'DETAIL_PAGE_URL' => 'IBLOCK.DETAIL_PAGE_URL',
        ],
        'filter' => [
            '=ACTIVE' => 'Y',
        ],
        'order' => [
            'ACTIVE_FROM' => $this->arParams['SORT_ORDER'],
            'ID' => 'DESC',
        ],
        'limit' => $this->arParams['NEWS_COUNT'],
    ])->fetchAll();
}

    

Помимо смены API здесь важно вот что. select перечисляет поля явно, и следующий разработчик видит состав $arResult['ITEMS'] на одном экране. CODE, IBLOCK_ID и IBLOCK_SECTION_ID нужны ReplaceDetailUrl(), чтобы подставить их в шаблон адреса, а IBLOCK_ID ещё и шаблону — для ссылки «Редактировать». IBLOCK_ID в фильтре нет, сущность и так знает свой инфоблок. SORT_ORDER и NEWS_COUNT приходят из $arParams уже проверенными в onPrepareComponentParams(), поэтому их можно подставлять в order и limit без дополнительных проверок.

ℹ️ Если это в новинку fetchAll() возвращает обычный массив массивов, как и ждёт шаблон. Но типы полей в нём уже D7: ACTIVE_FROM приходит объектом Bitrix\Main\Type\DateTime, а не строкой, поэтому дальше у него вызывается getTimestamp(), а не MakeTimeStamp().

Шаг 2. Batch-выборка авторов: loadAuthors()

Второй метод закрывает N+1: сначала собрать все нужные ID, потом сходить за ними один раз.

        // lesson-04-decomposition/class.php

use Bitrix\Main\UserTable;

protected function loadAuthors(array $items): array
{
    $authorIds = array_unique(array_filter(array_column($items, 'CREATED_BY')));

    if ($authorIds === []) {
        return [];
    }

    $result = UserTable::getList([
        'select' => ['ID', 'NAME', 'LAST_NAME'],
        'filter' => ['=ID' => $authorIds],
    ]);

    $authors = [];
    while ($user = $result->fetch()) {
        $authors[$user['ID']] = trim($user['NAME'] . ' ' . $user['LAST_NAME']);
    }

    return $authors;
}

    

array_column($items, 'CREATED_BY') вытаскивает столбец ID, array_filter убирает нули, array_unique — повторы: десять новостей одного редактора дают один ID в фильтре. Массив в =ID ORM превращает в IN (...). Ранний выход при пустом списке экономит запрос.

Результат — словарь ID → имя, сами элементы метод не трогает, соединение произойдёт уровнем выше. Сколько бы ни было новостей и авторов, за авторами идёт один запрос.

Шаг 3. Слой представления: formatItems()

Третий метод — чистое преобразование: на входе элементы и словарь авторов, на выходе массив для шаблона.

        // lesson-04-decomposition/class.php

protected function formatItems(array $items, array $authors): array
{
    return array_map(function (array $item) use ($authors) {
        if (!empty($item['PREVIEW_PICTURE'])) {
            $item['PREVIEW_PICTURE_SRC'] = \CFile::GetPath($item['PREVIEW_PICTURE']);
        }

        if (!empty($item['ACTIVE_FROM'])) {
            $item['ACTIVE_FROM_FORMATTED'] = FormatDate(
                'd F Y',
                $item['ACTIVE_FROM']->getTimestamp()
            );
        }

        if (!empty($item['CREATED_BY']) && isset($authors[$item['CREATED_BY']])) {
            $item['AUTHOR_NAME'] = $authors[$item['CREATED_BY']];
        }

        $item['DETAIL_PAGE_URL'] = \CIBlock::ReplaceDetailUrl(
            (string)($item['DETAIL_PAGE_URL'] ?? ''),
            $item,
            false,
            'E'
        );

        return $item;
    }, $items);
}

    

Методу всё равно, откуда пришли данные: его можно вызвать с массивом, собранным руками, без инфоблока, авторизации и HTTP-запроса. Он же пригодится, если завтра новости понадобится отдать JSON-ом из контроллера.

Одно исключение: \CFile::GetPath() читает b_file. Ядро держит эту таблицу в кэше (CACHED_b_file), так что в запрос на каждую картинку это обычно не превращается.

Шаг 4. executeComponent() как оглавление

Точка входа больше ничего не делает сама. Она решает, работать или отказать, открывает блок кэша и расставляет вызовы по порядку:

        // lesson-04-decomposition/class.php

public function executeComponent(): void
{
    try {
        $this->checkModules();


        if ($this->startResultCache($this->arParams['CACHE_TIME'], $this->getAdditionalCacheId())) {
            \CIBlock::registerWithTagCache(ElementNewsTable::getEntity()->getIblock()->getId());

            $rawItems = $this->getRawData();

            $items = $this->formatItems($rawItems, $this->loadAuthors($rawItems));

            $this->arResult = [
                'ITEMS' => $items,
                'TOTAL_COUNT' => count($items),
                'IS_ADMIN' => CurrentUser::get()->isAdmin(),
            ];

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

            $this->includeComponentTemplate();
        }

        $this->setSeo();
    } catch (\Exception $e) {
        $this->abortResultCache();
        ShowError($e->getMessage());
    }
}

    

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

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

SEO-код переехал из хвоста executeComponent() в отдельный метод setSeo(): global $APPLICATION, затем SetTitle() и SetPageProperty('description', …). $APPLICATION здесь остался потому, что у этого API нет штатного D7-аналога. Зато теперь глобальная переменная упомянута в одном методе из шести, и её видно.

Проверяем: компонент на ORM

Верните 'CACHE_TYPE' => 'A', очистите кэш и откройте http://components.bitrix/news/. Список выглядит так же, как до рефакторинга: те же новости в том же порядке, даты вида 20 октября 2026 (FormatDate('d F Y', …) в русской локали отдаёт месяц в родительном падеже со строчной буквы), имя автора под каждой, заголовки и ссылка «Подробнее» ведут на детальную страницу. Под администратором под каждой новостью есть «Редактировать», а в приватном окне их по-прежнему нет.

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

Снимите замер ещё раз: 'CACHE_TYPE' => 'N', страница с ?show_sql_stat=Y. После рефакторинга строк с class.php стало не меньше, а больше: семь вместо двух. Пять из них — сборка ElementNewsTable из ловушек выше (инфоблок по коду API, свойства, языки), шестой — сами новости одним запросом с JOIN на b_iblock, седьмой — авторы со списком ID в IN (...). Десять строк с result_modifier.php на месте, их мы ещё не трогали.

Под вашей учёткой компонент стал дороже на пять запросов. Выигрыш в другом: число больше не зависит от длины списка. Поставьте NEWS_COUNT равным 50 — строк с class.php по-прежнему семь, хотя под вторым администратором код урока 3 давал на тех же пятидесяти новостях пятьдесят одну. Если у вас список из пяти новостей, который смотрит только администратор, ORM не сэкономит ни одного запроса. Переходить на него стоит ради свойств в одном запросе и ради того, что счёт не растёт вместе с контентом.

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

  • Class "Bitrix\Iblock\Elements\ElementNewsTable" not found. У инфоблока не задан символьный код API или задан другой: нужен именно News (карточка инфоблока → «Символьный код API» → сохранить). Вторая причина той же ошибки — не подключён модуль iblock: обработчик автозагрузки приходит вместе с ним, поэтому checkModules() вызывается до первого обращения к сущности.
  • Ссылки ведут в никуда, в адресе видны решётки вроде #SITE_DIR#. В select не добавлен 'DETAIL_PAGE_URL' => 'IBLOCK.DETAIL_PAGE_URL' либо в formatItems() не вызван \CIBlock::ReplaceDetailUrl(): без первого подставлять нечего, без второго шаблон уходит в разметку как есть.
  • Правка новости видна только после очистки кэша. Нет строки \CIBlock::registerWithTagCache() или она стоит вне блока if.
  • Поле приходит пустым. В выборку попадает только то, что перечислено в select: у D7 набор ровно такой, как вы написали. Добавьте поле в select, свойство — в виде 'КОД_VALUE' => 'КОД.VALUE'.

Второй N+1: цикл в result_modifier.php

Компонент вычищен, а в замере всё ещё десять лишних запросов. Они из result_modifier.php, доставшегося от урока 1: foreach по $arResult['ITEMS'] и внутри CIBlockElement::GetProperty() за свойством SOURCE.

Этот N+1 живёт в шаблоне, а не в компоненте, поэтому его не находят, читая class.php. С переходом на сгенерированную сущность он лечится двумя правками: добавьте 'SOURCE_VALUE' => 'SOURCE.VALUE' в select метода getRawData(), а цикл из модификатора удалите. В снапшоте урока select без свойства, потому что шаблон его не выводит, а result_modifier.php оставлен как есть, чтобы вы увидели эти запросы в замере. С урока 5 его в шаблоне нет.

В том же файле есть вторая поломка, и она тише первой. Сверьте onPrepareComponentParams() в снапшотах уроков 3 и 4: в уроке 3 там разбирается SHOW_FULL_LIST, в уроке 4 этого параметра в контракте уже нет, а модификатор в первых же строках читает $arParams['SHOW_FULL_LIST']. PHP выдаёт на это предупреждение Undefined array key, но Битрикс по умолчанию предупреждения не показывает: main/include.php убирает E_WARNING из error_reporting. Вы ничего не увидите, а флаг SHOW_ALL никогда не выставится. Я нашёл это, только когда собрал предупреждения отдельным обработчиком. «Показать весь список» решает, какие данные выбрать, вёрстка тут ни при чём, поэтому параметр вместе с логикой должен жить в компоненте. Лечится той же чисткой модификатора; если режим «весь список» вам нужен, объявите параметр в onPrepareComponentParams() и учтите его в getRawData().

Проверяем: модификатор больше не ходит в базу

Удалите цикл с GetProperty() и блок с SHOW_FULL_LIST из templates/.default/result_modifier.php своего компонента, очистите кэш и снимите замер ещё раз. Строк с трассировкой на result_modifier.php в списке не остаётся, разметка не меняется. На стенде курса от компонента остаётся семь запросов вместо двенадцати в начале урока.

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

  • Запросы никуда не делись. Отредактирован не тот файл: у компонента может быть несколько шаблонов. Смотрите полный путь в трассировке «Откуда вызван:».
  • Unknown field definition после добавления свойства в select. У инфоблока нет свойства с таким кодом. Проверьте код в настройках свойств инфоблока, регистр тоже имеет значение.
  • Изменения не видны. Страница отдаётся из кэша: result_modifier.php выполняется внутри блока кэша и при попадании в кэш не запускается вовсе. Очистите кэш.
🛠 Практическая работа

Работаем с компонентом, который вы разбирали в аудите урока 1.

Шаг 1. Замер до. Включите статистику SQL, отключите кэш компонента и запишите два числа: общее по странице и количество запросов, у которых в трассировке «Откуда вызван:» стоят файлы вашего компонента. Если данные на стенде заводили вы сами, снимите замер ещё и под вторым администратором: CUser::GetByID() прячет N+1 от автора.

Шаг 2. Найдите цикл с обращением к базе. Он может быть в class.php, в result_modifier.php, в template.php. Признаки — GetByID, GetProperty, GetList или getList внутри foreach или while. Перепишите на batch-выборку по схеме из урока: собрать ID, отсеять пустые и повторы, сходить одним запросом, разложить в словарь ID → значение и подставить при форматировании.

Шаг 3. Разберите точку входа. Вынесите из executeComponent() минимум два метода с одной ответственностью каждый: один только достаёт данные, другой только преобразует их для шаблона. Названия ваши, критерий общий: вход и выход метода описываются без слова «и».

Шаг 4. Замер после. Повторите замер и сравните с записанными числами. Затем удвойте лимит выборки: было 10 элементов — поставьте 20. Число запросов компонента не должно измениться. Если вы перешли на ORM, проверьте и тег: поправьте элемент и убедитесь, что страница обновилась без очистки кэша.

Критерий готовности. Метод форматирования можно вызвать с массивом, собранным руками, и он не пойдёт в базу; удвоение числа элементов не меняет число запросов. Если второе не выполняется, остался цикл с обращением к базе — ищите его по трассировке.

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

Заключение

Компонент перестал быть одним методом. Выборка живёт в getRawData(), авторы — в loadAuthors(), подготовка данных для шаблона — в formatItems(), мета-теги — в setSeo(), а executeComponent() читается как оглавление. Источником данных стала сгенерированная сущность ElementNewsTable. Она стоит пять запросов на сборку и не ставит тег кэша сама, зато отдаёт свойства и шаблон адреса тем же запросом, а число запросов больше не растёт со списком.

Декомпозиция даёт две вещи: у каждой ошибки появляется адрес, и любой кусок кода можно выполнить отдельно. Метод, который делает одну вещь, можно проверить, переиспользовать и заменить целиком. Метод, который делает шесть, можно только каждый раз перечитывать сверху вниз.

Теперь посмотрите на компонент глазами человека, который завтра будет писать второй такой же. checkModules(), try/catch с abortResultCache(), getAdditionalCacheId(), нормализация CACHE_TIME и CACHE_GROUPS никак не связаны с новостями. Этот код придётся дословно повторить в каждом следующем компоненте, а потом править во всех местах сразу.

В уроке 5 «Общий код в модуль: базовый класс и трейты» этот каркас уедет в собственный модуль project.core, и компоненты будут собираться из BaseComponent и трейтов.

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

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

на связи

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

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

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

Войти