Last active
December 14, 2025 05:36
-
-
Save CodingOctocat/fb69a08373291823bfb56bf6fc489061 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Collections.Concurrent; | |
| using System.IO; | |
| using System.Text.Encodings.Web; | |
| using System.Text.Json; | |
| using System.Text.Json.Serialization; | |
| using System.Threading; | |
| using System.Threading.Tasks; | |
| namespace Helpers; | |
| /// <summary> | |
| /// JSON 文件读写辅助类。支持并发写合并、重试和备份机制。 | |
| /// </summary> | |
| public static class JsonFileHelper | |
| { | |
| /// <summary> | |
| /// 文件写入状态机。 | |
| /// </summary> | |
| private sealed class WriteState : IDisposable | |
| { | |
| /// <summary> | |
| /// 用于序列化写入操作的信号量。 | |
| /// </summary> | |
| public readonly SemaphoreSlim Semaphore = new(1, 1); | |
| /// <summary> | |
| /// 用于保护状态机字段的锁对象。 | |
| /// </summary> | |
| public readonly Lock SyncRoot = new(); | |
| /// <summary> | |
| /// 是否有待写入的数据。 | |
| /// </summary> | |
| public bool HasPending; | |
| /// <summary> | |
| /// 最新待写入的数据快照。 | |
| /// </summary> | |
| public object? LatestData; | |
| private bool _disposed; | |
| public void Dispose() | |
| { | |
| if (_disposed) | |
| { | |
| return; | |
| } | |
| Semaphore.Dispose(); | |
| LatestData = null; | |
| _disposed = true; | |
| } | |
| } | |
| #region Configuration | |
| /// <summary> | |
| /// 默认重试次数。 | |
| /// </summary> | |
| public const int DefaultRetryCount = 3; | |
| /// <summary> | |
| /// 默认 JSON 序列化选项。 | |
| /// <para>- 使用 <see cref="JavaScriptEncoder.UnsafeRelaxedJsonEscaping"/> 以支持更多字符</para> | |
| /// <para>- 启用缩进以提高可读性</para> | |
| /// <para>- 属性名不区分大小写</para> | |
| /// <para>- 枚举使用字符串表示</para> | |
| /// </summary> | |
| public static readonly JsonSerializerOptions DefaultJsonSerializerOptions = new() { | |
| Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, | |
| PropertyNameCaseInsensitive = true, | |
| WriteIndented = true, | |
| Converters = { | |
| new JsonStringEnumConverter() | |
| } | |
| }; | |
| /// <summary> | |
| /// 默认指数退避时间间隔数组。 | |
| /// </summary> | |
| private static readonly TimeSpan[] _defaultBackoff = [ | |
| TimeSpan.FromMilliseconds(80), | |
| TimeSpan.FromMilliseconds(200), | |
| TimeSpan.FromMilliseconds(500) | |
| ]; | |
| /// <summary> | |
| /// 文件写入状态字典。key:文件全路径;value:写入状态机,用于实现写合并 (Write Merging) 和并发控制。 | |
| /// </summary> | |
| private static readonly ConcurrentDictionary<string, WriteState> _fileWriteStates = new(); | |
| #endregion Configuration | |
| #region Load & Save Methods | |
| /// <summary> | |
| /// 获取跨进程文件锁。 | |
| /// <paramref name="lockFilePath"></paramref> | |
| /// </summary> | |
| public static IDisposable AcquireFileLock(string lockFilePath) | |
| { | |
| // 保持 FileShare.None 以实现独占锁 | |
| return new FileStream(lockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); | |
| } | |
| /// <summary> | |
| /// 异步获取跨进程文件锁。 | |
| /// </summary> | |
| /// <param name="lockFilePath"></param> | |
| /// <returns></returns> | |
| public static async Task<IDisposable> AcquireFileLockAsync(string lockFilePath) | |
| { | |
| await Task.Yield(); | |
| var fs = new FileStream( | |
| lockFilePath, | |
| FileMode.OpenOrCreate, | |
| FileAccess.ReadWrite, | |
| FileShare.None, | |
| 4096, | |
| FileOptions.Asynchronous); | |
| return fs; | |
| } | |
| /// <summary> | |
| /// 同步加载。如果主文件损坏(JSON 异常或 IO 截断),会自动尝试从 .bak 恢复。 | |
| /// </summary> | |
| /// <typeparam name="T"></typeparam> | |
| /// <param name="path"></param> | |
| /// <param name="options"></param> | |
| /// <returns></returns> | |
| public static T? Load<T>(string path, T? defaultValue = default, JsonSerializerOptions? options = null) | |
| { | |
| return LoadAsync(path, defaultValue, options).GetAwaiter().GetResult(); | |
| } | |
| /// <summary> | |
| /// 异步加载。如果主文件损坏(JSON 异常或 IO 中断),会自动尝试从 .bak 恢复。 | |
| /// </summary> | |
| /// <typeparam name="T"></typeparam> | |
| /// <param name="path"></param> | |
| /// <param name="defaultValue"></param> | |
| /// <param name="options"></param> | |
| /// <param name="cancellationToken"></param> | |
| /// <returns></returns> | |
| public static async Task<T?> LoadAsync<T>( | |
| string path, | |
| T? defaultValue = default, | |
| JsonSerializerOptions? options = null, | |
| CancellationToken cancellationToken = default) | |
| { | |
| string fullPath = Path.GetFullPath(path); | |
| options ??= DefaultJsonSerializerOptions; | |
| async Task<T?> TryLoadAsync() | |
| { | |
| if (File.Exists(fullPath)) | |
| { | |
| await using var stream = new FileStream( | |
| fullPath, | |
| FileMode.Open, | |
| FileAccess.Read, | |
| FileShare.Read, | |
| 4096, | |
| FileOptions.Asynchronous); | |
| return await JsonSerializer.DeserializeAsync<T>(stream, options, cancellationToken).ConfigureAwait(false); | |
| } | |
| // 文件不存在或读取异常都会跳到备份 | |
| return await TryRecoverFromBackupAsync<T>(fullPath, options, cancellationToken).ConfigureAwait(false); | |
| } | |
| try | |
| { | |
| return await TryLoadAsync().ConfigureAwait(false); | |
| } | |
| catch | |
| { | |
| return defaultValue; | |
| } | |
| } | |
| /// <summary> | |
| /// 异步保存。支持并发写合并、重试和备份机制。 | |
| /// </summary> | |
| /// <typeparam name="T"></typeparam> | |
| /// <param name="path"></param> | |
| /// <param name="data"></param> | |
| /// <param name="options"></param> | |
| /// <param name="retryCount"></param> | |
| /// <param name="backup"></param> | |
| /// <param name="cancellationToken"></param> | |
| /// <returns></returns> | |
| public static async Task SaveAsync<T>( | |
| string path, | |
| T? data, | |
| JsonSerializerOptions? options = null, | |
| int retryCount = DefaultRetryCount, | |
| bool backup = true, | |
| CancellationToken cancellationToken = default) | |
| { | |
| string fullPath = Path.GetFullPath(path); | |
| // 获取或创建该文件的写入状态机 | |
| var state = _fileWriteStates.GetOrAdd(fullPath, _ => new WriteState()); | |
| // 1、快速同步块:更新待写入的数据快照 | |
| lock (state.SyncRoot) | |
| { | |
| state.LatestData = data; | |
| state.HasPending = true; | |
| } | |
| // 2、异步等待信号量:序列化写入操作 | |
| // 如果当前有其他线程正在写入,这里会异步等待 | |
| await state.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); | |
| try | |
| { | |
| // 3、写合并循环 (Write Merging Loop) | |
| // 如果存在挂起的数据,则持续更新写入状态机 | |
| while (true) | |
| { | |
| object? currentData; | |
| lock (state.SyncRoot) | |
| { | |
| // 任务队列已清空 | |
| if (!state.HasPending) | |
| { | |
| break; | |
| } | |
| // 取出最新数据,重置标记 | |
| currentData = state.LatestData; | |
| state.HasPending = false; | |
| } | |
| // 执行实际的磁盘写入(包含重试逻辑和备份操作) | |
| await WriteToDiskAsync( | |
| fullPath, | |
| currentData, | |
| options, | |
| retryCount, | |
| backup, | |
| cancellationToken).ConfigureAwait(false); | |
| } | |
| } | |
| finally | |
| { | |
| try | |
| { | |
| state.Semaphore.Release(); | |
| } | |
| catch (ObjectDisposedException) | |
| { | |
| // NOP. | |
| } | |
| } | |
| } | |
| #endregion Load & Save Methods | |
| #region Cleanup | |
| /// <summary> | |
| /// 清理所有写入状态机,释放所有资源。 | |
| /// </summary> | |
| public static void ClearAllWriteStates() | |
| { | |
| foreach (var kvp in _fileWriteStates) | |
| { | |
| if (_fileWriteStates.TryRemove(kvp.Key, out var state)) | |
| { | |
| state.Dispose(); | |
| } | |
| } | |
| } | |
| /// <summary> | |
| /// 清理指定文件的写入状态机,释放相关资源。 | |
| /// </summary> | |
| /// <param name="path"></param> | |
| public static void ClearWriteState(string path) | |
| { | |
| string fullPath = Path.GetFullPath(path); | |
| if (_fileWriteStates.TryRemove(fullPath, out var state)) | |
| { | |
| state.Dispose(); | |
| } | |
| } | |
| #endregion Cleanup | |
| #region Helpers | |
| private static bool IsTransient(Exception ex) | |
| { | |
| // 这里可以根据需要添加更多瞬态异常,如 ERROR_SHARING_VIOLATION (HResult) | |
| return ex is IOException or UnauthorizedAccessException; | |
| } | |
| private static void TryDelete(string path) | |
| { | |
| try | |
| { | |
| if (File.Exists(path)) | |
| { | |
| File.Delete(path); | |
| } | |
| } | |
| catch | |
| { | |
| // NOP. | |
| } | |
| } | |
| private static async Task<T?> TryRecoverFromBackupAsync<T>(string path, JsonSerializerOptions options, CancellationToken cancellationToken) | |
| { | |
| string bak = path + ".bak"; | |
| if (!File.Exists(bak)) | |
| { | |
| // 实在没招了,抛出异常让上层处理 | |
| throw new IOException($"Main file is corrupted and no backup is available: {path}"); | |
| } | |
| try | |
| { | |
| // 尝试恢复主文件 | |
| File.Copy(bak, path, overwrite: true); | |
| TrySetHidden(path, false); | |
| } | |
| catch | |
| { | |
| // 即使 Copy 失败(如权限问题),也尝试直接读 bak 返回数据 | |
| } | |
| await using var stream = new FileStream( | |
| bak, | |
| FileMode.Open, | |
| FileAccess.Read, | |
| FileShare.Read, | |
| 4096, | |
| FileOptions.Asynchronous); | |
| return await JsonSerializer.DeserializeAsync<T>(stream, options, cancellationToken).ConfigureAwait(false); | |
| } | |
| private static void TrySetHidden(string? path, bool hidden) | |
| { | |
| if (String.IsNullOrEmpty(path) || !File.Exists(path)) | |
| { | |
| return; | |
| } | |
| try | |
| { | |
| var attrs = File.GetAttributes(path); | |
| if (hidden) | |
| { | |
| attrs |= FileAttributes.Hidden; | |
| } | |
| else | |
| { | |
| attrs &= ~FileAttributes.Hidden; | |
| } | |
| File.SetAttributes(path, attrs); | |
| } | |
| catch | |
| { | |
| // NOP | |
| } | |
| } | |
| private static async Task WriteTempFileAsync(string tempPath, byte[] bytes, CancellationToken cancellationToken) | |
| { | |
| // FileOptions.WriteThrough: 指示 OS 应该跳过缓存直接写盘,减少断电数据丢失概率 | |
| await using var fs = new FileStream( | |
| tempPath, | |
| FileMode.CreateNew, | |
| FileAccess.Write, | |
| FileShare.None, | |
| 4096, | |
| FileOptions.Asynchronous | FileOptions.WriteThrough); | |
| await fs.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); | |
| // 再次 Flush 确保万无一失 | |
| await fs.FlushAsync(cancellationToken).ConfigureAwait(false); | |
| } | |
| private static async Task WriteToDiskAsync<T>( | |
| string fullPath, | |
| T data, | |
| JsonSerializerOptions? options, | |
| int retryCount, | |
| bool backup, | |
| CancellationToken cancellationToken) | |
| { | |
| options ??= DefaultJsonSerializerOptions; | |
| string dir = Path.GetDirectoryName(fullPath) ?? "."; | |
| Directory.CreateDirectory(dir); | |
| string? backupPath = backup ? fullPath + ".bak" : null; | |
| int attempt = 0; | |
| while (true) | |
| { | |
| cancellationToken.ThrowIfCancellationRequested(); | |
| string tempPath = Path.Combine(dir, $"{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp"); | |
| try | |
| { | |
| // 序列化 | |
| byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data, options); | |
| // 写入临时文件 | |
| await WriteTempFileAsync(tempPath, jsonBytes, cancellationToken).ConfigureAwait(false); | |
| // 原子替换 | |
| if (File.Exists(fullPath)) | |
| { | |
| File.Replace(tempPath, fullPath, backupPath, true); | |
| } | |
| else | |
| { | |
| // 没有主文件时,直接移动,不需要备份 | |
| //if (backup) | |
| //{ | |
| // File.Copy(tempPath, backupPath!, overwrite: true); | |
| //} | |
| File.Move(tempPath, fullPath); | |
| } | |
| TryDelete(tempPath); | |
| if (backup) | |
| { | |
| TrySetHidden(backupPath, true); | |
| } | |
| return; | |
| } | |
| catch (Exception ex) when (IsTransient(ex)) | |
| { | |
| TryDelete(tempPath); | |
| attempt++; | |
| if (attempt > retryCount) | |
| { | |
| throw; | |
| } | |
| var backoff = attempt - 1 < _defaultBackoff.Length | |
| ? _defaultBackoff[attempt - 1] | |
| : _defaultBackoff[^1]; | |
| await Task.Delay(backoff, cancellationToken).ConfigureAwait(false); | |
| } | |
| catch | |
| { | |
| TryDelete(tempPath); | |
| throw; | |
| } | |
| } | |
| } | |
| #endregion Helpers | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment