Skip to content

Instantly share code, notes, and snippets.

@CodingOctocat
Last active December 14, 2025 05:36
Show Gist options
  • Select an option

  • Save CodingOctocat/fb69a08373291823bfb56bf6fc489061 to your computer and use it in GitHub Desktop.

Select an option

Save CodingOctocat/fb69a08373291823bfb56bf6fc489061 to your computer and use it in GitHub Desktop.
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