.NET Core 3.1 中 NLog 高级日志配置:实现接口交互追踪与日志分离

xieshuoshuo 发布于 14 小时前 17 次阅读 预计阅读时间: 10 分钟


概述

在现代 .NET Core 应用程序中,一个强大且灵活的日志系统是保障系统稳定性和可维护性的基石。本文将深入探讨如何在 .NET Core 3.1 项目中,通过 NLog 实现企业级日志解决方案,重点解决三个核心问题:

  • ✅ 物理隔离:将接口交互日志与系统运行日志分离存储,提高日志查询效率
  • ✅ 格式精简化:自定义布局模板,实现清晰、结构化的日志输出
  • ✅ 智能记录:基于自定义特性(Attribute)和中间件,精准捕获 API 请求/响应数据

环境准备与 NuGet 包安装

在开始之前,请确保您的开发环境满足以下要求:

必需的 NuGet 包

执行以下命令安装所需的 NuGet 包:

Install-Package NLog.Web.AspNetCore -Version 4.14.0
Install-Package NLog -Version 4.7.15

注意:如果您使用 Autofac 作为依赖注入容器,还需安装:

Install-Package Autofac.Extensions.DependencyInjection -Version 8.0.0

核心配置:NLog.config

在项目根目录创建 NLog.config 文件,并设置文件属性为 "始终复制到输出目录" (在 Visual Studio 中右键文件 → 属性 → 复制到输出目录)。

完整配置文件

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      autoReload="true"
      internalLogLevel="Error"
      internalLogFile="c:\temp\internal-nlog.txt">

    <!-- 扩展 NLog.Web.AspNetCore 功能 -->
    <extensions>
        <add assembly="NLog.Web.AspNetCore"/>
    </extensions>

    <!-- 全局布局变量定义 -->
    <variable name="commonLayout" value="----- $ {newline}【 $ {level:upperCase=true}】 $ {newline}时间: $ {longdate} $ {newline}IP  : $ {aspnet-request-ip:CheckForwardedForHeader=true} $ {newline}位置: $ {logger} ->  $ {aspnet-mvc-action} $ {newline}URL : $ {aspnet-request-url:IncludeQueryString=true} $ {newline}消息: $ {message} $ {onexception: $ {newline}异常: $ {exception:format=tostring}} $ {newline}-----" />

    <variable name="apiExchangeLayout" value="-----$ {newline}【接口交互】 $ {newline}时间: $ {longdate} $ {newline}IP  : $ {aspnet-request-ip:CheckForwardedForHeader=true} $ {newline}位置: $ {logger} ->  $ {aspnet-mvc-action} $ {newline}URL : $ {aspnet-request-url:IncludeQueryString=true} $ {newline}详情: $ {newline} $ {message} $ {onexception: $ {newline}异常: $ {exception:format=tostring}} $ {newline}-----" />

    <!-- 日志目标配置 -->
    <targets>
        <!-- 系统运行日志 -->
        <target xsi:type="File" name="own_file" fileName="log/ $ {shortdate}/system_ $ {shortdate}.log" encoding="utf-8" layout=" $ {commonLayout}" />

        <!-- 错误日志 -->
        <target xsi:type="File" name="error_file" fileName="log/ $ {shortdate}/error_ $ {shortdate}.log" encoding="utf-8" layout=" $ {commonLayout}" />

        <!-- API接口交互日志 -->
        <target xsi:type="File" name="api_exchange_file" fileName="log/ $ {shortdate}/api_exchange_ $ {shortdate}.log" encoding="utf-8" layout=" $ {apiExchangeLayout}" />

        <!-- 黑洞目标,用于过滤不需要的日志 -->
        <target xsi:type="Null" name="blackhole" />
    </targets>

    <!-- 日志路由规则 -->
    <rules>
        <!-- 优先处理API交互日志 -->
        <logger name="ApiExchangeLogger" minlevel="Trace" writeTo="api_exchange_file" final="true" />

        <!-- 过滤Microsoft框架日志 -->
        <logger name="Microsoft.*" maxlevel="Info" writeTo="blackhole" final="true" />

        <!-- 错误日志单独记录 -->
        <logger name="*" minlevel="Error" writeTo="error_file" />

        <!-- 其他日志记录到系统日志 -->
        <logger name="*" minlevel="Trace" writeTo="own_file" />
    </rules>
