U:调整所有代码格式

This commit is contained in:
jimmy committed 2025-12-30 09:17:25 +08:00
1 parent d133bacf55
commit 155b67e831
342 files changed
+9431 -9868

No files matched your search

+2 -2
View File
@@ -15,8 +15,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0"/>
</ItemGroup>
</Project>
+4 -13
View File
@@ -16,10 +16,7 @@ public static class CacheExtensions
Func<T> factory,
TimeSpan? expiration = null)
{
if (cache.TryGet<T>(key, out var cached))
{
return cached!;
}
if (cache.TryGet<T>(key, out var cached)) return cached!;
var value = factory();
cache.Set(key, value, expiration);
@@ -35,10 +32,7 @@ public static class CacheExtensions
Func<Task<T>> factory,
TimeSpan? expiration = null)
{
if (cache.TryGet<T>(key, out var cached))
{
return cached!;
}
if (cache.TryGet<T>(key, out var cached)) return cached!;
var value = await factory();
cache.Set(key, value, expiration);
@@ -54,10 +48,7 @@ public static class CacheExtensions
Func<T> factory,
TimeSpan slidingExpiration)
{
if (cache.TryGet<T>(key, out var cached))
{
return cached!;
}
if (cache.TryGet<T>(key, out var cached)) return cached!;
var value = factory();
cache.SetSliding(key, value, slidingExpiration);
@@ -81,4 +72,4 @@ public static class CacheExtensions
var prefix = pattern.TrimEnd('*');
cache.RemoveByPrefix(prefix);
}
}
}
@@ -36,4 +36,4 @@ public static class CacheServiceExtensions
return services;
}
}
}
+40 -49
View File
@@ -57,11 +57,11 @@ public interface ICacheService
/// <summary>
/// 基于 MemoryCache 的缓存服务实现
/// </summary>
public class MemoryCacheService : ICacheService
{
private readonly IMemoryCache _cache;
private readonly HashSet<string> _keys = new(); // 跟踪所有键(用于 Clear)
private readonly object _lockObj = new();
public class MemoryCacheService : ICacheService
{
private readonly IMemoryCache _cache;
private readonly HashSet<string> _keys = new(); // 跟踪所有键(用于 Clear)
private readonly object _lockObj = new();
public MemoryCacheService(IMemoryCache cache)
{
@@ -134,50 +134,41 @@ public interface ICacheService
}
}
public void Clear()
public void Clear()
{
lock (_lockObj)
{
lock (_lockObj)
{
foreach (var key in _keys.ToList())
{
_cache.Remove(key);
}
_keys.Clear();
}
}
public void RemoveByPrefix(string prefix)
{
if (string.IsNullOrWhiteSpace(prefix))
{
return;
}
List<string> targets;
lock (_lockObj)
{
targets = _keys.Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList();
}
foreach (var key in targets)
{
Remove(key);
}
}
public IReadOnlyCollection<string> Keys
{
get
{
lock (_lockObj)
{
return _keys.ToList().AsReadOnly();
}
}
}
public bool Exists(string key)
{
return _cache.TryGetValue(key, out _);
foreach (var key in _keys.ToList()) _cache.Remove(key);
_keys.Clear();
}
}
public void RemoveByPrefix(string prefix)
{
if (string.IsNullOrWhiteSpace(prefix)) return;
List<string> targets;
lock (_lockObj)
{
targets = _keys.Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList();
}
foreach (var key in targets) Remove(key);
}
public IReadOnlyCollection<string> Keys
{
get
{
lock (_lockObj)
{
return _keys.ToList().AsReadOnly();
}
}
}
public bool Exists(string key)
{
return _cache.TryGetValue(key, out _);
}
}
+29
View File
@@ -9,6 +9,7 @@
### 场景对比
#### ❌ 不使用 Cache - 重复查询
```csharp
public class ProductService {
private readonly IRepository<Category> _categoryRepo;
@@ -29,6 +30,7 @@ public class ProductService {
```
#### ✅ 使用 Cache - 智能缓存
```csharp
public class ProductService {
private readonly IRepository<Category> _categoryRepo;
@@ -53,6 +55,7 @@ public class ProductService {
```
**效果**:
- 第一次:查询数据库(耗时 50ms)
- 后续访问:从内存读取(耗时 < 1ms)
- 用户体验:界面秒开,不再等待
@@ -72,6 +75,7 @@ public class ProductService {
## 🚀 快速开始(未来)
### 1. 注册服务
```csharp
// App.xaml.cs
protected override void OnStartup(StartupEventArgs e) {
@@ -85,6 +89,7 @@ protected override void OnStartup(StartupEventArgs e) {
```
### 2. 注入使用
```csharp
public class WeatherService {
private readonly ICacheService _cache;
@@ -111,6 +116,7 @@ public class WeatherService {
## 📚 核心 API 设计
### ICacheService 接口
```csharp
public interface ICacheService {
/// <summary>
@@ -157,6 +163,7 @@ public interface ICacheService {
```
### 扩展方法(最常用)
```csharp
public static class CacheExtensions {
/// <summary>
@@ -200,6 +207,7 @@ public static class CacheExtensions {
## 💡 典型使用场景
### 场景1:字典数据缓存(最常见)
```csharp
public class UserViewModel : ViewModelBase {
private readonly ICacheService _cache;
@@ -224,6 +232,7 @@ public class UserViewModel : ViewModelBase {
---
### 场景2:API响应缓存
```csharp
public class WeatherService {
private readonly ICacheService _cache;
@@ -247,6 +256,7 @@ public class WeatherService {
---
### 场景3:计算结果缓存
```csharp
public class ReportService {
private readonly ICacheService _cache;
@@ -277,6 +287,7 @@ public class ReportService {
---
### 场景4:用户权限缓存
```csharp
public class AuthorizationService {
private readonly ICacheService _cache;
@@ -300,6 +311,7 @@ public class AuthorizationService {
---
### 场景5:配置数据缓存
```csharp
public class SystemConfigService {
private readonly ICacheService _cache;
@@ -325,6 +337,7 @@ public class SystemConfigService {
## 🔧 高级功能
### 1. 缓存键约定
```csharp
// 推荐的缓存键命名规范
public static class CacheKeys {
@@ -346,6 +359,7 @@ var user = await _cache.GetOrSetAsync(
---
### 2. 缓存依赖(级联清除)
```csharp
public class ProductService {
// 当产品更新时,清除相关缓存
@@ -367,6 +381,7 @@ public class ProductService {
---
### 3. 缓存预热
```csharp
public class CacheWarmupService {
private readonly ICacheService _cache;
@@ -406,6 +421,7 @@ protected override async void OnStartup(StartupEventArgs e) {
---
### 4. 缓存统计(可选)
```csharp
public interface ICacheStatistics {
long TotalHits { get; } // 命中次数
@@ -446,12 +462,14 @@ services.AddMemoryCache(options => {
## 🏗️ 实现方案
### 基于 Microsoft.Extensions.Caching.Memory
- **内置库**:无需额外依赖
- **高性能**:基于 ConcurrentDictionary
- **线程安全**:完全线程安全
- **自动清理**:后台定期清理过期项
### 架构设计
```
ICacheService(接口)
↓
@@ -474,6 +492,7 @@ Microsoft.Extensions.Caching.Memory.IMemoryCache
## 🔄 过期策略对比
### 绝对过期 (Absolute Expiration)
```csharp
// 从设置时开始,10分钟后过期(无论是否访问)
_cache.Set("key", value, TimeSpan.FromMinutes(10));
@@ -485,6 +504,7 @@ _cache.Set("key", value, TimeSpan.FromMinutes(10));
```
### 滑动过期 (Sliding Expiration)
```csharp
// 最后访问后30分钟无访问则过期
_cache.SetSliding("key", value, TimeSpan.FromMinutes(30));
@@ -496,6 +516,7 @@ _cache.SetSliding("key", value, TimeSpan.FromMinutes(30));
```
### 组合使用
```csharp
// 滑动30分钟,但最多缓存2小时
services.AddMemoryCache();
@@ -511,6 +532,7 @@ _cache.Set("key", value, new MemoryCacheEntryOptions {
## 💾 缓存大小管理
### 设置项大小
```csharp
_cache.Set("large-data", value, new MemoryCacheEntryOptions {
Size = 1, // 占用1个单位(Size单位由SizeLimit定义)
@@ -519,6 +541,7 @@ _cache.Set("large-data", value, new MemoryCacheEntryOptions {
```
### 优先级
```csharp
// 低优先级:内存不足时优先清除
_cache.Set("low-priority", value, new MemoryCacheEntryOptions {
@@ -541,6 +564,7 @@ _cache.Set("never-remove", value, new MemoryCacheEntryOptions {
## 📝 最佳实践
### 1. 缓存键命名规范
```csharp
✅ 好的命名
"user:profile:123"
@@ -554,6 +578,7 @@ _cache.Set("never-remove", value, new MemoryCacheEntryOptions {
```
### 2. 合理设置过期时间
```csharp
// 根据数据变化频率设置
字典数据:1小时 - 1天
@@ -563,6 +588,7 @@ API响应:5分钟 - 15分钟
```
### 3. 避免缓存过大对象
```csharp
❌ 不推荐:缓存整个DataTable
_cache.Set("all-products", largeDataTable);
@@ -572,6 +598,7 @@ _cache.Set("all-products", productDtos);
```
### 4. 及时清除失效缓存
```csharp
public async Task UpdateUserAsync(User user) {
await _repository.UpdateAsync(user);
@@ -582,6 +609,7 @@ public async Task UpdateUserAsync(User user) {
```
### 5. 使用Result<T>模式
```csharp
public async Task<Result<Product>> GetProductAsync(int id) {
return await _cache.GetOrSetAsync(
@@ -602,6 +630,7 @@ public async Task<Result<Product>> GetProductAsync(int id) {
## 🚀 未来扩展
### 可能的扩展方向
1. **分布式缓存**(基于 Redis)
2. **多级缓存**(L1: Memory, L2: Redis)
3. **缓存预加载**(后台任务)
+15 -15
View File
@@ -1,22 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- 程序集元数据 -->
<Description>Sharp Framework 核心库 - 提供 Result&lt;T&gt; 模式、Global 容器、系统配置等基础功能</Description>
<!-- 程序集元数据 -->
<Description>Sharp Framework 核心库 - 提供 Result&lt;T&gt; 模式、Global 容器、系统配置等基础功能</Description>
<!-- NuGet 包元数据 -->
<PackageTags>sharp;common;result;railway-oriented;functional;utilities</PackageTags>
</PropertyGroup>
<!-- NuGet 包元数据 -->
<PackageTags>sharp;common;result;railway-oriented;functional;utilities</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0"/>
</ItemGroup>
</Project>
@@ -15,11 +15,9 @@ public sealed class SystemConfig
[RegularExpression(@"^([0-9]{1,3}\.){3}[0-9]{1,3}$|^localhost$|^[a-zA-Z0-9_.-]+$")]
public string HTTPIp { get; set; } = "127.0.0.1";
[Range(1, 65535)]
public int HttpPort { get; set; } = 8080;
[Range(1, 65535)] public int HttpPort { get; set; } = 8080;
[Range(1, 600)]
public int TimeoutSeconds { get; set; } = 30;
[Range(1, 600)] public int TimeoutSeconds { get; set; } = 30;
public string Scheme { get; set; } = "http";
}
}
@@ -21,20 +21,14 @@ public static class QuickStartExtensions
// 1. 配置管理(如果没传就用默认)
// Note: ConfigBuilder.CreateDefault() 在 Config 模块中
// 这里假设用户已经传入 configuration 或在外部创建
if (configuration != null)
{
services.AddSingleton(configuration);
}
if (configuration != null) services.AddSingleton(configuration);
// 2. 缓存服务(使用 Cache 模块的扩展方法)
// Note: AddSharpCache() 在 Cache.DependencyInjection 中定义
services.AddMemoryCache();
// 3. 全局服务定位器(兼容性)
if (configuration != null)
{
RegisterGlobalServices(services, configuration);
}
if (configuration != null) RegisterGlobalServices(services, configuration);
return services;
}
@@ -51,4 +45,4 @@ public static class QuickStartExtensions
Global.RegisterSingleton(configuration);
Global.RegisterSingleton(provider);
}
}
}
@@ -33,6 +33,4 @@ public class ExceptionExpand : Exception
{
return $"Error Code: {ErrorCode}\nMessage: {Message}\nSuggestion: {Suggestion}\n{base.ToString()}";
}
}
}
+11 -38
View File
@@ -171,26 +171,17 @@ public static class CollectionExpand
var nodes = list.Select(item => new TreeNode<T>(item, idSelector(item), parentIdSelector(item))).ToList();
var lookup = nodes.ToLookup(n => n.ParentId);
foreach (var node in nodes)
{
node.Children = lookup[node.Id].ToList();
}
foreach (var node in nodes) node.Children = lookup[node.Id].ToList();
// 构建层级深度
void SetLevel(TreeNode<T> node, int level)
{
node.Level = level;
foreach (var child in node.Children)
{
SetLevel(child, level + 1);
}
foreach (var child in node.Children) SetLevel(child, level + 1);
}
var rootNodes = nodes.Where(n => Equals(n.ParentId, rootParentId)).ToList();
foreach (var root in rootNodes)
{
SetLevel(root, 0);
}
foreach (var root in rootNodes) SetLevel(root, 0);
return rootNodes;
}
@@ -210,10 +201,7 @@ public static class CollectionExpand
foreach (var node in nodes)
{
result.Add(node.Data);
if (node.Children.Any())
{
Traverse(node.Children);
}
if (node.Children.Any()) Traverse(node.Children);
}
}
@@ -231,10 +219,7 @@ public static class CollectionExpand
foreach (var node in nodes)
{
action(node);
if (node.Children.Any())
{
Traverse(node.Children);
}
if (node.Children.Any()) Traverse(node.Children);
}
}
@@ -270,10 +255,7 @@ public static class CollectionExpand
}
}
if (batch.Count > 0)
{
yield return batch;
}
if (batch.Count > 0) yield return batch;
}
/// <summary>
@@ -281,10 +263,7 @@ public static class CollectionExpand
/// </summary>
public static void ForEachBatch<T>(this IEnumerable<T> source, int batchSize, Action<IEnumerable<T>> action)
{
foreach (var batch in source.Batch(batchSize))
{
action(batch);
}
foreach (var batch in source.Batch(batchSize)) action(batch);
}
#endregion
@@ -333,10 +312,7 @@ public static class CollectionExpand
/// </summary>
public static void ForEach<T>(this IEnumerable<T> source, Action<T> action)
{
foreach (var item in source)
{
action(item);
}
foreach (var item in source) action(item);
}
/// <summary>
@@ -344,11 +320,8 @@ public static class CollectionExpand
/// </summary>
public static void ForEach<T>(this IEnumerable<T> source, Action<T, int> action)
{
int index = 0;
foreach (var item in source)
{
action(item, index++);
}
var index = 0;
foreach (var item in source) action(item, index++);
}
/// <summary>
@@ -475,4 +448,4 @@ public static class CollectionExpand
}
#endregion
}
}
+13 -22
View File
@@ -238,7 +238,7 @@ public static class DateTimeExpand
/// </summary>
public static DateTime StartOfWeek(this DateTime dt, DayOfWeek firstDayOfWeek = DayOfWeek.Monday)
{
int diff = (7 + (dt.DayOfWeek - firstDayOfWeek)) % 7;
var diff = (7 + (dt.DayOfWeek - firstDayOfWeek)) % 7;
return dt.AddDays(-diff).Date;
}
@@ -255,17 +255,14 @@ public static class DateTimeExpand
/// </summary>
public static DateTime AddWorkDays(this DateTime dt, int workDays)
{
int direction = workDays < 0 ? -1 : 1;
int daysToAdd = Math.Abs(workDays);
DateTime result = dt;
var direction = workDays < 0 ? -1 : 1;
var daysToAdd = Math.Abs(workDays);
var result = dt;
while (daysToAdd > 0)
{
result = result.AddDays(direction);
if (result.DayOfWeek != DayOfWeek.Saturday && result.DayOfWeek != DayOfWeek.Sunday)
{
daysToAdd--;
}
if (result.DayOfWeek != DayOfWeek.Saturday && result.DayOfWeek != DayOfWeek.Sunday) daysToAdd--;
}
return result;
@@ -279,14 +276,10 @@ public static class DateTimeExpand
if (startDate > endDate)
(startDate, endDate) = (endDate, startDate);
int workDays = 0;
for (DateTime date = startDate; date <= endDate; date = date.AddDays(1))
{
var workDays = 0;
for (var date = startDate; date <= endDate; date = date.AddDays(1))
if (date.DayOfWeek != DayOfWeek.Saturday && date.DayOfWeek != DayOfWeek.Sunday)
{
workDays++;
}
}
return workDays;
}
@@ -297,12 +290,10 @@ public static class DateTimeExpand
public static int CalculateAge(this DateTime birthDate, DateTime? referenceDate = null)
{
var reference = referenceDate ?? DateTime.Today;
int age = reference.Year - birthDate.Year;
var age = reference.Year - birthDate.Year;
if (reference.Month < birthDate.Month || (reference.Month == birthDate.Month && reference.Day < birthDate.Day))
{
age--;
}
if (reference.Month < birthDate.Month ||
(reference.Month == birthDate.Month && reference.Day < birthDate.Day)) age--;
return age;
}
@@ -320,8 +311,8 @@ public static class DateTimeExpand
/// </summary>
public static DateTime StartOfQuarter(this DateTime dt)
{
int quarter = dt.GetQuarter();
int month = (quarter - 1) * 3 + 1;
var quarter = dt.GetQuarter();
var month = (quarter - 1) * 3 + 1;
return new DateTime(dt.Year, month, 1, 0, 0, 0, dt.Kind);
}
@@ -527,4 +518,4 @@ public static class DateTimeExpand
}
#endregion
}
}
+13 -24
View File
@@ -277,10 +277,7 @@ public static class StringExpand
if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(trimString))
return str ?? string.Empty;
while (str.StartsWith(trimString))
{
str = str.Substring(trimString.Length);
}
while (str.StartsWith(trimString)) str = str.Substring(trimString.Length);
return str;
}
@@ -293,10 +290,7 @@ public static class StringExpand
if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(trimString))
return str ?? string.Empty;
while (str.EndsWith(trimString))
{
str = str.Substring(0, str.Length - trimString.Length);
}
while (str.EndsWith(trimString)) str = str.Substring(0, str.Length - trimString.Length);
return str;
}
@@ -344,7 +338,7 @@ public static class StringExpand
var result = new StringBuilder();
result.Append(words[0].ToLowerInvariant());
for (int i = 1; i < words.Length; i++)
for (var i = 1; i < words.Length; i++)
{
result.Append(char.ToUpperInvariant(words[i][0]));
if (words[i].Length > 1)
@@ -620,10 +614,7 @@ public static class StringExpand
return string.Empty;
var sb = new StringBuilder(str.Length * count);
for (int i = 0; i < count; i++)
{
sb.Append(str);
}
for (var i = 0; i < count; i++) sb.Append(str);
return sb.ToString();
}
@@ -686,23 +677,21 @@ public static class StringExpand
if (string.IsNullOrEmpty(target))
return source.Length;
int[,] distance = new int[source.Length + 1, target.Length + 1];
var distance = new int[source.Length + 1, target.Length + 1];
for (int i = 0; i <= source.Length; i++)
for (var i = 0; i <= source.Length; i++)
distance[i, 0] = i;
for (int j = 0; j <= target.Length; j++)
for (var j = 0; j <= target.Length; j++)
distance[0, j] = j;
for (int i = 1; i <= source.Length; i++)
for (var i = 1; i <= source.Length; i++)
for (var j = 1; j <= target.Length; j++)
{
for (int j = 1; j <= target.Length; j++)
{
int cost = (target[j - 1] == source[i - 1]) ? 0 : 1;
distance[i, j] = Math.Min(
Math.Min(distance[i - 1, j] + 1, distance[i, j - 1] + 1),
distance[i - 1, j - 1] + cost);
}
var cost = target[j - 1] == source[i - 1] ? 0 : 1;
distance[i, j] = Math.Min(
Math.Min(distance[i - 1, j] + 1, distance[i, j - 1] + 1),
distance[i - 1, j - 1] + cost);
}
return distance[source.Length, target.Length];
+3 -20
View File
@@ -121,10 +121,7 @@ public static class Global
_services[typeof(T)] = descriptor;
// 如果是更新且需要通知,触发变更事件
if (isUpdate && notifyIfExists)
{
NotifyChanged(oldValue, instance);
}
if (isUpdate && notifyIfExists) NotifyChanged(oldValue, instance);
}
/// <summary>
@@ -237,15 +234,10 @@ public static class Global
{
// 双重检查锁定模式,保证单例只创建一次
if (descriptor.SingletonInstance == null)
{
lock (descriptor.Lock)
{
if (descriptor.SingletonInstance == null)
{
descriptor.SingletonInstance = descriptor.Factory();
}
if (descriptor.SingletonInstance == null) descriptor.SingletonInstance = descriptor.Factory();
}
}
return descriptor.SingletonInstance;
}
@@ -326,10 +318,7 @@ public static class Global
lock (lockObj)
{
if (_subscribers.TryGetValue(type, out var subscribers))
{
subscribers.Remove(handler);
}
if (_subscribers.TryGetValue(type, out var subscribers)) subscribers.Remove(handler);
}
}
@@ -344,20 +333,15 @@ public static class Global
// 获取订阅者列表的快照,避免在通知过程中锁定
List<Delegate> subscribersCopy;
if (_subscriberLocks.TryGetValue(type, out var lockObj))
{
lock (lockObj)
{
subscribersCopy = new List<Delegate>(subscribers);
}
}
else
{
subscribersCopy = new List<Delegate>(subscribers);
}
// 通知所有订阅者
foreach (var subscriber in subscribersCopy)
{
try
{
(subscriber as Action<ServiceChangedEventArgs<T>>)?.Invoke(eventArgs);
@@ -367,7 +351,6 @@ public static class Global
// 忽略订阅者抛出的异常,避免影响其他订阅者
// 在实际应用中,可以记录日志
}
}
}
#endregion
+16 -15
View File
@@ -749,33 +749,33 @@ dotnet test
### DI 容器
1. **生命周期选择**
- 使用 **Singleton** 用于:配置对象、数据库连接池、缓存服务
- 使用 **Transient** 用于:请求上下文、操作上下文
- 使用 **Singleton** 用于:配置对象、数据库连接池、缓存服务
- 使用 **Transient** 用于:请求上下文、操作上下文
2. **变更通知**
- 订阅者应该是轻量级的,避免长时间阻塞
- 订阅者中不要抛出异常(已被容器捕获和忽略)
- 订阅者应该是轻量级的,避免长时间阻塞
- 订阅者中不要抛出异常(已被容器捕获和忽略)
3. **线程安全**
- 容器本身是线程安全的
- 单例服务的初始化是线程安全的(双重检查锁定)
- 容器本身是线程安全的
- 单例服务的初始化是线程安全的(双重检查锁定)
### Result 模式
1. **何时使用 Result**
- 可预期的业务错误(验证失败、资源不存在)
- 外部 API 调用
- 需要传递错误码的场景
- 可预期的业务错误(验证失败、资源不存在)
- 外部 API 调用
- 需要传递错误码的场景
2. **何时使用异常**
- 不可预期的程序错误(NullReferenceException、OutOfMemoryException)
- 系统级错误
- 不可预期的程序错误(NullReferenceException、OutOfMemoryException)
- 系统级错误
3. **链式操作**
- 使用 `Map` 转换成功值
- 使用 `Bind` 链接返回 Result 的操作
- 使用 `Ensure` 进行验证
- 失败会自动传播,不需要手动检查
- 使用 `Map` 转换成功值
- 使用 `Bind` 链接返回 Result 的操作
- 使用 `Ensure` 进行验证
- 失败会自动传播,不需要手动检查
## 项目结构
@@ -812,6 +812,7 @@ Sharp/Common/
## 版本历史
### v1.0.0
- ✅ 轻量级 DI 容器(支持生命周期和变更通知)
- ✅ Result 模式(完整的函数式错误处理)
- ✅ 自定义异常(带错误码)
+70 -38
View File
@@ -14,10 +14,10 @@ public abstract class Result
{
if (isSuccess && exception is not null)
throw new ArgumentException("Success result cannot have an exception", nameof(exception));
if (isSuccess && errorCode.HasValue)
throw new ArgumentException("Success result cannot have an error code", nameof(errorCode));
if (!isSuccess && exception is null && string.IsNullOrEmpty(errorMessage) && !errorCode.HasValue)
throw new ArgumentException("Failed result must have either an exception, error message, or error code");
@@ -25,33 +25,60 @@ public abstract class Result
Exception = exception;
ErrorMessage = errorMessage ?? exception?.Message;
ErrorCode = errorCode;
// If we have an ExceptionExpand, extract the error code from it
if (exception is ExceptionExpand exceptionExpand && !errorCode.HasValue)
{
ErrorCode = exceptionExpand.ErrorCode;
}
if (exception is ExceptionExpand exceptionExpand && !errorCode.HasValue) ErrorCode = exceptionExpand.ErrorCode;
}
public static Result Success() => new SimpleResult(true);
public static Result<T> Success<T>(T value) => new Result<T>(value);
public static Result Failure(string errorMessage) => new SimpleResult(false, null, errorMessage);
public static Result Failure(Exception exception) => new SimpleResult(false, exception);
public static Result Failure(long errorCode, string errorMessage) => new SimpleResult(false, null, errorMessage, errorCode);
public static Result Failure(long errorCode, string errorMessage, Exception exception) => new SimpleResult(false, exception, errorMessage, errorCode);
public static Result<T> Failure<T>(string errorMessage) => new Result<T>(default, false, null, errorMessage);
public static Result<T> Failure<T>(Exception exception) => new Result<T>(default, false, exception);
public static Result<T> Failure<T>(long errorCode, string errorMessage) => new Result<T>(default, false, null, errorMessage, errorCode);
public static Result<T> Failure<T>(long errorCode, string errorMessage, Exception exception) => new Result<T>(default, false, exception, errorMessage, errorCode);
public static Result Success()
{
return new SimpleResult(true);
}
public static Result<T> Success<T>(T value)
{
return new Result<T>(value);
}
public static Result Failure(string errorMessage)
{
return new SimpleResult(false, null, errorMessage);
}
public static Result Failure(Exception exception)
{
return new SimpleResult(false, exception);
}
public static Result Failure(long errorCode, string errorMessage)
{
return new SimpleResult(false, null, errorMessage, errorCode);
}
public static Result Failure(long errorCode, string errorMessage, Exception exception)
{
return new SimpleResult(false, exception, errorMessage, errorCode);
}
public static Result<T> Failure<T>(string errorMessage)
{
return new Result<T>(default, false, null, errorMessage);
}
public static Result<T> Failure<T>(Exception exception)
{
return new Result<T>(default, false, exception);
}
public static Result<T> Failure<T>(long errorCode, string errorMessage)
{
return new Result<T>(default, false, null, errorMessage, errorCode);
}
public static Result<T> Failure<T>(long errorCode, string errorMessage, Exception exception)
{
return new Result<T>(default, false, exception, errorMessage, errorCode);
}
public static Result Try(Action action)
{
@@ -128,17 +155,18 @@ public abstract class Result
public Result<T> Match<T>(Func<T> onSuccess, Func<Exception?, string?, long?, T> onFailure)
{
return IsSuccess ? Result.Success(onSuccess()) : Result.Failure<T>(Exception ?? new Exception(ErrorMessage));
return IsSuccess ? Success(onSuccess()) : Failure<T>(Exception ?? new Exception(ErrorMessage));
}
public Result<T> Match<T>(Func<T> onSuccess, Func<Exception?, string?, T> onFailure)
{
return IsSuccess ? Result.Success(onSuccess()) : Result.Failure<T>(Exception ?? new Exception(ErrorMessage));
return IsSuccess ? Success(onSuccess()) : Failure<T>(Exception ?? new Exception(ErrorMessage));
}
private class SimpleResult : Result
{
public SimpleResult(bool isSuccess, Exception? exception = null, string? errorMessage = null, long? errorCode = null)
public SimpleResult(bool isSuccess, Exception? exception = null, string? errorMessage = null,
long? errorCode = null)
: base(isSuccess, exception, errorMessage, errorCode)
{
}
@@ -149,31 +177,35 @@ public class Result<T> : Result
{
public T? Value { get; }
public Result(T? value, bool isSuccess = true, Exception? exception = null, string? errorMessage = null, long? errorCode = null)
public Result(T? value, bool isSuccess = true, Exception? exception = null, string? errorMessage = null,
long? errorCode = null)
: base(isSuccess, exception, errorMessage, errorCode)
{
Value = value;
}
public static implicit operator Result<T>(T value) => new Result<T>(value);
public static implicit operator Result<T>(T value)
{
return new Result<T>(value);
}
public Result<TNew> Map<TNew>(Func<T, TNew> mapper)
{
if (IsFailure)
{
if (ErrorCode.HasValue)
return Result.Failure<TNew>(ErrorCode.Value, ErrorMessage ?? "Mapping failed", Exception);
return Result.Failure<TNew>(Exception ?? new Exception(ErrorMessage));
return Failure<TNew>(ErrorCode.Value, ErrorMessage ?? "Mapping failed", Exception);
return Failure<TNew>(Exception ?? new Exception(ErrorMessage));
}
try
{
var mappedValue = mapper(Value!);
return Result.Success(mappedValue);
return Success(mappedValue);
}
catch (Exception ex)
{
return Result.Failure<TNew>(ex);
return Failure<TNew>(ex);
}
}
@@ -182,8 +214,8 @@ public class Result<T> : Result
if (IsFailure)
{
if (ErrorCode.HasValue)
return Result.Failure<TNew>(ErrorCode.Value, ErrorMessage ?? "Binding failed", Exception);
return Result.Failure<TNew>(Exception ?? new Exception(ErrorMessage));
return Failure<TNew>(ErrorCode.Value, ErrorMessage ?? "Binding failed", Exception);
return Failure<TNew>(Exception ?? new Exception(ErrorMessage));
}
try
@@ -192,7 +224,7 @@ public class Result<T> : Result
}
catch (Exception ex)
{
return Result.Failure<TNew>(ex);
return Failure<TNew>(ex);
}
}
+28 -15
View File
@@ -57,10 +57,13 @@ public static class ResultExtensions
public static T GetOrElse<T>(this Result<T> result, Func<Exception?, string?, long?, T> alternativeProvider)
{
return result.IsSuccess ? result.Value! : alternativeProvider(result.Exception, result.ErrorMessage, result.ErrorCode);
return result.IsSuccess
? result.Value!
: alternativeProvider(result.Exception, result.ErrorMessage, result.ErrorCode);
}
public static Result<T> Where<T>(this Result<T> result, Func<T, bool> predicate, string errorMessage = "Predicate not satisfied")
public static Result<T> Where<T>(this Result<T> result, Func<T, bool> predicate,
string errorMessage = "Predicate not satisfied")
{
if (result.IsFailure)
return result;
@@ -68,7 +71,8 @@ public static class ResultExtensions
return predicate(result.Value!) ? result : Result.Failure<T>(errorMessage);
}
public static Result<T> Where<T>(this Result<T> result, Func<T, bool> predicate, long errorCode, string errorMessage = "Predicate not satisfied")
public static Result<T> Where<T>(this Result<T> result, Func<T, bool> predicate, long errorCode,
string errorMessage = "Predicate not satisfied")
{
if (result.IsFailure)
return result;
@@ -76,7 +80,8 @@ public static class ResultExtensions
return predicate(result.Value!) ? result : Result.Failure<T>(errorCode, errorMessage);
}
public static Result<T> Where<T>(this Result<T> result, Func<T, bool> predicate, Func<T, Exception> exceptionProvider)
public static Result<T> Where<T>(this Result<T> result, Func<T, bool> predicate,
Func<T, Exception> exceptionProvider)
{
if (result.IsFailure)
return result;
@@ -89,7 +94,8 @@ public static class ResultExtensions
return result.Bind(selector);
}
public static Result<V> SelectMany<T, U, V>(this Result<T> result, Func<T, Result<U>> selector, Func<T, U, V> resultSelector)
public static Result<V> SelectMany<T, U, V>(this Result<T> result, Func<T, Result<U>> selector,
Func<T, U, V> resultSelector)
{
return result.Bind(t => selector(t).Map(u => resultSelector(t, u)));
}
@@ -120,7 +126,8 @@ public static class ResultExtensions
return result;
}
public static Result<T> DoOnBoth<T>(this Result<T> result, Action<T> onSuccess, Action<Exception?, string?> onFailure)
public static Result<T> DoOnBoth<T>(this Result<T> result, Action<T> onSuccess,
Action<Exception?, string?> onFailure)
{
if (result.IsSuccess)
onSuccess(result.Value!);
@@ -129,7 +136,8 @@ public static class ResultExtensions
return result;
}
public static Result<T> DoOnBoth<T>(this Result<T> result, Action<T> onSuccess, Action<Exception?, string?, long?> onFailure)
public static Result<T> DoOnBoth<T>(this Result<T> result, Action<T> onSuccess,
Action<Exception?, string?, long?> onFailure)
{
if (result.IsSuccess)
onSuccess(result.Value!);
@@ -140,12 +148,12 @@ public static class ResultExtensions
public static Result Ignore<T>(this Result<T> result)
{
if (result.IsSuccess)
if (result.IsSuccess)
return Result.Success();
if (result.ErrorCode.HasValue)
return Result.Failure(result.ErrorCode.Value, result.ErrorMessage ?? "Operation failed", result.Exception);
return Result.Failure(result.Exception ?? new Exception(result.ErrorMessage));
}
@@ -172,7 +180,8 @@ public static class ResultExtensions
return result;
}
public static async Task<Result<T>> DoOnFailureAsync<T>(this Task<Result<T>> resultTask, Func<Exception?, string?, Task> asyncAction)
public static async Task<Result<T>> DoOnFailureAsync<T>(this Task<Result<T>> resultTask,
Func<Exception?, string?, Task> asyncAction)
{
var result = await resultTask;
if (result.IsFailure)
@@ -197,7 +206,8 @@ public static class ResultExtensions
}
}
public static async Task<Result<U>> BindAsync<T, U>(this Task<Result<T>> resultTask, Func<T, Task<Result<U>>> asyncBinder)
public static async Task<Result<U>> BindAsync<T, U>(this Task<Result<T>> resultTask,
Func<T, Task<Result<U>>> asyncBinder)
{
var result = await resultTask;
if (result.IsFailure)
@@ -221,7 +231,8 @@ public static class ResultExtensions
return predicate(result.Value!) ? result : Result.Failure<T>(errorMessage);
}
public static Result<T> Ensure<T>(this Result<T> result, Func<T, bool> predicate, long errorCode, string errorMessage)
public static Result<T> Ensure<T>(this Result<T> result, Func<T, bool> predicate, long errorCode,
string errorMessage)
{
if (result.IsFailure)
return result;
@@ -229,7 +240,8 @@ public static class ResultExtensions
return predicate(result.Value!) ? result : Result.Failure<T>(errorCode, errorMessage);
}
public static Result<T> Ensure<T>(this Result<T> result, Func<T, bool> predicate, Func<T, Exception> exceptionFactory)
public static Result<T> Ensure<T>(this Result<T> result, Func<T, bool> predicate,
Func<T, Exception> exceptionFactory)
{
if (result.IsFailure)
return result;
@@ -247,7 +259,8 @@ public static class ResultExtensions
return result.IsSuccess ? result : alternativeProvider(result.Exception, result.ErrorMessage);
}
public static Result<T> OrElse<T>(this Result<T> result, Func<Exception?, string?, long?, Result<T>> alternativeProvider)
public static Result<T> OrElse<T>(this Result<T> result,
Func<Exception?, string?, long?, Result<T>> alternativeProvider)
{
return result.IsSuccess ? result : alternativeProvider(result.Exception, result.ErrorMessage, result.ErrorCode);
}
+44 -47
View File
@@ -25,12 +25,12 @@ public static class CryptoHelper
throw new ArgumentNullException(nameof(password));
// 生成随机 Salt
byte[] salt = RandomNumberGenerator.GetBytes(16);
var salt = RandomNumberGenerator.GetBytes(16);
// 从密码派生密钥
using var deriveBytes = new Rfc2898DeriveBytes(password, salt, 10000, HashAlgorithmName.SHA256);
byte[] key = deriveBytes.GetBytes(32); // 256 bit
byte[] iv = deriveBytes.GetBytes(16); // 128 bit
var key = deriveBytes.GetBytes(32); // 256 bit
var iv = deriveBytes.GetBytes(16); // 128 bit
// AES 加密
using var aes = Aes.Create();
@@ -38,11 +38,11 @@ public static class CryptoHelper
aes.IV = iv;
using var encryptor = aes.CreateEncryptor();
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
// 组合 Salt + 密文
byte[] result = new byte[salt.Length + cipherBytes.Length];
var result = new byte[salt.Length + cipherBytes.Length];
Buffer.BlockCopy(salt, 0, result, 0, salt.Length);
Buffer.BlockCopy(cipherBytes, 0, result, salt.Length, cipherBytes.Length);
@@ -62,20 +62,20 @@ public static class CryptoHelper
if (string.IsNullOrEmpty(password))
throw new ArgumentNullException(nameof(password));
byte[] fullCipher = Convert.FromBase64String(cipherText);
var fullCipher = Convert.FromBase64String(cipherText);
// 提取 Salt
byte[] salt = new byte[16];
var salt = new byte[16];
Buffer.BlockCopy(fullCipher, 0, salt, 0, 16);
// 提取密文
byte[] cipherBytes = new byte[fullCipher.Length - 16];
var cipherBytes = new byte[fullCipher.Length - 16];
Buffer.BlockCopy(fullCipher, 16, cipherBytes, 0, cipherBytes.Length);
// 从密码派生密钥
using var deriveBytes = new Rfc2898DeriveBytes(password, salt, 10000, HashAlgorithmName.SHA256);
byte[] key = deriveBytes.GetBytes(32);
byte[] iv = deriveBytes.GetBytes(16);
var key = deriveBytes.GetBytes(32);
var iv = deriveBytes.GetBytes(16);
// AES 解密
using var aes = Aes.Create();
@@ -83,7 +83,7 @@ public static class CryptoHelper
aes.IV = iv;
using var decryptor = aes.CreateDecryptor();
byte[] plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
return Encoding.UTF8.GetString(plainBytes);
}
@@ -121,8 +121,8 @@ public static class CryptoHelper
using var rsa = RSA.Create();
rsa.ImportRSAPublicKey(Convert.FromBase64String(publicKey), out _);
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
byte[] cipherBytes = rsa.Encrypt(plainBytes, RSAEncryptionPadding.OaepSHA256);
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var cipherBytes = rsa.Encrypt(plainBytes, RSAEncryptionPadding.OaepSHA256);
return Convert.ToBase64String(cipherBytes);
}
@@ -143,8 +143,8 @@ public static class CryptoHelper
using var rsa = RSA.Create();
rsa.ImportRSAPrivateKey(Convert.FromBase64String(privateKey), out _);
byte[] cipherBytes = Convert.FromBase64String(cipherText);
byte[] plainBytes = rsa.Decrypt(cipherBytes, RSAEncryptionPadding.OaepSHA256);
var cipherBytes = Convert.FromBase64String(cipherText);
var plainBytes = rsa.Decrypt(cipherBytes, RSAEncryptionPadding.OaepSHA256);
return Encoding.UTF8.GetString(plainBytes);
}
@@ -164,8 +164,8 @@ public static class CryptoHelper
throw new ArgumentNullException(nameof(input));
using var md5 = MD5.Create();
byte[] inputBytes = Encoding.UTF8.GetBytes(input);
byte[] hashBytes = md5.ComputeHash(inputBytes);
var inputBytes = Encoding.UTF8.GetBytes(input);
var hashBytes = md5.ComputeHash(inputBytes);
return BytesToHex(hashBytes);
}
@@ -181,8 +181,8 @@ public static class CryptoHelper
throw new ArgumentNullException(nameof(input));
using var sha256 = SHA256.Create();
byte[] inputBytes = Encoding.UTF8.GetBytes(input);
byte[] hashBytes = sha256.ComputeHash(inputBytes);
var inputBytes = Encoding.UTF8.GetBytes(input);
var hashBytes = sha256.ComputeHash(inputBytes);
return BytesToHex(hashBytes);
}
@@ -198,8 +198,8 @@ public static class CryptoHelper
throw new ArgumentNullException(nameof(input));
using var sha512 = SHA512.Create();
byte[] inputBytes = Encoding.UTF8.GetBytes(input);
byte[] hashBytes = sha512.ComputeHash(inputBytes);
var inputBytes = Encoding.UTF8.GetBytes(input);
var hashBytes = sha512.ComputeHash(inputBytes);
return BytesToHex(hashBytes);
}
@@ -223,7 +223,7 @@ public static class CryptoHelper
if (string.IsNullOrEmpty(input))
throw new ArgumentNullException(nameof(input));
byte[] bytes = Encoding.UTF8.GetBytes(input);
var bytes = Encoding.UTF8.GetBytes(input);
return Convert.ToBase64String(bytes);
}
@@ -237,7 +237,7 @@ public static class CryptoHelper
if (string.IsNullOrEmpty(input))
throw new ArgumentNullException(nameof(input));
byte[] bytes = Convert.FromBase64String(input);
var bytes = Convert.FromBase64String(input);
return Encoding.UTF8.GetString(bytes);
}
@@ -293,11 +293,11 @@ public static class CryptoHelper
throw new ArgumentNullException(nameof(password));
// 生成随机 Salt
byte[] salt = RandomNumberGenerator.GetBytes(saltSize);
var salt = RandomNumberGenerator.GetBytes(saltSize);
// 使用 PBKDF2 计算哈希
using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256);
byte[] hash = pbkdf2.GetBytes(hashSize);
var hash = pbkdf2.GetBytes(hashSize);
// 格式: $pbkdf2-sha256$迭代次数$Salt$Hash
return $"$pbkdf2-sha256${iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
@@ -323,13 +323,13 @@ public static class CryptoHelper
if (parts.Length != 5 || parts[1] != "pbkdf2-sha256")
return false;
int iterations = int.Parse(parts[2]);
byte[] salt = Convert.FromBase64String(parts[3]);
byte[] storedHash = Convert.FromBase64String(parts[4]);
var iterations = int.Parse(parts[2]);
var salt = Convert.FromBase64String(parts[3]);
var storedHash = Convert.FromBase64String(parts[4]);
// 计算输入密码的哈希
using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256);
byte[] computedHash = pbkdf2.GetBytes(storedHash.Length);
var computedHash = pbkdf2.GetBytes(storedHash.Length);
// 常量时间比较,防止时序攻击
return CryptographicOperations.FixedTimeEquals(storedHash, computedHash);
@@ -357,11 +357,11 @@ public static class CryptoHelper
if (string.IsNullOrEmpty(secret))
throw new ArgumentNullException(nameof(secret));
byte[] keyBytes = Encoding.UTF8.GetBytes(secret);
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
var keyBytes = Encoding.UTF8.GetBytes(secret);
var messageBytes = Encoding.UTF8.GetBytes(message);
using var hmac = new HMACSHA256(keyBytes);
byte[] hashBytes = hmac.ComputeHash(messageBytes);
var hashBytes = hmac.ComputeHash(messageBytes);
return BytesToHex(hashBytes);
}
@@ -399,11 +399,11 @@ public static class CryptoHelper
if (string.IsNullOrEmpty(secret))
throw new ArgumentNullException(nameof(secret));
byte[] keyBytes = Encoding.UTF8.GetBytes(secret);
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
var keyBytes = Encoding.UTF8.GetBytes(secret);
var messageBytes = Encoding.UTF8.GetBytes(message);
using var hmac = new HMACSHA512(keyBytes);
byte[] hashBytes = hmac.ComputeHash(messageBytes);
var hashBytes = hmac.ComputeHash(messageBytes);
return BytesToHex(hashBytes);
}
@@ -449,10 +449,7 @@ public static class CryptoHelper
chars += special;
var result = new char[length];
for (int i = 0; i < length; i++)
{
result[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
}
for (var i = 0; i < length; i++) result[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
return new string(result);
}
@@ -490,16 +487,19 @@ public static class CryptoHelper
chars += lowercase;
password.Add(lowercase[RandomNumberGenerator.GetInt32(lowercase.Length)]);
}
if (requireUppercase)
{
chars += uppercase;
password.Add(uppercase[RandomNumberGenerator.GetInt32(uppercase.Length)]);
}
if (requireDigit)
{
chars += digits;
password.Add(digits[RandomNumberGenerator.GetInt32(digits.Length)]);
}
if (requireSpecialChar)
{
chars += special;
@@ -507,15 +507,12 @@ public static class CryptoHelper
}
// 填充剩余长度
for (int i = password.Count; i < length; i++)
{
password.Add(chars[RandomNumberGenerator.GetInt32(chars.Length)]);
}
for (var i = password.Count; i < length; i++) password.Add(chars[RandomNumberGenerator.GetInt32(chars.Length)]);
// 随机打乱
for (int i = password.Count - 1; i > 0; i--)
for (var i = password.Count - 1; i > 0; i--)
{
int j = RandomNumberGenerator.GetInt32(i + 1);
var j = RandomNumberGenerator.GetInt32(i + 1);
(password[i], password[j]) = (password[j], password[i]);
}
@@ -523,4 +520,4 @@ public static class CryptoHelper
}
#endregion
}
}
+10 -19
View File
@@ -87,13 +87,9 @@ public static class EnumHelper
return null;
foreach (var field in typeof(TEnum).GetFields())
{
if (field.GetCustomAttribute<DescriptionAttribute>() is { } attribute)
{
if (attribute.Description.Equals(description, StringComparison.OrdinalIgnoreCase))
return (TEnum)field.GetValue(null)!;
}
}
return null;
}
@@ -229,8 +225,8 @@ public static class EnumHelper
/// </summary>
public static TEnum AddFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum
{
int intValue = Convert.ToInt32(value);
int intFlag = Convert.ToInt32(flag);
var intValue = Convert.ToInt32(value);
var intFlag = Convert.ToInt32(flag);
return (TEnum)(object)(intValue | intFlag);
}
@@ -239,8 +235,8 @@ public static class EnumHelper
/// </summary>
public static TEnum RemoveFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum
{
int intValue = Convert.ToInt32(value);
int intFlag = Convert.ToInt32(flag);
var intValue = Convert.ToInt32(value);
var intFlag = Convert.ToInt32(flag);
return (TEnum)(object)(intValue & ~intFlag);
}
@@ -249,8 +245,8 @@ public static class EnumHelper
/// </summary>
public static TEnum ToggleFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum
{
int intValue = Convert.ToInt32(value);
int intFlag = Convert.ToInt32(flag);
var intValue = Convert.ToInt32(value);
var intFlag = Convert.ToInt32(flag);
return (TEnum)(object)(intValue ^ intFlag);
}
@@ -259,11 +255,9 @@ public static class EnumHelper
/// </summary>
public static IEnumerable<TEnum> GetFlags<TEnum>(this TEnum value) where TEnum : struct, Enum
{
foreach (TEnum flag in Enum.GetValues<TEnum>())
{
foreach (var flag in Enum.GetValues<TEnum>())
if (value.HasFlag(flag))
yield return flag;
}
}
/// <summary>
@@ -271,11 +265,8 @@ public static class EnumHelper
/// </summary>
public static TEnum CombineFlags<TEnum>(params TEnum[] flags) where TEnum : struct, Enum
{
int result = 0;
foreach (var flag in flags)
{
result |= Convert.ToInt32(flag);
}
var result = 0;
foreach (var flag in flags) result |= Convert.ToInt32(flag);
return (TEnum)(object)result;
}
@@ -399,4 +390,4 @@ public static class EnumHelper
}
#endregion
}
}
+7 -15
View File
@@ -276,13 +276,11 @@ public static class FileHelper
// 递归复制子目录
if (recursive)
{
foreach (var subDir in dir.GetDirectories())
{
var targetDirPath = Path.Combine(destDir, subDir.Name);
CopyDirectory(subDir.FullName, targetDirPath, true);
}
}
}
/// <summary>
@@ -351,10 +349,7 @@ public static class FileHelper
return Enumerable.Empty<string>();
var files = new List<string>();
foreach (var pattern in searchPatterns)
{
files.AddRange(GetFiles(directoryPath, pattern, recursive));
}
foreach (var pattern in searchPatterns) files.AddRange(GetFiles(directoryPath, pattern, recursive));
return files.Distinct();
}
@@ -664,7 +659,8 @@ public static class FileHelper
public static bool IsTextFile(string filePath)
{
var extension = GetExtension(filePath).ToLowerInvariant();
string[] textExtensions = { ".txt", ".cs", ".xml", ".json", ".html", ".css", ".js", ".ts", ".md", ".log", ".csv", ".sql" };
string[] textExtensions =
{ ".txt", ".cs", ".xml", ".json", ".html", ".css", ".js", ".ts", ".md", ".log", ".csv", ".sql" };
return textExtensions.Contains(extension);
}
@@ -756,16 +752,14 @@ public static class FileHelper
return;
var files = GetFiles(directoryPath, searchPattern, recursive);
foreach (var file in files)
{
DeleteFile(file);
}
foreach (var file in files) DeleteFile(file);
}
/// <summary>
/// 删除超过指定天数的文件
/// </summary>
public static int DeleteOldFiles(string directoryPath, int daysOld, string searchPattern = "*", bool recursive = false)
public static int DeleteOldFiles(string directoryPath, int daysOld, string searchPattern = "*",
bool recursive = false)
{
if (string.IsNullOrWhiteSpace(directoryPath))
throw new ArgumentNullException(nameof(directoryPath));
@@ -778,13 +772,11 @@ public static class FileHelper
var deletedCount = 0;
foreach (var file in files)
{
if (GetLastWriteTime(file) < cutoffDate)
{
DeleteFile(file);
deletedCount++;
}
}
return deletedCount;
}
@@ -870,4 +862,4 @@ public static class FileHelper
}
#endregion
}
}
+18 -28
View File
@@ -17,7 +17,7 @@ public static class JsonHelper
/// <summary>
/// 驼峰命名选项(默认格式化输出)
/// </summary>
public static readonly JsonSerializerOptions CamelCaseOptions = new JsonSerializerOptions
public static readonly JsonSerializerOptions CamelCaseOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
@@ -25,14 +25,14 @@ public static class JsonHelper
Converters =
{
new IpAddressConverter(),
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)
},
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, false)
}
};
/// <summary>
/// 驼峰命名选项(紧凑格式,不格式化输出)
/// </summary>
public static readonly JsonSerializerOptions CamelCaseCompactOptions = new JsonSerializerOptions
public static readonly JsonSerializerOptions CamelCaseCompactOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
@@ -41,14 +41,14 @@ public static class JsonHelper
Converters =
{
new IpAddressConverter(),
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)
},
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, false)
}
};
/// <summary>
/// 蛇形命名选项(snake_case)
/// </summary>
public static readonly JsonSerializerOptions SnakeCaseOptions = new JsonSerializerOptions
public static readonly JsonSerializerOptions SnakeCaseOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = true,
@@ -56,14 +56,14 @@ public static class JsonHelper
Converters =
{
new IpAddressConverter(),
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower, allowIntegerValues: false)
},
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower, false)
}
};
/// <summary>
/// 默认选项(保留原始命名,格式化输出)
/// </summary>
public static readonly JsonSerializerOptions DefaultOptions = new JsonSerializerOptions
public static readonly JsonSerializerOptions DefaultOptions = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
@@ -71,7 +71,7 @@ public static class JsonHelper
{
new IpAddressConverter(),
new JsonStringEnumConverter(allowIntegerValues: false)
},
}
};
#endregion
@@ -191,7 +191,8 @@ public static class JsonHelper
/// <summary>
/// 序列化对象到流
/// </summary>
public static async Task SerializeToStreamAsync<T>(Stream stream, T value, bool useCompact = false, CancellationToken cancellationToken = default)
public static async Task SerializeToStreamAsync<T>(Stream stream, T value, bool useCompact = false,
CancellationToken cancellationToken = default)
{
var options = useCompact ? CamelCaseCompactOptions : CamelCaseOptions;
await JsonSerializer.SerializeAsync(stream, value, options, cancellationToken);
@@ -200,7 +201,8 @@ public static class JsonHelper
/// <summary>
/// 从流反序列化对象
/// </summary>
public static async Task<T?> DeserializeFromStreamAsync<T>(Stream stream, CancellationToken cancellationToken = default)
public static async Task<T?> DeserializeFromStreamAsync<T>(Stream stream,
CancellationToken cancellationToken = default)
{
return await JsonSerializer.DeserializeAsync<T>(stream, CamelCaseOptions, cancellationToken);
}
@@ -287,16 +289,10 @@ public static class JsonHelper
var element = doc.RootElement;
foreach (var key in path.Split('.'))
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(key, out var property))
{
element = property;
}
else
{
return null;
}
}
return element.ToString();
}
@@ -345,16 +341,10 @@ public static class JsonHelper
var merged = new Dictionary<string, JsonElement>();
// 添加第一个 JSON 的所有属性
foreach (var property in doc1.RootElement.EnumerateObject())
{
merged[property.Name] = property.Value.Clone();
}
foreach (var property in doc1.RootElement.EnumerateObject()) merged[property.Name] = property.Value.Clone();
// 用第二个 JSON 的属性覆盖
foreach (var property in doc2.RootElement.EnumerateObject())
{
merged[property.Name] = property.Value.Clone();
}
foreach (var property in doc2.RootElement.EnumerateObject()) merged[property.Name] = property.Value.Clone();
return JsonSerializer.Serialize(merged, CamelCaseOptions);
}
@@ -403,7 +393,7 @@ public class IpAddressConverter : JsonConverter<IPAddress>
{
public override IPAddress Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var ipString = reader.GetString() ?? String.Empty;
var ipString = reader.GetString() ?? string.Empty;
return IPAddress.Parse(ipString);
}
+1 -5
View File
@@ -332,17 +332,13 @@ public static class ReflectionHelper
);
foreach (var sourceProp in sourceProperties)
{
if (targetProperties.TryGetValue(sourceProp.Name, out var targetProp))
{
// 检查类型是否兼容
if (targetProp.PropertyType.IsAssignableFrom(sourceProp.PropertyType))
{
var value = sourceProp.GetValue(source);
targetProp.SetValue(target, value);
}
}
}
}
/// <summary>
@@ -522,4 +518,4 @@ public static class ReflectionHelper
}
#endregion
}
}
+2 -4
View File
@@ -14,9 +14,9 @@ public static class SystemHelper
// todo: 2025/6/24 15:53 开发过程中使用固定端口
// return 8267;
TcpListener listener = new TcpListener(IPAddress.Loopback, 0); // 端口号为0表示由系统自动分配
var listener = new TcpListener(IPAddress.Loopback, 0); // 端口号为0表示由系统自动分配
listener.Start();
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
@@ -33,9 +33,7 @@ public static class SystemHelper
public static bool IsAdministrator()
{
if (!OperatingSystem.IsWindows())
{
throw new PlatformNotSupportedException("IsAdministrator method is only supported on Windows platform.");
}
var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
var principal = new System.Security.Principal.WindowsPrincipal(identity);
+16 -13
View File
@@ -158,8 +158,8 @@ public static class ValidationHelper
// 校验码对应值
char[] checkCodes = { '1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2' };
int sum = 0;
for (int i = 0; i < 17; i++)
var sum = 0;
for (var i = 0; i < 17; i++)
{
if (!char.IsDigit(idCard[i]))
return false;
@@ -167,9 +167,9 @@ public static class ValidationHelper
sum += (idCard[i] - '0') * weights[i];
}
int mod = sum % 11;
char expectedCheckCode = checkCodes[mod];
char actualCheckCode = char.ToUpperInvariant(idCard[17]);
var mod = sum % 11;
var expectedCheckCode = checkCodes[mod];
var actualCheckCode = char.ToUpperInvariant(idCard[17]);
return expectedCheckCode == actualCheckCode;
}
@@ -186,9 +186,9 @@ public static class ValidationHelper
try
{
int year = int.Parse(idCard.Substring(6, 4));
int month = int.Parse(idCard.Substring(10, 2));
int day = int.Parse(idCard.Substring(12, 2));
var year = int.Parse(idCard.Substring(6, 4));
var month = int.Parse(idCard.Substring(10, 2));
var day = int.Parse(idCard.Substring(12, 2));
return new DateTime(year, month, day);
}
@@ -210,7 +210,7 @@ public static class ValidationHelper
try
{
int genderCode = int.Parse(idCard.Substring(16, 1));
var genderCode = int.Parse(idCard.Substring(16, 1));
return genderCode % 2 == 0 ? "Female" : "Male";
}
catch
@@ -281,7 +281,8 @@ public static class ValidationHelper
/// <summary>
/// 验证浮点数在指定范围内(Result 模式)
/// </summary>
public static Result<double> ValidateRange(double value, double min, double max, string fieldName = "值", long errorCode = 400)
public static Result<double> ValidateRange(double value, double min, double max, string fieldName = "值",
long errorCode = 400)
{
if (value < min || value > max)
return Result.Failure<double>(errorCode, $"{fieldName}必须在 {min} 到 {max} 之间");
@@ -302,7 +303,8 @@ public static class ValidationHelper
/// <param name="fieldName">字段名称</param>
/// <param name="errorCode">错误码(默认 400)</param>
/// <returns>验证结果</returns>
public static Result<string> ValidateLength(string? value, int minLength, int maxLength, string fieldName = "字段", long errorCode = 400)
public static Result<string> ValidateLength(string? value, int minLength, int maxLength, string fieldName = "字段",
long errorCode = 400)
{
if (string.IsNullOrEmpty(value))
return Result.Failure<string>(errorCode, $"{fieldName}不能为空");
@@ -420,7 +422,8 @@ public static class ValidationHelper
/// <param name="errorMessage">错误消息</param>
/// <param name="errorCode">错误码(默认 400)</param>
/// <returns>验证结果</returns>
public static Result<string> ValidateRegex(string? value, string pattern, string errorMessage = "格式无效", long errorCode = 400)
public static Result<string> ValidateRegex(string? value, string pattern, string errorMessage = "格式无效",
long errorCode = 400)
{
if (string.IsNullOrWhiteSpace(value))
return Result.Failure<string>(errorCode, "值不能为空");
@@ -512,4 +515,4 @@ public static class ValidationHelper
}
#endregion
}
}
+22 -22
View File
@@ -1,30 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- 程序集元数据 -->
<Description>Sharp Framework 配置管理库 - 支持 JSON/XML/INI/TOML 多格式配置,提供热重载和验证功能</Description>
<!-- 程序集元数据 -->
<Description>Sharp Framework 配置管理库 - 支持 JSON/XML/INI/TOML 多格式配置,提供热重载和验证功能</Description>
<!-- NuGet 包元数据 -->
<PackageTags>sharp;config;configuration;hotreload;json;xml;ini;toml</PackageTags>
</PropertyGroup>
<!-- NuGet 包元数据 -->
<PackageTags>sharp;config;configuration;hotreload;json;xml;ini;toml</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Xml" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Ini" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Physical" Version="8.0.0" />
<PackageReference Include="Tomlyn" Version="0.19.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Xml" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Ini" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.FileProviders.Physical" Version="8.0.0"/>
<PackageReference Include="Tomlyn" Version="0.19.0"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1"/>
</ItemGroup>
</Project>
+1 -3
View File
@@ -29,11 +29,9 @@ public static class ConfigBuilder
foreach (var f in files)
{
if (!ConfigSource.TryGuess(f, out var src))
{
throw new NotSupportedException($"Unsupported config extension: {f}");
}
opt.Sources.Add(src);
}
});
}
}
}
+10 -13
View File
@@ -22,23 +22,20 @@ public static class ConfigBuilderExtensions
// 根据环境添加特定配置
var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
?? "Production";
?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
?? "Production";
var envConfigFile = $"appsettings.{environment}.json";
var envConfigPath = Path.Combine(basePath, envConfigFile);
if (File.Exists(envConfigPath))
{
configFiles.Add(envConfigFile);
}
if (File.Exists(envConfigPath)) configFiles.Add(envConfigFile);
// 构建配置
return ConfigBuilder.BuildFromFiles(
configFiles.ToArray(),
reloadOnChange: true,
includeEnvironment: true,
environmentPrefix: environmentPrefix,
basePath: basePath
true,
true,
environmentPrefix,
basePath
);
}
@@ -51,9 +48,9 @@ public static class ConfigBuilderExtensions
return ConfigBuilder.BuildFromFiles(
new[] { "appsettings.json" },
reloadOnChange: false,
includeEnvironment: false,
false,
false,
basePath: basePath
);
}
}
}
+35 -38
View File
@@ -6,16 +6,16 @@ namespace Config;
/// <summary>
/// 描述默认配置构建时的约定。
/// </summary>
public sealed class ConfigDefaultsOptions
{
private readonly IList<ConfigSource> _fluentSources = new List<ConfigSource>();
/// </summary>
public sealed class ConfigDefaultsOptions
{
private readonly IList<ConfigSource> _fluentSources = new List<ConfigSource>();
public ConfigDefaultsOptions()
{
BasePath = AppContext.BaseDirectory;
EnvironmentName = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
public ConfigDefaultsOptions()
{
BasePath = AppContext.BaseDirectory;
EnvironmentName = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
}
/// <summary>
@@ -66,26 +66,34 @@ namespace Config;
/// <summary>
/// 通过流式 API 添加 JSON 配置文件(默认 optional)。
/// </summary>
public ConfigDefaultsOptions WithJson(string path, bool optional = true) =>
AddFluentSource(path, ConfigFormat.Json, optional);
public ConfigDefaultsOptions WithJson(string path, bool optional = true)
{
return AddFluentSource(path, ConfigFormat.Json, optional);
}
/// <summary>
/// 通过流式 API 添加 INI 配置文件(默认 optional)。
/// </summary>
public ConfigDefaultsOptions WithIni(string path, bool optional = true) =>
AddFluentSource(path, ConfigFormat.Ini, optional);
public ConfigDefaultsOptions WithIni(string path, bool optional = true)
{
return AddFluentSource(path, ConfigFormat.Ini, optional);
}
/// <summary>
/// 通过流式 API 添加 XML 配置文件(默认 optional)。
/// </summary>
public ConfigDefaultsOptions WithXml(string path, bool optional = true) =>
AddFluentSource(path, ConfigFormat.Xml, optional);
public ConfigDefaultsOptions WithXml(string path, bool optional = true)
{
return AddFluentSource(path, ConfigFormat.Xml, optional);
}
/// <summary>
/// 通过流式 API 添加 TOML 配置文件(默认 optional)。
/// </summary>
public ConfigDefaultsOptions WithToml(string path, bool optional = true) =>
AddFluentSource(path, ConfigFormat.Toml, optional);
public ConfigDefaultsOptions WithToml(string path, bool optional = true)
{
return AddFluentSource(path, ConfigFormat.Toml, optional);
}
private ConfigDefaultsOptions AddFluentSource(string path, ConfigFormat format, bool optional)
{
@@ -98,7 +106,10 @@ namespace Config;
return this;
}
internal IEnumerable<ConfigSource> GetFluentSources() => _fluentSources;
internal IEnumerable<ConfigSource> GetFluentSources()
{
return _fluentSources;
}
}
/// <summary>
@@ -146,10 +157,7 @@ public static class ConfigDefaults
internal static void ApplyDefaults(ConfigOptions target, ConfigDefaultsOptions defaults)
{
if (defaults.BasePath is not null)
{
target.BasePath = defaults.BasePath;
}
if (defaults.BasePath is not null) target.BasePath = defaults.BasePath;
target.IncludeEnvironmentVariables = defaults.IncludeEnvironmentVariables;
target.EnvironmentPrefix = defaults.EnvironmentPrefix;
@@ -159,31 +167,20 @@ public static class ConfigDefaults
if (defaults.IncludeEnvironmentSpecificFile && !string.IsNullOrWhiteSpace(defaults.EnvironmentName))
{
var envFile = BuildEnvironmentSpecificFile(defaults.MainFile, defaults.EnvironmentName!);
target.Sources.Add(CreateSource(envFile, optional: true));
target.Sources.Add(CreateSource(envFile, true));
}
foreach (var file in defaults.AdditionalFiles)
{
target.Sources.Add(CreateSource(file, optional: true));
}
foreach (var file in defaults.AdditionalFiles) target.Sources.Add(CreateSource(file, true));
foreach (var source in defaults.GetFluentSources())
{
target.Sources.Add(source);
}
foreach (var source in defaults.GetFluentSources()) target.Sources.Add(source);
foreach (var source in defaults.AdditionalSources)
{
target.Sources.Add(source);
}
foreach (var source in defaults.AdditionalSources) target.Sources.Add(source);
}
private static ConfigSource CreateSource(string path, bool optional)
{
if (!ConfigSource.TryGuess(path, out var guessed))
{
throw new NotSupportedException($"Unsupported config extension: {path}");
}
return new ConfigSource
{
@@ -202,4 +199,4 @@ public static class ConfigDefaults
var envFile = $"{name}.{environment}{ext}";
return string.IsNullOrEmpty(dir) ? envFile : Path.Combine(dir, envFile);
}
}
}
+1 -1
View File
@@ -6,4 +6,4 @@ public enum ConfigFormat
Ini,
Xml,
Toml
}
}
+33 -60
View File
@@ -24,18 +24,11 @@ public sealed class ConfigManager
{
var builder = new ConfigurationBuilder();
if (!string.IsNullOrWhiteSpace(options.BasePath))
{
builder.SetBasePath(options.BasePath!);
}
if (!string.IsNullOrWhiteSpace(options.BasePath)) builder.SetBasePath(options.BasePath!);
if (options.FileProvider is not null)
{
builder.SetFileProvider(options.FileProvider);
}
if (options.FileProvider is not null) builder.SetFileProvider(options.FileProvider);
foreach (var src in options.Sources)
{
switch (src.Format)
{
case ConfigFormat.Json:
@@ -46,7 +39,7 @@ public sealed class ConfigManager
// 额外规则:将全局键复制到所有已声明的一级节中(若该节未显式定义该键),
// 以便像 "User=..." 可同时影响 "Database:User" 这类常见节。
var iniPath = options.BasePath is { Length: > 0 }
? System.IO.Path.Combine(options.BasePath!, src.Path)
? Path.Combine(options.BasePath!, src.Path)
: src.Path;
if (!File.Exists(iniPath))
{
@@ -64,7 +57,7 @@ public sealed class ConfigManager
case ConfigFormat.Toml:
// 解析 TOML 并以内存提供器注入(当前测试未要求 TOML 热更新)
var tomlPath = options.BasePath is { Length: > 0 }
? System.IO.Path.Combine(options.BasePath!, src.Path)
? Path.Combine(options.BasePath!, src.Path)
: src.Path;
if (!File.Exists(tomlPath))
{
@@ -76,9 +69,8 @@ public sealed class ConfigManager
var text = File.ReadAllText(tomlPath, new UTF8Encoding(false));
var parse = Toml.Parse(text);
if (parse.HasErrors)
{
throw new InvalidDataException($"Failed to load configuration from file '{tomlPath}'.", new FormatException("TOML parse failed"));
}
throw new InvalidDataException($"Failed to load configuration from file '{tomlPath}'.",
new FormatException("TOML parse failed"));
var model = parse.ToModel();
var pairs = FlattenTomlModel(model);
builder.AddInMemoryCollection(pairs);
@@ -86,7 +78,6 @@ public sealed class ConfigManager
default:
throw new NotSupportedException($"Unsupported format: {src.Format}");
}
}
if (options.IncludeEnvironmentVariables)
{
@@ -97,10 +88,7 @@ public sealed class ConfigManager
// - 同时兼容 "APP__Name" 这类紧跟双下划线的写法(用户容易误用),通过再注册一次 "APP__" 前缀捕获
var pfx = options.EnvironmentPrefix;
builder.AddEnvironmentVariables(pfx);
if (pfx.EndsWith("_"))
{
builder.AddEnvironmentVariables(pfx + "_");
}
if (pfx.EndsWith("_")) builder.AddEnvironmentVariables(pfx + "_");
}
else
{
@@ -109,9 +97,7 @@ public sealed class ConfigManager
}
if (options.ConfigureBuilderHook is not null)
{
builder = (ConfigurationBuilder)options.ConfigureBuilderHook(builder);
}
var configuration = builder.Build();
return new ConfigManager(configuration, options);
@@ -123,27 +109,20 @@ public sealed class ConfigManager
{
var target = new T();
if (string.IsNullOrWhiteSpace(section))
{
Configuration.Bind(target);
}
else
{
Configuration.GetSection(section).Bind(target);
}
if (validate)
{
ValidateObject(target, throwOnError: true);
}
if (validate) ValidateObject(target, true);
return target;
}
public bool TryGet<T>(out T instance, string? section = null, bool validate = false) where T : new()
{
instance = Get<T>(section, validate: false);
instance = Get<T>(section, false);
if (!validate) return true;
var results = ValidateObject(instance, throwOnError: false);
var results = ValidateObject(instance, false);
return results.Count == 0;
}
@@ -153,10 +132,7 @@ public sealed class ConfigManager
var visited = new HashSet<object>(ReferenceEqualityComparer.Instance);
RecursiveValidate(instance, results, visited);
if (throwOnError && results.Count > 0)
{
throw new ConfigurationValidationException(results);
}
if (throwOnError && results.Count > 0) throw new ConfigurationValidationException(results);
return results;
}
@@ -170,7 +146,7 @@ public sealed class ConfigManager
if (IsSimple(type)) return;
var context = new ValidationContext(instance);
Validator.TryValidateObject(instance, context, results, validateAllProperties: true);
Validator.TryValidateObject(instance, context, results, true);
foreach (var prop in type.GetProperties())
{
@@ -180,30 +156,27 @@ public sealed class ConfigManager
if (IsSimple(pType)) continue;
if (value is System.Collections.IEnumerable enumerable && value is not string)
{
foreach (var item in enumerable)
{
if (item is null) continue;
RecursiveValidate(item, results, visited);
}
}
else
{
RecursiveValidate(value, results, visited);
}
}
}
private static bool IsSimple(Type t)
{
t = Nullable.GetUnderlyingType(t) ?? t;
return t.IsPrimitive || t.IsEnum || t == typeof(string) || t == typeof(decimal) || t == typeof(DateTime) || t == typeof(Guid) || t == typeof(DateTimeOffset) || t == typeof(TimeSpan);
return t.IsPrimitive || t.IsEnum || t == typeof(string) || t == typeof(decimal) || t == typeof(DateTime) ||
t == typeof(Guid) || t == typeof(DateTimeOffset) || t == typeof(TimeSpan);
}
// Save APIs
public void Save<T>(T instance, string path, ConfigFormat format, bool indented = true, Encoding? encoding = null)
{
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(path)) ?? ".");
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path)) ?? ".");
var serializer = SerializerFactory.Get(format);
serializer.SerializeToFile(path, instance!, indented, encoding);
}
@@ -247,12 +220,12 @@ public sealed class ConfigManager
yield break;
case TomlArray arr:
for (var i = 0; i < arr.Count; i++)
{
foreach (var p in WalkValue(key + ":" + i, arr[i])) yield return p;
}
foreach (var p in WalkValue(key + ":" + i, arr[i]))
yield return p;
yield break;
default:
yield return new KeyValuePair<string, string?>(key, Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture));
yield return new KeyValuePair<string, string?>(key,
Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture));
yield break;
}
}
@@ -275,6 +248,7 @@ public sealed class ConfigManager
current = sections[name] = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
continue;
}
var idx = line.IndexOf('=');
if (idx <= 0) continue;
var key = line.Substring(0, idx).Trim();
@@ -293,38 +267,37 @@ public sealed class ConfigManager
foreach (var (sectionName, map) in sections)
{
foreach (var kv in root)
{
if (!map.ContainsKey(kv.Key))
{
yield return new KeyValuePair<string, string?>(sectionName + ":" + kv.Key, kv.Value);
}
}
foreach (var kv in map)
{
yield return new KeyValuePair<string, string?>(sectionName + ":" + kv.Key, kv.Value);
}
}
}
}
public static class SerializerFactory
{
public static IConfigSerializer Get(ConfigFormat format) => format switch
public static IConfigSerializer Get(ConfigFormat format)
{
ConfigFormat.Json => new JsonConfigSerializer(),
ConfigFormat.Xml => new XmlConfigSerializer(),
ConfigFormat.Ini => new IniConfigSerializer(),
ConfigFormat.Toml => new TomlConfigSerializer(),
_ => throw new NotSupportedException($"Unsupported format: {format}")
};
return format switch
{
ConfigFormat.Json => new JsonConfigSerializer(),
ConfigFormat.Xml => new XmlConfigSerializer(),
ConfigFormat.Ini => new IniConfigSerializer(),
ConfigFormat.Toml => new TomlConfigSerializer(),
_ => throw new NotSupportedException($"Unsupported format: {format}")
};
}
}
public sealed class ConfigurationValidationException : Exception
{
public IReadOnlyList<ValidationResult> Errors { get; }
public ConfigurationValidationException(IReadOnlyList<ValidationResult> errors)
: base($"Configuration validation failed: {string.Join("; ", errors.Select(e => e.ErrorMessage))}")
{
Errors = errors;
}
}
}
+1 -1
View File
@@ -16,4 +16,4 @@ public sealed class ConfigOptions
// Hook: allow external modules (如 Common) 自定义构建
public Func<IConfigurationBuilder, IConfigurationBuilder>? ConfigureBuilderHook { get; set; }
}
}
+5 -2
View File
@@ -8,7 +8,10 @@ public sealed class ConfigSource
public required ConfigFormat Format { get; init; }
public bool Optional { get; init; } = true;
public override string ToString() => $"{Format}:{Path} (Optional={Optional})";
public override string ToString()
{
return $"{Format}:{Path} (Optional={Optional})";
}
public static bool TryGuess(string path, [NotNullWhen(true)] out ConfigSource? source)
{
@@ -27,4 +30,4 @@ public sealed class ConfigSource
source = new ConfigSource { Path = path, Format = fmt.Value };
return true;
}
}
}
@@ -35,7 +35,10 @@ internal sealed class ConfigAccessor<T> : IConfigAccessor<T>, IDisposable where
public T Value => _current;
public void Dispose() => _subscription.Dispose();
public void Dispose()
{
_subscription.Dispose();
}
}
public static class ConfigServiceCollectionExtensions
@@ -101,4 +104,4 @@ public static class ConfigServiceCollectionExtensions
services.AddConfigBinding(section, validate, onReload);
return services;
}
}
}
+31 -25
View File
@@ -1,22 +1,24 @@
# Config 模块
面向 .NET 的通用配置模块,聚合多种配置源(JSON / INI / XML / TOML / 环境变量),支持强类型绑定、数据注解验证、可选热更新、文件读写序列化,并提供对外扩展 Hook 以便与外部公共库(如 Common 模块)集成。
面向 .NET 的通用配置模块,聚合多种配置源(JSON / INI / XML / TOML / 环境变量),支持强类型绑定、数据注解验证、可选热更新、文件读写序列化,并提供对外扩展
Hook 以便与外部公共库(如 Common 模块)集成。
本模块仅包含在 `src/Sharp/Config` 目录内的实现,遵从“公共项目,可插拔”的目标:你可以直接使用,也可通过依赖注入或 Hook 接入整体项目体系。
本模块仅包含在 `src/Sharp/Config` 目录内的实现,遵从“公共项目,可插拔”的目标:你可以直接使用,也可通过依赖注入或 Hook
接入整体项目体系。
## 功能清单
- 多格式配置读取:
- JSON / XML:使用 Microsoft 官方 provider,并支持 `ReloadOnChange`
- INI / TOML:内置轻量解析,注入为内存配置源(当前默认不启用文件级热更新)
- JSON / XML:使用 Microsoft 官方 provider,并支持 `ReloadOnChange`
- INI / TOML:内置轻量解析,注入为内存配置源(当前默认不启用文件级热更新)
- 多格式配置写入:支持将强类型对象写入为 JSON / XML / INI / TOML 文件
- 强类型绑定:`Get<T>()` / `TryGet<T>()` 直接从 IConfiguration 绑定到 POCO
- 数据注解验证:`[Required]` / `[Range]` 等;支持递归验证嵌套对象与集合
- 环境变量合并:可设置前缀,合并到配置树(优先级最高)
- 热更新(可选):JSON / XML 支持文件监控自动重载;回调 `OnReload`
- 可扩展:
- `ConfigOptions.ConfigureBuilderHook`:注入外部对 `IConfigurationBuilder` 的自定义构建(可与 Common 模块对接)
- `AddConfig(IServiceCollection, ...)`:便捷注册到 DI 容器
- `ConfigOptions.ConfigureBuilderHook`:注入外部对 `IConfigurationBuilder` 的自定义构建(可与 Common 模块对接)
- `AddConfig(IServiceCollection, ...)`:便捷注册到 DI 容器
## 架构与设计
@@ -27,12 +29,13 @@
- `ConfigSource`:单个配置源描述(路径、格式、是否可选)
- `ConfigBuilder`:便捷构建器(从文件列表快速构建)
- 序列化器:
- `Serialization/JsonConfigSerializer`
- `Serialization/XmlConfigSerializer`
- `Serialization/IniConfigSerializer`
- `Serialization/TomlConfigSerializer`
- `Serialization/JsonConfigSerializer`
- `Serialization/XmlConfigSerializer`
- `Serialization/IniConfigSerializer`
- `Serialization/TomlConfigSerializer`
注意:当前 INI / TOML 的读取在构建期解析为键值对注入,不启用文件级热更新;JSON / XML 使用官方 provider,支持 `ReloadOnChange`。
注意:当前 INI / TOML 的读取在构建期解析为键值对注入,不启用文件级热更新;JSON / XML 使用官方 provider,支持
`ReloadOnChange`。
## 使用方式
@@ -73,7 +76,8 @@ var cm = Config.ConfigBuilder.BuildFromFiles(
);
```
环境变量键:使用双下划线分隔层级,例如 `APP__Database__Host`。如果设置了 `environmentPrefix: "APP_"`,也兼容 `APP__Name` 这类书写,但推荐前缀不以 `_` 结尾,例如 `"APP"`。
环境变量键:使用双下划线分隔层级,例如 `APP__Database__Host`。如果设置了 `environmentPrefix: "APP_"`,也兼容 `APP__Name`
这类书写,但推荐前缀不以 `_` 结尾,例如 `"APP"`。
### 2) 强类型绑定与验证
@@ -175,33 +179,35 @@ var cm = provider.GetRequiredService<ConfigManager>();
- 配置源合并策略:后添加覆盖先添加(与官方一致)
- INI 读取:
- 将“根键(未在任何节中)”与“节内键”一起展平成键路径
- 当前默认行为:将根键复制到所有已声明的一级节(若该节未定义同名键)。此举旨在方便简化配置,但可能与部分 INI 规范理解有差异;如需关闭或自定义,可在后续版本提供开关
- INI 写入:支持简单类型与一层嵌套对象
- 将“根键(未在任何节中)”与“节内键”一起展平成键路径
- 当前默认行为:将根键复制到所有已声明的一级节(若该节未定义同名键)。此举旨在方便简化配置,但可能与部分 INI
规范理解有差异;如需关闭或自定义,可在后续版本提供开关
- INI 写入:支持简单类型与一层嵌套对象
- TOML 读取:
- 解析为键值对注入,默认不启用文件级热更新
- 直接 `LoadFromFile<T>(..., Toml)` 暂不支持;请通过配置管线绑定获取强类型
- 解析为键值对注入,默认不启用文件级热更新
- 直接 `LoadFromFile<T>(..., Toml)` 暂不支持;请通过配置管线绑定获取强类型
- 环境变量前缀:
- 推荐使用不带下划线的短前缀(如 `APP`)+ 双下划线分隔层级(`APP__Section__Key`)
- 模块对 `"APP_"` 这类带下划线结尾的前缀做了兼容处理,但仍建议遵循推荐写法
- 推荐使用不带下划线的短前缀(如 `APP`)+ 双下划线分隔层级(`APP__Section__Key`)
- 模块对 `"APP_"` 这类带下划线结尾的前缀做了兼容处理,但仍建议遵循推荐写法
- 验证:
- 使用 DataAnnotations,递归验证嵌套对象与集合
- 使用 DataAnnotations,递归验证嵌套对象与集合
- 性能:
- INI/TOML 读取为一次性解析(非流式);适合小中等规模配置。大型配置建议 JSON/XML
- INI/TOML 读取为一次性解析(非流式);适合小中等规模配置。大型配置建议 JSON/XML
## 常见问题(FAQ)
1. 为什么 INI/TOML 没有热更新?
- 当前实现为轻量解析注入内存,未挂载文件监控。JSON/XML 已可直接使用热更新。若需要对 INI/TOML 启用热更新,可按 `FileConfigurationProvider` 模式扩展(可在后续版本提供)。
- 当前实现为轻量解析注入内存,未挂载文件监控。JSON/XML 已可直接使用热更新。若需要对 INI/TOML 启用热更新,可按
`FileConfigurationProvider` 模式扩展(可在后续版本提供)。
2. 环境变量用法推荐?
- 使用 `environmentPrefix: "APP"`,并设置 `APP__Section__Key`。尽量避免在前缀尾部加 `_`,可减少歧义。
- 使用 `environmentPrefix: "APP"`,并设置 `APP__Section__Key`。尽量避免在前缀尾部加 `_`,可减少歧义。
3. TOML 的写入后如何强类型读取?
- 通过配置构建(Add... + 绑定)获取强类型对象;`LoadFromFile<T>` 暂不支持 TOML 直接反序列化。
- 通过配置构建(Add... + 绑定)获取强类型对象;`LoadFromFile<T>` 暂不支持 TOML 直接反序列化。
4. 能否与 Common 模块集成?
- 可以,通过 `ConfigOptions.ConfigureBuilderHook` 把 Common 的扩展注入到构建管线。
- 可以,通过 `ConfigOptions.ConfigureBuilderHook` 把 Common 的扩展注入到构建管线。
## 变更建议与后续计划
@@ -8,4 +8,4 @@ public interface IConfigSerializer
T? Deserialize<T>(string content);
void SerializeToFile<T>(string path, T instance, bool indented = true, Encoding? encoding = null);
T? DeserializeFromFile<T>(string path, Encoding? encoding = null);
}
}
@@ -9,18 +9,13 @@ public sealed class IniConfigSerializer : IConfigSerializer
{
var (root, sections) = ObjectToIniMaps(instance);
var sb = new StringBuilder();
foreach (var kv in root)
{
sb.Append(kv.Key).Append('=').Append(kv.Value).Append('\n');
}
foreach (var kv in root) sb.Append(kv.Key).Append('=').Append(kv.Value).Append('\n');
foreach (var section in sections)
{
sb.Append('\n').Append('[').Append(section.Key).Append(']').Append('\n');
foreach (var kv in section.Value)
{
sb.Append(kv.Key).Append('=').Append(kv.Value).Append('\n');
}
foreach (var kv in section.Value) sb.Append(kv.Key).Append('=').Append(kv.Value).Append('\n');
}
return sb.ToString();
}
@@ -48,7 +43,8 @@ public sealed class IniConfigSerializer : IConfigSerializer
return Deserialize<T>(text);
}
private static (Dictionary<string, string> root, Dictionary<string, Dictionary<string, string>> sections) ObjectToIniMaps<T>(T instance)
private static (Dictionary<string, string> root, Dictionary<string, Dictionary<string, string>> sections)
ObjectToIniMaps<T>(T instance)
{
var root = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var sections = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
@@ -71,15 +67,10 @@ public sealed class IniConfigSerializer : IConfigSerializer
{
var innerVal = inner.GetValue(value);
if (innerVal is null) continue;
if (IsSimple(inner.PropertyType))
{
sec[inner.Name] = Convert.ToString(innerVal) ?? string.Empty;
}
}
if (sec.Count > 0)
{
sections[prop.Name] = sec;
if (IsSimple(inner.PropertyType)) sec[inner.Name] = Convert.ToString(innerVal) ?? string.Empty;
}
if (sec.Count > 0) sections[prop.Name] = sec;
}
}
@@ -110,13 +101,9 @@ public sealed class IniConfigSerializer : IConfigSerializer
var value = line.Substring(idx + 1).Trim();
if (currentSection is null)
{
dict[key] = value;
}
else
{
currentSection[key] = value;
}
}
return dict;
@@ -125,24 +112,17 @@ public sealed class IniConfigSerializer : IConfigSerializer
private static IEnumerable<KeyValuePair<string, string?>> FlattenToPairs(Dictionary<string, object?> dict)
{
foreach (var (key, value) in dict)
{
if (value is Dictionary<string, object?> nested)
{
foreach (var inner in nested)
{
yield return new KeyValuePair<string, string?>(key + ":" + inner.Key, inner.Value?.ToString());
}
}
else
{
yield return new KeyValuePair<string, string?>(key, value?.ToString());
}
}
}
private static bool IsSimple(Type t)
{
t = Nullable.GetUnderlyingType(t) ?? t;
return t.IsPrimitive || t.IsEnum || t == typeof(string) || t == typeof(decimal) || t == typeof(DateTime) || t == typeof(Guid) || t == typeof(DateTimeOffset) || t == typeof(TimeSpan);
return t.IsPrimitive || t.IsEnum || t == typeof(string) || t == typeof(decimal) || t == typeof(DateTime) ||
t == typeof(Guid) || t == typeof(DateTimeOffset) || t == typeof(TimeSpan);
}
}
}
@@ -25,7 +25,9 @@ public sealed class JsonConfigSerializer : IConfigSerializer
}
public T? Deserialize<T>(string content)
=> JsonSerializer.Deserialize<T>(content, _options);
{
return JsonSerializer.Deserialize<T>(content, _options);
}
public void SerializeToFile<T>(string path, T instance, bool indented = true, Encoding? encoding = null)
{
@@ -40,4 +42,4 @@ public sealed class JsonConfigSerializer : IConfigSerializer
var text = File.ReadAllText(path, encoding);
return Deserialize<T>(text);
}
}
}
@@ -31,4 +31,4 @@ public sealed class TomlConfigSerializer : IConfigSerializer
var text = File.ReadAllText(path, encoding);
return Deserialize<T>(text);
}
}
}
@@ -35,4 +35,4 @@ public sealed class XmlConfigSerializer : IConfigSerializer
var text = File.ReadAllText(path, encoding);
return Deserialize<T>(text);
}
}
}
+5 -14
View File
@@ -26,10 +26,7 @@ internal class DataContext : IDataContext
{
var type = typeof(TEntity);
if (_repositories.ContainsKey(type))
{
return (IRepository<TEntity>)_repositories[type];
}
if (_repositories.ContainsKey(type)) return (IRepository<TEntity>)_repositories[type];
var repository = new Repository<TEntity>(_context, _options.EnableSoftDelete);
_repositories[type] = repository;
@@ -40,16 +37,10 @@ internal class DataContext : IDataContext
public async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
// 应用审计
if (_options.EnableAudit)
{
ApplyAuditInfo();
}
if (_options.EnableAudit) ApplyAuditInfo();
// 应用软删除
if (_options.EnableSoftDelete)
{
ApplySoftDelete();
}
if (_options.EnableSoftDelete) ApplySoftDelete();
return await _context.SaveChangesAsync(ct);
}
@@ -58,7 +49,7 @@ internal class DataContext : IDataContext
{
var entries = _context.ChangeTracker.Entries()
.Where(e => e.Entity is IAuditableEntity &&
(e.State == EntityState.Added || e.State == EntityState.Modified));
(e.State == EntityState.Added || e.State == EntityState.Modified));
var now = DateTime.UtcNow;
var userId = _options.CurrentUserId;
@@ -101,4 +92,4 @@ internal class DataContext : IDataContext
_context.Dispose();
_repositories.Clear();
}
}
}
+1 -1
View File
@@ -22,4 +22,4 @@ public interface IDataContext : IDisposable
/// 访问底层 DbContext(高级场景)
/// </summary>
DbContext Context { get; }
}
}
+27 -27
View File
@@ -1,37 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Sharp.Data</RootNamespace>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Sharp.Data</RootNamespace>
<!-- 程序集元数据 -->
<Description>Sharp Framework 数据访问库 - EF Core + Repository 模式,支持审计、软删除等企业级功能</Description>
<!-- 程序集元数据 -->
<Description>Sharp Framework 数据访问库 - EF Core + Repository 模式,支持审计、软删除等企业级功能</Description>
<!-- NuGet 包元数据 -->
<PackageTags>sharp;data;efcore;repository;entity-framework</PackageTags>
</PropertyGroup>
<!-- NuGet 包元数据 -->
<PackageTags>sharp;data;efcore;repository;entity-framework</PackageTags>
</PropertyGroup>
<ItemGroup>
<!-- EF Core -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.0" />
<ItemGroup>
<!-- EF Core -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.0"/>
<!-- 数据库提供者 -->
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.0" />
<!-- 数据库提供者 -->
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0"/>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.0"/>
<!-- 依赖注入 -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
</ItemGroup>
<!-- 依赖注入 -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0"/>
</ItemGroup>
<ItemGroup>
<!-- 引用 Common 模块,复用基础功能 -->
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
<ItemGroup>
<!-- 引用 Common 模块,复用基础功能 -->
<ProjectReference Include="..\Common\Common.csproj"/>
</ItemGroup>
</Project>
+8 -15
View File
@@ -34,7 +34,8 @@ public class DataContextBuilder<TContext> where TContext : DbContext
/// <summary>
/// 使用 SQL Server 数据库
/// </summary>
public DataContextBuilder<TContext> UseSqlServer(string connectionString, Action<SqlServerDbContextOptionsBuilder>? configure = null)
public DataContextBuilder<TContext> UseSqlServer(string connectionString,
Action<SqlServerDbContextOptionsBuilder>? configure = null)
{
_optionsBuilder.UseSqlServer(connectionString, configure);
_databaseConfigured = true;
@@ -44,7 +45,8 @@ public class DataContextBuilder<TContext> where TContext : DbContext
/// <summary>
/// 使用 MySQL 数据库
/// </summary>
public DataContextBuilder<TContext> UseMySql(string connectionString, string? serverVersion = null, Action<MySqlDbContextOptionsBuilder>? configure = null)
public DataContextBuilder<TContext> UseMySql(string connectionString, string? serverVersion = null,
Action<MySqlDbContextOptionsBuilder>? configure = null)
{
var version = string.IsNullOrEmpty(serverVersion)
? ServerVersion.AutoDetect(connectionString)
@@ -58,7 +60,8 @@ public class DataContextBuilder<TContext> where TContext : DbContext
/// <summary>
/// 使用 PostgreSQL 数据库
/// </summary>
public DataContextBuilder<TContext> UseNpgsql(string connectionString, Action<Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.NpgsqlDbContextOptionsBuilder>? configure = null)
public DataContextBuilder<TContext> UseNpgsql(string connectionString,
Action<Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.NpgsqlDbContextOptionsBuilder>? configure = null)
{
_optionsBuilder.UseNpgsql(connectionString, configure);
_databaseConfigured = true;
@@ -162,32 +165,22 @@ public class DataContextBuilder<TContext> where TContext : DbContext
public IDataContext Build()
{
// 如果没有配置数据库,默认使用 SQLite 内存数据库
if (!_databaseConfigured)
{
_optionsBuilder.UseSqlite("Data Source=:memory:");
}
if (!_databaseConfigured) _optionsBuilder.UseSqlite("Data Source=:memory:");
// 创建 DbContext 实例
var context = (TContext)Activator.CreateInstance(typeof(TContext), _optionsBuilder.Options)!;
// 自动迁移
if (_dataOptions.EnableAutoMigration)
{
context.Database.Migrate();
}
else
{
// 确保数据库已创建
context.Database.EnsureCreated();
}
// 获取当前用户 ID
if (_dataOptions.CurrentUserIdProvider != null)
{
_dataOptions.CurrentUserId = _dataOptions.CurrentUserIdProvider();
}
return new DataContext(context, _dataOptions);
}
}
}
+1 -1
View File
@@ -34,4 +34,4 @@ public class DataContextOptions
/// 设置当前用户 ID 的委托
/// </summary>
public Func<string?>? CurrentUserIdProvider { get; set; }
}
}
@@ -27,8 +27,8 @@ public static class DataServiceExtensions
{
var provider = configuration[$"{configSection}:Provider"] ?? "SqlServer";
var connectionString = configuration[$"{configSection}:ConnectionString"]
?? throw new InvalidOperationException(
$"未找到数据库连接字符串配置: {configSection}:ConnectionString");
?? throw new InvalidOperationException(
$"未找到数据库连接字符串配置: {configSection}:ConnectionString");
return AddSharpDatabase<TContext>(services, provider, connectionString);
}
@@ -51,9 +51,9 @@ public static class DataServiceExtensions
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(5),
errorNumbersToAdd: null);
3,
TimeSpan.FromSeconds(5),
null);
});
break;
@@ -63,18 +63,14 @@ public static class DataServiceExtensions
case "postgresql":
case "postgres":
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.EnableRetryOnFailure(3, TimeSpan.FromSeconds(5), null);
});
options.UseNpgsql(connectionString,
npgsqlOptions => { npgsqlOptions.EnableRetryOnFailure(3, TimeSpan.FromSeconds(5), null); });
break;
case "mysql":
var serverVersion = ServerVersion.AutoDetect(connectionString);
options.UseMySql(connectionString, serverVersion, mysqlOptions =>
{
mysqlOptions.EnableRetryOnFailure(3, TimeSpan.FromSeconds(5), null);
});
options.UseMySql(connectionString, serverVersion,
mysqlOptions => { mysqlOptions.EnableRetryOnFailure(3, TimeSpan.FromSeconds(5), null); });
break;
case "inmemory":
@@ -98,4 +94,4 @@ public static class DataServiceExtensions
return services;
}
}
}
+1 -1
View File
@@ -65,4 +65,4 @@ public abstract class AuditableEntityInt : AuditableEntity<int>
/// </summary>
public abstract class AuditableEntityLong : AuditableEntity<long>
{
}
}
+2 -2
View File
@@ -21,7 +21,7 @@ public abstract class Entity<TKey> where TKey : IEquatable<TKey>
if (GetType() != other.GetType())
return false;
if (Id.Equals(default(TKey)) || other.Id.Equals(default(TKey)))
if (Id.Equals(default) || other.Id.Equals(default))
return false;
return Id.Equals(other.Id);
@@ -72,4 +72,4 @@ public abstract class EntityInt : Entity<int>
/// </summary>
public abstract class EntityLong : Entity<long>
{
}
}
+1 -1
View File
@@ -24,4 +24,4 @@ public interface IAuditableEntity
/// 最后修改人
/// </summary>
string? UpdatedBy { get; set; }
}
}
+1 -1
View File
@@ -19,4 +19,4 @@ public interface ISoftDeletable
/// 删除人
/// </summary>
string? DeletedBy { get; set; }
}
}
@@ -70,4 +70,4 @@ public static class QueryableExtensions
return query.Provider.CreateQuery<T>(resultExpression);
}
}
}
+3
View File
@@ -177,6 +177,7 @@ public class MyEntity
### AuditableEntity<TKey> - 审计实体(可选)
如果继承此基类,会自动管理以下字段:
- `Id` - 主键
- `CreatedAt` - 创建时间
- `CreatedBy` - 创建人
@@ -361,6 +362,7 @@ builder.Services.AddHttpContextAccessor();
### 与 Sharp.Common 集成(已内置)
Data 模块已经引用了 Common 模块,可以直接使用:
- `PagedResult<T>` - 分页结果
- `Result<T>` - 错误处理(可选)
- 各种扩展方法
@@ -409,6 +411,7 @@ catch
### Q: 我必须使用 Entity/AuditableEntity 基类吗?
**不是!** 基类是**完全可选**的。你可以:
- 使用自己的实体定义
- 实现 `IAuditableEntity` 或 `ISoftDeletable` 接口
- 完全不使用审计和软删除功能
+1 -1
View File
@@ -87,4 +87,4 @@ public interface IRepository<TEntity> where TEntity : class
/// 根据主键删除实体(支持软删除)
/// </summary>
Task RemoveAsync(object id, CancellationToken ct = default);
}
}
+7 -9
View File
@@ -31,7 +31,8 @@ internal class Repository<TEntity> : IRepository<TEntity> where TEntity : class
return await _dbSet.FindAsync(new[] { id }, ct);
}
public async Task<TEntity?> FirstOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken ct = default)
public async Task<TEntity?> FirstOrDefaultAsync(Expression<Func<TEntity, bool>> predicate,
CancellationToken ct = default)
{
return await _dbSet.FirstOrDefaultAsync(predicate, ct);
}
@@ -41,7 +42,8 @@ internal class Repository<TEntity> : IRepository<TEntity> where TEntity : class
return await _dbSet.ToListAsync(ct);
}
public async Task<List<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken ct = default)
public async Task<List<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate,
CancellationToken ct = default)
{
return await _dbSet.Where(predicate).ToListAsync(ct);
}
@@ -134,13 +136,12 @@ internal class Repository<TEntity> : IRepository<TEntity> where TEntity : class
{
var now = DateTime.UtcNow;
foreach (var entity in entities)
{
if (entity is ISoftDeletable softDeletable)
{
softDeletable.IsDeleted = true;
softDeletable.DeletedAt = now;
}
}
_dbSet.UpdateRange(entities);
}
else
@@ -152,9 +153,6 @@ internal class Repository<TEntity> : IRepository<TEntity> where TEntity : class
public async Task RemoveAsync(object id, CancellationToken ct = default)
{
var entity = await GetByIdAsync(id, ct);
if (entity != null)
{
Remove(entity);
}
if (entity != null) Remove(entity);
}
}
}
+1
View File
@@ -3,6 +3,7 @@
本指南展示如何在不同场景下使用 Sharp.Data 模块。
## 目录
- [快速开始](#快速开始)
- [控制台应用](#控制台应用)
- [WPF 应用](#wpf-应用)
@@ -4,7 +4,8 @@ namespace Sharp.EventBus.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddEventBus(this IServiceCollection services, Action<EventBusOptions>? configure = null)
public static IServiceCollection AddEventBus(this IServiceCollection services,
Action<EventBusOptions>? configure = null)
{
var options = new EventBusOptions();
configure?.Invoke(options);
@@ -12,4 +13,4 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IEventBus, InMemoryEventBus>();
return services;
}
}
}
+14 -14
View File
@@ -1,21 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Sharp.EventBus</AssemblyName>
<RootNamespace>Sharp.EventBus</RootNamespace>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Sharp.EventBus</AssemblyName>
<RootNamespace>Sharp.EventBus</RootNamespace>
<!-- 程序集元数据 -->
<Description>Sharp Framework 事件总线库 - 进程内事件发布/订阅(规划中)</Description>
<!-- 程序集元数据 -->
<Description>Sharp Framework 事件总线库 - 进程内事件发布/订阅(规划中)</Description>
<!-- NuGet 包元数据 -->
<PackageTags>sharp;eventbus;pub-sub;events</PackageTags>
</PropertyGroup>
<!-- NuGet 包元数据 -->
<PackageTags>sharp;eventbus;pub-sub;events</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0"/>
</ItemGroup>
</Project>
+1 -1
View File
@@ -13,4 +13,4 @@ public class EventBusOptions
/// 全局错误回调。
/// </summary>
public Action<Exception, object>? OnError { get; set; }
}
}
+3 -2
View File
@@ -12,7 +12,8 @@ internal sealed class EventSubscription : IDisposable
private readonly Action<EventSubscription> _onDispose;
private bool _disposed;
public EventSubscription(Type eventType, Func<object, Task> handler, Func<object, bool>? filter, int priority, Action<EventSubscription> onDispose)
public EventSubscription(Type eventType, Func<object, Task> handler, Func<object, bool>? filter, int priority,
Action<EventSubscription> onDispose)
{
EventType = eventType;
Handler = handler;
@@ -33,4 +34,4 @@ internal sealed class EventSubscription : IDisposable
_disposed = true;
_onDispose(this);
}
}
}
+5 -3
View File
@@ -19,10 +19,12 @@ public interface IEventBus : IDisposable
/// <summary>
/// 订阅事件(同步处理器)。
/// </summary>
IDisposable Subscribe<TEvent>(Action<TEvent> handler, Func<TEvent, bool>? filter = null, int priority = 0) where TEvent : class;
IDisposable Subscribe<TEvent>(Action<TEvent> handler, Func<TEvent, bool>? filter = null, int priority = 0)
where TEvent : class;
/// <summary>
/// 订阅事件(异步处理器)。
/// </summary>
IDisposable Subscribe<TEvent>(Func<TEvent, Task> handler, Func<TEvent, bool>? filter = null, int priority = 0) where TEvent : class;
}
IDisposable Subscribe<TEvent>(Func<TEvent, Task> handler, Func<TEvent, bool>? filter = null, int priority = 0)
where TEvent : class;
}
+15 -13
View File
@@ -38,7 +38,8 @@ public sealed class InMemoryEventBus : IEventBus
return _channel.Writer.WriteAsync(@event, cancellationToken).AsTask();
}
public IDisposable Subscribe<TEvent>(Action<TEvent> handler, Func<TEvent, bool>? filter = null, int priority = 0) where TEvent : class
public IDisposable Subscribe<TEvent>(Action<TEvent> handler, Func<TEvent, bool>? filter = null, int priority = 0)
where TEvent : class
{
ThrowIfDisposed();
if (handler == null) throw new ArgumentNullException(nameof(handler));
@@ -52,17 +53,22 @@ public sealed class InMemoryEventBus : IEventBus
return AddSubscription(typeof(TEvent), Wrapper, evt => filter?.Invoke((TEvent)evt) ?? true, priority);
}
public IDisposable Subscribe<TEvent>(Func<TEvent, Task> handler, Func<TEvent, bool>? filter = null, int priority = 0) where TEvent : class
public IDisposable Subscribe<TEvent>(Func<TEvent, Task> handler, Func<TEvent, bool>? filter = null,
int priority = 0) where TEvent : class
{
ThrowIfDisposed();
if (handler == null) throw new ArgumentNullException(nameof(handler));
Task Wrapper(object evt) => handler((TEvent)evt);
Task Wrapper(object evt)
{
return handler((TEvent)evt);
}
return AddSubscription(typeof(TEvent), Wrapper, evt => filter?.Invoke((TEvent)evt) ?? true, priority);
}
private IDisposable AddSubscription(Type eventType, Func<object, Task> handler, Func<object, bool>? filter, int priority)
private IDisposable AddSubscription(Type eventType, Func<object, Task> handler, Func<object, bool>? filter,
int priority)
{
var sub = new EventSubscription(eventType, handler, filter, priority, RemoveSubscription);
var list = _subscriptions.GetOrAdd(eventType, _ => new List<EventSubscription>());
@@ -71,18 +77,17 @@ public sealed class InMemoryEventBus : IEventBus
list.Add(sub);
list.Sort((a, b) => a.Priority.CompareTo(b.Priority));
}
return sub;
}
private void RemoveSubscription(EventSubscription subscription)
{
if (_subscriptions.TryGetValue(subscription.EventType, out var list))
{
lock (list)
{
list.Remove(subscription);
}
}
}
private async Task ProcessAsync()
@@ -90,12 +95,8 @@ public sealed class InMemoryEventBus : IEventBus
try
{
while (await _channel.Reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false))
{
while (_channel.Reader.TryRead(out var evt))
{
await DispatchAsync(evt).ConfigureAwait(false);
}
}
while (_channel.Reader.TryRead(out var evt))
await DispatchAsync(evt).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -145,6 +146,7 @@ public sealed class InMemoryEventBus : IEventBus
{
// ignore
}
_cts.Dispose();
}
}
}
+27 -7
View File
@@ -9,6 +9,7 @@
### 场景对比
#### ❌ 不使用 EventBus - 紧耦合
```csharp
public class PlcMonitorService {
private readonly MainViewModel _mainViewModel;
@@ -27,6 +28,7 @@ public class PlcMonitorService {
```
#### ✅ 使用 EventBus - 解耦
```csharp
// 发布者:只管发布,不关心谁处理
public class PlcMonitorService {
@@ -75,6 +77,7 @@ public class PlcDataLogger {
## 🚀 快速开始(未来)
### 1. 注册服务
```csharp
// App.xaml.cs
protected override void OnStartup(StartupEventArgs e) {
@@ -88,6 +91,7 @@ protected override void OnStartup(StartupEventArgs e) {
```
### 2. 定义事件
```csharp
// 事件:简单的 POCO 类
public record UserLoginEvent(string Username, DateTime LoginTime);
@@ -98,6 +102,7 @@ public record OrderCreatedEvent(int OrderId, decimal TotalAmount);
```
### 3. 发布事件
```csharp
public class LoginService {
private readonly IEventBus _eventBus;
@@ -114,6 +119,7 @@ public class LoginService {
```
### 4. 订阅事件
```csharp
// 方式1:在构造函数中订阅
public class MainViewModel : ViewModelBase {
@@ -158,6 +164,7 @@ public class HighValueOrderMonitor {
## 📚 核心 API 设计
### IEventBus 接口
```csharp
public interface IEventBus {
/// <summary>
@@ -197,6 +204,7 @@ public interface IEventBus {
```
### EventSubscription 类(流式API)
```csharp
public class EventSubscription<TEvent> : IDisposable where TEvent : class {
/// <summary>
@@ -240,6 +248,7 @@ eventBus.CreateSubscription<PlcDataChangedEvent>()
## 🔧 高级功能
### 1. 事件优先级
```csharp
// 优先级:数字越小越先执行
eventBus.Subscribe<OrderCreatedEvent>(e => ValidateOrder(e), priority: 1);
@@ -248,18 +257,21 @@ eventBus.Subscribe<OrderCreatedEvent>(e => UpdateStatistics(e), priority: 20);
```
### 2. 弱引用订阅(防止内存泄漏)
```csharp
// 自动模式:订阅者被GC回收时自动取消订阅
eventBus.SubscribeWeak<UserLoginEvent>(viewModel, (vm, e) => vm.OnLogin(e));
```
### 3. 延迟发布
```csharp
// 延迟1秒发布(防抖场景)
eventBus.PublishDelayed(new SearchTextChangedEvent(text), TimeSpan.FromSeconds(1));
```
### 4. 错误处理
```csharp
// 全局错误处理器
services.AddEventBus(options => {
@@ -280,6 +292,7 @@ eventBus.Subscribe<OrderCreatedEvent>(e => {
```
### 5. 事件日志(可选)
```csharp
services.AddEventBus(options => {
options.EnableEventLogging = true; // 记录所有事件发布
@@ -292,6 +305,7 @@ services.AddEventBus(options => {
## 💡 典型使用场景
### 场景1:跨 ViewModel 通信
```csharp
// LoginViewModel 发布登录成功事件
public class LoginViewModel {
@@ -313,6 +327,7 @@ public class MainViewModel {
```
### 场景2:PLC数据监控
```csharp
// PLC监控服务发布数据变化
public class PlcMonitorService {
@@ -346,6 +361,7 @@ public class DataLoggerService {
```
### 场景3:报警系统
```csharp
// 报警事件
public record AlarmTriggeredEvent(string AlarmId, AlarmLevel Level, string Message);
@@ -389,11 +405,13 @@ services.AddEventBus(options => {
## 🏗️ 实现方案
### 基于 System.Threading.Channels
- **高性能**:异步无锁队列
- **线程安全**:内置并发支持
- **背压支持**:通道容量控制
### 架构设计
```
IEventBus(接口)
↓
@@ -415,12 +433,12 @@ System.Threading.Channels.Channel<EventEnvelope>
## 🔄 与其他模式对比
| 模式 | 优点 | 缺点 | 适用场景 |
|-----|------|------|---------|
| **EventBus** | 完全解耦、多订阅者 | 略微增加复杂度 | 跨模块通信、插件架构 |
| **直接回调** | 简单直接 | 紧耦合、单订阅者 | 简单父子组件通信 |
| **事件聚合器** | 解耦 | 需要手动管理 | 中等复杂度应用 |
| **消息队列** | 跨进程、持久化 | 重量级、需要中间件 | 分布式系统 |
| 模式 | 优点 | 缺点 | 适用场景 |
|--------------|-----------|-----------|------------|
| **EventBus** | 完全解耦、多订阅者 | 略微增加复杂度 | 跨模块通信、插件架构 |
| **直接回调** | 简单直接 | 紧耦合、单订阅者 | 简单父子组件通信 |
| **事件聚合器** | 解耦 | 需要手动管理 | 中等复杂度应用 |
| **消息队列** | 跨进程、持久化 | 重量级、需要中间件 | 分布式系统 |
---
@@ -437,6 +455,7 @@ System.Threading.Channels.Channel<EventEnvelope>
## 🚀 未来扩展
### 可能的扩展方向
1. **分布式 EventBus**(基于 Redis Pub/Sub)
2. **事件溯源**(Event Sourcing)支持
3. **事件重放**(Replay)功能
@@ -448,7 +467,8 @@ System.Threading.Channels.Channel<EventEnvelope>
- [MediatR](https://github.com/jbogard/MediatR) - .NET 消息中介库
- [Prism EventAggregator](https://prismlibrary.com/docs/event-aggregator.html) - WPF MVVM 事件聚合器
- [System.Threading.Channels](https://devblogs.microsoft.com/dotnet/an-introduction-to-system-threading-channels/) - .NET 高性能通道
- [System.Threading.Channels](https://devblogs.microsoft.com/dotnet/an-introduction-to-system-threading-channels/) -
.NET 高性能通道
---
@@ -25,10 +25,8 @@ public static class SharpLogBuilderExtensions
var opt = sp.GetRequiredService<IOptions<SharpLogOptions>>().Value;
o.MinLevel = opt.MinLevel;
foreach (var kv in opt.Overrides)
{
// category-specific rule
o.Rules.Add(new LoggerFilterRule(null, kv.Key, kv.Value, null));
}
}));
// Register Serilog provider via DI so it participates in logging pipeline
@@ -37,15 +35,13 @@ public static class SharpLogBuilderExtensions
var opt = sp.GetRequiredService<IOptions<SharpLogOptions>>().Value;
if (!opt.UseSerilog)
{
// If not using Serilog, don't inject a provider; return a no-op stub
return new NoopLoggerProvider();
}
var env = sp.GetService<IHostEnvironment>();
var cfg = sp.GetService<IConfiguration>();
var serilog = SerilogConfigurator.CreateLogger(opt, env, cfg);
return new SerilogLoggerProvider(serilog, dispose: true);
return new SerilogLoggerProvider(serilog, true);
});
return builder;
@@ -55,18 +51,38 @@ public static class SharpLogBuilderExtensions
{
private sealed class NoopLogger : ILogger
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => false;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { }
public IDisposable BeginScope<TState>(TState state) where TState : notnull
{
return NullScope.Instance;
}
public bool IsEnabled(LogLevel logLevel)
{
return false;
}
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
}
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
public void Dispose()
{
}
}
public ILogger CreateLogger(string categoryName) => new NoopLogger();
public void Dispose() { }
public ILogger CreateLogger(string categoryName)
{
return new NoopLogger();
}
public void Dispose()
{
}
}
}
}
+1 -2
View File
@@ -23,5 +23,4 @@ internal sealed class ActivityEnricher : ILogEventEnricher
logEvent.AddOrUpdateProperty(spanIdProp);
}
}
}
}
+34 -35
View File
@@ -19,10 +19,7 @@ internal static class SerilogConfigurator
// Minimum levels
cfg = cfg.MinimumLevel.Is(MapLevel(options.MinLevel));
foreach (var kv in options.Overrides)
{
cfg = cfg.MinimumLevel.Override(kv.Key, MapLevel(kv.Value));
}
foreach (var kv in options.Overrides) cfg = cfg.MinimumLevel.Override(kv.Key, MapLevel(kv.Value));
// Enrichers
cfg = cfg.Enrich.FromLogContext();
@@ -38,19 +35,13 @@ internal static class SerilogConfigurator
cfg = cfg.Enrich.WithEnvironmentName();
var appName = options.Enrich.Application
?? TryGetAppName()
?? AppDomain.CurrentDomain.FriendlyName;
?? TryGetAppName()
?? AppDomain.CurrentDomain.FriendlyName;
cfg = cfg.Enrich.WithProperty("Application", appName);
if (env != null)
{
cfg = cfg.Enrich.WithProperty("Environment", env.EnvironmentName);
}
if (env != null) cfg = cfg.Enrich.WithProperty("Environment", env.EnvironmentName);
if (options.Enrich.WithActivityTrace)
{
cfg = cfg.Enrich.With(new ActivityEnricher());
}
if (options.Enrich.WithActivityTrace) cfg = cfg.Enrich.With(new ActivityEnricher());
}
// Sinks
@@ -62,7 +53,8 @@ internal static class SerilogConfigurator
}
else
{
const string template = "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}";
const string template =
"[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}";
cfg = cfg.WriteTo.Async(a => a.Console(outputTemplate: template));
}
}
@@ -87,7 +79,8 @@ internal static class SerilogConfigurator
}
else
{
const string template = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}";
const string template =
"[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}";
cfg = cfg.WriteTo.Async(a => a.File(path,
rollingInterval: rolling,
retainedFileCountLimit: retained,
@@ -101,27 +94,33 @@ internal static class SerilogConfigurator
return cfg.CreateLogger();
}
private static LogEventLevel MapLevel(LogLevel level) => level switch
private static LogEventLevel MapLevel(LogLevel level)
{
LogLevel.Trace => LogEventLevel.Verbose,
LogLevel.Debug => LogEventLevel.Debug,
LogLevel.Information => LogEventLevel.Information,
LogLevel.Warning => LogEventLevel.Warning,
LogLevel.Error => LogEventLevel.Error,
LogLevel.Critical => LogEventLevel.Fatal,
_ => LogEventLevel.Information
};
return level switch
{
LogLevel.Trace => LogEventLevel.Verbose,
LogLevel.Debug => LogEventLevel.Debug,
LogLevel.Information => LogEventLevel.Information,
LogLevel.Warning => LogEventLevel.Warning,
LogLevel.Error => LogEventLevel.Error,
LogLevel.Critical => LogEventLevel.Fatal,
_ => LogEventLevel.Information
};
}
private static Serilog.RollingInterval MapRolling(Sharp.Log.Options.RollingInterval rolling) => rolling switch
private static Serilog.RollingInterval MapRolling(Options.RollingInterval rolling)
{
Sharp.Log.Options.RollingInterval.Infinite => Serilog.RollingInterval.Infinite,
Sharp.Log.Options.RollingInterval.Year => Serilog.RollingInterval.Year,
Sharp.Log.Options.RollingInterval.Month => Serilog.RollingInterval.Month,
Sharp.Log.Options.RollingInterval.Day => Serilog.RollingInterval.Day,
Sharp.Log.Options.RollingInterval.Hour => Serilog.RollingInterval.Hour,
Sharp.Log.Options.RollingInterval.Minute => Serilog.RollingInterval.Minute,
_ => Serilog.RollingInterval.Day
};
return rolling switch
{
Sharp.Log.Options.RollingInterval.Infinite => Serilog.RollingInterval.Infinite,
Sharp.Log.Options.RollingInterval.Year => Serilog.RollingInterval.Year,
Sharp.Log.Options.RollingInterval.Month => Serilog.RollingInterval.Month,
Sharp.Log.Options.RollingInterval.Day => Serilog.RollingInterval.Day,
Sharp.Log.Options.RollingInterval.Hour => Serilog.RollingInterval.Hour,
Sharp.Log.Options.RollingInterval.Minute => Serilog.RollingInterval.Minute,
_ => Serilog.RollingInterval.Day
};
}
private static string? TryGetAppName()
{
@@ -134,4 +133,4 @@ internal static class SerilogConfigurator
return null;
}
}
}
}
+23 -23
View File
@@ -1,30 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- 程序集元数据 -->
<Description>Sharp Framework 日志库 - 基于 Serilog 的结构化日志系统,支持文件、控制台等多种输出</Description>
<!-- 程序集元数据 -->
<Description>Sharp Framework 日志库 - 基于 Serilog 的结构化日志系统,支持文件、控制台等多种输出</Description>
<!-- NuGet 包元数据 -->
<PackageTags>sharp;log;logging;serilog;structured-logging</PackageTags>
</PropertyGroup>
<!-- NuGet 包元数据 -->
<PackageTags>sharp;log;logging;serilog;structured-logging</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0"/>
<PackageReference Include="Serilog" Version="4.0.0"/>
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1"/>
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0"/>
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0"/>
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1"/>
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0"/>
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0"/>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0"/>
</ItemGroup>
</Project>
@@ -51,9 +51,20 @@ public sealed class SharpRequestLoggingMiddleware
private static readonly double TimestampToMilliseconds = 1000.0 / Stopwatch.Frequency;
private readonly long _start;
private ValueStopwatch(long start) => _start = start;
public static ValueStopwatch StartNew() => new(Stopwatch.GetTimestamp());
public long GetElapsedMilliseconds() => (long)((Stopwatch.GetTimestamp() - _start) * TimestampToMilliseconds);
private ValueStopwatch(long start)
{
_start = start;
}
public static ValueStopwatch StartNew()
{
return new ValueStopwatch(Stopwatch.GetTimestamp());
}
public long GetElapsedMilliseconds()
{
return (long)((Stopwatch.GetTimestamp() - _start) * TimestampToMilliseconds);
}
}
}
@@ -63,5 +74,4 @@ public static class SharpRequestLoggingExtensions
{
return app.UseMiddleware<SharpRequestLoggingMiddleware>();
}
}
}
+1 -2
View File
@@ -48,5 +48,4 @@ public sealed class EnrichOptions
public bool WithEnvironmentName { get; set; } = true;
public string? Application { get; set; } = null; // default to entry assembly name
public bool WithActivityTrace { get; set; } = true;
}
}
+15 -4
View File
@@ -1,11 +1,13 @@
# Sharp.Log
面向“快速集成”的日志整合层:基于 `Microsoft.Extensions.Logging` 抽象,内置 Serilog 预设,实现一行接入,默认即用(Console + 按日滚动文件,结构化 JSON,丰富上下文)。
面向“快速集成”的日志整合层:基于 `Microsoft.Extensions.Logging` 抽象,内置 Serilog 预设,实现一行接入,默认即用(Console +
按日滚动文件,结构化 JSON,丰富上下文)。
- 目标:不重造日志框架,仅做预设与桥接,减少样板代码
- 适用:控制台应用、Worker、ASP.NET Core(含中间件)
## 特性
- 一行集成:`builder.Logging.AddSharpLog()`,无配置也可用
- 输出:Console(可彩色)、文件(按日滚动、保留数量、异步写)
- 结构化:文本模板/JSON;支持模板+参数与异常详情
@@ -15,6 +17,7 @@
- 配置驱动:支持 `appsettings.json:SharpLog` 与环境变量绑定
## 快速开始
1) 引用项目(或 NuGet 包)
2) 在应用入口添加一行:
@@ -33,6 +36,7 @@ app.Run();
```
// WPF/WinForms/任意桌面
```csharp
using Sharp.Log;
using Microsoft.Extensions.Configuration;
@@ -48,11 +52,13 @@ logger.LogInformation("桌面程序已启动");
```
无需任何 `appsettings.json`,即可获得:
- Console 文本输出
- 文件 JSON 输出:`logs/app-.log`(按天滚动,保留 30 个文件,异步写)
- 丰富上下文属性
## 可选配置(appsettings.json)
```json
{
"SharpLog": {
@@ -88,23 +94,28 @@ logger.LogInformation("桌面程序已启动");
```
环境变量示例:
- `SHARPLOG__MINLEVEL=Warning`
- `SHARPLOG__FILE__RETAINEDFILECOUNTLIMIT=7`
## API 概览
- `ILoggingBuilder AddSharpLog(Action<SharpLogOptions>? configure = null)`
- `IApplicationBuilder UseSharpRequestLogging()`
- 选项:`SharpLogOptions`、`FileOptions`、`EnrichOptions`、`RollingInterval`
## 常见问题
- 需要清空默认 Provider 吗?
- 如需仅使用 Sharp.Log,请在调用方执行 `builder.Logging.ClearProviders()`,然后 `AddSharpLog()`。
- 如需仅使用 Sharp.Log,请在调用方执行 `builder.Logging.ClearProviders()`,然后 `AddSharpLog()`。
- 一定要 Serilog 吗?
- 默认启用(文件/JSON/丰富器等依赖)。如不想使用,可在配置中设置 `UseSerilog=false`,保留框架自带 Console(建议不要 `ClearProviders()`)。
- 默认启用(文件/JSON/丰富器等依赖)。如不想使用,可在配置中设置 `UseSerilog=false`,保留框架自带 Console(建议不要
`ClearProviders()`)。
- 与分布式追踪兼容吗?
- 兼容。自动注入 `Activity.TraceId/SpanId`;若使用 OpenTelemetry,日志会带 Trace 关联信息。
- 兼容。自动注入 `Activity.TraceId/SpanId`;若使用 OpenTelemetry,日志会带 Trace 关联信息。
## 目录
- `Options/` 选项模型
- `Internal/` Serilog 配置与 Enricher
- `Extensions/` 集成扩展
+9 -3
View File
@@ -52,9 +52,15 @@ public static class SharpLogDefaults
_inner = provider.GetRequiredService<ILoggerFactory>();
}
public void AddProvider(ILoggerProvider provider) => _inner.AddProvider(provider);
public void AddProvider(ILoggerProvider provider)
{
_inner.AddProvider(provider);
}
public ILogger CreateLogger(string categoryName) => _inner.CreateLogger(categoryName);
public ILogger CreateLogger(string categoryName)
{
return _inner.CreateLogger(categoryName);
}
public void Dispose()
{
@@ -95,4 +101,4 @@ public static class SharpLogDefaults
}
}
}
}
}
+14
View File
@@ -3,6 +3,7 @@
本指南说明如何在不同类型项目中以“最少代码”集成 Sharp.Log,并按需进行配置与扩展。
## 1. 控制台/Worker 应用
```csharp
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -19,6 +20,7 @@ await host.RunAsync();
```
## 2. ASP.NET Core 应用
```csharp
using Sharp.Log.Extensions;
using Sharp.Log.Middleware;
@@ -33,6 +35,7 @@ app.Run();
```
## 3. 桌面/WPF/WinForms
```csharp
using Microsoft.Extensions.Configuration;
using Sharp.Log;
@@ -50,7 +53,9 @@ logger.LogInformation("桌面程序已启动");
无需 HostBuilder,即可获得默认 Console + 滚动文件输出;同样支持 `SharpLog` 配置节/环境变量。
## 4. 可选配置(appsettings.json)
添加 `SharpLog` 配置节(可省略)。
```json
{
"SharpLog": {
@@ -79,11 +84,13 @@ logger.LogInformation("桌面程序已启动");
```
环境变量覆盖示例:
- `SHARPLOG__MINLEVEL=Warning`
- `SHARPLOG__FILE__PATH=/var/logs/app-.log`
- `SHARPLOG__FILE__RETAINEDFILECOUNTLIMIT=7`
## 5. 常见定制
- 仅文件输出(关闭 Console):
```csharp
builder.Logging.AddSharpLog(o => { o.UseConsole = false; });
@@ -102,27 +109,34 @@ logger.LogInformation("桌面程序已启动");
```
## 6. 与现有 Provider 协作
- 默认不清空 Provider;若希望仅用 Sharp.Log,可在应用中先执行 `ClearProviders()` 再调用 `AddSharpLog()`。
- 如将 `UseSerilog` 设为 `false`,不会注入 Serilog Provider,请保留框架自带 Console Provider(不要 `ClearProviders()`)。
## 7. 请求日志中间件
启用后会记录:请求开始、结束(状态码与耗时)、异常。
```csharp
app.UseSharpRequestLogging();
```
## 8. 结构化与上下文
日志条目自动包含:
- `Application`、`Environment`、`MachineName`、`ProcessId`、`ThreadId`
- `TraceId`、`SpanId`(来自 `Activity.Current`)
你可使用 `BeginScope` 传入业务上下文:
```csharp
using var scope = logger.BeginScope(new Dictionary<string, object>{ ["UserId"] = userId });
logger.LogInformation("User {UserId} logged in", userId);
```
## 9. 测试建议
- 在测试中为文件输出设置临时目录:
```csharp
builder.Logging.AddSharpLog(o =>
@@ -6,5 +6,4 @@ namespace NetClient.Abstractions;
public interface IAccessTokenProvider
{
Task<string?> GetTokenAsync(CancellationToken cancellationToken = default);
}
}
+31 -11
View File
@@ -13,20 +13,40 @@ public interface IHttpClient
Task<string> GetStringAsync(string path, object? query = null, CancellationToken cancellationToken = default);
Task<byte[]> GetBytesAsync(string path, object? query = null, CancellationToken cancellationToken = default);
Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default);
Task<TResponse?> PutAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default);
Task<TResponse?> PatchAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default);
Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body,
CancellationToken cancellationToken = default);
Task<TResponse?> PutAsync<TRequest, TResponse>(string path, TRequest body,
CancellationToken cancellationToken = default);
Task<TResponse?> PatchAsync<TRequest, TResponse>(string path, TRequest body,
CancellationToken cancellationToken = default);
Task DeleteAsync(string path, object? query = null, CancellationToken cancellationToken = default);
Task<TResponse?> PostMultipartAsync<TResponse>(string path, System.Action<System.Net.Http.MultipartFormDataContent> buildContent, CancellationToken cancellationToken = default);
Task<TResponse?> PostMultipartAsync<TResponse>(string path, Action<MultipartFormDataContent> buildContent,
CancellationToken cancellationToken = default);
// Result-based convenience methods (non-throwing)
Task<Result<T>> GetResultAsync<T>(string path, object? query = null, CancellationToken cancellationToken = default);
Task<Result<string>> GetStringResultAsync(string path, object? query = null, CancellationToken cancellationToken = default);
Task<Result<byte[]>> GetBytesResultAsync(string path, object? query = null, CancellationToken cancellationToken = default);
Task<Result<TResponse>> PostResultAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default);
Task<Result<TResponse>> PutResultAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default);
Task<Result<TResponse>> PatchResultAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default);
Task<Result<string>> GetStringResultAsync(string path, object? query = null,
CancellationToken cancellationToken = default);
Task<Result<byte[]>> GetBytesResultAsync(string path, object? query = null,
CancellationToken cancellationToken = default);
Task<Result<TResponse>> PostResultAsync<TRequest, TResponse>(string path, TRequest body,
CancellationToken cancellationToken = default);
Task<Result<TResponse>> PutResultAsync<TRequest, TResponse>(string path, TRequest body,
CancellationToken cancellationToken = default);
Task<Result<TResponse>> PatchResultAsync<TRequest, TResponse>(string path, TRequest body,
CancellationToken cancellationToken = default);
Task<Result> DeleteResultAsync(string path, object? query = null, CancellationToken cancellationToken = default);
Task<Result<TResponse>> PostMultipartResultAsync<TResponse>(string path, System.Action<System.Net.Http.MultipartFormDataContent> buildContent, CancellationToken cancellationToken = default);
}
Task<Result<TResponse>> PostMultipartResultAsync<TResponse>(string path,
Action<MultipartFormDataContent> buildContent, CancellationToken cancellationToken = default);
}
@@ -9,5 +9,4 @@ public interface IHttpContentSerializer
string ContentType { get; }
Task SerializeAsync<T>(Stream stream, T? value, CancellationToken cancellationToken = default);
Task<T?> DeserializeAsync<T>(Stream stream, CancellationToken cancellationToken = default);
}
}
@@ -3,5 +3,4 @@ namespace NetClient.Abstractions;
public interface INetClientFactory
{
IHttpClient Create(string name);
}
}
@@ -12,10 +12,11 @@ public static class ConfigurationExtensions
/// Bind NetClientOptions from ConfigManager (section "NetClient").
/// Falls back to SystemConfig for BaseAddress/Timeout when not supplied.
/// </summary>
public static IServiceCollection AddNetClient(this IServiceCollection services, ConfigManager cm, string section = "NetClient")
public static IServiceCollection AddNetClient(this IServiceCollection services, ConfigManager cm,
string section = "NetClient")
{
var opt = cm.Get<NetClientOptions>(section, validate: false) ?? new NetClientOptions();
var sys = cm.Get<SystemConfig>("System", validate: false);
var opt = cm.Get<NetClientOptions>(section, false) ?? new NetClientOptions();
var sys = cm.Get<SystemConfig>("System", false);
if (opt.BaseAddress is null)
{
@@ -23,10 +24,7 @@ public static class ConfigurationExtensions
opt.BaseAddress = new Uri(baseUrl);
}
if (opt.Timeout.TotalSeconds <= 0)
{
opt.Timeout = TimeSpan.FromSeconds(Math.Max(1, sys.TimeoutSeconds));
}
if (opt.Timeout.TotalSeconds <= 0) opt.Timeout = TimeSpan.FromSeconds(Math.Max(1, sys.TimeoutSeconds));
// Delegate to existing AddNetClient(Action<NetClientOptions>)
services.AddNetClient(o =>
@@ -44,4 +42,4 @@ public static class ConfigurationExtensions
return services;
}
}
}
@@ -21,7 +21,8 @@ public static class ServiceCollectionExtensions
{
services.TryAddTransient<LoggingHandler>();
services.TryAddTransient<BearerTokenAuthenticationHandler>();
services.TryAddSingleton<IHttpContentSerializer>(sp => new SystemTextJsonContentSerializer(Common.Utils.JsonHelper.CamelCaseOptions));
services.TryAddSingleton<IHttpContentSerializer>(sp =>
new SystemTextJsonContentSerializer(Common.Utils.JsonHelper.CamelCaseOptions));
services.TryAddSingleton<INetClientFactory, NetClientFactory>();
return services;
}
@@ -32,7 +33,8 @@ public static class ServiceCollectionExtensions
var options = new NetClientOptions();
configure(options);
var builder = services.AddHttpClient<IHttpClient, DefaultNetClient>(client => ApplyClientOptions(client, options));
var builder =
services.AddHttpClient<IHttpClient, DefaultNetClient>(client => ApplyClientOptions(client, options));
if (options.EnableLogging)
builder.AddHttpMessageHandler<LoggingHandler>();
@@ -49,7 +51,8 @@ public static class ServiceCollectionExtensions
return builder;
}
public static IHttpClientBuilder AddNamedNetClient(this IServiceCollection services, string name, Action<NetClientOptions> configure)
public static IHttpClientBuilder AddNamedNetClient(this IServiceCollection services, string name,
Action<NetClientOptions> configure)
{
services.AddNetClientCore();
var options = new NetClientOptions();
@@ -96,15 +99,11 @@ public static class ServiceCollectionExtensions
.HandleTransientHttpError(); // 5xx + HttpRequestException + 408
if (retry.Handle429)
{
policyBuilder = policyBuilder.OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests);
}
if (retry.HandleRequestTimeout)
{
policyBuilder = policyBuilder.OrResult(msg => msg.StatusCode == HttpStatusCode.RequestTimeout);
}
return policyBuilder.WaitAndRetryAsync(delay);
}
}
}
@@ -17,5 +17,4 @@ public class ApiException : Exception
StatusCode = response.StatusCode;
ResponseContent = content;
}
}
}
@@ -15,18 +15,16 @@ public class BearerTokenAuthenticationHandler : DelegatingHandler
_tokenProvider = tokenProvider;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (request.Headers.Authorization is null)
{
var token = await _tokenProvider.GetTokenAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -14,13 +14,10 @@ public class CorrelationIdHandler : DelegatingHandler
_headerName = headerName;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (!request.Headers.Contains(_headerName))
{
request.Headers.Add(_headerName, Guid.NewGuid().ToString("N"));
}
if (!request.Headers.Contains(_headerName)) request.Headers.Add(_headerName, Guid.NewGuid().ToString("N"));
return base.SendAsync(request, cancellationToken);
}
}
}
@@ -16,7 +16,8 @@ public class LoggingHandler : DelegatingHandler
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
var sw = Stopwatch.StartNew();
try
@@ -24,15 +25,16 @@ public class LoggingHandler : DelegatingHandler
_logger.LogDebug("HTTP {Method} {Uri}", request.Method, request.RequestUri);
var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
sw.Stop();
_logger.LogDebug("HTTP {Method} {Uri} -> {Status} in {Elapsed} ms", request.Method, request.RequestUri, (int)response.StatusCode, sw.ElapsedMilliseconds);
_logger.LogDebug("HTTP {Method} {Uri} -> {Status} in {Elapsed} ms", request.Method, request.RequestUri,
(int)response.StatusCode, sw.ElapsedMilliseconds);
return response;
}
catch (Exception ex)
{
sw.Stop();
_logger.LogError(ex, "HTTP {Method} {Uri} failed after {Elapsed} ms", request.Method, request.RequestUri, sw.ElapsedMilliseconds);
_logger.LogError(ex, "HTTP {Method} {Uri} failed after {Elapsed} ms", request.Method, request.RequestUri,
sw.ElapsedMilliseconds);
throw;
}
}
}
}
@@ -26,8 +26,11 @@ public class DefaultNetClient : IHttpClient
_logger = logger;
}
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default)
=> _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken = default)
{
return _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
}
public async Task<T?> GetAsync<T>(string path, object? query = null, CancellationToken cancellationToken = default)
{
@@ -39,7 +42,8 @@ public class DefaultNetClient : IHttpClient
return await _serializer.DeserializeAsync<T>(stream, cancellationToken).ConfigureAwait(false);
}
public async Task<string> GetStringAsync(string path, object? query = null, CancellationToken cancellationToken = default)
public async Task<string> GetStringAsync(string path, object? query = null,
CancellationToken cancellationToken = default)
{
var uri = BuildUri(path, query);
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
@@ -48,7 +52,8 @@ public class DefaultNetClient : IHttpClient
return await resp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<byte[]> GetBytesAsync(string path, object? query = null, CancellationToken cancellationToken = default)
public async Task<byte[]> GetBytesAsync(string path, object? query = null,
CancellationToken cancellationToken = default)
{
var uri = BuildUri(path, query);
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
@@ -57,14 +62,23 @@ public class DefaultNetClient : IHttpClient
return await resp.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
}
public Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default)
=> SendWithBodyAsync<TRequest, TResponse>(HttpMethod.Post, path, body, cancellationToken);
public Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body,
CancellationToken cancellationToken = default)
{
return SendWithBodyAsync<TRequest, TResponse>(HttpMethod.Post, path, body, cancellationToken);
}
public Task<TResponse?> PutAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default)
=> SendWithBodyAsync<TRequest, TResponse>(HttpMethod.Put, path, body, cancellationToken);
public Task<TResponse?> PutAsync<TRequest, TResponse>(string path, TRequest body,
CancellationToken cancellationToken = default)
{
return SendWithBodyAsync<TRequest, TResponse>(HttpMethod.Put, path, body, cancellationToken);
}
public Task<TResponse?> PatchAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default)
=> SendWithBodyAsync<TRequest, TResponse>(HttpMethod.Patch, path, body, cancellationToken);
public Task<TResponse?> PatchAsync<TRequest, TResponse>(string path, TRequest body,
CancellationToken cancellationToken = default)
{
return SendWithBodyAsync<TRequest, TResponse>(HttpMethod.Patch, path, body, cancellationToken);
}
public async Task DeleteAsync(string path, object? query = null, CancellationToken cancellationToken = default)
{
@@ -74,7 +88,8 @@ public class DefaultNetClient : IHttpClient
await EnsureSuccess(resp).ConfigureAwait(false);
}
public async Task<TResponse?> PostMultipartAsync<TResponse>(string path, Action<MultipartFormDataContent> buildContent, CancellationToken cancellationToken = default)
public async Task<TResponse?> PostMultipartAsync<TResponse>(string path,
Action<MultipartFormDataContent> buildContent, CancellationToken cancellationToken = default)
{
var uri = BuildUri(path, null);
using var content = new MultipartFormDataContent();
@@ -86,7 +101,8 @@ public class DefaultNetClient : IHttpClient
return await _serializer.DeserializeAsync<TResponse>(stream, cancellationToken).ConfigureAwait(false);
}
public async Task<Result<T>> GetResultAsync<T>(string path, object? query = null, CancellationToken cancellationToken = default)
public async Task<Result<T>> GetResultAsync<T>(string path, object? query = null,
CancellationToken cancellationToken = default)
{
try
{
@@ -103,7 +119,8 @@ public class DefaultNetClient : IHttpClient
}
}
public async Task<Result<string>> GetStringResultAsync(string path, object? query = null, CancellationToken cancellationToken = default)
public async Task<Result<string>> GetStringResultAsync(string path, object? query = null,
CancellationToken cancellationToken = default)
{
try
{
@@ -120,7 +137,8 @@ public class DefaultNetClient : IHttpClient
}
}
public async Task<Result<byte[]>> GetBytesResultAsync(string path, object? query = null, CancellationToken cancellationToken = default)
public async Task<Result<byte[]>> GetBytesResultAsync(string path, object? query = null,
CancellationToken cancellationToken = default)
{
try
{
@@ -137,7 +155,8 @@ public class DefaultNetClient : IHttpClient
}
}
public async Task<Result<TResponse>> PostResultAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default)
public async Task<Result<TResponse>> PostResultAsync<TRequest, TResponse>(string path, TRequest body,
CancellationToken cancellationToken = default)
{
try
{
@@ -154,7 +173,8 @@ public class DefaultNetClient : IHttpClient
}
}
public async Task<Result<TResponse>> PutResultAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default)
public async Task<Result<TResponse>> PutResultAsync<TRequest, TResponse>(string path, TRequest body,
CancellationToken cancellationToken = default)
{
try
{
@@ -171,7 +191,8 @@ public class DefaultNetClient : IHttpClient
}
}
public async Task<Result<TResponse>> PatchResultAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default)
public async Task<Result<TResponse>> PatchResultAsync<TRequest, TResponse>(string path, TRequest body,
CancellationToken cancellationToken = default)
{
try
{
@@ -188,7 +209,8 @@ public class DefaultNetClient : IHttpClient
}
}
public async Task<Result> DeleteResultAsync(string path, object? query = null, CancellationToken cancellationToken = default)
public async Task<Result> DeleteResultAsync(string path, object? query = null,
CancellationToken cancellationToken = default)
{
try
{
@@ -205,11 +227,13 @@ public class DefaultNetClient : IHttpClient
}
}
public async Task<Result<TResponse>> PostMultipartResultAsync<TResponse>(string path, Action<MultipartFormDataContent> buildContent, CancellationToken cancellationToken = default)
public async Task<Result<TResponse>> PostMultipartResultAsync<TResponse>(string path,
Action<MultipartFormDataContent> buildContent, CancellationToken cancellationToken = default)
{
try
{
var value = await PostMultipartAsync<TResponse>(path, buildContent, cancellationToken).ConfigureAwait(false);
var value = await PostMultipartAsync<TResponse>(path, buildContent, cancellationToken)
.ConfigureAwait(false);
return Result.Success(value!);
}
catch (ApiException ex)
@@ -243,7 +267,9 @@ public class DefaultNetClient : IHttpClient
string? content = null;
try
{
content = response.Content is null ? null : await response.Content.ReadAsStringAsync().ConfigureAwait(false);
content = response.Content is null
? null
: await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -253,18 +279,20 @@ public class DefaultNetClient : IHttpClient
throw new ApiException($"Request failed with status {(int)response.StatusCode}", response, content);
}
private async Task<TResponse?> SendWithBodyAsync<TRequest, TResponse>(HttpMethod method, string path, TRequest body, CancellationToken cancellationToken)
private async Task<TResponse?> SendWithBodyAsync<TRequest, TResponse>(HttpMethod method, string path, TRequest body,
CancellationToken cancellationToken)
{
var uri = BuildUri(path, null);
using var ms = new MemoryStream();
await _serializer.SerializeAsync(ms, body, cancellationToken).ConfigureAwait(false);
ms.Position = 0;
using var content = new StreamContent(ms);
content.Headers.ContentType = new MediaTypeHeaderValue(_serializer.ContentType) { CharSet = Encoding.UTF8.WebName };
content.Headers.ContentType = new MediaTypeHeaderValue(_serializer.ContentType)
{ CharSet = Encoding.UTF8.WebName };
using var req = new HttpRequestMessage(method, uri) { Content = content };
using var resp = await SendAsync(req, cancellationToken).ConfigureAwait(false);
await EnsureSuccess(resp).ConfigureAwait(false);
await using var stream = await resp.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await _serializer.DeserializeAsync<TResponse>(stream, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -16,11 +16,10 @@ public class NetClientFactory : INetClientFactory
public IHttpClient Create(string name)
{
var httpFactory = _services.GetRequiredService<System.Net.Http.IHttpClientFactory>();
var httpFactory = _services.GetRequiredService<IHttpClientFactory>();
var client = httpFactory.CreateClient(name);
var serializer = _services.GetRequiredService<IHttpContentSerializer>();
var logger = _services.GetRequiredService<ILogger<DefaultNetClient>>();
return new DefaultNetClient(client, serializer, logger);
}
}
}
@@ -27,5 +27,4 @@ internal class SystemTextJsonContentSerializer : IHttpContentSerializer
{
return await JsonSerializer.DeserializeAsync<T>(stream, _options, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -14,10 +14,7 @@ internal static class QueryStringHelper
{
if (query is null) return string.Empty;
if (query is IEnumerable<KeyValuePair<string, object?>> pairs)
{
return BuildFromPairs(pairs);
}
if (query is IEnumerable<KeyValuePair<string, object?>> pairs) return BuildFromPairs(pairs);
var dict = new Dictionary<string, object?>();
foreach (var prop in query.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public))
@@ -33,6 +30,7 @@ internal static class QueryStringHelper
private static string BuildFromPairs(IEnumerable<KeyValuePair<string, object?>> pairs)
{
var sb = new StringBuilder();
void AppendPair(string key, string value)
{
if (sb.Length > 0) sb.Append('&');
@@ -48,21 +46,15 @@ internal static class QueryStringHelper
if (val is null) continue;
if (val is string s)
{
AppendPair(key, s);
}
else if (val is IEnumerable enumerable && val is not byte[])
{
foreach (var item in enumerable)
{
if (item is null) continue;
AppendPair(key, ConvertToString(item));
}
}
else
{
AppendPair(key, ConvertToString(val));
}
}
return sb.ToString();
@@ -79,5 +71,4 @@ internal static class QueryStringHelper
_ => value.ToString() ?? string.Empty
};
}
}
}
+19 -19
View File
@@ -1,27 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- 程序集元数据 -->
<Description>Sharp Framework HTTP 客户端库 - 集成 Polly 弹性策略的 HTTP 客户端</Description>
<!-- 程序集元数据 -->
<Description>Sharp Framework HTTP 客户端库 - 集成 Polly 弹性策略的 HTTP 客户端</Description>
<!-- NuGet 包元数据 -->
<PackageTags>sharp;http;client;polly;resilience</PackageTags>
</PropertyGroup>
<!-- NuGet 包元数据 -->
<PackageTags>sharp;http;client;polly;resilience</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.*" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0"/>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0"/>
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.*"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\Config\Config.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj"/>
<ProjectReference Include="..\Config\Config.csproj"/>
</ItemGroup>
</Project>
@@ -18,4 +18,4 @@ public class NetClientOptions
public RetryOptions Retry { get; set; } = new();
public JsonSerializerOptions JsonSerializerOptions { get; set; } = Common.Utils.JsonHelper.CamelCaseOptions;
}
}
+1 -2
View File
@@ -10,5 +10,4 @@ public class RetryOptions
public int MaxJitterMs { get; set; } = 100;
public bool Handle429 { get; set; } = true;
public bool HandleRequestTimeout { get; set; } = true; // 408
}
}
+6
View File
@@ -57,12 +57,14 @@ NetServer/
### 1. HTTP 服务器 (Server/)
**HttpServer.cs**
- 基于 `HttpListener` 的 HTTP 服务器实现
- 管理中间件管道和路由表
- 处理请求的并发执行
- 支持依赖注入
**HttpServerOptions.cs**
- 服务器配置选项类
- 支持 Fluent API 配置
- 管理中间件和控制器程序集
@@ -70,18 +72,21 @@ NetServer/
### 2. 路由系统 (Routing/)
**RouteTable.cs**
- 路由注册和管理
- 基于特性的路由发现
- 路由匹配(支持路径参数)
- 路由元数据收集
**RouteInfo.cs**
- 路由信息数据模型
- 包含授权、限流、缓存等配置
### 3. 中间件系统 (Middleware/)
**执行顺序:**
1. ExceptionHandling → 全局异常处理
2. Logging → 请求日志
3. CORS → 跨域处理
@@ -96,6 +101,7 @@ NetServer/
**BaseController.cs**
提供以下辅助方法:
- 请求读取:`GetQueryParam()`, `ReadJsonBodyAsync()`
- 响应写入:`WriteJsonResponseAsync()`, `WriteSuccessResponseAsync()`
- 快速响应:`NotFoundAsync()`, `UnauthorizedAsync()`
@@ -27,4 +27,4 @@ public interface ITokenProvider
/// 验证 Token
/// </summary>
ClaimsPrincipal? ValidateToken(string token);
}
}
@@ -16,4 +16,4 @@ public interface IMiddleware
/// <summary>
/// 中间件委托
/// </summary>
public delegate Task RequestDelegate(HttpContext context);
public delegate Task RequestDelegate(HttpContext context);
@@ -28,4 +28,4 @@ public class AuthorizeAttribute : Attribute
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
public class AllowAnonymousAttribute : Attribute
{
}
}
@@ -46,4 +46,4 @@ public class ResponseCacheAttribute : Attribute
{
Duration = duration;
}
}
}
@@ -38,4 +38,4 @@ public class EnableCorsAttribute : Attribute
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
public class DisableCorsAttribute : Attribute
{
}
}
@@ -21,7 +21,9 @@ public abstract class HttpMethodAttribute : Attribute
/// </summary>
public class HttpGetAttribute : HttpMethodAttribute
{
public HttpGetAttribute(string? template = null) : base("GET", template) { }
public HttpGetAttribute(string? template = null) : base("GET", template)
{
}
}
/// <summary>
@@ -29,7 +31,9 @@ public class HttpGetAttribute : HttpMethodAttribute
/// </summary>
public class HttpPostAttribute : HttpMethodAttribute
{
public HttpPostAttribute(string? template = null) : base("POST", template) { }
public HttpPostAttribute(string? template = null) : base("POST", template)
{
}
}
/// <summary>
@@ -37,7 +41,9 @@ public class HttpPostAttribute : HttpMethodAttribute
/// </summary>
public class HttpPutAttribute : HttpMethodAttribute
{
public HttpPutAttribute(string? template = null) : base("PUT", template) { }
public HttpPutAttribute(string? template = null) : base("PUT", template)
{
}
}
/// <summary>
@@ -45,7 +51,9 @@ public class HttpPutAttribute : HttpMethodAttribute
/// </summary>
public class HttpDeleteAttribute : HttpMethodAttribute
{
public HttpDeleteAttribute(string? template = null) : base("DELETE", template) { }
public HttpDeleteAttribute(string? template = null) : base("DELETE", template)
{
}
}
/// <summary>
@@ -53,7 +61,9 @@ public class HttpDeleteAttribute : HttpMethodAttribute
/// </summary>
public class HttpPatchAttribute : HttpMethodAttribute
{
public HttpPatchAttribute(string? template = null) : base("PATCH", template) { }
public HttpPatchAttribute(string? template = null) : base("PATCH", template)
{
}
}
/// <summary>
@@ -61,7 +71,9 @@ public class HttpPatchAttribute : HttpMethodAttribute
/// </summary>
public class HttpHeadAttribute : HttpMethodAttribute
{
public HttpHeadAttribute(string? template = null) : base("HEAD", template) { }
public HttpHeadAttribute(string? template = null) : base("HEAD", template)
{
}
}
/// <summary>
@@ -69,5 +81,7 @@ public class HttpHeadAttribute : HttpMethodAttribute
/// </summary>
public class HttpOptionsAttribute : HttpMethodAttribute
{
public HttpOptionsAttribute(string? template = null) : base("OPTIONS", template) { }
}
public HttpOptionsAttribute(string? template = null) : base("OPTIONS", template)
{
}
}
@@ -59,4 +59,4 @@ public class RateLimitAttribute : Attribute
Scope = scope;
WindowSeconds = windowSeconds;
}
}
}
@@ -20,4 +20,4 @@ public class RouteAttribute : Attribute
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ApiControllerAttribute : Attribute
{
}
}
@@ -60,7 +60,7 @@ public abstract class BaseController
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json; charset=utf-8";
var json = JsonHelper.ToJson(data, useCompact: true);
var json = JsonHelper.ToJson(data, true);
var bytes = Encoding.UTF8.GetBytes(json);
context.Response.ContentLength = bytes.Length;
@@ -70,7 +70,8 @@ public abstract class BaseController
/// <summary>
/// 写入文本响应
/// </summary>
protected async Task WriteTextResponseAsync(HttpContext context, string text, int statusCode = 200, string contentType = "text/plain")
protected async Task WriteTextResponseAsync(HttpContext context, string text, int statusCode = 200,
string contentType = "text/plain")
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = $"{contentType}; charset=utf-8";
@@ -91,7 +92,8 @@ public abstract class BaseController
/// <summary>
/// 写入错误响应
/// </summary>
protected async Task WriteErrorResponseAsync(HttpContext context, string message, int statusCode = 500, long? errorCode = null)
protected async Task WriteErrorResponseAsync(HttpContext context, string message, int statusCode = 500,
long? errorCode = null)
{
var response = ResultExtensions.HttpFailure<object>(message, errorCode);
await WriteJsonResponseAsync(context, response, statusCode);
@@ -153,10 +155,7 @@ public abstract class BaseController
protected void SetCookie(HttpContext context, string name, string value, int? expiresInSeconds = null)
{
var cookie = new System.Net.Cookie(name, value);
if (expiresInSeconds.HasValue)
{
cookie.Expires = DateTime.UtcNow.AddSeconds(expiresInSeconds.Value);
}
if (expiresInSeconds.HasValue) cookie.Expires = DateTime.UtcNow.AddSeconds(expiresInSeconds.Value);
context.Response.Cookies.Add(cookie);
}
@@ -180,4 +179,4 @@ public abstract class BaseController
};
context.Response.Cookies.Add(cookie);
}
}
}
@@ -12,10 +12,11 @@ public static class ServiceCollectionExtensions
/// Adds HttpServerOptions bound from ConfigManager (section "NetServer").
/// If no ListenUrls specified, uses SystemConfig to build a default base URL.
/// </summary>
public static IServiceCollection AddNetServer(this IServiceCollection services, ConfigManager cm, string section = "NetServer")
public static IServiceCollection AddNetServer(this IServiceCollection services, ConfigManager cm,
string section = "NetServer")
{
var options = cm.Get<HttpServerOptions>(section, validate: false) ?? new HttpServerOptions();
var sys = cm.Get<SystemConfig>("System", validate: false);
var options = cm.Get<HttpServerOptions>(section, false) ?? new HttpServerOptions();
var sys = cm.Get<SystemConfig>("System", false);
if (options.ListenUrls == null || options.ListenUrls.Count == 0)
{
@@ -26,4 +27,4 @@ public static class ServiceCollectionExtensions
services.TryAddSingleton(options);
return services;
}
}
}
@@ -108,4 +108,4 @@ public class DemoController : BaseController
message = "CORS enabled for this endpoint"
});
}
}
}
+1 -1
View File
@@ -95,4 +95,4 @@ public class Program
Console.WriteLine("服务器已停止。");
}
}
}
@@ -90,5 +90,8 @@ public class HttpResult<T>
/// <summary>
/// 获取原始 Result
/// </summary>
public Result<T> ToResult() => _result;
}
public Result<T> ToResult()
{
return _result;
}
}
Loaded 100 of 342 files, more files were not shown because too many files have changed in this diff. Show more