Перейти к содержимому

Контроллер изнутри - маршрут, действие, фильтры, ответ

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

Механика

Штатных входов у контроллера два. Первый - общий адрес обмена /bitrix/services/main/ajax.php с параметром action, второй - объявленный маршрут из файла /local/routes/web.php (роутер работает с версии main 21.400.0). Оба входа приводят к одному и тому же вызову метода класса.

Идентификатор действия читается по частям: [вендор]:[модуль].[Класс].[действие]. Без вендора платформа подставляет bitrix, поэтому чужой модуль без префикса просто не находится. Собирать этот идентификатор руками в разметке не нужно - ссылку строит Engine\UrlManager.

Класс ищется в базовом пространстве имён модуля. Его объявляет секция controllers файла .settings.php самого модуля, и без неё ядро смотрит в Bitrix\Main\Engine\DefaultController, где описания действия нет. Ответ в этом случае содержит код 22002 и текст о ненайденном описании.

Действие отличается от обычного метода тремя вещами сразу: оно публично, названо с суффиксом Action, а его аргументы платформа заполняет из запроса по имени и типу. Отсутствие обязательного аргумента даёт ошибку Could not find value for parameter ещё до входа в тело метода. Типовые объекты подставляет автоваринг (Engine\CurrentUser, UI\PageNavigation, Engine\JsonPayload), свои классы подключают правилом из getAutoWiredParameters(), а проверяемые структуры приходят через ValidationParameter.

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

Управляют этим набором декларативно. Атрибуты из Engine\ActionFilter\Attribute\Rule\* вешают прямо на метод, и это предпочтительный путь; getDefaultPreFilters() переопределяют только вместе с вызовом родителя. Пересобранная с нуля защита - самая частая причина открытого наружу действия.

Подпись сеанса проверяет фильтр Csrf: при несовпадении ключа действие не выполняется, а клиент получает ответ с ошибкой о подписи. Штатный вызов BX.ajax.runAction умеет забрать свежий ключ из этого ответа и повторить запрос сам, а обращение по прямому адресу такой попытки не делает. Соседний фильтр Authentication при неудаче отдаёт гостю ответ со статусом 401, потому что редирект на страницу входа включается только явным параметром $enableRedirect.

Ответ собирает ядро. Скаляр или массив из действия оборачиваются в Response\AjaxJson с полями status, data и errors; ошибки, добавленные через addError(), попадают в третье поле. Для разметки и заголовков возвращают HttpResponse, для чистого JSON без конверта - Response\Json.

Клиентский код видит не то же самое, что вернул метод. BX.ajax.runAction отдаёт промис: возвращённый массив лежит в поле data ответа, а список ошибок приходит отдельно в catch. Отсюда постоянная путаница с undefined при чтении результата сразу после вызова.

Контроллер компонента живёт по другим правилам входа. У него действия описаны в class.php, вызов идёт через BX.ajax.runComponentAction с режимом и подписанными параметрами, а пространство имён модуля здесь ни при чём. Устройство таких действий разобрано в отдельной странице про компонент.

Шаги

  1. Разобрать идентификатор действия на вендора, модуль, класс и имя метода без суффикса.
  2. Проверить базовое пространство имён контроллеров в файле настроек своего модуля.
  3. Сверить сигнатуру действия с составом запроса: имена аргументов, типы и обязательность.
  4. Посмотреть набор префильтров действия: подпись сеанса, метод запроса и требование авторизации.
  5. Проследить путь ответа от возврата метода до поля data в клиентском коде.

Код

Объявляем базовое пространство имён контроллеров:

/local/modules/vendor.module/.settings.php
return [
'controllers' => [
'value' => ['defaultNamespace' => '\\Vendor\\Module\\Controller'],
'readonly' => true,
],
];
// без этой секции ядро ищет действие в DefaultController и отвечает кодом 22002

