初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
|
||||
namespace PCL.Core.IO;
|
||||
|
||||
public class ByteStream(Stream stream)
|
||||
{
|
||||
private static readonly string[] _Units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
|
||||
public long Length => stream.Length;
|
||||
|
||||
public string GetReadableLength()
|
||||
{
|
||||
return GetReadableLength(Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 格式化大小
|
||||
/// </summary>
|
||||
/// <param name="length">字节</param>
|
||||
/// <param name="startUnit">开始单位</param>
|
||||
/// <param name="provider">格式化区域提供程序,默认使用 <see cref="CultureInfo.InvariantCulture" /></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException"></exception>
|
||||
public static string GetReadableLength(long length, int startUnit = 0, IFormatProvider? provider = null)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(startUnit, _Units.Length);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(startUnit, 0);
|
||||
|
||||
var isNegative = length < 0;
|
||||
decimal absBytes = isNegative ? -length : length;
|
||||
|
||||
if (absBytes == 0)
|
||||
return "0 B";
|
||||
|
||||
var unitIndex = startUnit;
|
||||
var value = absBytes;
|
||||
|
||||
while (value >= 1024 && unitIndex < _Units.Length - 1)
|
||||
{
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
if (unitIndex >= _Units.Length)
|
||||
throw new ArgumentOutOfRangeException(nameof(length),
|
||||
"Value too large for predefined units.");
|
||||
|
||||
var sign = isNegative ? "-" : "";
|
||||
return $"{sign}{value.ToString("0.##", provider ?? CultureInfo.InvariantCulture)} {_Units[unitIndex]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
using System.Security.AccessControl;
|
||||
|
||||
namespace PCL.Core.IO;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Logging;
|
||||
|
||||
public static class Directories {
|
||||
/// <summary>
|
||||
/// 异步检查是否拥有对指定文件夹的读写权限。
|
||||
/// 如果文件夹不存在或没有权限,返回 false,不修改文件系统。
|
||||
/// </summary>
|
||||
/// <param name="path">要检查的文件夹路径。</param>
|
||||
/// <param name="cancellationToken">取消操作的令牌。</param>
|
||||
/// <returns>如果拥有读写权限且文件夹存在,返回 true;否则返回 false。</returns>
|
||||
public static async Task<bool> CheckPermissionAsync(string? path, CancellationToken cancellationToken = default) {
|
||||
try {
|
||||
if (string.IsNullOrWhiteSpace(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 排除特殊系统文件夹
|
||||
if (IsSystemProtectedFolder(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查文件夹是否存在
|
||||
if (!Directory.Exists(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查目录访问权限
|
||||
var directoryInfo = new DirectoryInfo(path);
|
||||
var security = await Task.Run(() => directoryInfo.GetAccessControl(), cancellationToken);
|
||||
var rules = security.GetAccessRules(true, true, typeof(System.Security.Principal.NTAccount));
|
||||
|
||||
// 检查当前用户是否有读写权限,优先考虑拒绝规则
|
||||
var currentUser = System.Security.Principal.WindowsIdentity.GetCurrent();
|
||||
var principal = new System.Security.Principal.WindowsPrincipal(currentUser);
|
||||
|
||||
var isDenied = false;
|
||||
var isAllowed = false;
|
||||
|
||||
foreach (FileSystemAccessRule rule in rules) {
|
||||
if (!rule.FileSystemRights.HasFlag(FileSystemRights.Write))
|
||||
continue;
|
||||
|
||||
// 检查规则是否适用于当前用户或其组
|
||||
if (principal.IsInRole(rule.IdentityReference.Value)) {
|
||||
if (rule.AccessControlType == AccessControlType.Deny) {
|
||||
isDenied = true;
|
||||
break; // 拒绝优先,直接返回
|
||||
}
|
||||
if (rule.AccessControlType == AccessControlType.Allow) {
|
||||
isAllowed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDenied || !isAllowed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 尝试枚举目录内容以确认实际访问能力
|
||||
await Task.Run(() => Directory.EnumerateFiles(path, "*", SearchOption.TopDirectoryOnly).Any(), cancellationToken);
|
||||
return true;
|
||||
} catch (OperationCanceledException) {
|
||||
LogWrapper.Warn("权限检查被取消");
|
||||
return false;
|
||||
} catch (Exception ex) {
|
||||
LogWrapper.Warn(ex, $"没有对文件夹 {path} 的权限,请尝试以管理员权限运行。");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步检查文件夹权限,若无权限或文件夹不存在则抛出异常。
|
||||
/// 不修改文件系统。
|
||||
/// </summary>
|
||||
/// <param name="path">要检查的文件夹路径。</param>
|
||||
/// <param name="cancellationToken">取消操作的令牌。</param>
|
||||
/// <exception cref="ArgumentNullException">路径为空或只包含空白字符。</exception>
|
||||
/// <exception cref="DirectoryNotFoundException">文件夹不存在。</exception>
|
||||
/// <exception cref="UnauthorizedAccessException">无访问权限。</exception>
|
||||
/// <exception cref="OperationCanceledException">操作被取消。</exception>
|
||||
public static async Task CheckPermissionWithExceptionAsync(string? path, CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(path)) {
|
||||
throw new ArgumentNullException(nameof(path), "文件夹路径不能为空!");
|
||||
}
|
||||
|
||||
if (IsSystemProtectedFolder(path)) {
|
||||
throw new UnauthorizedAccessException($"无法访问受保护的系统文件夹:{path}");
|
||||
}
|
||||
|
||||
if (!Directory.Exists(path)) {
|
||||
throw new DirectoryNotFoundException($"文件夹不存在:{path}");
|
||||
}
|
||||
|
||||
try {
|
||||
var directoryInfo = new DirectoryInfo(path);
|
||||
var security = await Task.Run(() => directoryInfo.GetAccessControl(), cancellationToken);
|
||||
var rules = security.GetAccessRules(true, true, typeof(System.Security.Principal.NTAccount));
|
||||
|
||||
var hasAccess = rules.Cast<FileSystemAccessRule>()
|
||||
.Any(rule => rule.FileSystemRights.HasFlag(FileSystemRights.Write) &&
|
||||
rule.AccessControlType == AccessControlType.Allow);
|
||||
|
||||
if (!hasAccess) {
|
||||
throw new UnauthorizedAccessException($"没有对文件夹 {path} 的写权限");
|
||||
}
|
||||
|
||||
// 确认实际访问能力
|
||||
await Task.Run(() => Directory.EnumerateFiles(path, "*", SearchOption.TopDirectoryOnly).Any(), cancellationToken);
|
||||
} catch (UnauthorizedAccessException) {
|
||||
throw;
|
||||
} catch (OperationCanceledException) {
|
||||
throw;
|
||||
} catch (Exception ex) {
|
||||
throw new UnauthorizedAccessException($"无法访问文件夹 {path}:{ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否为受保护的系统文件夹。
|
||||
/// </summary>
|
||||
private static bool IsSystemProtectedFolder(string path) {
|
||||
return path.EndsWith(":\\System Volume Information", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(":\\$RECYCLE.BIN", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步删除文件夹及其内容,返回删除的文件数。支持忽略错误。
|
||||
/// </summary>
|
||||
/// <param name="path">要删除的文件夹路径。</param>
|
||||
/// <param name="ignoreIssue">是否忽略删除过程中的错误。</param>
|
||||
/// <param name="cancellationToken">取消操作的令牌。</param>
|
||||
/// <returns>成功删除的文件数。</returns>
|
||||
/// <exception cref="OperationCanceledException">操作被取消。</exception>
|
||||
public static async Task<int> DeleteDirectoryAsync(string? path, bool ignoreIssue = false, CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var deletedCount = 0;
|
||||
|
||||
try {
|
||||
// 枚举文件,延迟加载以提高性能
|
||||
foreach (var filePath in Directory.EnumerateFiles(path)) {
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
await FileDeleteAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
deletedCount++;
|
||||
break;
|
||||
} catch (Exception ex) when (attempt == 0) {
|
||||
LogWrapper.Error(ex, $"删除文件失败,将在 0.3s 后重试({filePath})");
|
||||
await Task.Delay(300, cancellationToken).ConfigureAwait(false);
|
||||
} catch (Exception ex) {
|
||||
if (ignoreIssue) {
|
||||
LogWrapper.Error(ex, "删除单个文件可忽略地失败");
|
||||
} else {
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 递归删除子目录
|
||||
foreach (var subDir in Directory.EnumerateDirectories(path)) {
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
deletedCount += await DeleteDirectoryAsync(subDir, ignoreIssue, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 删除空目录
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
Directory.Delete(path, true);
|
||||
break;
|
||||
} catch (Exception ex) when (attempt == 0) {
|
||||
LogWrapper.Error(ex, $"删除文件夹失败,将在 0.3s 后重试({path})");
|
||||
await Task.Delay(300, cancellationToken).ConfigureAwait(false);
|
||||
} catch (Exception ex) {
|
||||
if (ignoreIssue) {
|
||||
LogWrapper.Error(ex, "删除单个文件夹可忽略地失败");
|
||||
} else {
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (DirectoryNotFoundException ex) {
|
||||
// 处理疑似符号链接的情况
|
||||
LogWrapper.Error(ex, $"疑似为孤立符号链接,尝试直接删除({path})", "Developer");
|
||||
try {
|
||||
Directory.Delete(path);
|
||||
} catch (Exception deleteEx) {
|
||||
if (!ignoreIssue) {
|
||||
throw;
|
||||
}
|
||||
LogWrapper.Error(deleteEx, $"删除符号链接文件夹失败({path})");
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步复制文件夹及其内容,失败时抛出异常。
|
||||
/// </summary>
|
||||
/// <param name="fromPath">源文件夹路径。</param>
|
||||
/// <param name="toPath">目标文件夹路径。</param>
|
||||
/// <param name="progressIncrementHandler">进度更新回调,接收 0 到 1 的进度值。</param>
|
||||
/// <param name="cancellationToken">取消操作的令牌。</param>
|
||||
/// <exception cref="ArgumentNullException">源或目标文件夹路径为空。</exception>
|
||||
/// <exception cref="OperationCanceledException">操作被取消。</exception>
|
||||
public static async Task CopyDirectoryAsync(string? fromPath, string? toPath, Action<double>? progressIncrementHandler = null, CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrEmpty(fromPath)) {
|
||||
throw new ArgumentNullException(nameof(fromPath), "源文件夹路径为空");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(toPath)) {
|
||||
throw new ArgumentNullException(nameof(toPath), "目标文件夹路径为空");
|
||||
}
|
||||
|
||||
// 规范化路径
|
||||
fromPath = Path.GetFullPath(fromPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
toPath = Path.GetFullPath(toPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
|
||||
var allFiles = (await EnumerateFilesAsync(fromPath, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
var totalFiles = allFiles.Count;
|
||||
long copiedFiles = 0;
|
||||
|
||||
foreach (var file in allFiles) {
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var relativePath = file.FullName[fromPath.Length..];
|
||||
var destFilePath = Path.Combine(toPath, relativePath);
|
||||
|
||||
// 确保目标目录存在
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destFilePath)!);
|
||||
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
await FileCopyAsync(file.FullName, destFilePath, overwrite: true, cancellationToken).ConfigureAwait(false);
|
||||
copiedFiles++;
|
||||
progressIncrementHandler?.Invoke((double)copiedFiles / totalFiles);
|
||||
break;
|
||||
} catch (Exception ex) when (attempt == 0) {
|
||||
LogWrapper.Error(ex, $"复制文件失败,将在 0.3s 后重试({file.FullName} 到 {destFilePath})");
|
||||
await Task.Delay(300, cancellationToken).ConfigureAwait(false);
|
||||
} catch (Exception ex) {
|
||||
LogWrapper.Error(ex, $"复制文件失败({file.FullName} 到 {destFilePath})");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步遍历文件夹中的所有文件。
|
||||
/// </summary>
|
||||
/// <param name="directory">要遍历的文件夹路径。</param>
|
||||
/// <param name="cancellationToken">取消操作的令牌。</param>
|
||||
/// <returns>文件信息的枚举器。</returns>
|
||||
public static async Task<IEnumerable<FileInfo>> EnumerateFilesAsync(string? directory, CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) {
|
||||
throw new DirectoryNotFoundException($"目录不存在:{directory}");
|
||||
}
|
||||
|
||||
try {
|
||||
// DirectoryInfo.EnumerateFiles 是同步的,使用 Task.Run 包装
|
||||
return await Task.Run(() => new DirectoryInfo(directory).EnumerateFiles("*", SearchOption.AllDirectories).ToList(), cancellationToken).ConfigureAwait(false);
|
||||
} catch (OperationCanceledException) {
|
||||
LogWrapper.Warn("文件夹遍历被取消");
|
||||
return [];
|
||||
} catch (Exception ex) {
|
||||
LogWrapper.Error(ex, $"遍历文件夹失败({directory})");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助方法:异步打开 FileStream
|
||||
private static async Task<FileStream> FileStreamOpenAsync(string path, FileMode mode, FileAccess access, FileShare share, CancellationToken cancellationToken) {
|
||||
var fs = new FileStream(path, mode, access, share, bufferSize: 4096, useAsync: true);
|
||||
await Task.Yield(); // 确保异步上下文
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return fs;
|
||||
}
|
||||
|
||||
// 辅助方法:异步删除文件
|
||||
private static async Task FileDeleteAsync(string path, CancellationToken cancellationToken) {
|
||||
await Task.Run(() => File.Delete(path), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 辅助方法:异步复制文件
|
||||
private static async Task FileCopyAsync(string sourceFileName, string destFileName, bool overwrite, CancellationToken cancellationToken) {
|
||||
await using FileStream sourceStream = new(sourceFileName, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
|
||||
await using FileStream destStream = new(destFileName, overwrite ? FileMode.Create : FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, useAsync: true);
|
||||
await sourceStream.CopyToAsync(destStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Download;
|
||||
|
||||
/// <summary>
|
||||
/// 下载连接,负责与服务器进行通信。
|
||||
/// </summary>
|
||||
public interface IDlConnection
|
||||
{
|
||||
/// <summary>
|
||||
/// 开始连接,发起与服务器的通信。
|
||||
/// </summary>
|
||||
/// <param name="beginOffset">起始偏移,为 <c>0</c> 表示不使用分块</param>
|
||||
/// <returns>连接信息</returns>
|
||||
public Task<NDlConnectionInfo> StartAsync(long beginOffset);
|
||||
|
||||
/// <summary>
|
||||
/// 停止连接,同时停止服务器通信并释放资源。
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public Task StopAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定长度的数据,若无法继续读取则返回空数组。
|
||||
/// </summary>
|
||||
/// <param name="length">读取长度</param>
|
||||
/// <returns>字节数组形式的数据</returns>
|
||||
public Task<byte[]> ReadAsync(int length);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace PCL.Core.IO.Download;
|
||||
|
||||
/// <summary>
|
||||
/// 资源 ID 映射。
|
||||
/// </summary>
|
||||
/// <typeparam name="TMappingValue">映射目标类型</typeparam>
|
||||
public interface IDlResourceMapping<out TMappingValue>
|
||||
{
|
||||
public TMappingValue? Parse(string resId);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Download;
|
||||
|
||||
/// <summary>
|
||||
/// 下载写入器。
|
||||
/// </summary>
|
||||
public interface IDlWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否支持并行写入,即是否支持多次调用 <see cref="CreateStreamAsync"/>。
|
||||
/// </summary>
|
||||
public bool IsSupportParallel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建写入流。
|
||||
/// </summary>
|
||||
/// <returns>写入流</returns>
|
||||
public Task<Stream> CreateStreamAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 停止写入并释放资源。
|
||||
/// </summary>
|
||||
public Task StopAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 完成写入,用于执行某些并行操作的收尾工作 (例如合并文件)。
|
||||
/// </summary>
|
||||
public Task FinishAsync();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace PCL.Core.IO.Download;
|
||||
|
||||
/// <summary>
|
||||
/// 下载连接信息。
|
||||
/// </summary>
|
||||
/// <param name="Length">内容长度,单位为字节</param>
|
||||
/// <param name="BeginOffset">起始偏移</param>
|
||||
/// <param name="EndOffset">结束偏移</param>
|
||||
/// <param name="IsSupportSegment">是否支持分块</param>
|
||||
public record NDlConnectionInfo(
|
||||
long Length,
|
||||
long BeginOffset,
|
||||
long EndOffset,
|
||||
bool IsSupportSegment
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace PCL.Core.IO.Download;
|
||||
|
||||
/// <summary>
|
||||
/// 无泛型的下载器构建工厂。
|
||||
/// </summary>
|
||||
public abstract class NDlFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 新建连接。
|
||||
/// </summary>
|
||||
/// <param name="resId">资源 ID</param>
|
||||
/// <returns>下载连接</returns>
|
||||
public abstract IDlConnection? CreateConnection(string resId);
|
||||
|
||||
/// <summary>
|
||||
/// 新建写入器。
|
||||
/// </summary>
|
||||
/// <param name="resId">资源 ID</param>
|
||||
/// <returns>下载写入器</returns>
|
||||
public abstract IDlWriter? CreateWriter(string resId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下载器构建工厂。
|
||||
/// </summary>
|
||||
/// <typeparam name="TSourceArgument">下载源参数类型</typeparam>
|
||||
/// <typeparam name="TTargetArgument">写入目标参数类型</typeparam>
|
||||
public abstract class NDlFactory<TSourceArgument, TTargetArgument> : NDlFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 下载源映射。
|
||||
/// </summary>
|
||||
protected abstract IDlResourceMapping<TSourceArgument> SourceMapping { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 写入目标映射。
|
||||
/// </summary>
|
||||
protected abstract IDlResourceMapping<TTargetArgument> TargetMapping { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 新建连接。
|
||||
/// </summary>
|
||||
/// <param name="source">下载源参数</param>
|
||||
/// <returns>下载连接</returns>
|
||||
protected abstract IDlConnection CreateConnection(TSourceArgument source);
|
||||
|
||||
public override IDlConnection? CreateConnection(string resId)
|
||||
{
|
||||
var source = SourceMapping.Parse(resId);
|
||||
return (source is null) ? null : CreateConnection(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新建写入器。
|
||||
/// </summary>
|
||||
/// <param name="target">写入目标</param>
|
||||
/// <returns>下载写入器</returns>
|
||||
protected abstract IDlWriter CreateWriter(TTargetArgument target);
|
||||
|
||||
public override IDlWriter? CreateWriter(string resId)
|
||||
{
|
||||
var target = TargetMapping.Parse(resId);
|
||||
return (target is null) ? null : CreateWriter(target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace PCL.Core.IO.Download;
|
||||
|
||||
/// <summary>
|
||||
/// 下载源质量报告
|
||||
/// </summary>
|
||||
/// <param name="MaxSegmentCount">支持最大分块数量</param>
|
||||
/// <param name="RetryCount">重试计数</param>
|
||||
/// <param name="AverageSpeed">总体平均速度</param>
|
||||
public record NDlSourceReport(
|
||||
int MaxSegmentCount = 1,
|
||||
int RetryCount = 0,
|
||||
long AverageSpeed = -1
|
||||
);
|
||||
@@ -0,0 +1,948 @@
|
||||
using ICSharpCode.SharpZipLib.BZip2;
|
||||
using ICSharpCode.SharpZipLib.GZip;
|
||||
using ICSharpCode.SharpZipLib.Tar;
|
||||
using ICSharpCode.SharpZipLib.Zip;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.UI;
|
||||
using PCL.Core.Utils;
|
||||
using PCL.Core.Utils.Codecs;
|
||||
using PCL.Core.Utils.Exts;
|
||||
using PCL.Core.Utils.Hash;
|
||||
using PCL.Core.App.Localization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using PCL.Core.App;
|
||||
|
||||
namespace PCL.Core.IO;
|
||||
|
||||
public static class Files {
|
||||
public static readonly JsonSerializerOptions PrettierJsonOptions = new(JsonCompat.SerializerOptions) {
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 在指定路径创建一个指向目标文件的 .lnk 快捷方式。
|
||||
/// </summary>
|
||||
/// <param name="shortcut">要创建的快捷方式完整路径,建议以 ".lnk" 结尾</param>
|
||||
/// <param name="target">被指向的目标文件或可执行程序路径</param>
|
||||
/// <param name="arguments">启动时的命令行参数</param>
|
||||
/// <param name="workingDirectory">快捷方式的起始目录</param>
|
||||
/// <param name="description">快捷方式说明</param>
|
||||
/// <param name="icon">自定义图标,格式 "图标文件路径,索引"</param>
|
||||
// Partly generated by o4-mini-high (20250719)
|
||||
public static void CreateShortcut(
|
||||
string shortcut,
|
||||
string target,
|
||||
string? arguments = null,
|
||||
string? workingDirectory = null,
|
||||
string? description = null,
|
||||
string? icon = null) {
|
||||
if (string.IsNullOrWhiteSpace(shortcut))
|
||||
throw new ArgumentException("shortcutPath 不能为空", nameof(shortcut));
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
throw new ArgumentException("targetFilePath 不能为空", nameof(target));
|
||||
|
||||
// 确保目录存在
|
||||
var dir = Path.GetDirectoryName(shortcut);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
// 与 WshShell 交互
|
||||
var shellType = Type.GetTypeFromProgID("WScript.Shell", throwOnError: true)!;
|
||||
dynamic shell = Activator.CreateInstance(shellType)!;
|
||||
var link = shell.CreateShortcut(shortcut)!;
|
||||
|
||||
// 设置属性
|
||||
link.TargetPath = target;
|
||||
if (!string.IsNullOrEmpty(arguments)) link.Arguments = arguments;
|
||||
if (!string.IsNullOrEmpty(workingDirectory)) link.WorkingDirectory = workingDirectory;
|
||||
else link.WorkingDirectory = Path.GetDirectoryName(target) ?? Path.GetPathRoot(target);
|
||||
if (!string.IsNullOrEmpty(description)) link.Description = description;
|
||||
if (!string.IsNullOrEmpty(icon)) link.IconLocation = icon;
|
||||
|
||||
// 保存 .lnk 文件
|
||||
link.Save();
|
||||
}
|
||||
|
||||
public static bool ArePathsEqual(string path1, string path2) {
|
||||
var fullPath1 = Path.GetFullPath(path1).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var fullPath2 = Path.GetFullPath(path2).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return string.Equals(fullPath1, fullPath2, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static async Task<bool> ExportAsZipArchiveAsync(
|
||||
IEnumerable<string> sourceFiles,
|
||||
string dialogTitle,
|
||||
string defaultFileName,
|
||||
string fileFilter,
|
||||
string defaultDirectory,
|
||||
string tempDirPrefix,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var tempDirName = $"{tempDirPrefix}.tmp";
|
||||
var selectedPath = SystemDialogs.SelectSaveFile(dialogTitle, defaultFileName, fileFilter, defaultDirectory);
|
||||
|
||||
if (string.IsNullOrEmpty(selectedPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Directory.CreateDirectory(tempDirName);
|
||||
|
||||
if (File.Exists(selectedPath)) {
|
||||
File.Delete(selectedPath);
|
||||
LogWrapper.Info("Files", $"删除已有文件:{selectedPath}");
|
||||
}
|
||||
|
||||
await using var fileStream = new FileStream(selectedPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true);
|
||||
await using var zipStream = new ZipOutputStream(fileStream);
|
||||
foreach (var item in sourceFiles) {
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var itemFileName = Path.GetFileName(item);
|
||||
var tempPath = Path.Combine(tempDirName, itemFileName);
|
||||
|
||||
await CopyFileAsync(item, tempPath, cancellationToken);
|
||||
await using (var sourceStream = new FileStream(tempPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true)) {
|
||||
var entry = new ZipEntry(itemFileName);
|
||||
await zipStream.PutNextEntryAsync(entry, cancellationToken);
|
||||
await sourceStream.CopyToAsync(zipStream, cancellationToken);
|
||||
}
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
await zipStream.FinishAsync(cancellationToken);
|
||||
LogWrapper.Info("Files", $"导出 Zip 成功:{selectedPath}");
|
||||
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
LogWrapper.Warn(ex, "Log", "导出 Zip 失败");
|
||||
return false;
|
||||
} finally {
|
||||
if (Directory.Exists(tempDirName)) {
|
||||
Directory.Delete(tempDirName, true);
|
||||
LogWrapper.Debug("Log", $"清理临时文件夹:{tempDirName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region 异步文件操作
|
||||
|
||||
/// <summary>
|
||||
/// 复制文件,自动创建目标目录并覆盖已有文件。
|
||||
/// </summary>
|
||||
/// <param name="fromPath">源文件路径(完整或相对)</param>
|
||||
/// <param name="toPath">目标文件路径(完整或相对)</param>
|
||||
/// <param name="cancelToken">取消令牌</param>
|
||||
/// <exception cref="IOException">复制失败时抛出</exception>
|
||||
public static async Task CopyFileAsync(string fromPath, string toPath, CancellationToken cancelToken = default) {
|
||||
try {
|
||||
var fullFromPath = GetFullPath(fromPath);
|
||||
var fullToPath = GetFullPath(toPath);
|
||||
if (fullFromPath == fullToPath) return;
|
||||
|
||||
var directoryName = Path.GetDirectoryName(fullToPath);
|
||||
if (directoryName is null) {
|
||||
throw new InvalidOperationException("无法获取目标目录");
|
||||
}
|
||||
Directory.CreateDirectory(directoryName);
|
||||
|
||||
// 使用异步流复制
|
||||
const int bufferSize = 4096;
|
||||
await using var sourceStream = new FileStream(fullFromPath, FileMode.Open, FileAccess.Read,
|
||||
FileShare.ReadWrite, bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await using var destinationStream = new FileStream(fullToPath, FileMode.Create, FileAccess.Write,
|
||||
FileShare.Read, bufferSize, FileOptions.Asynchronous);
|
||||
await sourceStream.CopyToAsync(destinationStream, cancelToken);
|
||||
} catch (Exception ex) {
|
||||
throw new IOException($"复制文件出错:{fromPath} -> {toPath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task CopyDirectoryAsync(string sourceDir, string destDir, CancellationToken cancelToken = default) {
|
||||
Directory.CreateDirectory(destDir);
|
||||
|
||||
// 获取所有文件和子目录
|
||||
var files = Directory.GetFiles(sourceDir);
|
||||
var directories = Directory.GetDirectories(sourceDir);
|
||||
|
||||
// 限制并行度,防止资源耗尽
|
||||
var parallelOptions = new ParallelOptions {
|
||||
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount),
|
||||
CancellationToken = cancelToken
|
||||
};
|
||||
|
||||
// 并行复制文件
|
||||
await Parallel.ForEachAsync(files, parallelOptions, async (file, ct) => {
|
||||
var destFile = Path.Combine(destDir, Path.GetFileName(file));
|
||||
await CopyFileAsync(file, destFile, ct);
|
||||
});
|
||||
|
||||
// 并行处理子目录
|
||||
await Parallel.ForEachAsync(directories, parallelOptions, async (subDir, ct) => {
|
||||
var destSubDir = Path.Combine(destDir, Path.GetFileName(subDir));
|
||||
await CopyDirectoryAsync(subDir, destSubDir, ct);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取文件为字节数组,支持读取被占用的文件。
|
||||
/// </summary>
|
||||
/// <param name="filePath">文件路径(完整或相对)</param>
|
||||
/// <param name="cancelToken">取消令牌</param>
|
||||
/// <returns>文件内容的字节数组,失败时返回空数组</returns>
|
||||
public static async Task<byte[]> ReadAllBytesOrEmptyAsync(string filePath, CancellationToken cancelToken = default) {
|
||||
try {
|
||||
var fullPath = GetFullPath(filePath);
|
||||
if (File.Exists(fullPath)) {
|
||||
// 使用 ReadAllBytesAsync
|
||||
return await File.ReadAllBytesAsync(fullPath, cancelToken);
|
||||
}
|
||||
throw new FileNotFoundException(fullPath);
|
||||
} catch (Exception ex) {
|
||||
LogWrapper.Warn(ex, $"读取文件出错:{filePath}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取文件为字符串。
|
||||
/// </summary>
|
||||
/// <param name="filePath">文件路径(完整或相对)</param>
|
||||
/// <param name="encoding">文件编码,默认为 UTF-8</param>
|
||||
/// <param name="cancelToken">取消令牌</param>
|
||||
/// <returns>文件内容的字符串,失败时返回空字符串</returns>
|
||||
public static async Task<string> ReadAllTextOrEmptyAsync(string filePath, Encoding? encoding = null, CancellationToken cancelToken = default) {
|
||||
try {
|
||||
var fullPath = GetFullPath(filePath);
|
||||
if (!File.Exists(fullPath)) throw new FileNotFoundException(fullPath);
|
||||
if (encoding is null) return await File.ReadAllTextAsync(fullPath, cancelToken);
|
||||
return await File.ReadAllTextAsync(fullPath, encoding, cancelToken);
|
||||
} catch (Exception ex) {
|
||||
LogWrapper.Warn(ex, $"读取文件出错:{filePath}");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从流中读取所有文本。
|
||||
/// </summary>
|
||||
/// <param name="stream">要读取的流</param>
|
||||
/// <param name="encoding">文件编码(可选,若为 null 则动态检测)</param>
|
||||
/// <param name="cancelToken">取消令牌</param>
|
||||
/// <returns>流内容的字符串,失败时返回空字符串</returns>
|
||||
public static async Task<string> ReadAllTextOrEmptyAsync(Stream stream, Encoding? encoding = null, CancellationToken cancelToken = default) {
|
||||
try {
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
using var memoryStream = new MemoryStream();
|
||||
await stream.CopyToAsync(memoryStream, cancelToken);
|
||||
// 使用 MemoryStream 的内部 buffer 避免再分配一次完整的 byte 数组以节省内存
|
||||
// 注:内部 buffer 长度可能大于实际数据长度
|
||||
var buffer = memoryStream.GetBuffer();
|
||||
var len = (int)memoryStream.Length;
|
||||
return (encoding ?? EncodingDetector.DetectEncoding(buffer)).GetString(buffer, 0, len);
|
||||
} catch (Exception ex) {
|
||||
LogWrapper.Warn(ex, $"读取流出错: {stream}");
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步读取文件到流。
|
||||
/// </summary>
|
||||
/// <param name="filePath">文件路径(完整或相对)</param>
|
||||
/// <param name="cancelToken">取消令牌</param>
|
||||
/// <returns>包含文件内容的 MemoryStream,失败时返回空的 MemoryStream</returns>
|
||||
public static async Task<MemoryStream> ReadFileToStreamOrEmptyAsync(string filePath, CancellationToken cancelToken = default) {
|
||||
try {
|
||||
var fullPath = GetFullPath(filePath);
|
||||
if (!File.Exists(fullPath))
|
||||
throw new FileNotFoundException(fullPath);
|
||||
|
||||
await using var fileStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true);
|
||||
var memoryStream = new MemoryStream();
|
||||
await fileStream.CopyToAsync(memoryStream, cancelToken);
|
||||
memoryStream.Position = 0; // 重置流位置以便后续读取
|
||||
return memoryStream;
|
||||
} catch (Exception ex) {
|
||||
LogWrapper.Warn(ex, $"读取文件到流出错:{filePath}");
|
||||
return new MemoryStream(); // 返回空的 MemoryStream
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入字符串到文件,支持追加或覆盖,自动创建目录。
|
||||
/// </summary>
|
||||
/// <param name="filePath">文件路径(完整或相对)</param>
|
||||
/// <param name="text">要写入的文本</param>
|
||||
/// <param name="append">追加到文件(true)或覆盖(false)</param>
|
||||
/// <param name="encoding">文件编码(可选),默认智能检测</param>
|
||||
/// <param name="cancelToken">取消令牌</param>
|
||||
public static async Task WriteFileAsync(string filePath, string text, bool append = false, Encoding? encoding = null, CancellationToken cancelToken = default) {
|
||||
var fullPath = GetFullPath(filePath);
|
||||
var directoryName = Path.GetDirectoryName(fullPath);
|
||||
if (directoryName is null) {
|
||||
throw new InvalidOperationException("无法获取目标目录");
|
||||
}
|
||||
Directory.CreateDirectory(directoryName);
|
||||
|
||||
if (append) {
|
||||
// 编码检测使用 stream 而不是完整读取内容以避免多余的内存占用
|
||||
await using (var fileStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
encoding ??= EncodingDetector.DetectEncoding(fileStream);
|
||||
// 注:从此处开始,编码检测使用的 stream 已经销毁
|
||||
await File.AppendAllTextAsync(fullPath, text, encoding, cancelToken);
|
||||
} else {
|
||||
encoding ??= new UTF8Encoding(false); // 无 BOM 的 UTF-8
|
||||
await File.WriteAllTextAsync(fullPath, text, encoding, cancelToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入字节数组到文件,自动创建目录。
|
||||
/// </summary>
|
||||
/// <param name="filePath">文件路径(完整或相对)</param>
|
||||
/// <param name="content">要写入的字节数组</param>
|
||||
/// <param name="append">追加到文件(true)或覆盖(false),默认为 false</param>
|
||||
/// <param name="cancelToken">取消令牌</param>
|
||||
/// <returns>一个 Task,表示异步写入操作</returns>
|
||||
public static async Task WriteFileAsync(string filePath, byte[] content, bool append = false, CancellationToken cancelToken = default) {
|
||||
var fullPath = GetFullPath(filePath);
|
||||
var directoryName = Path.GetDirectoryName(fullPath);
|
||||
if (directoryName is null) {
|
||||
throw new InvalidOperationException("无法获取目标目录");
|
||||
}
|
||||
Directory.CreateDirectory(directoryName);
|
||||
|
||||
var fileMode = append ? FileMode.Append : FileMode.Create;
|
||||
await using var fileStream = new FileStream(fullPath, fileMode, FileAccess.Write, FileShare.Read);
|
||||
await fileStream.WriteAsync(content.AsMemory(), cancelToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将流写入文件,自动创建目录。
|
||||
/// </summary>
|
||||
/// <param name="filePath">文件路径(完整或相对)</param>
|
||||
/// <param name="stream">要写入的流</param>
|
||||
/// <param name="cancelToken">取消令牌</param>
|
||||
/// <returns>写入是否成功</returns>
|
||||
public static async Task<bool> WriteFileAsync(string filePath, Stream? stream, CancellationToken cancelToken = default) {
|
||||
if (stream is null) return false;
|
||||
try {
|
||||
var fullPath = GetFullPath(filePath);
|
||||
var directoryName = Path.GetDirectoryName(fullPath);
|
||||
if (directoryName is null) {
|
||||
throw new InvalidOperationException("无法获取目标目录");
|
||||
}
|
||||
Directory.CreateDirectory(directoryName);
|
||||
|
||||
await using var fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
fileStream.SetLength(0);
|
||||
await stream.CopyToAsync(fileStream, cancelToken).ConfigureAwait(false);
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
LogWrapper.Warn(ex, "保存流出错");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 文件解压
|
||||
|
||||
/// <summary>
|
||||
/// 尝试根据文件后缀名判断文件种类并解压,支持 zip、gz、tar、tar.gz 和 bzip2。
|
||||
/// 会尝试将 jar 文件以 zip 方式解压。不会清空目标目录,但会创建不存在的目录。
|
||||
/// </summary>
|
||||
/// <param name="compressFilePath">压缩文件路径</param>
|
||||
/// <param name="destDirectory">目标解压目录</param>
|
||||
/// <param name="progressIncrementHandler">进度更新回调,接收 0.0 到 1.0 的进度值</param>
|
||||
/// <param name="cancellationToken">取消操作的令牌</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="compressFilePath"/> 或 <paramref name="destDirectory"/> 为 null 或空时抛出</exception>
|
||||
/// <exception cref="NotSupportedException">当文件格式不受支持时抛出</exception>
|
||||
public static async Task ExtractFileAsync(string? compressFilePath, string? destDirectory, Action<double>? progressIncrementHandler = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrEmpty(compressFilePath)) {
|
||||
LogWrapper.Error(new ArgumentNullException(nameof(compressFilePath)), "压缩文件路径为空");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(destDirectory)) {
|
||||
LogWrapper.Error(new ArgumentNullException(nameof(destDirectory)), "目标目录路径为空");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Directory.CreateDirectory(destDirectory); // 创建目标目录(同步操作,因为通常很快且无异步版本)
|
||||
|
||||
if (compressFilePath.EndsWithF(".gz") || compressFilePath.EndsWithF(".tgz")) {
|
||||
await _ExtractGZipAsync(compressFilePath, destDirectory, progressIncrementHandler, cancellationToken).ConfigureAwait(false);
|
||||
} else if (compressFilePath.EndsWithF(".bz2")) {
|
||||
await _ExtractBZip2Async(compressFilePath, destDirectory, progressIncrementHandler, cancellationToken).ConfigureAwait(false);
|
||||
} else if (compressFilePath.EndsWithF(".tar")) {
|
||||
await _ExtractTarAsync(compressFilePath, destDirectory, progressIncrementHandler, cancellationToken).ConfigureAwait(false);
|
||||
} else if (compressFilePath.EndsWithF(".zip") || compressFilePath.EndsWithF(".jar")) {
|
||||
await _ExtractZipAsync(compressFilePath, destDirectory, progressIncrementHandler, cancellationToken).ConfigureAwait(false);
|
||||
} else {
|
||||
throw new NotSupportedException("不支持的压缩文件格式");
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
LogWrapper.Error(ex, $"解压文件 {compressFilePath} 失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步解压 GZip 文件(包括 .gz、.tgz 和 .tar.gz)。
|
||||
/// </summary>
|
||||
private static async Task _ExtractGZipAsync(
|
||||
string compressFilePath,
|
||||
string destDirectory,
|
||||
Action<double>? progressIncrementHandler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outputFileName = Path.GetFileName(compressFilePath).ToLower();
|
||||
var isTarGZip = outputFileName.EndsWithF(".tar.gz") || outputFileName.EndsWithF(".tgz");
|
||||
|
||||
if (isTarGZip)
|
||||
outputFileName = outputFileName
|
||||
.Replace(".tar.gz", "")
|
||||
.Replace(".tgz", "");
|
||||
else if (outputFileName.EndsWithF(".gz")) outputFileName = outputFileName.Replace(".gz", "");
|
||||
|
||||
var outputPath = _GetSafePath(destDirectory, outputFileName);
|
||||
|
||||
await using FileStream compressedFile = new(compressFilePath, FileMode.Open, FileAccess.Read);
|
||||
await using GZipInputStream gzipStream = new(compressedFile);
|
||||
|
||||
if (isTarGZip)
|
||||
{
|
||||
// 处理 .tgz / .tar.gz 文件
|
||||
await using TarInputStream tarStream = new(gzipStream, Encoding.UTF8);
|
||||
|
||||
await _ExtractTarStreamAsync(
|
||||
tarStream,
|
||||
destDirectory,
|
||||
progressIncrementHandler,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 处理普通 .gz 文件
|
||||
await using FileStream outputStream = new(outputPath, FileMode.Create, FileAccess.Write);
|
||||
|
||||
await gzipStream
|
||||
.CopyToAsync(outputStream, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
progressIncrementHandler?.Invoke(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步解压 BZip2 文件。
|
||||
/// </summary>
|
||||
private static async Task _ExtractBZip2Async(
|
||||
string compressFilePath,
|
||||
string destDirectory,
|
||||
Action<double>? progressIncrementHandler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outputFileName = Path.GetFileName(compressFilePath)
|
||||
.ToLower()
|
||||
.Replace(".bz2", "");
|
||||
|
||||
var outputPath = _GetSafePath(destDirectory, outputFileName);
|
||||
|
||||
await using FileStream compressedFile = new(compressFilePath, FileMode.Open, FileAccess.Read);
|
||||
await using BZip2InputStream bzip2Stream = new(compressedFile);
|
||||
await using FileStream outputStream = new(outputPath, FileMode.Create, FileAccess.Write);
|
||||
|
||||
await bzip2Stream
|
||||
.CopyToAsync(outputStream, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
progressIncrementHandler?.Invoke(1.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步解压 Tar 文件。
|
||||
/// </summary>
|
||||
private static async Task _ExtractTarAsync(
|
||||
string compressFilePath,
|
||||
string destDirectory,
|
||||
Action<double>? progressIncrementHandler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using FileStream compressedFile = new(compressFilePath, FileMode.Open, FileAccess.Read);
|
||||
await using TarInputStream tarStream = new(compressedFile, Encoding.UTF8);
|
||||
|
||||
await _ExtractTarStreamAsync(
|
||||
tarStream,
|
||||
destDirectory,
|
||||
progressIncrementHandler,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步解压 Tar 流中的内容到指定目录。
|
||||
/// </summary>
|
||||
/// <param name="tarStream">要解压的 Tar 输入流。</param>
|
||||
/// <param name="destDirectory">目标解压目录。</param>
|
||||
/// <param name="progressIncrementHandler">进度更新回调,接收 0.0 到 1.0 的进度值(基于条目计数)。</param>
|
||||
/// <param name="cancellationToken">用于取消操作的令牌。</param>
|
||||
/// <exception cref="OperationCanceledException">如果操作被取消。</exception>
|
||||
/// <exception cref="InvalidOperationException">如果路径不合法或解压失败。</exception>
|
||||
/// <exception cref="IOException">如果发生 IO 相关错误。</exception>
|
||||
private static async Task _ExtractTarStreamAsync(
|
||||
TarInputStream tarStream,
|
||||
string destDirectory,
|
||||
Action<double>? progressIncrementHandler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = new List<TarEntry>();
|
||||
long totalBytes = 0;
|
||||
|
||||
while (await _GetNextEntryAsync(tarStream, cancellationToken).ConfigureAwait(false) is { } entry)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
entries.Add(entry);
|
||||
totalBytes += entry.Size;
|
||||
}
|
||||
|
||||
long processedBytes = 0;
|
||||
|
||||
tarStream.Reset(); // 重置流以重新读取条目
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var destinationPath = _GetSafePath(destDirectory, entry.Name);
|
||||
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
await _CreateDirectoryAsync(destinationPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
await _CreateDirectoryAsync(Path.GetDirectoryName(destinationPath)!, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entry.Size == 0)
|
||||
{
|
||||
await File.Create(destinationPath).DisposeAsync();
|
||||
continue;
|
||||
}
|
||||
|
||||
await using FileStream outputStream = new(destinationPath, FileMode.Create, FileAccess.Write);
|
||||
|
||||
await tarStream
|
||||
.CopyEntryContentsAsync(outputStream, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
processedBytes += entry.Size;
|
||||
progressIncrementHandler?.Invoke((double)processedBytes / totalBytes);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to extract entry {entry.Name}: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<TarEntry?> _GetNextEntryAsync(
|
||||
TarInputStream tarStream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
var task = Task.Run(tarStream.GetNextEntry, cts.Token);
|
||||
|
||||
if (await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(10), cts.Token)).ConfigureAwait(false) == task)
|
||||
return await task.ConfigureAwait(false);
|
||||
|
||||
throw new TimeoutException("Operation timed out while reading next Tar entry.");
|
||||
}
|
||||
|
||||
private static string _GetSafePath(
|
||||
string destDirectory,
|
||||
string entryName)
|
||||
{
|
||||
var destinationRoot =
|
||||
Path.GetFullPath(destDirectory)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) +
|
||||
Path.DirectorySeparatorChar;
|
||||
|
||||
var fullPath = Path.GetFullPath(Path.Combine(destinationRoot, entryName));
|
||||
|
||||
return fullPath.StartsWith(destinationRoot, StringComparison.OrdinalIgnoreCase)
|
||||
? fullPath
|
||||
: throw new InvalidOperationException($"Invalid path detected: {entryName}");
|
||||
}
|
||||
|
||||
private static async Task _CreateDirectoryAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task
|
||||
.Run(() => Directory.CreateDirectory(path), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步解压 Zip 文件(包括 .zip 和 .jar)。
|
||||
/// </summary>
|
||||
private static async Task _ExtractZipAsync(
|
||||
string compressFilePath,
|
||||
string destDirectory,
|
||||
Action<double>? progressIncrementHandler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using ZipFile zipFile = new(compressFilePath);
|
||||
|
||||
var totalEntries = zipFile.Count;
|
||||
long currentEntry = 0;
|
||||
|
||||
foreach (ZipEntry entry in zipFile)
|
||||
{
|
||||
var destinationPath = _GetSafePath(destDirectory, entry.Name);
|
||||
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
Directory.CreateDirectory(destinationPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
|
||||
await using var zipStream = zipFile.GetInputStream(entry);
|
||||
await using FileStream outputStream = new(destinationPath, FileMode.Create, FileAccess.Write);
|
||||
|
||||
await zipStream
|
||||
.CopyToAsync(outputStream, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
currentEntry++;
|
||||
progressIncrementHandler?.Invoke((double)currentEntry / totalEntries);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 文件哈希计算
|
||||
|
||||
/// <summary>
|
||||
/// 异步计算文件的指定哈希值。
|
||||
/// </summary>
|
||||
/// <param name="filePath">要计算哈希的文件路径</param>
|
||||
/// <param name="hashProvider">哈希算法提供者(如 MD5Provider、SHA1Provider 等)</param>
|
||||
/// <param name="ignoreIfBusy">是否忽略被占用的文件</param>
|
||||
/// <returns>哈希值(十六进制字符串),失败时返回空字符串</returns>
|
||||
public static async Task<string> ComputeFileHashAsync(string? filePath, IHashProvider hashProvider, bool ignoreIfBusy = false) {
|
||||
if (string.IsNullOrEmpty(filePath)) {
|
||||
LogWrapper.Warn(new ArgumentNullException(nameof(filePath)), "文件路径为空");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// 检查文件是否被占用
|
||||
if (ignoreIfBusy && await CheckFileBusyAsync(filePath).ConfigureAwait(false)) {
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
using FileStream fs = new(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
return (await hashProvider.ComputeHashAsync(fs).ConfigureAwait(false)).ToHexString();
|
||||
} catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) {
|
||||
LogWrapper.Warn(ex, $"计算文件哈希失败:{filePath}");
|
||||
return string.Empty;
|
||||
} catch (Exception ex) {
|
||||
if (attempt == 0) {
|
||||
LogWrapper.Warn(ex, $"计算文件哈希可重试失败:{filePath}");
|
||||
await Task.Delay(Random.Shared.Next(200, 500)).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
LogWrapper.Warn(ex, $"计算文件哈希失败:{filePath}");
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// ReSharper disable InconsistentNaming
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取文件的 MD5 哈希值。
|
||||
/// </summary>
|
||||
public static Task<string> GetFileMD5Async(string? filePath)
|
||||
=> ComputeFileHashAsync(filePath, MD5Provider.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取文件的 SHA1 哈希值。
|
||||
/// </summary>
|
||||
public static Task<string> GetFileSHA1Async(string? filePath)
|
||||
=> ComputeFileHashAsync(filePath, SHA1Provider.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取文件的 SHA256 哈希值。
|
||||
/// </summary>
|
||||
public static Task<string> GetFileSHA256Async(string? filePath, bool ignoreIfBusy = false)
|
||||
=> ComputeFileHashAsync(filePath, SHA256Provider.Instance, ignoreIfBusy);
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取文件的 SHA512 哈希值。
|
||||
/// </summary>
|
||||
public static Task<string> GetFileSHA512Async(string? filePath, bool ignoreIfBusy = false)
|
||||
=> ComputeFileHashAsync(filePath, SHA512Provider.Instance, ignoreIfBusy);
|
||||
|
||||
// ReSharper restore InconsistentNaming
|
||||
|
||||
/// <summary>
|
||||
/// 获取文件的完整路径。
|
||||
/// </summary>
|
||||
public static string GetFullPath(string filePath) {
|
||||
ArgumentNullException.ThrowIfNull(filePath);
|
||||
return Path.IsPathRooted(filePath) ? filePath : Path.Combine(Paths.DefaultDirectory, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步检查文件是否被占用。
|
||||
/// </summary>
|
||||
/// <returns>若被占用则为 true,否则为 false</returns>
|
||||
public static async Task<bool> CheckFileBusyAsync(string filePath) {
|
||||
try {
|
||||
if (!File.Exists(filePath)) return false;
|
||||
await using FileStream fs = new(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.Read | FileShare.Delete);
|
||||
return false;
|
||||
} catch (IOException) { return true; } catch { return false; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// 从剪切板粘贴文件或文件夹
|
||||
/// </summary>
|
||||
/// <param name="dest">目标文件夹</param>
|
||||
/// <param name="copyFile">是否粘贴文件</param>
|
||||
/// <param name="copyDir">是否粘贴文件夹</param>
|
||||
/// <returns>总共粘贴的数量</returns>
|
||||
public static async Task<int> PasteFromClipboardAsync(string dest, bool copyFile, bool copyDir) {
|
||||
if (string.IsNullOrEmpty(dest)) {
|
||||
throw new ArgumentException("Destination folder cannot be null or empty.", nameof(dest));
|
||||
}
|
||||
|
||||
if (!Directory.Exists(dest)) {
|
||||
Directory.CreateDirectory(dest);
|
||||
}
|
||||
|
||||
var dataObject = Clipboard.GetDataObject();
|
||||
if (dataObject is null || !dataObject.GetDataPresent(DataFormats.FileDrop)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var data = dataObject.GetData(DataFormats.FileDrop);
|
||||
if (data is not string[] paths) {
|
||||
return 0;
|
||||
}
|
||||
if (paths.Length == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
foreach (var path in paths) {
|
||||
if (File.Exists(path) && copyFile) {
|
||||
var targetPath = Path.Combine(dest, Path.GetFileName(path));
|
||||
await CopyFileAsync(path, targetPath);
|
||||
count++;
|
||||
} else if (Directory.Exists(path) && copyDir) {
|
||||
var targetDir = Path.Combine(dest, new DirectoryInfo(path).Name);
|
||||
await CopyDirectoryAsync(path, targetDir);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 合并两个 JSON 对象,源 JSON 覆盖目标 JSON 的同名键,数组去重合并。
|
||||
/// </summary>
|
||||
/// <param name="target">目标 JSON 对象。</param>
|
||||
/// <param name="source">源 JSON 对象,优先级高于目标对象。</param>
|
||||
/// <returns>合并后的 JSON 对象。如果输入无效,返回源或目标的深拷贝。</returns>
|
||||
/// <exception cref="ArgumentNullException">如果 target 和 source 均为 null,则抛出异常。</exception>
|
||||
public static JsonNode MergeJson(JsonNode target, JsonNode source) {
|
||||
if (target is null && source is null) {
|
||||
throw new ArgumentNullException(nameof(target), "目标和源 JSON 不能同时为 null。");
|
||||
}
|
||||
|
||||
if (target is null) {
|
||||
return source.DeepClone();
|
||||
}
|
||||
|
||||
if (target is not JsonObject targetObj || source is not JsonObject sourceObj) {
|
||||
// 如果源是对象,优先返回源的深拷贝;否则返回目标的深拷贝
|
||||
return source.DeepClone();
|
||||
}
|
||||
|
||||
var result = (JsonObject)targetObj.DeepClone(); // 克隆以避免修改原始对象
|
||||
|
||||
foreach (var (key, sourceValue) in sourceObj) {
|
||||
var targetValue = result[key];
|
||||
|
||||
if (sourceValue is null) {
|
||||
// 忽略 null 值,保留目标值
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sourceValue is JsonObject && targetValue is JsonObject) {
|
||||
// 递归合并嵌套对象
|
||||
result[key] = MergeJson(targetValue, sourceValue);
|
||||
} else if (sourceValue is JsonArray sourceArray && targetValue is JsonArray targetArray) {
|
||||
// 合并数组并去重
|
||||
var uniqueValues = new HashSet<string>(StringComparer.Ordinal);
|
||||
JsonArray mergedArray = [];
|
||||
|
||||
// 添加目标数组元素
|
||||
foreach (var item in targetArray) {
|
||||
if (item is null) {
|
||||
continue;
|
||||
}
|
||||
var itemStr = item.ToJsonString();
|
||||
if (uniqueValues.Add(itemStr)) {
|
||||
mergedArray.Add(item.DeepClone());
|
||||
}
|
||||
}
|
||||
|
||||
// 添加源数组元素(源覆盖目标)
|
||||
foreach (var item in sourceArray) {
|
||||
if (item is null) {
|
||||
continue;
|
||||
}
|
||||
var itemStr = item.ToJsonString();
|
||||
if (uniqueValues.Add(itemStr)) {
|
||||
mergedArray.Add(item.DeepClone());
|
||||
}
|
||||
}
|
||||
|
||||
result[key] = mergedArray;
|
||||
} else {
|
||||
// 直接覆盖(包括简单值、数组替换或其他类型)
|
||||
result[key] = sourceValue.DeepClone();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查文件。若成功则返回 null,失败则返回错误的描述文本,描述文本不以句号结尾。不会抛出错误。
|
||||
/// </summary>
|
||||
public static async Task<string?> CheckAsync(
|
||||
string localPath,
|
||||
long minSize = -1,
|
||||
long actualSize = -1,
|
||||
string? hash = null,
|
||||
bool isJson = false) {
|
||||
try {
|
||||
LogWrapper.Debug("Checker", $"开始校验文件 {localPath}");
|
||||
var info = new FileInfo(localPath);
|
||||
if (!info.Exists) return $"文件不存在:{localPath}";
|
||||
|
||||
var fileSize = info.Length;
|
||||
var errors = new StringBuilder();
|
||||
var allowSizeMismatch = false; // 允许相信哈希正确但是大小不正确
|
||||
|
||||
if (!string.IsNullOrEmpty(hash)) {
|
||||
var computedHash = hash.Length switch {
|
||||
< 35 => await GetFileMD5Async(localPath), // MD5
|
||||
64 => await GetFileSHA256Async(localPath), // SHA256
|
||||
_ => await GetFileSHA1Async(localPath) // SHA1 (40)
|
||||
};
|
||||
|
||||
if (!string.Equals(hash, computedHash, StringComparison.OrdinalIgnoreCase)) {
|
||||
var hashType = hash.Length switch {
|
||||
< 35 => "MD5",
|
||||
64 => "SHA256",
|
||||
_ => "SHA1"
|
||||
};
|
||||
errors.AppendLine($"文件 {hashType} 应为 {hash},实际为 {computedHash}");
|
||||
} else {
|
||||
allowSizeMismatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查实际大小
|
||||
if (actualSize >= 0 && actualSize != fileSize && !allowSizeMismatch) {
|
||||
var contentPreview = fileSize < 2000 ? await ReadAllTextOrEmptyAsync(localPath) : "";
|
||||
errors.AppendLine($"文件大小应为 {actualSize} B,实际为 {fileSize} B" +
|
||||
(string.IsNullOrEmpty(contentPreview) ? "" : $",内容为 {contentPreview}"));
|
||||
}
|
||||
|
||||
// 检查最小大小
|
||||
if (minSize >= 0 && minSize > fileSize) {
|
||||
var contentPreview = fileSize < 2000 ? await ReadAllTextOrEmptyAsync(localPath) : "";
|
||||
errors.AppendLine($"文件大小应大于 {minSize} B,实际为 {fileSize} B" +
|
||||
(string.IsNullOrEmpty(contentPreview) ? "" : $",内容为 {contentPreview}"));
|
||||
}
|
||||
|
||||
// JSON 检查
|
||||
if (isJson) {
|
||||
var content = await ReadAllTextOrEmptyAsync(localPath);
|
||||
if (string.IsNullOrEmpty(content)) throw new Exception("读取到的文件为空");
|
||||
try {
|
||||
using var document = JsonDocument.Parse(content, JsonCompat.DocumentOptions);
|
||||
// 简单验证 JSON 有效性
|
||||
} catch (JsonException ex) {
|
||||
throw new Exception(Lang.Text("Common.Error.InvalidJson"), ex);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Length <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
errors.Insert(0, $"实际校验地址:{localPath}\n");
|
||||
return errors.ToString().TrimEnd();
|
||||
} catch (Exception ex) {
|
||||
LogWrapper.Warn("Checker", $"检查文件出错: {ex}");
|
||||
return ex.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查指定路径是否在指定文件夹中
|
||||
/// </summary>
|
||||
/// <param name="childPath">预检查路径</param>
|
||||
/// <param name="baseDirectory">应存在文件夹</param>
|
||||
/// <returns></returns>
|
||||
public static bool IsPathWithinDirectory(string childPath, string baseDirectory)
|
||||
{
|
||||
var baseDir = Path.GetFullPath(baseDirectory);
|
||||
var child = Path.GetFullPath(childPath);
|
||||
|
||||
return child.StartsWith(
|
||||
baseDir.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar,
|
||||
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.Caching;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Ae.Dns.Client;
|
||||
using Ae.Dns.Protocol;
|
||||
using Ae.Dns.Protocol.Enums;
|
||||
using Ae.Dns.Protocol.Records;
|
||||
using PCL.Core.IO.Net.Http;
|
||||
using PCL.Core.Logging;
|
||||
|
||||
namespace PCL.Core.IO.Net.Dns;
|
||||
|
||||
public class DnsQuery : IDisposable
|
||||
{
|
||||
private const string ModuleName = "DoH query";
|
||||
public static DnsQuery Instance { get; } = new();
|
||||
|
||||
private readonly DnsCachingClient _resolver;
|
||||
private readonly HttpClient[] _httpClients;
|
||||
|
||||
private DnsQuery()
|
||||
{
|
||||
var proxyHandler = new HttpClientHandler()
|
||||
{
|
||||
Proxy = HttpProxyManager.Instance
|
||||
};
|
||||
// 使用Ae.Dns创建DoH客户端,支持多个DoH服务器
|
||||
_httpClients =
|
||||
[
|
||||
new HttpClient(proxyHandler)
|
||||
{
|
||||
BaseAddress = new Uri("https://doh.pub/")
|
||||
},
|
||||
new HttpClient(proxyHandler)
|
||||
{
|
||||
BaseAddress = new Uri("https://doh.pysio.online/")
|
||||
},
|
||||
new HttpClient(proxyHandler)
|
||||
{
|
||||
BaseAddress = new Uri("https://cloudflare-dns.com/")
|
||||
}
|
||||
];
|
||||
_resolver = new DnsCachingClient(
|
||||
new WeightedDnsRacerClient(2, _httpClients.Select(static x => new DnsHttpClient(x)).ToArray<IDnsClient>()),
|
||||
new MemoryCache("DoH Query Cache"));
|
||||
}
|
||||
|
||||
public async Task<DnsMessage?> QueryAsync(string host, DnsQueryType qType, CancellationToken cts = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _resolver.Query(DnsQueryFactory.CreateQuery(host, qType), cts);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Warn(ModuleName, $"Failed to resolve DNS for {host}: {ex.Message}, use system default DNS");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IPAddress[]?> QueryForIpAsync(string host, CancellationToken cts = default)
|
||||
{
|
||||
var queryResponse = await Task.WhenAll(
|
||||
[
|
||||
QueryAsync(host, DnsQueryType.A, cts),
|
||||
QueryAsync(host, DnsQueryType.AAAA, cts)
|
||||
]
|
||||
);
|
||||
|
||||
if (queryResponse.All(static x => x is null))
|
||||
{
|
||||
LogWrapper.Warn(ModuleName, $"Failed to query IP for host {host} using DoH, use system default DNS");
|
||||
return await System.Net.Dns.GetHostAddressesAsync(host, cts);
|
||||
}
|
||||
|
||||
return queryResponse.Where(static x => x is not null)
|
||||
.SelectMany(static x => x!.Answers)
|
||||
.Where(static x => x.Type is DnsQueryType.A or DnsQueryType.AAAA)
|
||||
.Select(static x => x.Resource as DnsIpAddressResource)
|
||||
.Where(static x => x is not null)
|
||||
.Select(static x => x!.IPAddress)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
foreach (var client in _httpClients)
|
||||
{
|
||||
client.Dispose();
|
||||
}
|
||||
_resolver.Dispose(); // 好像这个包的 Dispose 并没有做什么 lol
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Ae.Dns.Protocol.Records;
|
||||
|
||||
namespace PCL.Core.IO.Net.Dns;
|
||||
|
||||
public class DnsSrvResource : IDnsResource
|
||||
{
|
||||
public string Target { get; set; } = "";
|
||||
public int Weight { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public int Port { get; set; }
|
||||
|
||||
public void WriteBytes(Memory<byte> bytes, ref int offset)
|
||||
{
|
||||
// 6 Bytes for priority, weight, port and 2 bytes for length
|
||||
var buf = bytes.Span[offset..];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf[offset..(offset + 2)], (ushort)Priority);
|
||||
offset += 2;
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf[offset..(offset + 2)], (ushort)Weight);
|
||||
offset += 2;
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf[offset..(offset + 2)], (ushort)Port);
|
||||
offset += 2;
|
||||
// target string
|
||||
foreach (var seg in Target.Split('.'))
|
||||
{
|
||||
var segBuf = Encoding.UTF8.GetBytes(seg);
|
||||
var segLength = (byte)segBuf.Length;
|
||||
buf[offset] = segLength;
|
||||
offset++;
|
||||
segBuf.CopyTo(buf[offset..]);
|
||||
offset += segBuf.Length;
|
||||
}
|
||||
}
|
||||
|
||||
public void ReadBytes(ReadOnlyMemory<byte> bytes, ref int offset, int length)
|
||||
{
|
||||
var buf = bytes.Slice(offset, length).Span;
|
||||
offset += length;
|
||||
var priorityBuf = buf[..2];
|
||||
Priority = BinaryPrimitives.ReadUInt16BigEndian(priorityBuf);
|
||||
var weightBuf = buf[2..4];
|
||||
Weight = BinaryPrimitives.ReadUInt16BigEndian(weightBuf);
|
||||
var portBuf = buf[4..6];
|
||||
Port = BinaryPrimitives.ReadUInt16BigEndian(portBuf);
|
||||
// left target buf. 1 Byte for length, then the string. Until we got end of string.
|
||||
var segments = new List<string>();
|
||||
var i = 6;
|
||||
while (i < length)
|
||||
{
|
||||
var segLength = buf[i];
|
||||
if (segLength == 0) break;
|
||||
i++;
|
||||
if (i + segLength > length)
|
||||
{
|
||||
throw new ArgumentException("Invalid DNS SRV resource record: segment length exceeds buffer size");
|
||||
}
|
||||
|
||||
segments.Add(Encoding.UTF8.GetString(buf.Slice(i, segLength)));
|
||||
i += segLength;
|
||||
}
|
||||
|
||||
Target = string.Join('.', segments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Ae.Dns.Protocol;
|
||||
using PCL.Core.Utils.Exts;
|
||||
|
||||
namespace PCL.Core.IO.Net.Dns;
|
||||
|
||||
public sealed class WeightedDnsRacerClient : IDnsClient
|
||||
{
|
||||
private readonly (IDnsClient Client, byte Weight)[] _clients;
|
||||
private readonly int _concurrentCount;
|
||||
private bool _updateWeigh = true;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public WeightedDnsRacerClient(int concurrentCount, params IDnsClient[] clients)
|
||||
{
|
||||
if (clients is null || clients.Length < concurrentCount)
|
||||
throw new ArgumentException("Not enough clients.");
|
||||
|
||||
_concurrentCount = concurrentCount;
|
||||
_clients = clients.Select(static c => (Client: c, Weight: (byte)0)).ToArray();
|
||||
}
|
||||
|
||||
public async Task<DnsMessage> Query(DnsMessage query, CancellationToken ct = default)
|
||||
{
|
||||
(IDnsClient Client, byte Weight)[] candidates;
|
||||
lock (_lock)
|
||||
{
|
||||
candidates = _clients
|
||||
.OrderByDescending(static x => x.Weight)
|
||||
.Take(_concurrentCount)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
var tasks = new Task<DnsMessage>[candidates.Length];
|
||||
|
||||
for (var i = 0; i < candidates.Length; i++)
|
||||
{
|
||||
var task = candidates[i].Client.Query(query, cts.Token);
|
||||
task.Forget();
|
||||
tasks[i] = task;
|
||||
}
|
||||
|
||||
var winnerTask = await tasks.WhenAnySuccessAsync().ConfigureAwait(false);
|
||||
if (winnerTask is null) throw new InvalidOperationException("All queries failed.");
|
||||
|
||||
var winner = candidates[Array.IndexOf(tasks, winnerTask)].Client;
|
||||
|
||||
if (_updateWeigh)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
for (var i = 0; i < _clients.Length; i++)
|
||||
{
|
||||
if (_clients[i].Client == winner)
|
||||
{
|
||||
if (_clients[i].Weight < 255)
|
||||
_clients[i] = (_clients[i].Client, (byte)(_clients[i].Weight + 1));
|
||||
else
|
||||
_updateWeigh = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
return await winnerTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var (client, _) in _clients)
|
||||
client?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using PCL.Core.IO.Storage.Cache;
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP 缓存处理器
|
||||
/// </summary>
|
||||
public class HttpCacheHandler : DelegatingHandler
|
||||
{
|
||||
private ICacheService _cacheService;
|
||||
public HttpCacheHandler(HttpMessageHandler invoker, ICacheService cacheService)
|
||||
{
|
||||
InnerHandler = invoker;
|
||||
_cacheService = cacheService;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
var uri = request.RequestUri?.ToString();
|
||||
if (string.IsNullOrEmpty(uri))
|
||||
{
|
||||
return await base.SendAsync(request, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// seek cache
|
||||
var cacheKey = CacheKeys.ApiResponse("http", uri);
|
||||
var cached = await _cacheService.GetAsync<byte[]>(cacheKey, ct).ConfigureAwait(false);
|
||||
if (cached.Found)
|
||||
{
|
||||
var cachedResponse = _DeserializeResponse(cached.Value!);
|
||||
cachedResponse.Headers.Add("X-Cache-Hit", "HIT");
|
||||
cachedResponse.RequestMessage = request;
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// seek metadata
|
||||
var metaKey = CacheKeys.ApiResponseMeta("http", uri);
|
||||
var metaCached = await _cacheService.GetAsync<HttpCacheMetadata>(metaKey, ct).ConfigureAwait(false);
|
||||
if (metaCached.Found)
|
||||
{
|
||||
if (metaCached.Value!.ETag is not null)
|
||||
{
|
||||
request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(metaCached.Value!.ETag));
|
||||
}
|
||||
|
||||
if (metaCached.Value!.LastModified is not null)
|
||||
{
|
||||
request.Headers.IfModifiedSince = DateTimeOffset.Parse(metaCached.Value!.LastModified);
|
||||
}
|
||||
}
|
||||
|
||||
// send request
|
||||
var response = await base.SendAsync(request, ct).ConfigureAwait(false);
|
||||
if (response.Headers.CacheControl?.NoStore ?? false)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
// not modified, return cached response if exists
|
||||
if (response.StatusCode is HttpStatusCode.NotModified)
|
||||
{
|
||||
var reCached = await _cacheService.GetAsync<byte[]>(cacheKey, ct).ConfigureAwait(false);
|
||||
if (reCached.Found)
|
||||
{
|
||||
var cachedResponse = _DeserializeResponse(reCached.Value!);
|
||||
cachedResponse.Headers.Add("X-Cache-Hit", "HIT");
|
||||
cachedResponse.RequestMessage = request;
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// cache response
|
||||
var body = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
var ttl = _ComputeTtl(response);
|
||||
|
||||
await _cacheService.SetAsync(cacheKey, body, new CachePolicy
|
||||
{
|
||||
AbsoluteExpiration = ttl,
|
||||
Group = "http",
|
||||
Tags = "http-response"
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
await _cacheService.SetAsync(metaKey, new HttpCacheMetadata
|
||||
{
|
||||
ETag = response.Headers.ETag?.Tag,
|
||||
LastModified = response.Content.Headers.LastModified?.ToString("O"),
|
||||
StatusCode = (int)response.StatusCode
|
||||
}, CachePolicy.NeverExpire, ct).ConfigureAwait(false);
|
||||
|
||||
// not matched, return original response
|
||||
response.Headers.Add("X-Cache-Hit", "MISS");
|
||||
return response;
|
||||
}
|
||||
|
||||
private static TimeSpan? _ComputeTtl(HttpResponseMessage response)
|
||||
{
|
||||
var cc = response.Headers.CacheControl;
|
||||
if (cc?.MaxAge is not null)
|
||||
{
|
||||
return cc.MaxAge;
|
||||
}
|
||||
|
||||
if (cc?.SharedMaxAge is not null)
|
||||
{
|
||||
return cc.SharedMaxAge;
|
||||
}
|
||||
|
||||
return TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage _DeserializeResponse(byte[] data)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(data)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PCL.Core.IO.Net.Http.Cache;
|
||||
|
||||
public record HttpCacheMetadata
|
||||
{
|
||||
public string? ETag { get; init; }
|
||||
public string? LastModified { get; init; }
|
||||
public int StatusCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.IO.Net.Dns;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Exts;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public class HostConnectionHandler
|
||||
{
|
||||
public static HostConnectionHandler Instance { get; } = new();
|
||||
private const string ModuleName = "HostConnectionHandler";
|
||||
|
||||
private readonly DnsQuery _dnsQuery = DnsQuery.Instance;
|
||||
private static readonly TimeSpan _CacheDuration = TimeSpan.FromMinutes(10); // 10 分钟缓存(RFC 建议值)
|
||||
private const int WaitTasks = 2;
|
||||
|
||||
// 缓存结构: (host, port) -> (IPAddress -> LastSuccessUtc)
|
||||
private readonly ConcurrentDictionary<(string host, int port), ConcurrentDictionary<IPAddress, DateTime>>
|
||||
_connectionCache = new();
|
||||
|
||||
public async ValueTask<Stream> GetConnectionAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var host = context.DnsEndPoint.Host;
|
||||
var port = context.DnsEndPoint.Port;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// 代理地址或者直连 IP 直接返回结果
|
||||
if (IPAddress.TryParse(host, out var directIp))
|
||||
{
|
||||
return await _ConnectToAddressAsync(directIp, port, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var addresses = await _dnsQuery.QueryForIpAsync(host, cancellationToken).ConfigureAwait(false);
|
||||
if (addresses is null || addresses.Length == 0)
|
||||
{
|
||||
throw new HttpRequestException($"DNS resolution failed for {host}");
|
||||
}
|
||||
|
||||
// 对待连接地址进行排序 (缓存成功+IPv6优先)
|
||||
var sortedAddresses = _SortAddresses(host, port, addresses, now);
|
||||
|
||||
// Happy Eyeballs 连接逻辑,优先 IPv6 稍后
|
||||
var connectionTasks = new List<Task<NetworkStream>>(WaitTasks);
|
||||
var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var connectCancellationToken = cancellationSource.Token;
|
||||
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < sortedAddresses.Length; i++)
|
||||
{
|
||||
var address = sortedAddresses[i];
|
||||
var delayMs = i * (address.AddressFamily.Equals(AddressFamily.InterNetwork) ? 150 : 80); // 偏向使用 v6
|
||||
|
||||
connectionTasks.Add(_DelayedConnectAsync(address, port, delayMs, connectCancellationToken));
|
||||
}
|
||||
|
||||
// 等待首个成功连接
|
||||
var winner = await connectionTasks
|
||||
.ToArray()
|
||||
.WhenAnySuccessAsync()
|
||||
.ConfigureAwait(false);
|
||||
var stream = await winner.ConfigureAwait(false);
|
||||
|
||||
// 更新缓存 + 记录成功
|
||||
var remoteIp = ((IPEndPoint)stream.Socket.RemoteEndPoint!).Address;
|
||||
_UpdateConnectionCache(host, port, remoteIp, now);
|
||||
LogWrapper.Debug(ModuleName, $"Connected to {host} via {remoteIp}");
|
||||
|
||||
// 取消其他连接
|
||||
// ReSharper disable once MethodHasAsyncOverload
|
||||
cancellationSource.Cancel();
|
||||
_ = _CleanupUnusedConnectionsAsync(connectionTasks, winner).ConfigureAwait(false);
|
||||
|
||||
return stream;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 清理所有连接
|
||||
// ReSharper disable once MethodHasAsyncOverload
|
||||
cancellationSource.Cancel();
|
||||
_ = _CleanupUnusedConnectionsAsync(connectionTasks, null).ConfigureAwait(false);
|
||||
|
||||
LogWrapper.Error(ex, ModuleName, $"All connection attempts failed for {host}");
|
||||
throw new HttpRequestException($"Connection failed for {host}", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cancellationSource.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private IPAddress[] _SortAddresses(string host, int port, IPAddress[] addresses, DateTime now)
|
||||
{
|
||||
var cacheKey = (host, port);
|
||||
var addressCache = _connectionCache.GetValueOrDefault(cacheKey);
|
||||
|
||||
var cachedAddresses = new List<IPAddress>();
|
||||
var uncachedAddresses = new List<IPAddress>();
|
||||
|
||||
foreach (var ip in addresses)
|
||||
{
|
||||
if (addressCache is not null &&
|
||||
addressCache.TryGetValue(ip, out var successTime) &&
|
||||
now - successTime <= _CacheDuration)
|
||||
{
|
||||
cachedAddresses.Add(ip);
|
||||
}
|
||||
else
|
||||
{
|
||||
uncachedAddresses.Add(ip);
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存地址: 按成功时间倒序 (最近成功的优先)
|
||||
cachedAddresses.Sort((a, b) =>
|
||||
addressCache![b].CompareTo(addressCache[a]));
|
||||
|
||||
// 非缓存地址: IPv6 优先 + 保持原始顺序
|
||||
var ipv6List = uncachedAddresses
|
||||
.Where(static ip => ip.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
.OrderBy(ip => Array.IndexOf(addresses, ip))
|
||||
.ToList();
|
||||
|
||||
var ipv4List = uncachedAddresses
|
||||
.Where(static ip => ip.AddressFamily == AddressFamily.InterNetwork)
|
||||
.OrderBy(ip => Array.IndexOf(addresses, ip))
|
||||
.ToList();
|
||||
|
||||
// 交错合并,确保有一个 v6 和一个 v4
|
||||
var sortedUncached = new List<IPAddress>();
|
||||
int i = 0, j = 0;
|
||||
while (i < ipv6List.Count || j < ipv4List.Count)
|
||||
{
|
||||
if (i < ipv6List.Count)
|
||||
sortedUncached.Add(ipv6List[i++]);
|
||||
if (j < ipv4List.Count)
|
||||
sortedUncached.Add(ipv4List[j++]);
|
||||
}
|
||||
|
||||
return cachedAddresses
|
||||
.Concat(sortedUncached)
|
||||
.Take(WaitTasks)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private void _UpdateConnectionCache(string host, int port, IPAddress ip, DateTime now)
|
||||
{
|
||||
var cacheKey = (host, port);
|
||||
var addressCache = _connectionCache.GetOrAdd(cacheKey, static _ =>
|
||||
new ConcurrentDictionary<IPAddress, DateTime>());
|
||||
|
||||
// 移除过期条目 (懒清理)
|
||||
foreach (var entry in addressCache)
|
||||
{
|
||||
if (now - entry.Value > _CacheDuration)
|
||||
{
|
||||
addressCache.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新当前IP
|
||||
addressCache[ip] = now;
|
||||
}
|
||||
|
||||
private static async Task<NetworkStream> _DelayedConnectAsync(IPAddress ip, int port, int delay, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
return await _ConnectToAddressAsync(ip, port, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<NetworkStream> _ConnectToAddressAsync(IPAddress ip, int port, CancellationToken cancellationToken)
|
||||
{
|
||||
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
|
||||
{
|
||||
NoDelay = true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(ip, port, cancellationToken).ConfigureAwait(false);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
socket.Dispose();
|
||||
throw new HttpRequestException($"Connection to {ip}:{port} failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task _CleanupUnusedConnectionsAsync(
|
||||
List<Task<NetworkStream>> allTasks,
|
||||
Task<NetworkStream>? winnerTask)
|
||||
{
|
||||
foreach (var task in allTasks.Where(t => t != winnerTask))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (task.IsCompletedSuccessfully)
|
||||
{
|
||||
var stream = await task.ConfigureAwait(false);
|
||||
await stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
else if (task.IsCompleted)
|
||||
{
|
||||
_ = task.Exception;
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = task.ContinueWith(static t =>
|
||||
{
|
||||
if (t.IsCompletedSuccessfully)
|
||||
{
|
||||
t.Result.Dispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = t.Exception;
|
||||
}
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Warn(ex, ModuleName, "Dispose connection with an error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpBasicExtension
|
||||
{
|
||||
extension(HttpRequestMessage requestMessage)
|
||||
{
|
||||
public HttpRequestMessage WithHttpVersionOption(Version httpVersion)
|
||||
{
|
||||
requestMessage.Version = httpVersion;
|
||||
return requestMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpContentExtension
|
||||
{
|
||||
extension (HttpRequestMessage requestMessage)
|
||||
{
|
||||
public HttpRequestMessage WithContent(HttpContent content, string? contentType = null)
|
||||
{
|
||||
requestMessage.Content = content;
|
||||
if (contentType is not null) requestMessage.WithHeader("Content-Type", contentType);
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithContent(string content, string? contentType = null)
|
||||
{
|
||||
requestMessage.Content = contentType is null
|
||||
? new StringContent(content, Encoding.UTF8)
|
||||
: new StringContent(content, Encoding.UTF8, contentType);
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithBinaryContent(byte[] content)
|
||||
{
|
||||
requestMessage.Content = new ByteArrayContent(content);
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithJsonContent<T>(T content, string? contentType = null, JsonSerializerOptions? options = null)
|
||||
{
|
||||
requestMessage.Content = new StringContent(
|
||||
JsonSerializer.Serialize(content, options ?? JsonCompat.SerializerOptions),
|
||||
Encoding.UTF8,
|
||||
contentType ?? "application/json");
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithFormContent(string form)
|
||||
{
|
||||
return requestMessage.WithContent(
|
||||
new ByteArrayContent(Encoding.UTF8.GetBytes(form)),
|
||||
"application/x-www-form-urlencoded");
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithFormContent(IEnumerable<KeyValuePair<string, string>> pairs)
|
||||
{
|
||||
requestMessage.Content = new FormUrlEncodedContent(pairs);
|
||||
return requestMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpCookieExtension
|
||||
{
|
||||
extension (HttpRequestMessage requestMessage)
|
||||
{
|
||||
public HttpRequestMessage WithCookie(string name, string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requestMessage);
|
||||
ArgumentNullException.ThrowIfNullOrEmpty(value);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var newCookie = $"{name}={_GetSafeCookieValue(value)}";
|
||||
|
||||
if (requestMessage.Headers.TryGetValues("Cookie", out var existingValues))
|
||||
{
|
||||
var existingCookie = string.Join("; ", existingValues);
|
||||
requestMessage.Headers.Remove("Cookie");
|
||||
requestMessage.Headers.Add("Cookie", $"{existingCookie}; {newCookie}");
|
||||
}
|
||||
else
|
||||
{
|
||||
requestMessage.Headers.Add("Cookie", newCookie);
|
||||
}
|
||||
|
||||
return requestMessage;
|
||||
}
|
||||
}
|
||||
|
||||
private static string _GetSafeCookieValue(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return value;
|
||||
var needsEncoding = value.Any(c => _ForbiddenCookieValueChar.Contains(c) || char.IsControl(c));
|
||||
return needsEncoding ? Uri.EscapeDataString(value) : value;
|
||||
}
|
||||
|
||||
private static readonly char[] _ForbiddenCookieValueChar = [';', ',', ' ', '\r', '\n', '\t', '\0', '=', '"', '\'', '\\', '<', '>'];
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpHeaderHandler
|
||||
{
|
||||
extension (HttpRequestMessage requestMessage)
|
||||
{
|
||||
public HttpRequestMessage WithHeader(string key, string value)
|
||||
{
|
||||
if (key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && requestMessage.Content is not null)
|
||||
requestMessage.Content.Headers.TryAddWithoutValidation(key, value);
|
||||
else
|
||||
requestMessage.Headers.TryAddWithoutValidation(key, value);
|
||||
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithHeaders(IDictionary<string, string> pairs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pairs);
|
||||
|
||||
foreach (var item in pairs)
|
||||
{
|
||||
requestMessage.WithHeader(item.Key, item.Value);
|
||||
}
|
||||
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithHeader(KeyValuePair<string, string> pair) =>
|
||||
requestMessage.WithHeader(pair.Key, pair.Value);
|
||||
|
||||
public HttpRequestMessage WithAuthentication(string scheme, string token)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(scheme);
|
||||
ArgumentException.ThrowIfNullOrEmpty(token);
|
||||
|
||||
requestMessage.Headers.Authorization = new AuthenticationHeaderValue(scheme, token);
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithBearerToken(string token) =>
|
||||
requestMessage.WithAuthentication("Bearer", token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using Microsoft.Win32;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Exts;
|
||||
using PCL.Core.Utils.OS;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public class HttpProxyManager : IWebProxy, IDisposable
|
||||
{
|
||||
public static readonly HttpProxyManager Instance = new();
|
||||
|
||||
public enum ProxyMode
|
||||
{
|
||||
NoProxy,
|
||||
SystemProxy,
|
||||
CustomProxy
|
||||
}
|
||||
|
||||
private readonly object _lock = new();
|
||||
private ProxyMode _mode = ProxyMode.SystemProxy;
|
||||
private readonly WebProxy _customWebProxy = new() { BypassProxyOnLocal = true };
|
||||
private readonly WebProxy _systemWebProxy = new() { BypassProxyOnLocal = true };
|
||||
private const string ProxyRegPathFull = @"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings";
|
||||
private const string ProxyRegPath = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
|
||||
private readonly RegistryChangeMonitor _proxyMonitor = new(ProxyRegPath);
|
||||
|
||||
private HttpProxyManager()
|
||||
{
|
||||
RefreshSystemProxy(); // 初始化系统代理
|
||||
_proxyMonitor.Changed += _OnSystemProxyChanged;
|
||||
}
|
||||
|
||||
private void _OnSystemProxyChanged(object? sender, EventArgs e)
|
||||
{
|
||||
RefreshSystemProxy();
|
||||
}
|
||||
|
||||
private enum ProxyProtocol
|
||||
{
|
||||
Http,
|
||||
Socks
|
||||
}
|
||||
|
||||
private record ProxyItem
|
||||
{
|
||||
public ProxyProtocol Protocol;
|
||||
public required string Address;
|
||||
}
|
||||
|
||||
private static ProxyItem[] _GetProxyFromString(string? proxyString)
|
||||
{
|
||||
if (proxyString.IsNullOrWhiteSpace()) return [];
|
||||
|
||||
var ret = new List<ProxyItem>();
|
||||
|
||||
// 形式:http=192.168.1.100:8080;socks=192.168.1.100:1080
|
||||
if (proxyString.Contains('='))
|
||||
{
|
||||
foreach (var segment in proxyString.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var eqIndex = segment.IndexOf('=');
|
||||
if (eqIndex <= 0 || eqIndex >= segment.Length - 1)
|
||||
continue;
|
||||
|
||||
var protocolStr = segment[..eqIndex].Trim();
|
||||
var address = segment[(eqIndex + 1)..].Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
continue;
|
||||
|
||||
ret.Add(new ProxyItem { Protocol = _ParseProtocol(protocolStr), Address = address });
|
||||
}
|
||||
|
||||
return ret.Count > 0 ? [.. ret] : [];
|
||||
}
|
||||
|
||||
// 形式:http://127.0.0.1:1145/ 或者单纯 127.0.0.1:1145
|
||||
if (Uri.TryCreate(proxyString, new UriCreationOptions(), out var proxyAddr))
|
||||
{
|
||||
var address = proxyAddr.Port > 0
|
||||
? $"{proxyAddr.Host}:{proxyAddr.Port}"
|
||||
: proxyAddr.Host;
|
||||
ret.Add(new ProxyItem { Protocol = _ParseProtocol(proxyAddr.Scheme), Address = address });
|
||||
}
|
||||
else
|
||||
{
|
||||
ret.Add(new ProxyItem { Protocol = ProxyProtocol.Http, Address = proxyString.Trim() });
|
||||
}
|
||||
|
||||
return [.. ret];
|
||||
}
|
||||
|
||||
private static ProxyProtocol _ParseProtocol(string scheme)
|
||||
{
|
||||
return scheme.ToLowerInvariant() switch
|
||||
{
|
||||
"socks" or "socks4" or "socks5" => ProxyProtocol.Socks,
|
||||
_ => ProxyProtocol.Http
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>刷新系统代理设置</summary>
|
||||
public void RefreshSystemProxy()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
// read from reg
|
||||
var isSystemProxyEnabled = (int)(Registry.GetValue(ProxyRegPathFull, "ProxyEnable", 0) ?? 0);
|
||||
var systemProxyString = Registry.GetValue(ProxyRegPathFull, "ProxyServer", string.Empty) as string;
|
||||
|
||||
// parse
|
||||
var proxies = _GetProxyFromString(systemProxyString);
|
||||
|
||||
// filter
|
||||
if (proxies.Length == 0 || !proxies.Any(static x => x.Protocol.Equals(ProxyProtocol.Http))) isSystemProxyEnabled = 0;
|
||||
var selectedProxy = proxies.FirstOrDefault(static x => x.Protocol.Equals(ProxyProtocol.Http));
|
||||
|
||||
// apply
|
||||
_systemWebProxy.Address = (isSystemProxyEnabled == 0 || selectedProxy!.Address.IsNullOrEmpty())
|
||||
? null
|
||||
: new Uri($"http://{selectedProxy.Address}");
|
||||
|
||||
LogWrapper.Info("Proxy",
|
||||
$"已从操作系统更新代理设置,系统代理状态:{isSystemProxyEnabled}|{systemProxyString}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Proxy", "获取系统代理时出现异常");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ProxyMode Mode
|
||||
{
|
||||
get { lock (_lock) return _mode; }
|
||||
set { lock (_lock) _mode = value; }
|
||||
}
|
||||
|
||||
public Uri? CustomProxyAddress
|
||||
{
|
||||
get { lock (_lock) return _customWebProxy.Address; }
|
||||
set { lock (_lock) _customWebProxy.Address = value; }
|
||||
}
|
||||
|
||||
public ICredentials? CustomProxyCredentials
|
||||
{
|
||||
get { lock (_lock) return _customWebProxy.Credentials; }
|
||||
set { lock (_lock) _customWebProxy.Credentials = value; }
|
||||
}
|
||||
|
||||
public bool BypassOnLocal
|
||||
{
|
||||
get { lock (_lock) return field; }
|
||||
set
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
field = value;
|
||||
_systemWebProxy.BypassProxyOnLocal = value;
|
||||
}
|
||||
}
|
||||
} = true;
|
||||
|
||||
public Uri? GetProxy(Uri destination)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _mode switch
|
||||
{
|
||||
ProxyMode.NoProxy => null, // 返回 null 表明没有代理
|
||||
ProxyMode.SystemProxy => _systemWebProxy.GetProxy(destination),
|
||||
ProxyMode.CustomProxy => _customWebProxy.GetProxy(destination),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsBypassed(Uri host)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _mode switch
|
||||
{
|
||||
ProxyMode.NoProxy => true,
|
||||
ProxyMode.SystemProxy => _systemWebProxy.IsBypassed(host),
|
||||
ProxyMode.CustomProxy => _customWebProxy.IsBypassed(host),
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public ICredentials? Credentials
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// 仅 CustomProxy 模式返回凭据
|
||||
return _mode == ProxyMode.CustomProxy
|
||||
? _customWebProxy.Credentials
|
||||
: null;
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_customWebProxy.Credentials = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_proxyMonitor.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpRequest
|
||||
{
|
||||
public static HttpRequestMessage Create(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Get, new Uri(url));
|
||||
}
|
||||
public static HttpRequestMessage CreateHead(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Head, new Uri(url));
|
||||
}
|
||||
public static HttpRequestMessage CreatePost(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Post, new Uri(url));
|
||||
}
|
||||
public static HttpRequestMessage CreatePut(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Put, new Uri(url));
|
||||
}
|
||||
public static HttpRequestMessage CreateDelete(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Delete, new Uri(url));
|
||||
}
|
||||
|
||||
public static async Task<string> GetStringAsync(string url)
|
||||
{
|
||||
using var resp = await Create(url).SendAsync().ConfigureAwait(false);
|
||||
return await resp.AsStringAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<T?> GetJsonAsync<T>(string url)
|
||||
{
|
||||
using var resp = await Create(url).SendAsync().ConfigureAwait(false);
|
||||
return await resp.AsJsonAsync<T>().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> PostJsonAsync<T>(string url, T data, string? contentType = null)
|
||||
{
|
||||
return await CreatePost(url)
|
||||
.WithJsonContent(data, contentType)
|
||||
.SendAsync()
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public class HttpResponseException:HttpRequestException,IDisposable
|
||||
{
|
||||
public HttpResponseMessage? Response { get; init; }
|
||||
|
||||
public HttpResponseException()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public HttpResponseException(string message) : base(message)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public HttpResponseException(string message, Exception? inner) : base(message, inner)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public HttpResponseException(HttpResponseMessage? response) : this(
|
||||
$"{(int?)response?.StatusCode} {response?.ReasonPhrase ?? (response is null ? "undefined" : Enum.GetName(response.StatusCode))})")
|
||||
{
|
||||
Response = response;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Response?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
~HttpResponseException()
|
||||
{
|
||||
try
|
||||
{
|
||||
Response?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Suppress Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpResponseExtension
|
||||
{
|
||||
extension(HttpResponseMessage responseMessage)
|
||||
{
|
||||
public bool IsSuccess => responseMessage.IsSuccessStatusCode;
|
||||
|
||||
public string AsString() { return responseMessage.AsStringAsync().GetAwaiter().GetResult(); }
|
||||
|
||||
public async Task<string> AsStringAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await responseMessage.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
} catch(TaskCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The request was canceled due to a timeout.");
|
||||
} catch(OperationCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The operation was canceled.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Stream> AsStreamAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await responseMessage.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
} catch(TaskCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The request was canceled due to a timeout.");
|
||||
} catch(OperationCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The operation was canceled.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<byte[]> AsByteArrayAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await responseMessage.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
} catch(TaskCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The request was canceled due to a timeout.");
|
||||
} catch(OperationCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The operation was canceled.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<T?> AsJsonAsync<T>(
|
||||
JsonSerializerOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = await responseMessage.AsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<T>(stream, options ?? JsonCompat.SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
} catch(JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException("Failed to deserialize JSON response.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, string[]> GetHeaders()
|
||||
{
|
||||
return responseMessage.Headers
|
||||
.Concat(responseMessage.Content.Headers)
|
||||
.ToDictionary(k => k.Key, v => v.Value.ToArray());
|
||||
}
|
||||
|
||||
public Dictionary<string, string[]> GetContentHeaders()
|
||||
{ return responseMessage.Content.Headers.ToDictionary(k => k.Key, v => v.Value.ToArray()); }
|
||||
|
||||
public string[] GetHeader(string name)
|
||||
{
|
||||
if(responseMessage.Headers.TryGetValues(name, out var values) ||
|
||||
responseMessage.Content.Headers.TryGetValues(name, out values))
|
||||
return values.ToArray();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public bool TryGetHeader(string name, out string[] values)
|
||||
{
|
||||
if(responseMessage.Headers.TryGetValues(name, out var headerValues) ||
|
||||
responseMessage.Content.Headers.TryGetValues(name, out headerValues))
|
||||
{
|
||||
values = headerValues.ToArray();
|
||||
return true;
|
||||
}
|
||||
|
||||
values = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
public string? GetFirstHeaderValue(string name) { return responseMessage.GetHeader(name).FirstOrDefault(); }
|
||||
|
||||
public bool TryGetFirstHeaderValue(string name, out string value)
|
||||
{
|
||||
var values = responseMessage.GetHeader(name);
|
||||
if(values.Length == 0)
|
||||
{
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
value = values.First();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void EnsureSuccessStatusCode()
|
||||
{
|
||||
if(!responseMessage.IsSuccess)
|
||||
throw new HttpRequestException(
|
||||
$"HTTP request failed with status code {responseMessage.StatusCode}: {responseMessage.ReasonPhrase}");
|
||||
}
|
||||
|
||||
public async Task EnsureSuccessStatusCodeWithContentAsync(CancellationToken ct = default)
|
||||
{
|
||||
if(!responseMessage.IsSuccess)
|
||||
{
|
||||
var content = await responseMessage
|
||||
.AsStringAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
throw new HttpRequestException(
|
||||
$"HTTP request failed with status code {responseMessage.StatusCode}: {responseMessage.ReasonPhrase}. Response content: {content}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public class HttpRoute(HttpMethod method, string path) : Attribute
|
||||
{
|
||||
public HttpMethod Method { get; } = method;
|
||||
public string Path { get; } = path;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
/// <summary>
|
||||
/// 用于 <see cref="HttpServer"/> 响应客户端请求的服务端响应结构。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class HttpRouteResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// HTTP 状态码
|
||||
/// </summary>
|
||||
public HttpStatusCode? StatusCode = null;
|
||||
|
||||
/// <summary>
|
||||
/// 此响应 <see cref="InputStream"/> 使用的字符编码
|
||||
/// </summary>
|
||||
public Encoding? ContentEncoding = null;
|
||||
|
||||
/// <summary>
|
||||
/// [Header] 内容 MIME 类型
|
||||
/// </summary>
|
||||
public string? ContentType = null;
|
||||
|
||||
/// <summary>
|
||||
/// [Header] 重定向目标 URL 或路径。
|
||||
/// </summary>
|
||||
public string? RedirectLocation = null;
|
||||
|
||||
/// <summary>
|
||||
/// [Header] 是否使用分块传输编码。
|
||||
/// </summary>
|
||||
public bool? SendChunked = null;
|
||||
|
||||
/// <summary>
|
||||
/// 随响应添加的 Cookies。
|
||||
/// </summary>
|
||||
public CookieCollection? Cookies = null;
|
||||
|
||||
/// <summary>
|
||||
/// 用于传输响应内容的输入流,若非空值,该流将被直接 <c>CopyTo</c> 到实际响应的 <c>OutputStream</c> 中。
|
||||
/// </summary>
|
||||
public Stream? InputStream = null;
|
||||
|
||||
/// <summary>
|
||||
/// 向标准 <see cref="HttpListener"/> 的响应对象写入数据。
|
||||
/// </summary>
|
||||
/// <param name="target">目标对象</param>
|
||||
public void Pour(HttpListenerResponse target)
|
||||
{
|
||||
target.StatusCode = (int)(StatusCode ?? HttpStatusCode.OK);
|
||||
if (ContentType is {} contentType) target.ContentType = contentType;
|
||||
if (ContentEncoding is {} contentEncoding) target.ContentEncoding = contentEncoding;
|
||||
if (RedirectLocation is {} redirectLocation) target.RedirectLocation = redirectLocation;
|
||||
if (SendChunked is {} sendChunked) target.SendChunked = sendChunked;
|
||||
if (Cookies is {} cookies) target.Cookies = cookies;
|
||||
if (InputStream is {} inputStream) inputStream.CopyTo(target.OutputStream);
|
||||
}
|
||||
|
||||
public Task<HttpRouteResponse> AsTask() => Task.FromResult(this);
|
||||
|
||||
/// <summary>
|
||||
/// 返回指定 HTTP 状态码的空响应
|
||||
/// </summary>
|
||||
/// <param name="statusCode">HTTP 状态码</param>
|
||||
public static HttpRouteResponse Empty(HttpStatusCode statusCode) => new() { StatusCode = statusCode };
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 204 (No Content) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse NoContent = Empty(HttpStatusCode.NoContent);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 400 (Bad Request) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse BadRequest = Empty(HttpStatusCode.BadRequest);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 403 (Forbidden) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse Forbidden = Empty(HttpStatusCode.Forbidden);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 404 (Not Found) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse NotFound = Empty(HttpStatusCode.NotFound);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 500 (Internal Server Error) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse InternalServerError = Empty(HttpStatusCode.InternalServerError);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 502 (Bad Gateway) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse BadGateway = Empty(HttpStatusCode.BadGateway);
|
||||
|
||||
/// <summary>
|
||||
/// 响应指定输入流的内容。
|
||||
/// </summary>
|
||||
/// <param name="stream">输入流</param>
|
||||
/// <param name="contentType">内容 MIME 类型</param>
|
||||
/// <param name="encoding">输入流内容使用的字符编码,默认为 UTF-8</param>
|
||||
public static HttpRouteResponse Input(Stream stream, string contentType = "application/octet-stream", Encoding? encoding = null) =>
|
||||
new() { InputStream = stream, ContentType = contentType, ContentEncoding = encoding ?? Encoding.UTF8 };
|
||||
|
||||
/// <summary>
|
||||
/// 响应指定文本内容。
|
||||
/// </summary>
|
||||
/// <param name="text">文本内容</param>
|
||||
/// <param name="contentType">内容 MIME 类型</param>
|
||||
/// <param name="encoding">响应流使用的字符编码,默认为 UTF-8</param>
|
||||
public static HttpRouteResponse Text(string text, string contentType = "text/plain", Encoding? encoding = null) =>
|
||||
Input(new StringStream(text, encoding), contentType, encoding);
|
||||
|
||||
/// <summary>
|
||||
/// 响应重定向。
|
||||
/// </summary>
|
||||
/// <param name="location">重定向目标 URL 或路径</param>
|
||||
/// <param name="statusCode">重定向状态码</param>
|
||||
public static HttpRouteResponse Redirect(string location, HttpStatusCode statusCode = HttpStatusCode.Found) =>
|
||||
new() { StatusCode = statusCode, RedirectLocation = location };
|
||||
|
||||
/// <summary>
|
||||
/// 响应指定对象序列化得到的 JSON 内容,固定使用 UTF-8 编码
|
||||
/// </summary>
|
||||
/// <param name="obj">用于序列化的对象</param>
|
||||
/// <param name="options">JSON 序列化选项</param>
|
||||
public static HttpRouteResponse Json(object obj, JsonSerializerOptions? options)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
JsonSerializer.Serialize(stream, obj, options ?? JsonCompat.SerializerOptions);
|
||||
stream.Position = 0;
|
||||
return new HttpRouteResponse
|
||||
{
|
||||
ContentEncoding = Encoding.UTF8,
|
||||
ContentType = "application/json, charset=utf-8",
|
||||
InputStream = stream
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 响应指定对象序列化得到的 JSON 内容,固定使用 UTF-8 编码
|
||||
/// </summary>
|
||||
/// <param name="obj">用于序列化的对象</param>
|
||||
public static HttpRouteResponse Json(object obj) => Json(obj, JsonCompat.SerializerOptions);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Exts;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpSenderExtension
|
||||
{
|
||||
extension(HttpRequestMessage requestMessage)
|
||||
{
|
||||
public async Task<HttpResponseMessage> SendAsync(
|
||||
HttpClient? httpClient = null,
|
||||
bool addMetedata = true,
|
||||
bool enableLogging = true,
|
||||
HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead,
|
||||
int retryTimes = 3,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = requestMessage;
|
||||
httpClient ??= NetworkService.GetClient();
|
||||
|
||||
if(addMetedata)
|
||||
{
|
||||
request
|
||||
.WithHeader("User-Agent", $"PCL-Community/PCL2-CE/{Basics.VersionName} (pclc.cc)")
|
||||
.WithHeader("Referer", $"https://{Basics.VersionCode}.ce.open.pcl2.server/");
|
||||
}
|
||||
|
||||
var requestId = Guid.NewGuid().ToString();
|
||||
if (enableLogging)
|
||||
LogWrapper.Info(
|
||||
"Request",
|
||||
$"Send request to {request.RequestUri} (method = {request.Method}, id = {requestId})");
|
||||
|
||||
var resp = await NetworkService.GetRetryPolicy(retryTimes)
|
||||
.ExecuteAsync(
|
||||
async token =>
|
||||
{
|
||||
if (enableLogging)
|
||||
LogWrapper.Debug("Request", $"Try attempt (id = {requestId})");
|
||||
try
|
||||
{
|
||||
using var requestCopy = await request
|
||||
.CloneAsync()
|
||||
.ConfigureAwait(false);
|
||||
return await httpClient
|
||||
.SendAsync(requestCopy, httpCompletionOption, token)
|
||||
.ConfigureAwait(false);
|
||||
}catch(Exception ex)
|
||||
{
|
||||
LogWrapper.Debug(ex, "Request", $"Try attempt failed (id = {requestId})");
|
||||
throw;
|
||||
}
|
||||
},cancellationToken
|
||||
)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (enableLogging)
|
||||
LogWrapper.Info("Request", $"End request, got http status code {resp.StatusCode} (id = {requestId})");
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public abstract class HttpServer : IDisposable
|
||||
{
|
||||
private readonly HttpListener _server = new();
|
||||
public readonly ushort Port;
|
||||
public readonly string[] Host;
|
||||
|
||||
private Task? _handleLoop;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private readonly Dictionary<(HttpMethod method, string path), Func<HttpListenerRequest, Task<HttpRouteResponse>>> _handlers = new();
|
||||
private bool _initialized = false;
|
||||
|
||||
protected HttpServer(IPAddress[] listenAddr, ushort port = 0)
|
||||
{
|
||||
// Check parameters
|
||||
ArgumentNullException.ThrowIfNull(listenAddr);
|
||||
|
||||
// Resolve port
|
||||
if (port == 0) port = (ushort)NetworkHelper.NewTcpPort();
|
||||
Port = port;
|
||||
|
||||
// Resolve host
|
||||
if (listenAddr.Length == 0)
|
||||
listenAddr = [IPAddress.Loopback, IPAddress.IPv6Loopback];
|
||||
|
||||
var hosts = new List<string>();
|
||||
foreach (var address in listenAddr)
|
||||
{
|
||||
_server.Prefixes.Add($"http://{address}:{port}/");
|
||||
hosts.Add(address.ToString());
|
||||
}
|
||||
Host = hosts.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化路由。子类应在此方法中调用 Register 方法注册路由。
|
||||
/// </summary>
|
||||
protected abstract void Init();
|
||||
|
||||
/// <summary>
|
||||
/// 注册一个路由处理器。
|
||||
/// </summary>
|
||||
/// <param name="method">HTTP 方法</param>
|
||||
/// <param name="path">路由路径</param>
|
||||
/// <param name="handler">请求处理函数</param>
|
||||
protected void Register(HttpMethod method, string path, Func<HttpListenerRequest, Task<HttpRouteResponse>> handler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(method);
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
|
||||
_handlers[(method, path)] = handler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动 HTTP 服务器。
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
// 如果没有注册路由,调用 Init 初始化
|
||||
if (!_initialized && _handlers.Count == 0)
|
||||
{
|
||||
Init();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
_server.Start();
|
||||
_handleLoop = _HandleRequestAsync();
|
||||
}
|
||||
|
||||
private async Task _HandleRequestAsync()
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = await _server.GetContextAsync();
|
||||
_ = Task.Run(async () => await _ProcessRequestAsync(context), cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) { break; } // Cancellation
|
||||
catch (ObjectDisposedException) { break; } // Disposed
|
||||
catch (HttpListenerException) { break; } // Closed
|
||||
}
|
||||
}
|
||||
|
||||
private async Task _ProcessRequestAsync(HttpListenerContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = context.Request;
|
||||
var response = context.Response;
|
||||
var path = request.Url?.AbsolutePath ?? string.Empty;
|
||||
var method = new HttpMethod(request.HttpMethod);
|
||||
|
||||
// 首先尝试精确匹配
|
||||
if (_handlers.TryGetValue((method, path), out var handler))
|
||||
{
|
||||
await _ExecuteHandlerAsync(handler, request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果没有精确匹配,尝试通配符匹配
|
||||
if (_handlers.TryGetValue((method, "*"), out var wildcardHandler))
|
||||
{
|
||||
await _ExecuteHandlerAsync(wildcardHandler, request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 没有找到匹配的路由
|
||||
response.StatusCode = (int)HttpStatusCode.NotFound;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
context.Response.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors when closing response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task _ExecuteHandlerAsync(Func<HttpListenerRequest, Task<HttpRouteResponse>> handler, HttpListenerRequest request, HttpListenerResponse response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var routeResponse = await handler(request);
|
||||
routeResponse.Pour(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||
response.ContentEncoding = System.Text.Encoding.UTF8;
|
||||
response.ContentType = "text/plain";
|
||||
var errorResponse =
|
||||
HttpRouteResponse.Text($"Internal Server Error:\n{ex}", "text/plain", System.Text.Encoding.UTF8);
|
||||
errorResponse.Pour(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止 HTTP 服务器。
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_server.Stop();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
Stop();
|
||||
_server.Close();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace PCL.Core.IO.Net;
|
||||
|
||||
public static class NetworkHelper
|
||||
{
|
||||
public static int NewTcpPort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
public static bool IsNetworkAvailable()
|
||||
{
|
||||
return NetworkInterface.GetIsNetworkAvailable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace PCL.Core.IO.Net;
|
||||
|
||||
public static class NetworkInterfaceUtils
|
||||
{
|
||||
public static List<NetworkInterface> GetAvailableInterface()
|
||||
{
|
||||
return NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(iface => !_IsVirtualInterface(iface))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public enum IPv6Status
|
||||
{
|
||||
Unknown,
|
||||
Public,
|
||||
RFC4193,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
public static IPv6Status GetIPv6Status()
|
||||
{
|
||||
foreach (var iface in GetAvailableInterface())
|
||||
{
|
||||
var ipv6Addresses = iface.GetIPProperties().UnicastAddresses
|
||||
.Where(addr => addr.Address.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
.Select(addr => addr.Address)
|
||||
.ToArray();
|
||||
|
||||
if (ipv6Addresses.Length == 0)
|
||||
{
|
||||
return IPv6Status.Unavailable;
|
||||
}
|
||||
|
||||
foreach (var ip in ipv6Addresses)
|
||||
{
|
||||
if (_IsPublicIPv6(ip))
|
||||
{
|
||||
return IPv6Status.Public;
|
||||
}
|
||||
if (_IsUniqueLocalIPv6(ip))
|
||||
{
|
||||
return IPv6Status.RFC4193;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return IPv6Status.Unknown;
|
||||
}
|
||||
|
||||
private static bool _IsVirtualInterface(NetworkInterface iface)
|
||||
{
|
||||
// 常见的虚拟接口类型和名称关键词
|
||||
var virtualTypes = new[] {
|
||||
NetworkInterfaceType.Loopback,
|
||||
NetworkInterfaceType.Tunnel,
|
||||
NetworkInterfaceType.Ppp
|
||||
};
|
||||
|
||||
var virtualKeywords = new[] {
|
||||
"virtual",
|
||||
"pseudo",
|
||||
"loopback",
|
||||
"tunnel",
|
||||
"vpn",
|
||||
"ppp",
|
||||
"veth",
|
||||
"docker",
|
||||
"hyper-v",
|
||||
"vmware",
|
||||
"virtualbox"
|
||||
};
|
||||
|
||||
return virtualTypes.Contains(iface.NetworkInterfaceType) ||
|
||||
virtualKeywords.Any(keyword => iface.Description.ToLower().Contains(keyword));
|
||||
}
|
||||
|
||||
private static bool _IsPublicIPv6(IPAddress ip)
|
||||
{
|
||||
byte[] addressBytes = ip.GetAddressBytes();
|
||||
// 公网IPv6地址范围:2000::/3(即首字节在0x20到0x3F之间)
|
||||
return addressBytes[0] >= 0x20 && addressBytes[0] <= 0x3F;
|
||||
}
|
||||
|
||||
private static bool _IsUniqueLocalIPv6(IPAddress ip)
|
||||
{
|
||||
byte[] addressBytes = ip.GetAddressBytes();
|
||||
// 唯一本地地址范围:FC00::/7(即首字节为0xFC或0xFD)
|
||||
return addressBytes[0] == 0xFC || addressBytes[0] == 0xFD;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.App.IoC;
|
||||
using PCL.Core.IO.Net.Http;
|
||||
using PCL.Core.IO.Net.Http.Cache;
|
||||
using PCL.Core.IO.Storage.Cache;
|
||||
using PCL.Core.Logging;
|
||||
using Polly;
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace PCL.Core.IO.Net;
|
||||
|
||||
[LifecycleService(LifecycleState.Loading)]
|
||||
[LifecycleScope("network", "网络服务")]
|
||||
public partial class NetworkService
|
||||
{
|
||||
|
||||
private const int LifeTime = 15;
|
||||
|
||||
#region AddressDefinition
|
||||
|
||||
private const string MicrosoftEntraIdServer = "https://login.microsoftonline.com/";
|
||||
|
||||
private const string MojangPistonMetaServer = "https://piston-meta.mojang.com/";
|
||||
|
||||
private const string MojangSessionServer = "https://sessionserver.mojang.com/";
|
||||
|
||||
private const string CurseForgeApiServer = "https://api.curseforge.com/v1/";
|
||||
|
||||
private const string ModrinthApiServer = "https://api.modrinth.com/v2/";
|
||||
|
||||
private const string MinecraftServiceServer = "https://api.minecraftservices.com/";
|
||||
|
||||
#endregion
|
||||
|
||||
#region HttpClientName
|
||||
|
||||
public const string Default = "default";
|
||||
|
||||
public const string MicrosoftEntraId = "microsoft_id";
|
||||
|
||||
public const string MinecraftService = "minecraft_service";
|
||||
|
||||
public const string Cache = "cache";
|
||||
|
||||
public const string MojangPistonMeta = "mojang_piston";
|
||||
|
||||
public const string MojangSession = "mojang_session";
|
||||
|
||||
public const string CurseForgeApi = "curseforge_api";
|
||||
|
||||
public const string ModrinthApi = "modrinth_api";
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
private static ServiceProvider? _provider;
|
||||
private static IHttpClientFactory? _factory;
|
||||
|
||||
[LifecycleStart]
|
||||
private static void _Start()
|
||||
{
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.ConfigureHttpClientDefaults(b => b
|
||||
.ConfigurePrimaryHttpMessageHandler(_GetSocketsHttpHandler)
|
||||
.ConfigureHttpClient(c => c.DefaultRequestHeaders
|
||||
.UserAgent.Add(new ProductInfoHeaderValue("PCL-CE", Basics.VersionName)))
|
||||
.SetHandlerLifetime(TimeSpan.FromMinutes(LifeTime)));
|
||||
|
||||
// 默认的 HTTP Client
|
||||
|
||||
services.AddHttpClient(Default);
|
||||
|
||||
// CurseForge
|
||||
|
||||
services.AddHttpClient(CurseForgeApi).ConfigureHttpClient(c =>
|
||||
{
|
||||
c.DefaultRequestHeaders.Add("x-api-key", Secrets.CurseForgeAPIKey);
|
||||
c.BaseAddress = new Uri(CurseForgeApiServer);
|
||||
});
|
||||
|
||||
// Modrinth
|
||||
|
||||
services.AddHttpClient(ModrinthApi).ConfigureHttpClient(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(ModrinthApiServer);
|
||||
});
|
||||
|
||||
// Microsoft Entra ID
|
||||
|
||||
services.AddHttpClient(MicrosoftEntraId).ConfigureHttpClient(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(MicrosoftEntraIdServer);
|
||||
});
|
||||
|
||||
// Minecraft Service API
|
||||
|
||||
services.AddHttpClient(MinecraftService).ConfigureHttpClient(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(MinecraftServiceServer);
|
||||
});
|
||||
|
||||
// Mojang Piston Manifest
|
||||
|
||||
services.AddHttpClient(MojangPistonMeta).ConfigureHttpClient(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(MojangPistonMetaServer);
|
||||
});
|
||||
|
||||
// Mojang Session Server
|
||||
|
||||
services.AddHttpClient(MojangSession).ConfigureHttpClient(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(MojangSessionServer);
|
||||
});
|
||||
|
||||
// Cache
|
||||
services.AddHttpClient(Cache)
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpCacheHandler(
|
||||
_GetSocketsHttpHandler(),CacheServiceManager.Current
|
||||
)).SetHandlerLifetime(TimeSpan.FromMinutes(LifeTime));
|
||||
|
||||
_provider?.Dispose();
|
||||
_provider = services.BuildServiceProvider();
|
||||
_factory = _provider.GetRequiredService<IHttpClientFactory>();
|
||||
}
|
||||
|
||||
[LifecycleStop]
|
||||
private static void _Stop()
|
||||
{
|
||||
_provider?.Dispose();
|
||||
}
|
||||
|
||||
private static SocketsHttpHandler _GetSocketsHttpHandler() => new SocketsHttpHandler
|
||||
{
|
||||
UseProxy = true,
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
Proxy = HttpProxyManager.Instance,
|
||||
AllowAutoRedirect = true,
|
||||
MaxAutomaticRedirections = 20,
|
||||
UseCookies = false,
|
||||
ConnectCallback = Config.Network.EnableDoH ? HostConnectionHandler.Instance.GetConnectionAsync : null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 获取 HttpClient
|
||||
/// </summary>
|
||||
/// <param name="wantClientType">指定要求的 HttpClient 来源</param>
|
||||
/// <returns>HttpClient 实例</returns>
|
||||
public static HttpClient GetClient(string wantClientType = "default")
|
||||
{
|
||||
return _factory?.CreateClient(wantClientType) ??
|
||||
throw new InvalidOperationException("在初始化完成前的意外调用");
|
||||
}
|
||||
|
||||
private const int BaseRetryDelayMs = 1000;
|
||||
private const int MaxRetryDelayMs = 30000;
|
||||
|
||||
private static TimeSpan _DefaultSleepDurationProvider(int attempt)
|
||||
{
|
||||
var delayMs = Math.Pow(2, attempt - 1) * BaseRetryDelayMs;
|
||||
delayMs = Math.Min(delayMs, MaxRetryDelayMs);
|
||||
return TimeSpan.FromMilliseconds(delayMs);
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取重试策略
|
||||
/// </summary>
|
||||
/// <param name="retry">最大重试次数</param>
|
||||
/// <param name="retryPolicy">定义重试器行为</param>
|
||||
/// <returns>AsyncPolicy</returns>
|
||||
public static AsyncPolicy GetRetryPolicy(int retry = 3, Func<int, TimeSpan>? retryPolicy = null)
|
||||
{
|
||||
retryPolicy ??= _DefaultSleepDurationProvider;
|
||||
|
||||
return Policy
|
||||
.Handle<HttpRequestException>()
|
||||
.WaitAndRetryAsync(
|
||||
retry,
|
||||
attempt => retryPolicy.Invoke(attempt),
|
||||
onRetry: (exception, timeSpan, retryAttempt, _) =>
|
||||
{
|
||||
LogWrapper.Debug(
|
||||
exception,
|
||||
"Network",
|
||||
$"HTTP 请求失败,正在进行第 {retryAttempt} 次重试,等待 {timeSpan.TotalMilliseconds} 毫秒。"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace PCL.Core.IO.Net;
|
||||
|
||||
public static class SocketExtensions
|
||||
{
|
||||
public static void SafeClose(this Socket? socket)
|
||||
{
|
||||
if (socket is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (socket.Connected)
|
||||
{
|
||||
socket.Shutdown(SocketShutdown.Both);
|
||||
}
|
||||
socket.Close();
|
||||
}
|
||||
catch { /* 忽略关闭时的任何错误 */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Logging;
|
||||
|
||||
namespace PCL.Core.IO.Net;
|
||||
public sealed class TcpForward(
|
||||
IPAddress listenAddress,
|
||||
int listenPort,
|
||||
IPAddress targetAddress,
|
||||
int targetPort,
|
||||
int maxConnections = 10)
|
||||
: IDisposable
|
||||
{
|
||||
private Socket? _listenerSocket;
|
||||
private CancellationTokenSource? _cts;
|
||||
private readonly SemaphoreSlim _connectionSemaphore = new(maxConnections, maxConnections);
|
||||
private readonly ConcurrentDictionary<Guid, ConnectionPair> _activeConnections = new();
|
||||
|
||||
private bool _isRunning;
|
||||
|
||||
public int LocalPort { get; private set; }
|
||||
|
||||
public int ActiveConnections => _activeConnections.Count;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_isRunning) return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_isRunning = true;
|
||||
|
||||
try
|
||||
{
|
||||
// 创建并启动监听 Socket
|
||||
_listenerSocket = new Socket(SocketType.Stream, ProtocolType.Tcp)
|
||||
{
|
||||
NoDelay = true, // 禁用 Nagle 算法以提高响应速度
|
||||
ReceiveBufferSize = 8192,
|
||||
SendBufferSize = 8192
|
||||
};
|
||||
|
||||
_listenerSocket.Bind(new IPEndPoint(listenAddress, listenPort));
|
||||
_listenerSocket.Listen(100); // 设置挂起连接队列的最大长度
|
||||
|
||||
if (_listenerSocket.LocalEndPoint is not IPEndPoint endPoint) throw new InvalidCastException("出现了意外的转换操作");
|
||||
LocalPort = endPoint.Port;
|
||||
|
||||
// 启动 TCP 接受连接任务
|
||||
_ = Task.Run(() => _AcceptConnectionsAsync(_cts.Token), _cts.Token);
|
||||
|
||||
LogWrapper.Info("TcpForward", $"MC 端口转发已启动,监听 {listenAddress}:{LocalPort},目标 {targetAddress}:{targetPort}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_isRunning = false;
|
||||
LogWrapper.Error(ex, "TcpForward", $"启动 MC 端口转发时发生错误: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!_isRunning) return;
|
||||
|
||||
_cts?.Cancel();
|
||||
_isRunning = false;
|
||||
|
||||
// 关闭所有活动连接
|
||||
foreach (var connection in _activeConnections.Values)
|
||||
{
|
||||
connection.ClientSocket.SafeClose();
|
||||
connection.TargetSocket.SafeClose();
|
||||
}
|
||||
_activeConnections.Clear();
|
||||
|
||||
_listenerSocket?.SafeClose();
|
||||
|
||||
LogWrapper.Info("TcpForward", "MC 端口转发已停止");
|
||||
}
|
||||
|
||||
private async Task _AcceptConnectionsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.Register(() =>
|
||||
{
|
||||
_listenerSocket.SafeClose();
|
||||
});
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_listenerSocket is null) break;
|
||||
var clientSocket = await _listenerSocket.AcceptAsync(cancellationToken);
|
||||
|
||||
// 检查是否达到最大连接限制
|
||||
if (_activeConnections.Count >= maxConnections)
|
||||
{
|
||||
clientSocket.SafeClose();
|
||||
LogWrapper.Warn("TcpForward", $"已达到最大连接数限制({maxConnections}),拒绝新连接");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 使用信号量控制并发处理
|
||||
await _connectionSemaphore.WaitAsync(cancellationToken);
|
||||
|
||||
// 异步处理连接,不等待完成
|
||||
_ = Task.Run(() => _HandleConnectionAsync(clientSocket, cancellationToken), cancellationToken)
|
||||
.ContinueWith(_ => _connectionSemaphore.Release(), TaskScheduler.Default);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "TcpForward", $"接受连接时发生错误");
|
||||
await Task.Delay(1000, cancellationToken); // 出错后等待 1 秒再继续
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task _HandleConnectionAsync(Socket clientSocket, CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionId = Guid.NewGuid();
|
||||
|
||||
try
|
||||
{
|
||||
LogWrapper.Info("TcpForward", $"接受来自 {clientSocket.RemoteEndPoint} 的连接");
|
||||
|
||||
// 连接到目标服务器
|
||||
var targetSocket = new Socket(SocketType.Stream, ProtocolType.Tcp)
|
||||
{
|
||||
NoDelay = true,
|
||||
ReceiveBufferSize = 8192,
|
||||
SendBufferSize = 8192
|
||||
};
|
||||
|
||||
await targetSocket.ConnectAsync(targetAddress, targetPort, cancellationToken);
|
||||
|
||||
// 保存连接对
|
||||
var connectionPair = new ConnectionPair(clientSocket, targetSocket);
|
||||
_activeConnections[connectionId] = connectionPair;
|
||||
|
||||
LogWrapper.Info("TcpForward", $"开始端口转发 {clientSocket.RemoteEndPoint} <-> {targetSocket.RemoteEndPoint}({connectionId})");
|
||||
|
||||
// 使用高性能的 SocketAsyncEventArgs 进行双向转发
|
||||
var forwardTask1 = _ForwardDataAsync(clientSocket, targetSocket, cancellationToken);
|
||||
var forwardTask2 = _ForwardDataAsync(targetSocket, clientSocket, cancellationToken);
|
||||
|
||||
// 等待任意一个方向的数据转发完成
|
||||
await Task.WhenAny(forwardTask1, forwardTask2);
|
||||
|
||||
Console.WriteLine($"端口转发 {connectionId} 已完成");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 取消操作,正常退出
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"处理连接 {connectionId} 时发生错误: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 清理资源
|
||||
clientSocket.SafeClose();
|
||||
|
||||
// 从活动连接中移除
|
||||
_activeConnections.TryRemove(connectionId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task _ForwardDataAsync(Socket source, Socket destination, CancellationToken cancellationToken)
|
||||
{
|
||||
using var bufferOwner = MemoryPool<byte>.Shared.Rent(8192);
|
||||
try
|
||||
{
|
||||
var buffer = bufferOwner.Memory;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var bytesRead = await source.ReceiveAsync(buffer, SocketFlags.None, cancellationToken);
|
||||
if (bytesRead == 0) break; // 连接已关闭
|
||||
|
||||
await destination.SendAsync(buffer[..bytesRead], SocketFlags.None, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch {/* 忽略错误 */}
|
||||
}
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void _Dispose(bool disposing)
|
||||
{
|
||||
if (!disposing) return;
|
||||
if (_disposed) return;
|
||||
Stop();
|
||||
_cts?.Dispose();
|
||||
_connectionSemaphore.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
~TcpForward()
|
||||
{
|
||||
_Dispose(false);
|
||||
}
|
||||
|
||||
private class ConnectionPair(Socket clientSocket, Socket targetSocket)
|
||||
{
|
||||
public Socket ClientSocket { get; } = clientSocket;
|
||||
public Socket TargetSocket { get; } = targetSocket;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.OS;
|
||||
|
||||
namespace PCL.Core.IO;
|
||||
|
||||
public static class PipeComm
|
||||
{
|
||||
private static void _PipeLog(string message) => LogWrapper.Trace("Pipe", message);
|
||||
private static void _PipeLogDebug(string message) => LogWrapper.Debug("Pipe", message);
|
||||
|
||||
/// <summary>
|
||||
/// 用于命名管道通信的统一字符编码
|
||||
/// </summary>
|
||||
public static readonly Encoding PipeEncoding = Encoding.UTF8;
|
||||
|
||||
/// <summary>
|
||||
/// 用于命名管道通信的统一终止符
|
||||
/// </summary>
|
||||
public const char PipeEndingChar = (char)27; // '\e' (ESC)
|
||||
|
||||
/// <summary>
|
||||
/// 在新的工作线程启动命名管道服务端
|
||||
/// </summary>
|
||||
/// <param name="identifier">服务端标识,用于日志标识及工作线程的命名</param>
|
||||
/// <param name="pipeName">命名管道名称</param>
|
||||
/// <param name="loopCallback">客户端连接后的回调函数,将会提供用于读取和写入数据的流,以及客户端进程 ID,返回 <c>true</c> 表示继续等待下一个客户端连接,返回 <c>false</c> 则停止服务端运行</param>
|
||||
/// <param name="stopCallback">服务端停止后的回调函数</param>
|
||||
/// <param name="stopWhenException">指定当回调函数抛出异常时是否停止服务端运行,使用 <c>true</c> 表示停止</param>
|
||||
/// <param name="allowedProcessId">允许连接的客户端进程 ID,如为 Nothing 则允许所有</param>
|
||||
public static NamedPipeServerStream StartPipeServer(string identifier, string pipeName, Func<StreamReader, StreamWriter, Process?, bool> loopCallback, Action? stopCallback = null, bool stopWhenException = false, int[]? allowedProcessId = null)
|
||||
{
|
||||
var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.None, 1024, 1024);
|
||||
var threadName = $"PipeServer/{identifier}";
|
||||
|
||||
Basics.RunInNewThread(() =>
|
||||
{
|
||||
LogWrapper.Debug("Pipe", $"{identifier}: {pipeName} 服务端已在 '{threadName}' 工作线程启动");
|
||||
var hasNextLoop = true;
|
||||
var connected = false;
|
||||
|
||||
while (hasNextLoop)
|
||||
{
|
||||
try
|
||||
{
|
||||
hasNextLoop = false;
|
||||
pipe.WaitForConnection(); // 等待客户端连接
|
||||
// 获取客户端进程实例并校验
|
||||
Process? clientProcess = null;
|
||||
var clientProcessId = 0;
|
||||
try
|
||||
{
|
||||
var pid = KernelInterop.GetNamedPipeClientProcessId(pipe.SafePipeHandle.DangerousGetHandle());
|
||||
clientProcessId = (int)pid;
|
||||
if (allowedProcessId is not null)
|
||||
{
|
||||
var denied = allowedProcessId.All(id => id != clientProcessId);
|
||||
if (denied)
|
||||
{
|
||||
hasNextLoop = true;
|
||||
pipe.Disconnect();
|
||||
_PipeLog($"[Pipe] {identifier}: 已拒绝 {clientProcessId}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
clientProcess = Process.GetProcessById(clientProcessId);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
if (allowedProcessId is not null)
|
||||
{
|
||||
hasNextLoop = true;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
connected = true;
|
||||
LogWrapper.Debug("Pipe", $"{identifier}: {clientProcessId} 已连接");
|
||||
// 初始化读取/写入流
|
||||
var reader = new StreamReader(pipe, PipeEncoding, false, 1024, true);
|
||||
var writer = new StreamWriter(pipe, PipeEncoding, 1024, true);
|
||||
// 执行回调函数
|
||||
hasNextLoop = loopCallback(reader, writer, clientProcess);
|
||||
// 写入终止符
|
||||
writer.Write(PipeEndingChar);
|
||||
writer.Flush(); // 刷新写入缓冲
|
||||
reader.Read(); // 等待客户端
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!pipe.IsConnected && connected && ex is IOException)
|
||||
{
|
||||
_PipeLogDebug($"{identifier}: 客户端连接已丢失");
|
||||
hasNextLoop = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogWrapper.Warn(ex, "Pipe", $"{identifier}: 服务端出错");
|
||||
if (stopWhenException) hasNextLoop = false;
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
pipe.Disconnect();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// 由于没妈的巨硬给的 IsConnected 不一定是准确的,需要运行 Disconnect() 确保管道断开连接
|
||||
// 如果已经断开会抛出 InvalidOperationException 这里直接忽略掉
|
||||
}
|
||||
connected = false;
|
||||
_PipeLogDebug($"{identifier}: 已断开连接");
|
||||
}
|
||||
|
||||
// 释放资源并执行停止回调
|
||||
pipe.Dispose();
|
||||
_PipeLogDebug($"{identifier}: 服务端已停止");
|
||||
stopCallback?.Invoke();
|
||||
}, threadName);
|
||||
|
||||
return pipe;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using PCL.Core.IO.Storage.Cache.Model;
|
||||
using PCL.Core.Logging;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache;
|
||||
|
||||
internal class CacheEvictionService(SqliteCacheStorage db, FileCacheStorage files, CacheOptions options)
|
||||
{
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _loop;
|
||||
private readonly object _startLock = new();
|
||||
|
||||
public void Start()
|
||||
{
|
||||
lock (_startLock)
|
||||
{
|
||||
if (_loop is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_loop = Task.Run(() => _EvictionLoopAsync(_cts.Token));
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (_startLock)
|
||||
{
|
||||
if (_cts is not null)
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
|
||||
_loop = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void CheckThreshold()
|
||||
{
|
||||
// NOTE: this method will not be implemented
|
||||
// because current eviction strategy is enough to keep the cache size under control without checking threshold after each write operation.
|
||||
}
|
||||
|
||||
private async Task _EvictionLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(options.EvictionInterval, ct).ConfigureAwait(false);
|
||||
await db.DeleteExpiredAsync(DateTime.UtcNow, ct).ConfigureAwait(false);
|
||||
|
||||
var stats = await db.GetStatsAsync(ct).ConfigureAwait(false);
|
||||
var excess = stats.TotalSizeBytes - (options.MaxCacheSize - options.ReserveBytes);
|
||||
if (excess > 0)
|
||||
{
|
||||
await _EvictAsync(excess, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Warn(ex, "CacheEviction", $"An error occurred while evicting cache entries:");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task _EvictAsync(long targetBytes, CancellationToken ct)
|
||||
{
|
||||
var freed = 0L;
|
||||
const int batchSize = 50;
|
||||
|
||||
while (freed < targetBytes && !ct.IsCancellationRequested)
|
||||
{
|
||||
var candidates = await db.GetEvictionCandidatesAsync(batchSize, ct).ConfigureAwait(false);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var can in candidates)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await db.DeleteAsync(can.CacheKey!, ct).ConfigureAwait(false);
|
||||
|
||||
if (can.EntryType is EntryType.FileRef && can.FileHash is not null)
|
||||
{
|
||||
await files.ForceDeleteAsync(can.FileHash).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
freed += can.DataSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record CacheEntryEvictedEventArgs(string Key, long DataSize, int Priority, long HitCount);
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache;
|
||||
|
||||
public class CacheKeys
|
||||
{
|
||||
#region Instance
|
||||
|
||||
/// <summary>实例元数据缓存键</summary>
|
||||
public static string InstanceMeta(string instancePath)
|
||||
=> _Build("instance", "meta", _HashSegment(instancePath));
|
||||
|
||||
/// <summary>实例 version.json 缓存键</summary>
|
||||
public static string InstanceManifest(string instancePath, string versionId)
|
||||
=> _Build("instance", "manifest", _HashSegment(instancePath), versionId);
|
||||
|
||||
/// <summary>实例指定类型的组件缓存键(mods/rp/shader/saves)</summary>
|
||||
public static string InstanceComponents(string instancePath, string compType)
|
||||
=> _Build("instance", "components", _HashSegment(instancePath), compType);
|
||||
|
||||
/// <summary>实例中单个组件文件缓存键</summary>
|
||||
public static string InstanceComponentFile(string instancePath, string fileHash)
|
||||
=> _Build("instance", "component", _HashSegment(instancePath), fileHash);
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region Download
|
||||
|
||||
/// <summary>URL 下载缓存键(去重)</summary>
|
||||
public static string Download(string url)
|
||||
=> _Build("download", _HashSegment(url));
|
||||
|
||||
/// <summary>Library 文件缓存键</summary>
|
||||
public static string Library(string mavenGroup, string artifact, string version)
|
||||
=> _Build("library", mavenGroup, artifact, version);
|
||||
|
||||
/// <summary>Asset 索引 JSON 缓存键</summary>
|
||||
public static string AssetIndex(string url)
|
||||
=> _Build("assets", "index", _HashSegment(url));
|
||||
|
||||
/// <summary>Asset 对象文件缓存键(由哈希定位)</summary>
|
||||
public static string AssetObject(string assetHash)
|
||||
=> _Build("assets", "object", assetHash);
|
||||
#endregion
|
||||
|
||||
#region API/Network
|
||||
|
||||
/// <summary>API 响应缓存键</summary>
|
||||
public static string ApiResponse(string source, string url)
|
||||
=> _Build("http", source, _HashSegment(url));
|
||||
|
||||
public static string ApiResponseMeta(string source, string url)
|
||||
=> _Build("http", "meta", source, _HashSegment(url));
|
||||
|
||||
/// <summary>模组市场搜索缓存键</summary>
|
||||
public static string CompSearch(string source, string query, int page)
|
||||
=> _Build("comp", "search", source, _HashSegment(query), page.ToString());
|
||||
|
||||
/// <summary>皮肤/头像缓存键</summary>
|
||||
public static string Skin(string url)
|
||||
=> _Build("skin", _HashSegment(url));
|
||||
|
||||
/// <summary>图片文件缓存键</summary>
|
||||
public static string Image(string url)
|
||||
=> _Build("image", _HashSegment(url));
|
||||
|
||||
/// <summary>新闻/公告缓存键</summary>
|
||||
public static string News(string url)
|
||||
=> _Build("news", _HashSegment(url));
|
||||
#endregion
|
||||
|
||||
#region Account
|
||||
|
||||
/// <summary>OAuth Token 缓存键(10min 滑动过期)</summary>
|
||||
public static string AuthToken(string accountId)
|
||||
=> _Build("auth", "token", accountId);
|
||||
|
||||
/// <summary>账户列表缓存键(短 TTL)</summary>
|
||||
public static string AccountList()
|
||||
=> "accounts:list"; // 固定键,无需哈希
|
||||
#endregion
|
||||
|
||||
private static string _Build(params string[] segments)
|
||||
=> string.Join(':', segments);
|
||||
|
||||
/// <summary>
|
||||
/// 对动态内容(路径、URL、长字符串)做 SHA256 哈希,取前 32 字符。
|
||||
/// 静态已知数据(枚举值、类型名)不哈希,保持可读性。
|
||||
/// </summary>
|
||||
private static string _HashSegment(string raw)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant()[..32];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache;
|
||||
|
||||
public record CacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// SQLite database file path.
|
||||
/// </summary>
|
||||
public required string DatabasePath { get; init; }
|
||||
/// <summary>
|
||||
/// File cache root directory. (Used for file-mapped storage mode)
|
||||
/// </summary>
|
||||
public required string FileCacheRoot { get; init; }
|
||||
/// <summary>
|
||||
/// Max physical cache size. When the total cache size exceeds this limit, the eviction process will be triggered to free up space.<br/>
|
||||
/// <b>(Default: 2 GiB)</b>
|
||||
/// </summary>
|
||||
public long MaxCacheSize { get; init; } = 2L * 1024 * 1024 * 1024; // 2 GiB
|
||||
/// <summary>
|
||||
/// SQLite inline storage size limit. Cache entries smaller than or equal to this size will be stored directly in the SQLite database, while larger entries will be stored as file-mapped.<br/>
|
||||
/// </summary>
|
||||
public int MaxInlineSize { get; init; } = 256 * 1024; // 256 KiB
|
||||
/// <summary>
|
||||
/// Background eviction interval. The cache will automatically check for expired entries and evict them at this interval.<br/>
|
||||
/// <b>(Default: 5 minutes)</b>
|
||||
/// </summary>
|
||||
public TimeSpan EvictionInterval { get; init; } = TimeSpan.FromMinutes(5);
|
||||
/// <summary>
|
||||
/// Reserve bytes for the cache. This amount of space will always be reserved for the cache, even when eviction is triggered.<br/>
|
||||
/// <b>(Default: 256 MiB)</b>
|
||||
/// </summary>
|
||||
public long ReserveBytes { get; init; } = 256L * 1024 * 1024; // 256 MiB
|
||||
/// <summary>
|
||||
/// Whether to enable compression for cache entries. (Enabled by default)<br/>
|
||||
/// </summary>
|
||||
public bool EnableCompression { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the caching policy for managing cached items.<br/>
|
||||
/// <code>CachePolicy.Default = AbsoluteExpiration = TimeSpan.FromHours(1), Normal (Priority), Auto (StorageMode)</code>
|
||||
/// </summary>
|
||||
public record CachePolicy
|
||||
{
|
||||
public static readonly CachePolicy Default = new();
|
||||
|
||||
public static readonly CachePolicy NeverExpire = new()
|
||||
{
|
||||
SlidingExpiration = null,
|
||||
AbsoluteExpiration = null,
|
||||
Priority = CachePriority.NeverEvict
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the absolute expiration time for the cached item. (Calculated from the time the item was cached)<br/>
|
||||
/// </summary>
|
||||
public TimeSpan? AbsoluteExpiration { get; init; } = TimeSpan.FromHours(1);
|
||||
/// <summary>
|
||||
/// Gets the sliding expiration time for the cached item. (Resets the expiration timer each time the item is accessed)<br/>
|
||||
/// </summary>
|
||||
public TimeSpan? SlidingExpiration { get; init; }
|
||||
/// <summary>
|
||||
/// Gets the priority of the cached item. (Determines eviction order when the cache needs to free up space)
|
||||
/// </summary>
|
||||
public CachePriority Priority { get; init; } = CachePriority.Normal;
|
||||
/// <summary>
|
||||
/// Gets the storage mode for the cached item.<br/>
|
||||
/// Auto <= 256KiB: Inline, > 256KiB: FileMapped
|
||||
/// </summary>
|
||||
public CacheStorageMode StorageMode { get; init; } = CacheStorageMode.Auto;
|
||||
/// <summary>
|
||||
/// Group. Used for grouping related cached items together, allowing for bulk operations like eviction or retrieval based on the group key.
|
||||
/// </summary>
|
||||
public string? Group { get; init; }
|
||||
/// <summary>
|
||||
/// Tag. Spilited by <c>','</c>. Used for multi-demension grouping.
|
||||
/// </summary>
|
||||
public string? Tags { get; init; }
|
||||
/// <summary>
|
||||
/// Content format version. (Increased when migration is needed.)
|
||||
/// </summary>
|
||||
public int ContentVersion { get; init; } = 1;
|
||||
/// <summary>
|
||||
/// The minimum time to live for the cached item. <br/>
|
||||
/// Used when the Internet is not stable.<br/>
|
||||
/// (Overrides Priority and StorageMode when the net is not stable)
|
||||
/// </summary>
|
||||
public TimeSpan? MinTimeToLive { get; init; }
|
||||
}
|
||||
|
||||
public enum CachePriority { Low, Normal, High, NeverEvict }
|
||||
public enum CacheStorageMode { Auto, Inline, FileMapped }
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.IO.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// The result of a cache lookup, indicating whether the value was found, the value itself if found, and the time it was cached at.
|
||||
/// </summary>
|
||||
public readonly record struct CacheResult<T>
|
||||
{
|
||||
public static readonly CacheResult<T> Miss = default;
|
||||
|
||||
public readonly bool Found;
|
||||
public readonly T? Value;
|
||||
public readonly DateTime CachedAt;
|
||||
|
||||
public static CacheResult<T> Hit(T value, DateTime cachedAt) => new(value, cachedAt);
|
||||
|
||||
private CacheResult(T value, DateTime cachedAt)
|
||||
{
|
||||
Found = true;
|
||||
Value = value;
|
||||
CachedAt = cachedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.IO.Storage.Cache.Model;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A cache service that provides asynchronous methods to store and retrieve data with support for expiration, tagging, grouping, and priority.<br/>
|
||||
/// It uses a combination of SQLite for metadata storage and the file system for large data storage.<br/>
|
||||
/// The service also includes an eviction mechanism to manage cache size and expired entries.<br/>
|
||||
/// </summary>
|
||||
public class CacheService : ICacheService, IAsyncDisposable
|
||||
{
|
||||
private readonly CacheOptions _options;
|
||||
private readonly SchemaManager _schemaManager;
|
||||
private readonly SqliteCacheStorage _db;
|
||||
private readonly FileCacheStorage _files;
|
||||
private readonly CacheEvictionService _eviction;
|
||||
|
||||
private long _hits;
|
||||
private long _misses;
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
private static readonly JsonSerializerOptions _JsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
|
||||
public CacheService()
|
||||
{
|
||||
_options = new CacheOptions
|
||||
{
|
||||
DatabasePath = Path.Combine(Paths.Temp, "Cache", "pcl_ce_cache.db"),
|
||||
FileCacheRoot = Path.Combine(Paths.Temp, "Cache", "files")
|
||||
};
|
||||
|
||||
_schemaManager = new SchemaManager($"Data Source={_options.DatabasePath}");
|
||||
_db = new SqliteCacheStorage(_options.DatabasePath);
|
||||
_files = new FileCacheStorage(_options.FileCacheRoot, _options.EnableCompression);
|
||||
_eviction = new CacheEvictionService(_db, _files, _options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unit Test constructor. This constructor allows injecting custom options for testing purposes.
|
||||
/// </summary>
|
||||
[Obsolete("This constructor is for testing purposes only.")]
|
||||
public CacheService(CacheOptions options)
|
||||
{
|
||||
_options = options;
|
||||
_schemaManager = new SchemaManager($"Data Source={_options.DatabasePath}");
|
||||
_db = new SqliteCacheStorage(_options.DatabasePath);
|
||||
_files = new FileCacheStorage(_options.FileCacheRoot, _options.EnableCompression);
|
||||
_eviction = new CacheEvictionService(_db, _files, _options);
|
||||
}
|
||||
|
||||
internal async Task InitializeAsync()
|
||||
{
|
||||
Directory.CreateDirectory(_options.FileCacheRoot);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_options.DatabasePath)!);
|
||||
|
||||
|
||||
await _schemaManager.EnsureCurrentSchemaAsync().ConfigureAwait(false);
|
||||
|
||||
await _db.CleanupStartupAsync().ConfigureAwait(false);
|
||||
|
||||
_eviction.Start();
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SetAsync<T>(string key, T value, CachePolicy? policy = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_ThrowIfNotReady();
|
||||
policy ??= CachePolicy.Default;
|
||||
|
||||
var (bytes, inline) = _Serialize(value);
|
||||
|
||||
var entry = new CacheEntry
|
||||
{
|
||||
CacheKey = key,
|
||||
ContentType = typeof(T).Name,
|
||||
ContentVersion = policy.ContentVersion,
|
||||
DataSize = bytes.Length,
|
||||
Tags = policy.Tags ?? string.Empty,
|
||||
GroupName = policy.Group ?? string.Empty,
|
||||
Priority = (int)policy.Priority,
|
||||
ExpiresAt = _ComputeExpiry(policy),
|
||||
};
|
||||
|
||||
if (inline && bytes.Length <= _options.MaxInlineSize)
|
||||
{
|
||||
entry = entry with
|
||||
{
|
||||
EntryType = EntryType.Inline,
|
||||
Data = bytes,
|
||||
ContentHash = _ComputeSha256(bytes)
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
using var ms = new MemoryStream(bytes);
|
||||
entry = entry with
|
||||
{
|
||||
EntryType = EntryType.FileRef,
|
||||
FileHash = await _files.StoreAsync(ms).ConfigureAwait(false),
|
||||
ContentHash = _ComputeSha256(bytes)
|
||||
};
|
||||
}
|
||||
|
||||
await _db.UpsertAsync(entry, ct).ConfigureAwait(false);
|
||||
|
||||
_eviction.CheckThreshold();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<CacheResult<T>> GetAsync<T>(string key, CancellationToken ct = default)
|
||||
{
|
||||
_ThrowIfNotReady();
|
||||
|
||||
var entry = await _db.LookupAsync(key, ct).ConfigureAwait(false);
|
||||
if (entry is null) { Interlocked.Increment(ref _misses); return CacheResult<T>.Miss; }
|
||||
|
||||
// 检查过期
|
||||
if (entry.ExpiresAt is not null && (DateTime)entry.ExpiresAt < DateTime.UtcNow)
|
||||
{
|
||||
await _db.DeleteAsync(key, ct).ConfigureAwait(false);
|
||||
if (entry.FileHash is not null)
|
||||
{
|
||||
await _files.ReleaseAsync(entry.FileHash).ConfigureAwait(false);
|
||||
}
|
||||
Interlocked.Increment(ref _misses);
|
||||
return CacheResult<T>.Miss;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _hits);
|
||||
byte[] data;
|
||||
|
||||
// 读取数据
|
||||
if (entry is { EntryType: EntryType.FileRef, FileHash: not null })
|
||||
{
|
||||
await using var stream = _files.Retrieve(entry.FileHash);
|
||||
if (stream is null)
|
||||
{
|
||||
await _db.DeleteAsync(key, ct).ConfigureAwait(false);
|
||||
Interlocked.Increment(ref _misses);
|
||||
return CacheResult<T>.Miss;
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms, ct).ConfigureAwait(false);
|
||||
data = ms.ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
data = entry.Data ?? [];
|
||||
}
|
||||
|
||||
// 反序列化
|
||||
var value = _Deserialize<T>(data);
|
||||
|
||||
// 更新访问时间
|
||||
await _db.TouchAsync(key, ct).ConfigureAwait(false);
|
||||
|
||||
return CacheResult<T>.Hit(value, entry.CachedAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> ExistsAsync(string key)
|
||||
{
|
||||
_ThrowIfNotReady();
|
||||
var entry = await _db.LookupAsync(key, CancellationToken.None).ConfigureAwait(false);
|
||||
if (entry is null) return false;
|
||||
return entry.ExpiresAt is null || (DateTime)entry.ExpiresAt >= DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> DeleteAsync(string key)
|
||||
{
|
||||
_ThrowIfNotReady();
|
||||
var entry = await _db.LookupAsync(key, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.FileHash is not null)
|
||||
{
|
||||
await _files.ReleaseAsync(entry.FileHash).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await _db.DeleteAsync(key, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string?> GetCachedFilePathAsync(string key)
|
||||
{
|
||||
var entry = await _db.LookupAsync(key, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
if (entry?.FileHash is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _files.GetFilePath(entry.FileHash);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string> CacheFileAsync(string key, Stream source,
|
||||
CachePolicy? policy = null, CancellationToken ct = default)
|
||||
{
|
||||
_ThrowIfNotReady();
|
||||
policy ??= CachePolicy.Default;
|
||||
|
||||
var hash = await _files.StoreAsync(source).ConfigureAwait(false);
|
||||
var entry = new CacheEntry
|
||||
{
|
||||
CacheKey = key,
|
||||
EntryType = EntryType.FileRef,
|
||||
ContentType = "application/octet-stream",
|
||||
DataSize = source.Length,
|
||||
FileHash = hash,
|
||||
ContentHash = hash,
|
||||
Tags = policy.Tags ?? "",
|
||||
GroupName = policy.Group ?? "",
|
||||
Priority = (int)policy.Priority,
|
||||
ExpiresAt = _ComputeExpiry(policy),
|
||||
};
|
||||
await _db.UpsertAsync(entry, ct).ConfigureAwait(false);
|
||||
_eviction.CheckThreshold();
|
||||
return hash;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<int> DeleteByGroupAsync(string groupName)
|
||||
{
|
||||
_ThrowIfNotReady();
|
||||
|
||||
// 先收集要被删除的文件 hash(用于清理 FileCacheStore)
|
||||
var fileHashes = await _db.GetFileHashesByGroupAsync(groupName, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// 逐个释放文件
|
||||
foreach (var hash in fileHashes)
|
||||
{
|
||||
if (hash is not null)
|
||||
{
|
||||
await _files.ReleaseAsync(hash).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除数据库中的记录
|
||||
return await _db.DeleteByGroupAsync(groupName, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<int> DeleteByTagAsync(string tag)
|
||||
{
|
||||
_ThrowIfNotReady();
|
||||
return _db.DeleteByTagAsync(tag, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<int> DeleteExpiredAsync()
|
||||
{
|
||||
_ThrowIfNotReady();
|
||||
var count = await _db.DeleteExpiredAsync(DateTime.UtcNow, CancellationToken.None).ConfigureAwait(false);
|
||||
_eviction.CheckThreshold();
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<CacheStats> GetStatsAsync()
|
||||
{
|
||||
var row = await _db.GetStatsAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
return new CacheStats
|
||||
{
|
||||
TotalEntries = row.TotalEntries,
|
||||
TotalSizeBytes = row.TotalSizeBytes,
|
||||
ExpiredEntries = row.ExpiredEntries,
|
||||
InlineEntries = row.InlineEntries,
|
||||
FileEntries = row.FileEntries,
|
||||
CacheHits = Interlocked.Read(ref _hits),
|
||||
CacheMisses = Interlocked.Read(ref _misses),
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ClearAsync()
|
||||
{
|
||||
_ThrowIfNotReady();
|
||||
|
||||
// 先释放所有文件引用
|
||||
var fileHashes = await _db.GetAllFileHashesAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
foreach (var hash in fileHashes)
|
||||
{
|
||||
if (hash is not null)
|
||||
{
|
||||
await _files.ReleaseAsync(hash).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 清空所有表
|
||||
await using var conn = new SqliteConnection($"Data Source={_options.DatabasePath}");
|
||||
await conn.OpenAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
DELETE FROM cache_entries;
|
||||
DELETE FROM instance_cache;
|
||||
DELETE FROM component_cache;
|
||||
VACUUM;
|
||||
""";
|
||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task CompactAsync()
|
||||
{
|
||||
_ThrowIfNotReady();
|
||||
return _db.CompactAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
#region Event
|
||||
|
||||
public event EventHandler<CacheEntryEvictedEventArgs>? EntryEvicted;
|
||||
|
||||
internal void OnEntryEvicted(CacheEntryEvictedEventArgs args)
|
||||
=> EntryEvicted?.Invoke(this, args);
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region Helper
|
||||
|
||||
private void _ThrowIfNotReady()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
|
||||
private static DateTime? _ComputeExpiry(CachePolicy policy)
|
||||
{
|
||||
if (policy.AbsoluteExpiration is null && policy.SlidingExpiration is null)
|
||||
return null; // 永不过期
|
||||
if (policy.AbsoluteExpiration is not null)
|
||||
return DateTime.UtcNow + policy.AbsoluteExpiration;
|
||||
// 仅滑动过期——初始也设一个基于滑动过期的过期点
|
||||
return DateTime.UtcNow + policy.SlidingExpiration;
|
||||
}
|
||||
|
||||
private static (byte[] bytes, bool isSmall) _Serialize<T>(T value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
byte[] raw => (raw, raw.Length <= 256 * 1024),
|
||||
string s => (Encoding.UTF8.GetBytes(s), s.Length <= 256 * 1024),
|
||||
_ => (JsonSerializer.SerializeToUtf8Bytes(value, _JsonOpts), true),
|
||||
};
|
||||
}
|
||||
|
||||
private static T _Deserialize<T>(byte[] data)
|
||||
{
|
||||
if (typeof(T) == typeof(byte[]))
|
||||
return (T)(object)data;
|
||||
if (typeof(T) == typeof(string))
|
||||
return (T)(object)Encoding.UTF8.GetString(data);
|
||||
return JsonSerializer.Deserialize<T>(data, _JsonOpts)!;
|
||||
}
|
||||
|
||||
private static string _ComputeSha256(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Instance/Component
|
||||
|
||||
public Task UpsertInstanceAsync(InstanceCacheRow row, CancellationToken ct = default)
|
||||
=> _db.UpsertInstanceAsync(row, ct);
|
||||
|
||||
public Task<InstanceCacheRow?> LookupInstanceAsync(string instancePath, CancellationToken ct = default)
|
||||
=> _db.LookupInstanceAsync(instancePath, ct);
|
||||
|
||||
public Task DeleteInstanceAsync(string instancePath, CancellationToken ct = default)
|
||||
=> _db.DeleteInstanceAsync(instancePath, ct);
|
||||
|
||||
public Task UpsertComponentAsync(ComponentCacheRow row, CancellationToken ct = default)
|
||||
=> _db.UpsertComponentAsync(row, ct);
|
||||
|
||||
public Task<List<ComponentCacheRow>> GetComponentsByInstanceAsync(
|
||||
string instancePath, string compType, CancellationToken ct = default)
|
||||
=> _db.GetComponentsByInstanceAsync(instancePath, compType, ct);
|
||||
|
||||
public Task<ComponentCacheRow?> GetComponentAsync(
|
||||
string instancePath, string compType, string fileName, CancellationToken ct = default)
|
||||
=> _db.GetComponentAsync(instancePath, compType, fileName, ct);
|
||||
|
||||
public Task DeleteComponentsByInstanceAsync(string instancePath, CancellationToken ct = default)
|
||||
=> _db.DeleteComponentsByInstanceAsync(instancePath, ct);
|
||||
|
||||
public Task<string?> GetComponentScanHashAsync(
|
||||
string instancePath, string compType, CancellationToken ct = default)
|
||||
=> _db.GetComponentScanHashAsync(instancePath, compType, ct);
|
||||
|
||||
#endregion
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
_eviction.Stop();
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new SqliteConnection($"Data Source={_options.DatabasePath}");
|
||||
await conn.OpenAsync().ConfigureAwait(false);
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
|
||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
|
||||
await CastAndDispose(_db).ConfigureAwait(false);
|
||||
await CastAndDispose(_files).ConfigureAwait(false);
|
||||
|
||||
return;
|
||||
|
||||
static async ValueTask CastAndDispose(IDisposable resource)
|
||||
{
|
||||
if (resource is IAsyncDisposable resourceAsyncDisposable)
|
||||
await resourceAsyncDisposable.DisposeAsync().ConfigureAwait(false);
|
||||
else
|
||||
resource.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using PCL.Core.App.IoC;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache;
|
||||
|
||||
[LifecycleScope("global_cache", "磁盘缓存服务")]
|
||||
[LifecycleService(LifecycleState.Loading, Priority = 500)]
|
||||
public partial class CacheServiceManager
|
||||
{
|
||||
private static CacheService? _service;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前缓存服务实例,如果尚未初始化则为 null
|
||||
/// </summary>
|
||||
public static CacheService Current => _service ?? throw new InvalidOperationException("Cache service is not initialized yet.");
|
||||
|
||||
[LifecycleStart]
|
||||
private static async Task _StartAsync()
|
||||
{
|
||||
Context.Debug("正在初始化缓存服务...");
|
||||
|
||||
_service = new CacheService();
|
||||
await _service.InitializeAsync().ConfigureAwait(false);
|
||||
|
||||
Context.Info("缓存服务初始化成功");
|
||||
}
|
||||
|
||||
[LifecycleStop]
|
||||
private static async Task _StopAsync()
|
||||
{
|
||||
Context.Debug("正在停止缓存服务...");
|
||||
|
||||
if (_service != null)
|
||||
{
|
||||
await _service.DisposeAsync().ConfigureAwait(false);
|
||||
_service = null;
|
||||
}
|
||||
|
||||
Context.Info("缓存服务已停止");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache;
|
||||
|
||||
public record CacheStats
|
||||
{
|
||||
public long TotalEntries { get; init; }
|
||||
public long TotalSizeBytes { get; init; }
|
||||
public long ExpiredEntries { get; init; }
|
||||
public long InlineEntries { get; init; }
|
||||
public long FileEntries { get; init; }
|
||||
public long CacheHits { get; init; }
|
||||
public long CacheMisses { get; init; }
|
||||
public double HitRate => (CacheHits + CacheMisses) > 0 ? (double)CacheHits / (CacheHits + CacheMisses) : 0;
|
||||
public DateTime? LastCleanup { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using PCL.Core.Utils.Hash;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache;
|
||||
|
||||
public class FileCacheStorage : IDisposable
|
||||
{
|
||||
private readonly HashStorage _hashStorage;
|
||||
private readonly string _basePath;
|
||||
private readonly ConcurrentDictionary<string, int> _refCounts = [];
|
||||
|
||||
public FileCacheStorage(string cacheRoot, bool enableCompression = true)
|
||||
{
|
||||
_basePath = cacheRoot;
|
||||
Directory.CreateDirectory(cacheRoot);
|
||||
|
||||
_hashStorage = new HashStorage(
|
||||
cacheRoot,
|
||||
SHA256Provider.Instance,
|
||||
compressObjects: enableCompression,
|
||||
correctMisplacedFile: false,
|
||||
prefixLength: 2);
|
||||
}
|
||||
|
||||
public async Task<string> StoreAsync(Stream source, string? knownHash = null)
|
||||
{
|
||||
var hash = await _hashStorage.PutAsync(source, knownHash).ConfigureAwait(false);
|
||||
if (hash is not null)
|
||||
{
|
||||
_refCounts.AddOrUpdate(hash, 1, (_, count) => count + 1);
|
||||
}
|
||||
return hash!;
|
||||
}
|
||||
|
||||
public Stream? Retrieve(string hash) => _hashStorage.Get(hash);
|
||||
|
||||
public string? GetFilePath(string hash)
|
||||
{
|
||||
var prefix = hash[..2];
|
||||
var paht = Path.Combine(_basePath, prefix, hash);
|
||||
return File.Exists(paht) ? paht : null;
|
||||
|
||||
}
|
||||
|
||||
public bool Exists(string hash) => _hashStorage.Exists(hash);
|
||||
|
||||
public async Task<bool> ReleaseAsync(string hash)
|
||||
{
|
||||
var spin = new SpinWait();
|
||||
while (true)
|
||||
{
|
||||
if (!_refCounts.TryGetValue(hash, out var count))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (TryRelease(hash, count))
|
||||
{
|
||||
case ReleaseResult.Removed:
|
||||
return await _hashStorage.DeleteAsync(hash).ConfigureAwait(false);
|
||||
case ReleaseResult.Decremented:
|
||||
return true;
|
||||
}
|
||||
|
||||
if (spin.NextSpinWillYield)
|
||||
await Task.Yield();
|
||||
else
|
||||
spin.SpinOnce();
|
||||
}
|
||||
}
|
||||
|
||||
private enum ReleaseResult { Retry, Decremented, Removed }
|
||||
|
||||
private ReleaseResult TryRelease(string hash, int expected) =>
|
||||
expected > 1
|
||||
? (_refCounts.TryUpdate(hash, expected - 1, expected) ? ReleaseResult.Decremented : ReleaseResult.Retry)
|
||||
: (_refCounts.TryRemove(new KeyValuePair<string, int>(hash, expected)) ? ReleaseResult.Removed : ReleaseResult.Retry);
|
||||
|
||||
public Task<bool> ForceDeleteAsync(string hash)
|
||||
{
|
||||
_refCounts.TryRemove(hash, out _);
|
||||
return _hashStorage.DeleteAsync(hash);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_refCounts.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a contract for a cache service.
|
||||
/// </summary>
|
||||
public interface ICacheService
|
||||
{
|
||||
/// <summary>
|
||||
/// Write a value to the cache with the specified key and optional cache policy. If the key already exists, it will be overwritten.<br/>
|
||||
/// Small data ( < 256KB) is storaged in SQLite inline, while larger data is storaged as file-mapped.
|
||||
/// </summary>
|
||||
Task SetAsync<T>(string key, T value, CachePolicy? policy = null, CancellationToken ct = default);
|
||||
/// <summary>
|
||||
/// Read the cache.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="CacheResult{T}"/> the tell 'Miss', 'Hit' or 'Expired'</returns>
|
||||
Task<CacheResult<T>> GetAsync<T>(string key, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a cache entry exists for the specified key.
|
||||
/// </summary>
|
||||
/// <param name="key"></param>
|
||||
/// <returns><see langword="true"/> if the entry exists and is not expired, <see langword="false"/> otherwise</returns>
|
||||
Task<bool> ExistsAsync(string key);
|
||||
/// <summary>
|
||||
/// Deletes the cache entry for the specified key.
|
||||
/// </summary>
|
||||
/// <param name="key"></param>
|
||||
/// <returns><see langword="true"/> if the entry was found and deleted, <see langword="false"/> otherwise</returns>
|
||||
Task<bool> DeleteAsync(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file path for the cached entry with the specified key.
|
||||
/// </summary>
|
||||
/// <returns>The file path if the entry exists, otherwise null</returns>
|
||||
Task<string?> GetCachedFilePathAsync(string key);
|
||||
/// <summary>
|
||||
/// Cache a file stream with the specified key and optional cache policy. If the key already exists, it will be overwritten.<br/>
|
||||
/// </summary>
|
||||
/// <returns>The file path</returns>
|
||||
Task<string> CacheFileAsync(string key, Stream source, CachePolicy? policy = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all cache entries belonging to the specified group.
|
||||
/// </summary>
|
||||
/// <returns>The number of entries deleted</returns>
|
||||
Task<int> DeleteByGroupAsync(string groupName);
|
||||
/// <summary>
|
||||
/// Deletes all cache entries with the specified tag.
|
||||
/// </summary>
|
||||
/// <param name="tag"></param>
|
||||
/// <returns>The number of entries deleted</returns>
|
||||
Task<int> DeleteByTagAsync(string tag);
|
||||
/// <summary>
|
||||
/// Deletes all expired cache entries.
|
||||
/// </summary>
|
||||
/// <returns>The number of entries deleted</returns>
|
||||
Task<int> DeleteExpiredAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cache statistics.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
Task<CacheStats> GetStatsAsync();
|
||||
/// <summary>
|
||||
/// Clear all cache entries. Use with caution as this will remove all cached data regardless of expiration or priority.
|
||||
/// </summary>
|
||||
Task ClearAsync();
|
||||
/// <summary>
|
||||
/// Compacts the cache by removing any unused space.
|
||||
/// </summary>
|
||||
Task CompactAsync();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache.Model;
|
||||
|
||||
public record CacheEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// SHA-256 (Raw key)
|
||||
/// </summary>
|
||||
public string CacheKey { get; init; } = string.Empty;
|
||||
public EntryType EntryType { get; init; }
|
||||
/// <summary>
|
||||
/// MIME
|
||||
/// </summary>
|
||||
public string ContentType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Cache data (Used in <see cref="EntryType.Inline"/> mode)
|
||||
/// </summary>
|
||||
public byte[]? Data { get; init; }
|
||||
/// <summary>
|
||||
/// Original data size
|
||||
/// </summary>
|
||||
public long DataSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File hash (SHA-256) (Used in <see cref="EntryType.FileRef"/> mode)
|
||||
/// </summary>
|
||||
public string? FileHash { get; init; }
|
||||
/// <summary>
|
||||
/// File path (Relative in cache directory) (Used in <see cref="EntryType.FileRef"/> mode)
|
||||
/// </summary>
|
||||
public string? FilePath { get; init; }
|
||||
|
||||
public string? ContentHash { get; init; }
|
||||
/// <summary>
|
||||
/// Content format version (Used for migration.)
|
||||
/// </summary>
|
||||
public int ContentVersion { get; init; }
|
||||
|
||||
public DateTime CachedAt { get; init; }
|
||||
public DateTime LastAccessAt { get; init; }
|
||||
public DateTime? ExpiresAt { get; init; }
|
||||
public long HitCount { get; init; }
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
public string GroupName { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// <c>0 = Low; 1 = Normal; 2 = High; 3 = NeverEvict</c>
|
||||
/// </summary>
|
||||
public int Priority { get; init; }
|
||||
}
|
||||
|
||||
public enum EntryType
|
||||
{
|
||||
Inline = 0,
|
||||
FileRef = 1
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache.Model;
|
||||
|
||||
public record ComponentCacheRow
|
||||
{
|
||||
public string InstancePath { get; init; } = string.Empty; // PK 1
|
||||
public string CompType { get; init; } = string.Empty; // PK 2: mods/rp/shader/saves/datapack
|
||||
public string FileName { get; init; } = string.Empty; // PK 3
|
||||
|
||||
public string RelativePath { get; init; } = string.Empty;
|
||||
public string? FileHash { get; init; } // SHA256
|
||||
public long FileSize { get; init; }
|
||||
public DateTime LastModified { get; init; }
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
// Mod 特有(JSON 序列化到 ModMetadata 列)
|
||||
public string? ModName { get; init; }
|
||||
public string? ModVersion { get; init; }
|
||||
public string? ModAuthor { get; init; }
|
||||
public string? ModDescription { get; init; }
|
||||
public string? ModLoader { get; init; } // forge / fabric / quilt / liteloader
|
||||
public string? ModDependencies { get; init; } // JSON array
|
||||
|
||||
// Scan metadata
|
||||
public int CacheVersion { get; init; } = 7;
|
||||
public DateTime ScannedAt { get; init; }
|
||||
public string? ScanHash { get; init; } // 目录扫描指纹
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache.Model;
|
||||
|
||||
public record InstanceCacheRow
|
||||
{
|
||||
public string InstancePath { get; init; } = string.Empty;
|
||||
public string InstanceName { get; init; } = string.Empty;
|
||||
public string InstanceState { get; init; } = "Error";
|
||||
public int CardType { get; init; }
|
||||
public bool IsStarred { get; init; }
|
||||
public string? Logo { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? ReleaseTime { get; init; }
|
||||
public string? VanillaName { get; init; }
|
||||
public string? VanillaVersion { get; init; }
|
||||
public int DropNumber { get; init; }
|
||||
public bool Reliable { get; init; }
|
||||
|
||||
///<summary>
|
||||
/// 加载器版本(统一 JSON 存储,替代 n 组 HasXxx + XxxVersion 字段)<br/>
|
||||
/// 格式:[{"type":"forge","version":"47.2.0"},{"type":"fabric","version":"0.15.0"}]<br/>
|
||||
/// 空数组 = 无加载器
|
||||
/// </summary>
|
||||
public string LoaderJson { get; internal set; } = "[]";
|
||||
|
||||
/// <summary>解析加载器列表(反序列化 LoaderJson)</summary>
|
||||
public List<LoaderEntry> GetLoaders() =>
|
||||
JsonSerializer.Deserialize<List<LoaderEntry>>(LoaderJson) ?? [];
|
||||
|
||||
/// <summary>快速检查是否存在指定类型的加载器</summary>
|
||||
public bool HasLoader(string type) =>
|
||||
GetLoaders().Any(l => l.Type == type);
|
||||
|
||||
/// <summary>获取指定加载器的版本(不存在返回 null)</summary>
|
||||
public string? GetLoaderVersion(string type) =>
|
||||
GetLoaders().FirstOrDefault(l => l.Type == type)?.Version;
|
||||
|
||||
/// <summary>将加载器列表序列化为 JSON 赋给 LoaderJson</summary>
|
||||
public void SetLoaders(List<LoaderEntry> loaders) =>
|
||||
LoaderJson = JsonSerializer.Serialize(loaders);
|
||||
|
||||
// Manifest summary
|
||||
public string? MainClass { get; init; }
|
||||
public string? AssetsIndex { get; init; }
|
||||
public string? InheritsFrom { get; init; }
|
||||
public int? JavaVersion { get; init; }
|
||||
|
||||
// Cache control
|
||||
public string SourceJsonHash { get; init; } = string.Empty; // version.json SHA256
|
||||
public int FormatVersion { get; init; } = 1;
|
||||
public DateTime CachedAt { get; init; }
|
||||
public DateTime? LastLoadedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载器版本条目——统一模型替代 N 组 HasXxx/XxxVersion 属性。
|
||||
/// 新增加载器类型只需在此记录中添加新条目,无需修改表结构或模型类。
|
||||
/// </summary>
|
||||
public record LoaderEntry
|
||||
{
|
||||
/// <summary>加载器类型标识,如 "forge" / "fabric" / "quilt" / "neoforge" / "liteloader" / "optifine" / "labymod" / "cleanroom" / "legacyfabric"</summary>
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>加载器版本号(空字符串 = 已检测到加载器但版本未知,null = 不存在)</summary>
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache;
|
||||
|
||||
public class SchemaManager(string connectionString)
|
||||
{
|
||||
private const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <exception cref="InvalidOperationException">Invalid schema version</exception>
|
||||
public async Task EnsureCurrentSchemaAsync()
|
||||
{
|
||||
await using var conn = new SqliteConnection(connectionString);
|
||||
await conn.OpenAsync().ConfigureAwait(false);
|
||||
|
||||
await _ExecutePragmaAsync(conn, "journal_mode", "WAL").ConfigureAwait(false);
|
||||
await _ExecutePragmaAsync(conn, "synchronous", "NORMAL").ConfigureAwait(false);
|
||||
await _ExecutePragmaAsync(conn, "cache_size", "-8000").ConfigureAwait(false); // 8MB page cache
|
||||
|
||||
await using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS cache_meta (
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
""";
|
||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var currentVersion = 0;
|
||||
await using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT value FROM cache_meta WHERE key = 'schema_version'";
|
||||
var result = await cmd.ExecuteScalarAsync().ConfigureAwait(false);
|
||||
if (result is not null)
|
||||
{
|
||||
currentVersion = int.Parse(result.ToString() ?? throw new InvalidOperationException("Invalid schema version"));
|
||||
}
|
||||
}
|
||||
|
||||
if (currentVersion < CurrentSchemaVersion)
|
||||
{
|
||||
// NOTE: this method is ApplyMigrations, not _ApplyMigrationAsync. see the 's' difference
|
||||
await _ApplyMigrationsAsync(conn, currentVersion).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _EnsureAllTablesExistAsync(conn).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<int> _ExecutePragmaAsync(SqliteConnection conn, string pragma, string value)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"PRAGMA {pragma} = {value}";
|
||||
return await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#region Version Upgrade
|
||||
|
||||
private async Task _ApplyMigrationsAsync(SqliteConnection conn, int fromVersion)
|
||||
{
|
||||
var v = fromVersion;
|
||||
while (v < CurrentSchemaVersion)
|
||||
{
|
||||
v++;
|
||||
await using var tx = conn.BeginTransaction();
|
||||
try
|
||||
{
|
||||
await _ApplyMigrationAsync(conn, v).ConfigureAwait(false);
|
||||
await using var setVer = conn.CreateCommand();
|
||||
setVer.CommandText = "INSERT OR REPLACE INTO cache_meta (key, value) VALUES ('schema_version', @v)";
|
||||
setVer.Parameters.AddWithValue("@v", v);
|
||||
await setVer.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
await tx.CommitAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await tx.RollbackAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task _ApplyMigrationAsync(SqliteConnection conn, int targetVersion)
|
||||
{
|
||||
switch (targetVersion)
|
||||
{
|
||||
case 1:
|
||||
await _ExecuteDdlAsync(conn, DdlV1CreateAllTables).ConfigureAwait(false);
|
||||
await _ExecuteDdlAsync(conn, DdlV1CreateIndexes).ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
// NOTE: add future migrations here
|
||||
// e.g.
|
||||
// case 2:
|
||||
// await _ExecuteDdlAsync(conn, "some sql").
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task<int> _EnsureAllTablesExistAsync(SqliteConnection conn)
|
||||
{
|
||||
await _ExecuteDdlAsync(conn, DdlV1CreateAllTables).ConfigureAwait(false);
|
||||
await _ExecuteDdlAsync(conn, DdlV1CreateIndexes).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "INSERT OR IGNORE INTO cache_meta (key, value) VALUES ('schema_version', @v)";
|
||||
cmd.Parameters.AddWithValue("@v", CurrentSchemaVersion);
|
||||
return await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<int> _ExecuteDdlAsync(SqliteConnection conn, string ddl)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = ddl;
|
||||
return await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#region DDL Text
|
||||
|
||||
private const string DdlV1CreateAllTables = """
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cache_entries (
|
||||
cache_key TEXT NOT NULL PRIMARY KEY,
|
||||
entry_type INTEGER NOT NULL DEFAULT 0,
|
||||
content_type TEXT NOT NULL DEFAULT '',
|
||||
data BLOB,
|
||||
data_size INTEGER NOT NULL DEFAULT 0,
|
||||
file_hash TEXT,
|
||||
file_path TEXT,
|
||||
content_hash TEXT,
|
||||
content_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_access_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT,
|
||||
hit_count INTEGER NOT NULL DEFAULT 0,
|
||||
tags TEXT NOT NULL DEFAULT '',
|
||||
group_name TEXT NOT NULL DEFAULT '',
|
||||
priority INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS instance_cache (
|
||||
instance_path TEXT NOT NULL PRIMARY KEY,
|
||||
instance_name TEXT NOT NULL,
|
||||
instance_state TEXT NOT NULL DEFAULT 'Error',
|
||||
card_type INTEGER NOT NULL DEFAULT 0,
|
||||
is_starred INTEGER NOT NULL DEFAULT 0,
|
||||
logo TEXT,
|
||||
description TEXT,
|
||||
release_time TEXT,
|
||||
vanilla_name TEXT,
|
||||
vanilla_version TEXT,
|
||||
drop_number INTEGER NOT NULL DEFAULT 0,
|
||||
reliable INTEGER NOT NULL DEFAULT 1,
|
||||
loaders_json TEXT NOT NULL DEFAULT '[]',
|
||||
main_class TEXT,
|
||||
assets_index TEXT,
|
||||
inherits_from TEXT,
|
||||
java_version INTEGER,
|
||||
source_json_hash TEXT NOT NULL,
|
||||
format_version INTEGER NOT NULL DEFAULT 1,
|
||||
cached_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_loaded_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS component_cache (
|
||||
instance_path TEXT NOT NULL,
|
||||
comp_type TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
relative_path TEXT NOT NULL,
|
||||
file_hash TEXT,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
last_modified TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
mod_name TEXT,
|
||||
mod_version TEXT,
|
||||
mod_author TEXT,
|
||||
mod_description TEXT,
|
||||
mod_loader TEXT,
|
||||
mod_deps TEXT,
|
||||
cache_version INTEGER NOT NULL DEFAULT 7,
|
||||
scanned_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
scan_hash TEXT,
|
||||
PRIMARY KEY (instance_path, comp_type, file_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cache_stats (
|
||||
stat_name TEXT NOT NULL PRIMARY KEY,
|
||||
stat_value INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
""";
|
||||
|
||||
private const string DdlV1CreateIndexes = """
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_expires ON cache_entries(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_group ON cache_entries(group_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_tags ON cache_entries(tags);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_access ON cache_entries(last_access_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_priority ON cache_entries(priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_hits ON cache_entries(hit_count);
|
||||
CREATE INDEX IF NOT EXISTS idx_cc_instance ON component_cache(instance_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_cc_scanhash ON component_cache(instance_path, comp_type, scan_hash);
|
||||
|
||||
""";
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using PCL.Core.IO.Storage.Cache.Model;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Storage.Cache;
|
||||
|
||||
public class SqliteCacheStorage(string dbPath) : IDisposable
|
||||
{
|
||||
private readonly string _connectionString = $"Data Source={dbPath};Pooling=True";
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private bool _disposed;
|
||||
|
||||
private async Task<SqliteConnection> _CreateConnectionAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(SqliteCacheStorage));
|
||||
}
|
||||
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
|
||||
// NOTE: WAL mode is managed in SchemaManager, so we don't set it here.
|
||||
// but new connection may need re-enable some session-level pragmas
|
||||
// but we don't have any for now, so we just return the connection.
|
||||
await conn.OpenAsync().ConfigureAwait(false);
|
||||
|
||||
return conn;
|
||||
}
|
||||
|
||||
public async Task CleanupStartupAsync()
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
|
||||
cmd.CommandText = "DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < datetime('now')";
|
||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
|
||||
await using var walCmd = conn.CreateCommand();
|
||||
walCmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)";
|
||||
await walCmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#region cache_entries
|
||||
|
||||
public async Task UpsertAsync(CacheEntry entry, CancellationToken ct)
|
||||
{
|
||||
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT OR REPLACE INTO cache_entries (
|
||||
cache_key, entry_type, content_type, data, data_size, file_hash, file_path,
|
||||
content_hash, content_version, created_at, last_access_at, expires_at,
|
||||
hit_count, tags, group_name, priority
|
||||
) VALUES (
|
||||
@cache_key, @entry_type, @content_type, @data, @data_size, @file_hash, @file_path,
|
||||
@content_hash, @content_version, COALESCE((SELECT created_at FROM cache_entries WHERE cache_key = @cache_key), datetime('now')),
|
||||
datetime('now'), @expires_at, 0, @tags, @group_name, @priority
|
||||
)
|
||||
""";
|
||||
_BindEntryParams(cmd, entry);
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CacheEntry?> LookupAsync(string cacheKey, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT * FROM cache_entries WHERE cache_key = @cache_key";
|
||||
cmd.Parameters.AddWithValue("@cache_key", cacheKey);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(ct).ConfigureAwait(false) ? _ReadEntry(reader) : null;
|
||||
}
|
||||
|
||||
public async Task TouchAsync(string key, CancellationToken ct)
|
||||
{
|
||||
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
|
||||
cmd.CommandText = """
|
||||
UPDATE cache_entries SET last_access_at = datetime('now'), hit_count = hit_count + 1 WHERE cache_key = @cache_key
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@cache_key", key);
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string cacheKey, CancellationToken ct)
|
||||
{
|
||||
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM cache_entries WHERE cache_key = @cache_key";
|
||||
cmd.Parameters.AddWithValue("@cache_key", cacheKey);
|
||||
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
return affected > 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> DeleteExpiredAsync(DateTime now, CancellationToken ct)
|
||||
{
|
||||
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND datetime(expires_at) < datetime('now')";
|
||||
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
return affected;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> DeleteByGroupAsync(string groupName, CancellationToken ct)
|
||||
{
|
||||
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM cache_entries WHERE group_name = @group_name";
|
||||
cmd.Parameters.AddWithValue("@group_name", groupName);
|
||||
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
return affected;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<string?>> GetFileHashesByGroupAsync(string groupName, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT DISTINCT file_hash FROM cache_entries WHERE group_name = @group_name AND file_hash IS NOT NULL";
|
||||
cmd.Parameters.AddWithValue("@group_name", groupName);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
var hashes = new List<string?>();
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
hashes.Add(reader.GetString(0));
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
public async Task<List<string?>> GetAllFileHashesAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT DISTINCT file_hash FROM cache_entries WHERE file_hash IS NOT NULL";
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
var hashes = new List<string?>();
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
hashes.Add(reader.GetString(0));
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteByTagAsync(string tag, CancellationToken ct)
|
||||
{
|
||||
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM cache_entries WHERE tags = @t OR tags LIKE @p1 OR tags LIKE @p2 OR tags LIKE @p3";
|
||||
cmd.Parameters.AddWithValue("@t", tag);
|
||||
cmd.Parameters.AddWithValue("@p1", $"{tag},%");
|
||||
cmd.Parameters.AddWithValue("@p2", $"%,{tag},%");
|
||||
cmd.Parameters.AddWithValue("@p3", $"%,{tag}");
|
||||
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
return affected;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CacheStatsRow> GetStatsAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) AS total, COALESCE(SUM(data_size), 0) AS total_size, COALESCE(SUM(CASE WHEN expires_at < datetime('now') THEN 1 ELSE 0 END), 0) AS expired, COALESCE(SUM(CASE WHEN entry_type = 0 THEN 1 ELSE 0 END), 0) AS inline, COALESCE(SUM(CASE WHEN entry_type = 1 THEN 1 ELSE 0 END), 0) AS file_ref FROM cache_entries";
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return new CacheStatsRow(
|
||||
TotalEntries: reader.GetInt64(0),
|
||||
TotalSizeBytes: reader.GetInt64(1),
|
||||
ExpiredEntries: reader.GetInt64(2),
|
||||
InlineEntries: reader.GetInt64(3),
|
||||
FileEntries: reader.GetInt64(4)
|
||||
);
|
||||
}
|
||||
|
||||
// none
|
||||
return new CacheStatsRow(0, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
public async Task<List<EvictionCandidate>> GetEvictionCandidatesAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
|
||||
cmd.CommandText = """
|
||||
SELECT cache_key, entry_type, file_hash, data_size, priority, hit_count
|
||||
FROM cache_entries
|
||||
WHERE priority < 3 -- 不淘汰 NeverEvict
|
||||
ORDER BY priority ASC, hit_count ASC, last_access_at ASC
|
||||
LIMIT @limit
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@limit", limit);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
var list = new List<EvictionCandidate>();
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
list.Add(new EvictionCandidate(
|
||||
CacheKey: reader.GetString(0),
|
||||
EntryType: (EntryType)reader.GetInt32(1),
|
||||
FileHash: reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
DataSize: reader.GetInt64(3),
|
||||
Priority: reader.GetInt32(4),
|
||||
HitCount: reader.GetInt64(5)
|
||||
));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task CompactAsync(CancellationToken ct)
|
||||
{
|
||||
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "VACUUM";
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region instance_cache
|
||||
|
||||
public async Task UpsertInstanceAsync(InstanceCacheRow row, CancellationToken ct)
|
||||
{
|
||||
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
|
||||
INSERT OR REPLACE INTO instance_cache
|
||||
(instance_path, instance_name, instance_state, card_type, is_starred,
|
||||
logo, description, release_time,
|
||||
vanilla_name, vanilla_version, drop_number, reliable,
|
||||
loaders_json,
|
||||
main_class, assets_index, inherits_from, java_version,
|
||||
source_json_hash, format_version, cached_at, last_loaded_at)
|
||||
VALUES
|
||||
(@p, @n, @st, @cty, @is,
|
||||
@lo, @de, @rt,
|
||||
@vn, @vv, @dn, @re,
|
||||
@lj,
|
||||
@mc, @ai, @if, @jv,
|
||||
@sh, @fv, datetime('now'), datetime('now'))
|
||||
""";
|
||||
_BindInstanceParams(cmd, row);
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<InstanceCacheRow?> LookupInstanceAsync(string instancePath, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT * FROM instance_cache WHERE instance_path = @p";
|
||||
cmd.Parameters.AddWithValue("@p", instancePath);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(ct).ConfigureAwait(false) ? _ReadInstanceRow(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteInstanceAsync(string instancePath, CancellationToken ct)
|
||||
{
|
||||
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM instance_cache WHERE instance_path = @p";
|
||||
cmd.Parameters.AddWithValue("@p", instancePath);
|
||||
return await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region component_cache
|
||||
|
||||
public async Task UpsertComponentAsync(ComponentCacheRow row, CancellationToken ct)
|
||||
{
|
||||
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
INSERT OR REPLACE INTO component_cache
|
||||
(instance_path, comp_type, file_name, relative_path,
|
||||
file_hash, file_size, last_modified, enabled,
|
||||
mod_name, mod_version, mod_author, mod_description,
|
||||
mod_loader, mod_deps,
|
||||
cache_version, scanned_at, scan_hash)
|
||||
VALUES
|
||||
(@ip, @ct, @fn, @rp,
|
||||
@fh, @fs, @lm, @en,
|
||||
@mn, @mv, @ma, @md,
|
||||
@ml, @mdeps,
|
||||
@cv, datetime('now'), @sh)";
|
||||
_BindComponentParams(cmd, row);
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<ComponentCacheRow>> GetComponentsByInstanceAsync(
|
||||
string instancePath, string compType, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
|
||||
cmd.CommandText = "SELECT * FROM component_cache WHERE instance_path = @ip AND comp_type = @ct";
|
||||
cmd.Parameters.AddWithValue("@ip", instancePath);
|
||||
cmd.Parameters.AddWithValue("@ct", compType);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var list = new List<ComponentCacheRow>();
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
list.Add(_ReadComponentRow(reader));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<ComponentCacheRow?> GetComponentAsync(
|
||||
string instancePath, string compType, string fileName, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
|
||||
cmd.CommandText = "SELECT * FROM component_cache WHERE instance_path = @ip AND comp_type = @ct AND file_name = @fn";
|
||||
cmd.Parameters.AddWithValue("@ip", instancePath);
|
||||
cmd.Parameters.AddWithValue("@ct", compType);
|
||||
cmd.Parameters.AddWithValue("@fn", fileName);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return await reader.ReadAsync(ct).ConfigureAwait(false) ? _ReadComponentRow(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteComponentsByInstanceAsync(string instancePath, CancellationToken ct)
|
||||
{
|
||||
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
|
||||
cmd.CommandText = "DELETE FROM component_cache WHERE instance_path = @ip";
|
||||
cmd.Parameters.AddWithValue("@ip", instancePath);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GetComponentScanHashAsync(
|
||||
string instancePath, string compType, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
|
||||
cmd.CommandText = """
|
||||
|
||||
SELECT DISTINCT scan_hash
|
||||
FROM component_cache
|
||||
WHERE instance_path = @ip AND comp_type = @ct AND scan_hash IS NOT NULL
|
||||
ORDER BY scanned_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@ip", instancePath);
|
||||
cmd.Parameters.AddWithValue("@ct", compType);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return result as string;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Arg Binding
|
||||
|
||||
private static void _BindEntryParams(SqliteCommand cmd, CacheEntry e)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@cache_key", e.CacheKey);
|
||||
cmd.Parameters.AddWithValue("@entry_type", e.EntryType);
|
||||
cmd.Parameters.AddWithValue("@content_type", e.ContentType);
|
||||
cmd.Parameters.AddWithValue("@data", (object?)e.Data ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@data_size", e.DataSize);
|
||||
cmd.Parameters.AddWithValue("@file_hash", (object?)e.FileHash ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@file_path", (object?)e.FilePath ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@content_hash", (object?)e.ContentHash ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@content_version", e.ContentVersion);
|
||||
cmd.Parameters.AddWithValue("@expires_at", e.ExpiresAt is not null ? ((DateTime)e.ExpiresAt).ToString("O") : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@tags", e.Tags);
|
||||
cmd.Parameters.AddWithValue("@group_name", e.GroupName);
|
||||
cmd.Parameters.AddWithValue("@priority", e.Priority);
|
||||
}
|
||||
|
||||
// 数据库中的时间以 ISO-8601 往返格式("O",UTC 带 Z)写入;读取必须用 RoundtripKind 解析,
|
||||
// 否则 DateTime.Parse 会把 UTC 转成本地时间并丢失 Kind,使其与 DateTime.UtcNow 的比较偏移一个
|
||||
// 时区(例如 UTC+8 用户的缓存过期判断会差 8 小时,导致缓存过久失效/供应陈旧数据)。
|
||||
// 用 TryParse 以容忍损坏/遗留数据:单行时间戳解析失败时回退到 MinValue(对过期判断即视为已过期、
|
||||
// 对时间排序即视为最旧,均为 fail-safe),避免一行坏数据抛异常中断整个读取路径。
|
||||
private static DateTime _ParseDateTime(string value) =>
|
||||
DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result)
|
||||
? result
|
||||
: DateTime.MinValue;
|
||||
|
||||
private static CacheEntry _ReadEntry(SqliteDataReader r) =>
|
||||
new()
|
||||
{
|
||||
CacheKey = r.GetString(0),
|
||||
EntryType = (EntryType)r.GetInt32(1),
|
||||
ContentType = r.GetString(2),
|
||||
Data = r.IsDBNull(3) ? null : (byte[])r.GetValue(3),
|
||||
DataSize = r.GetInt64(4),
|
||||
FileHash = r.IsDBNull(5) ? null : r.GetString(5),
|
||||
FilePath = r.IsDBNull(6) ? null : r.GetString(6),
|
||||
ContentHash = r.IsDBNull(7) ? null : r.GetString(7),
|
||||
ContentVersion = r.GetInt32(8),
|
||||
CachedAt = _ParseDateTime(r.GetString(9)),
|
||||
LastAccessAt = _ParseDateTime(r.GetString(10)),
|
||||
ExpiresAt = r.IsDBNull(11) ? null : _ParseDateTime(r.GetString(11)),
|
||||
HitCount = r.GetInt64(12),
|
||||
Tags = r.GetString(13),
|
||||
GroupName = r.GetString(14),
|
||||
Priority = r.GetInt32(15),
|
||||
};
|
||||
|
||||
private static void _BindInstanceParams(SqliteCommand cmd, InstanceCacheRow r)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@p", r.InstancePath);
|
||||
cmd.Parameters.AddWithValue("@n", r.InstanceName);
|
||||
cmd.Parameters.AddWithValue("@st", r.InstanceState);
|
||||
cmd.Parameters.AddWithValue("@cty", r.CardType);
|
||||
cmd.Parameters.AddWithValue("@is", r.IsStarred ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@lo", (object?)r.Logo ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@de", (object?)r.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@rt", (object?)r.ReleaseTime ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@vn", (object?)r.VanillaName ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@vv", (object?)r.VanillaVersion ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@dn", r.DropNumber);
|
||||
cmd.Parameters.AddWithValue("@re", r.Reliable ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@lj", r.LoaderJson);
|
||||
cmd.Parameters.AddWithValue("@mc", (object?)r.MainClass ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@ai", (object?)r.AssetsIndex ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@if", (object?)r.InheritsFrom ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@jv", (object?)r.JavaVersion ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@sh", r.SourceJsonHash);
|
||||
cmd.Parameters.AddWithValue("@fv", r.FormatVersion);
|
||||
}
|
||||
|
||||
private static InstanceCacheRow _ReadInstanceRow(SqliteDataReader r)
|
||||
{
|
||||
return new InstanceCacheRow
|
||||
{
|
||||
InstancePath = r.GetString(0),
|
||||
InstanceName = r.GetString(1),
|
||||
InstanceState = r.GetString(2),
|
||||
CardType = r.GetInt32(3),
|
||||
IsStarred = r.GetInt32(4) != 0,
|
||||
Logo = r.IsDBNull(5) ? null : r.GetString(5),
|
||||
Description = r.IsDBNull(6) ? null : r.GetString(6),
|
||||
ReleaseTime = r.IsDBNull(7) ? null : r.GetString(7),
|
||||
VanillaName = r.IsDBNull(8) ? null : r.GetString(8),
|
||||
VanillaVersion = r.IsDBNull(9) ? null : r.GetString(9),
|
||||
DropNumber = r.GetInt32(10),
|
||||
Reliable = r.GetInt32(11) != 0,
|
||||
LoaderJson = r.GetString(12),
|
||||
MainClass = r.IsDBNull(13) ? null : r.GetString(13),
|
||||
AssetsIndex = r.IsDBNull(14) ? null : r.GetString(14),
|
||||
InheritsFrom = r.IsDBNull(15) ? null : r.GetString(15),
|
||||
JavaVersion = r.IsDBNull(16) ? null : r.GetInt32(16),
|
||||
SourceJsonHash = r.GetString(17),
|
||||
FormatVersion = r.GetInt32(18),
|
||||
CachedAt = _ParseDateTime(r.GetString(19)),
|
||||
LastLoadedAt = r.IsDBNull(20) ? null : _ParseDateTime(r.GetString(20)),
|
||||
};
|
||||
}
|
||||
|
||||
private static void _BindComponentParams(SqliteCommand cmd, ComponentCacheRow r)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@instance_path", r.InstancePath);
|
||||
cmd.Parameters.AddWithValue("@comp_type", r.CompType);
|
||||
cmd.Parameters.AddWithValue("@file_name", r.FileName);
|
||||
cmd.Parameters.AddWithValue("@relative_path", r.RelativePath);
|
||||
cmd.Parameters.AddWithValue("@file_hash", (object?)r.FileHash ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@file_size", r.FileSize);
|
||||
cmd.Parameters.AddWithValue("@last_modified", r.LastModified.ToString("O"));
|
||||
cmd.Parameters.AddWithValue("@enabled", r.Enabled ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@mod_name", (object?)r.ModName ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@mod_version", (object?)r.ModVersion ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@mod_author", (object?)r.ModAuthor ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@mod_description", (object?)r.ModDescription ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@mod_loader", (object?)r.ModLoader ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@mod_dependencies", (object?)r.ModDependencies ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@cache_version", r.CacheVersion);
|
||||
cmd.Parameters.AddWithValue("@scan_hash", (object?)r.ScanHash ?? DBNull.Value);
|
||||
}
|
||||
|
||||
private static ComponentCacheRow _ReadComponentRow(SqliteDataReader r)
|
||||
{
|
||||
return new ComponentCacheRow
|
||||
{
|
||||
InstancePath = r.GetString(0),
|
||||
CompType = r.GetString(1),
|
||||
FileName = r.GetString(2),
|
||||
RelativePath = r.GetString(3),
|
||||
FileHash = r.IsDBNull(4) ? null : r.GetString(4),
|
||||
FileSize = r.GetInt64(5),
|
||||
LastModified = _ParseDateTime(r.GetString(6)),
|
||||
Enabled = r.GetInt32(7) != 0,
|
||||
ModName = r.IsDBNull(8) ? null : r.GetString(8),
|
||||
ModVersion = r.IsDBNull(9) ? null : r.GetString(9),
|
||||
ModAuthor = r.IsDBNull(10) ? null : r.GetString(10),
|
||||
ModDescription = r.IsDBNull(11) ? null : r.GetString(11),
|
||||
ModLoader = r.IsDBNull(12) ? null : r.GetString(12),
|
||||
ModDependencies = r.IsDBNull(13) ? null : r.GetString(13),
|
||||
CacheVersion = r.GetInt32(14),
|
||||
ScannedAt = _ParseDateTime(r.GetString(15)),
|
||||
ScanHash = r.IsDBNull(16) ? null : r.GetString(16),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_writeLock.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public record CacheStatsRow(long TotalEntries, long TotalSizeBytes, long ExpiredEntries, long InlineEntries, long FileEntries);
|
||||
|
||||
public record EvictionCandidate(string? CacheKey, EntryType EntryType, string? FileHash, long DataSize, int Priority, long HitCount);
|
||||
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Exts;
|
||||
using PCL.Core.Utils.Hash;
|
||||
|
||||
namespace PCL.Core.IO.Storage;
|
||||
|
||||
public class HashStorage(string folder, IHashProvider hashProvider, bool compressObjects = false, bool correctMisplacedFile = true, int prefixLength = 2)
|
||||
{
|
||||
/// <summary>
|
||||
/// 保存文件到哈希存储库中
|
||||
/// </summary>
|
||||
/// <param name="fromPath">欲存储的文件位置</param>
|
||||
/// <param name="hash">欲存储的文件的哈希,请确保与哈希存储库指定的哈希计算方法所用算法一致</param>
|
||||
/// <returns>成功返回文件的哈希,失败返回 null</returns>
|
||||
/// <exception cref="ArgumentNullException">提供的参数不正确</exception>
|
||||
public async Task<string?> PutAsync(string fromPath, string? hash = null)
|
||||
{
|
||||
//参数检查
|
||||
ArgumentNullException.ThrowIfNull(fromPath);
|
||||
var filePath = Path.GetFullPath(fromPath);
|
||||
if (!File.Exists(filePath)) return null;
|
||||
//必要数据准备
|
||||
await using var originalFs = File.Open(fromPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
return await PutAsync(originalFs, hash).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<string?> PutAsync(Stream input, string? hash = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
if (hash is not null && hash.Length != hashProvider.Length)
|
||||
throw new ArgumentException("Provide hash is not correct", nameof(hash));
|
||||
|
||||
if (input.CanSeek) input.Position = 0;
|
||||
|
||||
var fileHash = hash ?? (await hashProvider.ComputeHashAsync(input).ConfigureAwait(false)).ToHexString();
|
||||
var destPath = _GetDestPath(fileHash);
|
||||
//纠正: 由于之前错误设计导致的文件访问效率低下的文件结构
|
||||
if (correctMisplacedFile && _CorrectMisplacedFile(fileHash)) LogWrapper.Info("HashStorage", "Move misplaced file into correct folder");
|
||||
//检查是否已存在保存的文件
|
||||
if (File.Exists(destPath)) return fileHash;
|
||||
await using var destinationFs = _GetSaveStream(destPath);
|
||||
await input.CopyToAsync(destinationFs).ConfigureAwait(false);
|
||||
return fileHash;
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string hash)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hash);
|
||||
|
||||
var filePath = _GetDestPath(hash);
|
||||
if (!File.Exists(filePath) && correctMisplacedFile)
|
||||
filePath = _GetMisplacedFilePath(hash);
|
||||
|
||||
if (!File.Exists(filePath)) return Task.FromResult(false);
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
catch (FileNotFoundException) { /* 忽略此错误 */ }
|
||||
catch (DirectoryNotFoundException ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "HashStorage", $"Unexpected directory not found {filePath}");
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "HashStorage", $"Failed to delete file {filePath}");
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "HashStorage", $"Access denied when deleting file {filePath}");
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Stream? Get(string hash)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hash);
|
||||
var destPath = _GetDestPath(hash);
|
||||
if (correctMisplacedFile && _CorrectMisplacedFile(hash))
|
||||
LogWrapper.Info("HashStorage", $"Move misplaced file into correct folder: {hash}");
|
||||
|
||||
return File.Exists(destPath) ? _GetReadStream(destPath) : null;
|
||||
}
|
||||
|
||||
public bool Exists(string hash)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hash);
|
||||
return File.Exists(_GetDestPath(hash)) || (correctMisplacedFile && File.Exists(_GetMisplacedFilePath(hash)));
|
||||
}
|
||||
|
||||
private Stream _GetSaveStream(string destPath)
|
||||
{
|
||||
var fs = File.Open(destPath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read);
|
||||
if (compressObjects) return new DeflateStream(fs, CompressionMode.Compress);
|
||||
return fs;
|
||||
}
|
||||
|
||||
private Stream _GetReadStream(string destPath)
|
||||
{
|
||||
var fs = File.Open(destPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
if (compressObjects) return new DeflateStream(fs, CompressionMode.Decompress);
|
||||
return fs;
|
||||
}
|
||||
|
||||
private string _GetDestPath(string hash)
|
||||
{
|
||||
return Path.Combine(folder, _GetPrefixFolder(hash), hash);
|
||||
}
|
||||
|
||||
private bool _CorrectMisplacedFile(string hash)
|
||||
{
|
||||
var misplacedPath = _GetMisplacedFilePath(hash);
|
||||
if (!File.Exists(misplacedPath)) return false;
|
||||
var correctPath = _GetDestPath(hash);
|
||||
File.Move(misplacedPath, correctPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
private string _GetMisplacedFilePath(string hash)
|
||||
{
|
||||
return Path.Combine(folder, hash);
|
||||
}
|
||||
|
||||
private string _GetPrefixFolder(string hash)
|
||||
{
|
||||
if (hash.Length < prefixLength)
|
||||
throw new ArgumentException($"Hash length({hash.Length}) is shorter than required prefix length({prefixLength})", nameof(hash));
|
||||
|
||||
var folderName = hash[..prefixLength];
|
||||
var folderPath = Path.Combine(folder, folderName);
|
||||
Directory.CreateDirectory(folderPath);
|
||||
return folderName;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user