Июнь 2016

Том 31 номер 6

ASP.NET Core - Пользовательский промежуточный уровень для обнаружения и устранения ошибок 404 в приложениях ASP.NET Core

Стив Смит | Июнь 2016

Эта статья основана на ASP.NET Core 1.0 предварительной версии RC1. Некоторая информация может быть изменена при выпуске версии RC2.

Продукты и технологии:
ASP.NET Core 1.0, Entity Framework Core 1.0

В статье рассматриваются:

  • создание отдельного класса промежуточного уровня (middleware class);
  • обнаружение и протоколирование ошибок 404;
  • отображение запросов Not Found;
  • устранение ошибок 404;
  • конфигурирование промежуточного уровня и добавление поддержки для сохранения данных.

Исходный код можно скачать по ссылке bit.ly/1VUcY0J.

В веб-приложениях пользователи часто выдают запросы по путям, не обрабатываемым сервером, что приводит к появлению ответов 404 Not Found (и изредка весьма забавных страниц, на которых пользователю поясняется, в чем проблема). Как правило, это дело пользователя — самостоятельно находить то, что ему нужно, — наугад или с помощью поисковой системы. Однако, создав некий промежуточный уровень, вы можете добавить в приложение ASP.NET Core своего рода «бюро находок», которое будет помогать пользователям находить ресурсы, которые они ищут.

Что такое промежуточный уровень?

В документации на ASP.NET Core промежуточный уровень (middleware) определяется как «компоненты, которые включаются в конвейер приложения для обработки запросов и ответов». В простейшем виде промежуточный уровень является делегатом запроса, который можно представить как лямбда-выражение, например:

app.Run(async context => {
  await context.Response.WriteAsync(“Hello world”);
});

Если ваше приложение состоит только из этого кода промежуточного уровня, на каждый запрос оно будет возвращать только «Hello world». А поскольку он не ссылается на следующую часть промежуточного уровня, в данном конкретном примере он завершает конвейер — все, что определено после него, выполняться не будет. Однако тот факт, что это конец конвейера, еще не означает, что его нельзя «обернуть» в дополнительный промежуточный уровень. Скажем, вы могли бы добавить какой-то промежуточный уровень, который включает заголовок в предыдущий ответ:

app.Use(async (context, next) =>
{
  context.Response.Headers.Add("Author", "Steve Smith");
  await next.Invoke();
});
app.Run(async context =>
{
  await context.Response.WriteAsync("Hello world ");
});

Вызов app.Use обертывает вызов app.Run, инициируя его с помощью next.Invoke. Когда вы пишете собственный промежуточный уровень, вы можете выбирать, надо ли вам выполнять какие-то операции до, после или и до и после следующего компонента промежуточного уровня в конвейере. Кроме того, вы можете замкнуть конвейер, отказавшись от вызова следующего компонента. Я покажу, как это может помочь создать компонент промежуточного уровня, устраняющий ошибку 404.

Если вы используете шаблон MVC Core по умолчанию, вы не найдете столь низкоуровневого промежуточного кода на основе делегатов в своем начальном файле Startup. Рекомендуется инкапсулировать промежуточный уровень в собственных классах и предоставлять методы расширения (с именами вида UseMiddlewareName), которые можно будет вызвать из Startup. Этому соглашению следует промежуточный уровень ASP.NET, как демонстрируют следующие вызовы:

if (env.IsDevelopment())
{
  app.UseDeveloperExceptionPage();
}
app.UseStaticFiles()
app.UseMvc();

Порядок компонентов в промежуточном уровне важен. В предыдущем коде вызов UseDeveloperExceptionPage (который конфигурируется, только когда приложение выполняется в среде разработки) должен быть обернут вокруг (а значит, добавлен до) любого другого компонента, который может сгенерировать какую-либо ошибку.

В отдельном классе

