Compare commits

..
2 Commits
Author SHA1 Message Date
jimmy 155b67e831 U:调整所有代码格式 2025-12-30 09:17:25 +08:00
jimmy d133bacf55 D:删除构建文件,增加调试脚本 2025-12-29 10:08:26 +08:00
382 changed files with 10611 additions and 10934 deletions

No files matched your search

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