</nlog>

基础设施集成

Program.cs:NLog 全局初始化

在 .NET Core 3.1 中,推荐在 Program.cs 中进行 NLog 的全局初始化,这样可以确保捕获应用程序启动和关闭时的关键日志。

using NLog;
using NLog.Web;

public class Program
{
    public static void Main(string[] args)
    {
        // 初始化 NLog
        var logger = NLogBuilder.ConfigureNLog("nlog.config").GetCurrentClassLogger();
        try
        {
            logger.Info("应用程序启动中...");
            CreateHostBuilder(args).Build().Run();
        }
        catch (Exception ex)
        {
            // 捕获启动异常
            logger.Error(ex, "应用程序启动失败");
            throw;
        }
        finally
        {
            // 确保日志正确关闭
            NLog.LogManager.Shutdown();
        }
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup();
            })
            .ConfigureLogging(logging =>
            {
                logging.ClearProviders(); // 清除默认日志提供程序
                logging.SetMinimumLevel(LogLevel.Trace); // 设置最低日志级别
            })
            .UseNLog(); // 注册 NLog
}

Startup.cs:请求管道配置

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // 必须注入 HttpContextAccessor 以支持 NLog 的 ASP.NET Core 布局渲染器
        services.AddHttpContextAccessor();

        // 添加控制器
        services.AddControllers();

        // 其他服务注册...
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        // 处理反向代理(如 Nginx、IIS)的真实客户端 IP
        app.UseForwardedHeaders(new ForwardedHeadersOptions
        {
            ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
        });

        app.UseRouting();

        // 自定义日志中间件(必须在 UseRouting 之后)
        app.UseMiddleware();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}

高级功能:API 交互日志追踪

自定义标记特性 [Log]

创建一个自定义特性,用于标记需要记录交互日志的控制器或方法。

using System;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class LogAttribute : Attribute
{
    /// 
    /// 是否记录响应体(默认仅记录非200状态的响应)
    /// 
    public bool LogResponseBody { get; set; } = false;

    /// 
    /// 构造函数
    /// 
    /// 是否强制记录所有响应体
    public LogAttribute(bool logResponseBody = false)
    {
        LogResponseBody = logResponseBody;
    }
}

核心中间件实现

using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Routing;

public class HttpLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger _apiLogger;
    private const string newline = "rn";

    public HttpLoggingMiddleware(RequestDelegate next, ILoggerFactory loggerFactory)
    {
        _next = next;
        // 使用在 NLog.config 中定义的特定日志记录器
        _apiLogger = loggerFactory.CreateLogger("ApiExchangeLogger");
    }

    public async Task Invoke(HttpContext context)
    {
        // 获取端点元数据
        var endpoint = context.GetEndpoint();
        var logAttr = endpoint?.Metadata.GetMetadata();

        // 仅处理标记了 [Log] 特性的请求
        if (logAttr == null)
        {
            await _next(context);
            return;
        }

        // 1. 读取请求数据
        var (queryString, requestBody) = await ReadRequestData(context);

        // 2. 替换响应流以便捕获响应内容
        var originalBody = context.Response.Body;
        await using var responseBody = new MemoryStream();
        context.Response.Body = responseBody;

        try
        {
            await _next(context);

            // 3. 读取响应数据(根据策略决定是否记录)
            string responseText = await ReadResponseData(context, logAttr, responseBody);

            // 4. 记录日志
            LogApiExchange(context, queryString, requestBody, responseText, logAttr);

            // 5. 恢复原始响应流
            await WriteResponseToClient(context, responseBody, originalBody);
        }
        finally
        {
            context.Response.Body = originalBody;
        }
    }

    private async Task ReadRequestData(HttpContext context)
    {
        var queryString = context.Request.QueryString.Value ?? string.Empty;
        string requestBody = "[无请求体]";

        // 跳过文件上传请求的读取
        bool isUpload = context.Request.HasFormContentType && 
                       context.Request.ContentType?.Contains("multipart/form-data", StringComparison.OrdinalIgnoreCase);

        if (!isUpload && context.Request.ContentLength > 0)
        {
            context.Request.EnableBuffering();
            requestBody = await ReadStreamContent(context.Request.Body);
        }

        return (queryString, requestBody);
    }

    private async Task ReadResponseData(HttpContext context, LogAttribute logAttr, Stream responseBody)
    {
        if (context.Response.StatusCode == 200 && !logAttr.LogResponseBody)
        {
            return "[状态200,未记录响应体]";
        }

        return await ReadStreamContent(responseBody);
    }

    private void LogApiExchange(HttpContext context, string queryString, string requestBody, string responseText, LogAttribute logAttr)
    {
        var logMessage = new StringBuilder();
        logMessage.AppendLine("[请求]: Query:{queryString} | Body:{requestBody}");
        logMessage.AppendLine("[响应]: {responseText}");
        logMessage.AppendLine("[状态]: {context.Response.StatusCode}");

        // 根据状态码决定日志级别
        if (context.Response.StatusCode >= 500)
        {
            _apiLogger.LogError(logMessage.ToString());
        }
        else if (context.Response.StatusCode >= 400)
        {
            _apiLogger.LogWarning(logMessage.ToString());
        }
        else
        {
            _apiLogger.LogInformation(logMessage.ToString());
        }
    }

    private async Task WriteResponseToClient(HttpContext context, Stream responseBody, Stream originalBody)
    {
        responseBody.Seek(0, SeekOrigin.Begin);
        await responseBody.CopyToAsync(originalBody);
    }

    private async Task ReadStreamContent(Stream stream)
    {
        if (stream == null || !stream.CanRead)
            return "[不可读取的流]";

        stream.Seek(0, SeekOrigin.Begin);
        using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true);
        var content = await reader.ReadToEndAsync();
        stream.Seek(0, SeekOrigin.Begin);
        return content.Length > 1000 ? content.Substring(0, 1000) + "..." : content;
    }
}