Именно эта секция превращает часть vendor:module в каталог классов. AJAX- и HTTP-контроллеры разводят по разным пространствам имён, и в настройках модуля указывают то из них, которое обслуживает асинхронные вызовы.

Ведём к тому же действию свой маршрут:

/local/routes/web.php
use Bitrix\Main\Routing\RoutingConfigurator;
use Vendor\Module\Controller\Item;
return static function (RoutingConfigurator $routes) {
$routes->post('api/v1/item/{id}/', [Item::class, 'view'])
->where('id', '[0-9]+') // параметр без шаблона матчится как [^/]+
->name('Vendor.Item.View'); // имя уникально среди всех файлов маршрутов
};
// имя действия пишут без суффикса Action, как и в идентификаторе вызова
// сам файл подключают в секции routing файла настроек

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

Описываем действие с фильтрами-атрибутами:

namespace Vendor\Module\Controller;
use Bitrix\Main\Engine\Controller;
use Bitrix\Main\Engine\ActionFilter\Attribute\Rule\{Authentication, HttpMethod};
use Bitrix\Main\Engine\CurrentUser;
use Bitrix\Main\Error;
final class Item extends Controller
{
#[Authentication]
#[HttpMethod([HttpMethod::METHOD_POST])]
public function viewAction(int $id, CurrentUser $user): ?array
{
if ($id <= 0) {
$this->addError(new Error('Неверный идентификатор', 'INVALID_ID'));
return null; // ответ придёт со статусом ошибки
}
return ['id' => $id, 'userId' => $user->getId()];
}
}

Суффикс Action обязателен, а в идентификаторе его не пишут: метод выше вызывается как vendor:module.Item.view. Аргумент $id платформа берёт из запроса и приводит к числу, а объект пользователя подставляет автоваринг.

Конверт ответа при отказе выглядит так:

{
"status": "error",
"data": null,
"errors": [
{
"message": "Неверный идентификатор",
"code": "INVALID_ID",
"customData": null
}
]
}

Поле status различает успех и отказ, данные и ошибки лежат в разных полях, а код ошибки задаём мы сами. Отказ фильтра приходит в этом же конверте, поэтому клиентский код разбирает оба случая одинаково.

Добавляем свой префильтр, не ломая штатные:

protected function getDefaultPreFilters(): array
{
return array_merge(parent::getDefaultPreFilters(), [
new \Bitrix\Main\Engine\ActionFilter\CloseSession(), // снимает блокировку сессии
]);
}
// parent даёт три штатных фильтра: подпись, метод запроса и авторизацию
// возврат пустого массива снимает их разом, включая проверку подписи

Список префильтров действия задают и через configureActions(), но атрибуты на методе читаются рядом с кодом и потому предпочтительнее.

Подставляем в действие свой объект вместо идентификатора:

public function getAutoWiredParameters(): array
{
return [
new \Bitrix\Main\Engine\AutoWire\ExactParameter(
\Vendor\Module\Item::class, 'code',
static fn(string $className, string $code) => \Vendor\Module\Item::load($code)
),
];
}

Правило связывает параметр запроса code с готовой сущностью. Без такого правила нештатный тип в сигнатуре даёт ошибку о ненайденном значении параметра, а не пустой объект.

Строим ссылку на действие из PHP:

$url = \Bitrix\Main\Engine\UrlManager::getInstance()
->create('vendor:module.Item.view', ['id' => 7]);
// идентификатор сайта и кодирование параметров подставляются сами
// собранный строкой адрес ломается на многосайтовости и кириллице в параметрах
// третий аргумент делает адрес абсолютным: он нужен письмам и внешним клиентам

Тот же адрес пригождается при разборе сбоя: открытый в браузере, он показывает сырой конверт ответа целиком, без промежуточного разбора клиентским кодом.

Приводим ключи ответа к виду, привычному фронтенду:

