增加日志组件

This commit is contained in:
2026-01-15 18:56:39 +08:00
parent 801ffb25fd
commit 2754cdff15
13 changed files with 1153 additions and 142 deletions

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageReference Include="Serilog.Enrichers.Span" Version="3.1.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Exceptions" Version="8.4.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.Map" Version="2.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,183 @@
using Serilog;
using Serilog.Events;
using Serilog.Exceptions; // Nuget: Serilog.Exceptions
using Serilog.Enrichers.Span; // Nuget: Serilog.Enrichers.Span
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
namespace Ayay.SerilogLogs
{
/// <summary>
/// 日志引导程序
/// <para>负责初始化 Serilog 全局配置包括文件策略、控制台输出、Seq 连接以及上下文丰富化。</para>
/// <para>实现了按日期分文件夹、按模块分文件、以及主次日志分离的复杂策略。</para>
/// </summary>
public static class LogBootstrapper
{
/// <summary>
/// 初始化日志系统 (通常在程序启动最开始调用)
/// </summary>
/// <param name="opts">配置选项</param>
public static void Init(LogOptions opts)
{
// --------------------------------------------------------
// 1. 目录容错处理 (防止因为 D 盘不存在导致程序崩溃)
// --------------------------------------------------------
string finalLogPath = opts.LogRootPath;
try
{
if (!Directory.Exists(finalLogPath))
{
Directory.CreateDirectory(finalLogPath);
}
}
catch (Exception)
{
// 如果创建失败(比如没有 D 盘权限),回退到程序运行目录下的 Logs 文件夹
finalLogPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
}
// --------------------------------------------------------
// 2. 构建 Serilog 配置
// --------------------------------------------------------
var builder = new LoggerConfiguration();
// 2.1 设置全局最低门槛 (兜底策略)
builder.MinimumLevel.Is(opts.GlobalMinimumLevel);
// 2.2 应用模块级别的特殊配置 (关键:处理 Algorithm=Debug, Ping=Fatal 等)
if (opts.ModuleLevels != null)
{
foreach (var module in opts.ModuleLevels)
{
builder.MinimumLevel.Override(module.Key, module.Value);
}
}
// 强制覆盖微软自带的啰嗦日志
builder.MinimumLevel.Override("Microsoft", LogEventLevel.Warning);
builder.MinimumLevel.Override("System", LogEventLevel.Warning);
// 2.3 注入全套元数据 (Enrichers) - 让日志更聪明
builder
.Enrich.FromLogContext() // 允许使用 .ForContext() 注入上下文
.Enrich.WithProperty("AppId", opts.AppId) // 注入应用标识
.Enrich.WithThreadId() // 线程ID
.Enrich.WithProcessId() // 进程ID (用于识别重启)
.Enrich.WithExceptionDetails() // 结构化异常堆栈
.Enrich.WithSpan(); // 全链路追踪 ID
// --------------------------------------------------------
// 3. 配置输出端 (Sinks)
// --------------------------------------------------------
// 定义通用模板:包含了 SourceContext (模块名), TraceId, AppId
// 示例: 2026-01-15 12:00:01 [INF] [Algorithm] [Dev01] 计算完成
string outputTemplate = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] [{AppId}] {Message:lj}{NewLine}{Exception}";
// 3.1 控制台输出 (开发调试用)
builder.WriteTo.Async(a => a.Console(
restrictedToMinimumLevel: opts.ConsoleLevel,
outputTemplate: outputTemplate
));
// =================================================================
// 3.2 [核心逻辑] 双重 Map 实现: 日期文件夹 -> 模块文件
// =================================================================
// 第一层 Map根据【日期】分发 (key = "2026-01-15")
// 目的:实现 D:\Logs\App\2026-01-15\ 这样的目录结构
builder.WriteTo.Map(le => le.Timestamp.ToString("yyyy-MM-dd"), (dateKey, dailyConfig) =>
{
// 动态计算当天的文件夹路径
var dailyPath = Path.Combine(finalLogPath, dateKey);
// 第二层 Map根据【模块 SourceContext】分发
// 目的:在日期文件夹下,区分 System.txt, Network.txt
dailyConfig.Map("SourceContext", (moduleKey, moduleConfig) =>
{
// 如果没填 SourceContext默认归为 General
var moduleName = string.IsNullOrEmpty(moduleKey) ? "General" : moduleKey;
// --- A. 配置【主要数据】文件 (Main) ---
// 规则: 只存 Information 及以上
// 路径: .../2026-01-15/System-20260115.txt
moduleConfig.File(
path: Path.Combine(dailyPath, $"{moduleName}-.txt"), // RollingInterval.Day 会自动把 - 替换为 -yyyyMMdd
restrictedToMinimumLevel: LogEventLevel.Information, // 👈 过滤掉 Debug
rollingInterval: RollingInterval.Day,
// 文件大小限制 (超过 10MB 切割 Main_001.txt)
fileSizeLimitBytes: opts.FileSizeLimitBytes,
rollOnFileSizeLimit: opts.RollOnFileSizeLimit,
// ⚠️ 设为 null因为我们有自定义的 LogCleaner 接管清理工作,避免 Serilog 内部逻辑冲突
retainedFileCountLimit: null,
encoding: Encoding.UTF8,
outputTemplate: outputTemplate,
shared: true // 允许跨进程/多线程共享
);
// --- B. 配置【细节数据】文件 (Detail) ---
// 规则: 存 Debug 及以上 (包含 Main 的数据,是最全的备份)
// 路径: .../2026-01-15/SystemDetail-20260115.txt
moduleConfig.File(
path: Path.Combine(dailyPath, $"{moduleName}Detail-.txt"),
restrictedToMinimumLevel: LogEventLevel.Debug, // 👈 包含 Debug
rollingInterval: RollingInterval.Day,
// 文件大小限制 (与 Main 保持一致)
fileSizeLimitBytes: opts.FileSizeLimitBytes,
rollOnFileSizeLimit: opts.RollOnFileSizeLimit,
retainedFileCountLimit: null,
encoding: Encoding.UTF8,
outputTemplate: outputTemplate,
shared: true
);
}, sinkMapCountLimit: 20); // 限制模块数量,防止 Context 乱填导致句柄爆炸
}, sinkMapCountLimit: 2); // 限制日期 Sink 数量 (只需要保持今天和昨天,防止跨天运行时内存不释放)
// 3.3 Seq 远程输出 (生产监控)
if (!string.IsNullOrWhiteSpace(opts.SeqServerUrl))
{
builder.WriteTo.Async(a => a.Seq(
serverUrl: opts.SeqServerUrl,
apiKey: opts.SeqApiKey,
restrictedToMinimumLevel: opts.SeqLevel
));
}
// --------------------------------------------------------
// 4. 生成 Logger 并赋值给全局静态变量
// --------------------------------------------------------
Log.Logger = builder.CreateLogger();
// --------------------------------------------------------
// 5. 启动后台清理任务 (LogCleaner)
// --------------------------------------------------------
// 延迟 5 秒执行,避免在程序刚启动的高负载时刻争抢 IO 资源
Task.Delay(5000).ContinueWith(_ =>
{
// 调用我们在 LogCleaner.cs 中定义的静态方法
LogCleaner.RunAsync(
opts
);
});
}
/// <summary>
/// 关闭日志系统,确保缓冲区数据写入磁盘/网络
/// <para>请在程序退出 (OnExit) 时调用</para>
/// </summary>
public static void Close()
{
Log.CloseAndFlush();
}
}
}

