Рубрики
Программирование

Middleware в ASP.NET Core

При разработке ASP.NET Core приложения одной из основных задач является обработка входящих запросов. Но если задуматься, то работа с ними происходит не только в контроллере, но и до него и даже после. Давайте разберёмся с предобработкой запроса – как она устроено, с чем её едят и что с ней делать.

Middleware и конвейер запроса

Для того чтобы “подготовить” запрос к дальнейшей обработке мы можем воспользоваться middleware – программным обеспечением, собранным в конвейер. По сути оно представляет собой цепочку компонентов, которые выполняются один за другим перед и после работы остального приложения. Отсюда и название – эта часть находится посередине между самим сервисом и клиентом, который к нему обращается. Кроме того, мы можем и повлиять на ответ, возвращаемый пользователю, например, сжать его. Элегантность этого подхода заключается в том, что мы легко и просто внести нужные нам изменения, в чём мы убедимся чуть позже.

Иллюстрация работы конвейера. Взято с сайта microsoft.

Наиболее частыми задачами, которые выполняет middleware является аутентификация и авторизация, кэширование ответов, работа со статическими файлами и т.д. Естественно, что для этих задач не надо каждый раз писать новый код, ведь заботливые разработчики из microsoft уже предоставили нам целый набор инструментов на многие случаи.

Встроенные компоненты middleware

Давайте посмотрим на список уже существующих компонентов.

  • Authentication – предоставляет поддержку аутентификации
  • Authorization – предоставляет поддержку авторизации
  • Cookie Policy – отслеживает согласие пользователя на хранение связанной с ним информации в куках
  • CORS – обеспечивает поддержку кроссдоменных запросов
  • DeveloperExceptionPage – генерирует веб-страницу с информацией об ошибке при работе в режиме разработки
  • Diagnostics – набор middleware, который предоставляет страницы статусных кодов, функционал обработки исключений, страницу исключений разработчика
  • Forwarded Headers – перенаправляет заголовки запроса
  • Health Check – проверяет работоспособность приложения asp.net core
  • Header Propagation – обеспечивает передачу заголовков из HTTP-запроса
  • HTTP Logging – логгирует информацию о входящих запросах и генерируемых ответах
  • HTTP Method Override – позволяет входящему POST-запросу переопределить метод
  • HTTPS Redirection – перенаправляет все запросы HTTP на HTTPS
  • HTTP Strict Transport Security (HSTS) – для улучшения безопасности приложения добавляет специальный заголовок ответа
  • MVC – обеспечивает функционал фреймворка MVC
  • OWIN – обеспечивает взаимодействие с приложениями, серверами и компонентами, построенными на основе спецификации OWIN
  • Request Localization – обеспечивает поддержку локализации
  • Response Caching – позволяет кэшировать результаты запросов
  • Response Compression – обеспечивает сжатие ответа клиенту
  • URL Rewrite – предоставляет функциональность URL Rewriting
  • Endpoint Routing – предоставляет механизм маршрутизации
  • Session – предоставляет поддержку сессий
  • SPA – обрабатывает все запросы, возвращая страницу по умолчанию для SPA-приложения (одностраничного приложения)
  • Static Files – предоставляет поддержку обработки статических файлов
  • WebSockets – добавляет поддержку протокола WebSockets
  • W3CLogging – генерирует логи доступа в соответствии с форматом W3C Extended Log File Format

Совершенно не обязательно использовать все из них, но благодаря им и, если потребуется, своим собственным компонентам мы можем построить конвейер обработки и не переживать, что мы что-то да забыли при создании непосредственно контроллера.

Построение конвейера

Давайте посмотрим на примере. Создадим простенькое приложение и в данном случае нам хватит стандартного шаблона с прогнозом погоды. Тут правда есть отличие между версиями – если в 3.1 мы увидим файл Startup.cs с методом Configure, то в 7.0 интересующий нас код будет в Program.cs. Однако, в целом для нас это не будет иметь принципиального значения, поскольку в обоих случаях мы работаем с методами расширения класса WebApplication, который реализует IApplicationBuilder.

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

Впрочем, тут есть ещё одно отличие – в 7.0 мы можем получить информацию об окружении из объекта app, а вот в 3.1 на нужно обратиться к параметру IWebHostEnvironment env. Однако, главное для нас методы расширения, которые мы вызываем непосредственно для app – Use, Run и некоторые другие с которыми мы познакомимся.

Метод Use (равно как и производные от него, например, метод-расширение UseAuthorization) добавляет компонент, который позволяет передать обработку следующему компоненту в конвейере. При этом добавляя компонент мы в том числе задаём его место в конвейере. Кроме того в приведённом выше коде мы можем видеть, что для разного окружения конвейер будет разным – Swagger мы добавляем только окружения разработчика, а на проде мы его уже не увидим. Это позволяет нам избежать дополнительных расходов и адоптировать приложение под наши нужды, ведь на проде и в рабочем окружении нам может потребоваться разное поведение.

public static IApplicationBuilder Use(this IApplicationBuilder app, Func, Task> middleware);
public static IApplicationBuilder Use(this IApplicationBuilder app, Func middleware);

Давайте создадим простенький компонент, который будет добавлять guid в заголовок запроса.