use Bitrix\Main\Engine\Response\Converter;
$converter = new Converter(Converter::OUTPUT_JSON_FORMAT);
$data = $converter->process(['item_id' => 7, 'created_at' => $date->toString()]);
// на выходе ключи itemId и createdAt: преобразование идёт рекурсивно
// объект даты сам в строку не превратится: приводим его явно на границе ответа
// то же касается сущностей ORM, денег и любых объектов со своим форматом

Читаем ответ на стороне браузера:

BX.ajax.runAction('vendor:module.Item.view', { data: { id: 7 } })
.then((response) => console.log(response.data)) // возврат действия лежит здесь
.catch((response) => console.log(response.errors)); // код и текст отказа
// та же пара полей у варианта с ожиданием ответа
const { data, errors } = await BX.ajax.runAction('vendor:module.Item.view', {
method: 'POST',
data: { id: 7 },
});

Поле data содержит ровно то, что вернул метод, а поле errors - коллекцию ошибок контроллера. Ошибка фильтра приходит тем же путём, поэтому отказ по подписи и отказ по правам различают по коду, а не по поведению страницы.

Ограничения

Контроллеры для страниц и для асинхронных вызовов разделяют по разным пространствам имён. Вызов BX.ajax.runAction для действия, оставшегося только в HTTP-маршруте, вернёт ошибку 22002, и это ожидаемое поведение, а не сбой.

Маршрут может не сработать по причинам вне самого контроллера. Тот же адрес перекрывают старые правила urlrewrite.php, которые проверяются раньше, либо объявленный выше маршрут {any}: сопоставление идёт до первого совпадения.

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

Автоваринг понимает только штатные типы и объявленные правила. Всё остальное приходит скалярами из запроса, а нештатный тип в сигнатуре без правила останавливает вызов ошибкой о ненайденном значении параметра.

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

Типичные проблемы

Ответ: «Could not find description of Item.view in Bitrix\Main\Engine\DefaultController».

В файле настроек модуля не объявлено базовое пространство имён контроллеров. Ядро ищет действие в контроллере по умолчанию и описания там не находит.

Действие не находится, хотя класс лежит на месте и автозагрузка работает.

У метода забыт суффикс Action в имени класса контроллера. В идентификаторе суффикс не пишут, а в самом методе он обязателен.

Запрос отклоняется по подписи после долгой жизни вкладки или входа на другой вкладке.

Ключ сеанса сменился, а страница отправляет прежний. Штатный вызов забирает свежий ключ из ответа и повторяет запрос, обращение по прямому адресу - нет.

Даты в ответе приходят строкой не того формата, что показывает сайт.

Объект даты уезжает в ответ через сериализацию, а не через приведение к строке. На границе действия его приводят явно вызовом toString.

Печать результата сразу после вызова показывает undefined.

Вызов действия возвращает промис, а не данные. Ответ читают в then или через ожидание, и лежит он в поле data, а не в корне.

Гость получает 401 вместо страницы входа.

Фильтр аутентификации по умолчанию не делает редиректа. Он включается отдельным параметром и ведёт только на страницу авторизации с обратным адресом.

Частые вопросы

Почему не работает контроллер?

Чаще всего из-за адреса, а не из-за кода: не объявлено пространство имён модуля или в имени метода забыт суффикс. Точную причину называет код ошибки в ответе.

Как узнать, какие параметры надо прописывать в запросе?

По сигнатуре действия: имена аргументов совпадают с именами параметров запроса, а типы приводятся платформой. Обязательный аргумент без значения останавливает вызов.

Как вытащить возвращаемое значение из ajax-запроса?

Через промис вызова: возврат действия лежит в поле data ответа. Читать его сразу после вызова бесполезно, ответ к этому моменту ещё не пришёл.

Чем действие контроллера отличается от обычного метода?

Именем с суффиксом, аргументами из запроса и фильтрами вокруг вызова. Обычный метод класса контроллера снаружи недоступен и в идентификаторе не адресуется.

Можно ли обращаться к общему адресу обмена напрямую?

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

Смежное

Первоисточник