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 显示了呈现页面的一个示例。
图 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.AddNotFoundMiddlewareEntityFramework(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 正潜心研究中间件。