示例

[HttpPost]
[Log] // 只有这个接口会产生交互日志
public IActionResult PushData([FromBody] MyData data)
{
    return Ok("Success");
}

[HttpGet]
public IActionResult Heartbeat() // 这个接口再也不会出现在日志里
{
    return Ok("Alive");
}

[Log(true)] // 即使状态码是 200,也会记录出参
public IActionResult GetImportantInfo() { ... }

[Route("api/[controller]")]
[Log] // 该控制器下的所有 Action 都会记录日志
public class OrderController : ControllerBase { ... }

最佳实践与注意事项

关键配置要点

  1. ✅ 单点集成原则
    严格遵循在Program.cs中通过.UseNLog()进行集成,避免在Startup.cs中重复调用AddNLog(),这可能导致日志重复或丢失。
  2. ✅ 流处理安全
    每次读取请求/响应流后,必须调用stream.Seek(0, SeekOrigin.Begin)重置流位置,否则客户端将收到空响应。中间件中使用try-finally确保流的正确恢复。
  3. ✅ 日志分流机制
    NLog.config的规则配置中,关键属性final="true"用于阻止日志"冒泡"到后续规则,这是实现日志精准分流的核心机制。
  4. ✅ 生产环境优化
  • internalLogLevel从 "Error" 调整为 "Warn" 以减少内部日志
  • 添加文件归档策略:<target xsi:type="File" ... archiveEvery="Day" maxArchiveFiles="30" />
  • 考虑使用异步包装器提升性能:<targets async="true">

性能考量

⚡ 避免大型请求体记录:中间件已自动跳过multipart/form-data请求的读取,防止文件上传导致的内存溢出
⚡ 响应体截断:超过1000字符的响应体会被截断,平衡日志详细度与性能
⚡ 异步日志写入:NLog 默认使用异步写入,但可通过<targets async="true">进一步优化

总结

  • 通过本文介绍的 NLog 高级配置方案,您可以构建一个企业级的日志系统,实现:
  • 精准的问题定位:通过接口交互日志快速定位 API 层问题
  • 高效的日志管理:物理隔离不同类型的日志,提高查询效率
  • 灵活的扩展性:基于特性标记的机制,可以轻松扩展到其他监控场景

💡 扩展建议:考虑将此方案与ELK Stack(Elasticsearch, Logstash, Kibana)或 Application Insights 集成,实现日志的集中化管理和可视化分析。