U:调整所有代码格式

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

No files matched your search

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