View File

@@ -0,0 +1,157 @@
using Serilog;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace Ayay.SerilogLogs
{
/// <summary>
/// 日志清理工具
/// <para>弥补 Serilog 原生清理功能的不足,支持“按天数”和“按总大小”进行全局清理。</para>
/// <para>✅ 已适配多级目录结构,会自动清理删除文件后留下的空文件夹。</para>
/// </summary>
public static class LogCleaner
{
/// <summary>
/// 异步执行清理任务
/// </summary>
/// <param name="opts">配置选项</param>
public static void RunAsync(LogOptions opts)
{
string rootPath = opts.LogRootPath; // 日志根目录
int maxDays = opts.MaxRetentionDays; // 最大保留天数
long maxBytes = opts.MaxTotalLogSize; // 最大总字节数
Task.Run(() =>
{
try
{
CleanUp(rootPath, maxDays, maxBytes);
}
catch (Exception ex)
{
Log.Error(ex, "[LogCleaner] 日志自动清理任务执行失败");
}
});
}
private static void CleanUp(string rootPath, int maxDays, long maxBytes)
{
var dirInfo = new DirectoryInfo(rootPath);
if (!dirInfo.Exists) return;
// =========================================================
// 第一步:清理文件 (递归查找所有子目录)
// =========================================================
// 获取所有 .txt 文件,按最后修改时间升序排列 (最旧的在前面)
// SearchOption.AllDirectories 确保了能扫描到 2026-01-15 这种子文件夹里的内容
var allFiles = dirInfo.GetFiles("*.txt", SearchOption.AllDirectories)
.OrderBy(f => f.LastWriteTime)
.ToList();
if (allFiles.Count == 0) return;
long currentTotalSize = 0;
var cutOffDate = DateTime.Now.Date.AddDays(-maxDays); // 只保留到今天之前的 N 天
int deletedCount = 0;
// --- 策略 A: 按时间清理 ---
for (int i = allFiles.Count - 1; i >= 0; i--)
{
var file = allFiles[i];
if (file.LastWriteTime.Date < cutOffDate)
{
try
{
file.Delete();
allFiles.RemoveAt(i);
deletedCount++;
}
catch { /* 忽略占用 */ }
}
else
{
currentTotalSize += file.Length;
}
}
if (deletedCount > 0)
{
Log.ForContext("SourceContext", LogModules.Core)
.Information("[LogCleaner] 时间策略: 已删除 {Count} 个过期文件", deletedCount);
}
// --- 策略 B: 按总大小清理 ---
if (currentTotalSize > maxBytes)
{
int sizeDeletedCount = 0;
long freedBytes = 0;
foreach (var file in allFiles)
{
if (currentTotalSize <= maxBytes) break;
try
{
long len = file.Length;
file.Delete();
currentTotalSize -= len;
freedBytes += len;
sizeDeletedCount++;
}
catch { /* 忽略占用 */ }
}
if (sizeDeletedCount > 0)
{
Log.ForContext("SourceContext", LogModules.Core)
.Warning("[LogCleaner] 空间策略: 已删除 {Count} 个旧文件, 释放 {SizeMB:F2} MB",
sizeDeletedCount, freedBytes / 1024.0 / 1024.0);
}
}
// =========================================================
// 第二步:清理空文件夹 (新增逻辑)
// =========================================================
// 目的:当 2026-01-01 文件夹里的文件都被删光后,这个文件夹本身也应该被删掉
DeleteEmptyDirectories(dirInfo);
}
/// <summary>
/// 递归删除空文件夹
/// </summary>
private static void DeleteEmptyDirectories(DirectoryInfo dir)
{
// 1. 先递归处理子文件夹
foreach (var subDir in dir.GetDirectories())
{
DeleteEmptyDirectories(subDir);
}
// 2. 检查当前文件夹是否为空 (且不是根目录自己)
try
{
// 重新刷新状态
dir.Refresh();
// 如果没有文件 且 没有子文件夹
if (dir.GetFiles().Length == 0 && dir.GetDirectories().Length == 0)
{
// 注意:不要删除根目录 LogRootPath
// 这里虽然逻辑上递归会删到底,但通常外层调用时传入的是 Logs 根目录,
// 只要 LogBootstrapper 里保证 Logs 存在,这里删掉子目录没问题。
// 为了安全,可以判断一下是否是根目录的直接子级,或者简单地 try catch 忽略根目录无法删除的异常。
dir.Delete();
}
}
catch
{
// 忽略异常 (比如文件夹正被打开,或者试图删除根目录但被占用)
}
}
}
}

