日志还有使用技巧?直接写不就行了?这还需要学?
实际上,据笔者观察,很多 .NET 开发者都有不良习惯,例如:

  • 日志里面大量使用中文编写
  • 日志没统一格式,到处拼接参数、数据
  • 日志没有输出统一格式,导致采集、分析困难
  • 日志喜欢在各类中间件、模块中定义自定义拦截日志,导致性能消耗严重
  • 不知道 .NET 中日志有上下文和作用域,导致多条日志没有串联起来,查找日志困难
  • 不了解日志系统,导致生成、采集的日志价值不大,分析问题低效

根据笔者的带队经验,基于很多 .NET 开发者的习惯,所以写了本文。
本文目录:

  • 在 ASP.NET Core 中使用日志
  • 上下文属性和作用域
  • 格式化日志
  • 非侵入式日志
  • Serilog 日志格式模板

.NET 中的日志使用技巧

Serilog 是 .NET 社区中使用最广泛的日志框架,所以在日志部分,笔者将会围绕 Serilog 讲解其使用方法以及如何解决项目的日志需求。

示例项目在 Demo2.Console 中。

创建一个控制台程序,引入两个包:

Serilog.Sinks.Console
Serilog.Sinks.File

除此之外,还有 Serilog.Sinks.ElasticsearchSerilog.Sinks.RabbitMQ 等。Serilog 提供了用于将日志事件以各种格式写入存储的接收器。下面列出的许多接收器都是由更广泛的 Serilog 社区开发和支持的;https://github.com/serilog/serilog/wiki/Provided-Sinks

可以直接使用代码配置 Serilog:

private static Serilog.ILogger GetLogger()
{
	const string LogTemplate = "{SourceContext} {Scope} {Timestamp:HH:mm} [{Level}] {Message:lj} {Properties:j} {NewLine}{Exception}";
	var logger = new LoggerConfiguration()
		.Enrich.WithMachineName()
		.Enrich.WithThreadId()
		.Enrich.FromLogContext()
#if DEBUG
		.MinimumLevel.Debug()
#else
		                .MinimumLevel.Information()
#endif
		.WriteTo.Console(outputTemplate: LogTemplate)
		.WriteTo.File("log.txt", rollingInterval: RollingInterval.Day, outputTemplate: LogTemplate)
		.CreateLogger();
	return logger;
}

如果想从配置文件中加载,添加 Serilog.Settings.Configuration:

private static Serilog.ILogger GetJsonLogger()
{
	IConfiguration configuration = new ConfigurationBuilder()
							 .SetBasePath(AppContext.BaseDirectory)
							 .AddJsonFile(path: "serilog.json", optional: true, reloadOnChange: true)
							 .Build();
	if (configuration == null)
	{
		throw new ArgumentNullException($"未能找到 serilog.json 日志配置文件");
	}
	var logger = new LoggerConfiguration()
		.ReadFrom.Configuration(configuration)
		.CreateLogger();
	return logger;
}

serilog.json 配置文件示例:

{
  "Serilog": {
    "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
    "MinimumLevel": {
      "Default": "Debug"
    },
    "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ],
    "WriteTo": [
      {
        "Name": "Console",
        "Args": {
          "outputTemplate": "{SourceContext} {Scope} {Timestamp:HH:mm} [{Level}] {Message:lj} {Properties:j} {NewLine}{Exception}"
        }
      },
      {
        "Name": "File",
        "Args": {
          "path": "logs/log-.txt",
          "rollingInterval": "Day",
          "outputTemplate": "{SourceContext} {Scope} {Timestamp:HH:mm} [{Level}] {Message:lj} {Properties:j} {NewLine}{Exception}"
        }
      }
    ]
  }
}

依赖注入 Serilog。

引入 Serilog.Extensions.Logging 包。

	private static Microsoft.Extensions.Logging.ILogger InjectLogger()
	{
		var logger = GetJsonLogger();
		var ioc = new ServiceCollection();
		ioc.AddLogging(builder => builder.AddSerilog(logger: logger, dispose: true));
		var loggerProvider = ioc.BuildServiceProvider().GetRequiredService<ILoggerProvider>();
		return loggerProvider.CreateLogger("Program");
	}

