using System.Buffers;
using System.IO.Pipelines;
using Wangkanai.Detection.Services;

namespace Dpz.Core.Web.Library.Middleware;

public class HttpRequestRecord(RequestDelegate next, IDiagnosticContext diagnosticContext)
{
    public async Task InvokeAsync(
        HttpContext httpContext,
        IArticleService articleService,
        IConfiguration configuration,
        IDetectionService detectionService
    )
    {
        // 处理 sitemap 文件重定向(支持 sitemap.xml, sitemap_1.xml, sitemap_2.xml 等)
        var path = httpContext.Request.Path.Value ?? "";
        if (IsSitemapRequest(path) && string.Equals(configuration["AgileConfig:env"], "PROD"))
        {
            var host = configuration.GetSection("upyun")["Host"] ?? "https://localhost:37701";
            var baseUri = new Uri(host);
            var fileName = Path.GetFileName(path);
            var uri = new Uri(baseUri, $"/sitemap/{fileName}");
            httpContext.Response.Redirect(uri.ToString());
            return;
        }

        SetDetection(detectionService);
        await EnrichFromRequestAsync(httpContext.Request);

        await next.Invoke(httpContext);
        var routeValues = httpContext.Request.RouteValues;
        if (
            routeValues["controller"] is "Article"
            && routeValues["action"] is "Read"
            && routeValues["id"] is string id
        )
        {
            await articleService.ViewAsync(id);
        }
    }

    private async Task EnrichFromRequestAsync(HttpRequest request)
    {
        if (request.Query is { Count: > 0 })
        {
            diagnosticContext.Set("QueryString", request.Query);
        }

        var bodyContent = await GetBodyContentAsync(request.BodyReader);
        if (bodyContent != string.Empty)
        {
            diagnosticContext.Set("RequestBody", bodyContent);
        }
    }

    /// <summary>
    /// 读取 http body
    /// 如果 body 超过 1 &lt;&lt; 10 字节,则不记录 body
    /// </summary>
    /// <param name="reader"></param>
    /// <returns></returns>
    private static async Task<string> GetBodyContentAsync(PipeReader reader)
    {
        var result = new StringBuilder();
        const long maxLength = 1 << 10;
        long totalLength = 0;

        while (true)
        {
            var readResult = await reader.ReadAsync();
            var buffer = readResult.Buffer;
            // 如果 buffer 长度已经大于等于 maxLength,直接截取并返回
            if (buffer.Length >= maxLength)
            {
                var span = buffer.Slice(0, maxLength);
                AppendString(result, span);
                result.Append("...");
                reader.AdvanceTo(buffer.Start, buffer.GetPosition(maxLength));
                //await reader.CompleteAsync();
                break;
            }

            totalLength += buffer.Length;

            if (readResult.IsCompleted && buffer.Length > 0)
            {
                AppendString(result, in buffer);
            }

            reader.AdvanceTo(buffer.Start, buffer.End);
            if (totalLength >= maxLength)
            {
                result.Append("...");
                break;
            }

            if (readResult.IsCompleted)
            {
                break;
            }
        }

        return result.ToString();
    }

    private static void AppendString(
        StringBuilder result,
        in ReadOnlySequence<byte> readOnlySequence
    )
    {
        var span = readOnlySequence.IsSingleSegment
            ? readOnlySequence.First.Span
            : readOnlySequence.ToArray().AsSpan();
        result.Append(Encoding.UTF8.GetString(span));
    }

    private void SetDetection(IDetectionService detectionService)
    {
        diagnosticContext.Set("Device", detectionService.Device.Type.ToString());
        diagnosticContext.Set("Browser", detectionService.Browser.Name.ToString());
        diagnosticContext.Set("BrowserVersion", detectionService.Browser.Version.ToString());
        diagnosticContext.Set("Platform", detectionService.Platform.Name.ToString());
        diagnosticContext.Set("Engine", detectionService.Engine.Name.ToString());
        diagnosticContext.Set("Crawler", detectionService.Crawler.Name.ToString());
        diagnosticContext.Set("UserAgent", detectionService.UserAgent.ToString());
    }

    /// <summary>
    /// 判断是否为 sitemap 请求
    /// 匹配: /sitemap.xml, /sitemap_1.xml, /sitemap_2.xml 等
    /// </summary>
    private static bool IsSitemapRequest(string path)
    {
        if (string.IsNullOrEmpty(path))
        {
            return false;
        }

        var fileName = Path.GetFileName(path);
        return fileName.Equals("sitemap.xml", StringComparison.OrdinalIgnoreCase)
            || (
                fileName.StartsWith("sitemap_", StringComparison.OrdinalIgnoreCase)
                && fileName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)
            );
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

下面分段解释这段中间件(HttpRequestRecord)的功能、实现细节和需要注意的地方。

总体功能概览

