使用自訂 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
希望此篇文章可以幫助到需要的人
若內容有誤或有其他建議請不吝留言給筆者喔 !