View File

@@ -0,0 +1,28 @@
namespace Ayay.SerilogLogs
{
/// <summary>
/// 统一管理系统中的日志模块名称SourceContext
/// <para>使用常量可以避免硬编码字符串带来的拼写错误</para>
/// </summary>
public static class LogModules
{
// --- 核心架构层 ---
public const string Core = "Core"; // 系统主逻辑/启动关闭
public const string Network = "Network"; // 底层网络通讯 (TCP/UDP)
public const string WebApi = "WebAPI"; // 对外 HTTP 接口
public const string WebSocket = "WebSocket"; // 实时通讯
public const string Ping = "Ping"; // 心跳/Ping包 (通常量大且不重要)
// --- 业务逻辑层 ---
public const string UserSystem = "UserSystem"; // 用户账户/权限系统
public const string UserAction = "UserAction"; // 用户操作行为 (审计日志)
public const string DeviceOps = "DeviceOps"; // 设备运行/控制指令
// --- 核心算法层 ---
public const string Algorithm = "Algorithm"; // 算法分析/AI识别 (需要上下文追踪)
public const string Observation = "Observation"; // 观察点/埋点 (用于调试或统计)
// --- 第三方集成 ---
public const string Sdk = "SDK"; // 第三方 SDK 调用封装
}
}

