日志系统扩展与结构化日志
大约 12 分钟约 3628 字
日志系统扩展与结构化日志
简介
ASP.NET Core 的日志系统基于 ILogger<T> 接口,支持多日志提供者(Console、File、ELK 等)。深入理解日志管道、结构化日志(Serilog)和日志过滤,有助于构建可观测的生产级应用。
特点
日志管道
核心组件
// 日志管道架构:
// ILoggerFactory — 创建 ILogger 实例
// ILoggerProvider — 日志提供者(Console、File、Debug)
// ILogger — 日志记录器(实际写入日志)
// IExternalScopeProvider — 作用域支持
// 注册日志服务
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
builder.Logging.AddEventLog();
// 使用 ILogger<T>
public class UserService
{
private readonly ILogger<UserService> _logger;
public UserService(ILogger<UserService> logger)
{
_logger = logger;
}
public async Task<User?> GetUserAsync(int id)
{
_logger.LogInformation("获取用户: {UserId}", id); // 结构化
var user = await _db.Users.FindAsync(id);
if (user == null)
{
_logger.LogWarning("用户不存在: {UserId}", id);
return null;
}
_logger.LogInformation("用户查找成功: {UserId}, {UserName}", user.Id, user.Name);
return user;
}
}日志级别与过滤
// 日志级别(从低到高)
// Trace — 最详细,开发调试用
// Debug — 调试信息
// Information — 一般信息
// Warning — 警告(不影响运行但需关注)
// Error — 错误(影响当前操作)
// Critical — 严重错误(影响整个应用)
// None — 不记录
// 配置日志过滤
builder.Logging.AddFilter("Microsoft", LogLevel.Warning); // Microsoft 命名空间只记 Warning 以上
builder.Logging.AddFilter("System", LogLevel.Warning);
builder.Logging.AddFilter("MyApp.*", LogLevel.Debug);
// appsettings.json 配置
// {
// "Logging": {
// "LogLevel": {
// "Default": "Information",
// "Microsoft": "Warning",
// "Microsoft.Hosting.Lifetime": "Information",
// "MyApp.Services": "Debug"
// },
// "Console": {
// "LogLevel": {
// "Default": "Information"
// }
// }
// }
// }
// 作用域日志
using (_logger.BeginScope("处理订单 {OrderId}", orderId))
{
_logger.LogInformation("验证库存");
_logger.LogInformation("计算价格");
_logger.LogInformation("创建支付");
// 所有日志都附带 OrderId 作用域
}结构化日志
消息模板
// ❌ 字符串插值(不推荐)
_logger.LogInformation($"处理用户 {userId} 的订单 {orderId}");
// 日志输出:处理用户 123 的订单 456
// 无法按 userId 或 orderId 搜索!
// ✅ 结构化模板(推荐)
_logger.LogInformation("处理用户 {UserId} 的订单 {OrderId}", userId, orderId);
// 日志输出:处理用户 123 的订单 456
// 同时记录属性:{ UserId: 123, OrderId: 456 }
// 可以按 UserId=123 搜索所有相关日志!
// 模板规则:
// 1. 使用 {PropertyName} 占位符
// 2. 属性名使用 PascalCase
// 4. 属性值作为参数传入
// 5. 不要使用 $ 字符串插值
// 常见场景
_logger.LogInformation("HTTP {Method} {Path} → {StatusCode} ({Elapsed}ms)",
context.Request.Method,
context.Request.Path,
context.Response.StatusCode,
elapsed.TotalMilliseconds);
// 异常日志
try
{
await ProcessOrderAsync(order);
}
catch (Exception ex)
{
_logger.LogError(ex, "处理订单 {OrderId} 失败", order.Id);
// ex 作为第一个参数,记录完整异常堆栈
}Serilog 集成
配置 Serilog
// 安装包
// Serilog.AspNetCore
// Serilog.Sinks.Console
// Serilog.Sinks.File
// Serilog.Sinks.Elasticsearch
// Serilog.Enrichers.Environment
// Serilog.Enrichers.Thread
// Program.cs 配置
using Serilog;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("System", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithThreadId()
.Enrich.WithProperty("Application", "MyApp")
.Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
.WriteTo.Console(new RenderedCompactJsonFormatter()) // JSON 格式
.WriteTo.File(
path: "logs/app-.log",
rollingInterval: RollingInterval.Day,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}",
fileSizeLimitBytes: 10 * 1024 * 1024,
retainedFileCountLimit: 30)
.WriteTo.Seq("http://localhost:5341") // Seq 日志服务器
.CreateLogger();
builder.Host.UseSerilog(); // 替换默认日志
// 使用 Serilog 的请求日志中间件
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} → {StatusCode} ({Elapsed:0.000}ms)";
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("RequestId", httpContext.TraceIdentifier);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].ToString());
diagnosticContext.Set("UserId", httpContext.User?.FindFirst("sub")?.Value);
};
});Serilog Enricher 自定义
// 自定义 Enricher(附加额外属性到每条日志)
public class CorrelationIdEnricher : ILogEventEnricher
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CorrelationIdEnricher(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var context = _httpContextAccessor.HttpContext;
if (context != null)
{
var correlationId = context.TraceIdentifier;
logEvent.AddOrUpdateProperty(propertyFactory.CreateProperty("CorrelationId", correlationId));
}
}
}
// 注册
builder.Services.AddHttpContextAccessor();
Log.Logger = new LoggerConfiguration()
.Enrich.With<CorrelationIdEnricher>()
// ...
.CreateLogger();LoggerMessage 高性能日志
源生成器日志
// LoggerMessage 相比 Logger.LogInformation 快 10 倍以上
// 因为避免了每次调用的字符串格式化和装箱
// 方式 1:LoggerMessage.Define(手动)
public static partial class LogMessages
{
private static readonly Action<ILogger, int, string, Exception?> _userCreated =
LoggerMessage.Define<int, string>(
LogLevel.Information,
new EventId(1001, nameof(UserCreated)),
"用户创建成功: {UserId}, {UserName}");
public static void UserCreated(this ILogger logger, int userId, string userName)
=> _userCreated(logger, userId, userName, null);
}
// 方式 2:[LoggerMessage] 源生成器(.NET 6+,推荐)
public static partial class LogMessages
{
[LoggerMessage(Level = LogLevel.Information, Message = "用户创建成功: {UserId}, {UserName}")]
public static partial void UserCreated(this ILogger logger, int userId, string userName);
[LoggerMessage(Level = LogLevel.Error, Message = "处理订单 {OrderId} 失败")]
public static partial void OrderProcessFailed(this ILogger logger, int orderId, Exception ex);
[LoggerMessage(Level = LogLevel.Debug, Message = "HTTP {Method} {Path} → {StatusCode} ({Elapsed}ms)")]
public static partial void HttpRequest(this ILogger logger, string method, string path, int statusCode, double elapsed);
// 条件日志(自动检查级别)
[LoggerMessage(Level = LogLevel.Debug, Message = "缓存命中: {Key}")]
public static partial void CacheHit(this ILogger logger, string key);
// 调用时:logger.CacheHit("user:123");
// 如果 Debug 级别未启用,不会执行任何代码
}
// 使用
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public async Task ProcessAsync(Order order)
{
try
{
await DoProcessAsync(order);
}
catch (Exception ex)
{
_logger.OrderProcessFailed(order.Id, ex);
throw;
}
}
}Serilog 高级配置
结构化日志与 JSON 输出
// 完整的 Serilog 生产配置
using Serilog;
using Serilog.Events;
using Serilog.Exceptions;
using Serilog.Exceptions.Core;
using Serilog.Exceptions.EntityFrameworkCore.Dialects;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("System", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("Application", "MyApp")
.Enrich.WithExceptionDetails(new ExceptionEnrichmentOptions
{
// 包含 EF Core 异常中的 SQL 语句
Dialects = new List<IExceptionDialect> { new SqlServerDialect() }
})
.Enrich.WithThreadId()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File(
path: "logs/log-.txt",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] [{TraceId}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File(
path: "logs/log-.json",
formatter: new Serilog.Formatting.Json.JsonFormatter(renderMessage: true),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30)
.CreateLogger();
builder.Host.UseSerilog();Serilog Enricher 自定义
// 自定义 Enricher:添加请求信息
public class HttpContextEnricher : ILogEventEnricher
{
private readonly IHttpContextAccessor _httpContextAccessor;
public HttpContextEnricher(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var context = _httpContextAccessor.HttpContext;
if (context == null) return;
// 添加请求路径
var path = propertyFactory.CreateProperty(
"RequestPath", context.Request.Path.Value ?? "");
logEvent.AddPropertyIfAbsent(path);
// 添加用户 ID
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId != null)
{
var userProp = propertyFactory.CreateProperty("UserId", userId);
logEvent.AddPropertyIfAbsent(userProp);
}
// 添加 TraceId
var traceId = context.TraceIdentifier;
var traceProp = propertyFactory.CreateProperty("TraceId", traceId);
logEvent.AddPropertyIfAbsent(traceProp);
// 添加客户端 IP
var ip = context.Connection.RemoteIpAddress?.ToString();
if (ip != null)
{
var ipProp = propertyFactory.CreateProperty("ClientIp", ip);
logEvent.AddPropertyIfAbsent(ipProp);
}
}
}
// 注册
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<ILogEventEnricher, HttpContextEnricher>();
// 在 LoggerConfiguration 中使用
// .Enrich.With<HttpContextEnricher>()日志过滤策略
// 按命名空间和级别过滤
builder.Services.AddLogging(loggingBuilder =>
{
loggingBuilder.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
loggingBuilder.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
loggingBuilder.AddFilter("System.Net.Http", LogLevel.Warning);
loggingBuilder.AddFilter("MyApp.Services", LogLevel.Information);
loggingBuilder.AddFilter("MyApp.Infrastructure", LogLevel.Information);
// 特定命名空间更详细
loggingBuilder.AddFilter("MyApp.Services.PaymentService", LogLevel.Debug);
});
// Serilog 过滤
Log.Logger = new LoggerConfiguration()
.Filter.ByExcluding(logEvent =>
logEvent.Level == LogEventLevel.Information &&
logEvent.Properties.TryGetValue("SourceContext", out var source) &&
source.ToString().Contains("HealthCheck"))
.Filter.ByIncludingOnly(logEvent =>
logEvent.Level >= LogEventLevel.Warning ||
(logEvent.Properties.TryGetValue("SourceContext", out var source) &&
source.ToString().StartsWith("MyApp.")))
.CreateLogger();高性能日志方案
LoggerMessage 源生成器
// LoggerMessage 是最高性能的日志方案
// 编译时生成代码,避免运行时字符串分配
public static partial class LogMessages
{
// 定义日志方法(partial 方法,源生成器自动实现)
[LoggerMessage(
EventId = 1001,
Level = LogLevel.Information,
Message = "订单创建成功: OrderId={OrderId}, UserId={UserId}, Amount={Amount}")]
public static partial void OrderCreated(
ILogger logger,
long orderId,
long userId,
decimal amount);
[LoggerMessage(
EventId = 1002,
Level = LogLevel.Warning,
Message = "库存不足: ProductId={ProductId}, Requested={Requested}, Available={Available}")]
public static partial void InsufficientStock(
ILogger logger,
long productId,
int requested,
int available);
[LoggerMessage(
EventId = 1003,
Level = LogLevel.Error,
Message = "支付失败: OrderId={OrderId}, Error={Error}")]
public static partial void PaymentFailed(
ILogger logger,
long orderId,
string error);
[LoggerMessage(
EventId = 1004,
Level = LogLevel.Error,
Message = "订单处理异常")]
public static partial void OrderProcessingError(
ILogger logger,
Exception exception);
// 使用 EventName 代替数字 EventId
[LoggerMessage(
EventId = 2001,
Level = LogLevel.Debug,
Message = "缓存命中: Key={CacheKey}, ElapsedMs={ElapsedMs}")]
public static partial void CacheHit(
ILogger logger,
string cacheKey,
double elapsedMs);
}
// 使用
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger) => _logger = logger;
public async Task CreateOrderAsync(Order order)
{
// 高性能日志 — 零分配
LogMessages.OrderCreated(_logger, order.Id, order.UserId, order.Amount);
}
}日志性能对比
// 性能对比(100万次调用)
//
// 方式 1:字符串插值(最差)
// _logger.LogInformation($"Order {order.Id} created by {order.UserId}");
// → 每次调用都分配新字符串,即使日志级别不匹配
//
// 方式 2:占位符(中等)
// _logger.LogInformation("Order {OrderId} created by {UserId}", order.Id, order.UserId);
// → 仅当日志级别匹配时才格式化字符串
//
// 方式 3:LoggerMessage.Define(较好)
// private static readonly Action<ILogger, long, long, Exception?> _orderCreated =
// LoggerMessage.Define<long, long>(LogLevel.Information, 1001,
// "Order {OrderId} created by {UserId}");
//
// 方式 4:[LoggerMessage] 源生成器(最佳)
// → 编译时生成,零分配,强类型
// 热路径中的日志建议:
// 1. 使用 [LoggerMessage] 源生成器
// 2. 日志级别检查(_logger.IsEnabled(LogLevel.Debug))
// 3. 避免在日志消息中执行复杂计算
// 4. 使用结构化属性而非字符串拼接日志集中管理与 ELK 集成
Elasticsearch + Kibana 配置
// Serilog 写入 Elasticsearch
// 安装:Serilog.Sinks.Elasticsearch
Log.Logger = new LoggerConfiguration()
.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(
new Uri("http://localhost:9200"))
{
IndexFormat = "myapp-logs-{0:yyyy.MM.dd}",
AutoRegisterTemplate = true,
AutoRegisterTemplateVersion = AutoRegisterTemplateVersion.ESv7,
MinimumLogEventLevel = LogEventLevel.Information,
// 批量发送优化
BatchPostingLimit = 50,
Period = TimeSpan.FromSeconds(2),
// 失败重试
FailureCallback = e => Console.WriteLine($"日志发送失败: {e.Message}"),
EmitEventFailure = EmitEventFailureHandling.WriteToSelfLog |
EmitEventFailureHandling.ThrowException
})
.CreateLogger();Seq 日志管理
// Seq 是轻量级日志管理工具(适合中小项目)
// 安装:Serilog.Sinks.Seq
Log.Logger = new LoggerConfiguration()
.WriteTo.Seq("http://localhost:5341",
apiKey: "your-api-key",
batchPostingLimit: 50,
period: TimeSpan.FromSeconds(2),
minimumLevel: LogEventLevel.Information)
.CreateLogger();
// Seq 查询语法示例:
// 查找所有错误:@Level = 'Error'
// 查找特定用户:UserId = '12345'
// 查找慢请求:ElapsedMs > 1000
// 时间范围:@Timestamp >= datetime('2026-04-12')
// 组合查询:@Level = 'Error' and Application = 'MyApp'敏感信息脱敏
日志脱敏中间件
// 自定义 Serilog DestructuringPolicy 脱敏敏感数据
public class SensitiveDataDestructuringPolicy : IDestructuringPolicy
{
public bool TryDestructure(
object value, ILogEventPropertyValueFactory propertyValueFactory,
[NotNullWhen(true)] out LogEventPropertyValue? result)
{
result = null;
if (value is CreditCardRequest creditCard)
{
// 脱敏信用卡号
var properties = new List<LogEventProperty>
{
new("CardNumber", new ScalarValue(
MaskString(creditCard.CardNumber, showLast: 4))),
new("Expiry", new ScalarValue(creditCard.Expiry)),
new("Cvv", new ScalarValue("***"))
};
result = new StructureValue(properties);
return true;
}
if (value is LoginRequest login)
{
var properties = new List<LogEventProperty>
{
new("Username", new ScalarValue(login.Username)),
new("Password", new ScalarValue("***"))
};
result = new StructureValue(properties);
return true;
}
return false;
}
private static string MaskString(string input, int showLast)
{
if (input.Length <= showLast) return "***";
return new string('*', input.Length - showLast) + input[^showLast..];
}
}
// 注册
// .Destructure.With<SensitiveDataDestructuringPolicy>()全局脱敏 Enricher
public class SensitiveDataEnricher : ILogEventEnricher
{
private static readonly string[] SensitiveKeys =
{
"password", "secret", "token", "apikey", "connectionstring",
"creditcard", "ssn", "idcard"
};
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
// 检查消息模板中是否包含敏感关键字
foreach (var key in SensitiveKeys)
{
if (logEvent.MessageTemplate.Text.Contains(key,
StringComparison.OrdinalIgnoreCase))
{
var sanitized = propertyFactory.CreateProperty(
"ContainsSensitiveData", true);
logEvent.AddPropertyIfAbsent(sanitized);
break;
}
}
}
}日志健康检查与告警
基于日志的告警
// 自定义日志监听器(实现简单的日志告警)
public class LogAlertListener : ILogEventSink
{
private readonly ILogger<LogAlertListener> _logger;
private readonly int _errorThreshold;
private int _errorCount;
private DateTimeOffset _windowStart = DateTimeOffset.UtcNow;
public LogAlertListener(
ILogger<LogAlertListener> logger,
int errorThreshold = 10)
{
_logger = logger;
_errorThreshold = errorThreshold;
}
public void Emit(LogEvent logEvent)
{
if (logEvent.Level != LogEventLevel.Error) return;
Interlocked.Increment(ref _errorCount);
// 滑动窗口:每分钟最多 errorThreshold 个错误
var now = DateTimeOffset.UtcNow;
if (now - _windowStart > TimeSpan.FromMinutes(1))
{
if (_errorCount >= _errorThreshold)
{
_logger.LogCritical(
"日志告警:过去一分钟内错误数 {ErrorCount} 超过阈值 {Threshold}",
_errorCount, _errorThreshold);
// 触发告警通知(邮件/短信/Webhook)
}
_errorCount = 0;
_windowStart = now;
}
}
}
// 注册
// Log.Logger = new LoggerConfiguration()
// .WriteTo.Sink(new LogAlertListener(logger, 10))
// .CreateLogger();优点
缺点
缺点
总结
ASP.NET Core 日志管道:ILoggerFactory → ILoggerProvider → ILogger。结构化日志使用 {PropertyName} 占位符(不是字符串插值),支持属性化搜索。Serilog 提供更丰富的功能:JSON 格式、文件滚动、Seq/Elasticsearch Sink、Enricher。LoggerMessage 源生成器([LoggerMessage])是高性能日志方案,编译时生成代码,避免运行时开销。日志过滤通过 LogLevel 和分类器控制输出。生产环境推荐:Serilog + JSON 格式 + 文件滚动 + ELK/Seq 集中管理。
关键知识点
- 先分清这个主题位于请求链路、后台任务链路还是基础设施链路。
- 服务端主题通常不只关心功能正确,还关心稳定性、性能和可观测性。
- 任何框架能力都要结合配置、生命周期、异常传播和外部依赖一起看。
项目落地视角
- 画清请求进入、业务执行、外部调用、日志记录和错误返回的完整路径。
- 为关键链路补齐超时、重试、熔断、追踪和结构化日志。
- 把配置与敏感信息分离,并明确不同环境的差异来源。
常见误区
- 只会堆中间件或组件,不知道它们在链路中的执行顺序。
- 忽略生命周期和线程池、连接池等运行时资源约束。
- 没有监控和测试就对性能或可靠性下结论。
进阶路线
- 继续向运行时行为、可观测性、发布治理和微服务协同深入。
- 把主题和数据库、缓存、消息队列、认证授权联动起来理解。
- 沉淀团队级模板,包括统一异常处理、配置约定和基础设施封装。
适用场景
- 当你准备把《日志系统扩展与结构化日志》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合 API 服务、后台任务、实时通信、认证授权和微服务协作场景。
- 当需求开始涉及稳定性、性能、可观测性和发布流程时,这类主题会成为基础设施能力。
落地建议
- 先定义请求链路与失败路径,再决定中间件、过滤器、服务边界和依赖方式。
- 为关键链路补日志、指标、追踪、超时与重试策略。
- 环境配置与敏感信息分离,避免把生产参数写死在代码或镜像里。
排错清单
- 先确认问题发生在路由、模型绑定、中间件、业务层还是基础设施层。
- 检查 DI 生命周期、配置来源、序列化规则和认证上下文。
- 查看线程池、连接池、缓存命中率和外部依赖超时。
复盘问题
- 如果把《日志系统扩展与结构化日志》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《日志系统扩展与结构化日志》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《日志系统扩展与结构化日志》最大的收益和代价分别是什么?