最后,使用不同方式配置 Serilog 日志,然后启动程序打印日志。

	static void Main()
	{
		var log1 = GetLogger();
		log1.Debug("溪源More、痴者工良");
		var log2 = GetJsonLogger();
		log2.Debug("溪源More、痴者工良");
		var log3 = InjectLogger();
		log3.LogDebug("溪源More、痴者工良");
	}
20:50 [Debug] 溪源More、痴者工良 {"MachineName": "WIN-KQDULADM5LA", "ThreadId": 1}
20:50 [Debug] 溪源More、痴者工良 {"MachineName": "WIN-KQDULADM5LA", "ThreadId": 1}
20:50 [Debug] 溪源More、痴者工良 {"MachineName": "WIN-KQDULADM5LA", "ThreadId": 1}

在 ASP.NET Core 中使用日志

示例项目在 Demo2.Api 中。

新建一个 ASP.NET Core API 新项目,引入 Serilog.AspNetCore 包。

在 Program 中添加代码注入 Serilog 。

var builder = WebApplication.CreateBuilder(args);

Log.Logger = new LoggerConfiguration()
	.ReadFrom.Configuration(builder.Configuration)
	.CreateLogger();
builder.Host.UseSerilog(Log.Logger);
//builder.Host.UseSerilog();

将前面示例中的 serilog.json 文件内容复制到 appsettings.json 中。

启动程序后,尝试访问 API 接口,会打印示例如下的日志:

Microsoft.AspNetCore.Hosting.Diagnostics  20:32 [Information] Request finished HTTP/1.1 GET http://localhost:5148/WeatherForecast - - - 200 - application/json;+charset=utf-8 1029.4319ms {"ElapsedMilliseconds": 1029.4319, "StatusCode": 200, "ContentType": "application/json; charset=utf-8", "ContentLength": null, "Protocol": "HTTP/1.1", "Method": "GET", "Scheme": "http", "Host": "localhost:5148", "PathBase": "", "Path": "/WeatherForecast", "QueryString": "", "EventId": {"Id": 2}, "RequestId": "0HMOONQO5ONKU:00000003", "RequestPath": "/WeatherForecast", "ConnectionId": "0HMOONQO5ONKU"}

如果需要为请求上下文添加一些属性信息,可以添加一个中间件,示例如下:

app.UseSerilogRequestLogging(options =>
{
	options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
	{
		diagnosticContext.Set("TraceId", httpContext.TraceIdentifier);
	};
});
 HTTP GET /WeatherForecast responded 200 in 181.9992 ms {"TraceId": "0HMSD1OUG2DHG:00000003" ... ...

对请求上下文添加属性信息,比如当前请求的用户信息,在本次请求作用域中使用日志打印信息时,日志会包含这些上下文信息,这对于分析日志还有帮助,可以很容易分析日志中那些条目是同一个上下文。在微服务场景下,会使用 ElasticSearch 等日志存储引擎查询分析日志,如果在日志中添加了相关的上下文属性,那么在分析日志时可以通过对应的属性查询出来,分析日志时可以帮助排除故障。

如果需要打印 http 的请求和响应日志,我们可以使用 ASP.NET Core 自带的 HttpLoggingMiddleware 中间件。

首先注入请求日志拦截服务。

builder.Services.AddHttpLogging(logging =>
{
    logging.LoggingFields = HttpLoggingFields.All;
	// 避免打印大量的请求和响应内容,只打印 4kb
    logging.RequestBodyLogLimit = 4096;
    logging.ResponseBodyLogLimit = 4096;
});

通过组合 HttpLoggingFields 枚举,可以配置中间件打印 Request、Query、HttpMethod、Header、Response 等信息。

可以将HttpLogging 中间件放在 Swagger、Static 之后,这样的话可以避免打印哪些用处不大的请求,只保留 API 请求相关的日志。

app.UseHttpLogging();

HttpLoggingMiddleware 中的日志模式是以 Information 级别打印的,在项目上线之后,如果每个请求都被打印信息的话,会降低系统性能,因此我们可以在配置文件中覆盖配置,避免打印普通的日志。

"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"

上下文属性和作用域

示例项目在 Demo2.ScopeLog 中。

日志范围注意事项
Microsoft.Extensions.Logging.Abstractions 提供 BeginScopeAPI,可用于添加任意属性以记录特定代码区域内的事件。

解释其作用

API 有两种形式:

IDisposable BeginScope<TState>(TState state)
IDisposable BeginScope(this ILogger logger, string messageFormat, params object[] args)

使用如下的模板:

{SourceContext} {Timestamp:HH:mm} [{Level}] (ThreadId:{ThreadId}) {Message}{NewLine}{Exception} {Scope}

使用示例:

static void Main()
{
	var logger = GetLogger();
	using (logger.BeginScope("Checking mail"))
	{
		// Scope is "Checking mail"
		logger.LogInformation("Opening SMTP connection");

		using (logger.BeginScope("Downloading messages"))
		{
			// Scope is "Checking mail" -> "Downloading messages"
			logger.LogError("Connection interrupted");
		}
	}
}

image-20231220212411976

而在 Serilog 中,除了支持上述接口外,还通过 LogContext 提供了在日志中注入上下文属性的方法。其作用是添加属性之后,使得在其作用域之内打印日志时,日志会携带这些上下文属性信息。

using (LogContext.PushProperty("Test", 1))
{
	// Process request; all logged events will carry `RequestId`
	Log.Information("{Test} Adding {Item} to cart {CartId}", 1, 1);
}

嵌套复杂一些:

using (LogContext.PushProperty("A", 1))
{
    log.Information("Carries property A = 1");

    using (LogContext.PushProperty("A", 2))
    using (LogContext.PushProperty("B", 1))
    {
        log.Information("Carries A = 2 and B = 1");
    }

    log.Information("Carries property A = 1, again");
}

当需要设置大量属性时,下面的方式会比较麻烦;

using (LogContext.PushProperty("Test1", 1))
using (LogContext.PushProperty("Test2", 2))
{
}

例如在 ASP.NET Core 中间件中,我们可以批量添加:

public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
	var enrichers = new List<ILogEventEnricher>();
	if (!string.IsNullOrEmpty(correlationId))
	{
		enrichers.Add(new PropertyEnricher(_options.EnricherPropertyNames.CorrelationId, correlationId));
	}

	using (LogContext.Push(enrichers.ToArray()))
	{
		await next(context);
	}
}

