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