CSharp: LogHelper
/*# encoding: utf-8 
# 版权所有  2026 ©涂聚文有限公司™ ®
# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
# 描述: Gale-Shapley Algorithm
# Author    : geovindu,Geovin Du 涂聚文.
# IDE       : vs2026 c# .net 10
# os        : windows 10
# database  : mysql 9.0 sql server 2019, postgreSQL 17.0  Oracle 21c Neo4j
# Datetime  : 2026/07/05 23:16 
# User      :  geovindu
# Product   : Visual Studio 2026
# Project   : CSharpAlgorithms
# File      : LogHelper.cs*/using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Diagnostics;
using System.IO.Compression;namespace CSharpAlgorithms.common
{/// <summary>/// 日志级别/// </summary>public enum LogLevel{Off = 0,Error = 1,Info = 2,Debug = 3}/// <summary>/// 脱敏委托:级别、模块、原始消息 -> 脱敏后消息/// </summary>public delegate string DesensitizeDelegate(string level, string module, string msg);/// <summary>/// 告警回调委托/// </summary>public delegate void AlertCallbackDelegate(string eventType, string message, Dictionary<string, object> ext);/// <summary>/// 告警常量/// </summary>public static class AlertEvent{public const string DiskOverLimit = "DISK_OVER_LIMIT";public const string QueueBlocked = "QUEUE_BLOCKED";}/// <summary>/// 日志队列单元/// </summary>internal sealed class LogItem{public string Level { get; set; } = string.Empty;public string Module { get; set; } = string.Empty;public string TextData { get; set; } = string.Empty;public Dictionary<string, object>? JsonData { get; set; }public string StackTrace { get; set; } = string.Empty;}/// <summary>/// .NET10 高性能日志核心类,修复空引用异常/// </summary>public sealed class LogHelper{#region 单例 严格顺序初始化private static readonly Lazy<LogHelper> _lazyInstance = new Lazy<LogHelper>(() => new LogHelper(), LazyThreadSafetyMode.ExecutionAndPublication);public static LogHelper Instance => _lazyInstance.Value;// 【强制提前实例化】所有成员字段直接初始化,杜绝nullprivate readonly ConcurrentQueue<LogItem> _logQueue = new ConcurrentQueue<LogItem>();private readonly CancellationTokenSource _cts = new CancellationTokenSource();private readonly object _fileLock = new object();private readonly Random _random = new Random();private readonly Dictionary<string, ConsoleColor> _colorMap = new Dictionary<string, ConsoleColor>();private readonly Dictionary<string, double> _levelDiskLimit = new(){{ "debug", 150 },{ "info", 250 },{ "error", 100 }};private readonly Dictionary<string, double> _levelSamplingRate = new(){{ "debug", 1.0 },{ "info", 1.0 },{ "error", 1.0 }};private Task? _writeTask;private DateTime _lastCleanDay = DateTime.MinValue;private long _lastDiskCheckTs;private readonly int _pid = Environment.ProcessId;#region 配置属性public string LogRoot { get; private set; }public string DefaultModule { get; set; } = "default";public int KeepDays { get; set; } = 7;public long MaxFileSizeMb { get; set; } = 10;public int QueueMaxSize { get; set; } = 10000;public int QueueAlertThreshold { get; set; } = 7000;public int BatchWriteCount { get; set; } = 10;public bool EnableConsole { get; set; } = true;public bool EnableJsonFormat { get; set; } = false;public bool CompressBackupLog { get; set; } = true;public double MaxDiskTotalMb { get; set; } = 500;public double GlobalSamplingRate { get; set; } = 1.0;public int DiskCheckIntervalSec { get; set; } = 60;public string TimeFormat { get; set; } = "yyyy-MM-dd HH:mm:ss";public LogLevel MinLogLevel { get; set; } = LogLevel.Debug;// 回调public DesensitizeDelegate? DesensitizeCallback { get; private set; }public AlertCallbackDelegate? AlertCallback { get; private set; }#endregion// 私有构造:仅赋值、初始化,最后再启动线程private LogHelper(){LogRoot = Path.Combine(Directory.GetCurrentDirectory(), "Logs");Directory.CreateDirectory(LogRoot);// 颜色初始化_colorMap["DEBUG"] = ConsoleColor.Blue;_colorMap["INFO"] = ConsoleColor.Green;_colorMap["ERROR"] = ConsoleColor.Red;// Windows控制台编码兼容if (OperatingSystem.IsWindows() && !Console.IsOutputRedirected){try{Console.OutputEncoding = Encoding.UTF8;}catch{EnableConsole = true;}}// 【关键点】所有变量初始化完毕,最后再启动后台写入线程StartBackgroundWriter();}#endregion#region 回调注册/// <summary>/// 注册日志脱敏回调/// </summary>public void RegisterDesensitize(DesensitizeDelegate func){DesensitizeCallback = func;}/// <summary>/// 注册告警回调/// </summary>public void RegisterAlertCallback(AlertCallbackDelegate func){AlertCallback = func;}/// <summary>/// 触发告警/// </summary>private void TriggerAlert(string eventType, string msg, Dictionary<string, object> ext){if (AlertCallback == null) return;try{AlertCallback.Invoke(eventType, msg, ext);}catch{}}#endregion#region 对外配置APIpublic void SetLogLevel(LogLevel level) => MinLogLevel = level;public void SwitchJsonFormat(bool enable) => EnableJsonFormat = enable;public void SwitchConsole(bool enable) => EnableConsole = enable;/// <summary>/// 设置单级别采样率 0~1/// </summary>public void SetSamplingRate(string level, double rate){rate = Math.Clamp(rate, 0, 1);string lv = level.ToLower();if (_levelSamplingRate.ContainsKey(lv))_levelSamplingRate[lv] = rate;}/// <summary>/// 判断本条日志是否采样放行/// </summary>private bool NeedSample(string level){string lv = level.ToLower();double lvRate = _levelSamplingRate.TryGetValue(lv, out var val) ? val : 1;double final = GlobalSamplingRate * lvRate;if (final >= 1) return true;return _random.NextDouble() <= final;}#endregion#region 工具静态/私有方法/// <summary>/// 模块名清洗/// </summary>private string SafeModuleName(string module){if (string.IsNullOrWhiteSpace(module)) return DefaultModule;return Regex.Replace(module.Trim(), @"[^\w\-]", "_");}private string GetModuleLogDir(string module){string mod = SafeModuleName(module);string dir = Path.Combine(LogRoot, mod);Directory.CreateDirectory(dir);return dir;}private double GetDirTotalMb(string folder, string? filterPrefix = null){if (!Directory.Exists(folder)) return 0;long totalBytes = 0;var files = Directory.GetFiles(folder, "*.*", SearchOption.TopDirectoryOnly);foreach (var file in files){string name = Path.GetFileName(file);if (!name.EndsWith(".log") && !name.EndsWith(".zip")) continue;if (filterPrefix != null && !name.StartsWith(filterPrefix + "_")) continue;totalBytes += new FileInfo(file).Length;}return totalBytes / 1024.0 / 1024.0;}private List<string> GetSortedLogFiles(string folder, string? filterPrefix){var list = new List<(long mtime, string path)>();if (!Directory.Exists(folder)) return new List<string>();foreach (var file in Directory.GetFiles(folder)){string name = Path.GetFileName(file);if (!name.EndsWith(".log") && !name.EndsWith(".zip")) continue;if (filterPrefix != null && !name.StartsWith(filterPrefix + "_")) continue;list.Add((new FileInfo(file).LastWriteTimeUtc.Ticks, file));}return list.OrderBy(x => x.mtime).Select(x => x.path).ToList();}private bool TryFreeDiskSpace(string? level){while (true){double usage, limit;if (!string.IsNullOrEmpty(level)){usage = GetDirTotalMb(LogRoot, level.ToLower());limit = _levelDiskLimit[level.ToLower()];if (usage < limit) return true;}else{usage = GetDirTotalMb(LogRoot);limit = MaxDiskTotalMb;if (usage < limit) return true;}var files = GetSortedLogFiles(LogRoot, level?.ToLower());if (files.Count == 0) return false;bool deleted = false;foreach (var fp in files.Where(x => x.EndsWith(".zip"))){try{File.Delete(fp);Console.WriteLine($"[LogHelper CLEAN] 删除老旧压缩包:{Path.GetFileName(fp)}");deleted = true;break;}catch { }}if (deleted) continue;foreach (var fp in files.Where(x => x.EndsWith(".log"))){try{File.Delete(fp);Console.WriteLine($"[LogHelper CLEAN] 删除老旧日志:{Path.GetFileName(fp)}");deleted = true;break;}catch { }}if (!deleted) return false;}}private bool CheckDiskLimit(string level){long nowTs = DateTimeOffset.UtcNow.ToUnixTimeSeconds();long last = Interlocked.Read(ref _lastDiskCheckTs);if (nowTs - last < DiskCheckIntervalSec) return false;Interlocked.Exchange(ref _lastDiskCheckTs, nowTs);string lv = level.ToLower();double lvUsage = GetDirTotalMb(LogRoot, lv);double lvLimit = _levelDiskLimit[lv];double totalUsage = GetDirTotalMb(LogRoot);bool over = false;if (lvUsage >= lvLimit){var ext = new Dictionary<string, object>(){{"level", level}, {"usage_mb", lvUsage}, {"limit_mb", lvLimit}, {"total_usage", totalUsage}};TriggerAlert(AlertEvent.DiskOverLimit, $"日志【{level}】分级磁盘占用超限({lvUsage:F2}MB/{lvLimit:F2}MB),开始清理", ext);if (!TryFreeDiskSpace(lv)) over = true;}else if (totalUsage >= MaxDiskTotalMb){var ext = new Dictionary<string, object>(){{"usage_mb", totalUsage}, {"limit_mb", MaxDiskTotalMb}};TriggerAlert(AlertEvent.DiskOverLimit, $"日志总磁盘占用超限({totalUsage:F2}MB/{MaxDiskTotalMb:F2}MB),开始清理", ext);if (!TryFreeDiskSpace(null)) over = true;}if (over)Console.WriteLine($"[LogHelper FATAL] 磁盘清理后依旧超限,暂停持久化【{level}】日志");return over;}private void CleanExpireLogs(){DateTime expire = DateTime.Now.AddDays(-KeepDays);foreach (var file in Directory.GetFiles(LogRoot, "*.*", SearchOption.AllDirectories)){string name = Path.GetFileName(file);if (!name.EndsWith(".log") && !name.EndsWith(".zip")) continue;var fi = new FileInfo(file);if (fi.LastWriteTime < expire){try { File.Delete(file); } catch { }}}}private string GetLogPath(string level, string module){string dir = GetModuleLogDir(module);string date = DateTime.Now.ToString("yyyyMMdd");return Path.Combine(dir, $"{level.ToLower()}_{date}.log");}private void SplitLogFile(string logPath){lock (_fileLock){if (!File.Exists(logPath)) return;var fi = new FileInfo(logPath);long maxByte = MaxFileSizeMb * 1024 * 1024;if (fi.Length < maxByte) return;string basePath = Path.ChangeExtension(logPath, null);string bakPath = $"{basePath}_{DateTime.Now:HHmmss}.log";File.Move(logPath, bakPath);if (CompressBackupLog)_ = Task.Run(() => CompressFile(bakPath));}}private void CompressFile(string srcPath){if (!File.Exists(srcPath)) return;string zipPath = Path.ChangeExtension(srcPath, ".zip");try{using var fs = new FileStream(zipPath, FileMode.Create);using var zip = new ZipArchive(fs, ZipArchiveMode.Create);var entry = zip.CreateEntry(Path.GetFileName(srcPath));using var entryStream = entry.Open();using var srcStream = File.OpenRead(srcPath);srcStream.CopyTo(entryStream);File.Delete(srcPath);}catch { }}private static (string fileName, string funcName, int line) GetCallerInfo(int skipFrames){var stack = new StackTrace(skipFrames, true);var frame = stack.GetFrame(0);if (frame == null) return ("unknown", "unknown", 0);string file = Path.GetFileName(frame.GetFileName() ?? "unknown");string func = frame.GetMethod()?.Name ?? "unknown";int line = frame.GetFileLineNumber();return (file, func, line);}private static string GetThreadName(){string name = Thread.CurrentThread.Name;return string.IsNullOrWhiteSpace(name) ? $"Thread_{Thread.CurrentThread.ManagedThreadId}" : name;}private (string text, Dictionary<string, object>? json) BuildLogData(string level, string module, string msg, string stack){string modSafe = SafeModuleName(module);if (DesensitizeCallback != null){try{msg = DesensitizeCallback.Invoke(level, modSafe, msg);}catch { }}var now = DateTime.Now;string timeStr = now.ToString(TimeFormat);double timestamp = now.Ticks / 10000000.0;string threadName = GetThreadName();var (file, func, lineNo) = GetCallerInfo(4);if (EnableJsonFormat){var jsonObj = new Dictionary<string, object>(){["time"] = timeStr,["timestamp"] = timestamp,["level"] = level,["module"] = modSafe,["pid"] = _pid,["thread"] = threadName,["file"] = file,["function"] = func,["line"] = lineNo,["message"] = msg,["stack_trace"] = stack};return (string.Empty, jsonObj);}StringBuilder sb = new();sb.Append($"[{timeStr}] [{level}] [MODULE:{modSafe}] [PID:{_pid}] [T:{threadName}] [{func}·{lineNo}行·{file}] {msg}");if (!string.IsNullOrEmpty(stack))sb.AppendLine().Append(stack);sb.AppendLine();return (sb.ToString(), null);}#endregion#region 队列、写入逻辑(修复buffer空引用)private void EnqueueLog(string level, string module, string msg, string stack){if (level == "DEBUG" && MinLogLevel < LogLevel.Debug) return;if (level == "INFO" && MinLogLevel < LogLevel.Info) return;if (level == "ERROR" && MinLogLevel < LogLevel.Error) return;if (!NeedSample(level)) return;var (text, json) = BuildLogData(level, module, msg, stack);if (EnableConsole){string consoleStr;if (json != null){consoleStr = JsonSerializer.Serialize(json, new JsonSerializerOptions { WriteIndented = true });}else{consoleStr = text.TrimEnd('\n', '\r');}if (_colorMap.TryGetValue(level, out var color)){Console.ForegroundColor = color;Console.WriteLine(consoleStr);Console.ResetColor();}else{Console.WriteLine(consoleStr);}}var item = new LogItem(){Level = level.ToLower(),Module = module,TextData = text,JsonData = json,StackTrace = stack};if (_logQueue.Count < QueueMaxSize)_logQueue.Enqueue(item);elseConsole.WriteLine("[LogHelper WARN] 队列已满,丢弃持久化日志");}private void StartBackgroundWriter(){_writeTask = Task.Run(async () =>{// 【修复】循环内每次直接初始化字典,杜绝nullDictionary<string, List<LogItem>> buffer = new Dictionary<string, List<LogItem>>();var ticker = new PeriodicTimer(TimeSpan.FromMilliseconds(800));while (!_cts.Token.IsCancellationRequested){try{while (_logQueue.TryDequeue(out var item)){string key = $"{item.Level}|{item.Module}";// 安全判断,不会空引用if (!buffer.ContainsKey(key))buffer[key] = new List<LogItem>();buffer[key].Add(item);if (buffer[key].Count >= BatchWriteCount)FlushBuffer(buffer, key);}if (await ticker.WaitForNextTickAsync(_cts.Token))FlushAllBuffer(buffer);}catch{}}FlushAllBuffer(buffer);}, _cts.Token);}private void FlushBuffer(Dictionary<string, List<LogItem>> buffer, string key){if (!buffer.TryGetValue(key, out var items) || items.Count == 0) return;string[] parts = key.Split('|');string level = parts[0];string module = parts[1];if (CheckDiskLimit(level)){buffer.Remove(key);return;}string logPath = GetLogPath(level, module);SplitLogFile(logPath);StringBuilder writeSb = new StringBuilder();foreach (var it in items){if (it.JsonData != null){string jsonLine = JsonSerializer.Serialize(it.JsonData);writeSb.AppendLine(jsonLine);}else{writeSb.Append(it.TextData);}}try{File.AppendAllText(logPath, writeSb.ToString(), Encoding.UTF8);}catch (Exception ex){Console.WriteLine($"[LogHelper] 写入失败 level:{level} module:{module} err:{ex.Message}");}buffer.Remove(key);DateTime today = DateTime.Now.Date;if (_lastCleanDay != today){CleanExpireLogs();_lastCleanDay = today;}}private void FlushAllBuffer(Dictionary<string, List<LogItem>> buffer){var keys = buffer.Keys.ToList();foreach (var k in keys)FlushBuffer(buffer, k);}#endregion#region 对外日志快捷方法public void Debug(string msg) => EnqueueLog("DEBUG", DefaultModule, msg, string.Empty);public void Info(string msg) => EnqueueLog("INFO", DefaultModule, msg, string.Empty);public void Error(string msg, Exception? ex = null){string stack = ex == null ? string.Empty : ex.ToString();EnqueueLog("ERROR", DefaultModule, msg, stack);}public void DebugMod(string module, string msg) => EnqueueLog("DEBUG", module, msg, string.Empty);public void InfoMod(string module, string msg) => EnqueueLog("INFO", module, msg, string.Empty);public void ErrorMod(string module, string msg, Exception? ex = null){string stack = ex == null ? string.Empty : ex.ToString();EnqueueLog("ERROR", module, msg, stack);}/// <summary>/// 优雅关闭/// </summary>public async Task ShutdownAsync(){_cts.Cancel();if (_writeTask != null)await _writeTask;}#endregion}/// <summary>/// 全局静态快捷入口/// </summary>public static class Log{public static void Debug(string msg) => LogHelper.Instance.Debug(msg);public static void Info(string msg) => LogHelper.Instance.Info(msg);public static void Error(string msg, Exception? ex = null) => LogHelper.Instance.Error(msg, ex);public static void DebugMod(string module, string msg) => LogHelper.Instance.DebugMod(module, msg);public static void InfoMod(string module, string msg) => LogHelper.Instance.InfoMod(module, msg);public static void ErrorMod(string module, string msg, Exception? ex = null) => LogHelper.Instance.ErrorMod(module, msg, ex);public static void RegisterDesensitize(DesensitizeDelegate func) => LogHelper.Instance.RegisterDesensitize(func);public static void RegisterAlertCallback(AlertCallbackDelegate func) => LogHelper.Instance.RegisterAlertCallback(func);public static void SetSamplingRate(string level, double rate) => LogHelper.Instance.SetSamplingRate(level, rate);public static void SwitchJsonFormat(bool enable) => LogHelper.Instance.SwitchJsonFormat(enable);public static void SwitchConsole(bool enable) => LogHelper.Instance.SwitchConsole(enable);public static Task ShutdownAsync() => LogHelper.Instance.ShutdownAsync();}
}

  

调用:

            // 脱敏注册Log.RegisterDesensitize((level, module, msg) =>{return Regex.Replace(msg, @"1[3-9]\d{9}", "1*******");});// 告警注册Log.RegisterAlertCallback((type, msg, ext) =>{Console.WriteLine($"\n【告警】{type}:{msg}");});Log.SetSamplingRate("debug", 0.2);Log.Debug("手机号13900001111测试脱敏日志");Log.DebugMod("Server", "手机号13900001111测试脱敏日志");Log.Info("系统启动成功");try{int i = 0;int k = 1 / i;}catch (Exception e){Log.Error("计算异常", e);}// 程序退出等待落地// await Log.ShutdownAsync();