Я не хочу загромождать класс Startup всеми этими лямбда-выражениями и детализированной реализацией собственного промежуточного уровня. Как и в случае встроенного промежуточного уровня, мне нужно добавить свой промежуточный уровень в конвейер одной строкой кода. Кроме того, я предвижу, что моему промежуточному уровню потребуются сервисы, вводимые через механизм встраивания зависимостей (dependency injection, DI), что легко делается после рефакторинга промежуточного уровня в отдельный класс. (Подробнее о DI в ASP.NET Core See см. мою статью в прошлом номере по ссылке msdn.com/magazine/mt703433.)

Поскольку я использую Visual Studio, я могу добавить промежуточный уровень через Add New Item и выбор шаблона Middleware Class. На рис. 1 показан контент по умолчанию, создаваемый этим шаблоном, в том числе метод расширения для добавления промежуточного уровня в конвейер через UseMiddleware.

Рис. 1. Шаблон класса Middleware

public class MyMiddleware
{
  private readonly RequestDelegate _next;

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

  public Task Invoke(HttpContext httpContext)
  {

    return _next(httpContext);
  }
}

// Метод расширения для добавления промежуточного уровня
// в конвейер HTTP-запросов
public static class MyMiddlewareExtensions
{
  public static IApplicationBuilder UseMyMiddleware(
    this IApplicationBuilder builder)
  {
    return builder.UseMiddleware<MyMiddleware>();
  }
}

Как правило, я буду добавлять async в сигнатуру метода Invoke, а затем изменять его тело так:

await _next(httpContext);

Это сделает вызов асинхронным.

Создав отдельный класс промежуточного уровня, я перемещаю логику делегата в метод Invoke. Потом заменяю вызов в Configure на вызов метода расширения UseMyMiddleware. Запуск приложения в этот момент должен удостоверить, что промежуточный уровень ведет себя так же, как и раньше. Теперь, когда класс Configure состоит из серии выражений UseSomeMiddleware, он стал гораздо легче в понимании.

Обнаружение и протоколирование ответов 404 Not Found

В приложении ASP.NET, если сделан запрос, не подходящий ни для каких обработчиков, ответ будет включать StatusCode, установленный в 404. Я могу создать часть промежуточного уровня, которая будет проверять на наличие этого кода в ответе (после вызова _next) и предпринимать какое-то действие для записи подробностей запроса:

await _next(httpContext);
if (httpContext.Response.StatusCode == 404)
{
  _requestTracker.Record(httpContext.Request.Path);
}

Мне нужно отслеживать, сколько ошибок 404 наблюдалось по конкретному пути, чтобы я мог устранить наиболее распространенные случаи. Для этого я создаю сервис RequestTracker, который регистрирует экземпляры запросов 404 с учетом из пути. RequestTracker передается моему промежуточному уровню через DI, как показано на рис. 2.

Рис. 2. Механизм встраивания зависимостей передает RequestTracker промежуточному уровню

public class NotFoundMiddleware
{
  private readonly RequestDelegate _next;
  private readonly RequestTracker _requestTracker;
  private readonly ILogger _logger;

  public NotFoundMiddleware(RequestDelegate next,
    ILoggerFactory loggerFactory,
    RequestTracker requestTracker)
  {
    _next = next;
    _requestTracker = requestTracker;
    _logger = loggerFactory.CreateLogger<NotFoundMiddleware>();
  }
}

Рекомендуется инкапсулировать промежуточный уровень в собственных классах и предоставлять методы расширения (с именами вида UseMiddlewareName), которые можно будет вызвать из Startup.

Чтобы добавить NotFoundMiddleware в конвейер, я вызываю метод расширения UseNotFoundMiddleware. Но, поскольку теперь он зависит от пользовательского сервиса, конфигурируемого в контейнере сервисов, мне также нужно убедиться, что этот сервис зарегистрирован. Я создаю метод расширения AddNotFoundMiddleware в IServiceCollection и вызываю его в ConfigureServices в Startup:

public static IServiceCollection AddNotFoundMiddleware(
  this IServiceCollection services)
{
  services.AddSingleton<INotFoundRequestRepository,
    InMemoryNotFoundRequestRepository>();
  return services.AddSingleton<RequestTracker>();
}

