46.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--扩展功能--集成网关--网关集成日志
本篇文章,我们一起在网关中集成日志功能,我们要在网关中记录下游微服务出现的异常信息、请求信息以及响应信息。在微服务架构中,网关作为系统的入口,承担着非常重要的职责。通过在网关层面集成日志功能,我们可以更好地监控和追踪整个系统的运行状况,特别是在处理下游服务异常时能够及时发现和定位问题。我们将使用结构化日志来记录请求的详细信息,包括请求路径、请求方法、响应状态码等关键数据,同时也会捕获并记录下游服务可能出现的异常信息。这样不仅有助于系统的运维和监控,也能为问题排查提供有力支持。通过这种方式,我们可以在整个微服务架构中建立起完整的日志追踪体系,提高系统的可观察性。
一、记录下游微服务异常日志
这一小节,我们编写记录下游服务异常响应的中间件代码,每当下游服务触发响应异常时,我们都将记录异常信息。在微服务架构中,下游服务的异常处理和日志记录是非常重要的环节,它能帮助我们及时发现和解决系统中的问题。通过在网关层面统一捕获和记录这些异常,我们可以更好地监控系统的运行状况,快速定位和解决问题。当下游服务出现异常时,比如服务不可用、请求超时或者内部错误等情况,我们的中间件会自动捕获这些异常,并记录下详细的异常信息,包括异常发生的时间、请求的URL和方法、异常的具体类型和详细信息,以及相关的请求追踪ID等关键数据。这些信息会以结构化日志的形式被保存下来,方便后续的查询和分析。同时,我们也会确保这些异常信息能够正确地传递给上游调用者,这样不仅保证了系统的可观察性,也为系统的运维和问题诊断提供了重要的支持。在接下来的内容中,我们将通过具体的代码实现来展示如何构建这个异常日志中间件,让整个系统的异常处理变得更加可控和透明。
在网关服务的Middleware
文件夹下,创建记录下游服务错误响应与异常的 Ocelot 委托处理器DownstreamLoggingHandler
,代码如下:
using System.Text.Json;
using SP.Common.Logger;namespace SP.Gateway.Middleware;/// <summary>
/// 记录下游服务错误响应与异常的 Ocelot 委托处理器
/// </summary>
public class DownstreamLoggingHandler : DelegatingHandler
{private static readonly HashSet<string> SensitiveHeaders = new(StringComparer.OrdinalIgnoreCase){"Authorization", "Cookie", "Set-Cookie", "X-Api-Key", "X-Auth-Token"};private readonly ILogger<DownstreamLoggingHandler> _logger;private readonly ILoggerService _loggerService;private readonly IHttpContextAccessor _httpContextAccessor;public DownstreamLoggingHandler(ILogger<DownstreamLoggingHandler> logger,ILoggerService loggerService,IHttpContextAccessor httpContextAccessor){_logger = logger;_loggerService = loggerService;_httpContextAccessor = httpContextAccessor;}protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken){try{var response = await base.SendAsync(request, cancellationToken);if (!response.IsSuccessStatusCode){var httpContext = _httpContextAccessor.HttpContext;var upstream = httpContext?.Request;var log = new{Upstream = upstream == null ? null : new{Method = upstream.Method,Path = upstream.Path.ToString(),QueryString = upstream.QueryString.ToString(),Headers = upstream.Headers.Where(h => !SensitiveHeaders.Contains(h.Key)).ToDictionary(h => h.Key, h => string.Join(",", h.Value))},Downstream = new{Method = request.Method.Method,Url = request.RequestUri?.ToString(),StatusCode = (int)response.StatusCode,Reason = response.ReasonPhrase,ResponseHeaders = response.Headers.Where(h => !SensitiveHeaders.Contains(h.Key)).ToDictionary(h => h.Key, h => string.Join(",", h.Value)),ContentHeaders = response.Content?.Headers?.Where(h => !SensitiveHeaders.Contains(h.Key)).ToDictionary(h => h.Key, h => string.Join(",", h.Value))},User = httpContext == null ? null : new{UserId = httpContext.User?.FindFirst("UserId")?.Value,UserName = httpContext.User?.FindFirst("UserName")?.Value,Email = httpContext.User?.FindFirst("Email")?.Value,IsAuthenticated = httpContext.User?.Identity?.IsAuthenticated},Timestamp = DateTime.UtcNow};var json = JsonSerializer.Serialize(log, new JsonSerializerOptions{PropertyNamingPolicy = JsonNamingPolicy.CamelCase,WriteIndented = false});_loggerService.LogError("下游服务返回错误: {Details}", json);}return response;}catch (Exception ex){_loggerService.LogError(ex, "调用下游服务发生异常: {Method} {Url}", request.Method.Method, request.RequestUri?.ToString());throw;}}
}
在上面这段代码中,我们实现了一个用于记录下游服务错误响应与异常的 Ocelot 委托处理器。这个处理器继承自 DelegatingHandler
,主要用于拦截和处理 HTTP 请求。首先,我们定义了一个敏感头部信息的集合 SensitiveHeaders
,包含了一些需要特别保护的 HTTP 头部字段,如授权令牌、Cookie 等。这些敏感信息在记录日志时会被过滤掉,以保护用户隐私和系统安全。
处理器通过依赖注入接收了三个重要的服务:ILogger 用于常规日志记录,ILoggerService
用于结构化日志记录,以及 IHttpContextAccessor
用于访问当前的 HTTP 上下文。在 SendAsync
方法中,我们重写了请求处理逻辑。该方法首先尝试发送请求到下游服务,然后检查响应状态。如果响应不是成功状态码,就会收集详细的日志信息,包括:上游请求信息(请求方法、路径、查询字符串和过滤后的请求头)、下游响应信息(请求方法、URL、状态码、响应原因、响应头部)、用户信息(用户ID、用户名、邮箱、认证状态)以及时间戳。所有这些信息被组织成一个匿名对象,然后通过 JsonSerializer
序列化成 JSON 格式。序列化时使用了驼峰命名规则,并禁用了格式化缩进以减少日志体积。最后,通过 ILoggerService
将这些信息记录为错误日志。如果在调用下游服务过程中发生异常,catch 块会捕获异常并记录错误信息,包括请求方法和URL,然后重新抛出异常以确保错误能够正确传播。这样的设计既保证了异常信息的完整记录,又不会影响系统的正常错误处理流程。
Tip:
SensitiveHeaders
中定义的头部信息大部分在我们当前项目中使用不到的,但是为什么还要加上去呢?这是因为虽然我们的项目用不到,但是我们引入的第三发包或者使用的第三方应用与我们的项目交互的时候有可能用到,因此在这里就需要提前做好屏蔽。
在编写完DownstreamLoggingHandler
委托处理器后,我们需要在Program.cs
文件中注册这个委托处理器,并且将全局异常处理也添加到网关中。代码如下:
// more code ...
// Ocelot + Nacos 服务发现,并添加下游响应日志处理器
builder.Services.AddOcelot(builder.Configuration).AddNacosDiscovery().AddDelegatingHandler<DownstreamLoggingHandler>(true);// more code ...// 全局异常处理(包含请求缓冲),优先放在最前面
app.UseFullExceptionHandling();// more code ...
全局异常处理前面的文章我们已经讲解过了,在这里就不再多讲了,不清楚的翻看前面的文章即可。到目前位置,我们的网关服务就已经集成了全局异常处理和记录下游服务错误响应与异常的功能。
二、记录请求/响应日志
在微服务架构中,网关作为所有外部请求的入口点,每天要处理大量的请求和响应。通过记录这些请求和响应的详细信息,我们可以实现多个重要目标:首先,当系统出现异常或性能问题时,完整的请求/响应日志可以帮助开发团队快速重现问题场景,定位故障源头;其次,通过分析这些日志,我们可以了解系统的使用模式,识别出频繁访问的接口和潜在的性能瓶颈,从而有针对性地进行系统优化;再次,这些日志记录也可以用于安全审计,帮助我们发现潜在的安全威胁和异常访问模式。此外,在系统升级或重构时,这些历史数据能为架构决策提供重要参考,帮助我们更好地理解系统的实际使用情况和优化方向。因此,在网关中实现全面的请求/响应日志记录不仅是一个技术需求,更是确保系统可维护性、安全性和可扩展性的关键措施。
我们首先在网关的Middleware
文件夹下创建网关入口请求/响应日志中间件RequestResponseLoggingMiddleware
,它只记录文本/JSON/XML 等可读内容,并且限制长度以及过滤敏感头信息。代码如下:
using System.Text;
using System.Text.Json;
using SP.Common.Logger;namespace SP.Gateway.Middleware;/// <summary>
/// 网关入口请求/响应日志中间件(仅记录文本/JSON/XML 等可读内容,限制长度并过滤敏感头)
/// </summary>
public class RequestResponseLoggingMiddleware
{private readonly RequestDelegate _next;private readonly ILoggerService _logger;private const int MaxBodyChars = 4096; // 限制日志体长度,避免过大private static readonly HashSet<string> SensitiveHeaders = new(StringComparer.OrdinalIgnoreCase){"Authorization", "Cookie", "Set-Cookie", "X-Api-Key", "X-Auth-Token"};public RequestResponseLoggingMiddleware(RequestDelegate next, ILoggerService logger){_next = next;_logger = logger;}public async Task Invoke(HttpContext context){// 记录请求var requestInfo = await ReadRequestAsync(context.Request);// 捕获响应var originalBodyStream = context.Response.Body;await using var responseBody = new MemoryStream();context.Response.Body = responseBody;try{await _next(context);}finally{var responseInfo = await ReadResponseAsync(context.Response);// 写回响应流context.Response.Body.Seek(0, SeekOrigin.Begin);await context.Response.Body.CopyToAsync(originalBodyStream);context.Response.Body = originalBodyStream;var log = new{Type = "Upstream",Request = requestInfo,Response = responseInfo,User = new{UserId = context.User?.FindFirst("UserId")?.Value,UserName = context.User?.FindFirst("UserName")?.Value,Email = context.User?.FindFirst("Email")?.Value,IsAuthenticated = context.User?.Identity?.IsAuthenticated},Timestamp = DateTime.UtcNow};var json = JsonSerializer.Serialize(log, new JsonSerializerOptions{PropertyNamingPolicy = JsonNamingPolicy.CamelCase});// 成功响应记录为信息级别,非成功为警告级别if (context.Response.StatusCode >= 200 && context.Response.StatusCode < 400)_logger.LogInformation("上游请求响应: {Details}", json);else_logger.LogWarning("上游请求响应(非成功): {Details}", json);}}private static async Task<object> ReadRequestAsync(HttpRequest request){request.EnableBuffering();string? body = null;if (IsTextBased(request.ContentType)){request.Body.Seek(0, SeekOrigin.Begin);using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);var text = await reader.ReadToEndAsync();request.Body.Seek(0, SeekOrigin.Begin);body = Truncate(text, MaxBodyChars);}var headers = request.Headers.Where(h => !SensitiveHeaders.Contains(h.Key)).ToDictionary(h => h.Key, h => string.Join(",", h.Value));return new{Method = request.Method,Path = request.Path.ToString(),QueryString = request.QueryString.ToString(),Headers = headers,Body = body};}private static async Task<object> ReadResponseAsync(HttpResponse response){string? body = null;if (IsTextBased(response.ContentType)){response.Body.Seek(0, SeekOrigin.Begin);using var reader = new StreamReader(response.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);var text = await reader.ReadToEndAsync();response.Body.Seek(0, SeekOrigin.Begin);body = Truncate(text, MaxBodyChars);}var headers = response.Headers.Where(h => !SensitiveHeaders.Contains(h.Key)).ToDictionary(h => h.Key, h => string.Join(",", h.Value));return new{StatusCode = response.StatusCode,Headers = headers,Body = body};}private static bool IsTextBased(string? contentType){if (string.IsNullOrWhiteSpace(contentType)) return false;contentType = contentType.ToLowerInvariant();return contentType.StartsWith("text/")|| contentType.Contains("json")|| contentType.Contains("xml")|| contentType.Contains("javascript")|| contentType.Contains("html")|| contentType.Contains("plain")|| contentType.Contains("csv");}private static string Truncate(string input, int max){if (string.IsNullOrEmpty(input)) return input ?? string.Empty;return input.Length <= max ? input : input.Substring(0, max);}
}
在上面这段代码中,我们实现了一个用于记录网关入口请求和响应日志的中间件。这个中间件专门设计用来记录可读的文本内容,如JSON、XML等格式的数据,并对日志内容的长度进行了限制,同时也会过滤掉敏感的头部信息。中间件的核心是Invoke
方法,它首先记录请求信息,然后使用内存流来捕获响应内容。这种设计允许我们在不影响原始响应的情况下读取响应内容。在处理过程中,中间件会保存原始的响应流,创建一个新的内存流来暂存响应,这样就能在发送响应之前读取和记录响应内容。
为了确保安全性和性能,中间件实现了多个重要的限制。首先,通过MaxBodyChars
常量限制了日志体的最大长度为4096个字符,防止日志内容过大占用过多存储空间。其次,使用SensitiveHeaders
集合定义了需要过滤的敏感头部信息,确保不会记录下包含敏感信息的请求头。
在日志记录方面,中间件会收集完整的请求和响应信息,包括请求方法、路径、查询字符串、请求头以及请求体,同样也会记录响应状态码、响应头和响应体。这些信息会被组织成结构化的日志对象,并添加用户信息和时间戳。根据响应的状态码,中间件会选择不同的日志级别:成功的请求(状态码200-399)记录为信息级别,而其他状态码则记录为警告级别。
中间件还包含了一些辅助方法,如IsTextBased
方法用于判断内容类型是否为文本格式,Truncate
方法用于截断过长的字符串。这些方法帮助确保日志记录的高效性和实用性。通过ReadRequestAsync
和ReadResponseAsync
方法,中间件能够正确处理请求和响应的内容,确保在记录日志时不会影响到实际的请求处理流程。
在编写完记录请求/响应的中间件后,我们就需要在网关服务中引入这个中间件。在Program.cs
文件中,我们可以使用AddMiddleware
方法将中间件添加到应用程序的请求处理管道中。代码如下:
// more code ...
// 上游请求/响应日志(放在认证之后,便于记录用户信息)
app.UseMiddleware<RequestResponseLoggingMiddleware>();
// more code ...
到此为止,我们完成了网关集成日志的全部功能,包括记录请求和响应的详细信息、下游服务的异常响应,以及根据状态码选择不同的日志级别。
三、总结
本片文章主要介绍了如何在网关服务中集成日志功能,包括记录请求和响应的详细信息、下游服务的异常响应,以及根据状态码选择不同的日志级别。通过引入自定义的中间件,我们能够方便地记录请求和响应的日志,同时也能够及时发现和处理异常情况。这对于排查问题、监控系统运行状态以及优化性能都具有重要意义。