U:调整所有代码格式
This commit is contained in:
1 parent
d133bacf55
commit
155b67e831
263 files changed
+2891
-3328
No files matched your search
@@ -15,8 +15,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -16,10 +16,7 @@ public static class CacheExtensions
|
|||||||
Func<T> factory,
|
Func<T> factory,
|
||||||
TimeSpan? expiration = null)
|
TimeSpan? expiration = null)
|
||||||
{
|
{
|
||||||
if (cache.TryGet<T>(key, out var cached))
|
if (cache.TryGet<T>(key, out var cached)) return cached!;
|
||||||
{
|
|
||||||
return cached!;
|
|
||||||
}
|
|
||||||
|
|
||||||
var value = factory();
|
var value = factory();
|
||||||
cache.Set(key, value, expiration);
|
cache.Set(key, value, expiration);
|
||||||
@@ -35,10 +32,7 @@ public static class CacheExtensions
|
|||||||
Func<Task<T>> factory,
|
Func<Task<T>> factory,
|
||||||
TimeSpan? expiration = null)
|
TimeSpan? expiration = null)
|
||||||
{
|
{
|
||||||
if (cache.TryGet<T>(key, out var cached))
|
if (cache.TryGet<T>(key, out var cached)) return cached!;
|
||||||
{
|
|
||||||
return cached!;
|
|
||||||
}
|
|
||||||
|
|
||||||
var value = await factory();
|
var value = await factory();
|
||||||
cache.Set(key, value, expiration);
|
cache.Set(key, value, expiration);
|
||||||
@@ -54,10 +48,7 @@ public static class CacheExtensions
|
|||||||
Func<T> factory,
|
Func<T> factory,
|
||||||
TimeSpan slidingExpiration)
|
TimeSpan slidingExpiration)
|
||||||
{
|
{
|
||||||
if (cache.TryGet<T>(key, out var cached))
|
if (cache.TryGet<T>(key, out var cached)) return cached!;
|
||||||
{
|
|
||||||
return cached!;
|
|
||||||
}
|
|
||||||
|
|
||||||
var value = factory();
|
var value = factory();
|
||||||
cache.SetSliding(key, value, slidingExpiration);
|
cache.SetSliding(key, value, slidingExpiration);
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ public interface ICacheService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 基于 MemoryCache 的缓存服务实现
|
/// 基于 MemoryCache 的缓存服务实现
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MemoryCacheService : ICacheService
|
public class MemoryCacheService : ICacheService
|
||||||
{
|
{
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
private readonly HashSet<string> _keys = new(); // 跟踪所有键(用于 Clear)
|
private readonly HashSet<string> _keys = new(); // 跟踪所有键(用于 Clear)
|
||||||
private readonly object _lockObj = new();
|
private readonly object _lockObj = new();
|
||||||
@@ -138,20 +138,14 @@ public interface ICacheService
|
|||||||
{
|
{
|
||||||
lock (_lockObj)
|
lock (_lockObj)
|
||||||
{
|
{
|
||||||
foreach (var key in _keys.ToList())
|
foreach (var key in _keys.ToList()) _cache.Remove(key);
|
||||||
{
|
|
||||||
_cache.Remove(key);
|
|
||||||
}
|
|
||||||
_keys.Clear();
|
_keys.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RemoveByPrefix(string prefix)
|
public void RemoveByPrefix(string prefix)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(prefix))
|
if (string.IsNullOrWhiteSpace(prefix)) return;
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<string> targets;
|
List<string> targets;
|
||||||
lock (_lockObj)
|
lock (_lockObj)
|
||||||
@@ -159,10 +153,7 @@ public interface ICacheService
|
|||||||
targets = _keys.Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList();
|
targets = _keys.Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var key in targets)
|
foreach (var key in targets) Remove(key);
|
||||||
{
|
|
||||||
Remove(key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyCollection<string> Keys
|
public IReadOnlyCollection<string> Keys
|
||||||
@@ -180,4 +171,4 @@ public interface ICacheService
|
|||||||
{
|
{
|
||||||
return _cache.TryGetValue(key, out _);
|
return _cache.TryGetValue(key, out _);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
### 场景对比
|
### 场景对比
|
||||||
|
|
||||||
#### ❌ 不使用 Cache - 重复查询
|
#### ❌ 不使用 Cache - 重复查询
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class ProductService {
|
public class ProductService {
|
||||||
private readonly IRepository<Category> _categoryRepo;
|
private readonly IRepository<Category> _categoryRepo;
|
||||||
@@ -29,6 +30,7 @@ public class ProductService {
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### ✅ 使用 Cache - 智能缓存
|
#### ✅ 使用 Cache - 智能缓存
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class ProductService {
|
public class ProductService {
|
||||||
private readonly IRepository<Category> _categoryRepo;
|
private readonly IRepository<Category> _categoryRepo;
|
||||||
@@ -53,6 +55,7 @@ public class ProductService {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**效果**:
|
**效果**:
|
||||||
|
|
||||||
- 第一次:查询数据库(耗时 50ms)
|
- 第一次:查询数据库(耗时 50ms)
|
||||||
- 后续访问:从内存读取(耗时 < 1ms)
|
- 后续访问:从内存读取(耗时 < 1ms)
|
||||||
- 用户体验:界面秒开,不再等待
|
- 用户体验:界面秒开,不再等待
|
||||||
@@ -72,6 +75,7 @@ public class ProductService {
|
|||||||
## 🚀 快速开始(未来)
|
## 🚀 快速开始(未来)
|
||||||
|
|
||||||
### 1. 注册服务
|
### 1. 注册服务
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// App.xaml.cs
|
// App.xaml.cs
|
||||||
protected override void OnStartup(StartupEventArgs e) {
|
protected override void OnStartup(StartupEventArgs e) {
|
||||||
@@ -85,6 +89,7 @@ protected override void OnStartup(StartupEventArgs e) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. 注入使用
|
### 2. 注入使用
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class WeatherService {
|
public class WeatherService {
|
||||||
private readonly ICacheService _cache;
|
private readonly ICacheService _cache;
|
||||||
@@ -111,6 +116,7 @@ public class WeatherService {
|
|||||||
## 📚 核心 API 设计
|
## 📚 核心 API 设计
|
||||||
|
|
||||||
### ICacheService 接口
|
### ICacheService 接口
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public interface ICacheService {
|
public interface ICacheService {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -157,6 +163,7 @@ public interface ICacheService {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 扩展方法(最常用)
|
### 扩展方法(最常用)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public static class CacheExtensions {
|
public static class CacheExtensions {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -200,6 +207,7 @@ public static class CacheExtensions {
|
|||||||
## 💡 典型使用场景
|
## 💡 典型使用场景
|
||||||
|
|
||||||
### 场景1:字典数据缓存(最常见)
|
### 场景1:字典数据缓存(最常见)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class UserViewModel : ViewModelBase {
|
public class UserViewModel : ViewModelBase {
|
||||||
private readonly ICacheService _cache;
|
private readonly ICacheService _cache;
|
||||||
@@ -224,6 +232,7 @@ public class UserViewModel : ViewModelBase {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 场景2:API响应缓存
|
### 场景2:API响应缓存
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class WeatherService {
|
public class WeatherService {
|
||||||
private readonly ICacheService _cache;
|
private readonly ICacheService _cache;
|
||||||
@@ -247,6 +256,7 @@ public class WeatherService {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 场景3:计算结果缓存
|
### 场景3:计算结果缓存
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class ReportService {
|
public class ReportService {
|
||||||
private readonly ICacheService _cache;
|
private readonly ICacheService _cache;
|
||||||
@@ -277,6 +287,7 @@ public class ReportService {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 场景4:用户权限缓存
|
### 场景4:用户权限缓存
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class AuthorizationService {
|
public class AuthorizationService {
|
||||||
private readonly ICacheService _cache;
|
private readonly ICacheService _cache;
|
||||||
@@ -300,6 +311,7 @@ public class AuthorizationService {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 场景5:配置数据缓存
|
### 场景5:配置数据缓存
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class SystemConfigService {
|
public class SystemConfigService {
|
||||||
private readonly ICacheService _cache;
|
private readonly ICacheService _cache;
|
||||||
@@ -325,6 +337,7 @@ public class SystemConfigService {
|
|||||||
## 🔧 高级功能
|
## 🔧 高级功能
|
||||||
|
|
||||||
### 1. 缓存键约定
|
### 1. 缓存键约定
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 推荐的缓存键命名规范
|
// 推荐的缓存键命名规范
|
||||||
public static class CacheKeys {
|
public static class CacheKeys {
|
||||||
@@ -346,6 +359,7 @@ var user = await _cache.GetOrSetAsync(
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2. 缓存依赖(级联清除)
|
### 2. 缓存依赖(级联清除)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class ProductService {
|
public class ProductService {
|
||||||
// 当产品更新时,清除相关缓存
|
// 当产品更新时,清除相关缓存
|
||||||
@@ -367,6 +381,7 @@ public class ProductService {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 3. 缓存预热
|
### 3. 缓存预热
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class CacheWarmupService {
|
public class CacheWarmupService {
|
||||||
private readonly ICacheService _cache;
|
private readonly ICacheService _cache;
|
||||||
@@ -406,6 +421,7 @@ protected override async void OnStartup(StartupEventArgs e) {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 4. 缓存统计(可选)
|
### 4. 缓存统计(可选)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public interface ICacheStatistics {
|
public interface ICacheStatistics {
|
||||||
long TotalHits { get; } // 命中次数
|
long TotalHits { get; } // 命中次数
|
||||||
@@ -446,12 +462,14 @@ services.AddMemoryCache(options => {
|
|||||||
## 🏗️ 实现方案
|
## 🏗️ 实现方案
|
||||||
|
|
||||||
### 基于 Microsoft.Extensions.Caching.Memory
|
### 基于 Microsoft.Extensions.Caching.Memory
|
||||||
|
|
||||||
- **内置库**:无需额外依赖
|
- **内置库**:无需额外依赖
|
||||||
- **高性能**:基于 ConcurrentDictionary
|
- **高性能**:基于 ConcurrentDictionary
|
||||||
- **线程安全**:完全线程安全
|
- **线程安全**:完全线程安全
|
||||||
- **自动清理**:后台定期清理过期项
|
- **自动清理**:后台定期清理过期项
|
||||||
|
|
||||||
### 架构设计
|
### 架构设计
|
||||||
|
|
||||||
```
|
```
|
||||||
ICacheService(接口)
|
ICacheService(接口)
|
||||||
↓
|
↓
|
||||||
@@ -474,6 +492,7 @@ Microsoft.Extensions.Caching.Memory.IMemoryCache
|
|||||||
## 🔄 过期策略对比
|
## 🔄 过期策略对比
|
||||||
|
|
||||||
### 绝对过期 (Absolute Expiration)
|
### 绝对过期 (Absolute Expiration)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 从设置时开始,10分钟后过期(无论是否访问)
|
// 从设置时开始,10分钟后过期(无论是否访问)
|
||||||
_cache.Set("key", value, TimeSpan.FromMinutes(10));
|
_cache.Set("key", value, TimeSpan.FromMinutes(10));
|
||||||
@@ -485,6 +504,7 @@ _cache.Set("key", value, TimeSpan.FromMinutes(10));
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 滑动过期 (Sliding Expiration)
|
### 滑动过期 (Sliding Expiration)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 最后访问后30分钟无访问则过期
|
// 最后访问后30分钟无访问则过期
|
||||||
_cache.SetSliding("key", value, TimeSpan.FromMinutes(30));
|
_cache.SetSliding("key", value, TimeSpan.FromMinutes(30));
|
||||||
@@ -496,6 +516,7 @@ _cache.SetSliding("key", value, TimeSpan.FromMinutes(30));
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 组合使用
|
### 组合使用
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 滑动30分钟,但最多缓存2小时
|
// 滑动30分钟,但最多缓存2小时
|
||||||
services.AddMemoryCache();
|
services.AddMemoryCache();
|
||||||
@@ -511,6 +532,7 @@ _cache.Set("key", value, new MemoryCacheEntryOptions {
|
|||||||
## 💾 缓存大小管理
|
## 💾 缓存大小管理
|
||||||
|
|
||||||
### 设置项大小
|
### 设置项大小
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
_cache.Set("large-data", value, new MemoryCacheEntryOptions {
|
_cache.Set("large-data", value, new MemoryCacheEntryOptions {
|
||||||
Size = 1, // 占用1个单位(Size单位由SizeLimit定义)
|
Size = 1, // 占用1个单位(Size单位由SizeLimit定义)
|
||||||
@@ -519,6 +541,7 @@ _cache.Set("large-data", value, new MemoryCacheEntryOptions {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 优先级
|
### 优先级
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 低优先级:内存不足时优先清除
|
// 低优先级:内存不足时优先清除
|
||||||
_cache.Set("low-priority", value, new MemoryCacheEntryOptions {
|
_cache.Set("low-priority", value, new MemoryCacheEntryOptions {
|
||||||
@@ -541,6 +564,7 @@ _cache.Set("never-remove", value, new MemoryCacheEntryOptions {
|
|||||||
## 📝 最佳实践
|
## 📝 最佳实践
|
||||||
|
|
||||||
### 1. 缓存键命名规范
|
### 1. 缓存键命名规范
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
✅ 好的命名
|
✅ 好的命名
|
||||||
"user:profile:123"
|
"user:profile:123"
|
||||||
@@ -554,6 +578,7 @@ _cache.Set("never-remove", value, new MemoryCacheEntryOptions {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. 合理设置过期时间
|
### 2. 合理设置过期时间
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 根据数据变化频率设置
|
// 根据数据变化频率设置
|
||||||
字典数据:1小时 - 1天
|
字典数据:1小时 - 1天
|
||||||
@@ -563,6 +588,7 @@ API响应:5分钟 - 15分钟
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 3. 避免缓存过大对象
|
### 3. 避免缓存过大对象
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
❌ 不推荐:缓存整个DataTable
|
❌ 不推荐:缓存整个DataTable
|
||||||
_cache.Set("all-products", largeDataTable);
|
_cache.Set("all-products", largeDataTable);
|
||||||
@@ -572,6 +598,7 @@ _cache.Set("all-products", productDtos);
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 4. 及时清除失效缓存
|
### 4. 及时清除失效缓存
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public async Task UpdateUserAsync(User user) {
|
public async Task UpdateUserAsync(User user) {
|
||||||
await _repository.UpdateAsync(user);
|
await _repository.UpdateAsync(user);
|
||||||
@@ -582,6 +609,7 @@ public async Task UpdateUserAsync(User user) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 5. 使用Result<T>模式
|
### 5. 使用Result<T>模式
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public async Task<Result<Product>> GetProductAsync(int id) {
|
public async Task<Result<Product>> GetProductAsync(int id) {
|
||||||
return await _cache.GetOrSetAsync(
|
return await _cache.GetOrSetAsync(
|
||||||
@@ -602,6 +630,7 @@ public async Task<Result<Product>> GetProductAsync(int id) {
|
|||||||
## 🚀 未来扩展
|
## 🚀 未来扩展
|
||||||
|
|
||||||
### 可能的扩展方向
|
### 可能的扩展方向
|
||||||
|
|
||||||
1. **分布式缓存**(基于 Redis)
|
1. **分布式缓存**(基于 Redis)
|
||||||
2. **多级缓存**(L1: Memory, L2: Redis)
|
2. **多级缓存**(L1: Memory, L2: Redis)
|
||||||
3. **缓存预加载**(后台任务)
|
3. **缓存预加载**(后台任务)
|
||||||
|
|||||||
@@ -13,10 +13,10 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" 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" Version="9.0.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -15,11 +15,9 @@ public sealed class SystemConfig
|
|||||||
[RegularExpression(@"^([0-9]{1,3}\.){3}[0-9]{1,3}$|^localhost$|^[a-zA-Z0-9_.-]+$")]
|
[RegularExpression(@"^([0-9]{1,3}\.){3}[0-9]{1,3}$|^localhost$|^[a-zA-Z0-9_.-]+$")]
|
||||||
public string HTTPIp { get; set; } = "127.0.0.1";
|
public string HTTPIp { get; set; } = "127.0.0.1";
|
||||||
|
|
||||||
[Range(1, 65535)]
|
[Range(1, 65535)] public int HttpPort { get; set; } = 8080;
|
||||||
public int HttpPort { get; set; } = 8080;
|
|
||||||
|
|
||||||
[Range(1, 600)]
|
[Range(1, 600)] public int TimeoutSeconds { get; set; } = 30;
|
||||||
public int TimeoutSeconds { get; set; } = 30;
|
|
||||||
|
|
||||||
public string Scheme { get; set; } = "http";
|
public string Scheme { get; set; } = "http";
|
||||||
}
|
}
|
||||||
@@ -21,20 +21,14 @@ public static class QuickStartExtensions
|
|||||||
// 1. 配置管理(如果没传就用默认)
|
// 1. 配置管理(如果没传就用默认)
|
||||||
// Note: ConfigBuilder.CreateDefault() 在 Config 模块中
|
// Note: ConfigBuilder.CreateDefault() 在 Config 模块中
|
||||||
// 这里假设用户已经传入 configuration 或在外部创建
|
// 这里假设用户已经传入 configuration 或在外部创建
|
||||||
if (configuration != null)
|
if (configuration != null) services.AddSingleton(configuration);
|
||||||
{
|
|
||||||
services.AddSingleton(configuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 缓存服务(使用 Cache 模块的扩展方法)
|
// 2. 缓存服务(使用 Cache 模块的扩展方法)
|
||||||
// Note: AddSharpCache() 在 Cache.DependencyInjection 中定义
|
// Note: AddSharpCache() 在 Cache.DependencyInjection 中定义
|
||||||
services.AddMemoryCache();
|
services.AddMemoryCache();
|
||||||
|
|
||||||
// 3. 全局服务定位器(兼容性)
|
// 3. 全局服务定位器(兼容性)
|
||||||
if (configuration != null)
|
if (configuration != null) RegisterGlobalServices(services, configuration);
|
||||||
{
|
|
||||||
RegisterGlobalServices(services, configuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,5 +34,3 @@ public class ExceptionExpand : Exception
|
|||||||
return $"Error Code: {ErrorCode}\nMessage: {Message}\nSuggestion: {Suggestion}\n{base.ToString()}";
|
return $"Error Code: {ErrorCode}\nMessage: {Message}\nSuggestion: {Suggestion}\n{base.ToString()}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -171,26 +171,17 @@ public static class CollectionExpand
|
|||||||
var nodes = list.Select(item => new TreeNode<T>(item, idSelector(item), parentIdSelector(item))).ToList();
|
var nodes = list.Select(item => new TreeNode<T>(item, idSelector(item), parentIdSelector(item))).ToList();
|
||||||
var lookup = nodes.ToLookup(n => n.ParentId);
|
var lookup = nodes.ToLookup(n => n.ParentId);
|
||||||
|
|
||||||
foreach (var node in nodes)
|
foreach (var node in nodes) node.Children = lookup[node.Id].ToList();
|
||||||
{
|
|
||||||
node.Children = lookup[node.Id].ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建层级深度
|
// 构建层级深度
|
||||||
void SetLevel(TreeNode<T> node, int level)
|
void SetLevel(TreeNode<T> node, int level)
|
||||||
{
|
{
|
||||||
node.Level = level;
|
node.Level = level;
|
||||||
foreach (var child in node.Children)
|
foreach (var child in node.Children) SetLevel(child, level + 1);
|
||||||
{
|
|
||||||
SetLevel(child, level + 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var rootNodes = nodes.Where(n => Equals(n.ParentId, rootParentId)).ToList();
|
var rootNodes = nodes.Where(n => Equals(n.ParentId, rootParentId)).ToList();
|
||||||
foreach (var root in rootNodes)
|
foreach (var root in rootNodes) SetLevel(root, 0);
|
||||||
{
|
|
||||||
SetLevel(root, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rootNodes;
|
return rootNodes;
|
||||||
}
|
}
|
||||||
@@ -210,10 +201,7 @@ public static class CollectionExpand
|
|||||||
foreach (var node in nodes)
|
foreach (var node in nodes)
|
||||||
{
|
{
|
||||||
result.Add(node.Data);
|
result.Add(node.Data);
|
||||||
if (node.Children.Any())
|
if (node.Children.Any()) Traverse(node.Children);
|
||||||
{
|
|
||||||
Traverse(node.Children);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,10 +219,7 @@ public static class CollectionExpand
|
|||||||
foreach (var node in nodes)
|
foreach (var node in nodes)
|
||||||
{
|
{
|
||||||
action(node);
|
action(node);
|
||||||
if (node.Children.Any())
|
if (node.Children.Any()) Traverse(node.Children);
|
||||||
{
|
|
||||||
Traverse(node.Children);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,10 +255,7 @@ public static class CollectionExpand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (batch.Count > 0)
|
if (batch.Count > 0) yield return batch;
|
||||||
{
|
|
||||||
yield return batch;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -281,10 +263,7 @@ public static class CollectionExpand
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static void ForEachBatch<T>(this IEnumerable<T> source, int batchSize, Action<IEnumerable<T>> action)
|
public static void ForEachBatch<T>(this IEnumerable<T> source, int batchSize, Action<IEnumerable<T>> action)
|
||||||
{
|
{
|
||||||
foreach (var batch in source.Batch(batchSize))
|
foreach (var batch in source.Batch(batchSize)) action(batch);
|
||||||
{
|
|
||||||
action(batch);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -333,10 +312,7 @@ public static class CollectionExpand
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static void ForEach<T>(this IEnumerable<T> source, Action<T> action)
|
public static void ForEach<T>(this IEnumerable<T> source, Action<T> action)
|
||||||
{
|
{
|
||||||
foreach (var item in source)
|
foreach (var item in source) action(item);
|
||||||
{
|
|
||||||
action(item);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -344,11 +320,8 @@ public static class CollectionExpand
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static void ForEach<T>(this IEnumerable<T> source, Action<T, int> action)
|
public static void ForEach<T>(this IEnumerable<T> source, Action<T, int> action)
|
||||||
{
|
{
|
||||||
int index = 0;
|
var index = 0;
|
||||||
foreach (var item in source)
|
foreach (var item in source) action(item, index++);
|
||||||
{
|
|
||||||
action(item, index++);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ public static class DateTimeExpand
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static DateTime StartOfWeek(this DateTime dt, DayOfWeek firstDayOfWeek = DayOfWeek.Monday)
|
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;
|
return dt.AddDays(-diff).Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,17 +255,14 @@ public static class DateTimeExpand
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static DateTime AddWorkDays(this DateTime dt, int workDays)
|
public static DateTime AddWorkDays(this DateTime dt, int workDays)
|
||||||
{
|
{
|
||||||
int direction = workDays < 0 ? -1 : 1;
|
var direction = workDays < 0 ? -1 : 1;
|
||||||
int daysToAdd = Math.Abs(workDays);
|
var daysToAdd = Math.Abs(workDays);
|
||||||
DateTime result = dt;
|
var result = dt;
|
||||||
|
|
||||||
while (daysToAdd > 0)
|
while (daysToAdd > 0)
|
||||||
{
|
{
|
||||||
result = result.AddDays(direction);
|
result = result.AddDays(direction);
|
||||||
if (result.DayOfWeek != DayOfWeek.Saturday && result.DayOfWeek != DayOfWeek.Sunday)
|
if (result.DayOfWeek != DayOfWeek.Saturday && result.DayOfWeek != DayOfWeek.Sunday) daysToAdd--;
|
||||||
{
|
|
||||||
daysToAdd--;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -279,14 +276,10 @@ public static class DateTimeExpand
|
|||||||
if (startDate > endDate)
|
if (startDate > endDate)
|
||||||
(startDate, endDate) = (endDate, startDate);
|
(startDate, endDate) = (endDate, startDate);
|
||||||
|
|
||||||
int workDays = 0;
|
var workDays = 0;
|
||||||
for (DateTime date = startDate; date <= endDate; date = date.AddDays(1))
|
for (var date = startDate; date <= endDate; date = date.AddDays(1))
|
||||||
{
|
|
||||||
if (date.DayOfWeek != DayOfWeek.Saturday && date.DayOfWeek != DayOfWeek.Sunday)
|
if (date.DayOfWeek != DayOfWeek.Saturday && date.DayOfWeek != DayOfWeek.Sunday)
|
||||||
{
|
|
||||||
workDays++;
|
workDays++;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return workDays;
|
return workDays;
|
||||||
}
|
}
|
||||||
@@ -297,12 +290,10 @@ public static class DateTimeExpand
|
|||||||
public static int CalculateAge(this DateTime birthDate, DateTime? referenceDate = null)
|
public static int CalculateAge(this DateTime birthDate, DateTime? referenceDate = null)
|
||||||
{
|
{
|
||||||
var reference = referenceDate ?? DateTime.Today;
|
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))
|
if (reference.Month < birthDate.Month ||
|
||||||
{
|
(reference.Month == birthDate.Month && reference.Day < birthDate.Day)) age--;
|
||||||
age--;
|
|
||||||
}
|
|
||||||
|
|
||||||
return age;
|
return age;
|
||||||
}
|
}
|
||||||
@@ -320,8 +311,8 @@ public static class DateTimeExpand
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static DateTime StartOfQuarter(this DateTime dt)
|
public static DateTime StartOfQuarter(this DateTime dt)
|
||||||
{
|
{
|
||||||
int quarter = dt.GetQuarter();
|
var quarter = dt.GetQuarter();
|
||||||
int month = (quarter - 1) * 3 + 1;
|
var month = (quarter - 1) * 3 + 1;
|
||||||
return new DateTime(dt.Year, month, 1, 0, 0, 0, dt.Kind);
|
return new DateTime(dt.Year, month, 1, 0, 0, 0, dt.Kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -277,10 +277,7 @@ public static class StringExpand
|
|||||||
if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(trimString))
|
if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(trimString))
|
||||||
return str ?? string.Empty;
|
return str ?? string.Empty;
|
||||||
|
|
||||||
while (str.StartsWith(trimString))
|
while (str.StartsWith(trimString)) str = str.Substring(trimString.Length);
|
||||||
{
|
|
||||||
str = str.Substring(trimString.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
@@ -293,10 +290,7 @@ public static class StringExpand
|
|||||||
if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(trimString))
|
if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(trimString))
|
||||||
return str ?? string.Empty;
|
return str ?? string.Empty;
|
||||||
|
|
||||||
while (str.EndsWith(trimString))
|
while (str.EndsWith(trimString)) str = str.Substring(0, str.Length - trimString.Length);
|
||||||
{
|
|
||||||
str = str.Substring(0, str.Length - trimString.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
@@ -344,7 +338,7 @@ public static class StringExpand
|
|||||||
var result = new StringBuilder();
|
var result = new StringBuilder();
|
||||||
result.Append(words[0].ToLowerInvariant());
|
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]));
|
result.Append(char.ToUpperInvariant(words[i][0]));
|
||||||
if (words[i].Length > 1)
|
if (words[i].Length > 1)
|
||||||
@@ -620,10 +614,7 @@ public static class StringExpand
|
|||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
|
||||||
var sb = new StringBuilder(str.Length * count);
|
var sb = new StringBuilder(str.Length * count);
|
||||||
for (int i = 0; i < count; i++)
|
for (var i = 0; i < count; i++) sb.Append(str);
|
||||||
{
|
|
||||||
sb.Append(str);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
@@ -686,24 +677,22 @@ public static class StringExpand
|
|||||||
if (string.IsNullOrEmpty(target))
|
if (string.IsNullOrEmpty(target))
|
||||||
return source.Length;
|
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;
|
distance[i, 0] = i;
|
||||||
|
|
||||||
for (int j = 0; j <= target.Length; j++)
|
for (var j = 0; j <= target.Length; j++)
|
||||||
distance[0, j] = 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++)
|
var cost = target[j - 1] == source[i - 1] ? 0 : 1;
|
||||||
{
|
|
||||||
int cost = (target[j - 1] == source[i - 1]) ? 0 : 1;
|
|
||||||
distance[i, j] = Math.Min(
|
distance[i, j] = Math.Min(
|
||||||
Math.Min(distance[i - 1, j] + 1, distance[i, j - 1] + 1),
|
Math.Min(distance[i - 1, j] + 1, distance[i, j - 1] + 1),
|
||||||
distance[i - 1, j - 1] + cost);
|
distance[i - 1, j - 1] + cost);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return distance[source.Length, target.Length];
|
return distance[source.Length, target.Length];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,10 +121,7 @@ public static class Global
|
|||||||
_services[typeof(T)] = descriptor;
|
_services[typeof(T)] = descriptor;
|
||||||
|
|
||||||
// 如果是更新且需要通知,触发变更事件
|
// 如果是更新且需要通知,触发变更事件
|
||||||
if (isUpdate && notifyIfExists)
|
if (isUpdate && notifyIfExists) NotifyChanged(oldValue, instance);
|
||||||
{
|
|
||||||
NotifyChanged(oldValue, instance);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -237,14 +234,9 @@ public static class Global
|
|||||||
{
|
{
|
||||||
// 双重检查锁定模式,保证单例只创建一次
|
// 双重检查锁定模式,保证单例只创建一次
|
||||||
if (descriptor.SingletonInstance == null)
|
if (descriptor.SingletonInstance == null)
|
||||||
{
|
|
||||||
lock (descriptor.Lock)
|
lock (descriptor.Lock)
|
||||||
{
|
{
|
||||||
if (descriptor.SingletonInstance == null)
|
if (descriptor.SingletonInstance == null) descriptor.SingletonInstance = descriptor.Factory();
|
||||||
{
|
|
||||||
descriptor.SingletonInstance = descriptor.Factory();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return descriptor.SingletonInstance;
|
return descriptor.SingletonInstance;
|
||||||
@@ -326,10 +318,7 @@ public static class Global
|
|||||||
|
|
||||||
lock (lockObj)
|
lock (lockObj)
|
||||||
{
|
{
|
||||||
if (_subscribers.TryGetValue(type, out var subscribers))
|
if (_subscribers.TryGetValue(type, out var subscribers)) subscribers.Remove(handler);
|
||||||
{
|
|
||||||
subscribers.Remove(handler);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,20 +333,15 @@ public static class Global
|
|||||||
// 获取订阅者列表的快照,避免在通知过程中锁定
|
// 获取订阅者列表的快照,避免在通知过程中锁定
|
||||||
List<Delegate> subscribersCopy;
|
List<Delegate> subscribersCopy;
|
||||||
if (_subscriberLocks.TryGetValue(type, out var lockObj))
|
if (_subscriberLocks.TryGetValue(type, out var lockObj))
|
||||||
{
|
|
||||||
lock (lockObj)
|
lock (lockObj)
|
||||||
{
|
{
|
||||||
subscribersCopy = new List<Delegate>(subscribers);
|
subscribersCopy = new List<Delegate>(subscribers);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
subscribersCopy = new List<Delegate>(subscribers);
|
subscribersCopy = new List<Delegate>(subscribers);
|
||||||
}
|
|
||||||
|
|
||||||
// 通知所有订阅者
|
// 通知所有订阅者
|
||||||
foreach (var subscriber in subscribersCopy)
|
foreach (var subscriber in subscribersCopy)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
(subscriber as Action<ServiceChangedEventArgs<T>>)?.Invoke(eventArgs);
|
(subscriber as Action<ServiceChangedEventArgs<T>>)?.Invoke(eventArgs);
|
||||||
@@ -368,7 +352,6 @@ public static class Global
|
|||||||
// 在实际应用中,可以记录日志
|
// 在实际应用中,可以记录日志
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|||||||
@@ -812,6 +812,7 @@ Sharp/Common/
|
|||||||
## 版本历史
|
## 版本历史
|
||||||
|
|
||||||
### v1.0.0
|
### v1.0.0
|
||||||
|
|
||||||
- ✅ 轻量级 DI 容器(支持生命周期和变更通知)
|
- ✅ 轻量级 DI 容器(支持生命周期和变更通知)
|
||||||
- ✅ Result 模式(完整的函数式错误处理)
|
- ✅ Result 模式(完整的函数式错误处理)
|
||||||
- ✅ 自定义异常(带错误码)
|
- ✅ 自定义异常(带错误码)
|
||||||
|
|||||||
@@ -27,31 +27,58 @@ public abstract class Result
|
|||||||
ErrorCode = errorCode;
|
ErrorCode = errorCode;
|
||||||
|
|
||||||
// If we have an ExceptionExpand, extract the error code from it
|
// If we have an ExceptionExpand, extract the error code from it
|
||||||
if (exception is ExceptionExpand exceptionExpand && !errorCode.HasValue)
|
if (exception is ExceptionExpand exceptionExpand && !errorCode.HasValue) ErrorCode = exceptionExpand.ErrorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result Success()
|
||||||
{
|
{
|
||||||
ErrorCode = exceptionExpand.ErrorCode;
|
return new SimpleResult(true);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Result Success() => new SimpleResult(true);
|
public static Result<T> Success<T>(T value)
|
||||||
|
{
|
||||||
|
return new Result<T>(value);
|
||||||
|
}
|
||||||
|
|
||||||
public static Result<T> Success<T>(T value) => new Result<T>(value);
|
public static Result Failure(string errorMessage)
|
||||||
|
{
|
||||||
|
return new SimpleResult(false, null, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
public static Result Failure(string errorMessage) => new SimpleResult(false, null, errorMessage);
|
public static Result Failure(Exception exception)
|
||||||
|
{
|
||||||
|
return new SimpleResult(false, exception);
|
||||||
|
}
|
||||||
|
|
||||||
public static Result Failure(Exception exception) => 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) => 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 Failure(long errorCode, string errorMessage, Exception exception) => 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>(string errorMessage) => 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>(Exception exception) => 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) => new Result<T>(default, false, null, errorMessage, errorCode);
|
public static Result<T> Failure<T>(long errorCode, string errorMessage, Exception exception)
|
||||||
|
{
|
||||||
public static Result<T> Failure<T>(long errorCode, string errorMessage, Exception exception) => new Result<T>(default, false, exception, errorMessage, errorCode);
|
return new Result<T>(default, false, exception, errorMessage, errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
public static Result Try(Action action)
|
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)
|
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)
|
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
|
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)
|
: base(isSuccess, exception, errorMessage, errorCode)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -149,31 +177,35 @@ public class Result<T> : Result
|
|||||||
{
|
{
|
||||||
public T? Value { get; }
|
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)
|
: base(isSuccess, exception, errorMessage, errorCode)
|
||||||
{
|
{
|
||||||
Value = value;
|
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)
|
public Result<TNew> Map<TNew>(Func<T, TNew> mapper)
|
||||||
{
|
{
|
||||||
if (IsFailure)
|
if (IsFailure)
|
||||||
{
|
{
|
||||||
if (ErrorCode.HasValue)
|
if (ErrorCode.HasValue)
|
||||||
return Result.Failure<TNew>(ErrorCode.Value, ErrorMessage ?? "Mapping failed", Exception);
|
return Failure<TNew>(ErrorCode.Value, ErrorMessage ?? "Mapping failed", Exception);
|
||||||
return Result.Failure<TNew>(Exception ?? new Exception(ErrorMessage));
|
return Failure<TNew>(Exception ?? new Exception(ErrorMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var mappedValue = mapper(Value!);
|
var mappedValue = mapper(Value!);
|
||||||
return Result.Success(mappedValue);
|
return Success(mappedValue);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Failure<TNew>(ex);
|
return Failure<TNew>(ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,8 +214,8 @@ public class Result<T> : Result
|
|||||||
if (IsFailure)
|
if (IsFailure)
|
||||||
{
|
{
|
||||||
if (ErrorCode.HasValue)
|
if (ErrorCode.HasValue)
|
||||||
return Result.Failure<TNew>(ErrorCode.Value, ErrorMessage ?? "Binding failed", Exception);
|
return Failure<TNew>(ErrorCode.Value, ErrorMessage ?? "Binding failed", Exception);
|
||||||
return Result.Failure<TNew>(Exception ?? new Exception(ErrorMessage));
|
return Failure<TNew>(Exception ?? new Exception(ErrorMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -192,7 +224,7 @@ public class Result<T> : Result
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Failure<TNew>(ex);
|
return Failure<TNew>(ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,10 +57,13 @@ public static class ResultExtensions
|
|||||||
|
|
||||||
public static T GetOrElse<T>(this Result<T> result, Func<Exception?, string?, long?, T> alternativeProvider)
|
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)
|
if (result.IsFailure)
|
||||||
return result;
|
return result;
|
||||||
@@ -68,7 +71,8 @@ public static class ResultExtensions
|
|||||||
return predicate(result.Value!) ? result : Result.Failure<T>(errorMessage);
|
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)
|
if (result.IsFailure)
|
||||||
return result;
|
return result;
|
||||||
@@ -76,7 +80,8 @@ public static class ResultExtensions
|
|||||||
return predicate(result.Value!) ? result : Result.Failure<T>(errorCode, errorMessage);
|
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)
|
if (result.IsFailure)
|
||||||
return result;
|
return result;
|
||||||
@@ -89,7 +94,8 @@ public static class ResultExtensions
|
|||||||
return result.Bind(selector);
|
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)));
|
return result.Bind(t => selector(t).Map(u => resultSelector(t, u)));
|
||||||
}
|
}
|
||||||
@@ -120,7 +126,8 @@ public static class ResultExtensions
|
|||||||
return result;
|
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)
|
if (result.IsSuccess)
|
||||||
onSuccess(result.Value!);
|
onSuccess(result.Value!);
|
||||||
@@ -129,7 +136,8 @@ public static class ResultExtensions
|
|||||||
return result;
|
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)
|
if (result.IsSuccess)
|
||||||
onSuccess(result.Value!);
|
onSuccess(result.Value!);
|
||||||
@@ -172,7 +180,8 @@ public static class ResultExtensions
|
|||||||
return result;
|
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;
|
var result = await resultTask;
|
||||||
if (result.IsFailure)
|
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;
|
var result = await resultTask;
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
@@ -221,7 +231,8 @@ public static class ResultExtensions
|
|||||||
return predicate(result.Value!) ? result : Result.Failure<T>(errorMessage);
|
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)
|
if (result.IsFailure)
|
||||||
return result;
|
return result;
|
||||||
@@ -229,7 +240,8 @@ public static class ResultExtensions
|
|||||||
return predicate(result.Value!) ? result : Result.Failure<T>(errorCode, errorMessage);
|
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)
|
if (result.IsFailure)
|
||||||
return result;
|
return result;
|
||||||
@@ -247,7 +259,8 @@ public static class ResultExtensions
|
|||||||
return result.IsSuccess ? result : alternativeProvider(result.Exception, result.ErrorMessage);
|
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);
|
return result.IsSuccess ? result : alternativeProvider(result.Exception, result.ErrorMessage, result.ErrorCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ public static class CryptoHelper
|
|||||||
throw new ArgumentNullException(nameof(password));
|
throw new ArgumentNullException(nameof(password));
|
||||||
|
|
||||||
// 生成随机 Salt
|
// 生成随机 Salt
|
||||||
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
var salt = RandomNumberGenerator.GetBytes(16);
|
||||||
|
|
||||||
// 从密码派生密钥
|
// 从密码派生密钥
|
||||||
using var deriveBytes = new Rfc2898DeriveBytes(password, salt, 10000, HashAlgorithmName.SHA256);
|
using var deriveBytes = new Rfc2898DeriveBytes(password, salt, 10000, HashAlgorithmName.SHA256);
|
||||||
byte[] key = deriveBytes.GetBytes(32); // 256 bit
|
var key = deriveBytes.GetBytes(32); // 256 bit
|
||||||
byte[] iv = deriveBytes.GetBytes(16); // 128 bit
|
var iv = deriveBytes.GetBytes(16); // 128 bit
|
||||||
|
|
||||||
// AES 加密
|
// AES 加密
|
||||||
using var aes = Aes.Create();
|
using var aes = Aes.Create();
|
||||||
@@ -38,11 +38,11 @@ public static class CryptoHelper
|
|||||||
aes.IV = iv;
|
aes.IV = iv;
|
||||||
|
|
||||||
using var encryptor = aes.CreateEncryptor();
|
using var encryptor = aes.CreateEncryptor();
|
||||||
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
|
var plainBytes = Encoding.UTF8.GetBytes(plainText);
|
||||||
byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
|
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
|
||||||
|
|
||||||
// 组合 Salt + 密文
|
// 组合 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(salt, 0, result, 0, salt.Length);
|
||||||
Buffer.BlockCopy(cipherBytes, 0, result, salt.Length, cipherBytes.Length);
|
Buffer.BlockCopy(cipherBytes, 0, result, salt.Length, cipherBytes.Length);
|
||||||
|
|
||||||
@@ -62,20 +62,20 @@ public static class CryptoHelper
|
|||||||
if (string.IsNullOrEmpty(password))
|
if (string.IsNullOrEmpty(password))
|
||||||
throw new ArgumentNullException(nameof(password));
|
throw new ArgumentNullException(nameof(password));
|
||||||
|
|
||||||
byte[] fullCipher = Convert.FromBase64String(cipherText);
|
var fullCipher = Convert.FromBase64String(cipherText);
|
||||||
|
|
||||||
// 提取 Salt
|
// 提取 Salt
|
||||||
byte[] salt = new byte[16];
|
var salt = new byte[16];
|
||||||
Buffer.BlockCopy(fullCipher, 0, salt, 0, 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);
|
Buffer.BlockCopy(fullCipher, 16, cipherBytes, 0, cipherBytes.Length);
|
||||||
|
|
||||||
// 从密码派生密钥
|
// 从密码派生密钥
|
||||||
using var deriveBytes = new Rfc2898DeriveBytes(password, salt, 10000, HashAlgorithmName.SHA256);
|
using var deriveBytes = new Rfc2898DeriveBytes(password, salt, 10000, HashAlgorithmName.SHA256);
|
||||||
byte[] key = deriveBytes.GetBytes(32);
|
var key = deriveBytes.GetBytes(32);
|
||||||
byte[] iv = deriveBytes.GetBytes(16);
|
var iv = deriveBytes.GetBytes(16);
|
||||||
|
|
||||||
// AES 解密
|
// AES 解密
|
||||||
using var aes = Aes.Create();
|
using var aes = Aes.Create();
|
||||||
@@ -83,7 +83,7 @@ public static class CryptoHelper
|
|||||||
aes.IV = iv;
|
aes.IV = iv;
|
||||||
|
|
||||||
using var decryptor = aes.CreateDecryptor();
|
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);
|
return Encoding.UTF8.GetString(plainBytes);
|
||||||
}
|
}
|
||||||
@@ -121,8 +121,8 @@ public static class CryptoHelper
|
|||||||
using var rsa = RSA.Create();
|
using var rsa = RSA.Create();
|
||||||
rsa.ImportRSAPublicKey(Convert.FromBase64String(publicKey), out _);
|
rsa.ImportRSAPublicKey(Convert.FromBase64String(publicKey), out _);
|
||||||
|
|
||||||
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
|
var plainBytes = Encoding.UTF8.GetBytes(plainText);
|
||||||
byte[] cipherBytes = rsa.Encrypt(plainBytes, RSAEncryptionPadding.OaepSHA256);
|
var cipherBytes = rsa.Encrypt(plainBytes, RSAEncryptionPadding.OaepSHA256);
|
||||||
|
|
||||||
return Convert.ToBase64String(cipherBytes);
|
return Convert.ToBase64String(cipherBytes);
|
||||||
}
|
}
|
||||||
@@ -143,8 +143,8 @@ public static class CryptoHelper
|
|||||||
using var rsa = RSA.Create();
|
using var rsa = RSA.Create();
|
||||||
rsa.ImportRSAPrivateKey(Convert.FromBase64String(privateKey), out _);
|
rsa.ImportRSAPrivateKey(Convert.FromBase64String(privateKey), out _);
|
||||||
|
|
||||||
byte[] cipherBytes = Convert.FromBase64String(cipherText);
|
var cipherBytes = Convert.FromBase64String(cipherText);
|
||||||
byte[] plainBytes = rsa.Decrypt(cipherBytes, RSAEncryptionPadding.OaepSHA256);
|
var plainBytes = rsa.Decrypt(cipherBytes, RSAEncryptionPadding.OaepSHA256);
|
||||||
|
|
||||||
return Encoding.UTF8.GetString(plainBytes);
|
return Encoding.UTF8.GetString(plainBytes);
|
||||||
}
|
}
|
||||||
@@ -164,8 +164,8 @@ public static class CryptoHelper
|
|||||||
throw new ArgumentNullException(nameof(input));
|
throw new ArgumentNullException(nameof(input));
|
||||||
|
|
||||||
using var md5 = MD5.Create();
|
using var md5 = MD5.Create();
|
||||||
byte[] inputBytes = Encoding.UTF8.GetBytes(input);
|
var inputBytes = Encoding.UTF8.GetBytes(input);
|
||||||
byte[] hashBytes = md5.ComputeHash(inputBytes);
|
var hashBytes = md5.ComputeHash(inputBytes);
|
||||||
|
|
||||||
return BytesToHex(hashBytes);
|
return BytesToHex(hashBytes);
|
||||||
}
|
}
|
||||||
@@ -181,8 +181,8 @@ public static class CryptoHelper
|
|||||||
throw new ArgumentNullException(nameof(input));
|
throw new ArgumentNullException(nameof(input));
|
||||||
|
|
||||||
using var sha256 = SHA256.Create();
|
using var sha256 = SHA256.Create();
|
||||||
byte[] inputBytes = Encoding.UTF8.GetBytes(input);
|
var inputBytes = Encoding.UTF8.GetBytes(input);
|
||||||
byte[] hashBytes = sha256.ComputeHash(inputBytes);
|
var hashBytes = sha256.ComputeHash(inputBytes);
|
||||||
|
|
||||||
return BytesToHex(hashBytes);
|
return BytesToHex(hashBytes);
|
||||||
}
|
}
|
||||||
@@ -198,8 +198,8 @@ public static class CryptoHelper
|
|||||||
throw new ArgumentNullException(nameof(input));
|
throw new ArgumentNullException(nameof(input));
|
||||||
|
|
||||||
using var sha512 = SHA512.Create();
|
using var sha512 = SHA512.Create();
|
||||||
byte[] inputBytes = Encoding.UTF8.GetBytes(input);
|
var inputBytes = Encoding.UTF8.GetBytes(input);
|
||||||
byte[] hashBytes = sha512.ComputeHash(inputBytes);
|
var hashBytes = sha512.ComputeHash(inputBytes);
|
||||||
|
|
||||||
return BytesToHex(hashBytes);
|
return BytesToHex(hashBytes);
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ public static class CryptoHelper
|
|||||||
if (string.IsNullOrEmpty(input))
|
if (string.IsNullOrEmpty(input))
|
||||||
throw new ArgumentNullException(nameof(input));
|
throw new ArgumentNullException(nameof(input));
|
||||||
|
|
||||||
byte[] bytes = Encoding.UTF8.GetBytes(input);
|
var bytes = Encoding.UTF8.GetBytes(input);
|
||||||
return Convert.ToBase64String(bytes);
|
return Convert.ToBase64String(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ public static class CryptoHelper
|
|||||||
if (string.IsNullOrEmpty(input))
|
if (string.IsNullOrEmpty(input))
|
||||||
throw new ArgumentNullException(nameof(input));
|
throw new ArgumentNullException(nameof(input));
|
||||||
|
|
||||||
byte[] bytes = Convert.FromBase64String(input);
|
var bytes = Convert.FromBase64String(input);
|
||||||
return Encoding.UTF8.GetString(bytes);
|
return Encoding.UTF8.GetString(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,11 +293,11 @@ public static class CryptoHelper
|
|||||||
throw new ArgumentNullException(nameof(password));
|
throw new ArgumentNullException(nameof(password));
|
||||||
|
|
||||||
// 生成随机 Salt
|
// 生成随机 Salt
|
||||||
byte[] salt = RandomNumberGenerator.GetBytes(saltSize);
|
var salt = RandomNumberGenerator.GetBytes(saltSize);
|
||||||
|
|
||||||
// 使用 PBKDF2 计算哈希
|
// 使用 PBKDF2 计算哈希
|
||||||
using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256);
|
using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256);
|
||||||
byte[] hash = pbkdf2.GetBytes(hashSize);
|
var hash = pbkdf2.GetBytes(hashSize);
|
||||||
|
|
||||||
// 格式: $pbkdf2-sha256$迭代次数$Salt$Hash
|
// 格式: $pbkdf2-sha256$迭代次数$Salt$Hash
|
||||||
return $"$pbkdf2-sha256${iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(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")
|
if (parts.Length != 5 || parts[1] != "pbkdf2-sha256")
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
int iterations = int.Parse(parts[2]);
|
var iterations = int.Parse(parts[2]);
|
||||||
byte[] salt = Convert.FromBase64String(parts[3]);
|
var salt = Convert.FromBase64String(parts[3]);
|
||||||
byte[] storedHash = Convert.FromBase64String(parts[4]);
|
var storedHash = Convert.FromBase64String(parts[4]);
|
||||||
|
|
||||||
// 计算输入密码的哈希
|
// 计算输入密码的哈希
|
||||||
using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256);
|
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);
|
return CryptographicOperations.FixedTimeEquals(storedHash, computedHash);
|
||||||
@@ -357,11 +357,11 @@ public static class CryptoHelper
|
|||||||
if (string.IsNullOrEmpty(secret))
|
if (string.IsNullOrEmpty(secret))
|
||||||
throw new ArgumentNullException(nameof(secret));
|
throw new ArgumentNullException(nameof(secret));
|
||||||
|
|
||||||
byte[] keyBytes = Encoding.UTF8.GetBytes(secret);
|
var keyBytes = Encoding.UTF8.GetBytes(secret);
|
||||||
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
|
var messageBytes = Encoding.UTF8.GetBytes(message);
|
||||||
|
|
||||||
using var hmac = new HMACSHA256(keyBytes);
|
using var hmac = new HMACSHA256(keyBytes);
|
||||||
byte[] hashBytes = hmac.ComputeHash(messageBytes);
|
var hashBytes = hmac.ComputeHash(messageBytes);
|
||||||
|
|
||||||
return BytesToHex(hashBytes);
|
return BytesToHex(hashBytes);
|
||||||
}
|
}
|
||||||
@@ -399,11 +399,11 @@ public static class CryptoHelper
|
|||||||
if (string.IsNullOrEmpty(secret))
|
if (string.IsNullOrEmpty(secret))
|
||||||
throw new ArgumentNullException(nameof(secret));
|
throw new ArgumentNullException(nameof(secret));
|
||||||
|
|
||||||
byte[] keyBytes = Encoding.UTF8.GetBytes(secret);
|
var keyBytes = Encoding.UTF8.GetBytes(secret);
|
||||||
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
|
var messageBytes = Encoding.UTF8.GetBytes(message);
|
||||||
|
|
||||||
using var hmac = new HMACSHA512(keyBytes);
|
using var hmac = new HMACSHA512(keyBytes);
|
||||||
byte[] hashBytes = hmac.ComputeHash(messageBytes);
|
var hashBytes = hmac.ComputeHash(messageBytes);
|
||||||
|
|
||||||
return BytesToHex(hashBytes);
|
return BytesToHex(hashBytes);
|
||||||
}
|
}
|
||||||
@@ -449,10 +449,7 @@ public static class CryptoHelper
|
|||||||
chars += special;
|
chars += special;
|
||||||
|
|
||||||
var result = new char[length];
|
var result = new char[length];
|
||||||
for (int i = 0; i < length; i++)
|
for (var i = 0; i < length; i++) result[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
|
||||||
{
|
|
||||||
result[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return new string(result);
|
return new string(result);
|
||||||
}
|
}
|
||||||
@@ -490,16 +487,19 @@ public static class CryptoHelper
|
|||||||
chars += lowercase;
|
chars += lowercase;
|
||||||
password.Add(lowercase[RandomNumberGenerator.GetInt32(lowercase.Length)]);
|
password.Add(lowercase[RandomNumberGenerator.GetInt32(lowercase.Length)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireUppercase)
|
if (requireUppercase)
|
||||||
{
|
{
|
||||||
chars += uppercase;
|
chars += uppercase;
|
||||||
password.Add(uppercase[RandomNumberGenerator.GetInt32(uppercase.Length)]);
|
password.Add(uppercase[RandomNumberGenerator.GetInt32(uppercase.Length)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireDigit)
|
if (requireDigit)
|
||||||
{
|
{
|
||||||
chars += digits;
|
chars += digits;
|
||||||
password.Add(digits[RandomNumberGenerator.GetInt32(digits.Length)]);
|
password.Add(digits[RandomNumberGenerator.GetInt32(digits.Length)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireSpecialChar)
|
if (requireSpecialChar)
|
||||||
{
|
{
|
||||||
chars += special;
|
chars += special;
|
||||||
@@ -507,15 +507,12 @@ public static class CryptoHelper
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 填充剩余长度
|
// 填充剩余长度
|
||||||
for (int i = password.Count; i < length; i++)
|
for (var i = password.Count; i < length; i++) password.Add(chars[RandomNumberGenerator.GetInt32(chars.Length)]);
|
||||||
{
|
|
||||||
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]);
|
(password[i], password[j]) = (password[j], password[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,13 +87,9 @@ public static class EnumHelper
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
foreach (var field in typeof(TEnum).GetFields())
|
foreach (var field in typeof(TEnum).GetFields())
|
||||||
{
|
|
||||||
if (field.GetCustomAttribute<DescriptionAttribute>() is { } attribute)
|
if (field.GetCustomAttribute<DescriptionAttribute>() is { } attribute)
|
||||||
{
|
|
||||||
if (attribute.Description.Equals(description, StringComparison.OrdinalIgnoreCase))
|
if (attribute.Description.Equals(description, StringComparison.OrdinalIgnoreCase))
|
||||||
return (TEnum)field.GetValue(null)!;
|
return (TEnum)field.GetValue(null)!;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -229,8 +225,8 @@ public static class EnumHelper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static TEnum AddFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum
|
public static TEnum AddFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum
|
||||||
{
|
{
|
||||||
int intValue = Convert.ToInt32(value);
|
var intValue = Convert.ToInt32(value);
|
||||||
int intFlag = Convert.ToInt32(flag);
|
var intFlag = Convert.ToInt32(flag);
|
||||||
return (TEnum)(object)(intValue | intFlag);
|
return (TEnum)(object)(intValue | intFlag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,8 +235,8 @@ public static class EnumHelper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static TEnum RemoveFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum
|
public static TEnum RemoveFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum
|
||||||
{
|
{
|
||||||
int intValue = Convert.ToInt32(value);
|
var intValue = Convert.ToInt32(value);
|
||||||
int intFlag = Convert.ToInt32(flag);
|
var intFlag = Convert.ToInt32(flag);
|
||||||
return (TEnum)(object)(intValue & ~intFlag);
|
return (TEnum)(object)(intValue & ~intFlag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,8 +245,8 @@ public static class EnumHelper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static TEnum ToggleFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum
|
public static TEnum ToggleFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum
|
||||||
{
|
{
|
||||||
int intValue = Convert.ToInt32(value);
|
var intValue = Convert.ToInt32(value);
|
||||||
int intFlag = Convert.ToInt32(flag);
|
var intFlag = Convert.ToInt32(flag);
|
||||||
return (TEnum)(object)(intValue ^ intFlag);
|
return (TEnum)(object)(intValue ^ intFlag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,23 +255,18 @@ public static class EnumHelper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static IEnumerable<TEnum> GetFlags<TEnum>(this TEnum value) where TEnum : struct, Enum
|
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))
|
if (value.HasFlag(flag))
|
||||||
yield return flag;
|
yield return flag;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 从多个标志位组合成枚举值
|
/// 从多个标志位组合成枚举值
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static TEnum CombineFlags<TEnum>(params TEnum[] flags) where TEnum : struct, Enum
|
public static TEnum CombineFlags<TEnum>(params TEnum[] flags) where TEnum : struct, Enum
|
||||||
{
|
{
|
||||||
int result = 0;
|
var result = 0;
|
||||||
foreach (var flag in flags)
|
foreach (var flag in flags) result |= Convert.ToInt32(flag);
|
||||||
{
|
|
||||||
result |= Convert.ToInt32(flag);
|
|
||||||
}
|
|
||||||
return (TEnum)(object)result;
|
return (TEnum)(object)result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -276,14 +276,12 @@ public static class FileHelper
|
|||||||
|
|
||||||
// 递归复制子目录
|
// 递归复制子目录
|
||||||
if (recursive)
|
if (recursive)
|
||||||
{
|
|
||||||
foreach (var subDir in dir.GetDirectories())
|
foreach (var subDir in dir.GetDirectories())
|
||||||
{
|
{
|
||||||
var targetDirPath = Path.Combine(destDir, subDir.Name);
|
var targetDirPath = Path.Combine(destDir, subDir.Name);
|
||||||
CopyDirectory(subDir.FullName, targetDirPath, true);
|
CopyDirectory(subDir.FullName, targetDirPath, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 移动目录
|
/// 移动目录
|
||||||
@@ -351,10 +349,7 @@ public static class FileHelper
|
|||||||
return Enumerable.Empty<string>();
|
return Enumerable.Empty<string>();
|
||||||
|
|
||||||
var files = new List<string>();
|
var files = new List<string>();
|
||||||
foreach (var pattern in searchPatterns)
|
foreach (var pattern in searchPatterns) files.AddRange(GetFiles(directoryPath, pattern, recursive));
|
||||||
{
|
|
||||||
files.AddRange(GetFiles(directoryPath, pattern, recursive));
|
|
||||||
}
|
|
||||||
|
|
||||||
return files.Distinct();
|
return files.Distinct();
|
||||||
}
|
}
|
||||||
@@ -664,7 +659,8 @@ public static class FileHelper
|
|||||||
public static bool IsTextFile(string filePath)
|
public static bool IsTextFile(string filePath)
|
||||||
{
|
{
|
||||||
var extension = GetExtension(filePath).ToLowerInvariant();
|
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);
|
return textExtensions.Contains(extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,16 +752,14 @@ public static class FileHelper
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var files = GetFiles(directoryPath, searchPattern, recursive);
|
var files = GetFiles(directoryPath, searchPattern, recursive);
|
||||||
foreach (var file in files)
|
foreach (var file in files) DeleteFile(file);
|
||||||
{
|
|
||||||
DeleteFile(file);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 删除超过指定天数的文件
|
/// 删除超过指定天数的文件
|
||||||
/// </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))
|
if (string.IsNullOrWhiteSpace(directoryPath))
|
||||||
throw new ArgumentNullException(nameof(directoryPath));
|
throw new ArgumentNullException(nameof(directoryPath));
|
||||||
@@ -778,13 +772,11 @@ public static class FileHelper
|
|||||||
var deletedCount = 0;
|
var deletedCount = 0;
|
||||||
|
|
||||||
foreach (var file in files)
|
foreach (var file in files)
|
||||||
{
|
|
||||||
if (GetLastWriteTime(file) < cutoffDate)
|
if (GetLastWriteTime(file) < cutoffDate)
|
||||||
{
|
{
|
||||||
DeleteFile(file);
|
DeleteFile(file);
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return deletedCount;
|
return deletedCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public static class JsonHelper
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 驼峰命名选项(默认格式化输出)
|
/// 驼峰命名选项(默认格式化输出)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly JsonSerializerOptions CamelCaseOptions = new JsonSerializerOptions
|
public static readonly JsonSerializerOptions CamelCaseOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
@@ -25,14 +25,14 @@ public static class JsonHelper
|
|||||||
Converters =
|
Converters =
|
||||||
{
|
{
|
||||||
new IpAddressConverter(),
|
new IpAddressConverter(),
|
||||||
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)
|
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, false)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 驼峰命名选项(紧凑格式,不格式化输出)
|
/// 驼峰命名选项(紧凑格式,不格式化输出)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly JsonSerializerOptions CamelCaseCompactOptions = new JsonSerializerOptions
|
public static readonly JsonSerializerOptions CamelCaseCompactOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
WriteIndented = false,
|
WriteIndented = false,
|
||||||
@@ -41,14 +41,14 @@ public static class JsonHelper
|
|||||||
Converters =
|
Converters =
|
||||||
{
|
{
|
||||||
new IpAddressConverter(),
|
new IpAddressConverter(),
|
||||||
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)
|
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, false)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 蛇形命名选项(snake_case)
|
/// 蛇形命名选项(snake_case)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly JsonSerializerOptions SnakeCaseOptions = new JsonSerializerOptions
|
public static readonly JsonSerializerOptions SnakeCaseOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
@@ -56,14 +56,14 @@ public static class JsonHelper
|
|||||||
Converters =
|
Converters =
|
||||||
{
|
{
|
||||||
new IpAddressConverter(),
|
new IpAddressConverter(),
|
||||||
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower, allowIntegerValues: false)
|
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower, false)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 默认选项(保留原始命名,格式化输出)
|
/// 默认选项(保留原始命名,格式化输出)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly JsonSerializerOptions DefaultOptions = new JsonSerializerOptions
|
public static readonly JsonSerializerOptions DefaultOptions = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
@@ -71,7 +71,7 @@ public static class JsonHelper
|
|||||||
{
|
{
|
||||||
new IpAddressConverter(),
|
new IpAddressConverter(),
|
||||||
new JsonStringEnumConverter(allowIntegerValues: false)
|
new JsonStringEnumConverter(allowIntegerValues: false)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -191,7 +191,8 @@ public static class JsonHelper
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 序列化对象到流
|
/// 序列化对象到流
|
||||||
/// </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;
|
var options = useCompact ? CamelCaseCompactOptions : CamelCaseOptions;
|
||||||
await JsonSerializer.SerializeAsync(stream, value, options, cancellationToken);
|
await JsonSerializer.SerializeAsync(stream, value, options, cancellationToken);
|
||||||
@@ -200,7 +201,8 @@ public static class JsonHelper
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 从流反序列化对象
|
/// 从流反序列化对象
|
||||||
/// </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);
|
return await JsonSerializer.DeserializeAsync<T>(stream, CamelCaseOptions, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -287,16 +289,10 @@ public static class JsonHelper
|
|||||||
var element = doc.RootElement;
|
var element = doc.RootElement;
|
||||||
|
|
||||||
foreach (var key in path.Split('.'))
|
foreach (var key in path.Split('.'))
|
||||||
{
|
|
||||||
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(key, out var property))
|
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(key, out var property))
|
||||||
{
|
|
||||||
element = property;
|
element = property;
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return element.ToString();
|
return element.ToString();
|
||||||
}
|
}
|
||||||
@@ -345,16 +341,10 @@ public static class JsonHelper
|
|||||||
var merged = new Dictionary<string, JsonElement>();
|
var merged = new Dictionary<string, JsonElement>();
|
||||||
|
|
||||||
// 添加第一个 JSON 的所有属性
|
// 添加第一个 JSON 的所有属性
|
||||||
foreach (var property in doc1.RootElement.EnumerateObject())
|
foreach (var property in doc1.RootElement.EnumerateObject()) merged[property.Name] = property.Value.Clone();
|
||||||
{
|
|
||||||
merged[property.Name] = property.Value.Clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用第二个 JSON 的属性覆盖
|
// 用第二个 JSON 的属性覆盖
|
||||||
foreach (var property in doc2.RootElement.EnumerateObject())
|
foreach (var property in doc2.RootElement.EnumerateObject()) merged[property.Name] = property.Value.Clone();
|
||||||
{
|
|
||||||
merged[property.Name] = property.Value.Clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
return JsonSerializer.Serialize(merged, CamelCaseOptions);
|
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)
|
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);
|
return IPAddress.Parse(ipString);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -332,9 +332,7 @@ public static class ReflectionHelper
|
|||||||
);
|
);
|
||||||
|
|
||||||
foreach (var sourceProp in sourceProperties)
|
foreach (var sourceProp in sourceProperties)
|
||||||
{
|
|
||||||
if (targetProperties.TryGetValue(sourceProp.Name, out var targetProp))
|
if (targetProperties.TryGetValue(sourceProp.Name, out var targetProp))
|
||||||
{
|
|
||||||
// 检查类型是否兼容
|
// 检查类型是否兼容
|
||||||
if (targetProp.PropertyType.IsAssignableFrom(sourceProp.PropertyType))
|
if (targetProp.PropertyType.IsAssignableFrom(sourceProp.PropertyType))
|
||||||
{
|
{
|
||||||
@@ -342,8 +340,6 @@ public static class ReflectionHelper
|
|||||||
targetProp.SetValue(target, value);
|
targetProp.SetValue(target, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 创建目标类型实例并复制属性
|
/// 创建目标类型实例并复制属性
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ public static class SystemHelper
|
|||||||
// todo: 2025/6/24 15:53 开发过程中使用固定端口
|
// todo: 2025/6/24 15:53 开发过程中使用固定端口
|
||||||
// return 8267;
|
// return 8267;
|
||||||
|
|
||||||
TcpListener listener = new TcpListener(IPAddress.Loopback, 0); // 端口号为0表示由系统自动分配
|
var listener = new TcpListener(IPAddress.Loopback, 0); // 端口号为0表示由系统自动分配
|
||||||
listener.Start();
|
listener.Start();
|
||||||
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
listener.Stop();
|
listener.Stop();
|
||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
@@ -33,9 +33,7 @@ public static class SystemHelper
|
|||||||
public static bool IsAdministrator()
|
public static bool IsAdministrator()
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
if (!OperatingSystem.IsWindows())
|
||||||
{
|
|
||||||
throw new PlatformNotSupportedException("IsAdministrator method is only supported on Windows platform.");
|
throw new PlatformNotSupportedException("IsAdministrator method is only supported on Windows platform.");
|
||||||
}
|
|
||||||
|
|
||||||
var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
|
var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
|
||||||
var principal = new System.Security.Principal.WindowsPrincipal(identity);
|
var principal = new System.Security.Principal.WindowsPrincipal(identity);
|
||||||
|
|||||||
@@ -158,8 +158,8 @@ public static class ValidationHelper
|
|||||||
// 校验码对应值
|
// 校验码对应值
|
||||||
char[] checkCodes = { '1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2' };
|
char[] checkCodes = { '1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2' };
|
||||||
|
|
||||||
int sum = 0;
|
var sum = 0;
|
||||||
for (int i = 0; i < 17; i++)
|
for (var i = 0; i < 17; i++)
|
||||||
{
|
{
|
||||||
if (!char.IsDigit(idCard[i]))
|
if (!char.IsDigit(idCard[i]))
|
||||||
return false;
|
return false;
|
||||||
@@ -167,9 +167,9 @@ public static class ValidationHelper
|
|||||||
sum += (idCard[i] - '0') * weights[i];
|
sum += (idCard[i] - '0') * weights[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
int mod = sum % 11;
|
var mod = sum % 11;
|
||||||
char expectedCheckCode = checkCodes[mod];
|
var expectedCheckCode = checkCodes[mod];
|
||||||
char actualCheckCode = char.ToUpperInvariant(idCard[17]);
|
var actualCheckCode = char.ToUpperInvariant(idCard[17]);
|
||||||
|
|
||||||
return expectedCheckCode == actualCheckCode;
|
return expectedCheckCode == actualCheckCode;
|
||||||
}
|
}
|
||||||
@@ -186,9 +186,9 @@ public static class ValidationHelper
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
int year = int.Parse(idCard.Substring(6, 4));
|
var year = int.Parse(idCard.Substring(6, 4));
|
||||||
int month = int.Parse(idCard.Substring(10, 2));
|
var month = int.Parse(idCard.Substring(10, 2));
|
||||||
int day = int.Parse(idCard.Substring(12, 2));
|
var day = int.Parse(idCard.Substring(12, 2));
|
||||||
|
|
||||||
return new DateTime(year, month, day);
|
return new DateTime(year, month, day);
|
||||||
}
|
}
|
||||||
@@ -210,7 +210,7 @@ public static class ValidationHelper
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
int genderCode = int.Parse(idCard.Substring(16, 1));
|
var genderCode = int.Parse(idCard.Substring(16, 1));
|
||||||
return genderCode % 2 == 0 ? "Female" : "Male";
|
return genderCode % 2 == 0 ? "Female" : "Male";
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -281,7 +281,8 @@ public static class ValidationHelper
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证浮点数在指定范围内(Result 模式)
|
/// 验证浮点数在指定范围内(Result 模式)
|
||||||
/// </summary>
|
/// </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)
|
if (value < min || value > max)
|
||||||
return Result.Failure<double>(errorCode, $"{fieldName}必须在 {min} 到 {max} 之间");
|
return Result.Failure<double>(errorCode, $"{fieldName}必须在 {min} 到 {max} 之间");
|
||||||
@@ -302,7 +303,8 @@ public static class ValidationHelper
|
|||||||
/// <param name="fieldName">字段名称</param>
|
/// <param name="fieldName">字段名称</param>
|
||||||
/// <param name="errorCode">错误码(默认 400)</param>
|
/// <param name="errorCode">错误码(默认 400)</param>
|
||||||
/// <returns>验证结果</returns>
|
/// <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))
|
if (string.IsNullOrEmpty(value))
|
||||||
return Result.Failure<string>(errorCode, $"{fieldName}不能为空");
|
return Result.Failure<string>(errorCode, $"{fieldName}不能为空");
|
||||||
@@ -420,7 +422,8 @@ public static class ValidationHelper
|
|||||||
/// <param name="errorMessage">错误消息</param>
|
/// <param name="errorMessage">错误消息</param>
|
||||||
/// <param name="errorCode">错误码(默认 400)</param>
|
/// <param name="errorCode">错误码(默认 400)</param>
|
||||||
/// <returns>验证结果</returns>
|
/// <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))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
return Result.Failure<string>(errorCode, "值不能为空");
|
return Result.Failure<string>(errorCode, "值不能为空");
|
||||||
|
|||||||
@@ -13,18 +13,18 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1" />
|
<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.Json" Version="8.0.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Xml" 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.Ini" Version="8.0.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" 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="Microsoft.Extensions.FileProviders.Physical" Version="8.0.0"/>
|
||||||
<PackageReference Include="Tomlyn" Version="0.19.0" />
|
<PackageReference Include="Tomlyn" Version="0.19.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -29,9 +29,7 @@ public static class ConfigBuilder
|
|||||||
foreach (var f in files)
|
foreach (var f in files)
|
||||||
{
|
{
|
||||||
if (!ConfigSource.TryGuess(f, out var src))
|
if (!ConfigSource.TryGuess(f, out var src))
|
||||||
{
|
|
||||||
throw new NotSupportedException($"Unsupported config extension: {f}");
|
throw new NotSupportedException($"Unsupported config extension: {f}");
|
||||||
}
|
|
||||||
opt.Sources.Add(src);
|
opt.Sources.Add(src);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,18 +27,15 @@ public static class ConfigBuilderExtensions
|
|||||||
|
|
||||||
var envConfigFile = $"appsettings.{environment}.json";
|
var envConfigFile = $"appsettings.{environment}.json";
|
||||||
var envConfigPath = Path.Combine(basePath, envConfigFile);
|
var envConfigPath = Path.Combine(basePath, envConfigFile);
|
||||||
if (File.Exists(envConfigPath))
|
if (File.Exists(envConfigPath)) configFiles.Add(envConfigFile);
|
||||||
{
|
|
||||||
configFiles.Add(envConfigFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建配置
|
// 构建配置
|
||||||
return ConfigBuilder.BuildFromFiles(
|
return ConfigBuilder.BuildFromFiles(
|
||||||
configFiles.ToArray(),
|
configFiles.ToArray(),
|
||||||
reloadOnChange: true,
|
true,
|
||||||
includeEnvironment: true,
|
true,
|
||||||
environmentPrefix: environmentPrefix,
|
environmentPrefix,
|
||||||
basePath: basePath
|
basePath
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +48,8 @@ public static class ConfigBuilderExtensions
|
|||||||
|
|
||||||
return ConfigBuilder.BuildFromFiles(
|
return ConfigBuilder.BuildFromFiles(
|
||||||
new[] { "appsettings.json" },
|
new[] { "appsettings.json" },
|
||||||
reloadOnChange: false,
|
false,
|
||||||
includeEnvironment: false,
|
false,
|
||||||
basePath: basePath
|
basePath: basePath
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ namespace Config;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 描述默认配置构建时的约定。
|
/// 描述默认配置构建时的约定。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ConfigDefaultsOptions
|
public sealed class ConfigDefaultsOptions
|
||||||
{
|
{
|
||||||
private readonly IList<ConfigSource> _fluentSources = new List<ConfigSource>();
|
private readonly IList<ConfigSource> _fluentSources = new List<ConfigSource>();
|
||||||
|
|
||||||
public ConfigDefaultsOptions()
|
public ConfigDefaultsOptions()
|
||||||
@@ -66,26 +66,34 @@ namespace Config;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 通过流式 API 添加 JSON 配置文件(默认 optional)。
|
/// 通过流式 API 添加 JSON 配置文件(默认 optional)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ConfigDefaultsOptions WithJson(string path, bool optional = true) =>
|
public ConfigDefaultsOptions WithJson(string path, bool optional = true)
|
||||||
AddFluentSource(path, ConfigFormat.Json, optional);
|
{
|
||||||
|
return AddFluentSource(path, ConfigFormat.Json, optional);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 通过流式 API 添加 INI 配置文件(默认 optional)。
|
/// 通过流式 API 添加 INI 配置文件(默认 optional)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ConfigDefaultsOptions WithIni(string path, bool optional = true) =>
|
public ConfigDefaultsOptions WithIni(string path, bool optional = true)
|
||||||
AddFluentSource(path, ConfigFormat.Ini, optional);
|
{
|
||||||
|
return AddFluentSource(path, ConfigFormat.Ini, optional);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 通过流式 API 添加 XML 配置文件(默认 optional)。
|
/// 通过流式 API 添加 XML 配置文件(默认 optional)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ConfigDefaultsOptions WithXml(string path, bool optional = true) =>
|
public ConfigDefaultsOptions WithXml(string path, bool optional = true)
|
||||||
AddFluentSource(path, ConfigFormat.Xml, optional);
|
{
|
||||||
|
return AddFluentSource(path, ConfigFormat.Xml, optional);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 通过流式 API 添加 TOML 配置文件(默认 optional)。
|
/// 通过流式 API 添加 TOML 配置文件(默认 optional)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ConfigDefaultsOptions WithToml(string path, bool optional = true) =>
|
public ConfigDefaultsOptions WithToml(string path, bool optional = true)
|
||||||
AddFluentSource(path, ConfigFormat.Toml, optional);
|
{
|
||||||
|
return AddFluentSource(path, ConfigFormat.Toml, optional);
|
||||||
|
}
|
||||||
|
|
||||||
private ConfigDefaultsOptions AddFluentSource(string path, ConfigFormat format, bool optional)
|
private ConfigDefaultsOptions AddFluentSource(string path, ConfigFormat format, bool optional)
|
||||||
{
|
{
|
||||||
@@ -98,7 +106,10 @@ namespace Config;
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal IEnumerable<ConfigSource> GetFluentSources() => _fluentSources;
|
internal IEnumerable<ConfigSource> GetFluentSources()
|
||||||
|
{
|
||||||
|
return _fluentSources;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -146,10 +157,7 @@ public static class ConfigDefaults
|
|||||||
|
|
||||||
internal static void ApplyDefaults(ConfigOptions target, ConfigDefaultsOptions defaults)
|
internal static void ApplyDefaults(ConfigOptions target, ConfigDefaultsOptions defaults)
|
||||||
{
|
{
|
||||||
if (defaults.BasePath is not null)
|
if (defaults.BasePath is not null) target.BasePath = defaults.BasePath;
|
||||||
{
|
|
||||||
target.BasePath = defaults.BasePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
target.IncludeEnvironmentVariables = defaults.IncludeEnvironmentVariables;
|
target.IncludeEnvironmentVariables = defaults.IncludeEnvironmentVariables;
|
||||||
target.EnvironmentPrefix = defaults.EnvironmentPrefix;
|
target.EnvironmentPrefix = defaults.EnvironmentPrefix;
|
||||||
@@ -159,31 +167,20 @@ public static class ConfigDefaults
|
|||||||
if (defaults.IncludeEnvironmentSpecificFile && !string.IsNullOrWhiteSpace(defaults.EnvironmentName))
|
if (defaults.IncludeEnvironmentSpecificFile && !string.IsNullOrWhiteSpace(defaults.EnvironmentName))
|
||||||
{
|
{
|
||||||
var envFile = BuildEnvironmentSpecificFile(defaults.MainFile, 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)
|
foreach (var file in defaults.AdditionalFiles) target.Sources.Add(CreateSource(file, true));
|
||||||
{
|
|
||||||
target.Sources.Add(CreateSource(file, optional: true));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var source in defaults.GetFluentSources())
|
foreach (var source in defaults.GetFluentSources()) target.Sources.Add(source);
|
||||||
{
|
|
||||||
target.Sources.Add(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var source in defaults.AdditionalSources)
|
foreach (var source in defaults.AdditionalSources) target.Sources.Add(source);
|
||||||
{
|
|
||||||
target.Sources.Add(source);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ConfigSource CreateSource(string path, bool optional)
|
private static ConfigSource CreateSource(string path, bool optional)
|
||||||
{
|
{
|
||||||
if (!ConfigSource.TryGuess(path, out var guessed))
|
if (!ConfigSource.TryGuess(path, out var guessed))
|
||||||
{
|
|
||||||
throw new NotSupportedException($"Unsupported config extension: {path}");
|
throw new NotSupportedException($"Unsupported config extension: {path}");
|
||||||
}
|
|
||||||
|
|
||||||
return new ConfigSource
|
return new ConfigSource
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,18 +24,11 @@ public sealed class ConfigManager
|
|||||||
{
|
{
|
||||||
var builder = new ConfigurationBuilder();
|
var builder = new ConfigurationBuilder();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.BasePath))
|
if (!string.IsNullOrWhiteSpace(options.BasePath)) builder.SetBasePath(options.BasePath!);
|
||||||
{
|
|
||||||
builder.SetBasePath(options.BasePath!);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.FileProvider is not null)
|
if (options.FileProvider is not null) builder.SetFileProvider(options.FileProvider);
|
||||||
{
|
|
||||||
builder.SetFileProvider(options.FileProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var src in options.Sources)
|
foreach (var src in options.Sources)
|
||||||
{
|
|
||||||
switch (src.Format)
|
switch (src.Format)
|
||||||
{
|
{
|
||||||
case ConfigFormat.Json:
|
case ConfigFormat.Json:
|
||||||
@@ -46,7 +39,7 @@ public sealed class ConfigManager
|
|||||||
// 额外规则:将全局键复制到所有已声明的一级节中(若该节未显式定义该键),
|
// 额外规则:将全局键复制到所有已声明的一级节中(若该节未显式定义该键),
|
||||||
// 以便像 "User=..." 可同时影响 "Database:User" 这类常见节。
|
// 以便像 "User=..." 可同时影响 "Database:User" 这类常见节。
|
||||||
var iniPath = options.BasePath is { Length: > 0 }
|
var iniPath = options.BasePath is { Length: > 0 }
|
||||||
? System.IO.Path.Combine(options.BasePath!, src.Path)
|
? Path.Combine(options.BasePath!, src.Path)
|
||||||
: src.Path;
|
: src.Path;
|
||||||
if (!File.Exists(iniPath))
|
if (!File.Exists(iniPath))
|
||||||
{
|
{
|
||||||
@@ -64,7 +57,7 @@ public sealed class ConfigManager
|
|||||||
case ConfigFormat.Toml:
|
case ConfigFormat.Toml:
|
||||||
// 解析 TOML 并以内存提供器注入(当前测试未要求 TOML 热更新)
|
// 解析 TOML 并以内存提供器注入(当前测试未要求 TOML 热更新)
|
||||||
var tomlPath = options.BasePath is { Length: > 0 }
|
var tomlPath = options.BasePath is { Length: > 0 }
|
||||||
? System.IO.Path.Combine(options.BasePath!, src.Path)
|
? Path.Combine(options.BasePath!, src.Path)
|
||||||
: src.Path;
|
: src.Path;
|
||||||
if (!File.Exists(tomlPath))
|
if (!File.Exists(tomlPath))
|
||||||
{
|
{
|
||||||
@@ -76,9 +69,8 @@ public sealed class ConfigManager
|
|||||||
var text = File.ReadAllText(tomlPath, new UTF8Encoding(false));
|
var text = File.ReadAllText(tomlPath, new UTF8Encoding(false));
|
||||||
var parse = Toml.Parse(text);
|
var parse = Toml.Parse(text);
|
||||||
if (parse.HasErrors)
|
if (parse.HasErrors)
|
||||||
{
|
throw new InvalidDataException($"Failed to load configuration from file '{tomlPath}'.",
|
||||||
throw new InvalidDataException($"Failed to load configuration from file '{tomlPath}'.", new FormatException("TOML parse failed"));
|
new FormatException("TOML parse failed"));
|
||||||
}
|
|
||||||
var model = parse.ToModel();
|
var model = parse.ToModel();
|
||||||
var pairs = FlattenTomlModel(model);
|
var pairs = FlattenTomlModel(model);
|
||||||
builder.AddInMemoryCollection(pairs);
|
builder.AddInMemoryCollection(pairs);
|
||||||
@@ -86,7 +78,6 @@ public sealed class ConfigManager
|
|||||||
default:
|
default:
|
||||||
throw new NotSupportedException($"Unsupported format: {src.Format}");
|
throw new NotSupportedException($"Unsupported format: {src.Format}");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (options.IncludeEnvironmentVariables)
|
if (options.IncludeEnvironmentVariables)
|
||||||
{
|
{
|
||||||
@@ -97,10 +88,7 @@ public sealed class ConfigManager
|
|||||||
// - 同时兼容 "APP__Name" 这类紧跟双下划线的写法(用户容易误用),通过再注册一次 "APP__" 前缀捕获
|
// - 同时兼容 "APP__Name" 这类紧跟双下划线的写法(用户容易误用),通过再注册一次 "APP__" 前缀捕获
|
||||||
var pfx = options.EnvironmentPrefix;
|
var pfx = options.EnvironmentPrefix;
|
||||||
builder.AddEnvironmentVariables(pfx);
|
builder.AddEnvironmentVariables(pfx);
|
||||||
if (pfx.EndsWith("_"))
|
if (pfx.EndsWith("_")) builder.AddEnvironmentVariables(pfx + "_");
|
||||||
{
|
|
||||||
builder.AddEnvironmentVariables(pfx + "_");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -109,9 +97,7 @@ public sealed class ConfigManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.ConfigureBuilderHook is not null)
|
if (options.ConfigureBuilderHook is not null)
|
||||||
{
|
|
||||||
builder = (ConfigurationBuilder)options.ConfigureBuilderHook(builder);
|
builder = (ConfigurationBuilder)options.ConfigureBuilderHook(builder);
|
||||||
}
|
|
||||||
|
|
||||||
var configuration = builder.Build();
|
var configuration = builder.Build();
|
||||||
return new ConfigManager(configuration, options);
|
return new ConfigManager(configuration, options);
|
||||||
@@ -123,27 +109,20 @@ public sealed class ConfigManager
|
|||||||
{
|
{
|
||||||
var target = new T();
|
var target = new T();
|
||||||
if (string.IsNullOrWhiteSpace(section))
|
if (string.IsNullOrWhiteSpace(section))
|
||||||
{
|
|
||||||
Configuration.Bind(target);
|
Configuration.Bind(target);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
Configuration.GetSection(section).Bind(target);
|
Configuration.GetSection(section).Bind(target);
|
||||||
}
|
|
||||||
|
|
||||||
if (validate)
|
if (validate) ValidateObject(target, true);
|
||||||
{
|
|
||||||
ValidateObject(target, throwOnError: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryGet<T>(out T instance, string? section = null, bool validate = false) where T : new()
|
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;
|
if (!validate) return true;
|
||||||
var results = ValidateObject(instance, throwOnError: false);
|
var results = ValidateObject(instance, false);
|
||||||
return results.Count == 0;
|
return results.Count == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,10 +132,7 @@ public sealed class ConfigManager
|
|||||||
var visited = new HashSet<object>(ReferenceEqualityComparer.Instance);
|
var visited = new HashSet<object>(ReferenceEqualityComparer.Instance);
|
||||||
RecursiveValidate(instance, results, visited);
|
RecursiveValidate(instance, results, visited);
|
||||||
|
|
||||||
if (throwOnError && results.Count > 0)
|
if (throwOnError && results.Count > 0) throw new ConfigurationValidationException(results);
|
||||||
{
|
|
||||||
throw new ConfigurationValidationException(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
@@ -170,7 +146,7 @@ public sealed class ConfigManager
|
|||||||
if (IsSimple(type)) return;
|
if (IsSimple(type)) return;
|
||||||
|
|
||||||
var context = new ValidationContext(instance);
|
var context = new ValidationContext(instance);
|
||||||
Validator.TryValidateObject(instance, context, results, validateAllProperties: true);
|
Validator.TryValidateObject(instance, context, results, true);
|
||||||
|
|
||||||
foreach (var prop in type.GetProperties())
|
foreach (var prop in type.GetProperties())
|
||||||
{
|
{
|
||||||
@@ -180,30 +156,27 @@ public sealed class ConfigManager
|
|||||||
if (IsSimple(pType)) continue;
|
if (IsSimple(pType)) continue;
|
||||||
|
|
||||||
if (value is System.Collections.IEnumerable enumerable && value is not string)
|
if (value is System.Collections.IEnumerable enumerable && value is not string)
|
||||||
{
|
|
||||||
foreach (var item in enumerable)
|
foreach (var item in enumerable)
|
||||||
{
|
{
|
||||||
if (item is null) continue;
|
if (item is null) continue;
|
||||||
RecursiveValidate(item, results, visited);
|
RecursiveValidate(item, results, visited);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
RecursiveValidate(value, results, visited);
|
RecursiveValidate(value, results, visited);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsSimple(Type t)
|
private static bool IsSimple(Type t)
|
||||||
{
|
{
|
||||||
t = Nullable.GetUnderlyingType(t) ?? 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
|
// Save APIs
|
||||||
public void Save<T>(T instance, string path, ConfigFormat format, bool indented = true, Encoding? encoding = null)
|
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);
|
var serializer = SerializerFactory.Get(format);
|
||||||
serializer.SerializeToFile(path, instance!, indented, encoding);
|
serializer.SerializeToFile(path, instance!, indented, encoding);
|
||||||
}
|
}
|
||||||
@@ -247,12 +220,12 @@ public sealed class ConfigManager
|
|||||||
yield break;
|
yield break;
|
||||||
case TomlArray arr:
|
case TomlArray arr:
|
||||||
for (var i = 0; i < arr.Count; i++)
|
for (var i = 0; i < arr.Count; i++)
|
||||||
{
|
foreach (var p in WalkValue(key + ":" + i, arr[i]))
|
||||||
foreach (var p in WalkValue(key + ":" + i, arr[i])) yield return p;
|
yield return p;
|
||||||
}
|
|
||||||
yield break;
|
yield break;
|
||||||
default:
|
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;
|
yield break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,6 +248,7 @@ public sealed class ConfigManager
|
|||||||
current = sections[name] = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
current = sections[name] = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var idx = line.IndexOf('=');
|
var idx = line.IndexOf('=');
|
||||||
if (idx <= 0) continue;
|
if (idx <= 0) continue;
|
||||||
var key = line.Substring(0, idx).Trim();
|
var key = line.Substring(0, idx).Trim();
|
||||||
@@ -293,23 +267,20 @@ public sealed class ConfigManager
|
|||||||
foreach (var (sectionName, map) in sections)
|
foreach (var (sectionName, map) in sections)
|
||||||
{
|
{
|
||||||
foreach (var kv in root)
|
foreach (var kv in root)
|
||||||
{
|
|
||||||
if (!map.ContainsKey(kv.Key))
|
if (!map.ContainsKey(kv.Key))
|
||||||
{
|
|
||||||
yield return new KeyValuePair<string, string?>(sectionName + ":" + kv.Key, kv.Value);
|
yield return new KeyValuePair<string, string?>(sectionName + ":" + kv.Key, kv.Value);
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach (var kv in map)
|
foreach (var kv in map)
|
||||||
{
|
|
||||||
yield return new KeyValuePair<string, string?>(sectionName + ":" + kv.Key, kv.Value);
|
yield return new KeyValuePair<string, string?>(sectionName + ":" + kv.Key, kv.Value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class SerializerFactory
|
public static class SerializerFactory
|
||||||
{
|
{
|
||||||
public static IConfigSerializer Get(ConfigFormat format) => format switch
|
public static IConfigSerializer Get(ConfigFormat format)
|
||||||
|
{
|
||||||
|
return format switch
|
||||||
{
|
{
|
||||||
ConfigFormat.Json => new JsonConfigSerializer(),
|
ConfigFormat.Json => new JsonConfigSerializer(),
|
||||||
ConfigFormat.Xml => new XmlConfigSerializer(),
|
ConfigFormat.Xml => new XmlConfigSerializer(),
|
||||||
@@ -317,11 +288,13 @@ public static class SerializerFactory
|
|||||||
ConfigFormat.Toml => new TomlConfigSerializer(),
|
ConfigFormat.Toml => new TomlConfigSerializer(),
|
||||||
_ => throw new NotSupportedException($"Unsupported format: {format}")
|
_ => throw new NotSupportedException($"Unsupported format: {format}")
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ConfigurationValidationException : Exception
|
public sealed class ConfigurationValidationException : Exception
|
||||||
{
|
{
|
||||||
public IReadOnlyList<ValidationResult> Errors { get; }
|
public IReadOnlyList<ValidationResult> Errors { get; }
|
||||||
|
|
||||||
public ConfigurationValidationException(IReadOnlyList<ValidationResult> errors)
|
public ConfigurationValidationException(IReadOnlyList<ValidationResult> errors)
|
||||||
: base($"Configuration validation failed: {string.Join("; ", errors.Select(e => e.ErrorMessage))}")
|
: base($"Configuration validation failed: {string.Join("; ", errors.Select(e => e.ErrorMessage))}")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ public sealed class ConfigSource
|
|||||||
public required ConfigFormat Format { get; init; }
|
public required ConfigFormat Format { get; init; }
|
||||||
public bool Optional { get; init; } = true;
|
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)
|
public static bool TryGuess(string path, [NotNullWhen(true)] out ConfigSource? source)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ internal sealed class ConfigAccessor<T> : IConfigAccessor<T>, IDisposable where
|
|||||||
|
|
||||||
public T Value => _current;
|
public T Value => _current;
|
||||||
|
|
||||||
public void Dispose() => _subscription.Dispose();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_subscription.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ConfigServiceCollectionExtensions
|
public static class ConfigServiceCollectionExtensions
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# Config 模块
|
# Config 模块
|
||||||
|
|
||||||
面向 .NET 的通用配置模块,聚合多种配置源(JSON / INI / XML / TOML / 环境变量),支持强类型绑定、数据注解验证、可选热更新、文件读写序列化,并提供对外扩展 Hook 以便与外部公共库(如 Common 模块)集成。
|
面向 .NET 的通用配置模块,聚合多种配置源(JSON / INI / XML / TOML / 环境变量),支持强类型绑定、数据注解验证、可选热更新、文件读写序列化,并提供对外扩展
|
||||||
|
Hook 以便与外部公共库(如 Common 模块)集成。
|
||||||
|
|
||||||
本模块仅包含在 `src/Sharp/Config` 目录内的实现,遵从“公共项目,可插拔”的目标:你可以直接使用,也可通过依赖注入或 Hook 接入整体项目体系。
|
本模块仅包含在 `src/Sharp/Config` 目录内的实现,遵从“公共项目,可插拔”的目标:你可以直接使用,也可通过依赖注入或 Hook
|
||||||
|
接入整体项目体系。
|
||||||
|
|
||||||
## 功能清单
|
## 功能清单
|
||||||
|
|
||||||
@@ -32,7 +34,8 @@
|
|||||||
- `Serialization/IniConfigSerializer`
|
- `Serialization/IniConfigSerializer`
|
||||||
- `Serialization/TomlConfigSerializer`
|
- `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) 强类型绑定与验证
|
### 2) 强类型绑定与验证
|
||||||
|
|
||||||
@@ -176,7 +180,8 @@ var cm = provider.GetRequiredService<ConfigManager>();
|
|||||||
- 配置源合并策略:后添加覆盖先添加(与官方一致)
|
- 配置源合并策略:后添加覆盖先添加(与官方一致)
|
||||||
- INI 读取:
|
- INI 读取:
|
||||||
- 将“根键(未在任何节中)”与“节内键”一起展平成键路径
|
- 将“根键(未在任何节中)”与“节内键”一起展平成键路径
|
||||||
- 当前默认行为:将根键复制到所有已声明的一级节(若该节未定义同名键)。此举旨在方便简化配置,但可能与部分 INI 规范理解有差异;如需关闭或自定义,可在后续版本提供开关
|
- 当前默认行为:将根键复制到所有已声明的一级节(若该节未定义同名键)。此举旨在方便简化配置,但可能与部分 INI
|
||||||
|
规范理解有差异;如需关闭或自定义,可在后续版本提供开关
|
||||||
- INI 写入:支持简单类型与一层嵌套对象
|
- INI 写入:支持简单类型与一层嵌套对象
|
||||||
- TOML 读取:
|
- TOML 读取:
|
||||||
- 解析为键值对注入,默认不启用文件级热更新
|
- 解析为键值对注入,默认不启用文件级热更新
|
||||||
@@ -192,7 +197,8 @@ var cm = provider.GetRequiredService<ConfigManager>();
|
|||||||
## 常见问题(FAQ)
|
## 常见问题(FAQ)
|
||||||
|
|
||||||
1. 为什么 INI/TOML 没有热更新?
|
1. 为什么 INI/TOML 没有热更新?
|
||||||
- 当前实现为轻量解析注入内存,未挂载文件监控。JSON/XML 已可直接使用热更新。若需要对 INI/TOML 启用热更新,可按 `FileConfigurationProvider` 模式扩展(可在后续版本提供)。
|
- 当前实现为轻量解析注入内存,未挂载文件监控。JSON/XML 已可直接使用热更新。若需要对 INI/TOML 启用热更新,可按
|
||||||
|
`FileConfigurationProvider` 模式扩展(可在后续版本提供)。
|
||||||
|
|
||||||
2. 环境变量用法推荐?
|
2. 环境变量用法推荐?
|
||||||
- 使用 `environmentPrefix: "APP"`,并设置 `APP__Section__Key`。尽量避免在前缀尾部加 `_`,可减少歧义。
|
- 使用 `environmentPrefix: "APP"`,并设置 `APP__Section__Key`。尽量避免在前缀尾部加 `_`,可减少歧义。
|
||||||
|
|||||||
@@ -9,18 +9,13 @@ public sealed class IniConfigSerializer : IConfigSerializer
|
|||||||
{
|
{
|
||||||
var (root, sections) = ObjectToIniMaps(instance);
|
var (root, sections) = ObjectToIniMaps(instance);
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
foreach (var kv in root)
|
foreach (var kv in root) sb.Append(kv.Key).Append('=').Append(kv.Value).Append('\n');
|
||||||
{
|
|
||||||
sb.Append(kv.Key).Append('=').Append(kv.Value).Append('\n');
|
|
||||||
}
|
|
||||||
foreach (var section in sections)
|
foreach (var section in sections)
|
||||||
{
|
{
|
||||||
sb.Append('\n').Append('[').Append(section.Key).Append(']').Append('\n');
|
sb.Append('\n').Append('[').Append(section.Key).Append(']').Append('\n');
|
||||||
foreach (var kv in section.Value)
|
foreach (var kv in section.Value) sb.Append(kv.Key).Append('=').Append(kv.Value).Append('\n');
|
||||||
{
|
|
||||||
sb.Append(kv.Key).Append('=').Append(kv.Value).Append('\n');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +43,8 @@ public sealed class IniConfigSerializer : IConfigSerializer
|
|||||||
return Deserialize<T>(text);
|
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 root = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
var sections = new Dictionary<string, 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);
|
var innerVal = inner.GetValue(value);
|
||||||
if (innerVal is null) continue;
|
if (innerVal is null) continue;
|
||||||
if (IsSimple(inner.PropertyType))
|
if (IsSimple(inner.PropertyType)) sec[inner.Name] = Convert.ToString(innerVal) ?? string.Empty;
|
||||||
{
|
|
||||||
sec[inner.Name] = Convert.ToString(innerVal) ?? string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sec.Count > 0)
|
|
||||||
{
|
|
||||||
sections[prop.Name] = sec;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sec.Count > 0) sections[prop.Name] = sec;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,14 +101,10 @@ public sealed class IniConfigSerializer : IConfigSerializer
|
|||||||
var value = line.Substring(idx + 1).Trim();
|
var value = line.Substring(idx + 1).Trim();
|
||||||
|
|
||||||
if (currentSection is null)
|
if (currentSection is null)
|
||||||
{
|
|
||||||
dict[key] = value;
|
dict[key] = value;
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
currentSection[key] = value;
|
currentSection[key] = value;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return dict;
|
return dict;
|
||||||
}
|
}
|
||||||
@@ -125,24 +112,17 @@ public sealed class IniConfigSerializer : IConfigSerializer
|
|||||||
private static IEnumerable<KeyValuePair<string, string?>> FlattenToPairs(Dictionary<string, object?> dict)
|
private static IEnumerable<KeyValuePair<string, string?>> FlattenToPairs(Dictionary<string, object?> dict)
|
||||||
{
|
{
|
||||||
foreach (var (key, value) in dict)
|
foreach (var (key, value) in dict)
|
||||||
{
|
|
||||||
if (value is Dictionary<string, object?> nested)
|
if (value is Dictionary<string, object?> nested)
|
||||||
{
|
|
||||||
foreach (var inner in nested)
|
foreach (var inner in nested)
|
||||||
{
|
|
||||||
yield return new KeyValuePair<string, string?>(key + ":" + inner.Key, inner.Value?.ToString());
|
yield return new KeyValuePair<string, string?>(key + ":" + inner.Key, inner.Value?.ToString());
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
yield return new KeyValuePair<string, string?>(key, value?.ToString());
|
yield return new KeyValuePair<string, string?>(key, value?.ToString());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsSimple(Type t)
|
private static bool IsSimple(Type t)
|
||||||
{
|
{
|
||||||
t = Nullable.GetUnderlyingType(t) ?? 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)
|
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)
|
public void SerializeToFile<T>(string path, T instance, bool indented = true, Encoding? encoding = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,10 +26,7 @@ internal class DataContext : IDataContext
|
|||||||
{
|
{
|
||||||
var type = typeof(TEntity);
|
var type = typeof(TEntity);
|
||||||
|
|
||||||
if (_repositories.ContainsKey(type))
|
if (_repositories.ContainsKey(type)) return (IRepository<TEntity>)_repositories[type];
|
||||||
{
|
|
||||||
return (IRepository<TEntity>)_repositories[type];
|
|
||||||
}
|
|
||||||
|
|
||||||
var repository = new Repository<TEntity>(_context, _options.EnableSoftDelete);
|
var repository = new Repository<TEntity>(_context, _options.EnableSoftDelete);
|
||||||
_repositories[type] = repository;
|
_repositories[type] = repository;
|
||||||
@@ -40,16 +37,10 @@ internal class DataContext : IDataContext
|
|||||||
public async Task<int> SaveChangesAsync(CancellationToken ct = default)
|
public async Task<int> SaveChangesAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// 应用审计
|
// 应用审计
|
||||||
if (_options.EnableAudit)
|
if (_options.EnableAudit) ApplyAuditInfo();
|
||||||
{
|
|
||||||
ApplyAuditInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应用软删除
|
// 应用软删除
|
||||||
if (_options.EnableSoftDelete)
|
if (_options.EnableSoftDelete) ApplySoftDelete();
|
||||||
{
|
|
||||||
ApplySoftDelete();
|
|
||||||
}
|
|
||||||
|
|
||||||
return await _context.SaveChangesAsync(ct);
|
return await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,23 +15,23 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- EF Core -->
|
<!-- EF Core -->
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0"/>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" 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.SqlServer" Version="9.0.0"/>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0"/>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0"/>
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" 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.InMemory" Version="9.0.0"/>
|
||||||
|
|
||||||
<!-- 依赖注入 -->
|
<!-- 依赖注入 -->
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- 引用 Common 模块,复用基础功能 -->
|
<!-- 引用 Common 模块,复用基础功能 -->
|
||||||
<ProjectReference Include="..\Common\Common.csproj" />
|
<ProjectReference Include="..\Common\Common.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -34,7 +34,8 @@ public class DataContextBuilder<TContext> where TContext : DbContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 使用 SQL Server 数据库
|
/// 使用 SQL Server 数据库
|
||||||
/// </summary>
|
/// </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);
|
_optionsBuilder.UseSqlServer(connectionString, configure);
|
||||||
_databaseConfigured = true;
|
_databaseConfigured = true;
|
||||||
@@ -44,7 +45,8 @@ public class DataContextBuilder<TContext> where TContext : DbContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 使用 MySQL 数据库
|
/// 使用 MySQL 数据库
|
||||||
/// </summary>
|
/// </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)
|
var version = string.IsNullOrEmpty(serverVersion)
|
||||||
? ServerVersion.AutoDetect(connectionString)
|
? ServerVersion.AutoDetect(connectionString)
|
||||||
@@ -58,7 +60,8 @@ public class DataContextBuilder<TContext> where TContext : DbContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 使用 PostgreSQL 数据库
|
/// 使用 PostgreSQL 数据库
|
||||||
/// </summary>
|
/// </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);
|
_optionsBuilder.UseNpgsql(connectionString, configure);
|
||||||
_databaseConfigured = true;
|
_databaseConfigured = true;
|
||||||
@@ -162,32 +165,22 @@ public class DataContextBuilder<TContext> where TContext : DbContext
|
|||||||
public IDataContext Build()
|
public IDataContext Build()
|
||||||
{
|
{
|
||||||
// 如果没有配置数据库,默认使用 SQLite 内存数据库
|
// 如果没有配置数据库,默认使用 SQLite 内存数据库
|
||||||
if (!_databaseConfigured)
|
if (!_databaseConfigured) _optionsBuilder.UseSqlite("Data Source=:memory:");
|
||||||
{
|
|
||||||
_optionsBuilder.UseSqlite("Data Source=:memory:");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建 DbContext 实例
|
// 创建 DbContext 实例
|
||||||
var context = (TContext)Activator.CreateInstance(typeof(TContext), _optionsBuilder.Options)!;
|
var context = (TContext)Activator.CreateInstance(typeof(TContext), _optionsBuilder.Options)!;
|
||||||
|
|
||||||
// 自动迁移
|
// 自动迁移
|
||||||
if (_dataOptions.EnableAutoMigration)
|
if (_dataOptions.EnableAutoMigration)
|
||||||
{
|
|
||||||
context.Database.Migrate();
|
context.Database.Migrate();
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
// 确保数据库已创建
|
// 确保数据库已创建
|
||||||
context.Database.EnsureCreated();
|
context.Database.EnsureCreated();
|
||||||
}
|
|
||||||
|
|
||||||
// 获取当前用户 ID
|
// 获取当前用户 ID
|
||||||
if (_dataOptions.CurrentUserIdProvider != null)
|
if (_dataOptions.CurrentUserIdProvider != null)
|
||||||
{
|
|
||||||
_dataOptions.CurrentUserId = _dataOptions.CurrentUserIdProvider();
|
_dataOptions.CurrentUserId = _dataOptions.CurrentUserIdProvider();
|
||||||
}
|
|
||||||
|
|
||||||
return new DataContext(context, _dataOptions);
|
return new DataContext(context, _dataOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +51,9 @@ public static class DataServiceExtensions
|
|||||||
options.UseSqlServer(connectionString, sqlOptions =>
|
options.UseSqlServer(connectionString, sqlOptions =>
|
||||||
{
|
{
|
||||||
sqlOptions.EnableRetryOnFailure(
|
sqlOptions.EnableRetryOnFailure(
|
||||||
maxRetryCount: 3,
|
3,
|
||||||
maxRetryDelay: TimeSpan.FromSeconds(5),
|
TimeSpan.FromSeconds(5),
|
||||||
errorNumbersToAdd: null);
|
null);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -63,18 +63,14 @@ public static class DataServiceExtensions
|
|||||||
|
|
||||||
case "postgresql":
|
case "postgresql":
|
||||||
case "postgres":
|
case "postgres":
|
||||||
options.UseNpgsql(connectionString, npgsqlOptions =>
|
options.UseNpgsql(connectionString,
|
||||||
{
|
npgsqlOptions => { npgsqlOptions.EnableRetryOnFailure(3, TimeSpan.FromSeconds(5), null); });
|
||||||
npgsqlOptions.EnableRetryOnFailure(3, TimeSpan.FromSeconds(5), null);
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "mysql":
|
case "mysql":
|
||||||
var serverVersion = ServerVersion.AutoDetect(connectionString);
|
var serverVersion = ServerVersion.AutoDetect(connectionString);
|
||||||
options.UseMySql(connectionString, serverVersion, mysqlOptions =>
|
options.UseMySql(connectionString, serverVersion,
|
||||||
{
|
mysqlOptions => { mysqlOptions.EnableRetryOnFailure(3, TimeSpan.FromSeconds(5), null); });
|
||||||
mysqlOptions.EnableRetryOnFailure(3, TimeSpan.FromSeconds(5), null);
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "inmemory":
|
case "inmemory":
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public abstract class Entity<TKey> where TKey : IEquatable<TKey>
|
|||||||
if (GetType() != other.GetType())
|
if (GetType() != other.GetType())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (Id.Equals(default(TKey)) || other.Id.Equals(default(TKey)))
|
if (Id.Equals(default) || other.Id.Equals(default))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
return Id.Equals(other.Id);
|
return Id.Equals(other.Id);
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ public class MyEntity
|
|||||||
### AuditableEntity<TKey> - 审计实体(可选)
|
### AuditableEntity<TKey> - 审计实体(可选)
|
||||||
|
|
||||||
如果继承此基类,会自动管理以下字段:
|
如果继承此基类,会自动管理以下字段:
|
||||||
|
|
||||||
- `Id` - 主键
|
- `Id` - 主键
|
||||||
- `CreatedAt` - 创建时间
|
- `CreatedAt` - 创建时间
|
||||||
- `CreatedBy` - 创建人
|
- `CreatedBy` - 创建人
|
||||||
@@ -361,6 +362,7 @@ builder.Services.AddHttpContextAccessor();
|
|||||||
### 与 Sharp.Common 集成(已内置)
|
### 与 Sharp.Common 集成(已内置)
|
||||||
|
|
||||||
Data 模块已经引用了 Common 模块,可以直接使用:
|
Data 模块已经引用了 Common 模块,可以直接使用:
|
||||||
|
|
||||||
- `PagedResult<T>` - 分页结果
|
- `PagedResult<T>` - 分页结果
|
||||||
- `Result<T>` - 错误处理(可选)
|
- `Result<T>` - 错误处理(可选)
|
||||||
- 各种扩展方法
|
- 各种扩展方法
|
||||||
@@ -409,6 +411,7 @@ catch
|
|||||||
### Q: 我必须使用 Entity/AuditableEntity 基类吗?
|
### Q: 我必须使用 Entity/AuditableEntity 基类吗?
|
||||||
|
|
||||||
**不是!** 基类是**完全可选**的。你可以:
|
**不是!** 基类是**完全可选**的。你可以:
|
||||||
|
|
||||||
- 使用自己的实体定义
|
- 使用自己的实体定义
|
||||||
- 实现 `IAuditableEntity` 或 `ISoftDeletable` 接口
|
- 实现 `IAuditableEntity` 或 `ISoftDeletable` 接口
|
||||||
- 完全不使用审计和软删除功能
|
- 完全不使用审计和软删除功能
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ internal class Repository<TEntity> : IRepository<TEntity> where TEntity : class
|
|||||||
return await _dbSet.FindAsync(new[] { id }, ct);
|
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);
|
return await _dbSet.FirstOrDefaultAsync(predicate, ct);
|
||||||
}
|
}
|
||||||
@@ -41,7 +42,8 @@ internal class Repository<TEntity> : IRepository<TEntity> where TEntity : class
|
|||||||
return await _dbSet.ToListAsync(ct);
|
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);
|
return await _dbSet.Where(predicate).ToListAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -134,13 +136,12 @@ internal class Repository<TEntity> : IRepository<TEntity> where TEntity : class
|
|||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
foreach (var entity in entities)
|
foreach (var entity in entities)
|
||||||
{
|
|
||||||
if (entity is ISoftDeletable softDeletable)
|
if (entity is ISoftDeletable softDeletable)
|
||||||
{
|
{
|
||||||
softDeletable.IsDeleted = true;
|
softDeletable.IsDeleted = true;
|
||||||
softDeletable.DeletedAt = now;
|
softDeletable.DeletedAt = now;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
_dbSet.UpdateRange(entities);
|
_dbSet.UpdateRange(entities);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -152,9 +153,6 @@ internal class Repository<TEntity> : IRepository<TEntity> where TEntity : class
|
|||||||
public async Task RemoveAsync(object id, CancellationToken ct = default)
|
public async Task RemoveAsync(object id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var entity = await GetByIdAsync(id, ct);
|
var entity = await GetByIdAsync(id, ct);
|
||||||
if (entity != null)
|
if (entity != null) Remove(entity);
|
||||||
{
|
|
||||||
Remove(entity);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
本指南展示如何在不同场景下使用 Sharp.Data 模块。
|
本指南展示如何在不同场景下使用 Sharp.Data 模块。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- [快速开始](#快速开始)
|
- [快速开始](#快速开始)
|
||||||
- [控制台应用](#控制台应用)
|
- [控制台应用](#控制台应用)
|
||||||
- [WPF 应用](#wpf-应用)
|
- [WPF 应用](#wpf-应用)
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ namespace Sharp.EventBus.DependencyInjection;
|
|||||||
|
|
||||||
public static class ServiceCollectionExtensions
|
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();
|
var options = new EventBusOptions();
|
||||||
configure?.Invoke(options);
|
configure?.Invoke(options);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -12,7 +12,8 @@ internal sealed class EventSubscription : IDisposable
|
|||||||
private readonly Action<EventSubscription> _onDispose;
|
private readonly Action<EventSubscription> _onDispose;
|
||||||
private bool _disposed;
|
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;
|
EventType = eventType;
|
||||||
Handler = handler;
|
Handler = handler;
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ public interface IEventBus : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 订阅事件(同步处理器)。
|
/// 订阅事件(同步处理器)。
|
||||||
/// </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>
|
||||||
/// 订阅事件(异步处理器)。
|
/// 订阅事件(异步处理器)。
|
||||||
/// </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;
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,8 @@ public sealed class InMemoryEventBus : IEventBus
|
|||||||
return _channel.Writer.WriteAsync(@event, cancellationToken).AsTask();
|
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();
|
ThrowIfDisposed();
|
||||||
if (handler == null) throw new ArgumentNullException(nameof(handler));
|
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);
|
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();
|
ThrowIfDisposed();
|
||||||
if (handler == null) throw new ArgumentNullException(nameof(handler));
|
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);
|
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 sub = new EventSubscription(eventType, handler, filter, priority, RemoveSubscription);
|
||||||
var list = _subscriptions.GetOrAdd(eventType, _ => new List<EventSubscription>());
|
var list = _subscriptions.GetOrAdd(eventType, _ => new List<EventSubscription>());
|
||||||
@@ -71,32 +77,27 @@ public sealed class InMemoryEventBus : IEventBus
|
|||||||
list.Add(sub);
|
list.Add(sub);
|
||||||
list.Sort((a, b) => a.Priority.CompareTo(b.Priority));
|
list.Sort((a, b) => a.Priority.CompareTo(b.Priority));
|
||||||
}
|
}
|
||||||
|
|
||||||
return sub;
|
return sub;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveSubscription(EventSubscription subscription)
|
private void RemoveSubscription(EventSubscription subscription)
|
||||||
{
|
{
|
||||||
if (_subscriptions.TryGetValue(subscription.EventType, out var list))
|
if (_subscriptions.TryGetValue(subscription.EventType, out var list))
|
||||||
{
|
|
||||||
lock (list)
|
lock (list)
|
||||||
{
|
{
|
||||||
list.Remove(subscription);
|
list.Remove(subscription);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessAsync()
|
private async Task ProcessAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (await _channel.Reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false))
|
while (await _channel.Reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false))
|
||||||
{
|
|
||||||
while (_channel.Reader.TryRead(out var evt))
|
while (_channel.Reader.TryRead(out var evt))
|
||||||
{
|
|
||||||
await DispatchAsync(evt).ConfigureAwait(false);
|
await DispatchAsync(evt).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// 正常关闭
|
// 正常关闭
|
||||||
@@ -145,6 +146,7 @@ public sealed class InMemoryEventBus : IEventBus
|
|||||||
{
|
{
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
_cts.Dispose();
|
_cts.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
### 场景对比
|
### 场景对比
|
||||||
|
|
||||||
#### ❌ 不使用 EventBus - 紧耦合
|
#### ❌ 不使用 EventBus - 紧耦合
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class PlcMonitorService {
|
public class PlcMonitorService {
|
||||||
private readonly MainViewModel _mainViewModel;
|
private readonly MainViewModel _mainViewModel;
|
||||||
@@ -27,6 +28,7 @@ public class PlcMonitorService {
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### ✅ 使用 EventBus - 解耦
|
#### ✅ 使用 EventBus - 解耦
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 发布者:只管发布,不关心谁处理
|
// 发布者:只管发布,不关心谁处理
|
||||||
public class PlcMonitorService {
|
public class PlcMonitorService {
|
||||||
@@ -75,6 +77,7 @@ public class PlcDataLogger {
|
|||||||
## 🚀 快速开始(未来)
|
## 🚀 快速开始(未来)
|
||||||
|
|
||||||
### 1. 注册服务
|
### 1. 注册服务
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// App.xaml.cs
|
// App.xaml.cs
|
||||||
protected override void OnStartup(StartupEventArgs e) {
|
protected override void OnStartup(StartupEventArgs e) {
|
||||||
@@ -88,6 +91,7 @@ protected override void OnStartup(StartupEventArgs e) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. 定义事件
|
### 2. 定义事件
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 事件:简单的 POCO 类
|
// 事件:简单的 POCO 类
|
||||||
public record UserLoginEvent(string Username, DateTime LoginTime);
|
public record UserLoginEvent(string Username, DateTime LoginTime);
|
||||||
@@ -98,6 +102,7 @@ public record OrderCreatedEvent(int OrderId, decimal TotalAmount);
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 3. 发布事件
|
### 3. 发布事件
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class LoginService {
|
public class LoginService {
|
||||||
private readonly IEventBus _eventBus;
|
private readonly IEventBus _eventBus;
|
||||||
@@ -114,6 +119,7 @@ public class LoginService {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 4. 订阅事件
|
### 4. 订阅事件
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 方式1:在构造函数中订阅
|
// 方式1:在构造函数中订阅
|
||||||
public class MainViewModel : ViewModelBase {
|
public class MainViewModel : ViewModelBase {
|
||||||
@@ -158,6 +164,7 @@ public class HighValueOrderMonitor {
|
|||||||
## 📚 核心 API 设计
|
## 📚 核心 API 设计
|
||||||
|
|
||||||
### IEventBus 接口
|
### IEventBus 接口
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public interface IEventBus {
|
public interface IEventBus {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -197,6 +204,7 @@ public interface IEventBus {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### EventSubscription 类(流式API)
|
### EventSubscription 类(流式API)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class EventSubscription<TEvent> : IDisposable where TEvent : class {
|
public class EventSubscription<TEvent> : IDisposable where TEvent : class {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -240,6 +248,7 @@ eventBus.CreateSubscription<PlcDataChangedEvent>()
|
|||||||
## 🔧 高级功能
|
## 🔧 高级功能
|
||||||
|
|
||||||
### 1. 事件优先级
|
### 1. 事件优先级
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 优先级:数字越小越先执行
|
// 优先级:数字越小越先执行
|
||||||
eventBus.Subscribe<OrderCreatedEvent>(e => ValidateOrder(e), priority: 1);
|
eventBus.Subscribe<OrderCreatedEvent>(e => ValidateOrder(e), priority: 1);
|
||||||
@@ -248,18 +257,21 @@ eventBus.Subscribe<OrderCreatedEvent>(e => UpdateStatistics(e), priority: 20);
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. 弱引用订阅(防止内存泄漏)
|
### 2. 弱引用订阅(防止内存泄漏)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 自动模式:订阅者被GC回收时自动取消订阅
|
// 自动模式:订阅者被GC回收时自动取消订阅
|
||||||
eventBus.SubscribeWeak<UserLoginEvent>(viewModel, (vm, e) => vm.OnLogin(e));
|
eventBus.SubscribeWeak<UserLoginEvent>(viewModel, (vm, e) => vm.OnLogin(e));
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 延迟发布
|
### 3. 延迟发布
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 延迟1秒发布(防抖场景)
|
// 延迟1秒发布(防抖场景)
|
||||||
eventBus.PublishDelayed(new SearchTextChangedEvent(text), TimeSpan.FromSeconds(1));
|
eventBus.PublishDelayed(new SearchTextChangedEvent(text), TimeSpan.FromSeconds(1));
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 错误处理
|
### 4. 错误处理
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 全局错误处理器
|
// 全局错误处理器
|
||||||
services.AddEventBus(options => {
|
services.AddEventBus(options => {
|
||||||
@@ -280,6 +292,7 @@ eventBus.Subscribe<OrderCreatedEvent>(e => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 5. 事件日志(可选)
|
### 5. 事件日志(可选)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
services.AddEventBus(options => {
|
services.AddEventBus(options => {
|
||||||
options.EnableEventLogging = true; // 记录所有事件发布
|
options.EnableEventLogging = true; // 记录所有事件发布
|
||||||
@@ -292,6 +305,7 @@ services.AddEventBus(options => {
|
|||||||
## 💡 典型使用场景
|
## 💡 典型使用场景
|
||||||
|
|
||||||
### 场景1:跨 ViewModel 通信
|
### 场景1:跨 ViewModel 通信
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// LoginViewModel 发布登录成功事件
|
// LoginViewModel 发布登录成功事件
|
||||||
public class LoginViewModel {
|
public class LoginViewModel {
|
||||||
@@ -313,6 +327,7 @@ public class MainViewModel {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 场景2:PLC数据监控
|
### 场景2:PLC数据监控
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// PLC监控服务发布数据变化
|
// PLC监控服务发布数据变化
|
||||||
public class PlcMonitorService {
|
public class PlcMonitorService {
|
||||||
@@ -346,6 +361,7 @@ public class DataLoggerService {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 场景3:报警系统
|
### 场景3:报警系统
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 报警事件
|
// 报警事件
|
||||||
public record AlarmTriggeredEvent(string AlarmId, AlarmLevel Level, string Message);
|
public record AlarmTriggeredEvent(string AlarmId, AlarmLevel Level, string Message);
|
||||||
@@ -389,11 +405,13 @@ services.AddEventBus(options => {
|
|||||||
## 🏗️ 实现方案
|
## 🏗️ 实现方案
|
||||||
|
|
||||||
### 基于 System.Threading.Channels
|
### 基于 System.Threading.Channels
|
||||||
|
|
||||||
- **高性能**:异步无锁队列
|
- **高性能**:异步无锁队列
|
||||||
- **线程安全**:内置并发支持
|
- **线程安全**:内置并发支持
|
||||||
- **背压支持**:通道容量控制
|
- **背压支持**:通道容量控制
|
||||||
|
|
||||||
### 架构设计
|
### 架构设计
|
||||||
|
|
||||||
```
|
```
|
||||||
IEventBus(接口)
|
IEventBus(接口)
|
||||||
↓
|
↓
|
||||||
@@ -416,7 +434,7 @@ System.Threading.Channels.Channel<EventEnvelope>
|
|||||||
## 🔄 与其他模式对比
|
## 🔄 与其他模式对比
|
||||||
|
|
||||||
| 模式 | 优点 | 缺点 | 适用场景 |
|
| 模式 | 优点 | 缺点 | 适用场景 |
|
||||||
|-----|------|------|---------|
|
|--------------|-----------|-----------|------------|
|
||||||
| **EventBus** | 完全解耦、多订阅者 | 略微增加复杂度 | 跨模块通信、插件架构 |
|
| **EventBus** | 完全解耦、多订阅者 | 略微增加复杂度 | 跨模块通信、插件架构 |
|
||||||
| **直接回调** | 简单直接 | 紧耦合、单订阅者 | 简单父子组件通信 |
|
| **直接回调** | 简单直接 | 紧耦合、单订阅者 | 简单父子组件通信 |
|
||||||
| **事件聚合器** | 解耦 | 需要手动管理 | 中等复杂度应用 |
|
| **事件聚合器** | 解耦 | 需要手动管理 | 中等复杂度应用 |
|
||||||
@@ -437,6 +455,7 @@ System.Threading.Channels.Channel<EventEnvelope>
|
|||||||
## 🚀 未来扩展
|
## 🚀 未来扩展
|
||||||
|
|
||||||
### 可能的扩展方向
|
### 可能的扩展方向
|
||||||
|
|
||||||
1. **分布式 EventBus**(基于 Redis Pub/Sub)
|
1. **分布式 EventBus**(基于 Redis Pub/Sub)
|
||||||
2. **事件溯源**(Event Sourcing)支持
|
2. **事件溯源**(Event Sourcing)支持
|
||||||
3. **事件重放**(Replay)功能
|
3. **事件重放**(Replay)功能
|
||||||
@@ -448,7 +467,8 @@ System.Threading.Channels.Channel<EventEnvelope>
|
|||||||
|
|
||||||
- [MediatR](https://github.com/jbogard/MediatR) - .NET 消息中介库
|
- [MediatR](https://github.com/jbogard/MediatR) - .NET 消息中介库
|
||||||
- [Prism EventAggregator](https://prismlibrary.com/docs/event-aggregator.html) - WPF MVVM 事件聚合器
|
- [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;
|
var opt = sp.GetRequiredService<IOptions<SharpLogOptions>>().Value;
|
||||||
o.MinLevel = opt.MinLevel;
|
o.MinLevel = opt.MinLevel;
|
||||||
foreach (var kv in opt.Overrides)
|
foreach (var kv in opt.Overrides)
|
||||||
{
|
|
||||||
// category-specific rule
|
// category-specific rule
|
||||||
o.Rules.Add(new LoggerFilterRule(null, kv.Key, kv.Value, null));
|
o.Rules.Add(new LoggerFilterRule(null, kv.Key, kv.Value, null));
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Register Serilog provider via DI so it participates in logging pipeline
|
// 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;
|
var opt = sp.GetRequiredService<IOptions<SharpLogOptions>>().Value;
|
||||||
|
|
||||||
if (!opt.UseSerilog)
|
if (!opt.UseSerilog)
|
||||||
{
|
|
||||||
// If not using Serilog, don't inject a provider; return a no-op stub
|
// If not using Serilog, don't inject a provider; return a no-op stub
|
||||||
return new NoopLoggerProvider();
|
return new NoopLoggerProvider();
|
||||||
}
|
|
||||||
|
|
||||||
var env = sp.GetService<IHostEnvironment>();
|
var env = sp.GetService<IHostEnvironment>();
|
||||||
var cfg = sp.GetService<IConfiguration>();
|
var cfg = sp.GetService<IConfiguration>();
|
||||||
var serilog = SerilogConfigurator.CreateLogger(opt, env, cfg);
|
var serilog = SerilogConfigurator.CreateLogger(opt, env, cfg);
|
||||||
return new SerilogLoggerProvider(serilog, dispose: true);
|
return new SerilogLoggerProvider(serilog, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
@@ -55,18 +51,38 @@ public static class SharpLogBuilderExtensions
|
|||||||
{
|
{
|
||||||
private sealed class NoopLogger : ILogger
|
private sealed class NoopLogger : ILogger
|
||||||
{
|
{
|
||||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull
|
||||||
public bool IsEnabled(LogLevel logLevel) => false;
|
{
|
||||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { }
|
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
|
private sealed class NullScope : IDisposable
|
||||||
{
|
{
|
||||||
public static readonly NullScope Instance = new();
|
public static readonly NullScope Instance = new();
|
||||||
public void Dispose() { }
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ILogger CreateLogger(string categoryName) => new NoopLogger();
|
public ILogger CreateLogger(string categoryName)
|
||||||
public void Dispose() { }
|
{
|
||||||
|
return new NoopLogger();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,4 +24,3 @@ internal sealed class ActivityEnricher : ILogEventEnricher
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,10 +19,7 @@ internal static class SerilogConfigurator
|
|||||||
|
|
||||||
// Minimum levels
|
// Minimum levels
|
||||||
cfg = cfg.MinimumLevel.Is(MapLevel(options.MinLevel));
|
cfg = cfg.MinimumLevel.Is(MapLevel(options.MinLevel));
|
||||||
foreach (var kv in options.Overrides)
|
foreach (var kv in options.Overrides) cfg = cfg.MinimumLevel.Override(kv.Key, MapLevel(kv.Value));
|
||||||
{
|
|
||||||
cfg = cfg.MinimumLevel.Override(kv.Key, MapLevel(kv.Value));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrichers
|
// Enrichers
|
||||||
cfg = cfg.Enrich.FromLogContext();
|
cfg = cfg.Enrich.FromLogContext();
|
||||||
@@ -42,15 +39,9 @@ internal static class SerilogConfigurator
|
|||||||
?? AppDomain.CurrentDomain.FriendlyName;
|
?? AppDomain.CurrentDomain.FriendlyName;
|
||||||
cfg = cfg.Enrich.WithProperty("Application", appName);
|
cfg = cfg.Enrich.WithProperty("Application", appName);
|
||||||
|
|
||||||
if (env != null)
|
if (env != null) cfg = cfg.Enrich.WithProperty("Environment", env.EnvironmentName);
|
||||||
{
|
|
||||||
cfg = cfg.Enrich.WithProperty("Environment", env.EnvironmentName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.Enrich.WithActivityTrace)
|
if (options.Enrich.WithActivityTrace) cfg = cfg.Enrich.With(new ActivityEnricher());
|
||||||
{
|
|
||||||
cfg = cfg.Enrich.With(new ActivityEnricher());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sinks
|
// Sinks
|
||||||
@@ -62,7 +53,8 @@ internal static class SerilogConfigurator
|
|||||||
}
|
}
|
||||||
else
|
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));
|
cfg = cfg.WriteTo.Async(a => a.Console(outputTemplate: template));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +79,8 @@ internal static class SerilogConfigurator
|
|||||||
}
|
}
|
||||||
else
|
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,
|
cfg = cfg.WriteTo.Async(a => a.File(path,
|
||||||
rollingInterval: rolling,
|
rollingInterval: rolling,
|
||||||
retainedFileCountLimit: retained,
|
retainedFileCountLimit: retained,
|
||||||
@@ -101,7 +94,9 @@ internal static class SerilogConfigurator
|
|||||||
return cfg.CreateLogger();
|
return cfg.CreateLogger();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LogEventLevel MapLevel(LogLevel level) => level switch
|
private static LogEventLevel MapLevel(LogLevel level)
|
||||||
|
{
|
||||||
|
return level switch
|
||||||
{
|
{
|
||||||
LogLevel.Trace => LogEventLevel.Verbose,
|
LogLevel.Trace => LogEventLevel.Verbose,
|
||||||
LogLevel.Debug => LogEventLevel.Debug,
|
LogLevel.Debug => LogEventLevel.Debug,
|
||||||
@@ -111,8 +106,11 @@ internal static class SerilogConfigurator
|
|||||||
LogLevel.Critical => LogEventLevel.Fatal,
|
LogLevel.Critical => LogEventLevel.Fatal,
|
||||||
_ => LogEventLevel.Information
|
_ => LogEventLevel.Information
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static Serilog.RollingInterval MapRolling(Sharp.Log.Options.RollingInterval rolling) => rolling switch
|
private static Serilog.RollingInterval MapRolling(Options.RollingInterval rolling)
|
||||||
|
{
|
||||||
|
return rolling switch
|
||||||
{
|
{
|
||||||
Sharp.Log.Options.RollingInterval.Infinite => Serilog.RollingInterval.Infinite,
|
Sharp.Log.Options.RollingInterval.Infinite => Serilog.RollingInterval.Infinite,
|
||||||
Sharp.Log.Options.RollingInterval.Year => Serilog.RollingInterval.Year,
|
Sharp.Log.Options.RollingInterval.Year => Serilog.RollingInterval.Year,
|
||||||
@@ -122,6 +120,7 @@ internal static class SerilogConfigurator
|
|||||||
Sharp.Log.Options.RollingInterval.Minute => Serilog.RollingInterval.Minute,
|
Sharp.Log.Options.RollingInterval.Minute => Serilog.RollingInterval.Minute,
|
||||||
_ => Serilog.RollingInterval.Day
|
_ => Serilog.RollingInterval.Day
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static string? TryGetAppName()
|
private static string? TryGetAppName()
|
||||||
{
|
{
|
||||||
|
|||||||
+12
-12
@@ -13,18 +13,18 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" 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="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0"/>
|
||||||
<PackageReference Include="Serilog" Version="4.0.0" />
|
<PackageReference Include="Serilog" Version="4.0.0"/>
|
||||||
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0"/>
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1"/>
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0"/>
|
||||||
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
|
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0"/>
|
||||||
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1"/>
|
||||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
|
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0"/>
|
||||||
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
|
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -51,9 +51,20 @@ public sealed class SharpRequestLoggingMiddleware
|
|||||||
private static readonly double TimestampToMilliseconds = 1000.0 / Stopwatch.Frequency;
|
private static readonly double TimestampToMilliseconds = 1000.0 / Stopwatch.Frequency;
|
||||||
private readonly long _start;
|
private readonly long _start;
|
||||||
|
|
||||||
private ValueStopwatch(long start) => _start = start;
|
private ValueStopwatch(long start)
|
||||||
public static ValueStopwatch StartNew() => new(Stopwatch.GetTimestamp());
|
{
|
||||||
public long GetElapsedMilliseconds() => (long)((Stopwatch.GetTimestamp() - _start) * TimestampToMilliseconds);
|
_start = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ValueStopwatch StartNew()
|
||||||
|
{
|
||||||
|
return new ValueStopwatch(Stopwatch.GetTimestamp());
|
||||||
|
}
|
||||||
|
|
||||||
|
public long GetElapsedMilliseconds()
|
||||||
|
{
|
||||||
|
return (long)((Stopwatch.GetTimestamp() - _start) * TimestampToMilliseconds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,4 +75,3 @@ public static class SharpRequestLoggingExtensions
|
|||||||
return app.UseMiddleware<SharpRequestLoggingMiddleware>();
|
return app.UseMiddleware<SharpRequestLoggingMiddleware>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,4 +49,3 @@ public sealed class EnrichOptions
|
|||||||
public string? Application { get; set; } = null; // default to entry assembly name
|
public string? Application { get; set; } = null; // default to entry assembly name
|
||||||
public bool WithActivityTrace { get; set; } = true;
|
public bool WithActivityTrace { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
+13
-2
@@ -1,11 +1,13 @@
|
|||||||
# Sharp.Log
|
# Sharp.Log
|
||||||
|
|
||||||
面向“快速集成”的日志整合层:基于 `Microsoft.Extensions.Logging` 抽象,内置 Serilog 预设,实现一行接入,默认即用(Console + 按日滚动文件,结构化 JSON,丰富上下文)。
|
面向“快速集成”的日志整合层:基于 `Microsoft.Extensions.Logging` 抽象,内置 Serilog 预设,实现一行接入,默认即用(Console +
|
||||||
|
按日滚动文件,结构化 JSON,丰富上下文)。
|
||||||
|
|
||||||
- 目标:不重造日志框架,仅做预设与桥接,减少样板代码
|
- 目标:不重造日志框架,仅做预设与桥接,减少样板代码
|
||||||
- 适用:控制台应用、Worker、ASP.NET Core(含中间件)
|
- 适用:控制台应用、Worker、ASP.NET Core(含中间件)
|
||||||
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
- 一行集成:`builder.Logging.AddSharpLog()`,无配置也可用
|
- 一行集成:`builder.Logging.AddSharpLog()`,无配置也可用
|
||||||
- 输出:Console(可彩色)、文件(按日滚动、保留数量、异步写)
|
- 输出:Console(可彩色)、文件(按日滚动、保留数量、异步写)
|
||||||
- 结构化:文本模板/JSON;支持模板+参数与异常详情
|
- 结构化:文本模板/JSON;支持模板+参数与异常详情
|
||||||
@@ -15,6 +17,7 @@
|
|||||||
- 配置驱动:支持 `appsettings.json:SharpLog` 与环境变量绑定
|
- 配置驱动:支持 `appsettings.json:SharpLog` 与环境变量绑定
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
1) 引用项目(或 NuGet 包)
|
1) 引用项目(或 NuGet 包)
|
||||||
2) 在应用入口添加一行:
|
2) 在应用入口添加一行:
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ app.Run();
|
|||||||
```
|
```
|
||||||
|
|
||||||
// WPF/WinForms/任意桌面
|
// WPF/WinForms/任意桌面
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using Sharp.Log;
|
using Sharp.Log;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -48,11 +52,13 @@ logger.LogInformation("桌面程序已启动");
|
|||||||
```
|
```
|
||||||
|
|
||||||
无需任何 `appsettings.json`,即可获得:
|
无需任何 `appsettings.json`,即可获得:
|
||||||
|
|
||||||
- Console 文本输出
|
- Console 文本输出
|
||||||
- 文件 JSON 输出:`logs/app-.log`(按天滚动,保留 30 个文件,异步写)
|
- 文件 JSON 输出:`logs/app-.log`(按天滚动,保留 30 个文件,异步写)
|
||||||
- 丰富上下文属性
|
- 丰富上下文属性
|
||||||
|
|
||||||
## 可选配置(appsettings.json)
|
## 可选配置(appsettings.json)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"SharpLog": {
|
"SharpLog": {
|
||||||
@@ -88,23 +94,28 @@ logger.LogInformation("桌面程序已启动");
|
|||||||
```
|
```
|
||||||
|
|
||||||
环境变量示例:
|
环境变量示例:
|
||||||
|
|
||||||
- `SHARPLOG__MINLEVEL=Warning`
|
- `SHARPLOG__MINLEVEL=Warning`
|
||||||
- `SHARPLOG__FILE__RETAINEDFILECOUNTLIMIT=7`
|
- `SHARPLOG__FILE__RETAINEDFILECOUNTLIMIT=7`
|
||||||
|
|
||||||
## API 概览
|
## API 概览
|
||||||
|
|
||||||
- `ILoggingBuilder AddSharpLog(Action<SharpLogOptions>? configure = null)`
|
- `ILoggingBuilder AddSharpLog(Action<SharpLogOptions>? configure = null)`
|
||||||
- `IApplicationBuilder UseSharpRequestLogging()`
|
- `IApplicationBuilder UseSharpRequestLogging()`
|
||||||
- 选项:`SharpLogOptions`、`FileOptions`、`EnrichOptions`、`RollingInterval`
|
- 选项:`SharpLogOptions`、`FileOptions`、`EnrichOptions`、`RollingInterval`
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
- 需要清空默认 Provider 吗?
|
- 需要清空默认 Provider 吗?
|
||||||
- 如需仅使用 Sharp.Log,请在调用方执行 `builder.Logging.ClearProviders()`,然后 `AddSharpLog()`。
|
- 如需仅使用 Sharp.Log,请在调用方执行 `builder.Logging.ClearProviders()`,然后 `AddSharpLog()`。
|
||||||
- 一定要 Serilog 吗?
|
- 一定要 Serilog 吗?
|
||||||
- 默认启用(文件/JSON/丰富器等依赖)。如不想使用,可在配置中设置 `UseSerilog=false`,保留框架自带 Console(建议不要 `ClearProviders()`)。
|
- 默认启用(文件/JSON/丰富器等依赖)。如不想使用,可在配置中设置 `UseSerilog=false`,保留框架自带 Console(建议不要
|
||||||
|
`ClearProviders()`)。
|
||||||
- 与分布式追踪兼容吗?
|
- 与分布式追踪兼容吗?
|
||||||
- 兼容。自动注入 `Activity.TraceId/SpanId`;若使用 OpenTelemetry,日志会带 Trace 关联信息。
|
- 兼容。自动注入 `Activity.TraceId/SpanId`;若使用 OpenTelemetry,日志会带 Trace 关联信息。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- `Options/` 选项模型
|
- `Options/` 选项模型
|
||||||
- `Internal/` Serilog 配置与 Enricher
|
- `Internal/` Serilog 配置与 Enricher
|
||||||
- `Extensions/` 集成扩展
|
- `Extensions/` 集成扩展
|
||||||
|
|||||||
@@ -52,9 +52,15 @@ public static class SharpLogDefaults
|
|||||||
_inner = provider.GetRequiredService<ILoggerFactory>();
|
_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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
本指南说明如何在不同类型项目中以“最少代码”集成 Sharp.Log,并按需进行配置与扩展。
|
本指南说明如何在不同类型项目中以“最少代码”集成 Sharp.Log,并按需进行配置与扩展。
|
||||||
|
|
||||||
## 1. 控制台/Worker 应用
|
## 1. 控制台/Worker 应用
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -19,6 +20,7 @@ await host.RunAsync();
|
|||||||
```
|
```
|
||||||
|
|
||||||
## 2. ASP.NET Core 应用
|
## 2. ASP.NET Core 应用
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using Sharp.Log.Extensions;
|
using Sharp.Log.Extensions;
|
||||||
using Sharp.Log.Middleware;
|
using Sharp.Log.Middleware;
|
||||||
@@ -33,6 +35,7 @@ app.Run();
|
|||||||
```
|
```
|
||||||
|
|
||||||
## 3. 桌面/WPF/WinForms
|
## 3. 桌面/WPF/WinForms
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Sharp.Log;
|
using Sharp.Log;
|
||||||
@@ -50,7 +53,9 @@ logger.LogInformation("桌面程序已启动");
|
|||||||
无需 HostBuilder,即可获得默认 Console + 滚动文件输出;同样支持 `SharpLog` 配置节/环境变量。
|
无需 HostBuilder,即可获得默认 Console + 滚动文件输出;同样支持 `SharpLog` 配置节/环境变量。
|
||||||
|
|
||||||
## 4. 可选配置(appsettings.json)
|
## 4. 可选配置(appsettings.json)
|
||||||
|
|
||||||
添加 `SharpLog` 配置节(可省略)。
|
添加 `SharpLog` 配置节(可省略)。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"SharpLog": {
|
"SharpLog": {
|
||||||
@@ -79,11 +84,13 @@ logger.LogInformation("桌面程序已启动");
|
|||||||
```
|
```
|
||||||
|
|
||||||
环境变量覆盖示例:
|
环境变量覆盖示例:
|
||||||
|
|
||||||
- `SHARPLOG__MINLEVEL=Warning`
|
- `SHARPLOG__MINLEVEL=Warning`
|
||||||
- `SHARPLOG__FILE__PATH=/var/logs/app-.log`
|
- `SHARPLOG__FILE__PATH=/var/logs/app-.log`
|
||||||
- `SHARPLOG__FILE__RETAINEDFILECOUNTLIMIT=7`
|
- `SHARPLOG__FILE__RETAINEDFILECOUNTLIMIT=7`
|
||||||
|
|
||||||
## 5. 常见定制
|
## 5. 常见定制
|
||||||
|
|
||||||
- 仅文件输出(关闭 Console):
|
- 仅文件输出(关闭 Console):
|
||||||
```csharp
|
```csharp
|
||||||
builder.Logging.AddSharpLog(o => { o.UseConsole = false; });
|
builder.Logging.AddSharpLog(o => { o.UseConsole = false; });
|
||||||
@@ -102,27 +109,34 @@ logger.LogInformation("桌面程序已启动");
|
|||||||
```
|
```
|
||||||
|
|
||||||
## 6. 与现有 Provider 协作
|
## 6. 与现有 Provider 协作
|
||||||
|
|
||||||
- 默认不清空 Provider;若希望仅用 Sharp.Log,可在应用中先执行 `ClearProviders()` 再调用 `AddSharpLog()`。
|
- 默认不清空 Provider;若希望仅用 Sharp.Log,可在应用中先执行 `ClearProviders()` 再调用 `AddSharpLog()`。
|
||||||
- 如将 `UseSerilog` 设为 `false`,不会注入 Serilog Provider,请保留框架自带 Console Provider(不要 `ClearProviders()`)。
|
- 如将 `UseSerilog` 设为 `false`,不会注入 Serilog Provider,请保留框架自带 Console Provider(不要 `ClearProviders()`)。
|
||||||
|
|
||||||
## 7. 请求日志中间件
|
## 7. 请求日志中间件
|
||||||
|
|
||||||
启用后会记录:请求开始、结束(状态码与耗时)、异常。
|
启用后会记录:请求开始、结束(状态码与耗时)、异常。
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
app.UseSharpRequestLogging();
|
app.UseSharpRequestLogging();
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8. 结构化与上下文
|
## 8. 结构化与上下文
|
||||||
|
|
||||||
日志条目自动包含:
|
日志条目自动包含:
|
||||||
|
|
||||||
- `Application`、`Environment`、`MachineName`、`ProcessId`、`ThreadId`
|
- `Application`、`Environment`、`MachineName`、`ProcessId`、`ThreadId`
|
||||||
- `TraceId`、`SpanId`(来自 `Activity.Current`)
|
- `TraceId`、`SpanId`(来自 `Activity.Current`)
|
||||||
|
|
||||||
你可使用 `BeginScope` 传入业务上下文:
|
你可使用 `BeginScope` 传入业务上下文:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using var scope = logger.BeginScope(new Dictionary<string, object>{ ["UserId"] = userId });
|
using var scope = logger.BeginScope(new Dictionary<string, object>{ ["UserId"] = userId });
|
||||||
logger.LogInformation("User {UserId} logged in", userId);
|
logger.LogInformation("User {UserId} logged in", userId);
|
||||||
```
|
```
|
||||||
|
|
||||||
## 9. 测试建议
|
## 9. 测试建议
|
||||||
|
|
||||||
- 在测试中为文件输出设置临时目录:
|
- 在测试中为文件输出设置临时目录:
|
||||||
```csharp
|
```csharp
|
||||||
builder.Logging.AddSharpLog(o =>
|
builder.Logging.AddSharpLog(o =>
|
||||||
|
|||||||
@@ -7,4 +7,3 @@ public interface IAccessTokenProvider
|
|||||||
{
|
{
|
||||||
Task<string?> GetTokenAsync(CancellationToken cancellationToken = default);
|
Task<string?> GetTokenAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,20 +13,40 @@ public interface IHttpClient
|
|||||||
Task<string> GetStringAsync(string path, object? query = null, CancellationToken cancellationToken = default);
|
Task<string> GetStringAsync(string path, object? query = null, CancellationToken cancellationToken = default);
|
||||||
Task<byte[]> GetBytesAsync(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?> PostAsync<TRequest, TResponse>(string path, TRequest body,
|
||||||
Task<TResponse?> PutAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
Task<TResponse?> PatchAsync<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 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)
|
// Result-based convenience methods (non-throwing)
|
||||||
Task<Result<T>> GetResultAsync<T>(string path, object? query = null, CancellationToken cancellationToken = default);
|
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<string>> GetStringResultAsync(string path, object? query = null,
|
||||||
Task<Result<TResponse>> PostResultAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default);
|
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<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> 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);
|
||||||
}
|
}
|
||||||
@@ -10,4 +10,3 @@ public interface IHttpContentSerializer
|
|||||||
Task SerializeAsync<T>(Stream stream, T? value, CancellationToken cancellationToken = default);
|
Task SerializeAsync<T>(Stream stream, T? value, CancellationToken cancellationToken = default);
|
||||||
Task<T?> DeserializeAsync<T>(Stream stream, CancellationToken cancellationToken = default);
|
Task<T?> DeserializeAsync<T>(Stream stream, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4,4 +4,3 @@ public interface INetClientFactory
|
|||||||
{
|
{
|
||||||
IHttpClient Create(string name);
|
IHttpClient Create(string name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,10 +12,11 @@ public static class ConfigurationExtensions
|
|||||||
/// Bind NetClientOptions from ConfigManager (section "NetClient").
|
/// Bind NetClientOptions from ConfigManager (section "NetClient").
|
||||||
/// Falls back to SystemConfig for BaseAddress/Timeout when not supplied.
|
/// Falls back to SystemConfig for BaseAddress/Timeout when not supplied.
|
||||||
/// </summary>
|
/// </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 opt = cm.Get<NetClientOptions>(section, false) ?? new NetClientOptions();
|
||||||
var sys = cm.Get<SystemConfig>("System", validate: false);
|
var sys = cm.Get<SystemConfig>("System", false);
|
||||||
|
|
||||||
if (opt.BaseAddress is null)
|
if (opt.BaseAddress is null)
|
||||||
{
|
{
|
||||||
@@ -23,10 +24,7 @@ public static class ConfigurationExtensions
|
|||||||
opt.BaseAddress = new Uri(baseUrl);
|
opt.BaseAddress = new Uri(baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opt.Timeout.TotalSeconds <= 0)
|
if (opt.Timeout.TotalSeconds <= 0) opt.Timeout = TimeSpan.FromSeconds(Math.Max(1, sys.TimeoutSeconds));
|
||||||
{
|
|
||||||
opt.Timeout = TimeSpan.FromSeconds(Math.Max(1, sys.TimeoutSeconds));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delegate to existing AddNetClient(Action<NetClientOptions>)
|
// Delegate to existing AddNetClient(Action<NetClientOptions>)
|
||||||
services.AddNetClient(o =>
|
services.AddNetClient(o =>
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ public static class ServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
services.TryAddTransient<LoggingHandler>();
|
services.TryAddTransient<LoggingHandler>();
|
||||||
services.TryAddTransient<BearerTokenAuthenticationHandler>();
|
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>();
|
services.TryAddSingleton<INetClientFactory, NetClientFactory>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,8 @@ public static class ServiceCollectionExtensions
|
|||||||
var options = new NetClientOptions();
|
var options = new NetClientOptions();
|
||||||
configure(options);
|
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)
|
if (options.EnableLogging)
|
||||||
builder.AddHttpMessageHandler<LoggingHandler>();
|
builder.AddHttpMessageHandler<LoggingHandler>();
|
||||||
@@ -49,7 +51,8 @@ public static class ServiceCollectionExtensions
|
|||||||
return builder;
|
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();
|
services.AddNetClientCore();
|
||||||
var options = new NetClientOptions();
|
var options = new NetClientOptions();
|
||||||
@@ -96,14 +99,10 @@ public static class ServiceCollectionExtensions
|
|||||||
.HandleTransientHttpError(); // 5xx + HttpRequestException + 408
|
.HandleTransientHttpError(); // 5xx + HttpRequestException + 408
|
||||||
|
|
||||||
if (retry.Handle429)
|
if (retry.Handle429)
|
||||||
{
|
|
||||||
policyBuilder = policyBuilder.OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests);
|
policyBuilder = policyBuilder.OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests);
|
||||||
}
|
|
||||||
|
|
||||||
if (retry.HandleRequestTimeout)
|
if (retry.HandleRequestTimeout)
|
||||||
{
|
|
||||||
policyBuilder = policyBuilder.OrResult(msg => msg.StatusCode == HttpStatusCode.RequestTimeout);
|
policyBuilder = policyBuilder.OrResult(msg => msg.StatusCode == HttpStatusCode.RequestTimeout);
|
||||||
}
|
|
||||||
|
|
||||||
return policyBuilder.WaitAndRetryAsync(delay);
|
return policyBuilder.WaitAndRetryAsync(delay);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,3 @@ public class ApiException : Exception
|
|||||||
ResponseContent = content;
|
ResponseContent = content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,18 +15,16 @@ public class BearerTokenAuthenticationHandler : DelegatingHandler
|
|||||||
_tokenProvider = tokenProvider;
|
_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)
|
if (request.Headers.Authorization is null)
|
||||||
{
|
{
|
||||||
var token = await _tokenProvider.GetTokenAsync(cancellationToken).ConfigureAwait(false);
|
var token = await _tokenProvider.GetTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (!string.IsNullOrWhiteSpace(token))
|
if (!string.IsNullOrWhiteSpace(token))
|
||||||
{
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,13 +14,10 @@ public class CorrelationIdHandler : DelegatingHandler
|
|||||||
_headerName = headerName;
|
_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))
|
if (!request.Headers.Contains(_headerName)) request.Headers.Add(_headerName, Guid.NewGuid().ToString("N"));
|
||||||
{
|
|
||||||
request.Headers.Add(_headerName, Guid.NewGuid().ToString("N"));
|
|
||||||
}
|
|
||||||
return base.SendAsync(request, cancellationToken);
|
return base.SendAsync(request, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +16,8 @@ public class LoggingHandler : DelegatingHandler
|
|||||||
_logger = logger;
|
_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();
|
var sw = Stopwatch.StartNew();
|
||||||
try
|
try
|
||||||
@@ -24,15 +25,16 @@ public class LoggingHandler : DelegatingHandler
|
|||||||
_logger.LogDebug("HTTP {Method} {Uri}", request.Method, request.RequestUri);
|
_logger.LogDebug("HTTP {Method} {Uri}", request.Method, request.RequestUri);
|
||||||
var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
sw.Stop();
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
sw.Stop();
|
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;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,8 +26,11 @@ public class DefaultNetClient : IHttpClient
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default)
|
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||||
=> _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<T?> GetAsync<T>(string path, object? query = null, CancellationToken cancellationToken = default)
|
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);
|
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);
|
var uri = BuildUri(path, query);
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
|
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
@@ -48,7 +52,8 @@ public class DefaultNetClient : IHttpClient
|
|||||||
return await resp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
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);
|
var uri = BuildUri(path, query);
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
|
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
@@ -57,14 +62,23 @@ public class DefaultNetClient : IHttpClient
|
|||||||
return await resp.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
return await resp.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default)
|
public Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body,
|
||||||
=> SendWithBodyAsync<TRequest, TResponse>(HttpMethod.Post, path, body, cancellationToken);
|
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)
|
public Task<TResponse?> PutAsync<TRequest, TResponse>(string path, TRequest body,
|
||||||
=> SendWithBodyAsync<TRequest, TResponse>(HttpMethod.Put, path, body, cancellationToken);
|
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)
|
public Task<TResponse?> PatchAsync<TRequest, TResponse>(string path, TRequest body,
|
||||||
=> SendWithBodyAsync<TRequest, TResponse>(HttpMethod.Patch, path, body, cancellationToken);
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return SendWithBodyAsync<TRequest, TResponse>(HttpMethod.Patch, path, body, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(string path, object? query = null, CancellationToken cancellationToken = default)
|
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);
|
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);
|
var uri = BuildUri(path, null);
|
||||||
using var content = new MultipartFormDataContent();
|
using var content = new MultipartFormDataContent();
|
||||||
@@ -86,7 +101,8 @@ public class DefaultNetClient : IHttpClient
|
|||||||
return await _serializer.DeserializeAsync<TResponse>(stream, cancellationToken).ConfigureAwait(false);
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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!);
|
return Result.Success(value!);
|
||||||
}
|
}
|
||||||
catch (ApiException ex)
|
catch (ApiException ex)
|
||||||
@@ -243,7 +267,9 @@ public class DefaultNetClient : IHttpClient
|
|||||||
string? content = null;
|
string? content = null;
|
||||||
try
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -253,14 +279,16 @@ public class DefaultNetClient : IHttpClient
|
|||||||
throw new ApiException($"Request failed with status {(int)response.StatusCode}", response, content);
|
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);
|
var uri = BuildUri(path, null);
|
||||||
using var ms = new MemoryStream();
|
using var ms = new MemoryStream();
|
||||||
await _serializer.SerializeAsync(ms, body, cancellationToken).ConfigureAwait(false);
|
await _serializer.SerializeAsync(ms, body, cancellationToken).ConfigureAwait(false);
|
||||||
ms.Position = 0;
|
ms.Position = 0;
|
||||||
using var content = new StreamContent(ms);
|
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 req = new HttpRequestMessage(method, uri) { Content = content };
|
||||||
using var resp = await SendAsync(req, cancellationToken).ConfigureAwait(false);
|
using var resp = await SendAsync(req, cancellationToken).ConfigureAwait(false);
|
||||||
await EnsureSuccess(resp).ConfigureAwait(false);
|
await EnsureSuccess(resp).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -16,11 +16,10 @@ public class NetClientFactory : INetClientFactory
|
|||||||
|
|
||||||
public IHttpClient Create(string name)
|
public IHttpClient Create(string name)
|
||||||
{
|
{
|
||||||
var httpFactory = _services.GetRequiredService<System.Net.Http.IHttpClientFactory>();
|
var httpFactory = _services.GetRequiredService<IHttpClientFactory>();
|
||||||
var client = httpFactory.CreateClient(name);
|
var client = httpFactory.CreateClient(name);
|
||||||
var serializer = _services.GetRequiredService<IHttpContentSerializer>();
|
var serializer = _services.GetRequiredService<IHttpContentSerializer>();
|
||||||
var logger = _services.GetRequiredService<ILogger<DefaultNetClient>>();
|
var logger = _services.GetRequiredService<ILogger<DefaultNetClient>>();
|
||||||
return new DefaultNetClient(client, serializer, logger);
|
return new DefaultNetClient(client, serializer, logger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,4 +28,3 @@ internal class SystemTextJsonContentSerializer : IHttpContentSerializer
|
|||||||
return await JsonSerializer.DeserializeAsync<T>(stream, _options, cancellationToken).ConfigureAwait(false);
|
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 null) return string.Empty;
|
||||||
|
|
||||||
if (query is IEnumerable<KeyValuePair<string, object?>> pairs)
|
if (query is IEnumerable<KeyValuePair<string, object?>> pairs) return BuildFromPairs(pairs);
|
||||||
{
|
|
||||||
return BuildFromPairs(pairs);
|
|
||||||
}
|
|
||||||
|
|
||||||
var dict = new Dictionary<string, object?>();
|
var dict = new Dictionary<string, object?>();
|
||||||
foreach (var prop in query.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public))
|
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)
|
private static string BuildFromPairs(IEnumerable<KeyValuePair<string, object?>> pairs)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
void AppendPair(string key, string value)
|
void AppendPair(string key, string value)
|
||||||
{
|
{
|
||||||
if (sb.Length > 0) sb.Append('&');
|
if (sb.Length > 0) sb.Append('&');
|
||||||
@@ -48,22 +46,16 @@ internal static class QueryStringHelper
|
|||||||
if (val is null) continue;
|
if (val is null) continue;
|
||||||
|
|
||||||
if (val is string s)
|
if (val is string s)
|
||||||
{
|
|
||||||
AppendPair(key, s);
|
AppendPair(key, s);
|
||||||
}
|
|
||||||
else if (val is IEnumerable enumerable && val is not byte[])
|
else if (val is IEnumerable enumerable && val is not byte[])
|
||||||
{
|
|
||||||
foreach (var item in enumerable)
|
foreach (var item in enumerable)
|
||||||
{
|
{
|
||||||
if (item is null) continue;
|
if (item is null) continue;
|
||||||
AppendPair(key, ConvertToString(item));
|
AppendPair(key, ConvertToString(item));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
AppendPair(key, ConvertToString(val));
|
AppendPair(key, ConvertToString(val));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
@@ -80,4 +72,3 @@ internal static class QueryStringHelper
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,15 +13,15 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" 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="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0"/>
|
||||||
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.*" />
|
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.*"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Common\Common.csproj" />
|
<ProjectReference Include="..\Common\Common.csproj"/>
|
||||||
<ProjectReference Include="..\Config\Config.csproj" />
|
<ProjectReference Include="..\Config\Config.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -11,4 +11,3 @@ public class RetryOptions
|
|||||||
public bool Handle429 { get; set; } = true;
|
public bool Handle429 { get; set; } = true;
|
||||||
public bool HandleRequestTimeout { get; set; } = true; // 408
|
public bool HandleRequestTimeout { get; set; } = true; // 408
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,12 +57,14 @@ NetServer/
|
|||||||
### 1. HTTP 服务器 (Server/)
|
### 1. HTTP 服务器 (Server/)
|
||||||
|
|
||||||
**HttpServer.cs**
|
**HttpServer.cs**
|
||||||
|
|
||||||
- 基于 `HttpListener` 的 HTTP 服务器实现
|
- 基于 `HttpListener` 的 HTTP 服务器实现
|
||||||
- 管理中间件管道和路由表
|
- 管理中间件管道和路由表
|
||||||
- 处理请求的并发执行
|
- 处理请求的并发执行
|
||||||
- 支持依赖注入
|
- 支持依赖注入
|
||||||
|
|
||||||
**HttpServerOptions.cs**
|
**HttpServerOptions.cs**
|
||||||
|
|
||||||
- 服务器配置选项类
|
- 服务器配置选项类
|
||||||
- 支持 Fluent API 配置
|
- 支持 Fluent API 配置
|
||||||
- 管理中间件和控制器程序集
|
- 管理中间件和控制器程序集
|
||||||
@@ -70,18 +72,21 @@ NetServer/
|
|||||||
### 2. 路由系统 (Routing/)
|
### 2. 路由系统 (Routing/)
|
||||||
|
|
||||||
**RouteTable.cs**
|
**RouteTable.cs**
|
||||||
|
|
||||||
- 路由注册和管理
|
- 路由注册和管理
|
||||||
- 基于特性的路由发现
|
- 基于特性的路由发现
|
||||||
- 路由匹配(支持路径参数)
|
- 路由匹配(支持路径参数)
|
||||||
- 路由元数据收集
|
- 路由元数据收集
|
||||||
|
|
||||||
**RouteInfo.cs**
|
**RouteInfo.cs**
|
||||||
|
|
||||||
- 路由信息数据模型
|
- 路由信息数据模型
|
||||||
- 包含授权、限流、缓存等配置
|
- 包含授权、限流、缓存等配置
|
||||||
|
|
||||||
### 3. 中间件系统 (Middleware/)
|
### 3. 中间件系统 (Middleware/)
|
||||||
|
|
||||||
**执行顺序:**
|
**执行顺序:**
|
||||||
|
|
||||||
1. ExceptionHandling → 全局异常处理
|
1. ExceptionHandling → 全局异常处理
|
||||||
2. Logging → 请求日志
|
2. Logging → 请求日志
|
||||||
3. CORS → 跨域处理
|
3. CORS → 跨域处理
|
||||||
@@ -96,6 +101,7 @@ NetServer/
|
|||||||
|
|
||||||
**BaseController.cs**
|
**BaseController.cs**
|
||||||
提供以下辅助方法:
|
提供以下辅助方法:
|
||||||
|
|
||||||
- 请求读取:`GetQueryParam()`, `ReadJsonBodyAsync()`
|
- 请求读取:`GetQueryParam()`, `ReadJsonBodyAsync()`
|
||||||
- 响应写入:`WriteJsonResponseAsync()`, `WriteSuccessResponseAsync()`
|
- 响应写入:`WriteJsonResponseAsync()`, `WriteSuccessResponseAsync()`
|
||||||
- 快速响应:`NotFoundAsync()`, `UnauthorizedAsync()`
|
- 快速响应:`NotFoundAsync()`, `UnauthorizedAsync()`
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ public abstract class HttpMethodAttribute : Attribute
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class HttpGetAttribute : HttpMethodAttribute
|
public class HttpGetAttribute : HttpMethodAttribute
|
||||||
{
|
{
|
||||||
public HttpGetAttribute(string? template = null) : base("GET", template) { }
|
public HttpGetAttribute(string? template = null) : base("GET", template)
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -29,7 +31,9 @@ public class HttpGetAttribute : HttpMethodAttribute
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class HttpPostAttribute : HttpMethodAttribute
|
public class HttpPostAttribute : HttpMethodAttribute
|
||||||
{
|
{
|
||||||
public HttpPostAttribute(string? template = null) : base("POST", template) { }
|
public HttpPostAttribute(string? template = null) : base("POST", template)
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -37,7 +41,9 @@ public class HttpPostAttribute : HttpMethodAttribute
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class HttpPutAttribute : HttpMethodAttribute
|
public class HttpPutAttribute : HttpMethodAttribute
|
||||||
{
|
{
|
||||||
public HttpPutAttribute(string? template = null) : base("PUT", template) { }
|
public HttpPutAttribute(string? template = null) : base("PUT", template)
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -45,7 +51,9 @@ public class HttpPutAttribute : HttpMethodAttribute
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class HttpDeleteAttribute : HttpMethodAttribute
|
public class HttpDeleteAttribute : HttpMethodAttribute
|
||||||
{
|
{
|
||||||
public HttpDeleteAttribute(string? template = null) : base("DELETE", template) { }
|
public HttpDeleteAttribute(string? template = null) : base("DELETE", template)
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -53,7 +61,9 @@ public class HttpDeleteAttribute : HttpMethodAttribute
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class HttpPatchAttribute : HttpMethodAttribute
|
public class HttpPatchAttribute : HttpMethodAttribute
|
||||||
{
|
{
|
||||||
public HttpPatchAttribute(string? template = null) : base("PATCH", template) { }
|
public HttpPatchAttribute(string? template = null) : base("PATCH", template)
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -61,7 +71,9 @@ public class HttpPatchAttribute : HttpMethodAttribute
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class HttpHeadAttribute : HttpMethodAttribute
|
public class HttpHeadAttribute : HttpMethodAttribute
|
||||||
{
|
{
|
||||||
public HttpHeadAttribute(string? template = null) : base("HEAD", template) { }
|
public HttpHeadAttribute(string? template = null) : base("HEAD", template)
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -69,5 +81,7 @@ public class HttpHeadAttribute : HttpMethodAttribute
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class HttpOptionsAttribute : HttpMethodAttribute
|
public class HttpOptionsAttribute : HttpMethodAttribute
|
||||||
{
|
{
|
||||||
public HttpOptionsAttribute(string? template = null) : base("OPTIONS", template) { }
|
public HttpOptionsAttribute(string? template = null) : base("OPTIONS", template)
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ public abstract class BaseController
|
|||||||
context.Response.StatusCode = statusCode;
|
context.Response.StatusCode = statusCode;
|
||||||
context.Response.ContentType = "application/json; charset=utf-8";
|
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);
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
context.Response.ContentLength = bytes.Length;
|
context.Response.ContentLength = bytes.Length;
|
||||||
@@ -70,7 +70,8 @@ public abstract class BaseController
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 写入文本响应
|
/// 写入文本响应
|
||||||
/// </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.StatusCode = statusCode;
|
||||||
context.Response.ContentType = $"{contentType}; charset=utf-8";
|
context.Response.ContentType = $"{contentType}; charset=utf-8";
|
||||||
@@ -91,7 +92,8 @@ public abstract class BaseController
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 写入错误响应
|
/// 写入错误响应
|
||||||
/// </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);
|
var response = ResultExtensions.HttpFailure<object>(message, errorCode);
|
||||||
await WriteJsonResponseAsync(context, response, statusCode);
|
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)
|
protected void SetCookie(HttpContext context, string name, string value, int? expiresInSeconds = null)
|
||||||
{
|
{
|
||||||
var cookie = new System.Net.Cookie(name, value);
|
var cookie = new System.Net.Cookie(name, value);
|
||||||
if (expiresInSeconds.HasValue)
|
if (expiresInSeconds.HasValue) cookie.Expires = DateTime.UtcNow.AddSeconds(expiresInSeconds.Value);
|
||||||
{
|
|
||||||
cookie.Expires = DateTime.UtcNow.AddSeconds(expiresInSeconds.Value);
|
|
||||||
}
|
|
||||||
context.Response.Cookies.Add(cookie);
|
context.Response.Cookies.Add(cookie);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ public static class ServiceCollectionExtensions
|
|||||||
/// Adds HttpServerOptions bound from ConfigManager (section "NetServer").
|
/// Adds HttpServerOptions bound from ConfigManager (section "NetServer").
|
||||||
/// If no ListenUrls specified, uses SystemConfig to build a default base URL.
|
/// If no ListenUrls specified, uses SystemConfig to build a default base URL.
|
||||||
/// </summary>
|
/// </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 options = cm.Get<HttpServerOptions>(section, false) ?? new HttpServerOptions();
|
||||||
var sys = cm.Get<SystemConfig>("System", validate: false);
|
var sys = cm.Get<SystemConfig>("System", false);
|
||||||
|
|
||||||
if (options.ListenUrls == null || options.ListenUrls.Count == 0)
|
if (options.ListenUrls == null || options.ListenUrls.Count == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -90,5 +90,8 @@ public class HttpResult<T>
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取原始 Result
|
/// 获取原始 Result
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Result<T> ToResult() => _result;
|
public Result<T> ToResult()
|
||||||
|
{
|
||||||
|
return _result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,8 @@ public static class ServiceCollectionExtensions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 添加 HTTP 服务器服务
|
/// 添加 HTTP 服务器服务
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddHttpServer(this IServiceCollection services, Action<Server.HttpServerOptions>? configure = null)
|
public static IServiceCollection AddHttpServer(this IServiceCollection services,
|
||||||
|
Action<Server.HttpServerOptions>? configure = null)
|
||||||
{
|
{
|
||||||
var options = new Server.HttpServerOptions();
|
var options = new Server.HttpServerOptions();
|
||||||
configure?.Invoke(options);
|
configure?.Invoke(options);
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ public class HttpServerBuilder
|
|||||||
public HttpServerBuilder UseUrls(params string[] urls)
|
public HttpServerBuilder UseUrls(params string[] urls)
|
||||||
{
|
{
|
||||||
_options.ListenUrls.Clear();
|
_options.ListenUrls.Clear();
|
||||||
foreach (var url in urls)
|
foreach (var url in urls) _options.AddListenUrl(url);
|
||||||
{
|
|
||||||
_options.AddListenUrl(url);
|
|
||||||
}
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,7 @@ public class AuthenticationMiddleware : IMiddleware
|
|||||||
if (!string.IsNullOrEmpty(token) && _authProvider != null)
|
if (!string.IsNullOrEmpty(token) && _authProvider != null)
|
||||||
{
|
{
|
||||||
var principal = await _authProvider.AuthenticateAsync(token);
|
var principal = await _authProvider.AuthenticateAsync(token);
|
||||||
if (principal != null)
|
if (principal != null) context.User = principal;
|
||||||
{
|
|
||||||
context.User = principal;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
@@ -40,23 +37,15 @@ public class AuthenticationMiddleware : IMiddleware
|
|||||||
// 从 Authorization 头获取 Bearer Token
|
// 从 Authorization 头获取 Bearer Token
|
||||||
var authHeader = context.Request.Headers["Authorization"];
|
var authHeader = context.Request.Headers["Authorization"];
|
||||||
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
|
||||||
return authHeader.Substring(7);
|
return authHeader.Substring(7);
|
||||||
}
|
|
||||||
|
|
||||||
// 从 Cookie 获取 Token
|
// 从 Cookie 获取 Token
|
||||||
var cookie = context.Request.Cookies["auth_token"];
|
var cookie = context.Request.Cookies["auth_token"];
|
||||||
if (cookie != null)
|
if (cookie != null) return cookie.Value;
|
||||||
{
|
|
||||||
return cookie.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从查询参数获取 Token
|
// 从查询参数获取 Token
|
||||||
var queryToken = context.Request.QueryString["token"];
|
var queryToken = context.Request.QueryString["token"];
|
||||||
if (!string.IsNullOrEmpty(queryToken))
|
if (!string.IsNullOrEmpty(queryToken)) return queryToken;
|
||||||
{
|
|
||||||
return queryToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -70,10 +59,12 @@ public class AuthorizationMiddleware : IMiddleware
|
|||||||
public async Task InvokeAsync(HttpContext context, Func<Task> next)
|
public async Task InvokeAsync(HttpContext context, Func<Task> next)
|
||||||
{
|
{
|
||||||
// 检查是否需要授权(由路由系统设置)
|
// 检查是否需要授权(由路由系统设置)
|
||||||
if (context.Items.TryGetValue("RequireAuthorization", out var requireAuthObj) && requireAuthObj is bool requireAuth && requireAuth)
|
if (context.Items.TryGetValue("RequireAuthorization", out var requireAuthObj) &&
|
||||||
|
requireAuthObj is bool requireAuth && requireAuth)
|
||||||
{
|
{
|
||||||
// 检查是否允许匿名访问
|
// 检查是否允许匿名访问
|
||||||
if (context.Items.TryGetValue("AllowAnonymous", out var allowAnonObj) && allowAnonObj is bool allowAnon && allowAnon)
|
if (context.Items.TryGetValue("AllowAnonymous", out var allowAnonObj) && allowAnonObj is bool allowAnon &&
|
||||||
|
allowAnon)
|
||||||
{
|
{
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
@@ -87,7 +78,8 @@ public class AuthorizationMiddleware : IMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证角色
|
// 验证角色
|
||||||
if (context.Items.TryGetValue("RequiredRoles", out var rolesObj) && rolesObj is string[] roles && roles.Length > 0)
|
if (context.Items.TryGetValue("RequiredRoles", out var rolesObj) && rolesObj is string[] roles &&
|
||||||
|
roles.Length > 0)
|
||||||
{
|
{
|
||||||
var hasRole = roles.Any(role => context.User.IsInRole(role));
|
var hasRole = roles.Any(role => context.User.IsInRole(role));
|
||||||
if (!hasRole)
|
if (!hasRole)
|
||||||
@@ -98,7 +90,8 @@ public class AuthorizationMiddleware : IMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证权限(基于 Claims)
|
// 验证权限(基于 Claims)
|
||||||
if (context.Items.TryGetValue("RequiredPermissions", out var permsObj) && permsObj is string[] permissions && permissions.Length > 0)
|
if (context.Items.TryGetValue("RequiredPermissions", out var permsObj) &&
|
||||||
|
permsObj is string[] permissions && permissions.Length > 0)
|
||||||
{
|
{
|
||||||
var userPermissions = context.User.Claims
|
var userPermissions = context.User.Claims
|
||||||
.Where(c => c.Type == "permission")
|
.Where(c => c.Type == "permission")
|
||||||
@@ -123,7 +116,7 @@ public class AuthorizationMiddleware : IMiddleware
|
|||||||
context.Response.ContentType = "application/json; charset=utf-8";
|
context.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
|
||||||
var response = ResultExtensions.HttpFailure<object>(message, 401);
|
var response = ResultExtensions.HttpFailure<object>(message, 401);
|
||||||
var json = Common.Utils.JsonHelper.ToJson(response, useCompact: true);
|
var json = Common.Utils.JsonHelper.ToJson(response, true);
|
||||||
var bytes = Encoding.UTF8.GetBytes(json);
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
context.Response.ContentLength = bytes.Length;
|
context.Response.ContentLength = bytes.Length;
|
||||||
@@ -136,7 +129,7 @@ public class AuthorizationMiddleware : IMiddleware
|
|||||||
context.Response.ContentType = "application/json; charset=utf-8";
|
context.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
|
||||||
var response = ResultExtensions.HttpFailure<object>(message, 403);
|
var response = ResultExtensions.HttpFailure<object>(message, 403);
|
||||||
var json = Common.Utils.JsonHelper.ToJson(response, useCompact: true);
|
var json = Common.Utils.JsonHelper.ToJson(response, true);
|
||||||
var bytes = Encoding.UTF8.GetBytes(json);
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
context.Response.ContentLength = bytes.Length;
|
context.Response.ContentLength = bytes.Length;
|
||||||
|
|||||||
@@ -28,34 +28,24 @@ public class CorsMiddleware : IMiddleware
|
|||||||
_options.AllowAnyOrigin ? "*" : origin);
|
_options.AllowAnyOrigin ? "*" : origin);
|
||||||
|
|
||||||
if (_options.AllowCredentials && !_options.AllowAnyOrigin)
|
if (_options.AllowCredentials && !_options.AllowAnyOrigin)
|
||||||
{
|
|
||||||
context.Response.AddHeader("Access-Control-Allow-Credentials", "true");
|
context.Response.AddHeader("Access-Control-Allow-Credentials", "true");
|
||||||
}
|
|
||||||
|
|
||||||
if (_options.AllowedHeaders.Length > 0)
|
if (_options.AllowedHeaders.Length > 0)
|
||||||
{
|
|
||||||
context.Response.AddHeader("Access-Control-Allow-Headers",
|
context.Response.AddHeader("Access-Control-Allow-Headers",
|
||||||
string.Join(", ", _options.AllowedHeaders));
|
string.Join(", ", _options.AllowedHeaders));
|
||||||
}
|
|
||||||
|
|
||||||
if (_options.AllowedMethods.Length > 0)
|
if (_options.AllowedMethods.Length > 0)
|
||||||
{
|
|
||||||
context.Response.AddHeader("Access-Control-Allow-Methods",
|
context.Response.AddHeader("Access-Control-Allow-Methods",
|
||||||
string.Join(", ", _options.AllowedMethods));
|
string.Join(", ", _options.AllowedMethods));
|
||||||
}
|
|
||||||
|
|
||||||
if (_options.ExposedHeaders.Length > 0)
|
if (_options.ExposedHeaders.Length > 0)
|
||||||
{
|
|
||||||
context.Response.AddHeader("Access-Control-Expose-Headers",
|
context.Response.AddHeader("Access-Control-Expose-Headers",
|
||||||
string.Join(", ", _options.ExposedHeaders));
|
string.Join(", ", _options.ExposedHeaders));
|
||||||
}
|
|
||||||
|
|
||||||
if (_options.MaxAge > 0)
|
if (_options.MaxAge > 0)
|
||||||
{
|
|
||||||
context.Response.AddHeader("Access-Control-Max-Age",
|
context.Response.AddHeader("Access-Control-Max-Age",
|
||||||
_options.MaxAge.ToString());
|
_options.MaxAge.ToString());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 处理预检请求
|
// 处理预检请求
|
||||||
if (context.Request.HttpMethod.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase))
|
if (context.Request.HttpMethod.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public class ExceptionHandlingMiddleware : IMiddleware
|
|||||||
var response = ResultExtensions.HttpFailure<object>("An internal server error occurred", 500);
|
var response = ResultExtensions.HttpFailure<object>("An internal server error occurred", 500);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var json = Common.Utils.JsonHelper.ToJson(response, useCompact: true);
|
var json = Common.Utils.JsonHelper.ToJson(response, true);
|
||||||
var bytes = Encoding.UTF8.GetBytes(json);
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
context.Response.ContentLength = bytes.Length;
|
context.Response.ContentLength = bytes.Length;
|
||||||
|
|||||||
@@ -37,10 +37,7 @@ public class MiddlewarePipeline
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void Use(Func<HttpContext, Func<Task>, Task> middleware)
|
public void Use(Func<HttpContext, Func<Task>, Task> middleware)
|
||||||
{
|
{
|
||||||
Use(next =>
|
Use(next => { return async context => await middleware(context, async () => await next(context)); });
|
||||||
{
|
|
||||||
return async context => await middleware(context, async () => await next(context));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -57,10 +54,7 @@ public class MiddlewarePipeline
|
|||||||
RequestDelegate pipeline = context => Task.CompletedTask;
|
RequestDelegate pipeline = context => Task.CompletedTask;
|
||||||
|
|
||||||
// 逆序构建管道
|
// 逆序构建管道
|
||||||
for (int i = _middlewares.Count - 1; i >= 0; i--)
|
for (var i = _middlewares.Count - 1; i >= 0; i--) pipeline = _middlewares[i](pipeline);
|
||||||
{
|
|
||||||
pipeline = _middlewares[i](pipeline);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pipeline;
|
return pipeline;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ public class RateLimitMiddleware : IMiddleware
|
|||||||
public async Task InvokeAsync(HttpContext context, Func<Task> next)
|
public async Task InvokeAsync(HttpContext context, Func<Task> next)
|
||||||
{
|
{
|
||||||
// 检查是否有限流配置
|
// 检查是否有限流配置
|
||||||
if (!context.Items.TryGetValue("RateLimitConfig", out var configObj) || configObj is not Routing.RateLimitConfig config)
|
if (!context.Items.TryGetValue("RateLimitConfig", out var configObj) ||
|
||||||
|
configObj is not Routing.RateLimitConfig config)
|
||||||
{
|
{
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
@@ -70,10 +71,7 @@ public class RateLimitMiddleware : IMiddleware
|
|||||||
.Select(kvp => kvp.Key)
|
.Select(kvp => kvp.Key)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
foreach (var key in expiredKeys)
|
foreach (var key in expiredKeys) _counters.TryRemove(key, out _);
|
||||||
{
|
|
||||||
_counters.TryRemove(key, out _);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +82,7 @@ public class RateLimitMiddleware : IMiddleware
|
|||||||
context.Response.AddHeader("Retry-After", retryAfter.ToString());
|
context.Response.AddHeader("Retry-After", retryAfter.ToString());
|
||||||
|
|
||||||
var response = ResultExtensions.HttpFailure<object>("Too many requests. Please try again later.", 429);
|
var response = ResultExtensions.HttpFailure<object>("Too many requests. Please try again later.", 429);
|
||||||
var json = Common.Utils.JsonHelper.ToJson(response, useCompact: true);
|
var json = Common.Utils.JsonHelper.ToJson(response, true);
|
||||||
var bytes = Encoding.UTF8.GetBytes(json);
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
context.Response.ContentLength = bytes.Length;
|
context.Response.ContentLength = bytes.Length;
|
||||||
@@ -123,10 +121,7 @@ internal class RateLimitCounter
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否超过限制
|
// 检查是否超过限制
|
||||||
if (_count >= maxRequests)
|
if (_count >= maxRequests) return false;
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_count++;
|
_count++;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -97,10 +97,8 @@ public class ResponseCacheMiddleware : IMiddleware
|
|||||||
|
|
||||||
// 如果指定了自定义缓存键
|
// 如果指定了自定义缓存键
|
||||||
if (!string.IsNullOrEmpty(config.CacheKey))
|
if (!string.IsNullOrEmpty(config.CacheKey))
|
||||||
{
|
|
||||||
// TODO: 支持占位符替换,如 {userId}
|
// TODO: 支持占位符替换,如 {userId}
|
||||||
return config.CacheKey;
|
return config.CacheKey;
|
||||||
}
|
|
||||||
|
|
||||||
// 默认使用路径作为键
|
// 默认使用路径作为键
|
||||||
var key = $"cache:{path}";
|
var key = $"cache:{path}";
|
||||||
@@ -124,10 +122,7 @@ public class ResponseCacheMiddleware : IMiddleware
|
|||||||
context.Response.ContentType = cachedResponse.ContentType;
|
context.Response.ContentType = cachedResponse.ContentType;
|
||||||
context.Response.AddHeader("X-Cache", "HIT");
|
context.Response.AddHeader("X-Cache", "HIT");
|
||||||
|
|
||||||
foreach (var header in cachedResponse.Headers)
|
foreach (var header in cachedResponse.Headers) context.Response.AddHeader(header.Key, header.Value);
|
||||||
{
|
|
||||||
context.Response.AddHeader(header.Key, header.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
var body = cachedResponse.Body ?? Array.Empty<byte>();
|
var body = cachedResponse.Body ?? Array.Empty<byte>();
|
||||||
context.Response.ContentLength = body.Length;
|
context.Response.ContentLength = body.Length;
|
||||||
@@ -167,10 +162,7 @@ public class InMemoryResponseCache : IResponseCache
|
|||||||
{
|
{
|
||||||
if (_cache.TryGetValue(key, out var entry))
|
if (_cache.TryGetValue(key, out var entry))
|
||||||
{
|
{
|
||||||
if (entry.ExpiresAt > DateTime.UtcNow)
|
if (entry.ExpiresAt > DateTime.UtcNow) return Task.FromResult<CachedResponse?>(entry.Response);
|
||||||
{
|
|
||||||
return Task.FromResult<CachedResponse?>(entry.Response);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过期,移除
|
// 过期,移除
|
||||||
_cache.TryRemove(key, out _);
|
_cache.TryRemove(key, out _);
|
||||||
|
|||||||
@@ -86,10 +86,7 @@ public class HttpRequestWrapper
|
|||||||
|
|
||||||
// 从 X-Real-IP 获取
|
// 从 X-Real-IP 获取
|
||||||
var realIp = Headers["X-Real-IP"];
|
var realIp = Headers["X-Real-IP"];
|
||||||
if (!string.IsNullOrEmpty(realIp))
|
if (!string.IsNullOrEmpty(realIp)) return realIp.Trim();
|
||||||
{
|
|
||||||
return realIp.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接从远程端点获取
|
// 直接从远程端点获取
|
||||||
return RemoteEndPoint.Address.ToString();
|
return RemoteEndPoint.Address.ToString();
|
||||||
@@ -152,10 +149,7 @@ public class HttpResponseWrapper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task FlushCapturedBodyAsync(Stream captured, CancellationToken cancellationToken = default)
|
public async Task FlushCapturedBodyAsync(Stream captured, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (captured.CanSeek)
|
if (captured.CanSeek) captured.Position = 0;
|
||||||
{
|
|
||||||
captured.Position = 0;
|
|
||||||
}
|
|
||||||
await captured.CopyToAsync(_response.OutputStream, cancellationToken).ConfigureAwait(false);
|
await captured.CopyToAsync(_response.OutputStream, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Common\Common.csproj" />
|
<ProjectReference Include="..\Common\Common.csproj"/>
|
||||||
<ProjectReference Include="..\Config\Config.csproj" />
|
<ProjectReference Include="..\Config\Config.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
#### 1.1 修改 BaseController
|
#### 1.1 修改 BaseController
|
||||||
|
|
||||||
**修改前:**
|
**修改前:**
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
protected async Task WriteSuccessResponseAsync<T>(HttpContext context, T data, string? message = null)
|
protected async Task WriteSuccessResponseAsync<T>(HttpContext context, T data, string? message = null)
|
||||||
{
|
{
|
||||||
@@ -22,6 +23,7 @@ protected async Task WriteSuccessResponseAsync<T>(HttpContext context, T data, s
|
|||||||
```
|
```
|
||||||
|
|
||||||
**修改后:**
|
**修改后:**
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
protected async Task WriteSuccessResponseAsync<T>(HttpContext context, T data, string? message = null)
|
protected async Task WriteSuccessResponseAsync<T>(HttpContext context, T data, string? message = null)
|
||||||
{
|
{
|
||||||
@@ -72,12 +74,14 @@ rm src/Sharp/NetServer/Models/ResponseModel.cs
|
|||||||
### 对于框架使用者
|
### 对于框架使用者
|
||||||
|
|
||||||
**修改前:**
|
**修改前:**
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
var response = ResponseModel<User>.Ok(user, "查询成功");
|
var response = ResponseModel<User>.Ok(user, "查询成功");
|
||||||
await WriteJsonResponseAsync(context, response);
|
await WriteJsonResponseAsync(context, response);
|
||||||
```
|
```
|
||||||
|
|
||||||
**修改后:**
|
**修改后:**
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
var response = ResultExtensions.HttpSuccess(user, "查询成功");
|
var response = ResultExtensions.HttpSuccess(user, "查询成功");
|
||||||
await WriteJsonResponseAsync(context, response);
|
await WriteJsonResponseAsync(context, response);
|
||||||
@@ -197,6 +201,7 @@ NetServer.Extensions(可选扩展包)
|
|||||||
**推荐方案:充分利用 Common,删除重复代码**
|
**推荐方案:充分利用 Common,删除重复代码**
|
||||||
|
|
||||||
理由:
|
理由:
|
||||||
|
|
||||||
1. Common 是项目的基础库,设计目的就是被其他模块使用
|
1. Common 是项目的基础库,设计目的就是被其他模块使用
|
||||||
2. Result 类设计完善,功能强大
|
2. Result 类设计完善,功能强大
|
||||||
3. 统一标准,避免维护两套相似的代码
|
3. 统一标准,避免维护两套相似的代码
|
||||||
@@ -219,6 +224,7 @@ NetServer.Extensions(可选扩展包)
|
|||||||
## 📝 总结
|
## 📝 总结
|
||||||
|
|
||||||
通过这次重构,NetServer 将:
|
通过这次重构,NetServer 将:
|
||||||
|
|
||||||
- ✅ 更轻量(减少不必要的依赖)
|
- ✅ 更轻量(减少不必要的依赖)
|
||||||
- ✅ 更统一(使用 Common 的标准)
|
- ✅ 更统一(使用 Common 的标准)
|
||||||
- ✅ 更灵活(用户可以自由选择实现)
|
- ✅ 更灵活(用户可以自由选择实现)
|
||||||
|
|||||||
@@ -56,25 +56,24 @@ public class RouteTable
|
|||||||
Method = method,
|
Method = method,
|
||||||
RequireAuthorization = requireAuth,
|
RequireAuthorization = requireAuth,
|
||||||
AllowAnonymous = methodAllowAnonymous != null,
|
AllowAnonymous = methodAllowAnonymous != null,
|
||||||
Roles = authorize?.Roles?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
Roles = authorize?.Roles?.Split(',',
|
||||||
Permissions = authorize?.Permissions?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
||||||
|
Permissions = authorize?.Permissions?.Split(',',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 限流配置
|
// 限流配置
|
||||||
var rateLimit = methodRateLimit ?? controllerRateLimit;
|
var rateLimit = methodRateLimit ?? controllerRateLimit;
|
||||||
if (rateLimit != null)
|
if (rateLimit != null)
|
||||||
{
|
|
||||||
routeInfo.RateLimitConfig = new RateLimitConfig
|
routeInfo.RateLimitConfig = new RateLimitConfig
|
||||||
{
|
{
|
||||||
MaxRequests = rateLimit.MaxRequests,
|
MaxRequests = rateLimit.MaxRequests,
|
||||||
WindowSeconds = rateLimit.WindowSeconds,
|
WindowSeconds = rateLimit.WindowSeconds,
|
||||||
Scope = rateLimit.Scope.ToString()
|
Scope = rateLimit.Scope.ToString()
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// 缓存配置
|
// 缓存配置
|
||||||
if (methodCache != null)
|
if (methodCache != null)
|
||||||
{
|
|
||||||
routeInfo.CacheConfig = new CacheConfig
|
routeInfo.CacheConfig = new CacheConfig
|
||||||
{
|
{
|
||||||
Duration = methodCache.Duration,
|
Duration = methodCache.Duration,
|
||||||
@@ -82,24 +81,24 @@ public class RouteTable
|
|||||||
VaryByQueryKeys = methodCache.VaryByQueryKeys,
|
VaryByQueryKeys = methodCache.VaryByQueryKeys,
|
||||||
CacheKey = methodCache.CacheKey
|
CacheKey = methodCache.CacheKey
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// CORS 配置
|
// CORS 配置
|
||||||
if (disableCors == null)
|
if (disableCors == null)
|
||||||
{
|
{
|
||||||
var cors = methodCors ?? controllerCors;
|
var cors = methodCors ?? controllerCors;
|
||||||
if (cors != null)
|
if (cors != null)
|
||||||
{
|
|
||||||
routeInfo.CorsConfig = new CorsConfig
|
routeInfo.CorsConfig = new CorsConfig
|
||||||
{
|
{
|
||||||
Origins = cors.Origins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
Origins = cors.Origins.Split(',',
|
||||||
Methods = cors.Methods.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
||||||
Headers = cors.Headers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
Methods = cors.Methods.Split(',',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
||||||
|
Headers = cors.Headers.Split(',',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
||||||
AllowCredentials = cors.AllowCredentials,
|
AllowCredentials = cors.AllowCredentials,
|
||||||
MaxAge = cors.MaxAge
|
MaxAge = cors.MaxAge
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_routes.Add(routeInfo);
|
_routes.Add(routeInfo);
|
||||||
}
|
}
|
||||||
@@ -125,7 +124,10 @@ public class RouteTable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取所有路由
|
/// 获取所有路由
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<RouteInfo> GetAllRoutes() => _routes.AsReadOnly();
|
public IReadOnlyList<RouteInfo> GetAllRoutes()
|
||||||
|
{
|
||||||
|
return _routes.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
private static string BuildRouteTemplate(string? controllerRoute, string? methodRoute)
|
private static string BuildRouteTemplate(string? controllerRoute, string? methodRoute)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,10 +68,7 @@ public class HttpServer
|
|||||||
|
|
||||||
private void ConfigureListener()
|
private void ConfigureListener()
|
||||||
{
|
{
|
||||||
foreach (var url in _options.ListenUrls)
|
foreach (var url in _options.ListenUrls) _listener.Prefixes.Add(url);
|
||||||
{
|
|
||||||
_listener.Prefixes.Add(url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterControllers()
|
private void RegisterControllers()
|
||||||
@@ -98,93 +95,74 @@ public class HttpServer
|
|||||||
{
|
{
|
||||||
// 1. 异常处理(最外层)
|
// 1. 异常处理(最外层)
|
||||||
if (_options.UseExceptionHandling)
|
if (_options.UseExceptionHandling)
|
||||||
{
|
|
||||||
_pipeline.Use(async (context, next) =>
|
_pipeline.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
var middleware = new ExceptionHandlingMiddleware(_serviceProvider?.GetService<ILogger<ExceptionHandlingMiddleware>>());
|
var middleware =
|
||||||
|
new ExceptionHandlingMiddleware(
|
||||||
|
_serviceProvider?.GetService<ILogger<ExceptionHandlingMiddleware>>());
|
||||||
await middleware.InvokeAsync(context, next);
|
await middleware.InvokeAsync(context, next);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 日志记录
|
// 2. 日志记录
|
||||||
if (_options.UseLogging)
|
if (_options.UseLogging)
|
||||||
{
|
|
||||||
_pipeline.Use(async (context, next) =>
|
_pipeline.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
var middleware = new LoggingMiddleware(_serviceProvider?.GetService<ILogger<LoggingMiddleware>>());
|
var middleware = new LoggingMiddleware(_serviceProvider?.GetService<ILogger<LoggingMiddleware>>());
|
||||||
await middleware.InvokeAsync(context, next);
|
await middleware.InvokeAsync(context, next);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 3. CORS
|
// 3. CORS
|
||||||
if (_options.UseCors)
|
if (_options.UseCors)
|
||||||
{
|
|
||||||
_pipeline.Use(async (context, next) =>
|
_pipeline.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
var middleware = new CorsMiddleware(_options.CorsOptions);
|
var middleware = new CorsMiddleware(_options.CorsOptions);
|
||||||
await middleware.InvokeAsync(context, next);
|
await middleware.InvokeAsync(context, next);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 认证
|
// 4. 认证
|
||||||
if (_options.UseAuthentication)
|
if (_options.UseAuthentication)
|
||||||
{
|
|
||||||
_pipeline.Use(async (context, next) =>
|
_pipeline.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
var authProvider = _serviceProvider?.GetService<Abstractions.IAuthenticationProvider>();
|
var authProvider = _serviceProvider?.GetService<Abstractions.IAuthenticationProvider>();
|
||||||
var middleware = new AuthenticationMiddleware(authProvider);
|
var middleware = new AuthenticationMiddleware(authProvider);
|
||||||
await middleware.InvokeAsync(context, next);
|
await middleware.InvokeAsync(context, next);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 授权
|
// 5. 授权
|
||||||
if (_options.UseAuthorization)
|
if (_options.UseAuthorization)
|
||||||
{
|
|
||||||
_pipeline.Use(async (context, next) =>
|
_pipeline.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
var middleware = new AuthorizationMiddleware();
|
var middleware = new AuthorizationMiddleware();
|
||||||
await middleware.InvokeAsync(context, next);
|
await middleware.InvokeAsync(context, next);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 限流
|
// 6. 限流
|
||||||
if (_options.UseRateLimit)
|
if (_options.UseRateLimit)
|
||||||
{
|
|
||||||
_pipeline.Use(async (context, next) =>
|
_pipeline.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
var middleware = new RateLimitMiddleware();
|
var middleware = new RateLimitMiddleware();
|
||||||
await middleware.InvokeAsync(context, next);
|
await middleware.InvokeAsync(context, next);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 7. 缓存
|
// 7. 缓存
|
||||||
if (_options.UseResponseCache)
|
if (_options.UseResponseCache)
|
||||||
{
|
|
||||||
_pipeline.Use(async (context, next) =>
|
_pipeline.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
var cache = _serviceProvider?.GetService<IResponseCache>();
|
var cache = _serviceProvider?.GetService<IResponseCache>();
|
||||||
var middleware = new ResponseCacheMiddleware(cache);
|
var middleware = new ResponseCacheMiddleware(cache);
|
||||||
await middleware.InvokeAsync(context, next);
|
await middleware.InvokeAsync(context, next);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 8. 自定义中间件
|
// 8. 自定义中间件
|
||||||
foreach (var middlewareAction in _options.CustomMiddlewares)
|
foreach (var middlewareAction in _options.CustomMiddlewares) _pipeline.Use(middlewareAction);
|
||||||
{
|
|
||||||
_pipeline.Use(middlewareAction);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. 路由处理(最内层)
|
// 9. 路由处理(最内层)
|
||||||
_pipeline.Use(async (context, next) =>
|
_pipeline.Use(async (context, next) => { await HandleRouteAsync(context); });
|
||||||
{
|
|
||||||
await HandleRouteAsync(context);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ProcessRequestsAsync(CancellationToken cancellationToken)
|
private async Task ProcessRequestsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var listenerContext = await _listener.GetContextAsync();
|
var listenerContext = await _listener.GetContextAsync();
|
||||||
@@ -212,9 +190,9 @@ public class HttpServer
|
|||||||
_logger?.LogError(ex, "Error accepting request");
|
_logger?.LogError(ex, "Error accepting request");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessSingleRequestAsync(HttpListenerContext listenerContext, CancellationToken cancellationToken)
|
private async Task ProcessSingleRequestAsync(HttpListenerContext listenerContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var context = new HttpContext(listenerContext)
|
var context = new HttpContext(listenerContext)
|
||||||
{
|
{
|
||||||
@@ -274,10 +252,7 @@ public class HttpServer
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = route.Method.Invoke(controller, new object[] { context });
|
var result = route.Method.Invoke(controller, new object[] { context });
|
||||||
if (result is Task task)
|
if (result is Task task) await task;
|
||||||
{
|
|
||||||
await task;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -306,7 +281,7 @@ public class HttpServer
|
|||||||
context.Response.ContentType = "application/json; charset=utf-8";
|
context.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
|
||||||
var response = ResultExtensions.HttpFailure<object>("Route not found", 404);
|
var response = ResultExtensions.HttpFailure<object>("Route not found", 404);
|
||||||
var json = Common.Utils.JsonHelper.ToJson(response, useCompact: true);
|
var json = Common.Utils.JsonHelper.ToJson(response, true);
|
||||||
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
|
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
context.Response.ContentLength = bytes.Length;
|
context.Response.ContentLength = bytes.Length;
|
||||||
|
|||||||
@@ -6,17 +6,13 @@ namespace Plc.Siemens.Options;
|
|||||||
|
|
||||||
public class SiemensPlcOptions : IValidatableObject
|
public class SiemensPlcOptions : IValidatableObject
|
||||||
{
|
{
|
||||||
[Required]
|
[Required] public string IpAddress { get; set; } = "127.0.0.1";
|
||||||
public string IpAddress { get; set; } = "127.0.0.1";
|
|
||||||
|
|
||||||
[Range(1, 65535)]
|
[Range(1, 65535)] public int Port { get; set; } = 102;
|
||||||
public int Port { get; set; } = 102;
|
|
||||||
|
|
||||||
[Range(0, 255)]
|
[Range(0, 255)] public short Rack { get; set; } = 0;
|
||||||
public short Rack { get; set; } = 0;
|
|
||||||
|
|
||||||
[Range(0, 255)]
|
[Range(0, 255)] public short Slot { get; set; } = 1;
|
||||||
public short Slot { get; set; } = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// S7-1200/1500 通常支持 960,S7-300 通常支持 240。
|
/// S7-1200/1500 通常支持 960,S7-300 通常支持 240。
|
||||||
@@ -50,26 +46,20 @@ public class SiemensPlcOptions : IValidatableObject
|
|||||||
{
|
{
|
||||||
// 验证IP地址格式
|
// 验证IP地址格式
|
||||||
if (!IPAddress.TryParse(IpAddress, out _))
|
if (!IPAddress.TryParse(IpAddress, out _))
|
||||||
{
|
|
||||||
yield return new ValidationResult(
|
yield return new ValidationResult(
|
||||||
$"Invalid IP address format: {IpAddress}",
|
$"Invalid IP address format: {IpAddress}",
|
||||||
new[] { nameof(IpAddress) });
|
new[] { nameof(IpAddress) });
|
||||||
}
|
|
||||||
|
|
||||||
// 验证PDU大小合理性
|
// 验证PDU大小合理性
|
||||||
if (PduSize < 128 || PduSize > 960)
|
if (PduSize < 128 || PduSize > 960)
|
||||||
{
|
|
||||||
yield return new ValidationResult(
|
yield return new ValidationResult(
|
||||||
$"PDU size must be between 128 and 960, got: {PduSize}",
|
$"PDU size must be between 128 and 960, got: {PduSize}",
|
||||||
new[] { nameof(PduSize) });
|
new[] { nameof(PduSize) });
|
||||||
}
|
|
||||||
|
|
||||||
// 验证MaxGapBytes的合理性
|
// 验证MaxGapBytes的合理性
|
||||||
if (MaxGapBytes < 0 || MaxGapBytes > PduSize)
|
if (MaxGapBytes < 0 || MaxGapBytes > PduSize)
|
||||||
{
|
|
||||||
yield return new ValidationResult(
|
yield return new ValidationResult(
|
||||||
$"MaxGapBytes ({MaxGapBytes}) must be between 0 and PduSize ({PduSize})",
|
$"MaxGapBytes ({MaxGapBytes}) must be between 0 and PduSize ({PduSize})",
|
||||||
new[] { nameof(MaxGapBytes) });
|
new[] { nameof(MaxGapBytes) });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -13,14 +13,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Plc\Plc.csproj" />
|
<ProjectReference Include="..\Plc\Plc.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0"/>
|
||||||
<PackageReference Include="S7netplus" Version="0.20.0" />
|
<PackageReference Include="S7netplus" Version="0.20.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
## 📖 概述
|
## 📖 概述
|
||||||
|
|
||||||
Plc.Siemens 是基于 [S7.Net Plus](https://github.com/S7NetPlus/s7netplus) 库的西门子S7系列PLC通信驱动模块,实现了 `Plc` 核心库的抽象接口,提供了对西门子 S7-200、S7-300、S7-400、S7-1200、S7-1500 系列PLC的完整支持。
|
Plc.Siemens 是基于 [S7.Net Plus](https://github.com/S7NetPlus/s7netplus) 库的西门子S7系列PLC通信驱动模块,实现了 `Plc`
|
||||||
|
核心库的抽象接口,提供了对西门子 S7-200、S7-300、S7-400、S7-1200、S7-1500 系列PLC的完整支持。
|
||||||
|
|
||||||
### 核心特性
|
### 核心特性
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ Plc.Siemens 是基于 [S7.Net Plus](https://github.com/S7NetPlus/s7netplus) 库
|
|||||||
## 🎯 支持的PLC型号
|
## 🎯 支持的PLC型号
|
||||||
|
|
||||||
| 系列 | CPU类型 | 默认端口 | PDU大小 | 测试状态 |
|
| 系列 | CPU类型 | 默认端口 | PDU大小 | 测试状态 |
|
||||||
|------|---------|---------|---------|---------|
|
|--------------|------------|------|-------|--------|
|
||||||
| S7-200 | S7200 | 102 | 240 | ✅ 已测试 |
|
| S7-200 | S7200 | 102 | 240 | ✅ 已测试 |
|
||||||
| S7-300 | S7300 | 102 | 240 | ✅ 已测试 |
|
| S7-300 | S7300 | 102 | 240 | ✅ 已测试 |
|
||||||
| S7-400 | S7400 | 102 | 480 | ✅ 已测试 |
|
| S7-400 | S7400 | 102 | 480 | ✅ 已测试 |
|
||||||
@@ -58,7 +59,8 @@ services.AddSiemensPlc(options =>
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
> **注意**:`MonitorInterval` 参数会被自动映射到核心服务 `PlcMonitorOptions.RefreshRate`。这是通过内部的 `Configure<PlcMonitorOptions>` 和 `IPostConfigureOptions` 机制实现的,用户无需手动配置核心选项。
|
> **注意**:`MonitorInterval` 参数会被自动映射到核心服务 `PlcMonitorOptions.RefreshRate`。这是通过内部的
|
||||||
|
`Configure<PlcMonitorOptions>` 和 `IPostConfigureOptions` 机制实现的,用户无需手动配置核心选项。
|
||||||
|
|
||||||
### 完整示例
|
### 完整示例
|
||||||
|
|
||||||
@@ -116,7 +118,7 @@ public class PlcController
|
|||||||
### 支持的存储区域
|
### 支持的存储区域
|
||||||
|
|
||||||
| 区域 | 标识符 | 说明 | 示例 |
|
| 区域 | 标识符 | 说明 | 示例 |
|
||||||
|------|--------|------|------|
|
|----------|-----|--------------|------------|
|
||||||
| 数据块 (DB) | DB | Data Block | `DB1.DBW0` |
|
| 数据块 (DB) | DB | Data Block | `DB1.DBW0` |
|
||||||
| 标志位 (M) | M | Memory/Flags | `M100.0` |
|
| 标志位 (M) | M | Memory/Flags | `M100.0` |
|
||||||
| 输入 (I) | I | Input | `I0.0` |
|
| 输入 (I) | I | Input | `I0.0` |
|
||||||
@@ -127,7 +129,7 @@ public class PlcController
|
|||||||
### 数据类型标识
|
### 数据类型标识
|
||||||
|
|
||||||
| 标识符 | 类型 | 字节数 | C# 类型 | 示例 |
|
| 标识符 | 类型 | 字节数 | C# 类型 | 示例 |
|
||||||
|--------|------|--------|---------|------|
|
|-----|----------|-------|------------------|--------------|
|
||||||
| DBX | Bool (位) | 1 bit | `bool` | `DB1.DBX0.0` |
|
| DBX | Bool (位) | 1 bit | `bool` | `DB1.DBX0.0` |
|
||||||
| DBB | Byte | 1 | `byte` | `DB1.DBB0` |
|
| DBB | Byte | 1 | `byte` | `DB1.DBB0` |
|
||||||
| DBW | Word | 2 | `short/ushort` | `DB1.DBW0` |
|
| DBW | Word | 2 | `short/ushort` | `DB1.DBW0` |
|
||||||
@@ -349,6 +351,7 @@ var tags = new[]
|
|||||||
```
|
```
|
||||||
|
|
||||||
**优化规则:**
|
**优化规则:**
|
||||||
|
|
||||||
1. 按 **存储区域** 和 **DB号** 分组
|
1. 按 **存储区域** 和 **DB号** 分组
|
||||||
2. 按 **字节偏移** 排序
|
2. 按 **字节偏移** 排序
|
||||||
3. 间隔 ≤ `MaxGapBytes` 的标签合并
|
3. 间隔 ≤ `MaxGapBytes` 的标签合并
|
||||||
@@ -368,6 +371,7 @@ var value = await _plcService.ReadAsync<short>("DB1.DBW0");
|
|||||||
```
|
```
|
||||||
|
|
||||||
**支持的转换:**
|
**支持的转换:**
|
||||||
|
|
||||||
- ✅ Bool (按位)
|
- ✅ Bool (按位)
|
||||||
- ✅ Byte
|
- ✅ Byte
|
||||||
- ✅ Short / UShort (2字节大端)
|
- ✅ Short / UShort (2字节大端)
|
||||||
@@ -476,6 +480,7 @@ var tags = new[]
|
|||||||
```
|
```
|
||||||
|
|
||||||
**解决方案:**
|
**解决方案:**
|
||||||
|
|
||||||
- ✅ 检查PLC IP地址是否正确
|
- ✅ 检查PLC IP地址是否正确
|
||||||
- ✅ 检查网络连通性 (`ping 192.168.1.100`)
|
- ✅ 检查网络连通性 (`ping 192.168.1.100`)
|
||||||
- ✅ 确认PLC的 **PUT/GET** 权限已启用
|
- ✅ 确认PLC的 **PUT/GET** 权限已启用
|
||||||
@@ -489,6 +494,7 @@ var tags = new[]
|
|||||||
```
|
```
|
||||||
|
|
||||||
**解决方案:**
|
**解决方案:**
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 根据PLC型号调整PDU
|
// 根据PLC型号调整PDU
|
||||||
options.PduSize = 240; // S7-300使用较小值
|
options.PduSize = 240; // S7-300使用较小值
|
||||||
@@ -501,6 +507,7 @@ options.PduSize = 240; // S7-300使用较小值
|
|||||||
```
|
```
|
||||||
|
|
||||||
**解决方案:**
|
**解决方案:**
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// ❌ 错误格式
|
// ❌ 错误格式
|
||||||
"DB1.0" // 缺少数据类型
|
"DB1.0" // 缺少数据类型
|
||||||
@@ -518,6 +525,7 @@ options.PduSize = 240; // S7-300使用较小值
|
|||||||
```
|
```
|
||||||
|
|
||||||
**解决方案:**
|
**解决方案:**
|
||||||
|
|
||||||
- 在TIA Portal中检查 **保护级别**
|
- 在TIA Portal中检查 **保护级别**
|
||||||
- 启用 **PUT/GET 通信**
|
- 启用 **PUT/GET 通信**
|
||||||
- 确认 DB块的 **访问权限** 未设置为 **优化访问**
|
- 确认 DB块的 **访问权限** 未设置为 **优化访问**
|
||||||
@@ -666,6 +674,7 @@ public class SecurePlcService : IPlcService
|
|||||||
## 📈 版本历史
|
## 📈 版本历史
|
||||||
|
|
||||||
### v1.0.0 (2025-01-09)
|
### v1.0.0 (2025-01-09)
|
||||||
|
|
||||||
- ✅ 初始发布
|
- ✅ 初始发布
|
||||||
- ✅ 支持 S7-200/300/400/1200/1500
|
- ✅ 支持 S7-200/300/400/1200/1500
|
||||||
- ✅ 完整的地址解析
|
- ✅ 完整的地址解析
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ public class SiemensDriver : IPlcDriver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<byte[]>> ReadBytesAsync(string address, ushort length, CancellationToken cancellationToken = default)
|
public async Task<Result<byte[]>> ReadBytesAsync(string address, ushort length,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!IsConnected || _plc == null)
|
if (!IsConnected || _plc == null)
|
||||||
return Result.Failure<byte[]>("PLC is not connected");
|
return Result.Failure<byte[]>("PLC is not connected");
|
||||||
@@ -91,7 +92,8 @@ public class SiemensDriver : IPlcDriver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default)
|
public async Task<Result> WriteBytesAsync(string address, byte[] data,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!IsConnected || _plc == null)
|
if (!IsConnected || _plc == null)
|
||||||
return Result.Failure("PLC is not connected");
|
return Result.Failure("PLC is not connected");
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ public class SiemensOptimizer : IPlcOptimizer
|
|||||||
foreach (var tag in tags)
|
foreach (var tag in tags)
|
||||||
{
|
{
|
||||||
if (tag.ParsedAddress == null)
|
if (tag.ParsedAddress == null)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
tag.ParsedAddress = SiemensAddressParser.Parse(tag.Address);
|
tag.ParsedAddress = SiemensAddressParser.Parse(tag.Address);
|
||||||
@@ -42,7 +41,7 @@ public class SiemensOptimizer : IPlcOptimizer
|
|||||||
tag.Address, tag.Id);
|
tag.Address, tag.Id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
validTags.Add(tag);
|
validTags.Add(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +68,10 @@ public class SiemensOptimizer : IPlcOptimizer
|
|||||||
foreach (var tag in sortedTags)
|
foreach (var tag in sortedTags)
|
||||||
{
|
{
|
||||||
var addr = (SiemensAddress)tag.ParsedAddress!;
|
var addr = (SiemensAddress)tag.ParsedAddress!;
|
||||||
int typeSize = tag.DataType == typeof(string)
|
var typeSize = tag.DataType == typeof(string)
|
||||||
? TypeHelper.GetSize(tag.DataType, tag.Length)
|
? TypeHelper.GetSize(tag.DataType, tag.Length)
|
||||||
: TypeHelper.GetSize(tag.DataType);
|
: TypeHelper.GetSize(tag.DataType);
|
||||||
int byteLength = typeSize * tag.Length; // 此标签的总字节数
|
var byteLength = typeSize * tag.Length; // 此标签的总字节数
|
||||||
|
|
||||||
// 如果是 bool,它可能在一个字节内,但我们在检查边界时假设它占用该字节
|
// 如果是 bool,它可能在一个字节内,但我们在检查边界时假设它占用该字节
|
||||||
|
|
||||||
@@ -85,14 +84,14 @@ public class SiemensOptimizer : IPlcOptimizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
var batchStartAddr = (SiemensAddress)currentBatch.ParsedStartAddress!;
|
var batchStartAddr = (SiemensAddress)currentBatch.ParsedStartAddress!;
|
||||||
int currentBatchEnd = batchStartAddr.ByteOffset + currentBatch.TotalLength;
|
var currentBatchEnd = batchStartAddr.ByteOffset + currentBatch.TotalLength;
|
||||||
|
|
||||||
// 计算间隔 (Gap)
|
// 计算间隔 (Gap)
|
||||||
int gap = addr.ByteOffset - currentBatchEnd;
|
var gap = addr.ByteOffset - currentBatchEnd;
|
||||||
|
|
||||||
// 计算潜在的新长度
|
// 计算潜在的新长度
|
||||||
// 新标签地址 + 其长度 - 批次起始位置
|
// 新标签地址 + 其长度 - 批次起始位置
|
||||||
int newTotalLength = (addr.ByteOffset + byteLength) - batchStartAddr.ByteOffset;
|
var newTotalLength = addr.ByteOffset + byteLength - batchStartAddr.ByteOffset;
|
||||||
|
|
||||||
// 合并条件:
|
// 合并条件:
|
||||||
// 1. 标签在当前结束之后(或位置相同)开始(因为已排序,所以是肯定的)
|
// 1. 标签在当前结束之后(或位置相同)开始(因为已排序,所以是肯定的)
|
||||||
@@ -108,7 +107,8 @@ public class SiemensOptimizer : IPlcOptimizer
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// 开启新批次
|
// 开启新批次
|
||||||
_logger?.LogTrace("Cannot merge tag {TagId} (gap={Gap}, newLength={Length}, maxPdu={MaxPdu}), creating new batch",
|
_logger?.LogTrace(
|
||||||
|
"Cannot merge tag {TagId} (gap={Gap}, newLength={Length}, maxPdu={MaxPdu}), creating new batch",
|
||||||
tag.Id, gap, newTotalLength, maxPduSize);
|
tag.Id, gap, newTotalLength, maxPduSize);
|
||||||
currentBatch = CreateBatch(tag, addr, byteLength);
|
currentBatch = CreateBatch(tag, addr, byteLength);
|
||||||
batches.Add(currentBatch);
|
batches.Add(currentBatch);
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ public static class SiemensAddressParser
|
|||||||
// 0. Try V Area (S7-200 Smart: V100 -> DB1.DBB100)
|
// 0. Try V Area (S7-200 Smart: V100 -> DB1.DBB100)
|
||||||
var vMatch = VRegex.Match(address);
|
var vMatch = VRegex.Match(address);
|
||||||
if (vMatch.Success)
|
if (vMatch.Success)
|
||||||
{
|
|
||||||
return new SiemensAddress
|
return new SiemensAddress
|
||||||
{
|
{
|
||||||
Area = SiemensMemoryArea.DataBlock,
|
Area = SiemensMemoryArea.DataBlock,
|
||||||
@@ -41,12 +40,10 @@ public static class SiemensAddressParser
|
|||||||
ByteOffset = int.Parse(vMatch.Groups[1].Value),
|
ByteOffset = int.Parse(vMatch.Groups[1].Value),
|
||||||
BitOffset = vMatch.Groups[3].Success ? int.Parse(vMatch.Groups[3].Value) : 0
|
BitOffset = vMatch.Groups[3].Success ? int.Parse(vMatch.Groups[3].Value) : 0
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Try DB
|
// 1. Try DB
|
||||||
var dbMatch = DbRegex.Match(address);
|
var dbMatch = DbRegex.Match(address);
|
||||||
if (dbMatch.Success)
|
if (dbMatch.Success)
|
||||||
{
|
|
||||||
return new SiemensAddress
|
return new SiemensAddress
|
||||||
{
|
{
|
||||||
Area = SiemensMemoryArea.DataBlock,
|
Area = SiemensMemoryArea.DataBlock,
|
||||||
@@ -54,12 +51,10 @@ public static class SiemensAddressParser
|
|||||||
ByteOffset = int.Parse(dbMatch.Groups[2].Value),
|
ByteOffset = int.Parse(dbMatch.Groups[2].Value),
|
||||||
BitOffset = dbMatch.Groups[4].Success ? int.Parse(dbMatch.Groups[4].Value) : 0
|
BitOffset = dbMatch.Groups[4].Success ? int.Parse(dbMatch.Groups[4].Value) : 0
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try M (Memory/Flags)
|
// 2. Try M (Memory/Flags)
|
||||||
var mMatch = MRegex.Match(address);
|
var mMatch = MRegex.Match(address);
|
||||||
if (mMatch.Success)
|
if (mMatch.Success)
|
||||||
{
|
|
||||||
return new SiemensAddress
|
return new SiemensAddress
|
||||||
{
|
{
|
||||||
Area = SiemensMemoryArea.Memory,
|
Area = SiemensMemoryArea.Memory,
|
||||||
@@ -67,12 +62,10 @@ public static class SiemensAddressParser
|
|||||||
ByteOffset = int.Parse(mMatch.Groups[1].Value),
|
ByteOffset = int.Parse(mMatch.Groups[1].Value),
|
||||||
BitOffset = mMatch.Groups[3].Success ? int.Parse(mMatch.Groups[3].Value) : 0
|
BitOffset = mMatch.Groups[3].Success ? int.Parse(mMatch.Groups[3].Value) : 0
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Try I (Inputs)
|
// 3. Try I (Inputs)
|
||||||
var iMatch = IRegex.Match(address);
|
var iMatch = IRegex.Match(address);
|
||||||
if (iMatch.Success)
|
if (iMatch.Success)
|
||||||
{
|
|
||||||
return new SiemensAddress
|
return new SiemensAddress
|
||||||
{
|
{
|
||||||
Area = SiemensMemoryArea.Inputs,
|
Area = SiemensMemoryArea.Inputs,
|
||||||
@@ -80,12 +73,10 @@ public static class SiemensAddressParser
|
|||||||
ByteOffset = int.Parse(iMatch.Groups[1].Value),
|
ByteOffset = int.Parse(iMatch.Groups[1].Value),
|
||||||
BitOffset = iMatch.Groups[3].Success ? int.Parse(iMatch.Groups[3].Value) : 0
|
BitOffset = iMatch.Groups[3].Success ? int.Parse(iMatch.Groups[3].Value) : 0
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Try Q (Outputs)
|
// 4. Try Q (Outputs)
|
||||||
var qMatch = QRegex.Match(address);
|
var qMatch = QRegex.Match(address);
|
||||||
if (qMatch.Success)
|
if (qMatch.Success)
|
||||||
{
|
|
||||||
return new SiemensAddress
|
return new SiemensAddress
|
||||||
{
|
{
|
||||||
Area = SiemensMemoryArea.Outputs,
|
Area = SiemensMemoryArea.Outputs,
|
||||||
@@ -93,7 +84,6 @@ public static class SiemensAddressParser
|
|||||||
ByteOffset = int.Parse(qMatch.Groups[1].Value),
|
ByteOffset = int.Parse(qMatch.Groups[1].Value),
|
||||||
BitOffset = qMatch.Groups[3].Success ? int.Parse(qMatch.Groups[3].Value) : 0
|
BitOffset = qMatch.Groups[3].Success ? int.Parse(qMatch.Groups[3].Value) : 0
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
throw new ArgumentException($"不支持或无效的地址格式: {address}");
|
throw new ArgumentException($"不支持或无效的地址格式: {address}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,12 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Common\Common.csproj" />
|
<ProjectReference Include="..\Common\Common.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -32,10 +32,7 @@ public class PlcInterfaceProxy : DispatchProxy, INotifyPropertyChanged
|
|||||||
if (targetMethod.Name.StartsWith("get_"))
|
if (targetMethod.Name.StartsWith("get_"))
|
||||||
{
|
{
|
||||||
var propName = targetMethod.Name.Substring(4);
|
var propName = targetMethod.Name.Substring(4);
|
||||||
if (_tags.TryGetValue(propName, out var tag))
|
if (_tags.TryGetValue(propName, out var tag)) return tag.Value; // Return cached value
|
||||||
{
|
|
||||||
return tag.Value; // Return cached value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Handle Property Setters (set_PropName)
|
// 2. Handle Property Setters (set_PropName)
|
||||||
@@ -60,6 +57,7 @@ public class PlcInterfaceProxy : DispatchProxy, INotifyPropertyChanged
|
|||||||
_propertyChangedInternal += handler;
|
_propertyChangedInternal += handler;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetMethod.Name == "remove_PropertyChanged" && args != null && args.Length == 1)
|
if (targetMethod.Name == "remove_PropertyChanged" && args != null && args.Length == 1)
|
||||||
{
|
{
|
||||||
var handler = (PropertyChangedEventHandler)args[0]!;
|
var handler = (PropertyChangedEventHandler)args[0]!;
|
||||||
|
|||||||
+33
-3
@@ -54,25 +54,33 @@ Plc 是一个通用的工业PLC通信抽象层库,提供了统一的接口来
|
|||||||
### 核心组件
|
### 核心组件
|
||||||
|
|
||||||
#### 1. **IPlcService** - PLC服务接口
|
#### 1. **IPlcService** - PLC服务接口
|
||||||
|
|
||||||
提供统一的PLC通信入口,包括:
|
提供统一的PLC通信入口,包括:
|
||||||
|
|
||||||
- 连接管理(`StartAsync`, `StopAsync`)
|
- 连接管理(`StartAsync`, `StopAsync`)
|
||||||
- 即时读写(`ReadAsync<T>`, `WriteAsync<T>`)
|
- 即时读写(`ReadAsync<T>`, `WriteAsync<T>`)
|
||||||
- 订阅监控(`Subscribe`, `Unsubscribe`)
|
- 订阅监控(`Subscribe`, `Unsubscribe`)
|
||||||
|
|
||||||
#### 2. **IPlcDriver** - PLC驱动接口
|
#### 2. **IPlcDriver** - PLC驱动接口
|
||||||
|
|
||||||
定义底层通信协议的契约:
|
定义底层通信协议的契约:
|
||||||
|
|
||||||
- 连接/断开(`ConnectAsync`, `DisconnectAsync`)
|
- 连接/断开(`ConnectAsync`, `DisconnectAsync`)
|
||||||
- 字节级读写(`ReadBytesAsync`, `WriteBytesAsync`)
|
- 字节级读写(`ReadBytesAsync`, `WriteBytesAsync`)
|
||||||
- 状态查询(`IsConnected`, `MaxPduSize`)
|
- 状态查询(`IsConnected`, `MaxPduSize`)
|
||||||
|
|
||||||
#### 3. **IPlcOptimizer** - 批量优化器接口
|
#### 3. **IPlcOptimizer** - 批量优化器接口
|
||||||
|
|
||||||
负责优化多标签读取策略:
|
负责优化多标签读取策略:
|
||||||
|
|
||||||
- 地址排序和分组
|
- 地址排序和分组
|
||||||
- 间隙合并(减少通信次数)
|
- 间隙合并(减少通信次数)
|
||||||
- PDU大小限制
|
- PDU大小限制
|
||||||
|
|
||||||
#### 4. **IDataConverter** - 数据转换器接口
|
#### 4. **IDataConverter** - 数据转换器接口
|
||||||
|
|
||||||
处理PLC字节序和.NET类型转换:
|
处理PLC字节序和.NET类型转换:
|
||||||
|
|
||||||
- 大端/小端转换
|
- 大端/小端转换
|
||||||
- 基础类型(bool, byte, short, int, float, double, string)
|
- 基础类型(bool, byte, short, int, float, double, string)
|
||||||
- 位偏移支持(bool类型)
|
- 位偏移支持(bool类型)
|
||||||
@@ -166,6 +174,7 @@ _plcService.Subscribe(tag);
|
|||||||
```
|
```
|
||||||
|
|
||||||
**属性说明:**
|
**属性说明:**
|
||||||
|
|
||||||
- `Id` - 唯一标识符
|
- `Id` - 唯一标识符
|
||||||
- `Address` - PLC地址(格式取决于驱动)
|
- `Address` - PLC地址(格式取决于驱动)
|
||||||
- `DataType` - .NET 类型
|
- `DataType` - .NET 类型
|
||||||
@@ -176,7 +185,7 @@ _plcService.Subscribe(tag);
|
|||||||
### 支持的数据类型
|
### 支持的数据类型
|
||||||
|
|
||||||
| C# 类型 | PLC 类型 | 字节数 | 说明 |
|
| C# 类型 | PLC 类型 | 字节数 | 说明 |
|
||||||
|---------|---------|--------|------|
|
|----------|--------|-----|---------|
|
||||||
| `bool` | BOOL | 1 | 支持位偏移 |
|
| `bool` | BOOL | 1 | 支持位偏移 |
|
||||||
| `byte` | BYTE | 1 | 无符号8位 |
|
| `byte` | BYTE | 1 | 无符号8位 |
|
||||||
| `short` | INT | 2 | 有符号16位 |
|
| `short` | INT | 2 | 有符号16位 |
|
||||||
@@ -194,9 +203,12 @@ _plcService.Subscribe(tag);
|
|||||||
本项目采用了**分桶(Batching/Bucketing)**优化策略,这是高性能 PLC 通信的关键。
|
本项目采用了**分桶(Batching/Bucketing)**优化策略,这是高性能 PLC 通信的关键。
|
||||||
|
|
||||||
### 1. 为什么需要优化?
|
### 1. 为什么需要优化?
|
||||||
PLC 通信通常基于 TCP/IP 协议,每次读写请求都包含网络握手、协议头封装和 PLC 内部处理时间。如果用户订阅了 100 个离散点位,如果发送 100 次网络请求,延迟将极高(例如 100 * 20ms = 2000ms),无法满足实时监控需求。
|
|
||||||
|
PLC 通信通常基于 TCP/IP 协议,每次读写请求都包含网络握手、协议头封装和 PLC 内部处理时间。如果用户订阅了 100 个离散点位,如果发送
|
||||||
|
100 次网络请求,延迟将极高(例如 100 * 20ms = 2000ms),无法满足实时监控需求。
|
||||||
|
|
||||||
### 2. 优化原理
|
### 2. 优化原理
|
||||||
|
|
||||||
系统通过 `IPlcOptimizer`(如 `SiemensOptimizer`)在内存中动态重组读写请求,将多个小的、地址相邻的请求合并为一个大的网络请求。
|
系统通过 `IPlcOptimizer`(如 `SiemensOptimizer`)在内存中动态重组读写请求,将多个小的、地址相邻的请求合并为一个大的网络请求。
|
||||||
|
|
||||||
#### 处理流程(伪代码)
|
#### 处理流程(伪代码)
|
||||||
@@ -220,11 +232,13 @@ graph LR
|
|||||||
### 3. 实际案例分析
|
### 3. 实际案例分析
|
||||||
|
|
||||||
假设用户订阅了以下点位(西门子 S7):
|
假设用户订阅了以下点位(西门子 S7):
|
||||||
|
|
||||||
- Tag A: `DB1.DBW0` (2 bytes)
|
- Tag A: `DB1.DBW0` (2 bytes)
|
||||||
- Tag B: `DB1.DBW4` (2 bytes)
|
- Tag B: `DB1.DBW4` (2 bytes)
|
||||||
- Tag C: `DB1.DBW1000` (2 bytes)
|
- Tag C: `DB1.DBW1000` (2 bytes)
|
||||||
|
|
||||||
**处理逻辑:**
|
**处理逻辑:**
|
||||||
|
|
||||||
1. **Tag A 和 Tag B**:
|
1. **Tag A 和 Tag B**:
|
||||||
* Tag A 结束于 byte 2,Tag B 开始于 byte 4。
|
* Tag A 结束于 byte 2,Tag B 开始于 byte 4。
|
||||||
* 间隙 Gap = 4 - 2 = 2 bytes。
|
* 间隙 Gap = 4 - 2 = 2 bytes。
|
||||||
@@ -284,7 +298,9 @@ public class OmronOptimizer : IPlcOptimizer
|
|||||||
> 本节描述了未来支持不同频率订阅(快/慢循环)的实现思路,目前尚未实装。
|
> 本节描述了未来支持不同频率订阅(快/慢循环)的实现思路,目前尚未实装。
|
||||||
|
|
||||||
### 1. 需求背景
|
### 1. 需求背景
|
||||||
|
|
||||||
在复杂的工业场景中,不同数据的实时性要求不同:
|
在复杂的工业场景中,不同数据的实时性要求不同:
|
||||||
|
|
||||||
- **关键控制信号**(如 启动/停止/报警):需要高频刷新(100ms)。
|
- **关键控制信号**(如 启动/停止/报警):需要高频刷新(100ms)。
|
||||||
- **普通模拟量**(如 温度/液位):变化较慢,只需低频刷新(1000ms)。
|
- **普通模拟量**(如 温度/液位):变化较慢,只需低频刷新(1000ms)。
|
||||||
- **配方/参数**(如 设定值):只需在变动时读取,或极低频刷新(5000ms)。
|
- **配方/参数**(如 设定值):只需在变动时读取,或极低频刷新(5000ms)。
|
||||||
@@ -362,7 +378,8 @@ while (!token.IsCancellationRequested)
|
|||||||
```
|
```
|
||||||
|
|
||||||
**优点**:单线程模型,无并发冲突,驱动兼容性好。
|
**优点**:单线程模型,无并发冲突,驱动兼容性好。
|
||||||
**缺点**:如果低频数据量极大,在 Tick 10 这一轮会耗时较长,可能导致该轮的高频数据延迟。可以通过将低频数据分片(Time-Slicing)分散到不同 Tick 来解决。
|
**缺点**:如果低频数据量极大,在 Tick 10 这一轮会耗时较长,可能导致该轮的高频数据延迟。可以通过将低频数据分片(Time-Slicing)分散到不同
|
||||||
|
Tick 来解决。
|
||||||
|
|
||||||
### 4. 配置扩展
|
### 4. 配置扩展
|
||||||
|
|
||||||
@@ -604,20 +621,24 @@ public static class ServiceCollectionExtensions
|
|||||||
## 🎨 设计原则
|
## 🎨 设计原则
|
||||||
|
|
||||||
### 1. **依赖倒置原则 (DIP)**
|
### 1. **依赖倒置原则 (DIP)**
|
||||||
|
|
||||||
- 高层模块(PlcMonitorService)不依赖低层模块(SiemensDriver)
|
- 高层模块(PlcMonitorService)不依赖低层模块(SiemensDriver)
|
||||||
- 两者都依赖抽象(IPlcDriver, IPlcOptimizer)
|
- 两者都依赖抽象(IPlcDriver, IPlcOptimizer)
|
||||||
|
|
||||||
### 2. **开闭原则 (OCP)**
|
### 2. **开闭原则 (OCP)**
|
||||||
|
|
||||||
- 对扩展开放:可添加新的PLC品牌驱动
|
- 对扩展开放:可添加新的PLC品牌驱动
|
||||||
- 对修改关闭:添加新品牌无需修改核心代码
|
- 对修改关闭:添加新品牌无需修改核心代码
|
||||||
|
|
||||||
### 3. **单一职责原则 (SRP)**
|
### 3. **单一职责原则 (SRP)**
|
||||||
|
|
||||||
- **PlcMonitorService** - 只负责监控循环和订阅管理
|
- **PlcMonitorService** - 只负责监控循环和订阅管理
|
||||||
- **IPlcDriver** - 只负责底层通信
|
- **IPlcDriver** - 只负责底层通信
|
||||||
- **IPlcOptimizer** - 只负责批量优化
|
- **IPlcOptimizer** - 只负责批量优化
|
||||||
- **IDataConverter** - 只负责类型转换
|
- **IDataConverter** - 只负责类型转换
|
||||||
|
|
||||||
### 4. **接口隔离原则 (ISP)**
|
### 4. **接口隔离原则 (ISP)**
|
||||||
|
|
||||||
- 接口粒度合适,不强迫实现不需要的方法
|
- 接口粒度合适,不强迫实现不需要的方法
|
||||||
- 驱动只需实现 `IPlcDriver`,无需关心优化逻辑
|
- 驱动只需实现 `IPlcDriver`,无需关心优化逻辑
|
||||||
|
|
||||||
@@ -626,6 +647,7 @@ public static class ServiceCollectionExtensions
|
|||||||
## 📈 性能优化建议
|
## 📈 性能优化建议
|
||||||
|
|
||||||
### 1. 合理设置刷新率
|
### 1. 合理设置刷新率
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// 高频数据(100ms)
|
// 高频数据(100ms)
|
||||||
var criticalTag = new PlcTag { Address = "...", ... };
|
var criticalTag = new PlcTag { Address = "...", ... };
|
||||||
@@ -642,10 +664,12 @@ Task.Run(async () =>
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. 减少订阅数量
|
### 2. 减少订阅数量
|
||||||
|
|
||||||
- 只订阅真正需要监控的标签
|
- 只订阅真正需要监控的标签
|
||||||
- 一次性读取的数据使用 `ReadAsync`
|
- 一次性读取的数据使用 `ReadAsync`
|
||||||
|
|
||||||
### 3. 调整 MaxGapBytes
|
### 3. 调整 MaxGapBytes
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
services.AddSiemensPlc(options =>
|
services.AddSiemensPlc(options =>
|
||||||
{
|
{
|
||||||
@@ -687,19 +711,24 @@ public async Task ReadAsync_ValidAddress_ReturnsSuccess()
|
|||||||
## 📝 常见问题
|
## 📝 常见问题
|
||||||
|
|
||||||
### Q: 如何处理连接断开?
|
### Q: 如何处理连接断开?
|
||||||
|
|
||||||
A: `PlcMonitorService` 内置自动重连机制,使用指数退避策略(1s, 2s, 4s, 8s, 16s, 30s)。
|
A: `PlcMonitorService` 内置自动重连机制,使用指数退避策略(1s, 2s, 4s, 8s, 16s, 30s)。
|
||||||
|
|
||||||
### Q: 是否支持异步属性?
|
### Q: 是否支持异步属性?
|
||||||
|
|
||||||
A: `PlcInterfaceProxy` 目前使用同步写入。对于异步场景,建议使用 `WriteAsync` 方法。
|
A: `PlcInterfaceProxy` 目前使用同步写入。对于异步场景,建议使用 `WriteAsync` 方法。
|
||||||
|
|
||||||
### Q: 如何优化大量标签的性能?
|
### Q: 如何优化大量标签的性能?
|
||||||
|
|
||||||
A:
|
A:
|
||||||
|
|
||||||
1. 确保标签地址连续或接近
|
1. 确保标签地址连续或接近
|
||||||
2. 调整 `MaxGapBytes` 参数
|
2. 调整 `MaxGapBytes` 参数
|
||||||
3. 增大 `PduSize`(如果PLC支持)
|
3. 增大 `PduSize`(如果PLC支持)
|
||||||
4. 考虑分组订阅(按更新频率)
|
4. 考虑分组订阅(按更新频率)
|
||||||
|
|
||||||
### Q: 支持哪些PLC品牌?
|
### Q: 支持哪些PLC品牌?
|
||||||
|
|
||||||
A: 当前支持西门子S7系列。其他品牌需要实现对应的驱动模块。
|
A: 当前支持西门子S7系列。其他品牌需要实现对应的驱动模块。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -715,6 +744,7 @@ MIT License - 详见 [LICENSE](LICENSE) 文件
|
|||||||
欢迎提交 Issue 和 Pull Request!
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
### 贡献指南
|
### 贡献指南
|
||||||
|
|
||||||
1. Fork 本仓库
|
1. Fork 本仓库
|
||||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public class BigEndianConverter : IDataConverter
|
|||||||
if (bitOffset < 0 || bitOffset > 7)
|
if (bitOffset < 0 || bitOffset > 7)
|
||||||
throw new ArgumentOutOfRangeException(nameof(bitOffset), "Bit offset must be 0-7");
|
throw new ArgumentOutOfRangeException(nameof(bitOffset), "Bit offset must be 0-7");
|
||||||
|
|
||||||
byte b = buffer[offset];
|
var b = buffer[offset];
|
||||||
return (b & (1 << bitOffset)) != 0;
|
return (b & (1 << bitOffset)) != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ public class PlcMonitorService : IPlcService, IDisposable
|
|||||||
|
|
||||||
_cts?.Cancel();
|
_cts?.Cancel();
|
||||||
if (_monitorTask != null)
|
if (_monitorTask != null)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _monitorTask;
|
await _monitorTask;
|
||||||
@@ -83,7 +82,6 @@ public class PlcMonitorService : IPlcService, IDisposable
|
|||||||
{
|
{
|
||||||
_monitorTask = null;
|
_monitorTask = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (_cts != null)
|
if (_cts != null)
|
||||||
{
|
{
|
||||||
@@ -98,33 +96,25 @@ public class PlcMonitorService : IPlcService, IDisposable
|
|||||||
public void Subscribe(PlcTag tag)
|
public void Subscribe(PlcTag tag)
|
||||||
{
|
{
|
||||||
if (_subscriptions.TryAdd(tag.Id, tag))
|
if (_subscriptions.TryAdd(tag.Id, tag))
|
||||||
{
|
|
||||||
_logger?.LogDebug("Subscribed to tag {TagId} at address {Address}", tag.Id, tag.Address);
|
_logger?.LogDebug("Subscribed to tag {TagId} at address {Address}", tag.Id, tag.Address);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
_logger?.LogWarning("Tag {TagId} is already subscribed", tag.Id);
|
_logger?.LogWarning("Tag {TagId} is already subscribed", tag.Id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public void Unsubscribe(string tagId)
|
public void Unsubscribe(string tagId)
|
||||||
{
|
{
|
||||||
if (_subscriptions.TryRemove(tagId, out _))
|
if (_subscriptions.TryRemove(tagId, out _))
|
||||||
{
|
|
||||||
_logger?.LogDebug("Unsubscribed tag {TagId}", tagId);
|
_logger?.LogDebug("Unsubscribed tag {TagId}", tagId);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
_logger?.LogWarning("Failed to unsubscribe tag {TagId} - not found", tagId);
|
_logger?.LogWarning("Failed to unsubscribe tag {TagId} - not found", tagId);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<T>> ReadAsync<T>(string address)
|
public async Task<Result<T>> ReadAsync<T>(string address)
|
||||||
{
|
{
|
||||||
_logger?.LogDebug("Reading from address {Address}", address);
|
_logger?.LogDebug("Reading from address {Address}", address);
|
||||||
|
|
||||||
// 临时读取(未优化)
|
// 临时读取(未优化)
|
||||||
int size = TypeHelper.GetSize(typeof(T));
|
var size = TypeHelper.GetSize(typeof(T));
|
||||||
var result = await _driver.ReadBytesAsync(address, (ushort)size);
|
var result = await _driver.ReadBytesAsync(address, (ushort)size);
|
||||||
|
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
@@ -160,7 +150,7 @@ public class PlcMonitorService : IPlcService, IDisposable
|
|||||||
|
|
||||||
_logger?.LogDebug("Writing to address {Address}", address);
|
_logger?.LogDebug("Writing to address {Address}", address);
|
||||||
|
|
||||||
int size = TypeHelper.GetSize(typeof(T));
|
var size = TypeHelper.GetSize(typeof(T));
|
||||||
var bytes = _converter.ToBytes(value, typeof(T), size);
|
var bytes = _converter.ToBytes(value, typeof(T), size);
|
||||||
|
|
||||||
if (bytes.Length == 0)
|
if (bytes.Length == 0)
|
||||||
@@ -172,13 +162,9 @@ public class PlcMonitorService : IPlcService, IDisposable
|
|||||||
var result = await _driver.WriteBytesAsync(address, bytes);
|
var result = await _driver.WriteBytesAsync(address, bytes);
|
||||||
|
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
{
|
|
||||||
_logger?.LogError("Failed to write to {Address}: {Error}", address, result.ErrorMessage);
|
_logger?.LogError("Failed to write to {Address}: {Error}", address, result.ErrorMessage);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
_logger?.LogDebug("Successfully wrote to {Address}", address);
|
_logger?.LogDebug("Successfully wrote to {Address}", address);
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -214,9 +200,11 @@ public class PlcMonitorService : IPlcService, IDisposable
|
|||||||
|
|
||||||
if (_reconnectAttempts >= MaxReconnectAttempts)
|
if (_reconnectAttempts >= MaxReconnectAttempts)
|
||||||
{
|
{
|
||||||
_logger?.LogCritical("Max reconnection attempts reached, monitor loop will continue trying");
|
_logger?.LogCritical(
|
||||||
|
"Max reconnection attempts reached, monitor loop will continue trying");
|
||||||
_reconnectAttempts = 0; // 重置以继续尝试
|
_reconnectAttempts = 0; // 重置以继续尝试
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,23 +230,18 @@ public class PlcMonitorService : IPlcService, IDisposable
|
|||||||
var readResult = await _driver.ReadBytesAsync(batch.StartAddress, (ushort)batch.TotalLength, token);
|
var readResult = await _driver.ReadBytesAsync(batch.StartAddress, (ushort)batch.TotalLength, token);
|
||||||
|
|
||||||
if (readResult.IsSuccess && readResult.Value != null)
|
if (readResult.IsSuccess && readResult.Value != null)
|
||||||
{
|
|
||||||
DistributeValues(batch, readResult.Value);
|
DistributeValues(batch, readResult.Value);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
_logger?.LogWarning("Batch read failed for address {Address}: {Error}",
|
_logger?.LogWarning("Batch read failed for address {Address}: {Error}",
|
||||||
batch.StartAddress, readResult.ErrorMessage);
|
batch.StartAddress, readResult.ErrorMessage);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
|
|
||||||
if (sw.Elapsed > _refreshRate)
|
if (sw.Elapsed > _refreshRate)
|
||||||
{
|
|
||||||
_logger?.LogWarning("Monitor loop took {Elapsed} ms, which is longer than refresh rate {Rate} ms",
|
_logger?.LogWarning("Monitor loop took {Elapsed} ms, which is longer than refresh rate {Rate} ms",
|
||||||
sw.Elapsed.TotalMilliseconds, _refreshRate.TotalMilliseconds);
|
sw.Elapsed.TotalMilliseconds, _refreshRate.TotalMilliseconds);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
_logger?.LogDebug("Monitor loop cancelled");
|
_logger?.LogDebug("Monitor loop cancelled");
|
||||||
@@ -284,7 +267,7 @@ public class PlcMonitorService : IPlcService, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var batchStart = batch.ParsedStartAddress;
|
var batchStart = batch.ParsedStartAddress;
|
||||||
int batchStartByte = batchStart.ByteOffset;
|
var batchStartByte = batchStart.ByteOffset;
|
||||||
|
|
||||||
foreach (var tag in batch.Tags)
|
foreach (var tag in batch.Tags)
|
||||||
{
|
{
|
||||||
@@ -295,16 +278,15 @@ public class PlcMonitorService : IPlcService, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var tagAddr = tag.ParsedAddress;
|
var tagAddr = tag.ParsedAddress;
|
||||||
int tagStartByte = tagAddr.ByteOffset;
|
var tagStartByte = tagAddr.ByteOffset;
|
||||||
int relativeOffset = tagStartByte - batchStartByte;
|
var relativeOffset = tagStartByte - batchStartByte;
|
||||||
|
|
||||||
// 检查边界
|
// 检查边界
|
||||||
int typeSize = tag.DataType == typeof(string)
|
var typeSize = tag.DataType == typeof(string)
|
||||||
? TypeHelper.GetSize(tag.DataType, tag.Length)
|
? TypeHelper.GetSize(tag.DataType, tag.Length)
|
||||||
: TypeHelper.GetSize(tag.DataType);
|
: TypeHelper.GetSize(tag.DataType);
|
||||||
|
|
||||||
if (relativeOffset + typeSize <= data.Length)
|
if (relativeOffset + typeSize <= data.Length)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 传递位偏移量以支持bool类型
|
// 传递位偏移量以支持bool类型
|
||||||
@@ -323,14 +305,11 @@ public class PlcMonitorService : IPlcService, IDisposable
|
|||||||
_logger?.LogError(ex, "Failed to parse value for tag {TagId} at address {Address}",
|
_logger?.LogError(ex, "Failed to parse value for tag {TagId} at address {Address}",
|
||||||
tag.Id, tag.Address);
|
tag.Id, tag.Address);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
_logger?.LogWarning("Tag {TagId} offset {Offset} + size {Size} exceeds batch data length {Length}",
|
_logger?.LogWarning("Tag {TagId} offset {Offset} + size {Size} exceeds batch data length {Length}",
|
||||||
tag.Id, relativeOffset, typeSize, data.Length);
|
tag.Id, relativeOffset, typeSize, data.Length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|||||||
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user