View File

@@ -0,0 +1,154 @@
using Serilog.Events;
using System.Collections.Generic;
namespace Ayay.SerilogLogs
{
/// <summary>
/// 日志组件配置选项
/// <para>包含身份标识、存储路径、分级策略以及自动清理策略。</para>
/// </summary>
public class LogOptions
{
// ==========================================
// 1. 基础身份标识
// ==========================================
/// <summary>
/// 应用名称/服务ID。
/// <para>例如: "VideoServer-01", "Gatekeeper-Api"。</para>
/// <para>在 Seq 中对应 {AppId} 属性,用于区分多服务环境下的日志来源。</para>
/// </summary>
public string AppId { get; set; } = "DefaultApp";
// ==========================================
// 2. 存储路径配置
// ==========================================
/// <summary>
/// 本地日志文件的存储根目录。
/// <para>默认: @"D:\Logs"</para>
/// <para>程序会自动在此目录下按模块创建子文件夹(如 System, Network。</para>
/// <para>注意:如果该目录无写入权限,组件会自动降级到程序运行目录。</para>
/// </summary>
public string LogRootPath { get; set; } = @"D:\Logs";
// ==========================================
// 3. Seq 集中式日志配置
// ==========================================
/// <summary>
/// Seq 服务器地址。
/// <para>例如: "http://192.168.1.100:5341"</para>
/// <para>如果留空 (null/empty),则不启用 Seq 投递。</para>
/// </summary>
public string SeqServerUrl { get; set; }
/// <summary>
/// Seq API Key (令牌)。
/// <para>建议在 Seq 后台申请仅具有 [Ingest] 权限的 Key。</para>
/// <para>配置 Key 后支持动态调整日志级别 (Dynamic Level Control)。</para>
/// </summary>
public string SeqApiKey { get; set; }
// ==========================================
// 4. 输出端级别控制 (Sink Levels)
// 用于控制不同媒介的“过滤网”疏密程度
// ==========================================
/// <summary>
/// 控制台输出的最低级别。
/// <para>默认: Information (开发调试时可改为 Debug)</para>
/// </summary>
public LogEventLevel ConsoleLevel { get; set; } = LogEventLevel.Information;
/// <summary>
/// 本地文件记录的最低级别。
/// <para>默认: Debug (保留详细案底,便于事后追溯)</para>
/// <para>注意:具体写入哪个文件(Main/Detail)由内部逻辑决定,此属性控制总开关。</para>
/// </summary>
public LogEventLevel FileLevel { get; set; } = LogEventLevel.Verbose;
/// <summary>
/// Seq 网络传输的最低级别。
/// <para>默认: Information (减少网络带宽和服务器存储压力)</para>
/// <para>生产环境建议设为 Information 或 Warning除非需要在线排错。</para>
/// </summary>
public LogEventLevel SeqLevel { get; set; } = LogEventLevel.Verbose;
// ==========================================
// 5. 业务模块级别控制 (Context Levels)
// 用于精细化控制特定业务模块的日志开关
// ==========================================
/// <summary>
/// 全局默认最低级别。
/// <para>如果某个日志没有指定模块,或者模块不在 ModuleLevels 列表中,则使用此级别。</para>
/// </summary>
public LogEventLevel GlobalMinimumLevel { get; set; } = LogEventLevel.Verbose;
/// <summary>
/// 针对特定业务模块的日志级别覆盖 (Override)。
/// <para>Key: 模块名称 (建议使用 LogModules 常量字符串)</para>
/// <para>Value: 该模块允许记录的最低级别</para>
/// </summary>
public Dictionary<string, LogEventLevel> ModuleLevels { get; set; } = new Dictionary<string, LogEventLevel>
{
// --- 系统层 ---
{ LogModules.Core, LogEventLevel.Debug }, // 系统主逻辑
{ LogModules.Network, LogEventLevel.Debug }, // 网络通讯:平时只看警告,防止心跳刷屏
{ LogModules.WebApi, LogEventLevel.Debug }, // WebAPI记录请求响应
// --- 业务层 ---
{ LogModules.UserSystem, LogEventLevel.Debug }, // 用户系统
{ LogModules.UserAction, LogEventLevel.Debug }, // 用户操作:必须记录,用于审计
{ LogModules.DeviceOps, LogEventLevel.Debug }, // 设备操作:记录关键指令
// --- 核心/高频数据 ---
{ LogModules.Algorithm, LogEventLevel.Debug }, // 算法:核心业务,开启 Debug 以记录全过程
{ LogModules.Observation, LogEventLevel.Debug }, // 观察点:最详细的埋点
// --- 降噪区 (垃圾数据屏蔽) ---
{ LogModules.WebSocket, LogEventLevel.Debug }, // WS数据量极大除非报错否则不记
{ LogModules.Ping, LogEventLevel.Debug }, // Ping几乎不记除非完全断连
{ LogModules.Sdk, LogEventLevel.Debug } // SDK屏蔽第三方的废话日志
};
// ==========================================
// 6. 文件滚动策略 (Rolling Policy)
// 控制单个日志文件的大小和生成频率
// ==========================================
/// <summary>
/// 单个日志文件大小限制 (单位:字节)。
/// <para>默认: 10MB (10 * 1024 * 1024)</para>
/// <para>当文件超过此大小时,会自动创建新文件 (例如 Main_001.txt)。</para>
/// <para>建议不要设置过大,否则记事本打开会很卡。</para>
/// </summary>
public long FileSizeLimitBytes { get; set; } = 10 * 1024 * 1024;
/// <summary>
/// 超过大小限制后是否创建新文件。
/// <para>默认: true (推荐)</para>
/// </summary>
public bool RollOnFileSizeLimit { get; set; } = true;
// ==========================================
// 7. 自动清理策略 (Auto Cleanup)
// 由后台 LogCleaner 任务执行,满足任意条件即清理
// ==========================================
/// <summary>
/// 历史日志最大保留天数 (时间策略)。
/// <para>默认: 30天</para>
/// <para>系统会强制删除最后修改时间超过此天数的文件。</para>
/// </summary>
public int MaxRetentionDays { get; set; } = 30;
/// <summary>
/// 日志目录总大小上限 (空间策略)。
/// <para>默认: 1GB (1024 * 1024 * 1024)</para>
/// <para>如果所有日志文件总和超过此大小,系统会按时间倒序(从旧到新)删除文件,直到空间低于此值。</para>
/// </summary>
public long MaxTotalLogSize { get; set; } = 1024L * 1024 * 1024;
}
}