В данном случае мой метод AddNotFoundMiddleware обеспечивает конфигурирование экземпляра RequestTracker как Singleton в контейнере сервисов, поэтому тот будет доступен для встраивания в NotFoundMiddleware при его создании. Он также подключает реализацию INotFoundRequestRepository в памяти, используемую RequestTracker для сохранения своих данных.

Поскольку может поступить множество одновременных запросов для одного и того же отсутствующего пути, код на рис. 3 использует простую блокировку, чтобы не добавлялись экземпляры-дубликаты NotFoundRequest и счетчики увеличивались корректно.

Рис. 3. RequestTracker

public class RequestTracker
{
  private readonly INotFoundRequestRepository _repo;
  private static object _lock = new object();

  public RequestTracker(INotFoundRequestRepository repo)
  {
    _repo = repo;
  }

  public void Record(string path)
  {
    lock(_lock)
    {
      var request = _repo.GetByPath(path);
      if (request != null)
      {
        request.IncrementCount();
      }
      else
      {
        request = new NotFoundRequest(path);
        request.IncrementCount();
        _repo.Add(request);
      }
    }
  }

  public IEnumerable<NotFoundRequest> ListRequests()
  {
    return _repo.List();
  }
  // Прочие методы
}

Отображение запросов Not Found

Теперь, когда у меня есть способ протоколирования ошибок 404, мне нужно как-то просматривать эти данные. Для этого я создам другой небольшой компонент промежуточного уровня, который будет отображать страницу со всеми зафиксированными NotFoundRequest, упорядоченными по частоте их возникновения. Этот компонент промежуточного уровня будет проверять, соответствует ли текущий запрос конкретному пути, и игнорировать (и пропускать) любые запросы, не соответствующие этому пути. Для подходящих путей он будет возвращать страницу с таблицей, содержащей запросы NotFound, упорядоченные по их частоте. Отсюда пользователь сможет назначать индивидуальным запросам правильный путь, который будет использовать при последующих запросах вместо возврата ошибки 404.

На рис. 4 демонстрируется, насколько просто сделать так, чтобы NotFoundPageMiddleware выполнял проверку для определенного пути, и вносить изменения на основе значений строк запроса, использующих тот же путь. Из соображений безопасности доступ к пути NotFoundPageMiddleware должен быть ограничен пользователями с правами администратора.

Рис. 4. NotFoundPageMiddleware

public async Task Invoke(HttpContext httpContext)
{
  if (!httpContext.Request.Path.StartsWithSegments("/fix404s"))
  {
    await _next(httpContext);
    return;
  }
  if (httpContext.Request.Query.Keys.Contains("path") &&
      httpContext.Request.Query.Keys.Contains("fixedpath"))
  {
    var request = _requestTracker.GetRequest(
      httpContext.Request.Query["path"]);
    request.SetCorrectedPath(
      httpContext.Request.Query["fixedpath"]);
    _requestTracker.UpdateRequest(request);
  }
  Render404List(httpContext);
}

В том виде, как написан этот код, в промежуточный компонент «зашито» прослушивание пути /fix404s. Хорошая идея — сделать это прослушивание настраиваемым, чтобы разные приложения могли указывать любой нужный им путь. Список запросов показывает все запросы, упорядоченные по тому, сколько ошибок 404 было зарегистрировано для них независимо от того, был ли установлен для них скорректированный путь. Этот компонент несложно расширить для поддержки какой-либо фильтрации. Другой интересной функцией могла бы быть запись более подробной информации, чтобы вы могли видеть, какие перенаправления (redirects) были наиболее частыми или какие ошибки 404 были наиболее распространенными за последние семь дней, но это я оставлю в качестве упражнения читателям (или сообществу открытого кода).

На рис. 5 показан пример того, как выглядит визуализированная страница.

Страница Fix 404s
Рис. 5. Страница Fix 404s

Добавление параметров

