[.Net Core] 使用自訂 Attribute 控制 API Log Middleware 是否運作

使用自訂 Attribute 控制 API Log Middleware 是否運作

前言

在開發 API 服務時,常會需要針對特定 API 把 Request & Response 資訊保存起來,因此我們希望透過 Middleware 建立通用的 Log 機制,並且依據 Controller 或 Action 上標記的 Attribute 來決定這個 Request & Response 是否需要被保存下來。

 

 

需求分析

  • 依據自定義 Attribute 標籤決定是否要存下 Request & Response 完整資料
     
  • Request 及 Response 需要分兩筆資料紀錄
     
  • Response 應包含 Global Exception Handler 整理後的資訊

 

 

目標規劃

為了滿足我們的需求,預計建立兩個 Middleware 在各自適合的時機抄寫 Log 資訊 (順序非常重要)。

  • Request Log Middleware
    流入 pipeline 時,只有在 Routing 決定 endpoint 目標後,才能取得到標記在 Action / Controller 上的 [ApiLog] 標籤去判斷要不要寫 Log;因此以流入的方向來說,Request Log Middleware 必需要安插在 Routing Middleware 後面才可以。
     
  • Response Log Middleware
    流出 pipeline 時,當錯誤發生只有在 Exception Middleware 捕捉例外錯誤後才可以記錄下錯誤訊息;因此以流出的方向來說,Response Log Middleware 需要安插在 Global Exception Middleware 後面才可以。
     

 

 

實作

首先建立一個 ApiLogAttribute 標籤,以此識別該 API 是否需要記錄下 Request 及 Response 的 Log 資訊,並提供 ApiLogEvent 作為特定事件標記,讓我們可以在紀錄 Log 時去標記這些額外的資訊。

/// <summary>
/// 註記是否要記錄 API Loc 到 DB 中
/// </summary>
public class ApiLogAttribute : Attribute
{
    public ApiLogEvent Event { get; set; }

    public ApiLogAttribute(ApiLogEvent ev = ApiLogEvent.None)
    {
        Event = ev;
    }
}

/// <summary>
/// 註記特殊事件定義
/// </summary>
public enum ApiLogEvent
{
    None,
    Transaction
}

 

使用方式就是在需要紀錄 Log 的 API 上加註 [ApiLog] 標籤即可。

[HttpGet("Echo")]
[ApiLog]
public ActionResult<EchoResp> Echo()
{
    var res = new EchoResp();

    res.Msg = "This's echo from API service!!";

    return res;
}

 

Request Log Middleware

接著定義紀錄 Request Log 的 LogRequestMiddleware 物件,並建立 IApplicationBuilder 擴充方法來將此 Middleware 加入 HTTP request pipeline。

  • 透過 Endpoint 判斷有無 ApiLogAttribute 標籤來決定要不要紀錄此筆 Log。
  • 建立一組不重複的 LogId 用來串起 Request 及 Response 兩筆 Log 的關聯。
/// <summary>
/// 紀錄 Request Log 使用的 Middleware
/// </summary>
public class RequestLogMiddleware
{
    private readonly RequestDelegate _next;
    private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
    private readonly ILogger _logger;

    public RequestLogMiddleware(RequestDelegate next, ILoggerFactory loggerFactory)
    {
        _next = next;
        _recyclableMemoryStreamManager = new RecyclableMemoryStreamManager();
        _logger = loggerFactory.CreateLogger<RequestLogMiddleware>();
    }

    public async Task Invoke(HttpContext context)
    {
        var endpoint = context.Features.Get<IEndpointFeature>()?.Endpoint;
        var attribute = endpoint?.Metadata.GetMetadata<ApiLogAttribute>();

        if (attribute == null)
        {
            // 無須紀錄 Log

            await _next(context);
        }
        else
        {
            // 須要紀錄 Log

            context.Request.EnableBuffering();
            await using var requestStream = _recyclableMemoryStreamManager.GetStream();
            await context.Request.Body.CopyToAsync(requestStream);

            // 產生唯一的 LogId 串起 Request & Response 兩筆 log 資料
            context.Items["ApiLogId"] = GetLogId();

            // 保存 Log 資訊
            _logger.LogInformation(
                $"LogId:{(string)context.Items["ApiLogId"]} " +
                $"Schema:{context.Request.Scheme} " +
                $"Host: {context.Request.Host.ToUriComponent()} " +
                $"Path: {context.Request.Path} " +
                $"QueryString: {context.Request.QueryString} " +
                $"RequestHeader: {GetHeaders(context.Request.Headers)} " +
                $"RequestBody: {ReadStreamInChunks(requestStream)}");

            context.Request.Body.Position = 0;
            await _next(context);
        }
    }

    private static string GetLogId()
    {
        // DateTime(yyyyMMddhhmmssfff) + 1-UpperCase + 2-Digits

        var random = new Random();
        var idBuild = new StringBuilder();
        idBuild.Append(DateTime.Now.ToString("yyyyMMddhhmmssfff"));
        idBuild.Append((char)random.Next('A', 'A' + 26));
        idBuild.Append(random.Next(10, 99));
        return idBuild.ToString();
    }

