Перейти к содержанию

Отмена обработки запроса

Отмена позволяет сообщить длительной операции, что дальнейшее ожидание больше не требуется. В HTTP API это особенно полезно, когда клиент закрыл соединение или прекратил запрос, а сервер ещё получает данные. Однако token не прерывает произвольный код автоматически и не возвращает уже совершённые изменения в прежнее состояние. В этом уроке проведём отмену от handler до repository.

Продолжаем каталог с общим журналом. В lesson-12/after контракт ICatalogRepository станет асинхронным и начнёт принимать CancellationToken. Источник пока остаётся памятью: настоящего I/O ещё нет. Такое изменение готовит границу к следующему шагу и позволяет объяснить передачу token без ложного обещания, что чтение массива стало сетевой операцией.

Token текущего обращения

Минимальный endpoint может объявить параметр CancellationToken, который инфраструктура связывает с текущим HTTP-запросом. Это специальная форма binding, описанная в документации Microsoft. Она отличается от чтения строки query: клиент не отправляет сериализованный объект token.

В методе поиска отдельного курса передача теперь имеет такую форму:

private static async Task<Results<Ok<Course>, ProblemHttpResult>> FindCourse(
    string id, ICatalogRepository repository, CancellationToken token)
{
    var course = await repository.FindAsync(id, token);
    return course is null ? MissingCourse(id) : TypedResults.Ok(course);
}

Handler не создаёт новый независимый token и не теряет существующий на границе метода. Он передаёт полученное значение зависимой операции. Это важнее наличия слова async в сигнатуре: если нижний метод не принимает отмену или вызывается без неё, цепочка уведомления обрывается.

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

Контракт и временная реализация

Полный Data/ICatalogRepository.cs текущего снимка показывает интерфейс и память:

namespace CatalogApi;
public interface ICatalogRepository
{
    Task<IReadOnlyList<Course>> GetAllAsync(CancellationToken token);
    Task<Course?> FindAsync(string id, CancellationToken token);
}
public sealed class InMemoryCatalogRepository : ICatalogRepository
{
    public Task<IReadOnlyList<Course>> GetAllAsync(CancellationToken token)
    {
        token.ThrowIfCancellationRequested();
        return Task.FromResult<IReadOnlyList<Course>>(CatalogSeed.Courses.ToArray());
    }
    public Task<Course?> FindAsync(string id, CancellationToken token)
    {
        token.ThrowIfCancellationRequested();
        return Task.FromResult(CatalogSeed.Courses.FirstOrDefault(c => c.Id == id));
    }
}

GetAllAsync возвращает Task<IReadOnlyList<Course>>, FindAsync — Task<Course?>. До формирования результата реализация проверяет token через ThrowIfCancellationRequested. Если отмена уже запрошена, метод сообщает её исключением и не выдаёт успешный список. Если token ещё активен, данные читаются из памяти и оборачиваются завершённым task.

Task.FromResult не создаёт отдельный поток и не делает копирование массива настоящим асинхронным I/O. Оно согласует временный источник с новым контрактом. Эта граница позволяет в будущем заменить память ожиданием файла или базы, сохранив форму вызывающего кода. Для долгого CPU-цикла потребовались бы дополнительные точки проверки, поскольку token не останавливает вычисление сам.

Обработчик списка тоже вызывает await repository.GetAllAsync(token) и затем выполняет прежнюю фильтрацию. Значения minLessons и q продолжают поступать из query, repository — из DI, token — из текущего контекста. Различные источники параметров могут сочетаться в одном handler, но каждое значение следует передавать по своему назначению.

Отмена является совместной работой

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

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

Отмена также не должна автоматически превращаться в предметный 404. Отсутствующий курс означает, что поиск выполнился и запись не найдена. Прерванный поиск означает, что операция не завершила получение результата. Возврат пустого массива или null в любой ветви исключения стирает это различие и вводит клиента в заблуждение.

В нашем request journal отмена текущего обращения имеет отдельную ветвь. Она проверяет и тип исключения, и состояние RequestAborted. Это помогает не называть каждое OperationCanceledException действием клиента. Позднее внешний HTTP-вызов или собственный дедлайн могут использовать другой token и дать похожий тип исключения с иной причиной.

Что нельзя обещать клиенту

Если соединение уже прекращено, сервер не может гарантировать доставку нового аккуратного JSON-ответа с объяснением отмены. Поэтому статья не обещает специальный статус в браузере для этого случая. Надёжнее объяснить, какие операции получили сигнал и какие события сможет увидеть контролируемая диагностика.

Для будущей записи базы особенно важно различать отмену ожидания и откат. Команда могла быть принята, выполнена и зафиксирована, а клиент перестал ждать до чтения ответа. Сам факт отменённого token не доказывает отсутствие изменения. Транзакции, идентификаторы операции и стратегия повтора нужны именно потому, что наблюдение клиента иногда остаётся неопределённым.

Нельзя просто ловить OperationCanceledException и повторять POST создания. Если первая попытка успела создать объект, повтор может создать второй. В уроке CRUD мы сохраним это ограничение открыто; управление конкурентными и транзакционными изменениями является отдельным предметом продолжения.

Token можно представить как наблюдаемое состояние, которое несколько участников читают по договорённости. Он не содержит результата операции, числа сохранённых строк или причины каждой возможной отмены. Поэтому по одному IsCancellationRequested нельзя восстановить весь предметный исход. Если операция уже завершилась, результат нужно оценивать по её собственному контракту и наблюдению хранилища.

Также не требуется вручную вызывать Dispose у полученного CancellationToken: это значение, а не созданный нами источник отмены. Иное правило относится к собственному CancellationTokenSource, например при введении отдельного таймаута. Его время жизни и объединение token нужно описать явно. В текущем снимке такого источника нет, поэтому мы не скрываем дополнительный таймаут внутри repository.

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

Ожидаемый обычный GET по-прежнему даёт четыре курса и сумму 60. Изменение контракта не меняет публичный JSON или URL. Изменилось внутреннее прохождение намерения отменить работу. В следующем уроке добавим настоящее асинхронное чтение небольшого локального файла и рассмотрим, почему ожидание I/O отличается от блокировки потока.