Мне хотелось бы иметь возможность указывать другой путь для страницы Fix 404s в других приложениях. Лучший способ сделать это — создать класс Options и передать его на промежуточный уровень, используя DI. Для данного промежуточного уровня я создаю класс NotFoundMiddlewareOptions, который включает свойство Path со значением, по умолчанию равным «/fix404s». Я могу передать его в NotFoundPageMiddleware с помощью интерфейса IOptions<T>, а затем присвоить какому-нибудь локальному полю содержимое свойства Value этого типа. После этого моя ссылка на магическую строку /fix404s может быть обновлена:

if (!httpContext.Request.Path.StartsWithSegments(
  _options.Path))

Устранение ошибок 404

Когда поступает запрос, соответствующий NotFoundRequest, который имеет CorrectedUrl, NotFoundMiddleware должен модифицировать этот запрос на использование CorrectedUrl. Для этого можно просто обновить свойство Path запроса:

string path = httpContext.Request.Path;
string correctedPath =
  _requestTracker.GetRequest(path)?.CorrectedPath;
if(correctedPath != null)
{
  httpContext.Request.Path = correctedPath; // перезапись пути
}
await _next(httpContext);

Благодаря этой реализации любой скорректированный URL будет действовать так, словно соответствующий запрос был выдан непосредственно по исправленному пути. Далее конвейер запроса продолжит работу, теперь используя перезаписанный путь. Такое поведение может оказаться нежелательным; к примеру, списки поисковой системы могут пострадать из-за дублирования контента, проиндексированного по нескольким URL. Этот подход мог бы привести к появлению десятков URL, сопоставленных с одним путем нижележащего приложения. По этой причине зачастую предпочтительнее исправлять ошибки 404, используя постоянное перенаправление (permanent redirect) (код состояния 301).

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

if(correctedPath != null)
{
  httpContext.Response.Redirect(httpContext.Request.PathBase +
    correctedPath + httpContext.Request.QueryString,
    permanent: true);
  return;
}
await _next(httpContext);

Будьте осторожны, чтобы не установить скорректированные пути, которые приведут к бесконечным циклам перенаправления.

В идеале, NotFoundMiddleware должен поддерживать как перезапись пути, так и постоянные перенаправления. Это можно реализовать, используя мой NotFoundMiddlewareOptions, который позволяет задавать то или другое для всех запросов, либо можно модифицировать CorrectedPath в пути NotFoundRequest, чтобы он включал и путь, и применяемый механизм. Но пока я просто обновлю класс параметров (options) для поддержки этого поведения и передам IOptions<NotFoundMiddleOptions> в NotFoundMiddleware так, как уже это делаю для NotFoundPageMiddleware. После этого логика перенаправления/перезаписи будет выглядеть так:

if(correctedPath != null)
{
  if (_options.FixPathBehavior == FixPathBehavior.Redirect)
  {
    httpContext.Response.Redirect(correctedPath,
      permanent: true);
    return;
  }
  if(_options.FixPathBehavior == FixPathBehavior.Rewrite)
  {
    // Перезапись пути
    httpContext.Request.Path = correctedPath;
  }
}

К этому моменту класс NotFoundMiddlewareOptions имеет два свойства, одно из которых является перечислением:

public enum FixPathBehavior
{
  Redirect,
  Rewrite
}

public class NotFoundMiddlewareOptions
{
  public string Path { get; set; } = "/fix404s";
  public FixPathBehavior FixPathBehavior { get; set; } =
    FixPathBehavior.Redirect;
}

Конфигурирование промежуточного уровня

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

services.Configure<NotFoundMiddlewareOptions>(
  Configuration.GetSection("NotFoundMiddleware"));

Закончив с этим, я могу настроить поведение NotFoundMiddleware, обновив appsettings.json (конфигурацию, используемую мной в этом примере):

"NotFoundMiddleware": {
  "FixPathBehavior": "Redirect",
  "Path": "/fix404s"
}

Заметьте, что преобразование из строковых JSON-значений в файле настроек в перечислимые для FixPathBehavior выполняется автоматически самой инфраструктурой.

Сохранение

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