app.Use(async(context, next) =>
{
    context.Request.Headers.Add("Request-ID", Guid.NewGuid().ToString());
    await next.Invoke();
});

Теперь мы можем получить этот guid уже в контроллере и если что-то пойдёт не так, то добавить его в лог.

Метод Run во многом похож на Use, но он добавляет терминальный компонент, который завершает обработку запроса. Он не вызывает никакие другие компоненты и не передаёт обработку запроса дальше. Поэтому данный метод следует вызывать в самом конце построения конвейера обработки запроса.

Добавление веток конвейера

Но что, если мы хотим чтобы запрос обрабатывался не по одному сценарию, а мог как-то иначе? Такая возможность у нас тоже есть.

Метод UseWhen на основании некоторого условия позволяет создать ответвление конвейера при обработке запроса.

public static IApplicationBuilder UseWhen (this IApplicationBuilder app, Func predicate, Action configuration);

Рассмотрим на примере. Давайте добавим ветку, для запроса по адресу /uid, которая сначала выведет нам сгенерированный Guid в консоль, а затем вернёт её.

app.UseWhen(
    context => context.Request.Path == "/uid",
    appBuilder =>
    {
        var guid = Guid.NewGuid().ToString();
        appBuilder.Use(async (context, next) =>
        {
            Console.WriteLine($"Uid: {guid}");
            await next();
        });

        appBuilder.Run(async context =>
        {
            await context.Response.WriteAsync($"Uid: {guid}");
        });
    });

Обратите внимание, что здесь мы используем метод Run в конце. Это позволяет нам прекратить обработку запроса в конвейере. Кроме того, поскольку мы сформировали ответ, то мы получим его в клиенте (что легко увидеть, например, в Postman), несмотря на то, что мы не изменяли наш контроллер.

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

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

public static IApplicationBuilder Map (this IApplicationBuilder app, string pathMatch, Action configuration);

Давайте перепишем правильным образом, тогда код в конвейере будет выглядеть следующим образом:

app.Map("/uid", appBuilder =>
{
    var guid = Guid.NewGuid().ToString();
    appBuilder.Use(async (context, next) =>
    {
        Console.WriteLine($"Uid: {guid}");
        await next();
    });

    appBuilder.Run(async context =>
    {
        await context.Response.WriteAsync($"Uid: {guid}");
    });
});

Тогда мы получим ровно тоже самое поведение, однако, именно этот подход будет более правильным – так мы сразу увидим, что эта ветка связана именно с путём, а не с каким-то иным условием. Если нам потребуется изменить поведение, то найти нужный метод будет гораздо проще.

Собственные классы middleware

Но что, если мы хотим задать какое-то сложное поведение или просто не хотим захламлять наш Program.cs (или Startup.cs) так называемыми inline middleware, которые мы использовали ранее? Тогда мы можем создать отдельный класс и использовать его. Давайте рассмотрим на примере добавления Guid в заголовок к нашему запросу создадим и используем такой компонент.

public class IdMiddleware
{
    private readonly RequestDelegate _next;

    public IdMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        context.Request.Headers.Add("Request-ID", Guid.NewGuid().ToString());

        await _next.Invoke(context);
    }
}

Здесь мы можем увидеть, что в конструкторе мы получаем делегат указывающий на следующий компонент, который мы вызываем после нужного нам действия. Если мы хотим создать терминальный компонент, но его можно не указывать – тогда цепочка прервётся и всё. Кроме того, если мы хотим в зависимости от данных или же ещё по каким-то причинам прервать обработку, то можно также не вызвать следующий компонент и, например, выбросить исключение.

Затем добавим наш компонент в конвейер:

app.UseMiddleware<IdMiddleware>();

Теперь наш код выглядит чище, а мы достигли желаемой цели. Однако, нет предела совершенству. Что, если мы хотим добиться единообразия и добавлять так же как и стандартные компоненты? И это мы тоже можем – создадим метод расширения и дело в шляпе:

public static class MiddlewareExtensions
{
    public static IApplicationBuilder UseId(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<IdMiddleware>();
    }
}

Тогда добавление будет выглядеть следующим образом:

app.UseId();

Красота! Так же стоит отметить, что компонент middleware также может принимать аргументы, так что мы можем реализовать довольно сложную логику. Впрочем, не стоит пихать сюда всё подряд и бизнес-логику надо обрабатывать в контроллере.

Обработка после

Как уже говорилось ранее, компоненты конвейера могут работать с запросом не только на входе, но и после его обработки. Давайте добавим в наш безусловно очень полезный компонент ещё одно действие – теперь мы сообщим в консоль что запрос выполнен.

public async Task InvokeAsync(HttpContext context)
{
    context.Request.Headers.Add("Request-ID", Guid.NewGuid().ToString());

    await _next.Invoke(context);

    Console.WriteLine($"Request ID: {context.Request.Headers["Request-ID"]} is completed");
}

Выполнив запрос мы увидим что, во-первых, у нас есть сообщение о том, что он выполнен в консоли, во-вторых, если поставим точки останова, сначала у нас выполнится первая часть (до _next.Invoke), затем следующие компоненты, после обратимся к контроллеру и пройдём обратно по конвейеру. Это позволяет нам в каждом компоненте как-то повлиять на ответ или выполнить какие-либо необходимые нам действия.

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *