2016 年 6 月

第 31 卷,第 6 期

ASP.NET - 使用自定义中间件检测和修复 ASP.NET Core 应用中的 404 错误

作者 Steve Smith

如果在学校或游乐园丢了东西,你可能已经通过联系这些地方的失物招领处,幸运地找回了自己的东西。用户在 Web 应用程序中频繁发出服务器无法处理的路径请求时,就会导致 404 未找到响应代码(有时会显示有趣的网页来向用户解释其中的问题)。通常,应由用户自己通过反复猜测或使用搜索引擎找到其在查找的内容。但是,如果你使用少量的中间件,就可以向 ASP.NET Core 应用添加“失物招领处”,从而帮助用户找到正在查找的资源。

什么是中间件?

ASP.NET Core 文档将中间件定义为“装配在应用程序管道中用以处理请求和响应的组件。” 简而言之,中间件是一种可以用 lambda 表达式表示的请求委托,如下例:

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.Run 中利用 next.Invoke 调入 app.Use 调用。写入自己的中间件时,你可以选择是否允许中间件在管道的下一中间件之前、之后或前后执行操作。你也可以通过选择不调用下一个将管道中断。这里我将演示如何利用此原理创建自己的 404 错误修复中间件。

如果你使用的是默认的 MVC Core 模板,就不会在初始“启动”文件中找到这种基于委托的低级中间件代码。建议将中间件封装到单独类,然后提供可从“启动”中调用的扩展方法(称为“UseMiddlewareName”)。内置的 ASP.NET 中间件遵循此规则,调用如下:

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

中间件的顺序很重要。在最初的编码中,调用 UseDeveloperExceptionPage(仅当应用程序在开发环境中运行时才会出现此配置)应在添加前注入任何其他可能产生错误的中间件中。

设置单独类

我不希望将“启动”类与所有的 lambda 表达式和中间件的详细实现混为一体。就像内置中间件一样,我希望利用一个代码行将中间件添加到管道中。同样,我预计中间件需要利用依赖关系注入 (DI) 来注入的服务,此过程在中间件重构为单独类后即可轻松实现。(有关 ASP.NET Core 中的 DI 的详细信息,请转至 msdn.com/magazine/mt703433 参阅我 5 月份的文章。)

由于我使用的是 Visual Studio,因而可以通过使用“添加新项目”并选择“中间件类”模板,进而添加中间件。图 1 显示了此模板生成的默认内容,其中包括通过 UseMiddleware 向管道添加中间件的扩展方法。

图 1 中间件类模板

public class MyMiddleware
{
  private readonly RequestDelegate _next;
  public MyMiddleware(RequestDelegate next)
  {
    _next = next;
  }
  public Task Invoke(HttpContext httpContext)
  {
    return _next(httpContext);
  }
}
// Extension method used to add the middleware to the HTTP request pipeline.
public static class MyMiddlewareExtensions
{
  public static IApplicationBuilder UseMyMiddleware(this IApplicationBuilder builder)
  {
    return builder.UseMiddleware<MyMiddleware>();
  }
}

通常,我会向调用方法签名添加异步,然后将其主体改为:

await _next(httpContext);

这将使调用异步进行。

创建单独的中间件类后,我将委托逻辑转入调用方法。然后将“配置”中的调用替换为调用 UseMyMiddleware 扩展方法。此时运行应用程序需要确认中间件仍像之前一样运行,且“配置”类在含有一系列 UseSomeMiddleware 语句时更易跟踪。

检测并记录 404 未找到响应

如果在 ASP.NET 应用程序中发出与任何处理程序都不匹配的请求,其响应将含有设置为 404 的状态代码。我可以创建少量的中间件来检查该响应代码(在调用 _next 之后),并采取措施记录请求详情:

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

我希望能够记录特定路径出现的 404 错误总数,进而可以通过纠正措施修复其中最常见的错误。为此,我创建了一个名为 RequestTracker 的服务,使其根据路径来记录 404 请求的实例。如图 2 所示,RequestTracker 通过 DI 传输到中间件。

图 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>();
  }
}

为了向管道中添加 NotFoundMiddleware,我调用了 UseNotFoundMiddleware 扩展方法。但是,由于现在扩展方法依赖于由服务容器配置的自定义服务,因此我还需要确保已注册该服务。我在 IServiceCollection 上创建了一个名为 IServiceCollection 的扩展方法并在“启动”的 ConfigureServices 中调用此方法:

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

此时,AddNotFoundMiddleware 方法可确保 RequestTracker 的实例在服务容器中配置为“单例模式”,因而创建实例后可以将其注入到 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();
  }
  // Other methods
}

显示未找到的请求

既然现在找到了记录 404 错误的方法,然后就需要一种查看错误数据的方法。为此,我需要创建另一个小的中间件组件,用以呈现已记录的所有 NotFoundRequests 按发生次数排序的页面。这个中间件将检查当前请求与特定路径匹配与否,并将忽略(允许通过)与路径不匹配的任何请求。而对于匹配的路径,中间件将返回一个附带表格的页面,表格中包含按频率排序的 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 错误的数量排序。增强中间件功能使其具备筛选功能也就不再是问题。还有其他有趣的功能可以记录更为详细的信息,这样就可以查找最受欢迎的重定向或者最近 7 天内最常见的 404 错误,不过这些就当做留给读者或开源社区的小练习吧。

图 5 显示了呈现页面的一个示例。

修复 404 错误页面
图 5 修复 404 错误页面

添加选项

我希望能够为不同应用的修复 404 错误页面指定不同的路径。实现此目的最好的方法就是创建“选项”类,利用 DI 将其传输到中间件。我为此中间件创建了一个类,即 NotFoundMiddlewareOptions,其中含有默认值为 /fix404s 的属性,我将其命名为 Path。我可以通过 IOptions<T> 接口将此属性传入NotFoundPageMiddleware,然后将本地字段设置为此类型的值属性。然后可将这神奇的 /fix404s 字符串引用更新为:

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

修复 404 错误

当请求与具有 CorrectedUrl 的 NotFoundRequest 匹配时,NotFoundMiddleware 应修改此请求,使其使用 CorrectedUrl。只需要更新该请求的路径属性即可达到此目的:

string path = httpContext.Request.Path;
string correctedPath = _requestTracker.GetRequest(path)?.CorrectedPath;
if(correctedPath != null)
{
  httpContext.Request.Path = correctedPath; // Rewrite the path
}
await _next(httpContext);

完成此实现后,任何经过更正的 URL 都会像其请求直接发送到经过更正的路径一样运行。现在可以使用重新编写的路径继续请求管道。这不一定是最理想的状态,一方面,搜索引擎列表可能会在多个 URL 编入重复内容。此方法可能会导致数十个 URL 都映射到同一基础应用程序路径。因此,通常倾向于利用永久性的重定向(状态代码 301)修复 404 错误。

如果我为了发送重定向而修改了中间件,这样可能会导致中间件短路,因为如果已经决定返回 301,就没有必要运行管道的剩余部分:

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

请注意不要将更正的路径设置为无限重定向循环。

理想状态下,NotFoundMiddleware 应该支持路径重新编写和永久性重定向。我可以通过 NotFoundMiddlewareOptions 实现此目的,可以为所有的请求设置一个或另一个,或者我可以在 NotFoundRequest 路径上修改 CorrectedPath,这样其中既包含路径,也包含要使用的机制。目前,我仅更新选项类来支持此操作,然后按照向 NotFoundPageMiddleware 传输的方式将 IOptions<NotFoundMiddleOptions> 传入 NotFoundMiddleware 中。选项就绪后,重定向/重新编写逻辑变为:

if(correctedPath != null)
{
  if (_options.FixPathBehavior == FixPathBehavior.Redirect)
  {
    httpContext.Response.Redirect(correctedPath, permanent: true);
    return;
  }
  if(_options.FixPathBehavior == FixPathBehavior.Rewrite)
  {
    httpContext.Request.Path = correctedPath; // Rewrite the path
  }
}

此时,NotFoundMiddlewareOptions 类具有两个属性,其中一个是枚举:

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

配置中间件

为中间件设置好“选项”后,在“启动”中对其进行配置时将这些选项的实例传入中间件。或者,你也可以将选项绑定到配置中。ASP.NET 配置很灵活,可以绑定到环境变量或设置文件或以编程方式进行构建。无论在何处设置配置,“选项”都可用一个代码行绑定到配置:

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

完成此操作后,我可以通过更新 appsettings.json(即在此实例中使用的配置)配置 NotFoundMiddleware 的操作:

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

请注意,框架会自动将设置文件中基于字符串的 JSON 值转换为 FixPathBehavior 中的枚举。

持久性

目前,一切都有序进行,美中不足的是 404 错误列表及其更正路径存储在内存中的集合中。这意味着每次重启应用程序时,所有的相关数据都会丢失。对此,我可以定期重置应用的 404 错误计数,这样就可以了解目前最常见的错误类型。不过,我当然也不想失去已经设置好的更正路径。

幸运地是,由于我将 RequestTracker 设置为依赖其持久性的抽象层 (INotFoundRequestRepository),利用 Entity Framework Core (EF).添加在数据库中存储结果的支持也就相当简单了。而且,还可以通过提供单独的帮助程序方法,使单个应用选择是使用 EF 还是内存中配置变得简单(非常适用于测试)。

要通过 EF 存储和检索 NotFoundRequests,首先需要的是 DbContext。我不希望依赖应用已配置的 DbContext,所以我要为 NotFoundMiddleware 创建专用的 DbContext:

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 存储库。此操作在新的扩展方法中执行(我将最初的扩展方法重命名为表明其是针对 InMemory 版本的):

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 中,因为大部分使用 EF的 ASP.NET 应用已经可以在 ConfigureServices 方法中提供连接字符串。如果有必要,在调用 services.AddNotFoundMiddleware­EntityFramework(connectionString) 时可以使用同一变量。

新应用要使用此中间件的 EF 版本,需要进行的最后一步是运行迁移以确保数据库表结构正确配置。进行此步时,我需要指定中间件的 DbContext,因为此时我使用的应用已具有自己的 DbContext。从项目的一开始运行的命令是:

dotnet ef database update --context NotFoundMiddlewareContext

如果出现数据库提供程序相关的错误,确保在“启动”中调用的是 services.AddNotFoundMiddlewareEntityFramework。

后续步骤

我在此展示的示例运行良好,其中包括内存中实现和利用 EF 在数据库存储未找到的请求计数并修复路径的实现。应对 404 错误列表及添加更正路径的功能进行安全设置,确保只有管理员可以进行访问。最后,当前的 EF 实现不包括任何缓存逻辑,因此当应用的请求发出时会随之产生数据库查询。出于性能考虑,我将利用 CachedRepository 模式添加缓存。

此示例的更新源代码可通过 bit.ly/1VUcY0J 获取。


Steve Smith是独立的培训师、导师、顾问以及 ASP.NET MVP。他为 ASP.NET Core 官方文档 (docs.asp.net) 撰写了许多文章,并帮助团队快速掌握了 ASP.NET Core。你可通过 ardalis.com 与他联系。

衷心感谢以下 Microsoft 技术专家对本文的审阅: Chris Ross
Chris Ross 是供职于 Microsoft ASP.NET 团队的开发人员。目前,Chris Ross 正潜心研究中间件。