Чтобы задействовать EF для сохранения и извлечения объектов NotFoundRequest, прежде всего нужен DbContext.

К счастью, поскольку я сконфигурировал RequestTracker на использование абстракции для его операций сохранения (INotFoundRequestRepository), добавить поддержку для сохранения результатов в базе данных с помощью Entity Framework Core (EF) довольно легко. Более того, нетрудно сделать так, чтобы индивидуальные приложения могли выбирать, требуется им использовать EF или достаточно конфигурации в памяти (этот вариант отлично подходит для тестирования), предоставив отдельные вспомогательные методы.

Чтобы задействовать EF для сохранения и извлечения объектов NotFoundRequest, прежде всего нужен DbContext. Я не хочу полагаться на тот, который мог быть сконфигурирован приложением, поэтому создаю свой контекст только для NotFoundMiddleware:

public class NotFoundMiddlewareDbContext : DbContext
{
  public DbSet<NotFoundRequest> NotFoundRequests { get; set; }
  protected override void OnModelCreating(
    ModelBuilder modelBuilder)
  {
    base.OnModelCreating(modelBuilder);
    modelBuilder.Entity<NotFoundRequest>().HasKey(r => r.Path);
  }
}

Получив dbContext, я должен реализовать интерфейс репозитария. Я создаю EfNotFoundRequestRepository, который запрашивает экземпляр NotFoundMiddlewareDbContext в своем конструкторе, и присваиваю его закрытому полю _dbContext. Реализовать индивидуальные методы нетрудно, например:

public IEnumerable<NotFoundRequest> List()
{
  return _dbContext.NotFoundRequests.AsEnumerable();
}

public void Update(NotFoundRequest notFoundRequest)
{
  _dbContext.Entry(notFoundRequest).State =
    EntityState.Modified;
  _dbContext.SaveChanges();
}

К этому моменту остается лишь связать DbContext и EF-репозитарий в контейнере сервисов приложения. Это делается в новом методе расширения (исходный метод расширения я переименовываю, чтобы указать, что он предназначен для версии в памяти):

public static IServiceCollection
  AddNotFoundMiddlewareEntityFramework(
  this IServiceCollection services, string connectionString)
{
  services.AddEntityFramework()
    .AddSqlServer()
    .AddDbContext<NotFoundMiddlewareDbContext>(options =>
      options.UseSqlServer(connectionString));

  services.AddSingleton<INotFoundRequestRepository,
    EfNotFoundRequestRepository>();
  return services.AddSingleton<RequestTracker>();
}

Я предпочел передавать строку подключения вместо ее хранения в NotFoundMiddlewareOptions, так как большинство приложений ASP.NET, использующих EF, уже предоставляют ему строку подключения в методе ConfigureServices. При желании ту же переменную можно использовать при вызове services.AddNotFoundMiddlewareEntityFramework(connectionString).

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

dotnet ef database update --context NotFoundMiddlewareContext

Если вы получите ошибку, связанную с провайдером базы данных, убедитесь, что вы вызываете services.AddNotFoundMiddlewareEntityFramework в Startup.

Следующие шаги

Пример, приведенный здесь, прекрасно работает и включает как реализацию в памяти, так и реализацию на основе EF для хранения счетчиков запросов Not Found и исправленных путей в базе данных. Список ошибок 404 и поддержка добавления скорректированных путей должны быть защищены, чтобы доступ к ним был только у администраторов. Наконец, текущая EF-реализация не включает никакой логики кеширования, из-за чего при каждом запросе к приложению приходится обращаться к базе данных. По соображениям производительности я бы добавил кеширование, используя шаблон CachedRepository.


Стив Смит (Steve Smith) — независимый тренер, преподаватель и консультант, а также обладатель звания ASP.NET MVP. Написал десятки статей для официальной документации ASP.NET Core (docs.asp.net) и работает с группами, осваивающими эту технологию. С ним можно связаться через сайт ardalis.com, также следите за его заметками в Twitter (@ardalis).

Выражаю благодарность за рецензирование статьи эксперту Microsoft Крису Россу (Chris Ross).