using Dpz.Core.Service;
using HandlebarsDotNet;
namespace Dpz.Core.Web.Library.Middleware;
/// <summary>
/// 面向 HTML 文档请求的友好错误页中间件。
/// </summary>
/// <remarks>
/// 该中间件负责两件事:
/// <list type="bullet">
/// <item>在非开发环境下,将渲染页面时抛出的未处理异常统一转为 HTTP 500;</item>
/// <item>对所有 4xx/5xx 状态码且响应尚未开始的 HTML 请求,渲染一个面向最终用户的错误页面。</item>
/// </list>
/// 以下请求不会被处理:<c>X-Requested-With: XMLHttpRequest</c>、<c>X-PJAX: true</c>、静态文件扩展名
/// 请求,以及 Accept 头不包含 <c>text/html</c> 的请求。
/// </remarks>
public sealed class HtmlErrorPageMiddleware(
RequestDelegate next,
IWebHostEnvironment environment,
ILogger<HtmlErrorPageMiddleware> logger
)
{
/// <summary>
/// HttpContext.Items 的键:错误页的自定义描述文案。
/// 由授权过滤器(CheckAuthorizeAttribute)写入,渲染时优先于目录中的默认描述。
/// </summary>
public const string ErrorMessageItemKey = "HtmlErrorPage.Message";
/// <summary>
/// HttpContext.Items 的键:标记异常是否已经记录过日志。
/// 由异常过滤器(ExceptionHandleAttribute)写入,避免这里重复记录同一次异常。
/// </summary>
public const string ExceptionLoggedItemKey = "HtmlErrorPage.ExceptionLogged";
/// <summary>
/// 视为静态文件的扩展名集合。此类请求不会被当作 HTML 页面处理。
/// </summary>
private static readonly HashSet<string> StaticFileExtensions = new(
StringComparer.OrdinalIgnoreCase
)
{
".avif",
".css",
".eot",
".gif",
".ico",
".jpeg",
".jpg",
".js",
".json",
".map",
".mp3",
".mp4",
".ogg",
".otf",
".pdf",
".png",
".svg",
".ttf",
".wav",
".webm",
".webp",
".woff",
".woff2",
".xml",
};
/// <summary>
/// 编译后的错误页模板。模板文件是部署时固定的静态资源,因此首次使用时才读取并编译一次,之后
/// 所有请求复用同一个编译结果(编译后的模板可安全地并发渲染)。
/// </summary>
private static HandlebarsTemplate<object, object>? _compiledErrorPageTemplate;
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (Exception exception) when (IsHtmlDocumentRequest(context.Request))
{
// 响应已经开始发送,无法改写状态码,只能继续向上抛出
if (context.Response.HasStarted)
{
throw;
}
// 异常过滤器已记录过日志时,这里不再重复记录
if (!context.Items.ContainsKey(ExceptionLoggedItemKey))
{
logger.LogError(
exception,
"Unhandled exception while rendering frontend request {RequestPath}",
context.Request.Path
);
}
// 清空已写入的响应内容,统一返回 500,交由下方逻辑渲染错误页
context.Response.Clear();
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
}
if (!ShouldRenderErrorPage(context))
{
return;
}
await RenderErrorPageAsync(context);
}
/// <summary>
/// 判断当前请求是否需要渲染友好错误页。
/// </summary>
/// <remarks>
/// 需要同时满足以下条件:状态码为 4xx/5xx、响应尚未开始、未写入内容且未设置 Content-Type、
/// 并且请求是 HTML 文档请求。
/// </remarks>
private static bool ShouldRenderErrorPage(HttpContext context)
{
var response = context.Response;
return response.StatusCode is >= 400 and <= 599
&& response is { HasStarted: false, ContentLength: null }
&& string.IsNullOrEmpty(response.ContentType)
&& IsHtmlDocumentRequest(context.Request);
}
/// <summary>
/// 判断请求是否期望返回 HTML 文档(而不是 AJAX、PJAX 或静态文件请求)。
/// </summary>
private static bool IsHtmlDocumentRequest(HttpRequest request)
{
if (
string.Equals(
request.Headers["X-Requested-With"],
"XMLHttpRequest",
StringComparison.OrdinalIgnoreCase
)
|| string.Equals(request.Headers["X-PJAX"], "true", StringComparison.OrdinalIgnoreCase)
)
{
return false;
}
var extension = Path.GetExtension(request.Path.Value);
if (!string.IsNullOrEmpty(extension) && StaticFileExtensions.Contains(extension))
{
return false;
}
return request
.GetTypedHeaders()
.Accept.Any(mediaType =>
string.Equals(
mediaType.MediaType.Value,
"text/html",
StringComparison.OrdinalIgnoreCase
)
);
}
/// <summary>
/// 获取编译后的错误页模板(延迟加载并缓存:只读一次 wwwroot/error-page.html、只编译一次)。
/// 并发首访时可能重复读取/编译,结果相同且只写入一次,可接受。
/// </summary>
private static HandlebarsTemplate<object, object> GetErrorPageTemplate(
IWebHostEnvironment environment
)
{
if (_compiledErrorPageTemplate is not null)
{
return _compiledErrorPageTemplate;
}
var templatePath = Path.Combine(environment.WebRootPath, "error-page.html");
var templateContent = File.ReadAllText(templatePath);
var handlebars = Handlebars.Create();
handlebars.Configuration.TextEncoder = new IgnoreTextEncoder();
var compiled = handlebars.Compile(templateContent);
_compiledErrorPageTemplate = compiled;
return compiled;
}
/// <summary>
/// 渲染最终的错误页面 HTML(带站点头部信息与返回首页入口)。
/// </summary>
private async Task RenderErrorPageAsync(HttpContext context)
{
var page = HtmlErrorPageCatalog.Get(context.Response.StatusCode);
var message = context.Items[ErrorMessageItemKey] as string;
var description = string.IsNullOrWhiteSpace(message) ? page.Description : message;
var html = GetErrorPageTemplate(environment)(
new
{
page.StatusCode,
page.Title,
page.Kind,
page.ImagePath,
Description = description,
context.TraceIdentifier,
}
);
// 错误页不缓存,避免用户看到过期的错误内容
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.Headers.CacheControl = "no-store, no-cache, must-revalidate";
context.Response.Headers.Pragma = "no-cache";
await context.Response.WriteAsync(html, context.RequestAborted);
}
}
/// <summary>
/// 错误页目录:按状态码提供标题、描述文案与配图。
/// </summary>
internal static class HtmlErrorPageCatalog
{
private static readonly IReadOnlyDictionary<int, HtmlErrorPageDefinition> Pages =
new Dictionary<int, HtmlErrorPageDefinition>
{
[400] = Lost(400, "请求无法处理", "请求内容似乎不太完整,请检查后再试一次。"),
[401] = Access(401, "需要登录", "登录后才能继续访问这里。"),
[403] = Access(403, "这里暂不开放", "当前账号没有访问这个页面的权限。"),
[404] = Lost(404, "页面走丢了", "没有在这里找到你要看的内容,它可能已经搬家了。"),
[405] = Lost(405, "操作方式不对", "这个地址不支持当前的请求方式。"),
[408] = Lost(408, "请求等待太久", "连接等待超时,请稍后重新尝试。"),
[429] = Access(429, "操作有点频繁", "请稍作休息,再继续访问。"),
[500] = Repair(500, "服务出了点状况", "问题已经被记录,请稍后再来看看。"),
[502] = Repair(502, "上游服务没有回应", "暂时无法取得需要的数据,请稍后再试。"),
[503] = Repair(503, "服务正在休息", "当前服务暂时不可用,请稍后再来。"),
[504] = Repair(504, "上游响应超时", "等待服务响应的时间有点久,请稍后再试。"),
};
public static HtmlErrorPageDefinition Get(int statusCode)
{
if (Pages.TryGetValue(statusCode, out var page))
{
return page;
}
return statusCode < 500
? Lost(statusCode, "请求未能完成", "当前请求暂时无法处理,请检查后再试。")
: Repair(statusCode, "服务暂时不可用", "服务遇到了一点问题,请稍后再试。");
}
/// <summary>访问类错误(与登录、权限、频率限制相关)。</summary>
private static HtmlErrorPageDefinition Access(
int statusCode,
string title,
string description
) => new(statusCode, title, description, "access", "/images/errors/error-access.svg");
/// <summary>丢失类错误(页面或资源不存在、请求方式不对等)。</summary>
private static HtmlErrorPageDefinition Lost(int statusCode, string title, string description) =>
new(statusCode, title, description, "lost", "/images/errors/error-lost.svg");
/// <summary>维修类错误(服务端异常或上游不可用)。</summary>
private static HtmlErrorPageDefinition Repair(
int statusCode,
string title,
string description
) => new(statusCode, title, description, "repair", "/images/errors/error-repair.svg");
}
/// <summary>
/// 单个错误页的定义:状态码、标题、描述、样式类别(决定 CSS 主题与配图)以及配图路径。
/// </summary>
internal sealed record HtmlErrorPageDefinition(
int StatusCode,
string Title,
string Description,
string Kind,
string ImagePath
);
/// <summary>
/// 友好错误页中间件的注册扩展方法。
/// </summary>
public static class HtmlErrorPageApplicationBuilderExtensions
{
public static IApplicationBuilder UseHtmlErrorPages(this IApplicationBuilder app)
{
return app.UseMiddleware<HtmlErrorPageMiddleware>();
}
}
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
代码解释
这是一个 ASP.NET Core 中间件,用于处理 HTML 页面请求的友好错误页面展示。以下是详细解释:
核心功能
1. HtmlErrorPageMiddleware(主中间件类)
这个中间件负责两个主要任务:
- 异常捕获:在非开发环境下,捕获渲染页面时的未处理异常,统一转为 HTTP 500
- 错误页渲染:为所有 4xx/5xx 状态码的 HTML 请求渲染用户友好的错误页面
关键设计点:
// 静态文件扩展名白名单
private static readonly HashSet<string> StaticFileExtensions
// 包含 .js, .css, .png 等,这些请求不会被处理
// HttpContext.Items 的两个键
ErrorMessageItemKey // 自定义错误描述(优先级高)
ExceptionLoggedItemKey // 标记异常是否已记录(避免重复日志)
2. 核心流程(InvokeAsync 方法)
try {
await next(context); // 执行后续中间件
}
catch (Exception) {
// 如果响应已开始,无法修改,继续抛出
// 否则记录日志,设置 500 状态码
}
// 判断是否需要渲染错误页
if (ShouldRenderErrorPage(context)) {
await RenderErrorPageAsync(context);
}
3. 请求过滤逻辑(IsHtmlDocumentRequest)
以下请求不会被处理:
- AJAX 请求(
X-Requested-With: XMLHttpRequest) - PJAX 请求(
X-PJAX: true) - 静态文件请求(扩展名在白名单中)
- Accept 头不包含
text/html的请求
4. HtmlErrorPageCatalog(错误页目录)
预定义了常见 HTTP 状态码的友好提示:
| 状态码 | 类型 | 标题示例 | 说明 |
|---|---|---|---|
| 401/403/429 | Access | "需要登录" | 访问控制类错误 |
| 404/405/408 | Lost | "页面走丢了" | 资源丢失类错误 |
| 500/502/503 | Repair | "服务出了点状况" | 服务端错误 |
每个错误页包含:
- 状态码
- 标题(用户友好的描述)
- 详细说明
- 样式类别(决定 CSS 主题)
- 配图路径(SVG 图片)
5. 模板渲染机制
private static HandlebarsTemplate<object, object>? _compiledErrorPageTemplate;
使用 Handlebars 模板引擎:
- 首次使用时从
wwwroot/error-page.html读取模板 - 编译后缓存,所有后续请求复用(线程安全)
- 使用
IgnoreTextEncoder避免 HTML 转义
渲染时注入的数据:
{
StatusCode, // 状态码
Title, // 标题
Kind, // 样式类别
ImagePath, // 配图路径
Description, // 描述(优先使用自定义消息)
TraceIdentifier // 请求追踪 ID
}
6. 响应头设置
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.Headers.CacheControl = "no-store, no-cache, must-revalidate";
context.Response.Headers.Pragma = "no-cache";
确保错误页不被缓存,避免用户看到过期内容。
使用方式
app.UseHtmlErrorPages(); // 在 Startup 中注册
设计亮点
- 延迟加载:模板只在首次使用时编译
- 避免重复日志:通过
HttpContext.Items标记 - 灵活的自定义:支持通过
ErrorMessageItemKey注入自定义描述 - 性能优化:编译后的模板可安全并发使用
- 用户友好:提供符合中文语境的错误提示
这是一个生产级的错误处理中间件,既保证了用户体验,又兼顾了日志记录和性能。
AI 正在分析代码…
评论加载中...