  • 这是一个 ASP.NET Core 中间件(通过构造函数注入 RequestDelegate 和 IDiagnosticContext)。
  • 主要职责有三点:
    1. 在生产环境下对 sitemap 请求(如 /sitemap.xml、/sitemap_1.xml 等)做外部重定向到配置的 upyun 主机。
    2. 收集并把一些请求信息(QueryString、请求 Body)和客户端探测信息(设备、浏览器、平台等)写入 diagnosticContext,以便后续日志/诊断使用。
    3. 在请求处理完成后,如果路由为 Article/Read 且有 id 字符串,则调用 articleService.ViewAsync(id)(通常用于记录文章被访问/增加阅读量)。

InvokeAsync 的执行流程

  1. 处理 sitemap 重定向:

    • 通过 IsSitemapRequest 判断请求路径是否匹配 sitemap 模式。
    • 仅当 configuration["AgileConfig:env"] 精确等于 "PROD" 时才重定向。
    • 从配置节 "upyun" 获取 Host(若没有则使用默认),拼接 /sitemap/{fileName} 并调用 httpContext.Response.Redirect。
    • 重定向后直接返回,不继续后续中间件。
  2. 设置探测信息(SetDetection):

    • 使用注入的 IDetectionService,把 Device、Browser、BrowserVersion、Platform、Engine、Crawler、UserAgent 等信息写入 diagnosticContext。
  3. 收集请求信息(EnrichFromRequestAsync):

    • 如果请求 Query 有键值,则把 request.Query 作为 "QueryString" 放入 diagnosticContext。
    • 读取请求 Body(通过 HttpRequest.BodyReader)并限制长度(见下文 GetBodyContentAsync),如果非空则写入 diagnosticContext 的 "RequestBody"。
  4. 调用下一个中间件:

    • await next.Invoke(httpContext);
  5. 后处理(在下游处理完成后):

    • 检查 httpContext.Request.RouteValues,若 controller == "Article" && action == "Read" && id 是 string,则调用 articleService.ViewAsync(id)。

GetBodyContentAsync 的实现细节

  • 使用 PipeReader(ReadAsync)逐块读取请求体(ReadOnlySequence)。
  • 最大记录长度为 maxLength = 1 << 10(1024 字节)。如果读取到的缓冲区长度达到或超过该值,会截取前 1024 字节并在末尾追加 "...",然后停止读取。
  • 在每次读取后通过 reader.AdvanceTo(...) 告知管线已消费的区域,避免阻塞。
  • 最终返回按 UTF-8 解码的字符串(通过 AppendString 转换 ReadOnlySequence)。
  • AppendString 会处理单段和多段序列:单段直接取 Span,若多段则 ToArray 然后 AsSpan,再 UTF-8 解码。

IsSitemapRequest 的判定规则

  • 取路径最后的文件名:
    • 等于 "sitemap.xml"(不区分大小写),或
    • 以 "sitemap_" 开头并以 ".xml" 结尾(不区分大小写)。
  • 空或 null 路径返回 false。

SetDetection 的作用

  • 从 IDetectionService 读取各种探测信息并通过 diagnosticContext.Set 写入相应键,方便日志系统或诊断系统捕获这些上下文信息。

注意点与潜在问题(建议)

  • 读取请求体会消费请求流:
    • 直接读取 HttpRequest.BodyReader 的数据会导致后续中间件/控制器无法再读取请求体(除非做了缓冲/重置)。通常需要在读取前调用 HttpRequest.EnableBuffering(),读取后将流位置重置(例如 request.Body.Position = 0 或 Seek(0, Begin)),或者使用能与管线配合的缓冲方法以保证下游可再次读取。
  • Env 比较是大小写敏感的:configuration["AgileConfig:env"] == "PROD"。若配置写成 prod 或 Prod 会不匹配,建议使用不区分大小写的比较。
  • AdvanceTo 的使用需要谨慎,确保不会因未正确推进而导致死循环或内存增长。当前实现基本合理,但要确保 reader.AdvanceTo 的端点正确选择。
  • 使用 ReadOnlySequence.ToArray() 在多段情况下会分配新数组,可能在大请求时导致性能/内存开销,不过你已经限制了最大读取 1KB,影响有限。
  • routeValues 的模式匹配使用 is 字面量做比较,语义上正确,但注意如果路由值不是字符串或键不存在会使条件不成立(这是预期行为)。

简要结论

  • 这是一个用于日志/诊断增强的中间件,提供 sitemap 重定向(生产环境)、客户端探测信息记录、查询字符串与请求体(最多 1KB)记录,并在文章阅读路由结束后触发统计(articleService.ViewAsync)。在使用时应注意请求体读取对下游管线的影响,并考虑环境判断大小写与配置健壮性。
评论加载中...