在业务系统中,可以通过在中间件获取 Token 中的用户信息,然后注入到日志上下文中,这样打印出来的日志,会携带用户信息。

格式化日志

引入 Serilog.Formatting.Compact 库。

本小节主要讲解三个知识点:

  • 序列化类型
  • 输入 JSON 格式日志

在打印日志是,我们往往想将一个对象打印到日志中,直接使用参数标识打印是不可行的:

logger.LogWarning("Test log {0}",new MyClass { A = "1",B = "2"});

打印结果:

image-20241013131111208

在 Serilog.Formatting.Compact 包中提供了一种格式化对象打印到日志的功能,我们只需要在标识符位置加上 @ 即可。

 logger.LogWarning("Test log {@Message}",new MyClass { A = "1",B = "2"});

image-20241013131240633


对于字典类型也可以起效:

logger.LogWarning("Test log {@Message}",new Dictionary<string, string>() { { "A","1"},{ "B","2"} });

完整示例代码:

static void Main()
{
	IConfiguration configuration = new ConfigurationBuilder()
							 .SetBasePath(AppContext.BaseDirectory)
							 .AddJsonFile(path: "serilog.json", optional: true, reloadOnChange: true)
							 .Build();
	if (configuration == null)
	{
		throw new ArgumentNullException($"未能找到 serilog.json 日志配置文件");
	}

	var loggerBuilder = new LoggerConfiguration()
		.ReadFrom.Configuration(configuration)
		.WriteTo.Console(new CompactJsonFormatter())
		.CreateLogger();

	var services = new ServiceCollection();
	services.AddLogging(s =>
	{
		s.AddSerilog(loggerBuilder);
	});

	var ioc = services.BuildServiceProvider();

	var logger = ioc.GetRequiredService<ILogger<Program>>();
	logger.LogWarning("Test log {@Message}", new Dictionary<string, string>() { { "A", "1" }, { "B", "2" } });
}

不够,这样一行或多行的数据对应微服务基础设施的日志收集来说,非常不方便,我们需要将日志自动生成 json 格式打印到控制台中,有 ELK 等日志系统自动收集。

这样搞其实也很简单,只需要去掉默认的日志模板即可:

image-20241013131621419

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