    private static string ReadStreamInChunks(Stream stream)
    {
        const int readChunkBufferLength = 4096;
        stream.Seek(0, SeekOrigin.Begin);
        using var textWriter = new StringWriter();
        using var reader = new StreamReader(stream);
        var readChunk = new char[readChunkBufferLength];
        int readChunkLength;
        do
        {
            readChunkLength = reader.ReadBlock(readChunk, 0, readChunkBufferLength);
            textWriter.Write(readChunk, 0, readChunkLength);
        } while (readChunkLength > 0);
        return textWriter.ToString();
    }

    private static string GetHeaders(IHeaderDictionary headers)
    {
        var headerStr = new StringBuilder();
        foreach (var header in headers)
        {
            headerStr.Append($"{header.Key}: {header.Value}。");
        }

        return headerStr.ToString();
    }
}

/// <summary>
/// 建立 Extension 將此 RequestLogMiddleware 加入 HTTP pipeline
/// </summary>
public static class RequestLogMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestLogMiddleware(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<RequestLogMiddleware>();
    }
}

 

Response Log Middleware

再來定義紀錄 Response Log 的 LogResponseMiddleware 物件,並建立 IApplicationBuilder 擴充方法來將此 Middleware 加入 HTTP request pipeline。

  • 透過 Endpoint 判斷有無 ApiLogAttribute 標籤來決定要不要紀錄此筆 Log。
  • 取回由 LogRequestMiddleware 建立的 LogId 放入 Response Log 中串起與 Request Log 的關聯。
/// <summary>
/// 紀錄 Response Log 使用的 Middleware
/// </summary>
public class ResponseLogMiddleware
{
    private readonly RequestDelegate _next;
    private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
    private readonly ILogger _logger;

    public ResponseLogMiddleware(RequestDelegate next, ILoggerFactory loggerFactory)
    {
        _next = next;
        _recyclableMemoryStreamManager = new RecyclableMemoryStreamManager();
        _logger = loggerFactory.CreateLogger<ResponseLogMiddleware>();
    }

    public async Task Invoke(HttpContext context)
    {
        var originalBodyStream = context.Response.Body;
        await using var responseBody = _recyclableMemoryStreamManager.GetStream();
        context.Response.Body = responseBody;

        // 流入 pipeline
        await _next(context);
        // 流出 pipeline

        context.Response.Body.Seek(0, SeekOrigin.Begin);
        var responseBodyTxt = await new StreamReader(context.Response.Body).ReadToEndAsync();
        context.Response.Body.Seek(0, SeekOrigin.Begin);
        await responseBody.CopyToAsync(originalBodyStream);

        var endpoint = context.Features.Get<IEndpointFeature>()?.Endpoint;
        var attribute = endpoint?.Metadata.GetMetadata<ApiLogAttribute>();

        if (attribute != null)
        {
            // 須要紀錄 Log

            _logger.LogInformation(
                $"LogId:{(string)context.Items["ApiLogId"]} " +
                $"Schema:{context.Request.Scheme} " +
                $"Host: {context.Request.Host.ToUriComponent()} " +
                $"Path: {context.Request.Path} " +
                $"QueryString: {context.Request.QueryString} " +
                $"ResponseHeader: {GetHeaders(context.Response.Headers)} " +
                $"ResponseBody: {responseBodyTxt}" +
                $"ResponseStatus: {context.Response.StatusCode}");
        }
    }

    private static string GetHeaders(IHeaderDictionary headers)
    {
        var headerStr = new StringBuilder();
        foreach (var header in headers)
        {
            headerStr.Append($"{header.Key}: {header.Value}。");
        }

        return headerStr.ToString();
    }
}

/// <summary>
/// 建立 Extension 將此 ResponseLogMiddleware 加入 HTTP pipeline
/// </summary>
public static class ResponseLogMiddlewareExtensions
{
    public static IApplicationBuilder UseResponseLogMiddleware(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<ResponseLogMiddleware>();
    }
}

 

最後在 startup.cs 中的擺放順序很重要,需要依照先前規劃的順序放入。

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ... 略 ...

    // Log response info (for response pipeline: after ExceptionMiddleware)
    app.UseResponseLogMiddleware();

    // Handle all exception here
    app.UseExceptionMiddleware();

    // Matches request to an endpoint.
    app.UseRouting();

    // Log request info (for request pipeline: after Routing)
    app.UseRequestLogMiddleware();

    // ... 略 ...
}

 

 

實際演練

當 API 有註記 [ApiLog] 標籤時,就會自動輸出 Log 資訊;以下僅使用 console 方式輸出進行測試,可以發現所有資訊都清楚地被記載,並且擁有相同的 LogId 以便關聯 Request 及 Response 兩筆資料。

 

Request Log

 

Response Log

 

 

參考資訊

Log Requests and Responses in ASP.NET Core 3

 


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !