Compare commits

..
34 Commits
Author SHA1 Message Date
jimmy 479eae64a1 A:PLC 调试页面增加串口设置 2026-01-21 14:42:45 +08:00
jimmy 4973979169 A:补充部分缺失功能
U:调整元信息数据
2026-01-21 13:55:53 +08:00
jimmy b15e57d150 A:增加缺失文件 2026-01-04 14:50:17 +08:00
jimmy b2a9e94bad 更新 src/Sharp/README.md 2025-12-31 11:26:10 +08:00
jimmy 155b67e831 U:调整所有代码格式 2025-12-30 09:17:25 +08:00
jimmy d133bacf55 D:删除构建文件,增加调试脚本 2025-12-29 10:08:26 +08:00
jimmy 3b89b2d722 版权调整 2025-12-28 17:57:29 +08:00
jimmy 2bd35348dc A:增加权限认定 2025-12-28 17:08:46 +08:00
jimmy b738a2b166 D:删除Client和Server模块 2025-12-28 15:47:55 +08:00
jimmy 5e9e9fa818 A:删除测试文档 2025-12-28 15:44:06 +08:00
jimmyandClaude Sonnet 4.5 8ca0679fca 移除 Client 和 Server 模块,由 Sharp.Demo 替代
Sharp.Demo 模块已经整合了 Client 和 Server 的所有功能,
并采用了最佳实践(Quick Start API、标准 DI、Result<T> 模式等),
因此 Client 和 Server 模块已不再需要。

变更内容:
- 从解决方案中移除 Client 和 Server 项目
- 更新 CLAUDE.md 文档(根目录和 src/Sharp/)
- 更新项目结构说明,明确 Sharp.Demo 为综合演示应用
- 更新构建和运行命令,指向 Sharp.Demo
- 更新依赖关系说明

Sharp.Demo 优势:
- 使用 Quick Start API 简化配置
- 标准依赖注入(移除 Global 容器依赖)
- Result<T> 模式演示
- IApplicationState 应用状态管理
- 完整的开发者工具集成(F10/F11/F12)
- 交互式模块演示(17+ 功能点)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-28 15:41:21 +08:00
jimmy 1b5d57bd19 A:日志集成 2025-12-28 15:05:59 +08:00
jimmy f610bf5e34 A:集成配置文件 2025-12-28 13:08:42 +08:00
jimmy 54545aad35 A 2025-12-28 12:59:06 +08:00
jimmy 012c9522f8 1 2025-12-24 22:55:59 +08:00
jimmy 99c2c59552 A:集成数据库功能 2025-12-24 21:16:48 +08:00
jimmy 974d16833c A:增加页面集中管理 2025-12-24 19:36:42 +08:00
jimmy 2943485a2a A:调整西门子PLC数据解析方式 2025-12-23 16:38:26 +08:00
jimmy 30451cabbd A:增加可选页面模块 2025-12-22 21:19:01 +08:00
jimmy 37aa848b5f A:简化HandyControl引用 2025-12-22 20:20:19 +08:00
jimmy 2563288afe A:整体并简化代码 2025-12-22 19:50:09 +08:00
jimmy 2c83fc72e4 A:剩余提交 2025-12-22 16:40:34 +08:00
jimmy dd0681ba90 A:调整页面展示和引入方式 2025-12-16 15:00:53 +08:00
jimmy 4ec76b7855 U:调整调用方式和兼容性 2025-12-15 13:37:11 +08:00
jimmy 433e206d45 A:调整整体README 2025-12-11 16:53:33 +08:00
jimmy 26a2661625 A:开放PLC定时循环参数 2025-12-11 15:12:49 +08:00
jimmy 5b7f127bb5 A:调整数据监听方案 2025-12-11 14:44:58 +08:00
jimmy ea6103a596 A:增加Float格式支持 2025-12-11 14:00:01 +08:00
jimmy de1a29c522 A:增加订阅类型选择 2025-12-10 14:10:13 +08:00
jimmy 6ed0d3f1c2 U:修改PLC监听数据不会更新BUG 2025-12-10 13:37:04 +08:00
jimmy d9e5a5483c A:增加对应的说明文档。增加串口调试页面 2025-12-09 15:31:37 +08:00
jimmy bc09280d4e A:优化PLC数据管理 2025-12-09 13:44:09 +08:00
jimmy 2f3eb935dd A:增加PLC功能和西门子PLC对接 2025-12-09 09:40:50 +08:00
jimmy bca001b90f A:初始提交 2025-12-08 14:05:44 +08:00
480 changed files with 33894 additions and 9851 deletions

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# Visual Studio
.vs/
bin/
obj/
[Dd]ebug/
[Rr]elease/
*.user
*.suo
*.userprefs
*.usertasks
*.sln.docstates
*.dll
*.pdb
*.exe
!src/**/bin/**/*.dll
!src/**/bin/**/*.pdb
!src/**/bin/**/*.exe
# Rider
.idea/
# Dotnet
project.lock.json
project.fragment.lock.json
artifacts/
# Other
Thumbs.db
Desktop.ini
+19
View File
@@ -0,0 +1,19 @@
# Sharp-Basic Framework 贡献者
## 主要作者
**duyl328**
- 邮箱:通过 GitHub 联系
- 网站:https://seyl.me/
- 角色:项目创始人和主要维护者
## 贡献者
感谢所有为 Sharp-Basic 做出贡献的开发者!
如果您想为项目做出贡献,请查看 CONTRIBUTING.md 文件。
---
本文件列出了对 Sharp-Basic Framework 有重大贡献的个人和组织。
如果您认为您的名字应该出现在这里,请提交 Pull Request。
+89
View File
@@ -0,0 +1,89 @@
# 更新日志
本文档记录 Sharp-Basic Framework 的所有重要更改。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [未发布]
### 计划中
- Cache 模块完整实现
- EventBus 模块完整实现
- 性能监控和诊断工具
## [1.0.1] - 2025-01-XX
### 新增
- 完整的项目元数据和版权信息
- AUTHORS 文件列出贡献者
- CONTRIBUTING.md 贡献指南
- NOTICE 文件包含第三方依赖声明
- CHANGELOG.md 版本变更记录
- Directory.Build.props 统一管理项目元信息
### 变更
- 更新许可证为 AGPL-3.0-or-later
- 完善 README.md,添加项目徽章和详细信息
- 统一版权声明为 Copyright © 2024-2025 duyl328
### 文档
- 更新 CLAUDE.md 项目指南
- 添加快速开始指南
## [1.0.0] - 2024-XX-XX
### 新增
- ✨ 初始版本发布
- 核心模块实现:
- **Sharp.Common**: 基础工具库(Result<T>、Global DI 容器、扩展方法)
- **Sharp.Config**: 多格式配置管理(JSON/XML/INI/TOML)
- **Sharp.Data**: EF Core 数据访问层(仓储模式、审计、软删除)
- **Sharp.Log**: Serilog 日志集成
- **Sharp.Wpf**: WPF MVVM 基础库
- **Sharp.Wpf.HandyControl**: HandyControl UI 库集成
- **Sharp.Wpf.DevTools**: 开发者工具(DevTools 窗口、日志查看器)
- **Sharp.NetClient**: HTTP 客户端(Polly 重试)
- **Sharp.NetServer**: 轻量级 Web 服务器
- **Sharp.Ports**: 串口通信库
- **Sharp.Plc**: PLC 通信抽象层
- **Sharp.Plc.Siemens**: 西门子 S7 PLC 驱动
- **Sharp.Process**: 进程执行管理
- **Sharp.Python**: Python 脚本执行
- 📦 完整的单元测试覆盖
- 📚 各模块 README 文档
- 🎯 Sharp.Demo 综合示例应用
### 架构
- 模块化设计,引入即用
- 约定优于配置的理念
- 固执己见的技术栈(EF Core, Serilog, HandyControl)
- Result<T> 函数式错误处理
- 混合 DI 方式(标准 DI + Global 容器)
---
## 版本说明
### 版本号格式: MAJOR.MINOR.PATCH
- **MAJOR**: 不兼容的 API 变更
- **MINOR**: 向后兼容的功能新增
- **PATCH**: 向后兼容的问题修正
### 变更类型
- `新增`: 新功能
- `变更`: 现有功能的变更
- `弃用`: 即将移除的功能
- `移除`: 已移除的功能
- `修复`: Bug 修复
- `安全`: 安全相关的修复
- `文档`: 文档相关变更
- `性能`: 性能改进
---
[未发布]: https://gitea.duyl328.org/jimmy/sharp-basic/compare/v1.0.1...HEAD
[1.0.1]: https://gitea.duyl328.org/jimmy/sharp-basic/compare/v1.0.0...v1.0.1
[1.0.0]: https://gitea.duyl328.org/jimmy/sharp-basic/releases/tag/v1.0.0
+226
View File
@@ -0,0 +1,226 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Sharp-Basic is a modular C# .NET 8 framework for building enterprise applications with reusable components. It provides a comprehensive toolkit with emphasis on:
- **Modularity**: Independent, reusable libraries that can be used selectively
- **Desktop Applications**: WPF-based UI toolkit with MVVM infrastructure
- **Industrial IoT**: PLC integration (Siemens) and serial port communication
- **Configuration as Code**: Multi-format (JSON/XML/INI/TOML) with hot-reload
- **Functional Programming**: Result<T> pattern for railway-oriented error handling
**Language**: All comments and documentation are in Chinese (中文).
## Build Commands
### Build the entire solution
```bash
cd src/Sharp
dotnet build Sharp.sln
```
### Build specific project
```bash
cd src/Sharp
dotnet build Sharp.Demo/Sharp.Demo.csproj # WPF demo application (最佳实践示例)
dotnet build Common/Common.csproj # Core utilities
dotnet build Config/Config.csproj # Configuration management
```
### Run tests
```bash
cd src/Sharp
dotnet test Sharp.sln # All tests
dotnet test ../../test/Common.Test # Specific test project
```
### Run the Sharp.Demo application
```bash
cd src/Sharp
dotnet run --project Sharp.Demo/Sharp.Demo.csproj
```
### Clean build artifacts
```bash
cd src/Sharp
dotnet clean Sharp.sln
```
## Architecture Overview
### Project Structure
```
src/Sharp/ # All source projects
├── Common/ # Core foundation (Result<T>, Global container, utilities, SystemConfig)
├── Config/ # Multi-format configuration with hot-reload
├── Wpf/ # WPF UI toolkit (MVVM, dialogs, converters) - NO UI library dependency
├── Wpf.HandyControl/ # HandyControl UI library integration (optional)
├── Wpf.DevTools/ # WPF Developer Tools (DevTools, LogViewer, Performance Monitor)
├── Data/ # EF Core wrapper with repository pattern
├── Log/ # Serilog-based structured logging
├── Cache/ # Memory cache (planned)
├── EventBus/ # Event pub/sub (planned)
├── NetClient/ # HTTP client with Polly resilience
├── NetServer/ # Lightweight HTTP server with middleware
├── Ports/ # Serial port abstraction (RS232/RS485)
├── Plc/ # PLC abstraction interface
├── Plc.Siemens/ # Siemens S7 PLC implementation (S7.NET+)
├── Process/ # Process execution
├── Python/ # Python script execution
└── Sharp.Demo/ # 综合演示应用(最佳实践示例,替代了 Client 和 Server)
test/ # MSTest test projects
├── Common.Test/
├── Config.Test/
├── Data.Test/
├── Log.Test/
├── NetClient.Test/
└── NetServer.Test/
appsettings.template.json # 完整的配置模板文件
```
### Key Architectural Patterns
#### 1. Result<T> Pattern (Railway-Oriented Programming)
- Eliminates exceptions as control flow
- Provides chainable operations: `Map`, `Bind`, `Match`, `Where`, `OnSuccess`, `OnFailure`
- Synchronous and async wrappers: `Result.Try()`, `Result.TryAsync()`
- Example usage:
```csharp
Result.Try(() => int.Parse(input))
.Map(x => x * 2)
.Where(v => v > 100, "Value too small")
.OnSuccess(v => Console.WriteLine(v))
.OnFailure((ex, msg) => logger.LogError(msg));
```
#### 2. Dependency Injection (Hybrid Approach)
- **Primary**: Microsoft.Extensions.DependencyInjection for standard DI
- **Fallback**: Global service locator (Common.Global) for legacy/convenience
- Services registered in `DependencyInjection` folders within each module
- Client app startup (App.xaml.cs) orchestrates all service registration
#### 3. Configuration Management
- Multi-source configuration builder supporting JSON/XML/INI/TOML
- Hot-reload with change notifications
- Strong typing with DataAnnotations validation
- IOptions<T> pattern integration
- Environment variable overrides with optional prefix
#### 4. Repository Pattern with EF Core
- Generic `IRepository<TEntity>` abstraction
- Built-in entity types: `Entity`, `AuditableEntity`, `ISoftDeletable`
- Auto-audit tracking (CreatedAt, CreatedBy, UpdatedAt, UpdatedBy)
- Multi-database support: SQL Server, SQLite, PostgreSQL, MySQL, In-Memory
#### 5. MVVM for WPF
- `ViewModelBase` with INotifyPropertyChanged and IsBusy tracking
- `RelayCommand` and `AsyncCommand` implementations
- Services: `IDialogService`, `IFileDialogService`, `IDispatcherService`
- Converters: BoolToVisibility, InverseBooleanConverter, EnumToDescriptionConverter
- Global exception handling for UI/Task/AppDomain exceptions
#### 6. Middleware Pattern
- Custom HTTP server (NetServer) with ASP.NET Core-style pipeline
- Middleware: Authentication, CORS, Rate Limiting, Caching, Logging, Exception Handling
- Attribute-based routing with `RouteAttribute`
- Custom `IMiddleware` interface for extensibility
## Module Dependencies
**Core Layer** (no external dependencies):
- Common → (no dependencies)
**Configuration Layer**:
- Config → Microsoft.Extensions.Configuration.*, Tomlyn (TOML support)
**Data Layer**:
- Data → EF Core, Common
- Repository → Data
**Network Layer**:
- NetClient → Polly, Microsoft.Extensions.Http, Common
- NetServer → Common, Log
**WPF Layer**:
- Wpf → HandyControl (optional UI controls)
- Client → Wpf, Config, NetClient, NetServer, Log, Plc.Siemens, Ports
**Industrial IoT**:
- Ports → (serial port communication)
- Plc → (abstraction only)
- Plc.Siemens → Plc, S7.NET+ library
**Logging**:
- Log → Serilog, Microsoft.Extensions.Logging
## Common Development Workflows
### Adding a new module/library
1. Create project under `src/Sharp/`
2. Target `net8.0` (or `net8.0-windows` for WPF)
3. Enable nullable reference types: `<Nullable>enable</Nullable>`
4. Create `DependencyInjection` folder for service registration extensions
5. Add test project under `test/` with MSTest
6. Add to Sharp.sln: `dotnet sln add <ProjectPath>`
### Working with Result<T>
- Use `Result.Try()` to wrap exception-prone code
- Chain operations with `Map()` for transformations
- Use `Bind()` for operations that return Result<T>
- Extract values with `GetOrDefault()`, `GetOrThrow()`, or `Match()`
- Always handle both success and failure cases
### Using the Global container
- Register singletons: `Global.RegisterSingleton<T>(instance)` or `Global.RegisterSingleton<T>(() => new T())`
- Register transients: `Global.RegisterTransient<T>(() => new T())`
- Subscribe to changes: `Global.Subscribe<T>(e => { /* handle change */ })`
- Retrieve services: `Global.Get<T>()`
- Prefer standard DI (IServiceCollection) for new code
### Configuration workflow
1. Add config files to project root (appsettings.json, etc.)
2. Create strongly-typed options class with DataAnnotations
3. Build ConfigManager in App.xaml.cs OnStartup
4. Register with DI: `services.AddSingleton(cfg)` and `services.AddSingleton(cfg.Configuration)`
5. Use hot-reload callbacks: `cfg.OnReload(() => { /* refresh */ })`
### WPF Client startup flow
The Client application (App.xaml.cs) orchestrates initialization:
1. Register global exception handlers (UI, Task, AppDomain)
2. Build ConfigManager from appsettings.json
3. Setup ServiceCollection and register all services
4. Create ServiceProvider
5. Show LoginWindow (modal)
6. On successful login, show MainWindow
### Working with PLC (Siemens)
- Interface: `IPLC` in Plc project
- Implementation: Plc.Siemens using S7.NET+ library
- Supports: S7-1200, S7-1500, S7-300, S7-400
- Register with DI: Add Siemens PLC service with connection options
- Methods: Read/Write memory areas, data blocks, etc.
### Serial Port Communication
- Service: `ISerialPortService` in Ports module
- Discovery: `SerialPortDiscovery` for auto-detection
- Event-based data reception: `DataReceivedEventArgs`
- Configured with: baud rate, parity, stop bits, data bits
## Code Style
- **Target Framework**: .NET 8 (net8.0 / net8.0-windows)
- **Nullable**: Enabled (`#nullable enable`)
- **Implicit Usings**: Enabled
- **Namespace Convention**: `Sharp.*` (Common will migrate to `Sharp.Common`)
- **Language**: All comments, docs, and strings in Chinese
- **Backwards Compatibility**: Maintain for minor versions; breaking changes only in major versions
- **Testing**: MSTest with parallel execution enabled
## License
GNU AGPLv3 - Network services that use this framework must open source modifications. Contact author for alternative commercial licensing.
+486
View File
@@ -0,0 +1,486 @@
# Sharp-Basic 配置指南
本文档说明 Sharp-Basic 框架的配置约定和最佳实践。
## 目录
- [配置文件模板](#配置文件模板)
- [约定配置节名称](#约定配置节名称)
- [环境变量覆盖](#环境变量覆盖)
- [模块配置详解](#模块配置详解)
- [最佳实践](#最佳实践)
---
## 配置文件模板
Sharp-Basic 提供了完整的配置文件模板:**`appsettings.template.json`**
### 使用方法
1. **复制模板文件**
```bash
cp appsettings.template.json appsettings.json
```
2. **根据项目需要选择性保留配置**
- 只使用数据库?保留 `Database` 节
- 需要PLC通信?保留 `Plc:Siemens` 节
- 不需要的配置可以删除
3. **修改配置值**
```json
{
"Database": {
"Provider": "SqlServer",
"ConnectionString": "你的数据库连接字符串"
},
"Plc": {
"Siemens": {
"IpAddress": "你的PLC IP地址"
}
}
}
```
---
## 约定配置节名称
Sharp-Basic 遵循 **"约定优于配置"** 原则,每个模块都有固定的配置节名称:
### 核心模块
| 模块 | 配置节名称 | 说明 | 必填字段 |
|------|------------|------|----------|
| **System** | `System` | 系统级HTTP配置 | `HTTPIp`, `HttpPort` |
| **Database** | `Database` | 数据库连接配置 | `Provider`, `ConnectionString` |
| **Logging** | `Logging` | 日志级别配置 | `LogLevel` |
| **SharpLog** | `SharpLog` | Sharp日志扩展 | 无(全部可选) |
### 网络模块
| 模块 | 配置节名称 | 说明 | 必填字段 |
|------|------------|------|----------|
| **NetClient** | `NetClient` | HTTP客户端配置 | 无(可使用System默认值) |
| **NetServer** | `NetServer` | HTTP服务端配置 | 无(可使用System默认值) |
### IoT模块
| 模块 | 配置节名称 | 说明 | 必填字段 |
|------|------------|------|----------|
| **Plc.Siemens** | `Plc:Siemens` | 西门子PLC配置 | `IpAddress`, `CpuType` |
| **SerialPort** | `SerialPort` | 串口通信配置 | `PortName`, `BaudRate` |
### 工具模块
| 模块 | 配置节名称 | 说明 | 必填字段 |
|------|------------|------|----------|
| **Process** | `Process` | 进程执行配置 | 无(全部可选) |
| **Python** | `Python` | Python脚本配置 | 无(全部可选) |
| **Cache** | `Cache` | 缓存配置 | 无(全部可选) |
| **EventBus** | `EventBus` | 事件总线配置 | 无(全部可选) |
---
## 环境变量覆盖
所有配置都可以通过环境变量覆盖,格式:`SHARP__{节名}__{键名}`
### 覆盖规则
- 使用双下划线 `__` 分隔层级
- 所有字母必须大写(Windows不区分大小写,Linux区分)
- 冒号 `:` 用双下划线 `__` 替代
### 示例
#### 覆盖数据库配置
```bash
# Windows (PowerShell)
$env:SHARP__Database__Provider="Sqlite"
$env:SHARP__Database__ConnectionString="Data Source=test.db"
# Linux/macOS
export SHARP__Database__Provider=Sqlite
export SHARP__Database__ConnectionString="Data Source=test.db"
```
#### 覆盖PLC配置
```bash
# Windows (PowerShell)
$env:SHARP__Plc__Siemens__IpAddress="192.168.1.200"
$env:SHARP__Plc__Siemens__CpuType="S71500"
# Linux/macOS
export SHARP__Plc__Siemens__IpAddress=192.168.1.200
export SHARP__Plc__Siemens__CpuType=S71500
```
#### 覆盖System配置
```bash
# Windows (PowerShell)
$env:SHARP__System__HttpPort="9090"
# Linux/macOS
export SHARP__System__HttpPort=9090
```
### 优先级
配置优先级从高到低:
1. **环境变量** - 最高优先级
2. **appsettings.{Environment}.json** - 环境特定配置
3. **appsettings.json** - 默认配置
---
## 模块配置详解
### 1. 数据库配置 (Database)
#### 最简配置
```json
{
"Database": {
"Provider": "Sqlite",
"ConnectionString": "Data Source=app.db"
}
}
```
#### 代码中使用
```csharp
var config = ConfigBuilder.CreateDefault().Configuration;
services.AddSharpDatabase<AppDbContext>(config); // 自动读取 Database 节
```
#### 支持的数据库提供程序
- `SqlServer` - Microsoft SQL Server
- `Sqlite` - SQLite
- `PostgreSQL` - PostgreSQL
- `MySql` - MySQL/MariaDB
- `InMemory` - 内存数据库(仅用于测试)
---
### 2. PLC配置 (Plc:Siemens)
#### 最简配置
```json
{
"Plc": {
"Siemens": {
"IpAddress": "192.168.1.100",
"CpuType": "S71200"
}
}
}
```
#### 代码中使用
```csharp
var config = ConfigBuilder.CreateDefault().Configuration;
services.AddSiemensPlc(config.GetSection("Plc:Siemens"));
```
#### 支持的CPU类型
- `S71200` - S7-1200 系列
- `S71500` - S7-1500 系列
- `S7300` - S7-300 系列
- `S7400` - S7-400 系列
- `S7200` - S7-200 系列
- `S7200Smart` - S7-200 Smart 系列
---
### 3. 日志配置 (Logging & SharpLog)
#### 最简配置
```json
{
"Logging": {
"LogLevel": {
"Default": "Information"
}
},
"SharpLog": {
"LogFilePath": "logs/app-.log",
"RollingInterval": "Day"
}
}
```
#### 代码中使用
```csharp
services.AddLogging(b => b.AddSharpLog()); // 自动读取 SharpLog 节
```
---
### 4. HTTP配置 (System, NetClient, NetServer)
#### System配置(共享默认值)
```json
{
"System": {
"HTTPIp": "127.0.0.1",
"HttpPort": 8080,
"TimeoutSeconds": 30,
"Scheme": "http"
}
}
```
#### NetClient配置(可选,留空则使用System默认值)
```json
{
"NetClient": {
"BaseAddress": "http://api.example.com",
"Timeout": "00:00:30",
"EnableLogging": true
}
}
```
#### NetServer配置(可选,留空则使用System默认值)
```json
{
"NetServer": {
"ListenUrls": [
"http://localhost:8080/"
]
}
}
```
---
## 最佳实践
### 1. 按需配置
**只保留需要的配置节**,不要保留所有模板内容:
```json
{
// ❌ 不好:保留了不使用的配置
"Database": { ... },
"Plc": { ... },
"SerialPort": { ... },
"Python": { ... }
}
{
// ✅ 好:只保留需要的配置
"Database": { ... },
"Logging": { ... }
}
```
### 2. 使用环境特定配置
为不同环境创建不同的配置文件:
```
appsettings.json # 默认配置
appsettings.Development.json # 开发环境
appsettings.Production.json # 生产环境
appsettings.Testing.json # 测试环境
```
### 3. 敏感信息使用环境变量
**不要在配置文件中存储敏感信息**(密码、密钥等):
```bash
# ✅ 好:使用环境变量
export SHARP__Database__ConnectionString="Server=prod;User=sa;Password=SecretPass123"
export SHARP__Plc__Siemens__IpAddress="192.168.1.100"
```
```json
// ❌ 不好:敏感信息写在配置文件
{
"Database": {
"ConnectionString": "Server=prod;User=sa;Password=SecretPass123"
}
}
```
### 4. 添加注释说明
在配置文件中添加注释(JSON5格式,或删除注释后部署):
```json
{
// 数据库配置 - 生产环境使用SQL Server
"Database": {
"Provider": "SqlServer",
"ConnectionString": "Server=.;Database=MyApp;..."
},
// PLC配置 - 车间1号PLC
"Plc": {
"Siemens": {
"IpAddress": "192.168.1.100", // 车间1号PLC
"CpuType": "S71200"
}
}
}
```
### 5. 配置验证
Sharp-Basic 会在启动时验证必填配置项:
```csharp
// ✅ 好:配置缺失时会抛出清晰的异常
services.AddSharpDatabase<AppDbContext>(config);
// 异常:未找到数据库连接字符串配置: Database:ConnectionString
services.AddSiemensPlc(config.GetSection("Plc:Siemens"));
// 异常:未配置PLC IP地址
```
---
## 快速参考
### 典型WPF应用配置
```json
{
"System": {
"HTTPIp": "127.0.0.1",
"HttpPort": 8080
},
"Database": {
"Provider": "Sqlite",
"ConnectionString": "Data Source=app.db"
},
"Logging": {
"LogLevel": {
"Default": "Information"
}
},
"SharpLog": {
"LogFilePath": "logs/app-.log",
"RollingInterval": "Day"
}
}
```
### 工业IoT应用配置
```json
{
"System": {
"HTTPIp": "0.0.0.0",
"HttpPort": 8080
},
"Database": {
"Provider": "SqlServer",
"ConnectionString": "Server=.;Database=ScadaDB;Trusted_Connection=True;"
},
"Plc": {
"Siemens": {
"IpAddress": "192.168.1.100",
"CpuType": "S71200",
"MonitorInterval": 500,
"AutoReconnect": true
}
},
"SerialPort": {
"PortName": "COM1",
"BaudRate": 9600
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Plc.Siemens": "Debug"
}
}
}
```
### Web服务应用配置
```json
{
"System": {
"HTTPIp": "0.0.0.0",
"HttpPort": 5000,
"Scheme": "https"
},
"Database": {
"Provider": "PostgreSQL",
"ConnectionString": "Host=localhost;Database=myapi;Username=postgres;Password=***"
},
"NetServer": {
"ListenUrls": [
"https://0.0.0.0:5000/",
"http://0.0.0.0:5001/"
],
"ShowDetailedErrors": false
},
"Cache": {
"DefaultExpirationSeconds": 300,
"SizeLimit": 10000
}
}
```
---
## 相关文档
- [appsettings.template.json](appsettings.template.json) - 完整配置模板
- [Config模块README](src/Sharp/Config/README.md) - 配置管理详解
- [Data模块README](src/Sharp/Data/README.md) - 数据库配置详解
- [Plc.Siemens模块README](src/Sharp/Plc.Siemens/README.md) - PLC配置详解
---
## 故障排查
### 问题:找不到配置节
**错误信息**:
```
未找到数据库连接字符串配置: Database:ConnectionString
```
**解决方法**:
1. 检查配置文件名是否为 `appsettings.json`
2. 检查配置节名称是否正确(区分大小写)
3. 检查配置文件是否在项目根目录
4. 检查配置文件的 "复制到输出目录" 设置
### 问题:环境变量不生效
**原因**:环境变量格式错误
**正确格式**:
```bash
# ✅ 正确:双下划线分隔
SHARP__Database__Provider=Sqlite
# ❌ 错误:单下划线
SHARP_Database_Provider=Sqlite
# ❌ 错误:使用冒号
SHARP:Database:Provider=Sqlite
```
### 问题:配置被覆盖
**检查优先级**:
1. 先检查环境变量
2. 再检查 appsettings.{Environment}.json
3. 最后检查 appsettings.json
---
**版本**: 1.0.0
**更新日期**: 2025-12-22
+197
View File
@@ -0,0 +1,197 @@
# 贡献指南
感谢您考虑为 Sharp-Basic Framework 做出贡献!
## 📜 行为准则
请遵守以下基本原则:
- 尊重所有贡献者
- 保持友好和包容的态度
- 接受建设性的批评
- 专注于对社区最有利的事情
## 🚀 如何贡献
### 报告 Bug
如果您发现了 bug,请创建一个 Issue 并包含以下信息:
1. **标题**: 简洁描述问题
2. **环境**: 操作系统、.NET 版本、项目版本
3. **复现步骤**: 详细的步骤
4. **预期行为**: 应该发生什么
5. **实际行为**: 实际发生了什么
6. **日志/截图**: 如果有的话
### 提出新功能
在开始编码之前:
1. 先创建一个 Issue 讨论您的想法
2. 等待维护者的反馈
3. 确保功能符合项目的整体方向
### 提交代码
#### 准备工作
```bash
# Fork 项目到您的账号
# Clone 到本地
git clone https://gitea.duyl328.org/您的用户名/sharp-basic.git
cd sharp-basic
# 添加上游仓库
git remote add upstream https://gitea.duyl328.org/jimmy/sharp-basic.git
# 创建功能分支
git checkout -b feature/您的功能名称
```
#### 代码规范
1. **命名约定**
- 类名:PascalCase(如 `ConfigManager`)
- 方法名:PascalCase(如 `BuildConfiguration`)
- 私有字段:_camelCase(如 `_logger`)
- 参数/局部变量:camelCase(如 `userName`)
2. **注释规范**
- 所有公共 API 必须有 XML 文档注释
- 使用中文编写注释和文档
- 复杂逻辑需要添加行内注释
3. **代码风格**
- 启用 nullable 引用类型
- 使用 `var` 当类型明显时
- 一行不超过 120 字符
- 使用 4 个空格缩进(不使用 Tab)
4. **测试要求**
- 新功能必须包含单元测试
- 测试覆盖率应 >= 80%
- 使用 MSTest 框架
- 测试方法命名: `方法名_场景_预期结果`
#### 提交信息规范
使用以下格式:
```
<类型>(<模块>): <简短描述>
<详细描述(可选)>
<关联 Issue(如果有)>
```
**类型**:
- `feat`: 新功能
- `fix`: Bug 修复
- `docs`: 文档更新
- `style`: 代码格式调整(不影响功能)
- `refactor`: 重构
- `test`: 测试相关
- `chore`: 构建/工具相关
**示例**:
```
feat(Config): 添加 TOML 格式支持
实现了基于 Tomlyn 的 TOML 配置文件解析功能
支持热重载和强类型绑定
Closes #123
```
#### Pull Request 流程
1. **确保代码质量**
```bash
# 运行所有测试
dotnet test
# 构建项目
dotnet build -c Release
```
2. **同步上游更改**
```bash
git fetch upstream
git rebase upstream/main
```
3. **提交 PR**
- 填写清晰的 PR 标题和描述
- 关联相关的 Issue
- 确保 CI 通过(如果有)
- 等待代码审查
4. **代码审查**
- 维护者会审查您的代码
- 根据反馈进行修改
- 保持耐心和礼貌
## 📂 项目结构
```
src/Sharp/ # 所有源代码项目
├── Common/ # 核心工具库
├── Config/ # 配置管理
├── Data/ # 数据访问层
├── Log/ # 日志模块
├── Wpf/ # WPF 基础库
└── ...
test/ # 测试项目
├── Common.Test/
├── Config.Test/
└── ...
```
## 🔧 开发环境设置
### 必需工具
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
- [Git](https://git-scm.com/)
- 推荐 IDE:
- [Visual Studio 2022](https://visualstudio.microsoft.com/) (Windows)
- [Rider](https://www.jetbrains.com/rider/) (跨平台)
- [VS Code](https://code.visualstudio.com/) + C# 扩展
### 首次构建
```bash
# 还原依赖
dotnet restore
# 构建解决方案
dotnet build Sharp.sln
# 运行测试
dotnet test
```
## 📋 待办事项
查看 [Issues](https://gitea.duyl328.org/jimmy/sharp-basic/issues) 页面了解:
- 标记为 `good first issue` 的任务适合新贡献者
- 标记为 `help wanted` 的任务需要帮助
## 📝 文档贡献
文档同样重要!您可以:
- 改进现有文档的清晰度
- 添加示例代码
- 修正错别字
- 翻译文档(如果需要)
## ⚖️ 许可证
提交代码即表示您同意将您的贡献以 [AGPL-3.0-or-later](LICENSE) 许可证发布。
## 💬 联系方式
如有任何问题:
- 创建 [Issue](https://gitea.duyl328.org/jimmy/sharp-basic/issues)
- 查看 [README](README.md) 获取更多信息
- 访问作者网站: [https://seyl.me/](https://seyl.me/)
---
再次感谢您的贡献!🎉
+2 -2
View File
@@ -219,8 +219,8 @@ If you develop a new program, and you want it to be of the greatest possible use
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
sharp-basic Sharp-Basic Framework
Copyright (C) 2025 jimmy Copyright (C) 2024-2025 duyl328
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
+64
View File
@@ -0,0 +1,64 @@
Sharp-Basic Framework
Copyright © 2024-2025 duyl328
https://gitea.duyl328.org/jimmy/sharp-basic
本软件采用 GNU Affero General Public License v3.0 或更高版本 (AGPL-3.0-or-later) 许可。
================================================================================
本软件使用了以下第三方开源组件:
--------------------------------------------------------------------------------
Microsoft.Extensions.* 系列
版权所有 © .NET Foundation and Contributors
许可证: MIT
来源: https://github.com/dotnet/runtime
包括但不限于:
- Microsoft.Extensions.DependencyInjection
- Microsoft.Extensions.Configuration
- Microsoft.Extensions.Logging
- Microsoft.Extensions.Options
--------------------------------------------------------------------------------
Entity Framework Core
版权所有 © .NET Foundation and Contributors
许可证: MIT
来源: https://github.com/dotnet/efcore
--------------------------------------------------------------------------------
Serilog
版权所有 © Serilog Contributors
许可证: Apache-2.0
来源: https://github.com/serilog/serilog
--------------------------------------------------------------------------------
Polly
版权所有 © Polly Contributors
许可证: BSD-3-Clause
来源: https://github.com/App-vNext/Polly
--------------------------------------------------------------------------------
HandyControl
版权所有 © HandyOrg
许可证: MIT
来源: https://github.com/HandyOrg/HandyControl
--------------------------------------------------------------------------------
S7.NET+
版权所有 © S7.NET+ Contributors
许可证: MIT
来源: https://github.com/S7NetPlus/s7netplus
--------------------------------------------------------------------------------
Tomlyn (TOML 解析)
版权所有 © Alexandre Mutel
许可证: BSD-2-Clause
来源: https://github.com/xoofx/Tomlyn
================================================================================
完整的第三方许可证文本,请参阅各组件的官方仓库或 NuGet 包中的 LICENSE 文件。
本软件依赖的所有第三方组件均为开源软件,并在其各自的许可证下分发。
使用本软件即表示您同意遵守这些第三方许可证的条款。
+83 -257
View File
@@ -1,287 +1,113 @@
# sharp-basic # Sharp-Basic Framework
C# 公共基础核心(模块化、多项目可复用) [![Version](https://img.shields.io/badge/version-1.0.1-blue.svg)](https://gitea.duyl328.org/jimmy/sharp-basic)
[![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-green.svg)](LICENSE)
[![.NET](https://img.shields.io/badge/.NET-8.0-purple.svg)](https://dotnet.microsoft.com/)
sharp-basic 旨在将常用基础能力拆分为可独立复用的模块(库项目),帮助业务项目即取即用,减少样板代码与重复实现。 一个基于 .NET 8 的现代化工业自动化与企业级应用开发套件,采用模块化架构设计,提供了从底层通信、基础设施到上层 UI 的完整解决方案。
当前仓库采用多项目结构,包含 Common、Config 等核心模块,以及 Logging、EventBus、NetClient/NetServer 等待实现或扩展的模块,同时提供 WPF/Client 示例项目便于演示与调试。 ## 📋 项目信息
## 功能概览 - **版本**: 1.0.1
- **作者**: [duyl328](https://seyl.me/)
- **仓库**: [https://gitea.duyl328.org/jimmy/sharp-basic](https://gitea.duyl328.org/jimmy/sharp-basic)
- **许可证**: AGPL-3.0-or-later
- 通用工具(Sharp.Common) ## 🏗️ 架构概览
- 结果类型 Result/Result<T>:统一成功/失败表达、错误码/异常、Map/Bind/LINQ 风格链式操作、同步/异步 Try 包装
- 轻量服务容器 Global:线程安全注册与获取(Singleton/Transient)、实例变更通知(订阅发布)、批量管理
- 实用工具与扩展:JSON 序列化选项与 IPAddress 转换、端口探测、管理员权限检测、字符串解析等
- 配置中心(Sharp.Config)
- 统一构建器:支持 Json/Ini/Xml/Toml 多源叠加、可选/必选、基路径/文件提供器、环境变量、热重载
- 强类型绑定与验证:DataAnnotations 递归验证、TryGet/异常详情、对象保存与读取(多格式序列化)
- 扩展钩子:自定义 IConfigurationBuilder 管道(ConfigureBuilderHook)
- 日志(Sharp.Log)【规划中】
- 基于 Microsoft.Extensions.Logging 的统一抽象,适配 Console/File/Serilog/NLog,结构化日志与上下文
- 事件总线(Sharp.EventBus)【规划中】
- 进程内发布订阅、异步/顺序/粘性事件,支持 WPF 弱订阅;外部 Broker(Redis/RabbitMQ/Kafka)适配扩展
- 网络(Sharp.NetClient / Sharp.NetServer)【规划中】
- HttpClientFactory 封装、重试熔断超时、请求签名、统一 Result<T> 返回;最小 API Host 与中间件
- 桌面工具(Sharp.Wpf / Sharp.Client)
- WPF 示例/样板,用于演示配置热更、事件订阅、日志等集成的参考
## 目录结构 本项目包含以下核心模块,旨在实现高内聚、低耦合的开发体验:
``` ### 🔌 核心基础设施
src/Sharp/ - **[Sharp.Common](Common/README.md)**: 基础工具库。提供轻量级 DI 容器 (`Global`)、函数式结果模式 (`Result<T>`)、以及丰富的扩展方法(String, DateTime, Encryption, Reflection)。
Common/ # 通用能力(Result、Global、Utils、扩展) - **[Sharp.Config](Config/README.md)**: 强大的配置管理。支持 JSON/XML/INI/TOML 多源聚合、强类型绑定和数据注解验证。
Config/ # 配置集中管理与序列化 - **[Sharp.Log](Log/README.md)**: 统一日志门面。基于 Serilog 封装,支持一行代码接入,提供结构化日志和文件滚动策略。
Log/ # 日志抽象(规划中) - **[Sharp.EventBus](EventBus/README.md)**: (开发中) 轻量级事件总线。
EventBus/ # 事件总线(规划中)
NetClient/ # 客户端网络(规划中) ### 💾 数据与存储
NetServer/ # 服务端网络(规划中) - **[Sharp.Data](Data/README.md)**: 极简数据访问层。基于 EF Core,提供开箱即用的仓储模式、自动审计(创建人/时间)、软删除和分页支持。
Wpf/ # WPF 示例 - **[Sharp.Repository](Repository/README.md)**: 数据库集成模块。通过配置切换 SQL Server/MySQL/PostgreSQL/Sqlite,支持自动迁移。
Client/ # WPF 示例(另一个样板)
test/ ### 🌐 网络与通信
Common.Test/ # Common 核心用例(MSTest) - **[Sharp.NetServer](NetServer/README.md)**: 轻量级 Web 框架。基于 `HttpListener`,提供类似 ASP.NET Core 的 Controller/Route/Middleware 开发体验,适合嵌入式或桌面应用内嵌 Web 服务。
- **[Sharp.NetClient](NetClient/README.md)**: 强类型 HTTP 客户端。集成 Polly 重试策略、Bearer Token 认证和 multipart 上传。
- **[Sharp.Ports](Ports/README.md)**: 串口通信库。提供串口发现、连接管理、数据监听和诊断工具。
### 🏭 工业互联 (IIoT)
- **[Sharp.Plc](Plc/README.md)**: PLC 通信抽象层。定义了统一的读写、订阅接口。
- **[Sharp.Plc.Siemens](Plc.Siemens/README.md)**: 西门子驱动实现。支持 S7-200/300/400/1200/1500,内置智能批量读取优化算法(自动合并相邻地址请求)。
### 🖥️ 桌面应用 UI
- **[Sharp.Wpf](Wpf/README.md)**: WPF 基础库。提供 MVVM 基类、全局异常捕获、常用转换器、对话框服务和行为扩展。
- **[Sharp.Client](Client/README.md)**: 综合客户端示例。集成 PLC 监控、串口调试和用户登录功能的工业控制台应用。
- **[Sharp.Server](Server/README.md)**: 服务器控制台示例。展示了如何在一个 WPF 应用中托管 NetServer Web 服务,并提供可视化管理界面。
## 🚀 快速开始
### 环境要求
- .NET 8.0 SDK
- Visual Studio 2022 或 VS Code
### 构建项目
```bash
# 还原依赖
dotnet restore
# 编译解决方案
dotnet build Sharp.sln -c Debug
``` ```
目标框架:.NET 8(net8.0 / net8.0-windows)。 ### 运行示例
## 快速开始 **1. 运行 WPF 客户端:**
```bash
> 当前未发布 NuGet 包,可在解决方案内直接引用相关项目,或将模块作为子模块引入你的解决方案。 dotnet run --project Client/Client.csproj
### 使用 Result 统一返回
```csharp
using Common.Results;
Result<int> r1 = Result.Success(123);
Result r2 = Result.Failure("something wrong");
// Try 模式(同步/异步)
var r3 = Result.Try(() => int.Parse("456"));
var r4 = await Result.TryAsync(async () => { await Task.Delay(10); return 789; });
// Map/Bind 链式管道
var r5 = r3.Map(x => x * 2).Where(v => v > 500, "too small");
// 提取值(或异常)
int value = r5.GetOrDefault(0); // 成功取值,否则默认
int must = r5.GetOrThrow(); // 失败抛出异常
``` ```
### 使用 Global 注册与订阅 **2. 运行嵌入式 Web 服务器:**
```bash
```csharp dotnet run --project Server/Server.csproj
using Common;
// 注册单例(实例/工厂),与瞬态
Global.RegisterSingleton(new AppConfig { AppName = "MyApp", Version = "1.0" });
Global.RegisterSingleton(() => new CacheService());
Global.RegisterTransient(() => new RequestContext());
// 订阅服务变更
Global.Subscribe<AppConfig>(e =>
{
Console.WriteLine($"Config changed: {e.OldValue?.Version} -> {e.NewValue.Version}");
});
// 更新将触发订阅回调
Global.Update(new AppConfig { AppName = "MyApp", Version = "1.1" });
// 获取
var config = Global.Get<AppConfig>();
``` ```
示例类型(自定义) **3. 运行 NetServer 纯控制台示例:**
```bash
```csharp dotnet run --project NetServer/Examples
public sealed class AppConfig { public string AppName { get; set; } = ""; public string Version { get; set; } = ""; }
public sealed class CacheService { }
public sealed class RequestContext { public string RequestId { get; set; } = Guid.NewGuid().ToString(); }
``` ```
### 配置管理(多源叠加/强类型/验证/保存) ## 🧪 测试
```csharp 项目包含完整的单元测试,位于解决方案同级的 `test` 目录中。
using Sharp.Config;
// 1) 从多个文件构建(支持 Json/Ini/Xml/Toml),并启用环境变量与热重载
var cfg = ConfigBuilder.Build(opt =>
{
opt.BasePath = AppContext.BaseDirectory;
opt.ReloadOnChange = true;
opt.IncludeEnvironmentVariables = true;
opt.EnvironmentPrefix = "MYAPP_"; // 可选
opt.Sources.Add(new ConfigSource { Path = "appsettings.json", Format = ConfigFormat.Json, Optional = false });
opt.Sources.Add(new ConfigSource { Path = "appsettings.Development.json", Format = ConfigFormat.Json, Optional = true });
});
// 2) 强类型绑定 + 验证(DataAnnotations)
var options = cfg.Get<MyOptions>(validate: true);
// 3) 热重载回调
using var _ = cfg.OnReload(() => Console.WriteLine("Configuration reloaded"));
// 4) 保存/读取对象(跨格式序列化)
cfg.Save(options, path: "out/appsettings.toml", format: ConfigFormat.Toml);
var reloaded = cfg.LoadFromFile<MyOptions>("out/appsettings.toml", ConfigFormat.Toml);
public sealed class MyOptions
{
[System.ComponentModel.DataAnnotations.Required]
public string Name { get; set; } = string.Empty;
public int Port { get; set; } = 8080;
}
```
示例 appsettings.json
```json
{
"name": "demo",
"port": 5000
}
```
## 测试
- 测试框架:MSTest(并行执行)。
- 运行:
```bash ```bash
dotnet test # 运行所有测试
dotnet test Sharp.sln
``` ```
核心用例位于 `test/Common.Test`,覆盖 Result 与 Global 的主要功能与边界场景。 ## 🤝 贡献指南
欢迎提交 Pull Request 或 Issue。请确保:
1. 代码风格符合 .NET 规范。
2. 新增功能包含对应的单元测试。
3. 提交信息简洁明了。
## WPF 工具包(Sharp.Wpf) ## 📄 许可证
提供即插即用的 WPF 常用基础能力: 本项目采用 [GNU Affero General Public License v3.0 或更高版本](LICENSE) (AGPL-3.0-or-later) 开源协议。
- 全局异常处理:统一捕获 UI/后台/未观察任务异常,支持弹窗与自定义日志 **关键要求**:
- MVVM 基础:`ViewModelBase`(INPC/IsBusy)、`RelayCommand`、`AsyncCommand` - ✅ 可以自由使用、修改和分发
- Dispatcher 封装:`IDispatcher`/`DispatcherService`,提供 UI 线程调用统一入口 - ✅ 必须保持开源(包括网络服务)
- 对话与文件对话:`IDialogService`、`IFileDialogService` - ✅ 修改后必须使用相同许可证
- 常用转换器:Bool/NullOrEmpty 可见性、布尔取反、枚举描述 - ⚠️ **网络服务条款**:如果您修改本软件并通过网络提供服务(如 Web API),必须向用户提供修改后的源代码
- 附加行为:EventToCommand(事件转 ICommand)
- 窗口工具:`WindowPlacement` 窗口位置/大小/状态持久化
### 引用 如需商业许可或有疑问,请联系作者。
在你的 WPF 项目中添加项目引用: ## 👤 作者
```xml **duyl328**
<!-- Client/Client.csproj --> - 网站: [https://seyl.me/](https://seyl.me/)
<ItemGroup> - 仓库: [https://gitea.duyl328.org/jimmy/sharp-basic](https://gitea.duyl328.org/jimmy/sharp-basic)
<ProjectReference Include="..\Wpf\Wpf.csproj" />
<!-- 或通过 NuGet: <PackageReference Include="Sharp.Wpf" Version="x.y.z" /> -->
</ItemGroup> 查看 [AUTHORS](AUTHORS) 文件了解完整贡献者列表。
```
### 全局异常处理(App 启动) ---
```csharp Copyright © 2024-2025 duyl328. All rights reserved.
// App.Startup.cs
using System.Windows;
using Sharp.Wpf.Exceptions;
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
new UiExceptionHandler().Register(new ExceptionHandlingOptions
{
ShowDialog = true,
DialogTitle = "应用程序错误"
});
}
}
```
### XAML 集成(窗口位置、行为、转换器)
```xml
<Window x:Class="Client.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:b="clr-namespace:Sharp.Wpf.Behaviors;assembly=Sharp.Wpf"
xmlns:w="clr-namespace:Sharp.Wpf.Windowing;assembly=Sharp.Wpf"
xmlns:conv="clr-namespace:Sharp.Wpf.Converters;assembly=Sharp.Wpf"
Title="MainWindow"
w:WindowPlacement.RememberPlacement="True"
w:WindowPlacement.PlacementId="MainWindow">
<Window.Resources>
<conv:BoolToVisibilityConverter x:Key="Bool2Vis" />
</Window.Resources>
<StackPanel Margin="16">
<Button Content="点击弹窗"
b:EventToCommand.EventName="Click"
b:EventToCommand.Command="{Binding ShowDialogCommand}" />
<Button Content="触发异常"
b:EventToCommand.EventName="Click"
b:EventToCommand.Command="{Binding ThrowCommand}" />
<TextBlock Text="正在处理..."
Visibility="{Binding IsBusy, Converter={StaticResource Bool2Vis}}"/>
</StackPanel>
</Window>
```
### ViewModel 与命令
```csharp
using System;
using System.Threading.Tasks;
using Sharp.Wpf.Commands;
using Sharp.Wpf.Mvvm;
using Sharp.Wpf.Services;
public sealed class MainViewModel : ViewModelBase
{
private readonly IDialogService _dialog = new DialogService();
public string Greeting { get; set; } = "欢迎使用 Sharp.Wpf";
public IAsyncCommand ShowDialogCommand { get; }
public RelayCommand ThrowCommand { get; }
public MainViewModel()
{
ShowDialogCommand = new AsyncCommand(async () =>
{
try { IsBusy = true; await Task.Delay(300); await _dialog.ShowInfoAsync("Hello!"); }
finally { IsBusy = false; }
});
ThrowCommand = new RelayCommand(() => throw new InvalidOperationException("演示未处理异常"));
}
}
```
### 文件对话
```csharp
var files = await new FileDialogService().OpenFilesAsync("Images|*.png;*.jpg");
var folder = await new FileDialogService().SelectFolderAsync();
```
## 路线图(Roadmap)
- P0(最优先)
- Logging 抽象与 Serilog 适配;EventBus 进程内 Typed 事件;Config 对接 IOptions 与变更广播
- P1
- Net.Client:HttpClientFactory 封装 + Result<T> 模式;WPF Toolkit:MVVM/事件聚合/通知与主题
- P2
- 可靠性(Polly)、OpenTelemetry、外部 Broker 适配(Redis/RabbitMQ/Kafka)、敏感字段加密
- P3
- 文档/示例完善、CI/CD(自动打包发布)、更多单测与性能基准
## 贡献与规范
- 欢迎通过 Issue/PR 参与贡献。
- 代码风格:.NET 8、可空启用、简化 `using`,命名空间建议统一为 `Sharp.*`(Common 模块后续将统一为 `Sharp.Common`)。
- 变更需保持向后兼容(次版本),破坏式变更请在主版本发布并注明迁移指南。
## 许可
本项目采用 GNU AGPLv3 许可证,详见根目录 `LICENSE`。
如需在私有环境或商业场景中使用且不希望开源网络服务端改动代码,请联系作者洽谈其他授权方式。
+361
View File
@@ -0,0 +1,361 @@
{
// ========================================
// Sharp-Basic 框架配置模板
//
// 这是一个完整的配置文件模板,包含了所有Sharp模块的配置示例
// 使用时可以根据项目需要选择性保留相关配置节
// ========================================
// ========================================
// 系统配置 (System)
// 约定配置节名称:System
// 用于:NetClient、NetServer 的默认HTTP配置
// ========================================
"System": {
// HTTP服务监听IP地址
// 支持:IP地址、localhost、域名
"HTTPIp": "127.0.0.1",
// HTTP服务监听端口
// 范围:1-65535
"HttpPort": 8080,
// HTTP请求超时时间(秒)
// 范围:1-600
"TimeoutSeconds": 30,
// HTTP协议
// 可选值:http、https
"Scheme": "http"
},
// ========================================
// 数据库配置 (Database)
// 约定配置节名称:Database
// 用于:Data 模块 - AddSharpDatabase<TContext>(configuration)
// ========================================
"Database": {
// 数据库提供程序
// 可选值:SqlServer、Sqlite、PostgreSQL、MySql、InMemory
"Provider": "SqlServer",
// 数据库连接字符串
// SqlServer示例:Server=.;Database=MyApp;Trusted_Connection=True;TrustServerCertificate=True;
// Sqlite示例:Data Source=app.db
// PostgreSQL示例:Host=localhost;Database=myapp;Username=postgres;Password=123456
// MySql示例:server=localhost;database=myapp;user=root;password=123456
"ConnectionString": "Server=.;Database=MyApp;Trusted_Connection=True;TrustServerCertificate=True;"
},
// ========================================
// 日志配置 (Logging)
// 约定配置节名称:Logging
// 用于:Log 模块 - AddLogging(b => b.AddSharpLog())
// ========================================
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
// Sharp日志扩展配置
"SharpLog": {
// 日志文件路径(相对或绝对路径)
"LogFilePath": "logs/app-.log",
// 日志滚动间隔
// 可选值:Infinite、Year、Month、Day、Hour、Minute
"RollingInterval": "Day",
// 单个日志文件最大大小(字节)
// null 表示不限制
"FileSizeLimitBytes": 10485760,
// 保留的日志文件数量
// null 表示不限制
"RetainedFileCountLimit": 31,
// 是否输出到控制台
"EnableConsole": true,
// 是否输出到文件
"EnableFile": true,
// 是否启用结构化日志(JSON格式)
"EnableStructuredLog": false
},
// ========================================
// HTTP客户端配置 (NetClient)
// 约定配置节名称:NetClient
// 用于:NetClient 模块 - AddNetClient(configuration.GetSection("NetClient"))
// ========================================
"NetClient": {
// 基础地址(留空则使用System配置)
"BaseAddress": "http://localhost:8080",
// 请求超时时间
"Timeout": "00:00:30",
// 是否启用日志
"EnableLogging": true,
// 是否包含关联ID
"IncludeCorrelationId": true,
// 关联ID请求头名称
"CorrelationIdHeaderName": "X-Correlation-ID",
// 是否使用Bearer Token认证
"UseBearerToken": false,
// 重试配置
"Retry": {
// 是否启用重试
"Enabled": true,
// 最大重试次数
"MaxRetries": 3,
// 重试延迟(秒)
"DelaySeconds": 2
},
// 默认请求头
"DefaultRequestHeaders": {
"User-Agent": "Sharp-NetClient/1.0",
"Accept": "application/json"
}
},
// ========================================
// HTTP服务端配置 (NetServer)
// 约定配置节名称:NetServer
// 用于:NetServer 模块 - AddNetServer(configManager)
// ========================================
"NetServer": {
// 监听URL列表(留空则使用System配置)
"ListenUrls": [
"http://localhost:8080/",
"http://0.0.0.0:8080/"
],
// 是否启用详细错误信息
"ShowDetailedErrors": true,
// 请求正文最大大小(字节)
"MaxRequestBodySize": 30000000,
// 请求超时时间
"RequestTimeout": "00:05:00"
},
// ========================================
// PLC配置 - 西门子 (Plc:Siemens)
// 约定配置节名称:Plc:Siemens
// 用于:Plc.Siemens 模块 - AddSiemensPlc(configuration.GetSection("Plc:Siemens"))
// ========================================
"Plc": {
"Siemens": {
// PLC IP地址
"IpAddress": "192.168.1.100",
// CPU类型
// 可选值:S71200、S71500、S7300、S7400、S7200、S7200Smart
"CpuType": "S71200",
// 机架号(S7-300/400)
"Rack": 0,
// 插槽号(S7-300/400)
"Slot": 1,
// 端口号
"Port": 102,
// 连接超时时间(毫秒)
"ConnectTimeout": 5000,
// 读写超时时间(毫秒)
"ReadWriteTimeout": 3000,
// 监控轮询间隔(毫秒)
"MonitorInterval": 500,
// 是否自动重连
"AutoReconnect": true,
// 重连间隔(毫秒)
"ReconnectInterval": 5000
}
},
// ========================================
// 串口配置 (SerialPort)
// 约定配置节名称:SerialPort
// 用于:Ports 模块 - AddSerialPortService()
// ========================================
"SerialPort": {
// 端口名称(如 COM1、/dev/ttyUSB0)
"PortName": "COM1",
// 波特率
// 常用值:9600、19200、38400、57600、115200
"BaudRate": 9600,
// 数据位
// 可选值:5、6、7、8
"DataBits": 8,
// 停止位
// 可选值:None、One、Two、OnePointFive
"StopBits": "One",
// 校验位
// 可选值:None、Odd、Even、Mark、Space
"Parity": "None",
// 读取超时时间(毫秒)
// -1 表示无限等待
"ReadTimeout": 1000,
// 写入超时时间(毫秒)
// -1 表示无限等待
"WriteTimeout": 1000,
// 接收缓冲区大小(字节)
"ReadBufferSize": 4096,
// 发送缓冲区大小(字节)
"WriteBufferSize": 2048
},
// ========================================
// 进程执行配置 (Process)
// 约定配置节名称:Process
// 用于:Process 模块 - AddSharpProcess()
// ========================================
"Process": {
// 默认工作目录
"WorkingDirectory": "",
// 默认超时时间(毫秒)
// -1 表示无限等待
"DefaultTimeout": 30000,
// 是否重定向标准输出
"RedirectStandardOutput": true,
// 是否重定向标准错误
"RedirectStandardError": true,
// 是否使用Shell执行
"UseShellExecute": false,
// 是否创建窗口
"CreateNoWindow": true
},
// ========================================
// Python配置 (Python)
// 约定配置节名称:Python
// 用于:Python 模块 - AddSharpPython()
// ========================================
"Python": {
// Python解释器路径
// 留空则自动查找系统PATH中的python
"PythonPath": "",
// Python脚本根目录
"ScriptDirectory": "scripts",
// 默认超时时间(毫秒)
"DefaultTimeout": 60000,
// 是否启用虚拟环境
"UseVirtualEnv": false,
// 虚拟环境路径
"VirtualEnvPath": ""
},
// ========================================
// 缓存配置 (Cache)
// 约定配置节名称:Cache
// 用于:Cache 模块 - AddSharpCache()
// ========================================
"Cache": {
// 默认过期时间(秒)
"DefaultExpirationSeconds": 600,
// 缓存大小限制(条目数)
// null 表示不限制
"SizeLimit": null,
// 内存压缩比例(0.0-1.0)
"CompactionPercentage": 0.25,
// 过期扫描频率(秒)
"ExpirationScanFrequency": 60
},
// ========================================
// 事件总线配置 (EventBus)
// 约定配置节名称:EventBus
// 用于:EventBus 模块 - AddEventBus()
// ========================================
"EventBus": {
// 是否启用异步处理
"EnableAsync": true,
// 异步事件队列容量
"QueueCapacity": 1000,
// 是否在异常时继续执行其他订阅者
"ContinueOnError": true,
// 是否启用死信队列
"EnableDeadLetterQueue": false,
// 死信队列容量
"DeadLetterQueueCapacity": 100
},
// ========================================
// WPF配置 (Wpf)
// 约定配置节名称:Wpf
// 用于:Wpf 模块 - AddSharpWpf()
// ========================================
"Wpf": {
// 默认对话框标题
"DefaultDialogTitle": "提示",
// 是否启用全局异常处理
"EnableGlobalExceptionHandler": true,
// 主题
// 可选值:Light、Dark
"Theme": "Light",
// 语言
// 可选值:zh-CN、en-US
"Language": "zh-CN"
},
// ========================================
// 环境变量覆盖说明
// ========================================
// 所有配置都可以通过环境变量覆盖,格式:
// SHARP__{节名}__{键名}
//
// 示例:
// SHARP__Database__Provider=Sqlite
// SHARP__Database__ConnectionString=Data Source=test.db
// SHARP__Plc__Siemens__IpAddress=192.168.1.200
// SHARP__System__HttpPort=9090
//
// 注意:双下划线 __ 用于分隔层级
// ========================================
}
+7
View File
@@ -0,0 +1,7 @@
@echo off
REM ========================================
REM 快速启动开发工具菜单
REM ========================================
cd /d "%~dp0\scripts"
call dev.bat
+194
View File
@@ -0,0 +1,194 @@
# 01 - 最小 WPF 应用
这是一个最简单的 Sharp WPF 应用示例,演示了框架的基础用法。
## 🎯 学习目标
- 了解 Sharp 框架的基本结构
- 掌握 MVVM 模式(ViewModelBase)
- 使用 Commands(RelayCommand, AsyncCommand)
- 使用对话框服务(IDialogService)
- 理解 Quick Start 配置方式
## 📦 使用的模块
- **Sharp.Common** - Result<T>, 基础工具
- **Sharp.Config** - 配置管理
- **Sharp.Wpf** - MVVM 基础设施
## 🏗️ 项目结构
```
01-MinimalWpfApp/
├── App.xaml # 应用程序入口
├── App.xaml.cs # Quick Start 配置
├── MainWindow.xaml # 主窗口界面
├── MainWindow.xaml.cs # 主窗口代码隐藏
├── ViewModels/
│ └── MainViewModel.cs # 主窗口 ViewModel
├── appsettings.json # 配置文件
└── README.md # 本文件
```
## 🚀 快速运行
```bash
cd examples/01-MinimalWpfApp
dotnet run
```
## 📝 核心代码讲解
### 1. App.xaml.cs - 应用启动
```csharp
protected override void OnStartup(StartupEventArgs e)
{
var services = new ServiceCollection();
// 一行添加基础服务(配置、日志、缓存)
services.AddSharpEssentials();
// 一行添加 WPF 服务(对话框、调度器、导航、主题)
services.AddSharpWpf();
// 注册 ViewModel 和 Window
services.AddViewModel<MainViewModel>();
services.AddTransient<MainWindow>();
// 构建服务提供者
_serviceProvider = services.BuildServiceProvider();
// 显示主窗口
var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
mainWindow.Show();
}
```
**关键点**:
- `AddSharpEssentials()` - 自动配置 Config + Log + Cache
- `AddSharpWpf()` - 自动注册 WPF 服务
- 无需手动配置,使用约定优于配置
### 2. MainViewModel.cs - ViewModel 示例
```csharp
public class MainViewModel : ViewModelBase
{
private readonly IDialogService _dialogService;
private string _message = "Hello, Sharp!";
private int _counter = 0;
public string Message
{
get => _message;
set => SetProperty(ref _message, value);
}
public int Counter
{
get => _counter;
set => SetProperty(ref _counter, value);
}
// 同步命令
public ICommand IncrementCommand { get; }
// 异步命令
public ICommand ShowDialogCommand { get; }
public MainViewModel(IDialogService dialogService)
{
_dialogService = dialogService;
// RelayCommand - 同步操作
IncrementCommand = new RelayCommand(() => {
Counter++;
Message = $"你点击了 {Counter} 次";
});
// AsyncCommand - 异步操作
ShowDialogCommand = new AsyncCommand(async (ct) => {
IsBusy = true; // 显示加载状态
// 模拟耗时操作
await Task.Delay(1000, ct);
_dialogService.ShowMessage($"当前计数: {Counter}", "提示");
IsBusy = false;
});
}
}
```
**关键点**:
- 继承 `ViewModelBase` 获得 INotifyPropertyChanged
- 使用 `SetProperty()` 自动触发属性变化通知
- `IsBusy` 属性用于显示加载状态
- `RelayCommand` 用于同步操作
- `AsyncCommand` 用于异步操作(支持取消)
### 3. MainWindow.xaml - 界面绑定
```xml
<Window ...>
<Window.DataContext>
<!-- ViewModel 会通过 DI 自动注入 -->
</Window.DataContext>
<StackPanel Margin="20">
<!-- 绑定属性 -->
<TextBlock Text="{Binding Message}" FontSize="24"/>
<!-- 绑定命令 -->
<Button Content="点击我" Command="{Binding IncrementCommand}"/>
<!-- 异步命令 -->
<Button Content="显示对话框" Command="{Binding ShowDialogCommand}"/>
<!-- 显示加载状态 -->
<TextBlock Text="加载中..." Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}"/>
</StackPanel>
</Window>
```
**关键点**:
- 使用 `{Binding}` 绑定 ViewModel 属性
- Command 绑定到 ICommand 属性
- 使用内置转换器(BoolToVisibilityConverter)
## 🎓 练习建议
### 练习1:添加新属性
在 MainViewModel 中添加一个 `Name` 属性,并在界面上显示。
### 练习2:添加清零按钮
添加一个 `ResetCommand`,点击后将 `Counter` 重置为 0。
### 练习3:使用对话框确认
在 ResetCommand 中,先弹出确认对话框,用户确认后再清零。
### 练习4:尝试 Result<T>
修改 ShowDialogCommand,使用 `Result.Try()` 包装可能出错的代码。
## 📚 延伸阅读
- [Sharp.Wpf README](../../src/Sharp/Wpf/README.md) - WPF 模块完整文档
- [QUICKSTART.md](../../src/Sharp/QUICKSTART.md) - 快速启动指南
- 下一个示例:[02-WpfWithDatabase](../02-WpfWithDatabase/README.md)
## ❓ 常见问题
**Q: 为什么 MainWindow 不需要手动设置 DataContext?**
A: 通过 DI 容器获取 Window 时,会自动注入对应的 ViewModel。
**Q: IsBusy 是在哪里定义的?**
A: `IsBusy` 是 `ViewModelBase` 的内置属性,用于显示加载状态。
**Q: 可以不用 DI 吗?**
A: 可以,但不推荐。使用 DI 可以更好地管理依赖关系,便于测试。
---
**开始编码吧!** 🚀
+233
View File
@@ -0,0 +1,233 @@
# 03 - 表单验证示例
演示如何使用 Sharp.Wpf 的表单验证功能,包括 DataAnnotations 验证、实时错误提示和自定义验证。
## 🎯 学习目标
- 使用 `ValidatableViewModelBase` 实现表单验证
- 掌握 DataAnnotations 验证特性
- 实现实时验证反馈
- 自定义验证规则
- 显示验证错误消息
## 📦 使用的模块
- **Sharp.Wpf** - ValidatableViewModelBase, 验证支持
## 🚀 快速运行
```bash
cd examples/03-FormValidation
dotnet run
```
## 📝 核心代码讲解
### 1. UserViewModel.cs - 带验证的 ViewModel
```csharp
using System.ComponentModel.DataAnnotations;
using Wpf.Validation;
public class UserViewModel : ValidatableViewModelBase
{
private string _username = "";
private string _email = "";
private string _password = "";
private string _confirmPassword = "";
private int _age;
[Required(ErrorMessage = "用户名不能为空")]
[StringLength(20, MinimumLength = 3, ErrorMessage = "用户名长度必须在3-20个字符之间")]
public string Username
{
get => _username;
set => SetProperty(ref _username, value, validate: true); // 启用验证
}
[Required(ErrorMessage = "邮箱不能为空")]
[EmailAddress(ErrorMessage = "邮箱格式不正确")]
public string Email
{
get => _email;
set => SetProperty(ref _email, value, validate: true);
}
[Required(ErrorMessage = "密码不能为空")]
[StringLength(50, MinimumLength = 6, ErrorMessage = "密码长度必须在6-50个字符之间")]
[RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$",
ErrorMessage = "密码必须包含大小写字母和数字")]
public string Password
{
get => _password;
set
{
SetProperty(ref _password, value, validate: true);
// 密码变化时,重新验证确认密码
ValidateProperty(ConfirmPassword, nameof(ConfirmPassword));
}
}
[Required(ErrorMessage = "请确认密码")]
public string ConfirmPassword
{
get => _confirmPassword;
set
{
SetProperty(ref _confirmPassword, value, validate: false);
// 自定义验证:确认密码必须与密码一致
if (value != Password)
{
AddError(nameof(ConfirmPassword), "两次密码输入不一致");
}
else
{
ClearErrors(nameof(ConfirmPassword));
}
}
}
[Range(18, 120, ErrorMessage = "年龄必须在18-120之间")]
public int Age
{
get => _age;
set => SetProperty(ref _age, value, validate: true);
}
// 提交命令
public ICommand SubmitCommand { get; }
public UserViewModel(IDialogService dialogService)
{
SubmitCommand = new RelayCommand(() =>
{
// 验证所有属性
if (ValidateAll())
{
dialogService.ShowMessage(
$"注册成功!\n用户名: {Username}\n邮箱: {Email}\n年龄: {Age}",
"成功");
}
else
{
dialogService.ShowMessage("请修正表单错误后再提交", "验证失败");
}
});
}
}
```
### 2. MainWindow.xaml - 验证错误显示
```xml
<Window x:Class="FormValidationExample.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="表单验证示例" Height="500" Width="400">
<Window.Resources>
<!-- 错误模板 -->
<ControlTemplate x:Key="ValidationErrorTemplate">
<DockPanel>
<Border BorderBrush="Red" BorderThickness="1" CornerRadius="2">
<AdornedElementPlaceholder/>
</Border>
<TextBlock Foreground="Red" FontSize="12" Margin="5,0,0,0"
Text="{Binding [0].ErrorContent}"
DockPanel.Dock="Right"/>
</DockPanel>
</ControlTemplate>
</Window.Resources>
<StackPanel Margin="20">
<TextBlock Text="用户注册表单" FontSize="24" FontWeight="Bold" Margin="0,0,0,20"/>
<!-- 用户名 -->
<TextBlock Text="用户名:"/>
<TextBox Text="{Binding Username, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"
Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}"
Margin="0,5,0,15"/>
<!-- 邮箱 -->
<TextBlock Text="邮箱:"/>
<TextBox Text="{Binding Email, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"
Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}"
Margin="0,5,0,15"/>
<!-- 密码 -->
<TextBlock Text="密码:"/>
<PasswordBox x:Name="PasswordBox" Margin="0,5,0,5"/>
<TextBlock Text="* 密码必须包含大小写字母和数字" FontSize="10" Foreground="Gray" Margin="0,0,0,15"/>
<!-- 确认密码 -->
<TextBlock Text="确认密码:"/>
<PasswordBox x:Name="ConfirmPasswordBox" Margin="0,5,0,15"/>
<!-- 年龄 -->
<TextBlock Text="年龄:"/>
<TextBox Text="{Binding Age, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"
Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}"
Margin="0,5,0,20"/>
<!-- 提交按钮 -->
<Button Content="提交注册" Command="{Binding SubmitCommand}"
Height="40" FontSize="16"/>
<!-- 验证状态 -->
<TextBlock Margin="0,10,0,0" Foreground="Red"
Text="表单包含错误,请修正后提交"
Visibility="{Binding HasErrors, Converter={StaticResource BoolToVisibilityConverter}}"/>
</StackPanel>
</Window>
```
### 3. 关键点说明
**ValidatableViewModelBase 的关键特性**:
1. **实时验证** - `SetProperty(ref field, value, validate: true)`
2. **验证所有属性** - `ValidateAll()` 返回 bool
3. **自定义错误** - `AddError(propertyName, errorMessage)`
4. **清除错误** - `ClearErrors(propertyName)`
5. **错误状态** - `HasErrors` 属性
**XAML 绑定要点**:
1. **UpdateSourceTrigger=PropertyChanged** - 实时触发验证
2. **ValidatesOnNotifyDataErrors=True** - 启用错误显示
3. **Validation.ErrorTemplate** - 自定义错误样式
## 🎓 练习建议
### 练习1:添加更多验证规则
添加电话号码字段,使用正则表达式验证手机号格式。
### 练习2:异步验证
实现用户名唯一性检查(模拟调用 API)。
### 练习3:条件验证
当用户勾选"我是学生"时,年龄必须小于25岁。
### 练习4:自定义验证特性
创建 `UniqueUsernameAttribute` 自定义验证特性。
## 💡 常见问题
**Q: PasswordBox 无法绑定,怎么办?**
A: PasswordBox 的 Password 属性不是依赖属性,不能直接绑定。可以使用附加属性或在代码隐藏中处理。
**Q: 为什么有时验证不触发?**
A: 确保:
1. 绑定使用了 `UpdateSourceTrigger=PropertyChanged`
2. SetProperty 的 validate 参数设为 true
3. XAML 中设置了 `ValidatesOnNotifyDataErrors=True`
**Q: 如何自定义错误消息样式?**
A: 修改 `Validation.ErrorTemplate`,可以自定义边框颜色、图标等。
## 📚 延伸阅读
- [DataAnnotations 验证特性](https://learn.microsoft.com/zh-cn/dotnet/api/system.componentmodel.dataannotations)
- [INotifyDataErrorInfo 接口](https://learn.microsoft.com/zh-cn/dotnet/api/system.componentmodel.inotifydataerrorinfo)
- 下一个示例:[04-Navigation](../04-Navigation/README.md)
---
**掌握表单验证,构建高质量的用户输入体验!** ✅
+315
View File
@@ -0,0 +1,315 @@
# 04 - 导航系统示例
演示如何使用 Sharp.Wpf 的导航服务实现多页面 WPF 应用,包括页面跳转、参数传递、前进/后退等功能。
## 🎯 学习目标
- 使用 `INavigationService` 实现页面导航
- 理解导航历史栈(前进/后退)
- 页面间参数传递
- 导航生命周期钩子(INavigationAware)
- 弹出式导航并获取结果
## 📦 使用的模块
- **Sharp.Wpf** - INavigationService, NavigationService
## 🚀 快速运行
```bash
cd examples/04-Navigation
dotnet run
```
## 📝 核心代码讲解
### 1. MainViewModel.cs - 主导航容器
```csharp
using Wpf.Navigation;
public class MainViewModel : ViewModelBase
{
private readonly INavigationService _navigationService;
private ViewModelBase? _currentPage;
public ViewModelBase? CurrentPage
{
get => _currentPage;
set => SetProperty(ref _currentPage, value);
}
public bool CanGoBack => _navigationService.CanGoBack;
public bool CanGoForward => _navigationService.CanGoForward;
public ICommand NavigateToHomeCommand { get; }
public ICommand NavigateToProductsCommand { get; }
public ICommand NavigateToSettingsCommand { get; }
public ICommand GoBackCommand { get; }
public ICommand GoForwardCommand { get; }
public MainViewModel(INavigationService navigationService)
{
_navigationService = navigationService;
// 监听导航事件
_navigationService.Navigated += (s, vm) =>
{
CurrentPage = vm;
OnPropertyChanged(nameof(CanGoBack));
OnPropertyChanged(nameof(CanGoForward));
};
// 导航命令
NavigateToHomeCommand = new AsyncCommand(async (ct) =>
{
await _navigationService.NavigateAsync<HomeViewModel>();
});
NavigateToProductsCommand = new AsyncCommand(async (ct) =>
{
await _navigationService.NavigateAsync<ProductsViewModel>();
});
NavigateToSettingsCommand = new AsyncCommand(async (ct) =>
{
await _navigationService.NavigateAsync<SettingsViewModel>();
});
GoBackCommand = new AsyncCommand(async (ct) =>
{
await _navigationService.GoBackAsync();
}, () => CanGoBack);
GoForwardCommand = new AsyncCommand(async (ct) =>
{
await _navigationService.GoForwardAsync();
}, () => CanGoForward);
// 初始导航到首页
Task.Run(async () => await _navigationService.NavigateAsync<HomeViewModel>());
}
}
```
### 2. ProductsViewModel.cs - 带参数的页面
```csharp
public class ProductsViewModel : ViewModelBase, INavigationAware
{
private readonly INavigationService _navigationService;
private readonly IDialogService _dialogService;
private string _category = "全部";
public string Category
{
get => _category;
set => SetProperty(ref _category, value);
}
public ICommand ViewProductDetailsCommand { get; }
public ProductsViewModel(
INavigationService navigationService,
IDialogService dialogService)
{
_navigationService = navigationService;
_dialogService = dialogService;
ViewProductDetailsCommand = new AsyncCommand<int>(async (productId, ct) =>
{
// 导航到产品详情页,传递产品ID
await _navigationService.NavigateAsync<ProductDetailViewModel>(productId);
});
}
// 导航生命周期:进入页面时
public async Task OnNavigatedToAsync(object? parameter)
{
if (parameter is string category)
{
Category = category;
_dialogService.ShowMessage($"显示分类: {category}", "提示");
}
// 可以在这里加载数据
await LoadProductsAsync();
}
// 导航生命周期:离开页面时
public async Task OnNavigatedFromAsync()
{
// 清理资源、保存状态等
await Task.CompletedTask;
}
private async Task LoadProductsAsync()
{
IsBusy = true;
await Task.Delay(500); // 模拟加载
IsBusy = false;
}
}
```
### 3. ProductDetailViewModel.cs - 详情页(带结果返回)
```csharp
public class ProductDetailViewModel : ViewModelBase,
INavigationAware,
INavigationResult<bool> // 返回 bool(是否购买)
{
private readonly TaskCompletionSource<bool> _resultSource = new();
private int _productId;
private string _productName = "";
public int ProductId
{
get => _productId;
set => SetProperty(ref _productId, value);
}
public string ProductName
{
get => _productName;
set => SetProperty(ref _productName, value);
}
public ICommand BuyCommand { get; }
public ICommand CancelCommand { get; }
public ProductDetailViewModel()
{
BuyCommand = new RelayCommand(() =>
{
// 返回结果:已购买
_resultSource.SetResult(true);
});
CancelCommand = new RelayCommand(() =>
{
// 返回结果:取消
_resultSource.SetResult(false);
});
}
public async Task OnNavigatedToAsync(object? parameter)
{
if (parameter is int productId)
{
ProductId = productId;
// 加载产品详情
ProductName = $"产品 #{productId}";
}
await Task.CompletedTask;
}
public Task OnNavigatedFromAsync() => Task.CompletedTask;
// 实现 INavigationResult<bool>
public Task<bool> GetResultAsync()
{
return _resultSource.Task;
}
}
```
### 4. App.xaml.cs - 注册导航服务
```csharp
protected override void OnStartup(StartupEventArgs e)
{
var services = new ServiceCollection();
services.AddSharpWpf(); // 自动注册 INavigationService
// 注册所有页面 ViewModel
services.AddViewModel<MainViewModel>();
services.AddViewModel<HomeViewModel>();
services.AddViewModel<ProductsViewModel>();
services.AddViewModel<ProductDetailViewModel>();
services.AddViewModel<SettingsViewModel>();
var provider = services.BuildServiceProvider();
var mainWindow = provider.GetRequiredService<MainWindow>();
mainWindow.Show();
}
```
### 5. MainWindow.xaml - 导航容器
```xml
<Window x:Class="NavigationExample.MainWindow"
Title="导航系统示例" Height="600" Width="800">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 导航栏 -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Background="#f0f0f0" Padding="10">
<Button Content="首页" Command="{Binding NavigateToHomeCommand}" Margin="0,0,10,0"/>
<Button Content="产品列表" Command="{Binding NavigateToProductsCommand}" Margin="0,0,10,0"/>
<Button Content="设置" Command="{Binding NavigateToSettingsCommand}" Margin="0,0,20,0"/>
<Button Content="← 后退" Command="{Binding GoBackCommand}" Margin="0,0,10,0"/>
<Button Content="前进 →" Command="{Binding GoForwardCommand}"/>
</StackPanel>
<!-- 内容区域 - 显示当前页面 -->
<ContentControl Grid.Row="1" Content="{Binding CurrentPage}">
<ContentControl.Resources>
<!-- 为每个 ViewModel 指定对应的 View -->
<DataTemplate DataType="{x:Type local:HomeViewModel}">
<local:HomeView/>
</DataTemplate>
<DataTemplate DataType="{x:Type local:ProductsViewModel}">
<local:ProductsView/>
</DataTemplate>
<DataTemplate DataType="{x:Type local:SettingsViewModel}">
<local:SettingsView/>
</DataTemplate>
</ContentControl.Resources>
</ContentControl>
</Grid>
</Window>
```
## 🎓 练习建议
### 练习1:面包屑导航
添加面包屑显示当前导航路径(如:首页 > 产品列表 > 产品详情)。
### 练习2:导航守卫
实现 `INavigationGuard` 接口,在离开页面前检查是否有未保存的更改。
### 练习3:嵌套导航
在产品列表页内部实现子导航(如:全部/热销/新品标签页切换)。
### 练习4:导航缓存
实现页面缓存,避免重复创建 ViewModel(保持页面状态)。
## 💡 常见问题
**Q: 导航后如何保持页面状态?**
A: 不要后退时,CurrentViewModel 会保留在前进栈中,前进时会恢复状态。如果需要完全保持状态,可以使用 Singleton ViewModel。
**Q: 如何传递复杂对象作为参数?**
A: 使用 `NavigateAsync(object parameter)`,parameter 可以是任何对象。
**Q: 导航历史栈何时清空?**
A: 调用 `_navigationService.ClearHistory()` 手动清空。
**Q: 能否使用动画过渡?**
A: 可以,在 ContentControl 上添加 Storyboard 实现页面切换动画。
## 📚 延伸阅读
- [WPF 数据模板](https://learn.microsoft.com/zh-cn/dotnet/desktop/wpf/data/data-templating-overview)
- [MVVM 导航模式](https://prismlibrary.com/docs/navigation.html)
- 下一个示例:[05-Localization](../05-Localization/README.md)
---
**掌握导航系统,构建流畅的多页面应用!** 🧭
+278
View File
@@ -0,0 +1,278 @@
# 05 - 国际化(i18n)示例
演示如何使用 Sharp.Wpf 的国际化功能实现多语言支持,包括运行时切换语言、资源文件管理等。
## 🎯 学习目标
- 使用 `ILocalizationService` 实现多语言
- 在 XAML 中使用 `{loc:Localize}` 绑定本地化字符串
- 运行时动态切换语言
- 管理资源文件(.resx)
- 格式化带参数的本地化字符串
## 📦 使用的模块
- **Sharp.Wpf** - ILocalizationService, LocalizeExtension
## 🚀 快速运行
```bash
cd examples/05-Localization
dotnet run
```
## 📝 核心代码讲解
### 1. 创建资源文件
```
Resources/
├── Strings.resx # 默认语言(中文)
├── Strings.en-US.resx # 英文
└── Strings.ja-JP.resx # 日文(可选)
```
**Strings.resx** (中文):
```xml
<data name="Welcome" xml:space="preserve">
<value>欢迎使用 Sharp 框架</value>
</data>
<data name="Username" xml:space="preserve">
<value>用户名</value>
</data>
<data name="Password" xml:space="preserve">
<value>密码</value>
</data>
<data name="Login" xml:space="preserve">
<value>登录</value>
</data>
<data name="WelcomeUser" xml:space="preserve">
<value>欢迎, {0}!</value>
</data>
```
**Strings.en-US.resx** (英文):
```xml
<data name="Welcome" xml:space="preserve">
<value>Welcome to Sharp Framework</value>
</data>
<data name="Username" xml:space="preserve">
<value>Username</value>
</data>
<data name="Password" xml:space="preserve">
<value>Password</value>
</data>
<data name="Login" xml:space="preserve">
<value>Login</value>
</data>
<data name="WelcomeUser" xml:space="preserve">
<value>Welcome, {0}!</value>
</data>
```
### 2. App.xaml.cs - 注册国际化服务
```csharp
using System.Globalization;
using System.Resources;
using Wpf.Localization;
protected override void OnStartup(StartupEventArgs e)
{
var services = new ServiceCollection();
services.AddSharpWpf();
// 添加国际化支持
var resourceManager = new ResourceManager(
"LocalizationExample.Resources.Strings", // 资源文件的完整命名空间
Assembly.GetExecutingAssembly());
services.AddLocalization(
resourceManager,
new CultureInfo("zh-CN"), // 中文
new CultureInfo("en-US"), // 英文
new CultureInfo("ja-JP") // 日文
);
services.AddViewModel<MainViewModel>();
var provider = services.BuildServiceProvider();
var mainWindow = provider.GetRequiredService<MainWindow>();
mainWindow.Show();
}
```
### 3. MainViewModel.cs - 语言切换
```csharp
using Wpf.Localization;
public class MainViewModel : ViewModelBase
{
private readonly ILocalizationService _localization;
private readonly IDialogService _dialogService;
public ILocalizationService Localization => _localization;
public List<CultureInfo> AvailableLanguages { get; }
public CultureInfo CurrentLanguage
{
get => _localization.CurrentCulture;
set
{
_localization.ChangeCulture(value);
OnPropertyChanged();
}
}
public ICommand ChangeLanguageCommand { get; }
public ICommand ShowGreetingCommand { get; }
public MainViewModel(
ILocalizationService localization,
IDialogService dialogService)
{
_localization = localization;
_dialogService = dialogService;
AvailableLanguages = localization.GetSupportedCultures().ToList();
// 监听语言变化
_localization.CultureChanged += (s, culture) =>
{
_dialogService.ShowMessage(
$"语言已切换到: {culture.NativeName}",
"提示");
};
ChangeLanguageCommand = new RelayCommand<CultureInfo>(culture =>
{
if (culture != null)
{
CurrentLanguage = culture;
}
});
ShowGreetingCommand = new RelayCommand(() =>
{
// 使用格式化字符串
var message = _localization.GetString("WelcomeUser", "张三");
_dialogService.ShowMessage(message, "问候");
});
}
}
```
### 4. MainWindow.xaml - 使用本地化绑定
```xml
<Window x:Class="LocalizationExample.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:Wpf.Localization;assembly=Sharp.Wpf"
Title="国际化示例" Height="400" Width="500">
<StackPanel Margin="20">
<!-- 方式1: 使用 LocalizeExtension(推荐) -->
<TextBlock Text="{loc:Localize Welcome}" FontSize="24" FontWeight="Bold"/>
<!-- 语言选择 -->
<StackPanel Orientation="Horizontal" Margin="0,20,0,0">
<TextBlock Text="选择语言:" VerticalAlignment="Center" Margin="0,0,10,0"/>
<ComboBox ItemsSource="{Binding AvailableLanguages}"
SelectedItem="{Binding CurrentLanguage}"
DisplayMemberPath="NativeName"
Width="150"/>
</StackPanel>
<!-- 登录表单 -->
<StackPanel Margin="0,30,0,0">
<!-- 用户名 -->
<TextBlock Text="{loc:Localize Username}"/>
<TextBox Margin="0,5,0,15"/>
<!-- 密码 -->
<TextBlock Text="{loc:Localize Password}"/>
<PasswordBox Margin="0,5,0,15"/>
<!-- 登录按钮 -->
<Button Content="{loc:Localize Login}"
Height="35" FontSize="14"/>
</StackPanel>
<!-- 方式2: 直接绑定 Localization 服务 -->
<StackPanel Margin="0,30,0,0">
<TextBlock Text="{Binding Localization[Welcome]}" FontStyle="Italic"/>
<TextBlock Text="{Binding Localization[Username]}" Margin="0,5,0,0"/>
</StackPanel>
<!-- 测试格式化字符串 -->
<Button Content="显示问候" Command="{Binding ShowGreetingCommand}" Margin="0,20,0,0"/>
</StackPanel>
</Window>
```
### 5. 在代码中使用本地化
```csharp
// 方式1: 通过服务获取
var welcomeText = _localization.GetString("Welcome");
// 方式2: 带格式化参数
var greeting = _localization.GetString("WelcomeUser", username);
// 方式3: 索引器(用于绑定)
var password = _localization["Password"];
```
## 🎓 练习建议
### 练习1:添加更多语言
添加法语(fr-FR)或德语(de-DE)资源文件。
### 练习2:保存语言偏好
将用户选择的语言保存到配置文件,下次启动时自动加载。
### 练习3:动态加载资源
实现从外部 JSON 文件加载翻译资源(而非编译进程序)。
### 练习4:翻译进度显示
显示每种语言的翻译完成度(如:英文 95%,日文 60%)。
## 💡 常见问题
**Q: 资源文件修改后不生效?**
A: 确保:
1. 资源文件的"生成操作"设为"嵌入的资源"
2. 重新生成项目(Clean + Rebuild)
**Q: 如何支持从右到左(RTL)语言?**
A: 监听 CultureChanged 事件,设置 `FlowDirection`:
```csharp
_localization.CultureChanged += (s, culture) => {
if (culture.TextInfo.IsRightToLeft) {
Application.Current.MainWindow.FlowDirection = FlowDirection.RightToLeft;
}
};
```
**Q: 能否热重载资源文件?**
A: Sharp.Wpf 当前使用编译时嵌入资源,不支持运行时重载。可以使用外部 JSON 文件实现热重载。
**Q: 如何处理复数形式?**
A: 使用不同的资源键或使用占位符:
```
"{Count} items" → "{0} 个项目"
```
## 📚 延伸阅读
- [.NET 资源文件](https://learn.microsoft.com/zh-cn/dotnet/core/extensions/resources)
- [CultureInfo 类](https://learn.microsoft.com/zh-cn/dotnet/api/system.globalization.cultureinfo)
- [WPF 本地化](https://learn.microsoft.com/zh-cn/dotnet/desktop/wpf/advanced/globalization-for-wpf)
- 下一个示例:[06-Theming](../06-Theming/README.md)
---
**构建面向全球用户的应用!** 🌍
+312
View File
@@ -0,0 +1,312 @@
# 06 - 主题系统示例
演示如何使用 Sharp.Wpf 的主题系统实现浅色/深色主题切换,包括自定义颜色、跟随系统主题等功能。
## 🎯 学习目标
- 使用 `IThemeService` 管理应用主题
- 实现浅色/深色主题切换
- 自定义主题颜色
- 跟随系统主题设置
- 动态主题切换(无需重启)
## 📦 使用的模块
- **Sharp.Wpf** - IThemeService, ThemeService
## 🚀 快速运行
```bash
cd examples/06-Theming
dotnet run
```
## 📝 核心代码讲解
### 1. App.xaml.cs - 注册主题服务
```csharp
using Wpf.Theming;
protected override void OnStartup(StartupEventArgs e)
{
var services = new ServiceCollection();
services.AddSharpWpf(); // 自动注册 IThemeService
services.AddViewModel<MainViewModel>();
var provider = services.BuildServiceProvider();
// 应用初始主题
var themeService = provider.GetRequiredService<IThemeService>();
themeService.ApplyTheme(Theme.Light); // 或 Theme.System
var mainWindow = provider.GetRequiredService<MainWindow>();
mainWindow.Show();
}
```
### 2. MainViewModel.cs - 主题切换逻辑
```csharp
using Wpf.Theming;
public class MainViewModel : ViewModelBase
{
private readonly IThemeService _themeService;
private Theme _currentTheme;
public Theme CurrentTheme
{
get => _currentTheme;
set
{
SetProperty(ref _currentTheme, value);
_themeService.ApplyTheme(value);
}
}
public ICommand SwitchToLightCommand { get; }
public ICommand SwitchToDarkCommand { get; }
public ICommand SwitchToSystemCommand { get; }
public MainViewModel(IThemeService themeService)
{
_themeService = themeService;
_currentTheme = themeService.CurrentTheme;
// 监听主题变化
_themeService.ThemeChanged += (s, theme) =>
{
OnPropertyChanged(nameof(CurrentTheme));
};
SwitchToLightCommand = new RelayCommand(() =>
{
CurrentTheme = Theme.Light;
});
SwitchToDarkCommand = new RelayCommand(() =>
{
CurrentTheme = Theme.Dark;
});
SwitchToSystemCommand = new RelayCommand(() =>
{
CurrentTheme = Theme.System;
});
}
}
```
### 3. MainWindow.xaml - 使用主题颜色
```xml
<Window x:Class="ThemingExample.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="主题系统示例" Height="500" Width="600"
Background="{DynamicResource BackgroundColor}"> <!-- 使用主题颜色 -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 主题切换栏 -->
<StackPanel Grid.Row="0" Orientation="Horizontal"
Background="{DynamicResource PrimaryColor}" Padding="15">
<TextBlock Text="选择主题:"
Foreground="White" VerticalAlignment="Center" Margin="0,0,10,0"/>
<Button Content="☀️ 浅色" Command="{Binding SwitchToLightCommand}"
Margin="0,0,10,0" Padding="15,5"/>
<Button Content="🌙 深色" Command="{Binding SwitchToDarkCommand}"
Margin="0,0,10,0" Padding="15,5"/>
<Button Content="🖥️ 跟随系统" Command="{Binding SwitchToSystemCommand}"
Padding="15,5"/>
</StackPanel>
<!-- 内容区域 -->
<ScrollViewer Grid.Row="1" Padding="20">
<StackPanel>
<TextBlock Text="主题颜色预览" FontSize="24" FontWeight="Bold"
Foreground="{DynamicResource ForegroundColor}" Margin="0,0,0,20"/>
<!-- 基础颜色 -->
<GroupBox Header="基础颜色" Margin="0,0,0,15">
<StackPanel>
<Border Background="{DynamicResource PrimaryColor}"
Height="40" Margin="0,5">
<TextBlock Text="PrimaryColor" Foreground="White"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
<Border Background="{DynamicResource BackgroundColor}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Height="40" Margin="0,5">
<TextBlock Text="BackgroundColor"
Foreground="{DynamicResource ForegroundColor}"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
</StackPanel>
</GroupBox>
<!-- 状态颜色 -->
<GroupBox Header="状态颜色" Margin="0,0,0,15">
<StackPanel>
<Border Background="{DynamicResource SuccessColor}"
Height="40" Margin="0,5">
<TextBlock Text="✓ Success" Foreground="White"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
<Border Background="{DynamicResource WarningColor}"
Height="40" Margin="0,5">
<TextBlock Text="⚠ Warning" Foreground="White"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
<Border Background="{DynamicResource ErrorColor}"
Height="40" Margin="0,5">
<TextBlock Text="✗ Error" Foreground="White"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
<Border Background="{DynamicResource InfoColor}"
Height="40" Margin="0,5">
<TextBlock Text="ℹ Info" Foreground="White"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
</StackPanel>
</GroupBox>
<!-- 控件预览 -->
<GroupBox Header="控件预览" Margin="0,0,0,15">
<StackPanel>
<!-- 按钮 -->
<Button Content="普通按钮" Margin="0,5" Padding="10,5"
Background="{DynamicResource ButtonBackgroundColor}"
Foreground="{DynamicResource ForegroundColor}"/>
<!-- 输入框 -->
<TextBox Text="输入框示例" Margin="0,5" Padding="5"
Background="{DynamicResource InputBackgroundColor}"
BorderBrush="{DynamicResource InputBorderColor}"/>
<!-- 复选框 -->
<CheckBox Content="复选框示例" Margin="0,5"
Foreground="{DynamicResource ForegroundColor}"/>
<!-- 单选按钮 -->
<RadioButton Content="单选按钮示例" Margin="0,5"
Foreground="{DynamicResource ForegroundColor}"/>
</StackPanel>
</GroupBox>
<!-- 文本示例 -->
<GroupBox Header="文本示例">
<StackPanel>
<TextBlock Text="这是标题文本" FontSize="20" FontWeight="Bold"
Foreground="{DynamicResource ForegroundColor}" Margin="0,5"/>
<TextBlock Text="这是正文文本。主题切换后,所有使用 DynamicResource 的颜色都会自动更新,无需重启应用。"
TextWrapping="Wrap" Foreground="{DynamicResource ForegroundColor}"
Margin="0,5"/>
<TextBlock Text="这是次要文本" FontSize="12" FontStyle="Italic"
Foreground="Gray" Margin="0,5"/>
</StackPanel>
</GroupBox>
</StackPanel>
</ScrollViewer>
</Grid>
</Window>
```
### 4. 自定义主题颜色
```csharp
public class CustomThemeViewModel : ViewModelBase
{
private readonly IThemeService _themeService;
public ICommand ChangePrimaryColorCommand { get; }
public CustomThemeViewModel(IThemeService themeService)
{
_themeService = themeService;
ChangePrimaryColorCommand = new RelayCommand(() =>
{
// 打开颜色选择器(可以使用第三方库或自定义)
var color = Colors.DeepSkyBlue; // 示例:选择的颜色
// 更新主题颜色
_themeService.SetColor("PrimaryColor", color);
});
}
}
```
### 5. 主题资源字典(可选 - 高级用法)
可以创建独立的资源字典文件:
**Themes/LightTheme.xaml**:
```xml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="PrimaryColor" Color="#0078D7"/>
<SolidColorBrush x:Key="BackgroundColor" Color="White"/>
<SolidColorBrush x:Key="ForegroundColor" Color="Black"/>
<!-- ... 其他颜色 -->
</ResourceDictionary>
```
**Themes/DarkTheme.xaml**:
```xml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="PrimaryColor" Color="#0078D7"/>
<SolidColorBrush x:Key="BackgroundColor" Color="#202020"/>
<SolidColorBrush x:Key="ForegroundColor" Color="White"/>
<!-- ... 其他颜色 -->
</ResourceDictionary>
```
## 🎓 练习建议
### 练习1:添加更多预设主题
添加"高对比度"主题,适合视力不佳的用户。
### 练习2:主题动画过渡
在切换主题时添加颜色渐变动画,而非瞬间切换。
### 练习3:保存主题偏好
将用户选择的主题保存到配置文件,下次启动时自动应用。
### 练习4:自定义主题编辑器
创建一个界面,允许用户自定义每种颜色,并导出/导入主题配置。
## 💡 常见问题
**Q: StaticResource 和 DynamicResource 的区别?**
A:
- `StaticResource`: 编译时绑定,主题切换后不会更新
- `DynamicResource`: 运行时绑定,主题切换后自动更新(推荐用于主题)
**Q: 主题切换后部分控件没变化?**
A: 确保使用了 `{DynamicResource}` 而非 `{StaticResource}`。
**Q: 如何跟随 Windows 系统主题?**
A: 使用 `Theme.System`,Sharp.Wpf 会自动检测系统主题设置。
**Q: 能否为不同的窗口设置不同主题?**
A: 可以,但不推荐。可以为每个 Window 设置独立的 ResourceDictionary。
## 📚 延伸阅读
- [WPF 资源](https://learn.microsoft.com/zh-cn/dotnet/desktop/wpf/systems/xaml-resources-overview)
- [WPF 样式和模板](https://learn.microsoft.com/zh-cn/dotnet/desktop/wpf/controls/styles-templates-overview)
- [Windows 主题检测](https://learn.microsoft.com/zh-cn/windows/apps/desktop/modernize/apply-windows-themes)
- 综合示例:[11-FullStackDemo](../11-FullStackDemo/README.md)
---
**打造美观现代的用户界面!** 🎨
+569
View File
@@ -0,0 +1,569 @@
# 11 - Full Stack Demo - 完整功能演示
这是一个综合示例,集成了 Sharp 框架的几乎所有功能,演示如何构建一个完整的工业监控系统。
## 🎯 功能列表
### 核心功能
- ✅ 用户登录(数据库验证)
- ✅ 多页面导航
- ✅ 主题切换(浅色/深色)
- ✅ 多语言支持(中英文)
- ✅ 表单验证
### 数据管理
- ✅ 产品 CRUD 操作
- ✅ 数据分页显示
- ✅ 缓存优化
### 工业IoT
- ✅ **PLC 通信测试** - 可配置 IP 和参数进行测试
- ✅ **串口通信测试** - 可选择串口和配置参数
### 其他
- ✅ 配置热重载
- ✅ 日志记录
- ✅ HTTP 客户端调用
## 📦 使用的模块
- Sharp.Common
- Sharp.Config
- Sharp.Wpf
- Sharp.Data
- Sharp.Cache
- Sharp.Log
- Sharp.Plc.Siemens
- Sharp.Ports
- Sharp.NetClient
## 🚀 快速运行
```bash
cd examples/11-FullStackDemo
dotnet run
```
## 📝 项目结构
```
11-FullStackDemo/
├── App.xaml.cs # 应用启动配置
├── appsettings.json # 配置文件
├── Models/
│ ├── User.cs # 用户实体
│ └── Product.cs # 产品实体
├── ViewModels/
│ ├── LoginViewModel.cs # 登录页
│ ├── MainViewModel.cs # 主窗口
│ ├── HomeViewModel.cs # 首页(仪表板)
│ ├── ProductsViewModel.cs # 产品管理
│ ├── PlcTestViewModel.cs # PLC 测试页 ⭐
│ ├── PortsTestViewModel.cs# 串口测试页 ⭐
│ └── SettingsViewModel.cs # 设置页
├── Views/
│ ├── LoginWindow.xaml
│ ├── MainWindow.xaml
│ ├── HomeView.xaml
│ ├── ProductsView.xaml
│ ├── PlcTestView.xaml # PLC 测试界面 ⭐
│ ├── PortsTestView.xaml # 串口测试界面 ⭐
│ └── SettingsView.xaml
└── Data/
└── AppDbContext.cs
```
## 🔧 核心代码讲解
### 1. App.xaml.cs - 完整启动配置
```csharp
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var services = new ServiceCollection();
// 1. 配置管理
var config = ConfigBuilder.CreateDefault().Configuration;
services.AddSingleton(config);
// 2. 数据库
services.AddSharpDatabase<AppDbContext>(config);
// 3. WPF 服务(包括导航、主题、国际化)
services.AddSharpWpf();
services.AddLocalization(Resources.Strings.ResourceManager,
new CultureInfo("zh-CN"),
new CultureInfo("en-US"));
// 4. 缓存
services.AddSharpCache();
// 5. 日志
services.AddLogging(builder => {
builder.AddConsole();
builder.AddDebug();
});
// 6. PLC 服务(可选 - 如果配置中启用)
var plcEnabled = config.GetValue<bool>("Plc:Enabled", false);
if (plcEnabled)
{
services.AddSiemensPlc(config.GetSection("Plc:Siemens"));
}
// 7. 串口服务
services.AddSingleton<ISerialPortService, SerialPortService>();
// 8. HTTP 客户端
services.AddHttpClient();
// 9. 注册 ViewModels
services.AddViewModel<LoginViewModel>();
services.AddViewModel<MainViewModel>();
services.AddViewModel<HomeViewModel>();
services.AddViewModel<ProductsViewModel>();
services.AddViewModel<PlcTestViewModel>();
services.AddViewModel<PortsTestViewModel>();
services.AddViewModel<SettingsViewModel>();
// 10. 注册 Windows
services.AddTransient<LoginWindow>();
services.AddTransient<MainWindow>();
_serviceProvider = services.BuildServiceProvider();
// 11. 初始化数据库
using (var scope = _serviceProvider.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
SeedData(db); // 初始化测试数据
}
// 12. 应用主题
var themeService = _serviceProvider.GetRequiredService<IThemeService>();
var savedTheme = config.GetValue<string>("App:Theme", "Light");
themeService.ApplyTheme(Enum.Parse<Theme>(savedTheme));
// 13. 显示登录窗口
ShowLoginWindow();
}
```
### 2. PlcTestViewModel.cs - PLC 测试页 ⭐
```csharp
public class PlcTestViewModel : ViewModelBase
{
private readonly IDialogService _dialogService;
private IPlcService? _plcService;
// 连接参数
private string _ipAddress = "192.168.1.100";
private int _rack = 0;
private int _slot = 1;
private bool _isConnected;
public string IpAddress
{
get => _ipAddress;
set => SetProperty(ref _ipAddress, value);
}
public int Rack
{
get => _rack;
set => SetProperty(ref _rack, value);
}
public int Slot
{
get => _slot;
set => SetProperty(ref _slot, value);
}
public bool IsConnected
{
get => _isConnected;
set => SetProperty(ref _isConnected, value);
}
// 读写测试
private string _address = "DB1.DBD0";
private string _value = "0";
private string _readResult = "";
public string Address
{
get => _address;
set => SetProperty(ref _address, value);
}
public string Value
{
get => _value;
set => SetProperty(ref _value, value);
}
public string ReadResult
{
get => _readResult;
set => SetProperty(ref _readResult, value);
}
public ICommand ConnectCommand { get; }
public ICommand DisconnectCommand { get; }
public ICommand ReadCommand { get; }
public ICommand WriteCommand { get; }
public PlcTestViewModel(IDialogService dialogService)
{
_dialogService = dialogService;
ConnectCommand = new AsyncCommand(async (ct) =>
{
try
{
IsBusy = true;
// 动态创建 PLC 服务
var options = new SiemensPlcOptions
{
IpAddress = IpAddress,
Rack = Rack,
Slot = Slot,
CpuType = CpuType.S71500
};
_plcService = new PlcMonitorService(
new SiemensDriver(options),
new SiemensOptimizer(),
new BigEndianConverter(),
Options.Create(new PlcMonitorOptions()),
null);
await _plcService.StartAsync(ct);
IsConnected = true;
_dialogService.ShowMessage("PLC 连接成功!", "成功");
}
catch (Exception ex)
{
_dialogService.ShowMessage($"连接失败: {ex.Message}", "错误");
}
finally
{
IsBusy = false;
}
});
DisconnectCommand = new AsyncCommand(async (ct) =>
{
if (_plcService != null)
{
await _plcService.StopAsync();
IsConnected = false;
_dialogService.ShowMessage("已断开连接", "提示");
}
});
ReadCommand = new AsyncCommand(async (ct) =>
{
if (_plcService == null)
{
_dialogService.ShowMessage("请先连接 PLC", "提示");
return;
}
try
{
IsBusy = true;
var result = await _plcService.ReadAsync<int>(Address);
if (result.IsSuccess)
{
ReadResult = $"读取成功: {result.Value}";
}
else
{
ReadResult = $"读取失败: {result.ErrorMessage}";
}
}
catch (Exception ex)
{
ReadResult = $"读取异常: {ex.Message}";
}
finally
{
IsBusy = false;
}
});
WriteCommand = new AsyncCommand(async (ct) =>
{
if (_plcService == null)
{
_dialogService.ShowMessage("请先连接 PLC", "提示");
return;
}
try
{
IsBusy = true;
var intValue = int.Parse(Value);
var result = await _plcService.WriteAsync(Address, intValue);
if (result.IsSuccess)
{
_dialogService.ShowMessage("写入成功!", "成功");
}
else
{
_dialogService.ShowMessage($"写入失败: {result.ErrorMessage}", "错误");
}
}
catch (Exception ex)
{
_dialogService.ShowMessage($"写入异常: {ex.Message}", "错误");
}
finally
{
IsBusy = false;
}
});
}
}
```
### 3. PortsTestViewModel.cs - 串口测试页 ⭐
```csharp
public class PortsTestViewModel : ViewModelBase
{
private readonly ISerialPortService _serialPortService;
private readonly IDialogService _dialogService;
// 可用串口列表
public ObservableCollection<string> AvailablePorts { get; } = new();
private string? _selectedPort;
private int _baudRate = 9600;
private bool _isConnected;
public string? SelectedPort
{
get => _selectedPort;
set => SetProperty(ref _selectedPort, value);
}
public int BaudRate
{
get => _baudRate;
set => SetProperty(ref _baudRate, value);
}
public bool IsConnected
{
get => _isConnected;
set => SetProperty(ref _isConnected, value);
}
// 发送/接收
private string _sendData = "";
private string _receiveData = "";
public string SendData
{
get => _sendData;
set => SetProperty(ref _sendData, value);
}
public string ReceiveData
{
get => _receiveData;
set => SetProperty(ref _receiveData, value);
}
public ICommand RefreshPortsCommand { get; }
public ICommand ConnectCommand { get; }
public ICommand DisconnectCommand { get; }
public ICommand SendCommand { get; }
public ICommand ClearCommand { get; }
public PortsTestViewModel(
ISerialPortService serialPortService,
IDialogService dialogService)
{
_serialPortService = serialPortService;
_dialogService = dialogService;
// 监听数据接收
_serialPortService.DataReceived += (s, e) =>
{
Application.Current.Dispatcher.Invoke(() =>
{
ReceiveData += $"[{DateTime.Now:HH:mm:ss}] 接收: {BitConverter.ToString(e.Data)}\n";
});
};
RefreshPortsCommand = new RelayCommand(() =>
{
AvailablePorts.Clear();
var ports = SerialPortDiscovery.GetAvailablePorts();
foreach (var port in ports)
{
AvailablePorts.Add(port.PortName);
}
if (AvailablePorts.Count > 0)
{
SelectedPort = AvailablePorts[0];
}
});
ConnectCommand = new AsyncCommand(async (ct) =>
{
if (string.IsNullOrEmpty(SelectedPort))
{
_dialogService.ShowMessage("请选择串口", "提示");
return;
}
try
{
var options = new SerialPortOptions
{
PortName = SelectedPort,
BaudRate = BaudRate
};
var result = await _serialPortService.StartListeningAsync(options, autoReconnect: false);
if (result.IsSuccess)
{
IsConnected = true;
_dialogService.ShowMessage("串口连接成功!", "成功");
}
else
{
_dialogService.ShowMessage($"连接失败: {result.ErrorMessage}", "错误");
}
}
catch (Exception ex)
{
_dialogService.ShowMessage($"连接异常: {ex.Message}", "错误");
}
});
DisconnectCommand = new AsyncCommand(async (ct) =>
{
await _serialPortService.StopListeningAsync();
IsConnected = false;
_dialogService.ShowMessage("已断开连接", "提示");
});
SendCommand = new AsyncCommand(async (ct) =>
{
if (!IsConnected)
{
_dialogService.ShowMessage("请先连接串口", "提示");
return;
}
try
{
// 将字符串转换为字节数组
var data = Encoding.UTF8.GetBytes(SendData);
var result = await _serialPortService.SendAsync(data);
if (result.IsSuccess)
{
ReceiveData += $"[{DateTime.Now:HH:mm:ss}] 发送: {SendData}\n";
}
else
{
_dialogService.ShowMessage($"发送失败: {result.ErrorMessage}", "错误");
}
}
catch (Exception ex)
{
_dialogService.ShowMessage($"发送异常: {ex.Message}", "错误");
}
});
ClearCommand = new RelayCommand(() =>
{
ReceiveData = "";
});
// 初始化:刷新串口列表
RefreshPortsCommand.Execute(null);
}
}
```
### 4. appsettings.json - 配置文件
```json
{
"App": {
"Theme": "Light",
"Language": "zh-CN"
},
"Database": {
"Provider": "Sqlite",
"ConnectionString": "Data Source=demo.db"
},
"Plc": {
"Enabled": false,
"Siemens": {
"IpAddress": "192.168.1.100",
"Port": 102,
"CpuType": "S71500",
"Rack": 0,
"Slot": 1
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning"
}
}
}
```
## 🎓 使用说明
### 测试 PLC 连接
1. 进入"PLC 测试"页面
2. 输入 PLC 的 IP 地址、Rack、Slot
3. 点击"连接"按钮
4. 连接成功后,输入地址(如 `DB1.DBD0`)
5. 点击"读取"或"写入"测试通信
### 测试串口通信
1. 进入"串口测试"页面
2. 点击"刷新端口"查看可用串口
3. 选择串口并设置波特率
4. 点击"连接"
5. 在发送框输入数据,点击"发送"
6. 接收框会显示收到的数据
## 💡 提示
- PLC 和串口功能可以独立测试,无需实际设备(会显示连接失败,但不影响其他功能)
- 数据库使用 SQLite,无需安装
- 配置文件支持热重载,修改后自动生效
- 所有功能都有错误处理,不会导致程序崩溃
## 📚 延伸阅读
- 所有示例项目的 README
- Sharp 框架文档
---
**这是一个完整的工业监控系统模板,可以直接用于实际项目开发!** 🏭
+273
View File
@@ -0,0 +1,273 @@
# Sharp Framework Examples
本目录包含 Sharp 框架各个功能的示例项目,帮助你快速上手。
## 📚 示例项目列表
### 基础示例
#### 1. **01-MinimalWpfApp** - 最小 WPF 应用 ⭐
**演示内容**:
- 最简单的 Sharp WPF 应用
- 仅使用 Common + Wpf 模块
- MVVM 基础(ViewModelBase, Commands)
- 对话框服务
**适用场景**:刚接触 Sharp 框架,想快速了解基本用法
**运行**:
```bash
cd examples/01-MinimalWpfApp
dotnet run
```
---
#### 2. **02-WpfWithDatabase** - WPF + 数据库 ⭐⭐
**演示内容**:
- WPF + SQLite 数据库
- Repository 模式(CRUD操作)
- 数据绑定与列表展示
- 添加/编辑/删除产品
**适用场景**:需要本地数据存储的桌面应用
**技术栈**:Common, Config, Wpf, Data (SQLite)
---
#### 3. **03-FormValidation** - 表单验证 ⭐⭐
**演示内容**:
- ValidatableViewModelBase 使用
- DataAnnotations 验证
- 实时验证反馈
- 错误消息显示
**适用场景**:需要用户输入验证的应用
---
#### 4. **04-Navigation** - 导航系统 ⭐⭐
**演示内容**:
- INavigationService 使用
- 多页面导航
- 前进/后退功能
- 页面间参数传递
- 导航生命周期(INavigationAware)
**适用场景**:多页面 WPF 应用
---
#### 5. **05-Localization** - 国际化 ⭐⭐
**演示内容**:
- 多语言支持(中文、英文)
- LocalizeExtension XAML 绑定
- 运行时切换语言
- 资源文件管理
**适用场景**:需要多语言支持的应用
---
#### 6. **06-Theming** - 主题系统 ⭐⭐
**演示内容**:
- 浅色/深色主题切换
- 自定义主题颜色
- 跟随系统主题
- 动态主题切换
**适用场景**:需要主题切换的现代应用
---
### 工业IoT示例
#### 7. **07-PlcMonitoring** - PLC 监控 ⭐⭐⭐
**演示内容**:
- 连接西门子 PLC
- 实时数据监控
- 数据变化订阅
- 读写PLC标签
- 数据可视化(图表)
**适用场景**:工业自动化、设备监控
**技术栈**:Wpf, Plc.Siemens, Cache(缓存历史数据)
---
#### 8. **08-SerialPortComm** - 串口通信 ⭐⭐
**演示内容**:
- 串口自动发现
- 数据收发
- HEX/ASCII 显示
- 数据记录
**适用场景**:串口设备通信、调试工具
**技术栈**:Wpf, Ports
---
### 网络示例
#### 9. **09-HttpServer** - 嵌入式 Web 服务器 ⭐⭐⭐
**演示内容**:
- 在 WPF 应用中嵌入 HTTP 服务器
- RESTful API 实现
- 中间件使用(CORS, 认证, 日志)
- WPF 界面管理服务器
**适用场景**:设备管理后台、本地Web服务
**技术栈**:Wpf, NetServer, Log
---
#### 10. **10-HttpClient** - HTTP 客户端 ⭐⭐
**演示内容**:
- 调用外部 API
- 重试策略(Polly)
- Result<T> 错误处理
- 缓存 API 响应
**适用场景**:需要调用外部服务的应用
**技术栈**:Wpf, NetClient, Cache
---
### 综合示例
#### 11. **11-FullStackDemo** - 完整功能演示 ⭐⭐⭐⭐
**演示内容**:
- 用户登录(数据库验证)
- 产品管理(CRUD)
- PLC 数据监控
- 串口通信
- 数据报表导出
- 多语言
- 主题切换
- 配置热重载
**适用场景**:工业监控系统、MES系统、数采系统
**技术栈**:全部模块
---
#### 12. **12-CachingDemo** - 缓存使用 ⭐
**演示内容**:
- 字典数据缓存
- API 响应缓存
- 计算结果缓存
- 缓存失效策略
**技术栈**:Wpf, Cache, NetClient
---
#### 13. **13-EventBusDemo** - 事件总线 ⭐
**演示内容**:
- 跨模块通信
- 事件发布/订阅
- 解耦架构
**技术栈**:Wpf, EventBus
---
## 🚀 快速开始
### 运行示例
```bash
# 1. 克隆仓库
git clone https://github.com/your-repo/sharp-basic.git
cd sharp-basic/examples
# 2. 选择一个示例
cd 01-MinimalWpfApp
# 3. 还原依赖并运行
dotnet restore
dotnet run
```
### 从示例创建新项目
```bash
# 复制示例作为起点
cp -r examples/02-WpfWithDatabase MyNewProject
cd MyNewProject
# 修改项目名称
# 编辑 .csproj 文件和命名空间
# 开始开发!
```
---
## 📖 学习路径
### 新手推荐路径
1. **01-MinimalWpfApp** - 了解基础
2. **02-WpfWithDatabase** - 学习数据库操作
3. **03-FormValidation** - 掌握表单验证
4. **04-Navigation** - 理解导航系统
### 工业自动化开发者
1. **07-PlcMonitoring** - PLC 通信
2. **08-SerialPortComm** - 串口通信
3. **11-FullStackDemo** - 完整系统
### Web 开发者转 WPF
1. **09-HttpServer** - HTTP 服务器
2. **10-HttpClient** - HTTP 客户端
3. **06-Theming** - 主题系统
---
## 💡 示例代码说明
所有示例都遵循:
- ✅ **最佳实践**:展示推荐的使用方式
- ✅ **完整代码**:可直接运行,无需额外配置
- ✅ **详细注释**:中文注释,解释关键代码
- ✅ **README**:每个示例都有独立的 README
---
## 🛠️ 技术要求
- .NET 8.0 SDK
- Visual Studio 2022 或 VS Code (推荐安装 C# Dev Kit)
- Windows 10/11 (WPF 应用需要 Windows)
---
## 📝 贡献示例
欢迎贡献新的示例!请确保:
1. 代码简洁明了
2. 包含 README 说明
3. 注释使用中文
4. 可独立运行
---
## ❓ 常见问题
**Q: 示例可以直接用于生产吗?**
A: 示例代码主要用于学习,生产使用需要根据实际情况调整(如安全性、错误处理、日志等)。
**Q: 示例使用什么数据库?**
A: 大部分示例使用 SQLite(无需安装,开箱即用),也有 SQL Server 示例。
**Q: 可以在 .NET Framework 上运行吗?**
A: 不可以,Sharp 框架基于 .NET 8,需要 .NET 8 运行时。
---
**选择一个示例开始你的 Sharp 之旅吧!** 🎉
+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:*)"
]
}
}
+4 -4
View File
@@ -2,17 +2,17 @@
## Project Structure & Module Organization ## Project Structure & Module Organization
- Solution: `Sharp.sln` aggregates all modules in this folder. - Solution: `Sharp.sln` aggregates all modules in this folder.
- Libraries: `Common`, `Config`, `Log`, `EventBus`, `Sharp.Infrastructure`. - Libraries: `Common`, `Config`, `Log`, `Cache`, `EventBus`, `Data`, `NetServer`, `NetClient`, `Ports`, `Plc`, `Plc.Siemens`, `Process`, `Python`, `Wpf`, `Wpf.HandyControl`, `Wpf.DevTools`.
- Networking: `NetServer` (HTTP server + middleware), `NetClient` (typed HTTP client). - Networking: `NetServer` (HTTP server + middleware), `NetClient` (typed HTTP client).
- UI: `Wpf` (shared WPF helpers), `Client` (WPF app using `Wpf`). - UI: `Wpf` (shared WPF helpers), `Wpf.HandyControl`, `Wpf.DevTools`, `Sharp.Demo` (WPF demo app).
- Examples: `NetServer/Examples` contains a runnable sample entry point. - Examples: `NetServer/Examples` contains a runnable sample entry point.
- Tests: solution includes external projects under `../../test/*` (e.g., `Common.Test`, `NetClient.Test`). - Tests: solution includes external projects under `../../test/*` (e.g., `Common.Test`, `NetClient.Test`).
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- Restore: `dotnet restore` - Restore: `dotnet restore`
- Build (Debug/Release): `dotnet build Sharp.sln -c Debug` | `-c Release` - Build (Debug/Release): `dotnet build Sharp.sln -c Debug` | `-c Release`
- Run server example: `dotnet run --project NetServer/NetServer.csproj` - Run server example: `dotnet run --project NetServer/Examples/NetServer.Examples.csproj`
- Run WPF client (Windows): `dotnet run --project Client/Client.csproj` - Run WPF demo (Windows): `dotnet run --project Sharp.Demo/Sharp.Demo.csproj`
- Run all tests: `dotnet test Sharp.sln -c Debug --no-build` - Run all tests: `dotnet test Sharp.sln -c Debug --no-build`
- Run a single test project: `dotnet test ../../test/Common.Test/Common.Test.csproj` - Run a single test project: `dotnet test ../../test/Common.Test/Common.Test.csproj`
+346
View File
@@ -0,0 +1,346 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Sharp-Basic is a modular C# .NET 8 framework for building WPF and C# applications with reusable components. It provides a comprehensive toolkit with emphasis on:
- **Modularity**: Independent, reusable libraries that can be used selectively - "引入即用"
- **Quick Start**: Zero-configuration setup with sensible defaults - "约定优于配置"
- **Desktop Applications**: WPF-based UI toolkit with MVVM infrastructure
- **Industrial IoT**: PLC integration (Siemens) and serial port communication (optional)
- **Configuration as Code**: Multi-format (JSON/XML/INI/TOML) with hot-reload
- **Functional Programming**: Result<T> pattern for railway-oriented error handling
- **Opinionated**: Strong technology choices (EF Core, Serilog, etc.) for team consistency
**Design Philosophy**: "固执己见的框架" - Makes strong choices about technology stack to enable fast development. If you can't use EF Core, this framework may not be for you.
**Language**: All comments and documentation are in Chinese (中文).
## Quick Start
**New to Sharp?** See `QUICKSTART.md` for zero-configuration setup guide.
### Minimal WPF Application (3 lines)
```csharp
var services = new ServiceCollection();
services.AddSharpEssentials(); // Config + Logging + Cache
services.AddSharpWpf(); // WPF Services (Dialog, Dispatcher)
services.AddViewModel<MainViewModel>();
```
### With Database (1 more line)
```csharp
services.AddSharpDatabase<AppDbContext>(config); // Reads from appsettings.json
```
## Build Commands
### Build the entire solution
```bash
cd src/Sharp
dotnet build Sharp.sln
```
### Build specific project
```bash
cd src/Sharp
dotnet build Sharp.Demo/Sharp.Demo.csproj # WPF demo application (最佳实践示例)
dotnet build Common/Common.csproj # Core utilities
dotnet build Config/Config.csproj # Configuration management
```
### Run tests
```bash
cd src/Sharp
dotnet test Sharp.sln # All tests
dotnet test ../../test/Common.Test # Specific test project
```
### Run the Sharp.Demo application
```bash
cd src/Sharp
dotnet run --project Sharp.Demo/Sharp.Demo.csproj
```
### Clean build artifacts
```bash
cd src/Sharp
dotnet clean Sharp.sln
```
## Architecture Overview
### Project Structure
```
src/Sharp/ # All source projects
├── Common/ # Core foundation (Result<T>, Global container, utilities, SystemConfig)
├── Config/ # Multi-format configuration with hot-reload
├── Wpf/ # WPF UI toolkit (MVVM, dialogs, converters) - NO UI library dependency
├── Wpf.HandyControl/ # HandyControl UI library integration (optional)
├── Wpf.DevTools/ # WPF Developer Tools (DevTools, LogViewer, Performance Monitor)
├── Data/ # EF Core wrapper with repository pattern
├── Log/ # Serilog-based structured logging
├── Cache/ # Memory cache (planned)
├── EventBus/ # Event pub/sub (planned)
├── NetClient/ # HTTP client with Polly resilience
├── NetServer/ # Lightweight HTTP server with middleware
├── Ports/ # Serial port abstraction (RS232/RS485)
├── Plc/ # PLC abstraction interface
├── Plc.Siemens/ # Siemens S7 PLC implementation (S7.NET+)
├── Process/ # Process execution
├── Python/ # Python script execution
└── Sharp.Demo/ # 综合演示应用(最佳实践示例,替代了 Client 和 Server)
test/ # MSTest test projects
├── Common.Test/
├── Config.Test/
├── Data.Test/
├── Log.Test/
├── NetClient.Test/
└── NetServer.Test/
../../appsettings.template.json # 完整的配置模板文件(约定配置节名称)
```
### Key Architectural Patterns
#### 1. Result<T> Pattern (Railway-Oriented Programming)
- Eliminates exceptions as control flow
- Provides chainable operations: `Map`, `Bind`, `Match`, `Where`, `OnSuccess`, `OnFailure`
- Synchronous and async wrappers: `Result.Try()`, `Result.TryAsync()`
- Example usage:
```csharp
Result.Try(() => int.Parse(input))
.Map(x => x * 2)
.Where(v => v > 100, "Value too small")
.OnSuccess(v => Console.WriteLine(v))
.OnFailure((ex, msg) => logger.LogError(msg));
```
#### 2. Dependency Injection (Hybrid Approach)
- **Primary**: Microsoft.Extensions.DependencyInjection for standard DI
- **Fallback**: Global service locator (Common.Global) for legacy/convenience
- Services registered in `DependencyInjection` folders within each module
- Sharp.Demo app startup (App.xaml.cs) orchestrates all service registration
#### 3. Configuration Management
- Multi-source configuration builder supporting JSON/XML/INI/TOML
- Hot-reload with change notifications
- Strong typing with DataAnnotations validation
- IOptions<T> pattern integration
- Environment variable overrides with optional prefix
#### 4. Repository Pattern with EF Core
- Generic `IRepository<TEntity>` abstraction
- Built-in entity types: `Entity`, `AuditableEntity`, `ISoftDeletable`
- Auto-audit tracking (CreatedAt, CreatedBy, UpdatedAt, UpdatedBy)
- Multi-database support: SQL Server, SQLite, PostgreSQL, MySQL, In-Memory
#### 5. MVVM for WPF
- `ViewModelBase` with INotifyPropertyChanged and IsBusy tracking
- `RelayCommand` and `AsyncCommand` implementations
- Services: `IDialogService`, `IFileDialogService`, `IDispatcherService`
- Converters: BoolToVisibility, InverseBooleanConverter, EnumToDescriptionConverter
- Global exception handling for UI/Task/AppDomain exceptions
#### 6. Middleware Pattern
- Custom HTTP server (NetServer) with ASP.NET Core-style pipeline
- Middleware: Authentication, CORS, Rate Limiting, Caching, Logging, Exception Handling
- Attribute-based routing with `RouteAttribute`
- Custom `IMiddleware` interface for extensibility
## Module Dependencies
**Core Layer** (no external dependencies):
- Common → (no dependencies)
**Configuration Layer**:
- Config → Microsoft.Extensions.Configuration.*, Tomlyn (TOML support)
**Data Layer**:
- Data → EF Core, Common
- Repository → Data
**Network Layer**:
- NetClient → Polly, Microsoft.Extensions.Http, Common
- NetServer → Common, Log
**WPF Layer**:
- Wpf → (no UI library dependency - pure MVVM)
- Wpf.HandyControl → Wpf, HandyControl 3.5.0 (optional UI library integration)
- Wpf.DevTools → Wpf (Developer Tools: DevTools, LogViewer, Performance Monitor)
- Sharp.Demo → Wpf, Wpf.HandyControl, Wpf.DevTools, Config, NetClient, NetServer, Log, Data, Plc.Siemens, Ports, Process, Python
**Industrial IoT**:
- Ports → (serial port communication)
- Plc → (abstraction only)
- Plc.Siemens → Plc, S7.NET+ library
**Logging**:
- Log → Serilog, Microsoft.Extensions.Logging
## Common Development Workflows
### Quick Start for New Projects (Recommended)
1. Install NuGet packages (or reference projects)
2. Create `appsettings.json` in project root
3. Use Quick Start extensions in `App.xaml.cs`:
```csharp
protected override void OnStartup(StartupEventArgs e) {
var services = new ServiceCollection();
// Option 1: Minimal (Config + Log + Cache + WPF)
services.AddSharpEssentials();
services.AddSharpWpf();
// Option 2: With Database
var config = ConfigBuilder.CreateDefault().Configuration;
services.AddSharpEssentials(config);
services.AddSharpDatabase<AppDbContext>(config); // Reads from appsettings.json
services.AddSharpWpf();
// Register ViewModels
services.AddViewModel<MainViewModel>();
var provider = services.BuildServiceProvider();
// Show main window...
}
```
4. See `QUICKSTART.md` for complete examples
### Adding a new module/library
1. Create project under `src/Sharp/`
2. Target `net8.0` (or `net8.0-windows` for WPF)
3. Enable nullable reference types: `<Nullable>enable</Nullable>`
4. Create `DependencyInjection` folder for service registration extensions with `AddSharpXxx()` methods
5. Create comprehensive `README.md` with Quick Start section
6. Add test project under `test/` with MSTest
7. Add to Sharp.sln: `dotnet sln add <ProjectPath>`
### Working with Result<T>
- Use `Result.Try()` to wrap exception-prone code
- Chain operations with `Map()` for transformations
- Use `Bind()` for operations that return Result<T>
- Extract values with `GetOrDefault()`, `GetOrThrow()`, or `Match()`
- Always handle both success and failure cases
### Using the Global container
- Register singletons: `Global.RegisterSingleton<T>(instance)` or `Global.RegisterSingleton<T>(() => new T())`
- Register transients: `Global.RegisterTransient<T>(() => new T())`
- Subscribe to changes: `Global.Subscribe<T>(e => { /* handle change */ })`
- Retrieve services: `Global.Get<T>()`
- **Note**: Prefer standard DI (IServiceCollection) for new code; Global is for legacy/compatibility
### Configuration workflow (Quick Start)
1. Create `appsettings.json` in project root
2. Use default configuration:
```csharp
var config = ConfigBuilder.CreateDefault().Configuration;
```
3. Configuration automatically loaded with:
- Hot-reload enabled
- Environment variables (prefix: `APP_`)
- Environment-specific files (appsettings.Development.json, etc.)
### Configuration workflow (Manual)
1. Add config files to project root (appsettings.json, etc.)
2. Create strongly-typed options class with DataAnnotations
3. Build ConfigManager: `ConfigBuilder.BuildFromFiles(...)`
4. Register with DI: `services.AddSingleton(cfg)` and `services.AddSingleton(cfg.Configuration)`
5. Use hot-reload callbacks: `cfg.OnReload(() => { /* refresh */ })`
### WPF Sharp.Demo startup flow (Modern Quick Start)
```csharp
protected override void OnStartup(StartupEventArgs e) {
var services = new ServiceCollection();
var config = ConfigBuilderExtensions.CreateDefault().Configuration;
services.AddSharpEssentials(config);
services.AddSharpWpf();
services.AddSharpHandyControl();
services.AddSharpDevTools();
services.AddViewModel<LauncherViewModel>();
services.AddTransient<LauncherWindow>();
var provider = services.BuildServiceProvider();
provider.GetRequiredService<LauncherWindow>().Show();
}
```
### Working with PLC (Siemens)
- Interface: `IPlcService` in Plc project
- Implementation: Plc.Siemens using S7.NET+ library
- Supports: S7-1200, S7-1500, S7-300, S7-400
- Register with DI: `services.AddSiemensPlc(config.GetSection("Plc:Siemens"))`
- Methods: Read/Write memory areas, data blocks, Subscribe for monitoring
### Serial Port Communication
- Service: `ISerialPortService` in Ports module
- Discovery: `SerialPortDiscovery` for auto-detection
- Event-based data reception: `DataReceivedEventArgs`
- Configured with: baud rate, parity, stop bits, data bits
### Using Cache (When Implemented)
```csharp
// Dictionary data caching
var categories = await _cache.GetOrSetAsync(
"categories:all",
() => _repository.GetAllAsync(),
TimeSpan.FromMinutes(30)
);
// API response caching
var weather = await _cache.GetOrSetAsync(
$"weather:{city}",
() => _apiClient.GetWeatherAsync(city),
TimeSpan.FromMinutes(5)
);
```
### Using EventBus (When Implemented)
```csharp
// Publish
_eventBus.Publish(new UserLoginEvent(username, DateTime.Now));
// Subscribe
_eventBus.Subscribe<UserLoginEvent>(e => {
WelcomeMessage = $"欢迎, {e.Username}!";
});
```
## Code Style
- **Target Framework**: .NET 8 (net8.0 / net8.0-windows)
- **Nullable**: Enabled (`#nullable enable`)
- **Implicit Usings**: Enabled
- **Namespace Convention**: `Sharp.*` (Common will migrate to `Sharp.Common`)
- **Language**: All comments, docs, and strings in Chinese
- **Backwards Compatibility**: Maintain for minor versions; breaking changes only in major versions
- **Testing**: MSTest with parallel execution enabled
## Design Principles
1. **"引入即用" (Plug and Play)**: Every module should work with minimal configuration
2. **"约定优于配置" (Convention over Configuration)**: Sensible defaults for everything
3. **"固执己见" (Opinionated)**: Strong technology choices (EF Core, Serilog, HandyControl)
- If you can't use our stack, this framework is not for you
- Designed for team/personal consistency, not maximum flexibility
4. **Quick Start First**: Every module README must have a "Quick Start" section showing 1-line setup
5. **No Abstraction Overkill**: Don't create interfaces just for flexibility - commit to implementations
## Module README Template
Every module must have a README with:
1. **Quick Start** section (5-second setup)
2. **Why You Need This** section (problem/solution)
3. **Core API** section (main interfaces/classes)
4. **Typical Scenarios** section (3-5 real-world examples)
5. **Configuration** section (if applicable)
See `EventBus/README.md` and `Cache/README.md` for examples (even though not implemented yet).
## License
GNU AGPLv3 - Network services that use this framework must open source modifications. Contact author for alternative commercial licensing.
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Sharp.Cache</AssemblyName>
<RootNamespace>Sharp.Cache</RootNamespace>
<!-- 程序集元数据 -->
<Description>Sharp Framework 缓存库 - 内存缓存抽象和实现</Description>
<!-- NuGet 包元数据 -->
<PackageTags>sharp;cache;memory-cache;caching</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0"/>
</ItemGroup>
</Project>
+75
View File
@@ -0,0 +1,75 @@
using System;
namespace Sharp.Cache;
/// <summary>
/// 缓存扩展方法
/// </summary>
public static class CacheExtensions
{
/// <summary>
/// 获取缓存,不存在则执行工厂方法并缓存结果(同步)
/// </summary>
public static T GetOrSet<T>(
this ICacheService cache,
string key,
Func<T> factory,
TimeSpan? expiration = null)
{
if (cache.TryGet<T>(key, out var cached)) return cached!;
var value = factory();
cache.Set(key, value, expiration);
return value;
}
/// <summary>
/// 获取缓存,不存在则执行工厂方法并缓存结果(异步)
/// </summary>
public static async Task<T> GetOrSetAsync<T>(
this ICacheService cache,
string key,
Func<Task<T>> factory,
TimeSpan? expiration = null)
{
if (cache.TryGet<T>(key, out var cached)) return cached!;
var value = await factory();
cache.Set(key, value, expiration);
return value;
}
/// <summary>
/// 获取或设置(滑动过期)
/// </summary>
public static T GetOrSetSliding<T>(
this ICacheService cache,
string key,
Func<T> factory,
TimeSpan slidingExpiration)
{
if (cache.TryGet<T>(key, out var cached)) return cached!;
var value = factory();
cache.SetSliding(key, value, slidingExpiration);
return value;
}
/// <summary>
/// 批量移除(支持通配符)
/// </summary>
public static void RemoveByPattern(this ICacheService cache, string pattern)
{
if (string.IsNullOrWhiteSpace(pattern))
return;
if (!pattern.EndsWith("*", StringComparison.Ordinal))
{
cache.Remove(pattern);
return;
}
var prefix = pattern.TrimEnd('*');
cache.RemoveByPrefix(prefix);
}
}
@@ -0,0 +1,39 @@
using Microsoft.Extensions.DependencyInjection;
namespace Sharp.Cache.DependencyInjection;
/// <summary>
/// 缓存服务注册扩展
/// </summary>
public static class CacheServiceExtensions
{
/// <summary>
/// 添加 Sharp 内存缓存服务
/// </summary>
public static IServiceCollection AddSharpCache(this IServiceCollection services)
{
// 注册 MemoryCache
services.AddMemoryCache();
// 注册 ICacheService
services.AddSingleton<ICacheService, MemoryCacheService>();
return services;
}
/// <summary>
/// 添加 Sharp 内存缓存服务(带配置)
/// </summary>
public static IServiceCollection AddSharpCache(
this IServiceCollection services,
Action<Microsoft.Extensions.Caching.Memory.MemoryCacheOptions> configure)
{
// 注册 MemoryCache 并配置
services.AddMemoryCache(configure);
// 注册 ICacheService
services.AddSingleton<ICacheService, MemoryCacheService>();
return services;
}
}
+174
View File
@@ -0,0 +1,174 @@
using System.Collections.Generic;
using Microsoft.Extensions.Caching.Memory;
namespace Sharp.Cache;
/// <summary>
/// 缓存服务接口
/// </summary>
public interface ICacheService
{
/// <summary>
/// 获取缓存值
/// </summary>
T? Get<T>(string key);
/// <summary>
/// 尝试获取缓存值
/// </summary>
bool TryGet<T>(string key, out T? value);
/// <summary>
/// 设置缓存
/// </summary>
void Set<T>(string key, T value, TimeSpan? expiration = null);
/// <summary>
/// 设置缓存(滑动过期)
/// </summary>
void SetSliding<T>(string key, T value, TimeSpan slidingExpiration);
/// <summary>
/// 移除缓存
/// </summary>
void Remove(string key);
/// <summary>
/// 清空所有缓存
/// </summary>
void Clear();
/// <summary>
/// 按前缀批量移除
/// </summary>
void RemoveByPrefix(string prefix);
/// <summary>
/// 已缓存键集合(只读)
/// </summary>
IReadOnlyCollection<string> Keys { get; }
/// <summary>
/// 检查缓存是否存在
/// </summary>
bool Exists(string key);
}
/// <summary>
/// 基于 MemoryCache 的缓存服务实现
/// </summary>
public class MemoryCacheService : ICacheService
{
private readonly IMemoryCache _cache;
private readonly HashSet<string> _keys = new(); // 跟踪所有键(用于 Clear)
private readonly object _lockObj = new();
public MemoryCacheService(IMemoryCache cache)
{
_cache = cache;
}
public T? Get<T>(string key)
{
return _cache.Get<T>(key);
}
public bool TryGet<T>(string key, out T? value)
{
return _cache.TryGetValue(key, out value);
}
public void Set<T>(string key, T value, TimeSpan? expiration = null)
{
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = expiration ?? TimeSpan.FromHours(1)
};
options.RegisterPostEvictionCallback((k, v, r, s) =>
{
lock (_lockObj)
{
_keys.Remove(k.ToString() ?? "");
}
});
_cache.Set(key, value, options);
lock (_lockObj)
{
_keys.Add(key);
}
}
public void SetSliding<T>(string key, T value, TimeSpan slidingExpiration)
{
var options = new MemoryCacheEntryOptions
{
SlidingExpiration = slidingExpiration
};
options.RegisterPostEvictionCallback((k, v, r, s) =>
{
lock (_lockObj)
{
_keys.Remove(k.ToString() ?? "");
}
});
_cache.Set(key, value, options);
lock (_lockObj)
{
_keys.Add(key);
}
}
public void Remove(string key)
{
_cache.Remove(key);
lock (_lockObj)
{
_keys.Remove(key);
}
}
public void Clear()
{
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 _);
}
}
+648
View File
@@ -0,0 +1,648 @@
# Sharp.Cache
轻量级内存缓存模块,基于 `Microsoft.Extensions.Caching.Memory`,提供简洁的缓存API。
## 📋 为什么需要 Cache?
### 场景对比
#### ❌ 不使用 Cache - 重复查询
```csharp
public class ProductService {
private readonly IRepository<Category> _categoryRepo;
// 问题:每次打开编辑对话框都查询数据库
public async Task<ProductEditViewModel> GetEditViewModelAsync(int productId) {
var product = await _repository.GetByIdAsync(productId);
// 分类数据很少变化,但每次都查询
var categories = await _categoryRepo.GetAllAsync();
return new ProductEditViewModel {
Product = product,
Categories = categories
};
}
}
```
#### ✅ 使用 Cache - 智能缓存
```csharp
public class ProductService {
private readonly IRepository<Category> _categoryRepo;
private readonly ICacheService _cache;
public async Task<ProductEditViewModel> GetEditViewModelAsync(int productId) {
var product = await _repository.GetByIdAsync(productId);
// 从缓存获取,不存在则查询并缓存30分钟
var categories = await _cache.GetOrSetAsync(
"categories:all",
() => _categoryRepo.GetAllAsync(),
TimeSpan.FromMinutes(30)
);
return new ProductEditViewModel {
Product = product,
Categories = categories
};
}
}
```
**效果**:
- 第一次:查询数据库(耗时 50ms)
- 后续访问:从内存读取(耗时 < 1ms)
- 用户体验:界面秒开,不再等待
---
## 🎯 设计目标
1. **极简API** - 一行代码搞定90%的缓存需求
2. **类型安全** - 强类型缓存,编译期检查
3. **自动过期** - 支持绝对过期和滑动过期
4. **引入即用** - 基于 .NET 内置 MemoryCache,零配置
5. **线程安全** - 完全线程安全
---
## 🚀 快速开始
### 1. 注册服务
```csharp
// App.xaml.cs
protected override void OnStartup(StartupEventArgs e) {
var services = new ServiceCollection();
// 一行注册(基于 Microsoft.Extensions.Caching.Memory)
services.AddMemoryCache();
var provider = services.BuildServiceProvider();
}
```
### 2. 注入使用
```csharp
public class WeatherService {
private readonly ICacheService _cache;
private readonly IHttpClient _httpClient;
public WeatherService(ICacheService cache, IHttpClient httpClient) {
_cache = cache;
_httpClient = httpClient;
}
public async Task<Weather> GetWeatherAsync(string city) {
// 缓存5分钟,避免频繁调用外部API
return await _cache.GetOrSetAsync(
$"weather:{city}",
() => _httpClient.GetAsync<Weather>($"/api/weather?city={city}"),
TimeSpan.FromMinutes(5)
);
}
}
```
---
## 📚 核心 API 设计
### ICacheService 接口
```csharp
public interface ICacheService {
/// <summary>
/// 获取缓存值
/// </summary>
T? Get<T>(string key);
/// <summary>
/// 尝试获取缓存值
/// </summary>
bool TryGet<T>(string key, out T? value);
/// <summary>
/// 设置缓存
/// </summary>
/// <param name="key">缓存键</param>
/// <param name="value">缓存值</param>
/// <param name="expiration">绝对过期时间(从现在开始计算)</param>
void Set<T>(string key, T value, TimeSpan? expiration = null);
/// <summary>
/// 设置缓存(滑动过期)
/// </summary>
/// <param name="key">缓存键</param>
/// <param name="value">缓存值</param>
/// <param name="slidingExpiration">滑动过期时间(最后访问后N时间过期)</param>
void SetSliding<T>(string key, T value, TimeSpan slidingExpiration);
/// <summary>
/// 移除缓存
/// </summary>
void Remove(string key);
/// <summary>
/// 清空所有缓存
/// </summary>
void Clear();
/// <summary>
/// 检查缓存是否存在
/// </summary>
bool Exists(string key);
}
```
### 扩展方法(最常用)
```csharp
public static class CacheExtensions {
/// <summary>
/// 获取缓存,不存在则执行工厂方法并缓存结果(同步)
/// </summary>
public static T GetOrSet<T>(
this ICacheService cache,
string key,
Func<T> factory,
TimeSpan? expiration = null);
/// <summary>
/// 获取缓存,不存在则执行工厂方法并缓存结果(异步)
/// </summary>
public static Task<T> GetOrSetAsync<T>(
this ICacheService cache,
string key,
Func<Task<T>> factory,
TimeSpan? expiration = null);
/// <summary>
/// 获取或设置(滑动过期)
/// </summary>
public static T GetOrSetSliding<T>(
this ICacheService cache,
string key,
Func<T> factory,
TimeSpan slidingExpiration);
/// <summary>
/// 批量移除(支持通配符)
/// </summary>
public static void RemoveByPattern(
this ICacheService cache,
string pattern); // 如 "user:*"
}
```
---
## 💡 典型使用场景
### 场景1:字典数据缓存(最常见)
```csharp
public class UserViewModel : ViewModelBase {
private readonly ICacheService _cache;
private readonly IRepository<Department> _deptRepo;
public async Task LoadDepartmentsAsync() {
// 部门数据很少变化,缓存1小时
Departments = await _cache.GetOrSetAsync(
"departments:all",
() => _deptRepo.GetAllAsync(),
TimeSpan.FromHours(1)
);
}
public void OnDepartmentUpdated() {
// 手动清除缓存,下次会重新加载
_cache.Remove("departments:all");
}
}
```
---
### 场景2:API响应缓存
```csharp
public class WeatherService {
private readonly ICacheService _cache;
private readonly IHttpClient _httpClient;
// 每个城市的天气数据缓存5分钟
public async Task<Weather> GetWeatherAsync(string city) {
return await _cache.GetOrSetAsync(
$"weather:{city}",
async () => {
var response = await _httpClient.GetAsync<WeatherApiResponse>(
$"https://api.weather.com/v1/current?city={city}");
return response.ToWeather();
},
TimeSpan.FromMinutes(5)
);
}
}
```
---
### 场景3:计算结果缓存
```csharp
public class ReportService {
private readonly ICacheService _cache;
// 月度报表计算耗时,缓存24小时
public async Task<MonthlyReport> GetMonthlyReportAsync(int year, int month) {
return await _cache.GetOrSetAsync(
$"report:monthly:{year}:{month}",
async () => {
// 复杂的统计计算
var data = await _repository.Query()
.Where(x => x.Date.Year == year && x.Date.Month == month)
.ToListAsync();
return CalculateReport(data); // 耗时操作
},
TimeSpan.FromHours(24)
);
}
// 当有新数据时,清除缓存
public void OnDataAdded(DateTime date) {
_cache.Remove($"report:monthly:{date.Year}:{date.Month}");
}
}
```
---
### 场景4:用户权限缓存
```csharp
public class AuthorizationService {
private readonly ICacheService _cache;
// 用户权限缓存(滑动过期:30分钟内无访问则过期)
public async Task<List<string>> GetUserPermissionsAsync(int userId) {
return await _cache.GetOrSetSliding(
$"user:permissions:{userId}",
async () => {
return await _repository.Query<UserPermission>()
.Where(x => x.UserId == userId)
.Select(x => x.PermissionCode)
.ToListAsync();
},
TimeSpan.FromMinutes(30) // 滑动过期
);
}
}
```
---
### 场景5:配置数据缓存
```csharp
public class SystemConfigService {
private readonly ICacheService _cache;
private readonly IConfigManager _configManager;
public T GetConfig<T>(string key) where T : class, new() {
return _cache.GetOrSet(
$"config:{key}",
() => _configManager.Get<T>(key),
TimeSpan.FromMinutes(10)
);
}
// Config模块有热重载,可以监听变化并清除缓存
public void OnConfigChanged(string key) {
_cache.Remove($"config:{key}");
}
}
```
---
## 🔧 高级功能
### 1. 缓存键约定
```csharp
// 推荐的缓存键命名规范
public static class CacheKeys {
// 格式: {模块}:{实体}:{参数}
public static string UserById(int userId) => $"user:profile:{userId}";
public static string AllDepartments => "departments:all";
public static string WeatherByCity(string city) => $"weather:{city}";
public static string ReportMonthly(int year, int month) => $"report:monthly:{year}:{month}";
}
// 使用
var user = await _cache.GetOrSetAsync(
CacheKeys.UserById(userId),
() => _userRepo.GetByIdAsync(userId),
TimeSpan.FromMinutes(10)
);
```
---
### 2. 缓存依赖(级联清除)
```csharp
public class ProductService {
// 当产品更新时,清除相关缓存
public async Task UpdateProductAsync(Product product) {
await _repository.UpdateAsync(product);
// 清除产品详情缓存
_cache.Remove($"product:{product.Id}");
// 清除产品列表缓存(可能包含此产品)
_cache.RemoveByPattern("products:list:*");
// 清除分类统计缓存
_cache.Remove($"category:stats:{product.CategoryId}");
}
}
```
---
### 3. 缓存预热
```csharp
public class CacheWarmupService {
private readonly ICacheService _cache;
// 应用启动时预热常用数据
public async Task WarmupAsync() {
// 加载所有字典数据
await _cache.GetOrSetAsync(
"departments:all",
() => _deptRepo.GetAllAsync(),
TimeSpan.FromHours(1)
);
await _cache.GetOrSetAsync(
"categories:all",
() => _categoryRepo.GetAllAsync(),
TimeSpan.FromHours(1)
);
// 加载系统配置
_cache.GetOrSet(
"system:config",
() => _configManager.Get<SystemConfig>(),
TimeSpan.FromMinutes(10)
);
}
}
// App.xaml.cs
protected override async void OnStartup(StartupEventArgs e) {
// ...
var warmup = _serviceProvider.GetRequiredService<CacheWarmupService>();
await warmup.WarmupAsync(); // 预热缓存
}
```
---
### 4. 缓存统计(可选)
```csharp
public interface ICacheStatistics {
long TotalHits { get; } // 命中次数
long TotalMisses { get; } // 未命中次数
double HitRate { get; } // 命中率
int CurrentItemCount { get; } // 当前缓存项数量
}
// 诊断用
public class DiagnosticsViewModel {
public void ShowCacheStats() {
var stats = _cache.GetStatistics();
MessageBox.Show($"缓存命中率: {stats.HitRate:P2}\n" +
$"缓存项数量: {stats.CurrentItemCount}");
}
}
```
---
## ⚙️ 配置选项
```csharp
services.AddMemoryCache(options => {
// 缓存大小限制(单位:MB)
options.SizeLimit = 100; // 限制100MB
// 压缩比例(当达到SizeLimit时,压缩到此比例)
options.CompactionPercentage = 0.25; // 压缩到25%
// 过期扫描频率
options.ExpirationScanFrequency = TimeSpan.FromMinutes(1);
});
```
---
## 🏗️ 实现方案
### 基于 Microsoft.Extensions.Caching.Memory
- **内置库**:无需额外依赖
- **高性能**:基于 ConcurrentDictionary
- **线程安全**:完全线程安全
- **自动清理**:后台定期清理过期项
### 架构设计
```
ICacheService(接口)
↓
MemoryCacheService(实现)
↓ 内部使用
Microsoft.Extensions.Caching.Memory.IMemoryCache
```
---
## 📊 性能特点
- **读取速度**:< 1ms(内存访问)
- **线程安全**:无锁并发读取
- **内存占用**:可配置上限,自动压缩
- **过期清理**:后台自动扫描
---
## 🔄 过期策略对比
### 绝对过期 (Absolute Expiration)
```csharp
// 从设置时开始,10分钟后过期(无论是否访问)
_cache.Set("key", value, TimeSpan.FromMinutes(10));
// 适用场景:
// - API响应缓存(数据有时效性)
// - 报表数据(每天更新)
// - 字典数据(定期刷新)
```
### 滑动过期 (Sliding Expiration)
```csharp
// 最后访问后30分钟无访问则过期
_cache.SetSliding("key", value, TimeSpan.FromMinutes(30));
// 适用场景:
// - 用户会话数据
// - 用户权限缓存
// - 热点数据(频繁访问的数据)
```
### 组合使用
```csharp
// 滑动30分钟,但最多缓存2小时
services.AddMemoryCache();
_cache.Set("key", value, new MemoryCacheEntryOptions {
SlidingExpiration = TimeSpan.FromMinutes(30),
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2)
});
```
---
## 💾 缓存大小管理
### 设置项大小
```csharp
_cache.Set("large-data", value, new MemoryCacheEntryOptions {
Size = 1, // 占用1个单位(Size单位由SizeLimit定义)
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
```
### 优先级
```csharp
// 低优先级:内存不足时优先清除
_cache.Set("low-priority", value, new MemoryCacheEntryOptions {
Priority = CacheItemPriority.Low
});
// 高优先级:内存不足时最后清除
_cache.Set("high-priority", value, new MemoryCacheEntryOptions {
Priority = CacheItemPriority.High
});
// 永不移除(谨慎使用)
_cache.Set("never-remove", value, new MemoryCacheEntryOptions {
Priority = CacheItemPriority.NeverRemove
});
```
---
## 📝 最佳实践
### 1. 缓存键命名规范
```csharp
✅ 好的命名
"user:profile:123"
"product:details:456"
"report:monthly:2024:12"
❌ 不好的命名
"user123"
"prod"
"report1"
```
### 2. 合理设置过期时间
```csharp
// 根据数据变化频率设置
字典数据:1小时 - 1天
用户信息:10分钟 - 30分钟
API响应:5分钟 - 15分钟
统计数据:1小时 - 24小时
```
### 3. 避免缓存过大对象
```csharp
❌ 不推荐:缓存整个DataTable
_cache.Set("all-products", largeDataTable);
✅ 推荐:缓存DTO列表
_cache.Set("all-products", productDtos);
```
### 4. 及时清除失效缓存
```csharp
public async Task UpdateUserAsync(User user) {
await _repository.UpdateAsync(user);
// 立即清除缓存
_cache.Remove($"user:profile:{user.Id}");
}
```
### 5. 使用Result<T>模式
```csharp
public async Task<Result<Product>> GetProductAsync(int id) {
return await _cache.GetOrSetAsync(
$"product:{id}",
async () => {
var product = await _repository.GetByIdAsync(id);
return product != null
? Result<Product>.Success(product)
: Result<Product>.Failure("产品不存在");
},
TimeSpan.FromMinutes(10)
);
}
```
---
## 🚀 未来扩展
### 可能的扩展方向
1. **分布式缓存**(基于 Redis)
2. **多级缓存**(L1: Memory, L2: Redis)
3. **缓存预加载**(后台任务)
4. **缓存同步**(多实例间同步)
---
## 📖 参考资料
- [Microsoft.Extensions.Caching.Memory](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/memory) - 官方文档
- [IMemoryCache](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.caching.memory.imemorycache) - API文档
- [缓存最佳实践](https://docs.microsoft.com/en-us/azure/architecture/best-practices/caching) - Azure架构指南
---
**状态**:已实现(内存缓存)
**当前实现**:IMemoryCache 封装 + 绝对/滑动过期 + 前缀移除
-16
View File
@@ -1,16 +0,0 @@
<Application x:Class="Client.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml" />
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/Theme.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
-100
View File
@@ -1,100 +0,0 @@
using System;
using System.Windows;
using Client.Services;
using Client.ViewModels;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Common;
using Config;
using NetClient.DependencyInjection;
using Sharp.Infrastructure.Configuration;
using Sharp.Log.Extensions;
using Sharp.NetServer.DependencyInjection;
using Sharp.Wpf.Exceptions;
using Sharp.Wpf.Services;
namespace Client
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
private ServiceProvider? _serviceProvider;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// 1) Build configuration manager from appsettings.json (+ reload on change + env vars)
var cm = ConfigBuilder.BuildFromFiles(new[] { "appsettings.json" }, reloadOnChange: true, includeEnvironment: true, basePath: AppContext.BaseDirectory);
// 2) Build DI container
var services = new ServiceCollection();
// ConfigManager and typed SystemConfig accessor
services.AddSingleton(cm);
services.TryAddSingleton<IConfiguration>(cm.Configuration); // Register IConfiguration for BindConfiguration
services.AddSystemConfig(cm);
// Logging (binds from section "SharpLog")
services.AddLogging(b => b.AddSharpLog());
// Net modules from configuration
services.AddNetClient(cm);
services.AddNetServer(cm);
// WPF exception handler
services.AddSingleton<IUiExceptionHandler, UiExceptionHandler>();
// WPF app services
services.AddSingleton<IDialogService, DialogService>();
services.AddSingleton<IAuthenticationService, DemoAuthenticationService>();
services.AddSingleton<IUserSession, UserSession>();
services.AddTransient<LoginViewModel>();
services.AddTransient<MainViewModel>();
services.AddTransient<LoginWindow>();
services.AddTransient<MainWindow>();
_serviceProvider = services.BuildServiceProvider();
// 3) Activate global WPF exception handler
var exceptionHandler = _serviceProvider.GetRequiredService<IUiExceptionHandler>();
exceptionHandler.Register();
// 4) Register global facades for convenience
Global.RegisterSingleton(_serviceProvider);
Global.RegisterSingleton(cm);
Global.RegisterSingleton(_serviceProvider.GetRequiredService<ISystemConfigAccessor>());
// 5) Show login window first, then main shell if authenticated
var logger = _serviceProvider.GetRequiredService<ILogger<App>>();
logger.LogInformation("Showing login window");
var loginWindow = _serviceProvider.GetRequiredService<LoginWindow>();
var loginResult = loginWindow.ShowDialog();
logger.LogInformation("Login window closed with result: {LoginResult}", loginResult);
if (loginResult == true)
{
logger.LogInformation("Login successful, showing main window");
var main = _serviceProvider.GetRequiredService<MainWindow>();
main.Show();
}
else
{
logger.LogInformation("Login cancelled or failed, shutting down application");
Shutdown();
}
}
protected override void OnExit(ExitEventArgs e)
{
_serviceProvider?.Dispose();
base.OnExit(e);
}
}
}
-10
View File
@@ -1,10 +0,0 @@
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]
-26
View File
@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="HandyControl" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Wpf\Wpf.csproj" />
<ProjectReference Include="..\Config\Config.csproj" />
<ProjectReference Include="..\NetClient\NetClient.csproj" />
<ProjectReference Include="..\NetServer\NetServer.csproj" />
<ProjectReference Include="..\Log\Log.csproj" />
<ProjectReference Include="..\Sharp.Infrastructure\Sharp.Infrastructure.csproj" />
</ItemGroup>
</Project>
-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup />
</Project>
@@ -1,60 +0,0 @@
using System.Windows;
using System.Windows.Controls;
namespace Client.Helpers;
public static class PasswordBoxHelper
{
public static readonly DependencyProperty BindablePasswordProperty =
DependencyProperty.RegisterAttached(
"BindablePassword",
typeof(string),
typeof(PasswordBoxHelper),
new PropertyMetadata(null, OnBindablePasswordChanged));
private static readonly DependencyProperty IsUpdatingProperty =
DependencyProperty.RegisterAttached(
"IsUpdating",
typeof(bool),
typeof(PasswordBoxHelper),
new PropertyMetadata(false));
public static string GetBindablePassword(DependencyObject obj) => (string)obj.GetValue(BindablePasswordProperty);
public static void SetBindablePassword(DependencyObject obj, string value) => obj.SetValue(BindablePasswordProperty, value);
private static bool GetIsUpdating(DependencyObject obj) => (bool)obj.GetValue(IsUpdatingProperty);
private static void SetIsUpdating(DependencyObject obj, bool value) => obj.SetValue(IsUpdatingProperty, value);
private static void OnBindablePasswordChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not PasswordBox passwordBox)
{
return;
}
passwordBox.PasswordChanged -= HandlePasswordChanged;
if (!GetIsUpdating(passwordBox))
{
passwordBox.Password = e.NewValue?.ToString() ?? string.Empty;
}
passwordBox.PasswordChanged += HandlePasswordChanged;
}
private static void HandlePasswordChanged(object sender, RoutedEventArgs e)
{
if (sender is not PasswordBox passwordBox)
{
return;
}
passwordBox.PasswordChanged -= HandlePasswordChanged;
SetIsUpdating(passwordBox, true);
SetBindablePassword(passwordBox, passwordBox.Password);
SetIsUpdating(passwordBox, false);
passwordBox.PasswordChanged += HandlePasswordChanged;
}
}
-69
View File
@@ -1,69 +0,0 @@
<hc:GlowWindow x:Class="Client.LoginWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:hc="https://handyorg.github.io/handycontrol"
xmlns:conv="clr-namespace:Sharp.Wpf.Converters;assembly=Sharp.Wpf"
xmlns:h="clr-namespace:Client.Helpers"
mc:Ignorable="d"
Title="Sharp 客户端登录"
WindowStartupLocation="CenterScreen"
Width="420"
Height="420"
ResizeMode="NoResize">
<hc:GlowWindow.Resources>
<conv:BoolToVisibilityConverter x:Key="BoolToVisibility" />
</hc:GlowWindow.Resources>
<Grid Background="{DynamicResource RegionBrush}">
<Border CornerRadius="16" Padding="32" Background="{DynamicResource PopupBrush}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<StackPanel>
<TextBlock Text="欢迎回来"
FontSize="24"
FontWeight="Bold"
Margin="0,0,0,4" />
<TextBlock Text="请输入管理员账号信息"
Foreground="{DynamicResource SecondaryTextBrush}" />
</StackPanel>
<StackPanel Grid.Row="1" Margin="0,24,0,0">
<TextBlock Text="账号" FontWeight="SemiBold" />
<TextBox Margin="0,8,0,0"
Text="{Binding UserName, UpdateSourceTrigger=PropertyChanged}"
Height="36" />
</StackPanel>
<StackPanel Grid.Row="2" Margin="0,12,0,0">
<TextBlock Text="密码" FontWeight="SemiBold" />
<PasswordBox Margin="0,8,0,0"
h:PasswordBoxHelper.BindablePassword="{Binding Password, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Height="36" />
</StackPanel>
<TextBlock Grid.Row="3"
Margin="0,12,0,0"
Text="{Binding ErrorMessage}"
Foreground="Tomato"
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}" />
<Button Grid.Row="4"
Margin="0,24,0,0"
Height="40"
Content="登 录"
Command="{Binding LoginCommand}" />
<ProgressBar Grid.Row="5"
Height="4"
IsIndeterminate="True"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
Margin="0,24,0,0"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</Grid>
</Border>
</Grid>
</hc:GlowWindow>
-33
View File
@@ -1,33 +0,0 @@
using System;
using HandyControl.Controls;
using Client.ViewModels;
using Microsoft.Extensions.Logging;
namespace Client;
public partial class LoginWindow : GlowWindow
{
private readonly LoginViewModel _viewModel;
private readonly ILogger<LoginWindow>? _logger;
public LoginWindow(LoginViewModel viewModel, ILogger<LoginWindow>? logger = null)
{
InitializeComponent();
_viewModel = viewModel;
_logger = logger;
DataContext = viewModel;
_viewModel.LoginSucceeded += HandleLoginSucceeded;
}
private void HandleLoginSucceeded(object? sender, EventArgs e)
{
_logger?.LogInformation("HandleLoginSucceeded called, setting DialogResult = true");
DialogResult = true;
}
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
_viewModel.LoginSucceeded -= HandleLoginSucceeded;
}
}
-110
View File
@@ -1,110 +0,0 @@
<hc:GlowWindow x:Class="Client.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:hc="https://handyorg.github.io/handycontrol"
xmlns:w="clr-namespace:Sharp.Wpf.Windowing;assembly=Sharp.Wpf"
xmlns:conv="clr-namespace:Sharp.Wpf.Converters;assembly=Sharp.Wpf"
mc:Ignorable="d"
Title="Sharp 控制台"
Height="560"
Width="960"
WindowStartupLocation="CenterScreen"
w:WindowPlacement.RememberPlacement="True"
w:WindowPlacement.PlacementId="MainWindow">
<hc:GlowWindow.Resources>
<conv:BoolToVisibilityConverter x:Key="Bool2Vis" />
</hc:GlowWindow.Resources>
<Grid Background="{DynamicResource RegionBrush}">
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Background="{DynamicResource PopupBrush}" Padding="24" CornerRadius="16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="{Binding Greeting}" FontSize="26" FontWeight="Bold" />
<TextBlock Text="{Binding StatusMessage}" Margin="0,6,0,0" Foreground="{DynamicResource SecondaryTextBrush}" />
<TextBlock Text="最后同步: " Margin="0,12,0,0" Foreground="{DynamicResource SecondaryTextBrush}">
<Run Text="{Binding LastSyncTime, StringFormat=yyyy-MM-dd HH:mm:ss}" />
</TextBlock>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Content="刷新仪表盘"
Padding="18,6"
Margin="0,0,12,0"
Command="{Binding RefreshDashboardCommand}" />
<Button Content="演示弹窗"
Padding="18,6"
Margin="0,0,12,0"
Command="{Binding ShowDialogCommand}" />
<Button Content="触发异常"
Padding="18,6"
Command="{Binding ThrowCommand}" />
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="1" Margin="0,24,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<UniformGrid Columns="2" Margin="0,0,16,0">
<Border Background="{DynamicResource PopupBrush}" Margin="0,0,16,16" Padding="20" CornerRadius="12">
<StackPanel>
<TextBlock Text="登录用户" FontWeight="SemiBold" />
<TextBlock Text="{Binding UserDisplayName}" FontSize="20" Margin="0,8,0,0" />
<TextBlock Text="当前会话已建立"
Foreground="{DynamicResource SecondaryTextBrush}" />
</StackPanel>
</Border>
<Border Background="{DynamicResource PopupBrush}" Margin="0,0,16,16" Padding="20" CornerRadius="12">
<StackPanel>
<TextBlock Text="系统健康度" FontWeight="SemiBold" />
<TextBlock Text="{Binding StatusMessage}" Margin="0,8,0,0" />
<ProgressBar Height="6" Margin="0,12,0,0"
IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource Bool2Vis}}" />
</StackPanel>
</Border>
<Border Background="{DynamicResource PopupBrush}" Margin="0,0,16,16" Padding="20" CornerRadius="12">
<StackPanel>
<TextBlock Text="上次操作" FontWeight="SemiBold" />
<TextBlock Text="{Binding Greeting}" Margin="0,8,0,0" TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Background="{DynamicResource PopupBrush}" Margin="0,0,16,16" Padding="20" CornerRadius="12">
<StackPanel>
<TextBlock Text="提示" FontWeight="SemiBold" />
<TextBlock Text="使用 HandyControl 让界面更柔和" Margin="0,8,0,0" />
</StackPanel>
</Border>
</UniformGrid>
<Border Grid.Column="1" Background="{DynamicResource PopupBrush}" Padding="24" CornerRadius="12">
<StackPanel>
<TextBlock Text="操作日志" FontSize="18" FontWeight="SemiBold" />
<TextBlock Text="演示命令" Foreground="{DynamicResource SecondaryTextBrush}" Margin="0,8,0,16" />
<ItemsControl>
<ItemsControl.Items>
<TextBlock Text="• 刷新仪表盘会模拟异步调用" Margin="0,4" />
<TextBlock Text="• 演示弹窗来自 Sharp.Wpf DialogService" Margin="0,4" />
<TextBlock Text="• 触发异常会被全局处理" Margin="0,4" />
<TextBlock Text="• 登录信息通过依赖注入管理" Margin="0,4" />
</ItemsControl.Items>
</ItemsControl>
</StackPanel>
</Border>
</Grid>
</Grid>
</Grid>
</hc:GlowWindow>
-13
View File
@@ -1,13 +0,0 @@
using HandyControl.Controls;
using Client.ViewModels;
namespace Client;
public partial class MainWindow : GlowWindow
{
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
@@ -1,32 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Client.Services;
public interface IAuthenticationService
{
Task<AuthenticationResult> LoginAsync(string userName, string password, CancellationToken cancellationToken = default);
}
public sealed record AuthenticationResult(bool Succeeded, string? Error, string? DisplayName);
public sealed class DemoAuthenticationService : IAuthenticationService
{
public async Task<AuthenticationResult> LoginAsync(string userName, string password, CancellationToken cancellationToken = default)
{
await Task.Delay(600, cancellationToken);
if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(password))
{
return new AuthenticationResult(false, "请输入账号和密码", null);
}
if (!string.Equals(userName, "admin", StringComparison.OrdinalIgnoreCase) || password != "123456")
{
return new AuthenticationResult(false, "账号或密码不正确 (admin / 123456)", null);
}
return new AuthenticationResult(true, null, "Sharp 管理员");
}
}
-31
View File
@@ -1,31 +0,0 @@
namespace Client.Services;
public interface IUserSession
{
bool IsAuthenticated { get; }
string? UserName { get; }
string? DisplayName { get; }
void SignIn(string userName, string? displayName);
void SignOut();
}
public sealed class UserSession : IUserSession
{
public bool IsAuthenticated { get; private set; }
public string? UserName { get; private set; }
public string? DisplayName { get; private set; }
public void SignIn(string userName, string? displayName)
{
IsAuthenticated = true;
UserName = userName;
DisplayName = string.IsNullOrWhiteSpace(displayName) ? userName : displayName;
}
public void SignOut()
{
IsAuthenticated = false;
UserName = null;
DisplayName = null;
}
}
@@ -1,101 +0,0 @@
using System;
using System.Threading.Tasks;
using Client.Services;
using Microsoft.Extensions.Logging;
using Sharp.Wpf.Commands;
using Sharp.Wpf.Mvvm;
namespace Client.ViewModels;
public sealed class LoginViewModel : ViewModelBase
{
private readonly IAuthenticationService _authenticationService;
private readonly IUserSession _userSession;
private readonly ILogger<LoginViewModel> _logger;
private readonly IAsyncCommand _loginCommand;
private string _userName = "admin";
private string _password = string.Empty;
private string? _errorMessage;
public event EventHandler? LoginSucceeded;
public LoginViewModel(IAuthenticationService authenticationService, IUserSession userSession, ILogger<LoginViewModel> logger)
{
_authenticationService = authenticationService;
_userSession = userSession;
_logger = logger;
_loginCommand = new AsyncCommand(LoginAsync);
}
public string UserName
{
get => _userName;
set
{
if (SetProperty(ref _userName, value))
{
}
}
}
public string Password
{
get => _password;
set
{
if (SetProperty(ref _password, value))
{
}
}
}
public string? ErrorMessage
{
get => _errorMessage;
private set
{
if (SetProperty(ref _errorMessage, value))
{
OnPropertyChanged(nameof(HasError));
}
}
}
public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage);
public IAsyncCommand LoginCommand => _loginCommand;
private async Task LoginAsync()
{
try
{
_logger.LogInformation("Login attempt for user: {UserName}", UserName);
IsBusy = true;
ErrorMessage = null;
var result = await _authenticationService.LoginAsync(UserName, Password);
if (!result.Succeeded)
{
_logger.LogWarning("Login failed for user: {UserName}, Error: {Error}", UserName, result.Error);
ErrorMessage = result.Error;
return;
}
_logger.LogInformation("Login succeeded for user: {UserName}", UserName);
_userSession.SignIn(UserName, result.DisplayName);
_logger.LogInformation("Invoking LoginSucceeded event");
LoginSucceeded?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Login failed for user: {UserName}", UserName);
ErrorMessage = "An unexpected error occurred.";
}
finally
{
IsBusy = false;
}
}
}
@@ -1,86 +0,0 @@
using System;
using System.Threading.Tasks;
using System.Windows.Input;
using Client.Services;
using Sharp.Wpf.Commands;
using Sharp.Wpf.Mvvm;
using Sharp.Wpf.Services;
namespace Client.ViewModels;
public sealed class MainViewModel : ViewModelBase
{
private readonly IDialogService _dialogService;
private readonly IUserSession _userSession;
private string _greeting = "欢迎使用 Sharp";
private string _statusMessage = "系统就绪";
private DateTime _lastSyncTime = DateTime.Now;
public MainViewModel(IDialogService dialogService, IUserSession userSession)
{
_dialogService = dialogService;
_userSession = userSession;
_greeting = $"欢迎回来,{UserDisplayName}";
_statusMessage = "准备就绪";
ShowDialogCommand = new AsyncCommand(ShowDialogAsync);
RefreshDashboardCommand = new AsyncCommand(RefreshDashboardAsync);
ThrowCommand = new RelayCommand(() => throw new InvalidOperationException("演示:未处理异常将被全局处理"));
}
public string UserDisplayName => _userSession.DisplayName ?? _userSession.UserName ?? "访客";
public string Greeting
{
get => _greeting;
set => SetProperty(ref _greeting, value);
}
public string StatusMessage
{
get => _statusMessage;
set => SetProperty(ref _statusMessage, value);
}
public DateTime LastSyncTime
{
get => _lastSyncTime;
set => SetProperty(ref _lastSyncTime, value);
}
public IAsyncCommand ShowDialogCommand { get; }
public IAsyncCommand RefreshDashboardCommand { get; }
public ICommand ThrowCommand { get; }
private async Task ShowDialogAsync()
{
try
{
IsBusy = true;
await Task.Delay(300).ConfigureAwait(false);
await _dialogService.ShowInfoAsync($"Hi {UserDisplayName}, 这是来自 Sharp.Wpf 的提醒!").ConfigureAwait(false);
Greeting = $"问候更新时间: {DateTime.Now:HH:mm:ss}";
}
finally
{
IsBusy = false;
}
}
private async Task RefreshDashboardAsync()
{
try
{
IsBusy = true;
StatusMessage = "正在同步仪表盘数据...";
await Task.Delay(600).ConfigureAwait(false);
LastSyncTime = DateTime.Now;
StatusMessage = "同步完成";
}
finally
{
IsBusy = false;
}
}
}
@@ -1,77 +0,0 @@
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "A01D87CBC408FE1E9934F8938867D92F17E3A2CF"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Controls.Ribbon;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Forms.Integration;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Media.TextFormatting;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Shell;
namespace Client {
/// <summary>
/// App
/// </summary>
public partial class App : System.Windows.Application {
private bool _contentLoaded;
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
System.Uri resourceLocater = new System.Uri("/Client;component/app.xaml", System.UriKind.Relative);
#line 1 "..\..\..\App.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
/// <summary>
/// Application Entry Point.
/// </summary>
[System.STAThreadAttribute()]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
public static void Main() {
Client.App app = new Client.App();
app.InitializeComponent();
app.Run();
}
}
}
@@ -1,25 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码由工具生成。
// 运行时版本:4.0.30319.42000
//
// 对此文件的更改可能会导致不正确的行为,并且如果
// 重新生成代码,这些更改将会丢失。
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Client")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+f7660c49e81d5fe2747f6514545ceb961c2066d3")]
[assembly: System.Reflection.AssemblyProductAttribute("Client")]
[assembly: System.Reflection.AssemblyTitleAttribute("Client")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
// 由 MSBuild WriteCodeFragment 类生成。
@@ -1 +0,0 @@
eeea53cf7db257ee716efdd2fdcdb5f1a810b49927811d410a05e1ff19899369
@@ -1,16 +0,0 @@
is_global = true
build_property.TargetFramework = net8.0-windows
build_property.TargetPlatformMinVersion = 7.0
build_property.UsingMicrosoftNETSdkWeb =
build_property.ProjectTypeGuids =
build_property.InvariantGlobalization =
build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = Client
build_property.ProjectDir = D:\yuanll\sharp-basic\src\Sharp\Client\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false
build_property.EffectiveAnalysisLevelStyle = 8.0
build_property.EnableCodeStyleSeverity =
@@ -1,89 +0,0 @@
#pragma checksum "..\..\..\MainWindow.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "9E813BA3492F961B4CE0E3DE4E78408D972934B6"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using HandyControl.Controls;
using HandyControl.Data;
using HandyControl.Expression.Media;
using HandyControl.Expression.Shapes;
using HandyControl.Interactivity;
using HandyControl.Media.Animation;
using HandyControl.Media.Effects;
using HandyControl.Properties.Langs;
using HandyControl.Themes;
using HandyControl.Tools;
using HandyControl.Tools.Converter;
using HandyControl.Tools.Extension;
using Sharp.Wpf.Converters;
using Sharp.Wpf.Windowing;
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Controls.Ribbon;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Forms.Integration;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Media.TextFormatting;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Shell;
namespace Client {
/// <summary>
/// MainWindow
/// </summary>
public partial class MainWindow : HandyControl.Controls.GlowWindow, System.Windows.Markup.IComponentConnector {
private bool _contentLoaded;
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
System.Uri resourceLocater = new System.Uri("/Client;component/mainwindow.xaml", System.UriKind.Relative);
#line 1 "..\..\..\MainWindow.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1800:DoNotCastUnnecessarily")]
void System.Windows.Markup.IComponentConnector.Connect(int connectionId, object target) {
this._contentLoaded = true;
}
}
}
+18 -5
View File
@@ -1,9 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup>
<!-- 程序集元数据 -->
<Description>Sharp Framework 核心库 - 提供 Result&lt;T&gt; 模式、Global 容器、系统配置等基础功能</Description>
<!-- 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>
</Project> </Project>
@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Common.Configuration;
/// <summary>
/// 系统级通用配置
/// 包含 HTTP 访问所需的基础字段,可被 NetClient/NetServer 等模块复用
/// 约定配置节名称:System
/// </summary>
public sealed class SystemConfig
{
[Required]
[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, 600)] public int TimeoutSeconds { get; set; } = 30;
public string Scheme { get; set; } = "http";
}
@@ -0,0 +1,48 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Common.DependencyInjection;
/// <summary>
/// 快速启动扩展,提供"零配置"的便捷注册方法
/// </summary>
public static class QuickStartExtensions
{
/// <summary>
/// 添加 Sharp 框架的所有基础服务(一行搞定)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="configuration">配置(可选,不传则自动创建默认配置)</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddSharpEssentials(
this IServiceCollection services,
IConfiguration? configuration = null)
{
// 1. 配置管理(如果没传就用默认)
// Note: ConfigBuilder.CreateDefault() 在 Config 模块中
// 这里假设用户已经传入 configuration 或在外部创建
if (configuration != null) services.AddSingleton(configuration);
// 2. 缓存服务(使用 Cache 模块的扩展方法)
// Note: AddSharpCache() 在 Cache.DependencyInjection 中定义
services.AddMemoryCache();
// 3. 全局服务定位器(兼容性)
if (configuration != null) RegisterGlobalServices(services, configuration);
return services;
}
/// <summary>
/// 注册到 Global 容器(向后兼容)
/// </summary>
private static void RegisterGlobalServices(IServiceCollection services, IConfiguration configuration)
{
// 构建临时 ServiceProvider
var provider = services.BuildServiceProvider();
// 注册到 Global
Global.RegisterSingleton(configuration);
Global.RegisterSingleton(provider);
}
}
@@ -34,5 +34,3 @@ public class ExceptionExpand : Exception
return $"Error Code: {ErrorCode}\nMessage: {Message}\nSuggestion: {Suggestion}\n{base.ToString()}"; return $"Error Code: {ErrorCode}\nMessage: {Message}\nSuggestion: {Suggestion}\n{base.ToString()}";
} }
} }
+10 -37
View File
@@ -171,26 +171,17 @@ public static class CollectionExpand
var nodes = list.Select(item => new TreeNode<T>(item, idSelector(item), parentIdSelector(item))).ToList(); var nodes = list.Select(item => new TreeNode<T>(item, idSelector(item), parentIdSelector(item))).ToList();
var lookup = nodes.ToLookup(n => n.ParentId); var lookup = nodes.ToLookup(n => n.ParentId);
foreach (var node in nodes) foreach (var node in nodes) node.Children = lookup[node.Id].ToList();
{
node.Children = lookup[node.Id].ToList();
}
// 构建层级深度 // 构建层级深度
void SetLevel(TreeNode<T> node, int level) void SetLevel(TreeNode<T> node, int level)
{ {
node.Level = level; node.Level = level;
foreach (var child in node.Children) foreach (var child in node.Children) SetLevel(child, level + 1);
{
SetLevel(child, level + 1);
}
} }
var rootNodes = nodes.Where(n => Equals(n.ParentId, rootParentId)).ToList(); var rootNodes = nodes.Where(n => Equals(n.ParentId, rootParentId)).ToList();
foreach (var root in rootNodes) foreach (var root in rootNodes) SetLevel(root, 0);
{
SetLevel(root, 0);
}
return rootNodes; return rootNodes;
} }
@@ -210,10 +201,7 @@ public static class CollectionExpand
foreach (var node in nodes) foreach (var node in nodes)
{ {
result.Add(node.Data); result.Add(node.Data);
if (node.Children.Any()) if (node.Children.Any()) Traverse(node.Children);
{
Traverse(node.Children);
}
} }
} }
@@ -231,10 +219,7 @@ public static class CollectionExpand
foreach (var node in nodes) foreach (var node in nodes)
{ {
action(node); action(node);
if (node.Children.Any()) if (node.Children.Any()) Traverse(node.Children);
{
Traverse(node.Children);
}
} }
} }
@@ -270,10 +255,7 @@ public static class CollectionExpand
} }
} }
if (batch.Count > 0) if (batch.Count > 0) yield return batch;
{
yield return batch;
}
} }
/// <summary> /// <summary>
@@ -281,10 +263,7 @@ public static class CollectionExpand
/// </summary> /// </summary>
public static void ForEachBatch<T>(this IEnumerable<T> source, int batchSize, Action<IEnumerable<T>> action) public static void ForEachBatch<T>(this IEnumerable<T> source, int batchSize, Action<IEnumerable<T>> action)
{ {
foreach (var batch in source.Batch(batchSize)) foreach (var batch in source.Batch(batchSize)) action(batch);
{
action(batch);
}
} }
#endregion #endregion
@@ -333,10 +312,7 @@ public static class CollectionExpand
/// </summary> /// </summary>
public static void ForEach<T>(this IEnumerable<T> source, Action<T> action) public static void ForEach<T>(this IEnumerable<T> source, Action<T> action)
{ {
foreach (var item in source) foreach (var item in source) action(item);
{
action(item);
}
} }
/// <summary> /// <summary>
@@ -344,11 +320,8 @@ public static class CollectionExpand
/// </summary> /// </summary>
public static void ForEach<T>(this IEnumerable<T> source, Action<T, int> action) public static void ForEach<T>(this IEnumerable<T> source, Action<T, int> action)
{ {
int index = 0; var index = 0;
foreach (var item in source) foreach (var item in source) action(item, index++);
{
action(item, index++);
}
} }
/// <summary> /// <summary>
+12 -21
View File
@@ -238,7 +238,7 @@ public static class DateTimeExpand
/// </summary> /// </summary>
public static DateTime StartOfWeek(this DateTime dt, DayOfWeek firstDayOfWeek = DayOfWeek.Monday) public static DateTime StartOfWeek(this DateTime dt, DayOfWeek firstDayOfWeek = DayOfWeek.Monday)
{ {
int diff = (7 + (dt.DayOfWeek - firstDayOfWeek)) % 7; var diff = (7 + (dt.DayOfWeek - firstDayOfWeek)) % 7;
return dt.AddDays(-diff).Date; return dt.AddDays(-diff).Date;
} }
@@ -255,17 +255,14 @@ public static class DateTimeExpand
/// </summary> /// </summary>
public static DateTime AddWorkDays(this DateTime dt, int workDays) public static DateTime AddWorkDays(this DateTime dt, int workDays)
{ {
int direction = workDays < 0 ? -1 : 1; var direction = workDays < 0 ? -1 : 1;
int daysToAdd = Math.Abs(workDays); var daysToAdd = Math.Abs(workDays);
DateTime result = dt; var result = dt;
while (daysToAdd > 0) while (daysToAdd > 0)
{ {
result = result.AddDays(direction); result = result.AddDays(direction);
if (result.DayOfWeek != DayOfWeek.Saturday && result.DayOfWeek != DayOfWeek.Sunday) if (result.DayOfWeek != DayOfWeek.Saturday && result.DayOfWeek != DayOfWeek.Sunday) daysToAdd--;
{
daysToAdd--;
}
} }
return result; return result;
@@ -279,14 +276,10 @@ public static class DateTimeExpand
if (startDate > endDate) if (startDate > endDate)
(startDate, endDate) = (endDate, startDate); (startDate, endDate) = (endDate, startDate);
int workDays = 0; var workDays = 0;
for (DateTime date = startDate; date <= endDate; date = date.AddDays(1)) for (var date = startDate; date <= endDate; date = date.AddDays(1))
{
if (date.DayOfWeek != DayOfWeek.Saturday && date.DayOfWeek != DayOfWeek.Sunday) if (date.DayOfWeek != DayOfWeek.Saturday && date.DayOfWeek != DayOfWeek.Sunday)
{
workDays++; workDays++;
}
}
return workDays; return workDays;
} }
@@ -297,12 +290,10 @@ public static class DateTimeExpand
public static int CalculateAge(this DateTime birthDate, DateTime? referenceDate = null) public static int CalculateAge(this DateTime birthDate, DateTime? referenceDate = null)
{ {
var reference = referenceDate ?? DateTime.Today; var reference = referenceDate ?? DateTime.Today;
int age = reference.Year - birthDate.Year; var age = reference.Year - birthDate.Year;
if (reference.Month < birthDate.Month || (reference.Month == birthDate.Month && reference.Day < birthDate.Day)) if (reference.Month < birthDate.Month ||
{ (reference.Month == birthDate.Month && reference.Day < birthDate.Day)) age--;
age--;
}
return age; return age;
} }
@@ -320,8 +311,8 @@ public static class DateTimeExpand
/// </summary> /// </summary>
public static DateTime StartOfQuarter(this DateTime dt) public static DateTime StartOfQuarter(this DateTime dt)
{ {
int quarter = dt.GetQuarter(); var quarter = dt.GetQuarter();
int month = (quarter - 1) * 3 + 1; var month = (quarter - 1) * 3 + 1;
return new DateTime(dt.Year, month, 1, 0, 0, 0, dt.Kind); return new DateTime(dt.Year, month, 1, 0, 0, 0, dt.Kind);
} }
+13 -24
View File
@@ -277,10 +277,7 @@ public static class StringExpand
if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(trimString)) if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(trimString))
return str ?? string.Empty; return str ?? string.Empty;
while (str.StartsWith(trimString)) while (str.StartsWith(trimString)) str = str.Substring(trimString.Length);
{
str = str.Substring(trimString.Length);
}
return str; return str;
} }
@@ -293,10 +290,7 @@ public static class StringExpand
if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(trimString)) if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(trimString))
return str ?? string.Empty; return str ?? string.Empty;
while (str.EndsWith(trimString)) while (str.EndsWith(trimString)) str = str.Substring(0, str.Length - trimString.Length);
{
str = str.Substring(0, str.Length - trimString.Length);
}
return str; return str;
} }
@@ -344,7 +338,7 @@ public static class StringExpand
var result = new StringBuilder(); var result = new StringBuilder();
result.Append(words[0].ToLowerInvariant()); result.Append(words[0].ToLowerInvariant());
for (int i = 1; i < words.Length; i++) for (var i = 1; i < words.Length; i++)
{ {
result.Append(char.ToUpperInvariant(words[i][0])); result.Append(char.ToUpperInvariant(words[i][0]));
if (words[i].Length > 1) if (words[i].Length > 1)
@@ -620,10 +614,7 @@ public static class StringExpand
return string.Empty; return string.Empty;
var sb = new StringBuilder(str.Length * count); var sb = new StringBuilder(str.Length * count);
for (int i = 0; i < count; i++) for (var i = 0; i < count; i++) sb.Append(str);
{
sb.Append(str);
}
return sb.ToString(); return sb.ToString();
} }
@@ -686,23 +677,21 @@ public static class StringExpand
if (string.IsNullOrEmpty(target)) if (string.IsNullOrEmpty(target))
return source.Length; return source.Length;
int[,] distance = new int[source.Length + 1, target.Length + 1]; var distance = new int[source.Length + 1, target.Length + 1];
for (int i = 0; i <= source.Length; i++) for (var i = 0; i <= source.Length; i++)
distance[i, 0] = i; distance[i, 0] = i;
for (int j = 0; j <= target.Length; j++) for (var j = 0; j <= target.Length; j++)
distance[0, j] = j; distance[0, j] = j;
for (int i = 1; i <= source.Length; i++) for (var i = 1; i <= source.Length; i++)
for (var j = 1; j <= target.Length; j++)
{ {
for (int j = 1; j <= target.Length; j++) var cost = target[j - 1] == source[i - 1] ? 0 : 1;
{ distance[i, j] = Math.Min(
int cost = (target[j - 1] == source[i - 1]) ? 0 : 1; Math.Min(distance[i - 1, j] + 1, distance[i, j - 1] + 1),
distance[i, j] = Math.Min( distance[i - 1, j - 1] + cost);
Math.Min(distance[i - 1, j] + 1, distance[i, j - 1] + 1),
distance[i - 1, j - 1] + cost);
}
} }
return distance[source.Length, target.Length]; return distance[source.Length, target.Length];
+7 -20
View File
@@ -55,7 +55,11 @@ public class ServiceChangedEventArgs<T> : EventArgs
/// - 生命周期管理(Singleton/Transient) /// - 生命周期管理(Singleton/Transient)
/// - 服务变更通知机制 /// - 服务变更通知机制
/// - 支持工厂方法注册 /// - 支持工厂方法注册
///
/// ⚠️ 注意:此类已标记为非首选使用,建议新项目使用 Microsoft.Extensions.DependencyInjection。
/// 此类仅用于向后兼容和快速原型开发。
/// </remarks> /// </remarks>
[Obsolete("不推荐使用Global容器。建议使用Microsoft.Extensions.DependencyInjection作为首选DI方案。此类保留用于向后兼容和快速原型开发。", false)]
public static class Global public static class Global
{ {
private class ServiceDescriptor private class ServiceDescriptor
@@ -117,10 +121,7 @@ public static class Global
_services[typeof(T)] = descriptor; _services[typeof(T)] = descriptor;
// 如果是更新且需要通知,触发变更事件 // 如果是更新且需要通知,触发变更事件
if (isUpdate && notifyIfExists) if (isUpdate && notifyIfExists) NotifyChanged(oldValue, instance);
{
NotifyChanged(oldValue, instance);
}
} }
/// <summary> /// <summary>
@@ -233,15 +234,10 @@ public static class Global
{ {
// 双重检查锁定模式,保证单例只创建一次 // 双重检查锁定模式,保证单例只创建一次
if (descriptor.SingletonInstance == null) if (descriptor.SingletonInstance == null)
{
lock (descriptor.Lock) lock (descriptor.Lock)
{ {
if (descriptor.SingletonInstance == null) if (descriptor.SingletonInstance == null) descriptor.SingletonInstance = descriptor.Factory();
{
descriptor.SingletonInstance = descriptor.Factory();
}
} }
}
return descriptor.SingletonInstance; return descriptor.SingletonInstance;
} }
@@ -322,10 +318,7 @@ public static class Global
lock (lockObj) lock (lockObj)
{ {
if (_subscribers.TryGetValue(type, out var subscribers)) if (_subscribers.TryGetValue(type, out var subscribers)) subscribers.Remove(handler);
{
subscribers.Remove(handler);
}
} }
} }
@@ -340,20 +333,15 @@ public static class Global
// 获取订阅者列表的快照,避免在通知过程中锁定 // 获取订阅者列表的快照,避免在通知过程中锁定
List<Delegate> subscribersCopy; List<Delegate> subscribersCopy;
if (_subscriberLocks.TryGetValue(type, out var lockObj)) if (_subscriberLocks.TryGetValue(type, out var lockObj))
{
lock (lockObj) lock (lockObj)
{ {
subscribersCopy = new List<Delegate>(subscribers); subscribersCopy = new List<Delegate>(subscribers);
} }
}
else else
{
subscribersCopy = new List<Delegate>(subscribers); subscribersCopy = new List<Delegate>(subscribers);
}
// 通知所有订阅者 // 通知所有订阅者
foreach (var subscriber in subscribersCopy) foreach (var subscriber in subscribersCopy)
{
try try
{ {
(subscriber as Action<ServiceChangedEventArgs<T>>)?.Invoke(eventArgs); (subscriber as Action<ServiceChangedEventArgs<T>>)?.Invoke(eventArgs);
@@ -363,7 +351,6 @@ public static class Global
// 忽略订阅者抛出的异常,避免影响其他订阅者 // 忽略订阅者抛出的异常,避免影响其他订阅者
// 在实际应用中,可以记录日志 // 在实际应用中,可以记录日志
} }
}
} }
#endregion #endregion
+16 -15
View File
@@ -749,33 +749,33 @@ dotnet test
### DI 容器 ### DI 容器
1. **生命周期选择** 1. **生命周期选择**
- 使用 **Singleton** 用于:配置对象、数据库连接池、缓存服务 - 使用 **Singleton** 用于:配置对象、数据库连接池、缓存服务
- 使用 **Transient** 用于:请求上下文、操作上下文 - 使用 **Transient** 用于:请求上下文、操作上下文
2. **变更通知** 2. **变更通知**
- 订阅者应该是轻量级的,避免长时间阻塞 - 订阅者应该是轻量级的,避免长时间阻塞
- 订阅者中不要抛出异常(已被容器捕获和忽略) - 订阅者中不要抛出异常(已被容器捕获和忽略)
3. **线程安全** 3. **线程安全**
- 容器本身是线程安全的 - 容器本身是线程安全的
- 单例服务的初始化是线程安全的(双重检查锁定) - 单例服务的初始化是线程安全的(双重检查锁定)
### Result 模式 ### Result 模式
1. **何时使用 Result** 1. **何时使用 Result**
- 可预期的业务错误(验证失败、资源不存在) - 可预期的业务错误(验证失败、资源不存在)
- 外部 API 调用 - 外部 API 调用
- 需要传递错误码的场景 - 需要传递错误码的场景
2. **何时使用异常** 2. **何时使用异常**
- 不可预期的程序错误(NullReferenceException、OutOfMemoryException) - 不可预期的程序错误(NullReferenceException、OutOfMemoryException)
- 系统级错误 - 系统级错误
3. **链式操作** 3. **链式操作**
- 使用 `Map` 转换成功值 - 使用 `Map` 转换成功值
- 使用 `Bind` 链接返回 Result 的操作 - 使用 `Bind` 链接返回 Result 的操作
- 使用 `Ensure` 进行验证 - 使用 `Ensure` 进行验证
- 失败会自动传播,不需要手动检查 - 失败会自动传播,不需要手动检查
## 项目结构 ## 项目结构
@@ -812,6 +812,7 @@ Sharp/Common/
## 版本历史 ## 版本历史
### v1.0.0 ### v1.0.0
- ✅ 轻量级 DI 容器(支持生命周期和变更通知) - ✅ 轻量级 DI 容器(支持生命周期和变更通知)
- ✅ Result 模式(完整的函数式错误处理) - ✅ Result 模式(完整的函数式错误处理)
- ✅ 自定义异常(带错误码) - ✅ 自定义异常(带错误码)
+58 -26
View File
@@ -27,31 +27,58 @@ public abstract class Result
ErrorCode = errorCode; ErrorCode = errorCode;
// If we have an ExceptionExpand, extract the error code from it // If we have an ExceptionExpand, extract the error code from it
if (exception is ExceptionExpand exceptionExpand && !errorCode.HasValue) if (exception is ExceptionExpand exceptionExpand && !errorCode.HasValue) ErrorCode = exceptionExpand.ErrorCode;
{
ErrorCode = exceptionExpand.ErrorCode;
}
} }
public static Result Success() => new SimpleResult(true); public static Result Success()
{
return new SimpleResult(true);
}
public static Result<T> Success<T>(T value) => new Result<T>(value); public static Result<T> Success<T>(T value)
{
return new Result<T>(value);
}
public static Result Failure(string errorMessage) => new SimpleResult(false, null, errorMessage); public static Result Failure(string errorMessage)
{
return new SimpleResult(false, null, errorMessage);
}
public static Result Failure(Exception exception) => new SimpleResult(false, exception); public static Result Failure(Exception exception)
{
return new SimpleResult(false, exception);
}
public static Result Failure(long errorCode, string errorMessage) => new SimpleResult(false, null, errorMessage, errorCode); public static Result Failure(long errorCode, string errorMessage)
{
return new SimpleResult(false, null, errorMessage, errorCode);
}
public static Result Failure(long errorCode, string errorMessage, Exception exception) => new SimpleResult(false, exception, errorMessage, errorCode); public static Result Failure(long errorCode, string errorMessage, Exception exception)
{
return new SimpleResult(false, exception, errorMessage, errorCode);
}
public static Result<T> Failure<T>(string errorMessage) => new Result<T>(default, false, null, errorMessage); public static Result<T> Failure<T>(string errorMessage)
{
return new Result<T>(default, false, null, errorMessage);
}
public static Result<T> Failure<T>(Exception exception) => new Result<T>(default, false, exception); public static Result<T> Failure<T>(Exception exception)
{
return new Result<T>(default, false, exception);
}
public static Result<T> Failure<T>(long errorCode, string errorMessage) => new Result<T>(default, false, null, errorMessage, errorCode); public static Result<T> Failure<T>(long errorCode, string errorMessage)
{
return new Result<T>(default, false, null, errorMessage, errorCode);
}
public static Result<T> Failure<T>(long errorCode, string errorMessage, Exception exception) => new Result<T>(default, false, exception, errorMessage, errorCode); public static Result<T> Failure<T>(long errorCode, string errorMessage, Exception exception)
{
return new Result<T>(default, false, exception, errorMessage, errorCode);
}
public static Result Try(Action action) public static Result Try(Action action)
{ {
@@ -128,17 +155,18 @@ public abstract class Result
public Result<T> Match<T>(Func<T> onSuccess, Func<Exception?, string?, long?, T> onFailure) public Result<T> Match<T>(Func<T> onSuccess, Func<Exception?, string?, long?, T> onFailure)
{ {
return IsSuccess ? Result.Success(onSuccess()) : Result.Failure<T>(Exception ?? new Exception(ErrorMessage)); return IsSuccess ? Success(onSuccess()) : Failure<T>(Exception ?? new Exception(ErrorMessage));
} }
public Result<T> Match<T>(Func<T> onSuccess, Func<Exception?, string?, T> onFailure) public Result<T> Match<T>(Func<T> onSuccess, Func<Exception?, string?, T> onFailure)
{ {
return IsSuccess ? Result.Success(onSuccess()) : Result.Failure<T>(Exception ?? new Exception(ErrorMessage)); return IsSuccess ? Success(onSuccess()) : Failure<T>(Exception ?? new Exception(ErrorMessage));
} }
private class SimpleResult : Result private class SimpleResult : Result
{ {
public SimpleResult(bool isSuccess, Exception? exception = null, string? errorMessage = null, long? errorCode = null) public SimpleResult(bool isSuccess, Exception? exception = null, string? errorMessage = null,
long? errorCode = null)
: base(isSuccess, exception, errorMessage, errorCode) : base(isSuccess, exception, errorMessage, errorCode)
{ {
} }
@@ -149,31 +177,35 @@ public class Result<T> : Result
{ {
public T? Value { get; } public T? Value { get; }
public Result(T? value, bool isSuccess = true, Exception? exception = null, string? errorMessage = null, long? errorCode = null) public Result(T? value, bool isSuccess = true, Exception? exception = null, string? errorMessage = null,
long? errorCode = null)
: base(isSuccess, exception, errorMessage, errorCode) : base(isSuccess, exception, errorMessage, errorCode)
{ {
Value = value; Value = value;
} }
public static implicit operator Result<T>(T value) => new Result<T>(value); public static implicit operator Result<T>(T value)
{
return new Result<T>(value);
}
public Result<TNew> Map<TNew>(Func<T, TNew> mapper) public Result<TNew> Map<TNew>(Func<T, TNew> mapper)
{ {
if (IsFailure) if (IsFailure)
{ {
if (ErrorCode.HasValue) if (ErrorCode.HasValue)
return Result.Failure<TNew>(ErrorCode.Value, ErrorMessage ?? "Mapping failed", Exception); return Failure<TNew>(ErrorCode.Value, ErrorMessage ?? "Mapping failed", Exception);
return Result.Failure<TNew>(Exception ?? new Exception(ErrorMessage)); return Failure<TNew>(Exception ?? new Exception(ErrorMessage));
} }
try try
{ {
var mappedValue = mapper(Value!); var mappedValue = mapper(Value!);
return Result.Success(mappedValue); return Success(mappedValue);
} }
catch (Exception ex) catch (Exception ex)
{ {
return Result.Failure<TNew>(ex); return Failure<TNew>(ex);
} }
} }
@@ -182,8 +214,8 @@ public class Result<T> : Result
if (IsFailure) if (IsFailure)
{ {
if (ErrorCode.HasValue) if (ErrorCode.HasValue)
return Result.Failure<TNew>(ErrorCode.Value, ErrorMessage ?? "Binding failed", Exception); return Failure<TNew>(ErrorCode.Value, ErrorMessage ?? "Binding failed", Exception);
return Result.Failure<TNew>(Exception ?? new Exception(ErrorMessage)); return Failure<TNew>(Exception ?? new Exception(ErrorMessage));
} }
try try
@@ -192,7 +224,7 @@ public class Result<T> : Result
} }
catch (Exception ex) catch (Exception ex)
{ {
return Result.Failure<TNew>(ex); return Failure<TNew>(ex);
} }
} }
+25 -12
View File
@@ -57,10 +57,13 @@ public static class ResultExtensions
public static T GetOrElse<T>(this Result<T> result, Func<Exception?, string?, long?, T> alternativeProvider) public static T GetOrElse<T>(this Result<T> result, Func<Exception?, string?, long?, T> alternativeProvider)
{ {
return result.IsSuccess ? result.Value! : alternativeProvider(result.Exception, result.ErrorMessage, result.ErrorCode); return result.IsSuccess
? result.Value!
: alternativeProvider(result.Exception, result.ErrorMessage, result.ErrorCode);
} }
public static Result<T> Where<T>(this Result<T> result, Func<T, bool> predicate, string errorMessage = "Predicate not satisfied") public static Result<T> Where<T>(this Result<T> result, Func<T, bool> predicate,
string errorMessage = "Predicate not satisfied")
{ {
if (result.IsFailure) if (result.IsFailure)
return result; return result;
@@ -68,7 +71,8 @@ public static class ResultExtensions
return predicate(result.Value!) ? result : Result.Failure<T>(errorMessage); return predicate(result.Value!) ? result : Result.Failure<T>(errorMessage);
} }
public static Result<T> Where<T>(this Result<T> result, Func<T, bool> predicate, long errorCode, string errorMessage = "Predicate not satisfied") public static Result<T> Where<T>(this Result<T> result, Func<T, bool> predicate, long errorCode,
string errorMessage = "Predicate not satisfied")
{ {
if (result.IsFailure) if (result.IsFailure)
return result; return result;
@@ -76,7 +80,8 @@ public static class ResultExtensions
return predicate(result.Value!) ? result : Result.Failure<T>(errorCode, errorMessage); return predicate(result.Value!) ? result : Result.Failure<T>(errorCode, errorMessage);
} }
public static Result<T> Where<T>(this Result<T> result, Func<T, bool> predicate, Func<T, Exception> exceptionProvider) public static Result<T> Where<T>(this Result<T> result, Func<T, bool> predicate,
Func<T, Exception> exceptionProvider)
{ {
if (result.IsFailure) if (result.IsFailure)
return result; return result;
@@ -89,7 +94,8 @@ public static class ResultExtensions
return result.Bind(selector); return result.Bind(selector);
} }
public static Result<V> SelectMany<T, U, V>(this Result<T> result, Func<T, Result<U>> selector, Func<T, U, V> resultSelector) public static Result<V> SelectMany<T, U, V>(this Result<T> result, Func<T, Result<U>> selector,
Func<T, U, V> resultSelector)
{ {
return result.Bind(t => selector(t).Map(u => resultSelector(t, u))); return result.Bind(t => selector(t).Map(u => resultSelector(t, u)));
} }
@@ -120,7 +126,8 @@ public static class ResultExtensions
return result; return result;
} }
public static Result<T> DoOnBoth<T>(this Result<T> result, Action<T> onSuccess, Action<Exception?, string?> onFailure) public static Result<T> DoOnBoth<T>(this Result<T> result, Action<T> onSuccess,
Action<Exception?, string?> onFailure)
{ {
if (result.IsSuccess) if (result.IsSuccess)
onSuccess(result.Value!); onSuccess(result.Value!);
@@ -129,7 +136,8 @@ public static class ResultExtensions
return result; return result;
} }
public static Result<T> DoOnBoth<T>(this Result<T> result, Action<T> onSuccess, Action<Exception?, string?, long?> onFailure) public static Result<T> DoOnBoth<T>(this Result<T> result, Action<T> onSuccess,
Action<Exception?, string?, long?> onFailure)
{ {
if (result.IsSuccess) if (result.IsSuccess)
onSuccess(result.Value!); onSuccess(result.Value!);
@@ -172,7 +180,8 @@ public static class ResultExtensions
return result; return result;
} }
public static async Task<Result<T>> DoOnFailureAsync<T>(this Task<Result<T>> resultTask, Func<Exception?, string?, Task> asyncAction) public static async Task<Result<T>> DoOnFailureAsync<T>(this Task<Result<T>> resultTask,
Func<Exception?, string?, Task> asyncAction)
{ {
var result = await resultTask; var result = await resultTask;
if (result.IsFailure) if (result.IsFailure)
@@ -197,7 +206,8 @@ public static class ResultExtensions
} }
} }
public static async Task<Result<U>> BindAsync<T, U>(this Task<Result<T>> resultTask, Func<T, Task<Result<U>>> asyncBinder) public static async Task<Result<U>> BindAsync<T, U>(this Task<Result<T>> resultTask,
Func<T, Task<Result<U>>> asyncBinder)
{ {
var result = await resultTask; var result = await resultTask;
if (result.IsFailure) if (result.IsFailure)
@@ -221,7 +231,8 @@ public static class ResultExtensions
return predicate(result.Value!) ? result : Result.Failure<T>(errorMessage); return predicate(result.Value!) ? result : Result.Failure<T>(errorMessage);
} }
public static Result<T> Ensure<T>(this Result<T> result, Func<T, bool> predicate, long errorCode, string errorMessage) public static Result<T> Ensure<T>(this Result<T> result, Func<T, bool> predicate, long errorCode,
string errorMessage)
{ {
if (result.IsFailure) if (result.IsFailure)
return result; return result;
@@ -229,7 +240,8 @@ public static class ResultExtensions
return predicate(result.Value!) ? result : Result.Failure<T>(errorCode, errorMessage); return predicate(result.Value!) ? result : Result.Failure<T>(errorCode, errorMessage);
} }
public static Result<T> Ensure<T>(this Result<T> result, Func<T, bool> predicate, Func<T, Exception> exceptionFactory) public static Result<T> Ensure<T>(this Result<T> result, Func<T, bool> predicate,
Func<T, Exception> exceptionFactory)
{ {
if (result.IsFailure) if (result.IsFailure)
return result; return result;
@@ -247,7 +259,8 @@ public static class ResultExtensions
return result.IsSuccess ? result : alternativeProvider(result.Exception, result.ErrorMessage); return result.IsSuccess ? result : alternativeProvider(result.Exception, result.ErrorMessage);
} }
public static Result<T> OrElse<T>(this Result<T> result, Func<Exception?, string?, long?, Result<T>> alternativeProvider) public static Result<T> OrElse<T>(this Result<T> result,
Func<Exception?, string?, long?, Result<T>> alternativeProvider)
{ {
return result.IsSuccess ? result : alternativeProvider(result.Exception, result.ErrorMessage, result.ErrorCode); return result.IsSuccess ? result : alternativeProvider(result.Exception, result.ErrorMessage, result.ErrorCode);
} }
+43 -46
View File
@@ -25,12 +25,12 @@ public static class CryptoHelper
throw new ArgumentNullException(nameof(password)); throw new ArgumentNullException(nameof(password));
// 生成随机 Salt // 生成随机 Salt
byte[] salt = RandomNumberGenerator.GetBytes(16); var salt = RandomNumberGenerator.GetBytes(16);
// 从密码派生密钥 // 从密码派生密钥
using var deriveBytes = new Rfc2898DeriveBytes(password, salt, 10000, HashAlgorithmName.SHA256); using var deriveBytes = new Rfc2898DeriveBytes(password, salt, 10000, HashAlgorithmName.SHA256);
byte[] key = deriveBytes.GetBytes(32); // 256 bit var key = deriveBytes.GetBytes(32); // 256 bit
byte[] iv = deriveBytes.GetBytes(16); // 128 bit var iv = deriveBytes.GetBytes(16); // 128 bit
// AES 加密 // AES 加密
using var aes = Aes.Create(); using var aes = Aes.Create();
@@ -38,11 +38,11 @@ public static class CryptoHelper
aes.IV = iv; aes.IV = iv;
using var encryptor = aes.CreateEncryptor(); using var encryptor = aes.CreateEncryptor();
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText); var plainBytes = Encoding.UTF8.GetBytes(plainText);
byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
// 组合 Salt + 密文 // 组合 Salt + 密文
byte[] result = new byte[salt.Length + cipherBytes.Length]; var result = new byte[salt.Length + cipherBytes.Length];
Buffer.BlockCopy(salt, 0, result, 0, salt.Length); Buffer.BlockCopy(salt, 0, result, 0, salt.Length);
Buffer.BlockCopy(cipherBytes, 0, result, salt.Length, cipherBytes.Length); Buffer.BlockCopy(cipherBytes, 0, result, salt.Length, cipherBytes.Length);
@@ -62,20 +62,20 @@ public static class CryptoHelper
if (string.IsNullOrEmpty(password)) if (string.IsNullOrEmpty(password))
throw new ArgumentNullException(nameof(password)); throw new ArgumentNullException(nameof(password));
byte[] fullCipher = Convert.FromBase64String(cipherText); var fullCipher = Convert.FromBase64String(cipherText);
// 提取 Salt // 提取 Salt
byte[] salt = new byte[16]; var salt = new byte[16];
Buffer.BlockCopy(fullCipher, 0, salt, 0, 16); Buffer.BlockCopy(fullCipher, 0, salt, 0, 16);
// 提取密文 // 提取密文
byte[] cipherBytes = new byte[fullCipher.Length - 16]; var cipherBytes = new byte[fullCipher.Length - 16];
Buffer.BlockCopy(fullCipher, 16, cipherBytes, 0, cipherBytes.Length); Buffer.BlockCopy(fullCipher, 16, cipherBytes, 0, cipherBytes.Length);
// 从密码派生密钥 // 从密码派生密钥
using var deriveBytes = new Rfc2898DeriveBytes(password, salt, 10000, HashAlgorithmName.SHA256); using var deriveBytes = new Rfc2898DeriveBytes(password, salt, 10000, HashAlgorithmName.SHA256);
byte[] key = deriveBytes.GetBytes(32); var key = deriveBytes.GetBytes(32);
byte[] iv = deriveBytes.GetBytes(16); var iv = deriveBytes.GetBytes(16);
// AES 解密 // AES 解密
using var aes = Aes.Create(); using var aes = Aes.Create();
@@ -83,7 +83,7 @@ public static class CryptoHelper
aes.IV = iv; aes.IV = iv;
using var decryptor = aes.CreateDecryptor(); using var decryptor = aes.CreateDecryptor();
byte[] plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
return Encoding.UTF8.GetString(plainBytes); return Encoding.UTF8.GetString(plainBytes);
} }
@@ -121,8 +121,8 @@ public static class CryptoHelper
using var rsa = RSA.Create(); using var rsa = RSA.Create();
rsa.ImportRSAPublicKey(Convert.FromBase64String(publicKey), out _); rsa.ImportRSAPublicKey(Convert.FromBase64String(publicKey), out _);
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText); var plainBytes = Encoding.UTF8.GetBytes(plainText);
byte[] cipherBytes = rsa.Encrypt(plainBytes, RSAEncryptionPadding.OaepSHA256); var cipherBytes = rsa.Encrypt(plainBytes, RSAEncryptionPadding.OaepSHA256);
return Convert.ToBase64String(cipherBytes); return Convert.ToBase64String(cipherBytes);
} }
@@ -143,8 +143,8 @@ public static class CryptoHelper
using var rsa = RSA.Create(); using var rsa = RSA.Create();
rsa.ImportRSAPrivateKey(Convert.FromBase64String(privateKey), out _); rsa.ImportRSAPrivateKey(Convert.FromBase64String(privateKey), out _);
byte[] cipherBytes = Convert.FromBase64String(cipherText); var cipherBytes = Convert.FromBase64String(cipherText);
byte[] plainBytes = rsa.Decrypt(cipherBytes, RSAEncryptionPadding.OaepSHA256); var plainBytes = rsa.Decrypt(cipherBytes, RSAEncryptionPadding.OaepSHA256);
return Encoding.UTF8.GetString(plainBytes); return Encoding.UTF8.GetString(plainBytes);
} }
@@ -164,8 +164,8 @@ public static class CryptoHelper
throw new ArgumentNullException(nameof(input)); throw new ArgumentNullException(nameof(input));
using var md5 = MD5.Create(); using var md5 = MD5.Create();
byte[] inputBytes = Encoding.UTF8.GetBytes(input); var inputBytes = Encoding.UTF8.GetBytes(input);
byte[] hashBytes = md5.ComputeHash(inputBytes); var hashBytes = md5.ComputeHash(inputBytes);
return BytesToHex(hashBytes); return BytesToHex(hashBytes);
} }
@@ -181,8 +181,8 @@ public static class CryptoHelper
throw new ArgumentNullException(nameof(input)); throw new ArgumentNullException(nameof(input));
using var sha256 = SHA256.Create(); using var sha256 = SHA256.Create();
byte[] inputBytes = Encoding.UTF8.GetBytes(input); var inputBytes = Encoding.UTF8.GetBytes(input);
byte[] hashBytes = sha256.ComputeHash(inputBytes); var hashBytes = sha256.ComputeHash(inputBytes);
return BytesToHex(hashBytes); return BytesToHex(hashBytes);
} }
@@ -198,8 +198,8 @@ public static class CryptoHelper
throw new ArgumentNullException(nameof(input)); throw new ArgumentNullException(nameof(input));
using var sha512 = SHA512.Create(); using var sha512 = SHA512.Create();
byte[] inputBytes = Encoding.UTF8.GetBytes(input); var inputBytes = Encoding.UTF8.GetBytes(input);
byte[] hashBytes = sha512.ComputeHash(inputBytes); var hashBytes = sha512.ComputeHash(inputBytes);
return BytesToHex(hashBytes); return BytesToHex(hashBytes);
} }
@@ -223,7 +223,7 @@ public static class CryptoHelper
if (string.IsNullOrEmpty(input)) if (string.IsNullOrEmpty(input))
throw new ArgumentNullException(nameof(input)); throw new ArgumentNullException(nameof(input));
byte[] bytes = Encoding.UTF8.GetBytes(input); var bytes = Encoding.UTF8.GetBytes(input);
return Convert.ToBase64String(bytes); return Convert.ToBase64String(bytes);
} }
@@ -237,7 +237,7 @@ public static class CryptoHelper
if (string.IsNullOrEmpty(input)) if (string.IsNullOrEmpty(input))
throw new ArgumentNullException(nameof(input)); throw new ArgumentNullException(nameof(input));
byte[] bytes = Convert.FromBase64String(input); var bytes = Convert.FromBase64String(input);
return Encoding.UTF8.GetString(bytes); return Encoding.UTF8.GetString(bytes);
} }
@@ -293,11 +293,11 @@ public static class CryptoHelper
throw new ArgumentNullException(nameof(password)); throw new ArgumentNullException(nameof(password));
// 生成随机 Salt // 生成随机 Salt
byte[] salt = RandomNumberGenerator.GetBytes(saltSize); var salt = RandomNumberGenerator.GetBytes(saltSize);
// 使用 PBKDF2 计算哈希 // 使用 PBKDF2 计算哈希
using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256); using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256);
byte[] hash = pbkdf2.GetBytes(hashSize); var hash = pbkdf2.GetBytes(hashSize);
// 格式: $pbkdf2-sha256$迭代次数$Salt$Hash // 格式: $pbkdf2-sha256$迭代次数$Salt$Hash
return $"$pbkdf2-sha256${iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}"; return $"$pbkdf2-sha256${iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
@@ -323,13 +323,13 @@ public static class CryptoHelper
if (parts.Length != 5 || parts[1] != "pbkdf2-sha256") if (parts.Length != 5 || parts[1] != "pbkdf2-sha256")
return false; return false;
int iterations = int.Parse(parts[2]); var iterations = int.Parse(parts[2]);
byte[] salt = Convert.FromBase64String(parts[3]); var salt = Convert.FromBase64String(parts[3]);
byte[] storedHash = Convert.FromBase64String(parts[4]); var storedHash = Convert.FromBase64String(parts[4]);
// 计算输入密码的哈希 // 计算输入密码的哈希
using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256); using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256);
byte[] computedHash = pbkdf2.GetBytes(storedHash.Length); var computedHash = pbkdf2.GetBytes(storedHash.Length);
// 常量时间比较,防止时序攻击 // 常量时间比较,防止时序攻击
return CryptographicOperations.FixedTimeEquals(storedHash, computedHash); return CryptographicOperations.FixedTimeEquals(storedHash, computedHash);
@@ -357,11 +357,11 @@ public static class CryptoHelper
if (string.IsNullOrEmpty(secret)) if (string.IsNullOrEmpty(secret))
throw new ArgumentNullException(nameof(secret)); throw new ArgumentNullException(nameof(secret));
byte[] keyBytes = Encoding.UTF8.GetBytes(secret); var keyBytes = Encoding.UTF8.GetBytes(secret);
byte[] messageBytes = Encoding.UTF8.GetBytes(message); var messageBytes = Encoding.UTF8.GetBytes(message);
using var hmac = new HMACSHA256(keyBytes); using var hmac = new HMACSHA256(keyBytes);
byte[] hashBytes = hmac.ComputeHash(messageBytes); var hashBytes = hmac.ComputeHash(messageBytes);
return BytesToHex(hashBytes); return BytesToHex(hashBytes);
} }
@@ -399,11 +399,11 @@ public static class CryptoHelper
if (string.IsNullOrEmpty(secret)) if (string.IsNullOrEmpty(secret))
throw new ArgumentNullException(nameof(secret)); throw new ArgumentNullException(nameof(secret));
byte[] keyBytes = Encoding.UTF8.GetBytes(secret); var keyBytes = Encoding.UTF8.GetBytes(secret);
byte[] messageBytes = Encoding.UTF8.GetBytes(message); var messageBytes = Encoding.UTF8.GetBytes(message);
using var hmac = new HMACSHA512(keyBytes); using var hmac = new HMACSHA512(keyBytes);
byte[] hashBytes = hmac.ComputeHash(messageBytes); var hashBytes = hmac.ComputeHash(messageBytes);
return BytesToHex(hashBytes); return BytesToHex(hashBytes);
} }
@@ -449,10 +449,7 @@ public static class CryptoHelper
chars += special; chars += special;
var result = new char[length]; var result = new char[length];
for (int i = 0; i < length; i++) for (var i = 0; i < length; i++) result[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
{
result[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
}
return new string(result); return new string(result);
} }
@@ -490,16 +487,19 @@ public static class CryptoHelper
chars += lowercase; chars += lowercase;
password.Add(lowercase[RandomNumberGenerator.GetInt32(lowercase.Length)]); password.Add(lowercase[RandomNumberGenerator.GetInt32(lowercase.Length)]);
} }
if (requireUppercase) if (requireUppercase)
{ {
chars += uppercase; chars += uppercase;
password.Add(uppercase[RandomNumberGenerator.GetInt32(uppercase.Length)]); password.Add(uppercase[RandomNumberGenerator.GetInt32(uppercase.Length)]);
} }
if (requireDigit) if (requireDigit)
{ {
chars += digits; chars += digits;
password.Add(digits[RandomNumberGenerator.GetInt32(digits.Length)]); password.Add(digits[RandomNumberGenerator.GetInt32(digits.Length)]);
} }
if (requireSpecialChar) if (requireSpecialChar)
{ {
chars += special; chars += special;
@@ -507,15 +507,12 @@ public static class CryptoHelper
} }
// 填充剩余长度 // 填充剩余长度
for (int i = password.Count; i < length; i++) for (var i = password.Count; i < length; i++) password.Add(chars[RandomNumberGenerator.GetInt32(chars.Length)]);
{
password.Add(chars[RandomNumberGenerator.GetInt32(chars.Length)]);
}
// 随机打乱 // 随机打乱
for (int i = password.Count - 1; i > 0; i--) for (var i = password.Count - 1; i > 0; i--)
{ {
int j = RandomNumberGenerator.GetInt32(i + 1); var j = RandomNumberGenerator.GetInt32(i + 1);
(password[i], password[j]) = (password[j], password[i]); (password[i], password[j]) = (password[j], password[i]);
} }
+9 -18
View File
@@ -87,13 +87,9 @@ public static class EnumHelper
return null; return null;
foreach (var field in typeof(TEnum).GetFields()) foreach (var field in typeof(TEnum).GetFields())
{
if (field.GetCustomAttribute<DescriptionAttribute>() is { } attribute) if (field.GetCustomAttribute<DescriptionAttribute>() is { } attribute)
{
if (attribute.Description.Equals(description, StringComparison.OrdinalIgnoreCase)) if (attribute.Description.Equals(description, StringComparison.OrdinalIgnoreCase))
return (TEnum)field.GetValue(null)!; return (TEnum)field.GetValue(null)!;
}
}
return null; return null;
} }
@@ -229,8 +225,8 @@ public static class EnumHelper
/// </summary> /// </summary>
public static TEnum AddFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum public static TEnum AddFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum
{ {
int intValue = Convert.ToInt32(value); var intValue = Convert.ToInt32(value);
int intFlag = Convert.ToInt32(flag); var intFlag = Convert.ToInt32(flag);
return (TEnum)(object)(intValue | intFlag); return (TEnum)(object)(intValue | intFlag);
} }
@@ -239,8 +235,8 @@ public static class EnumHelper
/// </summary> /// </summary>
public static TEnum RemoveFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum public static TEnum RemoveFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum
{ {
int intValue = Convert.ToInt32(value); var intValue = Convert.ToInt32(value);
int intFlag = Convert.ToInt32(flag); var intFlag = Convert.ToInt32(flag);
return (TEnum)(object)(intValue & ~intFlag); return (TEnum)(object)(intValue & ~intFlag);
} }
@@ -249,8 +245,8 @@ public static class EnumHelper
/// </summary> /// </summary>
public static TEnum ToggleFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum public static TEnum ToggleFlag<TEnum>(this TEnum value, TEnum flag) where TEnum : struct, Enum
{ {
int intValue = Convert.ToInt32(value); var intValue = Convert.ToInt32(value);
int intFlag = Convert.ToInt32(flag); var intFlag = Convert.ToInt32(flag);
return (TEnum)(object)(intValue ^ intFlag); return (TEnum)(object)(intValue ^ intFlag);
} }
@@ -259,11 +255,9 @@ public static class EnumHelper
/// </summary> /// </summary>
public static IEnumerable<TEnum> GetFlags<TEnum>(this TEnum value) where TEnum : struct, Enum public static IEnumerable<TEnum> GetFlags<TEnum>(this TEnum value) where TEnum : struct, Enum
{ {
foreach (TEnum flag in Enum.GetValues<TEnum>()) foreach (var flag in Enum.GetValues<TEnum>())
{
if (value.HasFlag(flag)) if (value.HasFlag(flag))
yield return flag; yield return flag;
}
} }
/// <summary> /// <summary>
@@ -271,11 +265,8 @@ public static class EnumHelper
/// </summary> /// </summary>
public static TEnum CombineFlags<TEnum>(params TEnum[] flags) where TEnum : struct, Enum public static TEnum CombineFlags<TEnum>(params TEnum[] flags) where TEnum : struct, Enum
{ {
int result = 0; var result = 0;
foreach (var flag in flags) foreach (var flag in flags) result |= Convert.ToInt32(flag);
{
result |= Convert.ToInt32(flag);
}
return (TEnum)(object)result; return (TEnum)(object)result;
} }
+6 -14
View File
@@ -276,13 +276,11 @@ public static class FileHelper
// 递归复制子目录 // 递归复制子目录
if (recursive) if (recursive)
{
foreach (var subDir in dir.GetDirectories()) foreach (var subDir in dir.GetDirectories())
{ {
var targetDirPath = Path.Combine(destDir, subDir.Name); var targetDirPath = Path.Combine(destDir, subDir.Name);
CopyDirectory(subDir.FullName, targetDirPath, true); CopyDirectory(subDir.FullName, targetDirPath, true);
} }
}
} }
/// <summary> /// <summary>
@@ -351,10 +349,7 @@ public static class FileHelper
return Enumerable.Empty<string>(); return Enumerable.Empty<string>();
var files = new List<string>(); var files = new List<string>();
foreach (var pattern in searchPatterns) foreach (var pattern in searchPatterns) files.AddRange(GetFiles(directoryPath, pattern, recursive));
{
files.AddRange(GetFiles(directoryPath, pattern, recursive));
}
return files.Distinct(); return files.Distinct();
} }
@@ -664,7 +659,8 @@ public static class FileHelper
public static bool IsTextFile(string filePath) public static bool IsTextFile(string filePath)
{ {
var extension = GetExtension(filePath).ToLowerInvariant(); var extension = GetExtension(filePath).ToLowerInvariant();
string[] textExtensions = { ".txt", ".cs", ".xml", ".json", ".html", ".css", ".js", ".ts", ".md", ".log", ".csv", ".sql" }; string[] textExtensions =
{ ".txt", ".cs", ".xml", ".json", ".html", ".css", ".js", ".ts", ".md", ".log", ".csv", ".sql" };
return textExtensions.Contains(extension); return textExtensions.Contains(extension);
} }
@@ -756,16 +752,14 @@ public static class FileHelper
return; return;
var files = GetFiles(directoryPath, searchPattern, recursive); var files = GetFiles(directoryPath, searchPattern, recursive);
foreach (var file in files) foreach (var file in files) DeleteFile(file);
{
DeleteFile(file);
}
} }
/// <summary> /// <summary>
/// 删除超过指定天数的文件 /// 删除超过指定天数的文件
/// </summary> /// </summary>
public static int DeleteOldFiles(string directoryPath, int daysOld, string searchPattern = "*", bool recursive = false) public static int DeleteOldFiles(string directoryPath, int daysOld, string searchPattern = "*",
bool recursive = false)
{ {
if (string.IsNullOrWhiteSpace(directoryPath)) if (string.IsNullOrWhiteSpace(directoryPath))
throw new ArgumentNullException(nameof(directoryPath)); throw new ArgumentNullException(nameof(directoryPath));
@@ -778,13 +772,11 @@ public static class FileHelper
var deletedCount = 0; var deletedCount = 0;
foreach (var file in files) foreach (var file in files)
{
if (GetLastWriteTime(file) < cutoffDate) if (GetLastWriteTime(file) < cutoffDate)
{ {
DeleteFile(file); DeleteFile(file);
deletedCount++; deletedCount++;
} }
}
return deletedCount; return deletedCount;
} }
+18 -28
View File
@@ -17,7 +17,7 @@ public static class JsonHelper
/// <summary> /// <summary>
/// 驼峰命名选项(默认格式化输出) /// 驼峰命名选项(默认格式化输出)
/// </summary> /// </summary>
public static readonly JsonSerializerOptions CamelCaseOptions = new JsonSerializerOptions public static readonly JsonSerializerOptions CamelCaseOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true, WriteIndented = true,
@@ -25,14 +25,14 @@ public static class JsonHelper
Converters = Converters =
{ {
new IpAddressConverter(), new IpAddressConverter(),
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false) new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, false)
}, }
}; };
/// <summary> /// <summary>
/// 驼峰命名选项(紧凑格式,不格式化输出) /// 驼峰命名选项(紧凑格式,不格式化输出)
/// </summary> /// </summary>
public static readonly JsonSerializerOptions CamelCaseCompactOptions = new JsonSerializerOptions public static readonly JsonSerializerOptions CamelCaseCompactOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false, WriteIndented = false,
@@ -41,14 +41,14 @@ public static class JsonHelper
Converters = Converters =
{ {
new IpAddressConverter(), new IpAddressConverter(),
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false) new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, false)
}, }
}; };
/// <summary> /// <summary>
/// 蛇形命名选项(snake_case) /// 蛇形命名选项(snake_case)
/// </summary> /// </summary>
public static readonly JsonSerializerOptions SnakeCaseOptions = new JsonSerializerOptions public static readonly JsonSerializerOptions SnakeCaseOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = true, WriteIndented = true,
@@ -56,14 +56,14 @@ public static class JsonHelper
Converters = Converters =
{ {
new IpAddressConverter(), new IpAddressConverter(),
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower, allowIntegerValues: false) new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower, false)
}, }
}; };
/// <summary> /// <summary>
/// 默认选项(保留原始命名,格式化输出) /// 默认选项(保留原始命名,格式化输出)
/// </summary> /// </summary>
public static readonly JsonSerializerOptions DefaultOptions = new JsonSerializerOptions public static readonly JsonSerializerOptions DefaultOptions = new()
{ {
WriteIndented = true, WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
@@ -71,7 +71,7 @@ public static class JsonHelper
{ {
new IpAddressConverter(), new IpAddressConverter(),
new JsonStringEnumConverter(allowIntegerValues: false) new JsonStringEnumConverter(allowIntegerValues: false)
}, }
}; };
#endregion #endregion
@@ -191,7 +191,8 @@ public static class JsonHelper
/// <summary> /// <summary>
/// 序列化对象到流 /// 序列化对象到流
/// </summary> /// </summary>
public static async Task SerializeToStreamAsync<T>(Stream stream, T value, bool useCompact = false, CancellationToken cancellationToken = default) public static async Task SerializeToStreamAsync<T>(Stream stream, T value, bool useCompact = false,
CancellationToken cancellationToken = default)
{ {
var options = useCompact ? CamelCaseCompactOptions : CamelCaseOptions; var options = useCompact ? CamelCaseCompactOptions : CamelCaseOptions;
await JsonSerializer.SerializeAsync(stream, value, options, cancellationToken); await JsonSerializer.SerializeAsync(stream, value, options, cancellationToken);
@@ -200,7 +201,8 @@ public static class JsonHelper
/// <summary> /// <summary>
/// 从流反序列化对象 /// 从流反序列化对象
/// </summary> /// </summary>
public static async Task<T?> DeserializeFromStreamAsync<T>(Stream stream, CancellationToken cancellationToken = default) public static async Task<T?> DeserializeFromStreamAsync<T>(Stream stream,
CancellationToken cancellationToken = default)
{ {
return await JsonSerializer.DeserializeAsync<T>(stream, CamelCaseOptions, cancellationToken); return await JsonSerializer.DeserializeAsync<T>(stream, CamelCaseOptions, cancellationToken);
} }
@@ -287,16 +289,10 @@ public static class JsonHelper
var element = doc.RootElement; var element = doc.RootElement;
foreach (var key in path.Split('.')) foreach (var key in path.Split('.'))
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(key, out var property)) if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(key, out var property))
{
element = property; element = property;
}
else else
{
return null; return null;
}
}
return element.ToString(); return element.ToString();
} }
@@ -345,16 +341,10 @@ public static class JsonHelper
var merged = new Dictionary<string, JsonElement>(); var merged = new Dictionary<string, JsonElement>();
// 添加第一个 JSON 的所有属性 // 添加第一个 JSON 的所有属性
foreach (var property in doc1.RootElement.EnumerateObject()) foreach (var property in doc1.RootElement.EnumerateObject()) merged[property.Name] = property.Value.Clone();
{
merged[property.Name] = property.Value.Clone();
}
// 用第二个 JSON 的属性覆盖 // 用第二个 JSON 的属性覆盖
foreach (var property in doc2.RootElement.EnumerateObject()) foreach (var property in doc2.RootElement.EnumerateObject()) merged[property.Name] = property.Value.Clone();
{
merged[property.Name] = property.Value.Clone();
}
return JsonSerializer.Serialize(merged, CamelCaseOptions); return JsonSerializer.Serialize(merged, CamelCaseOptions);
} }
@@ -403,7 +393,7 @@ public class IpAddressConverter : JsonConverter<IPAddress>
{ {
public override IPAddress Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) public override IPAddress Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{ {
var ipString = reader.GetString() ?? String.Empty; var ipString = reader.GetString() ?? string.Empty;
return IPAddress.Parse(ipString); return IPAddress.Parse(ipString);
} }
@@ -332,17 +332,13 @@ public static class ReflectionHelper
); );
foreach (var sourceProp in sourceProperties) foreach (var sourceProp in sourceProperties)
{
if (targetProperties.TryGetValue(sourceProp.Name, out var targetProp)) if (targetProperties.TryGetValue(sourceProp.Name, out var targetProp))
{
// 检查类型是否兼容 // 检查类型是否兼容
if (targetProp.PropertyType.IsAssignableFrom(sourceProp.PropertyType)) if (targetProp.PropertyType.IsAssignableFrom(sourceProp.PropertyType))
{ {
var value = sourceProp.GetValue(source); var value = sourceProp.GetValue(source);
targetProp.SetValue(target, value); targetProp.SetValue(target, value);
} }
}
}
} }
/// <summary> /// <summary>
+2 -4
View File
@@ -14,9 +14,9 @@ public static class SystemHelper
// todo: 2025/6/24 15:53 开发过程中使用固定端口 // todo: 2025/6/24 15:53 开发过程中使用固定端口
// return 8267; // return 8267;
TcpListener listener = new TcpListener(IPAddress.Loopback, 0); // 端口号为0表示由系统自动分配 var listener = new TcpListener(IPAddress.Loopback, 0); // 端口号为0表示由系统自动分配
listener.Start(); listener.Start();
int port = ((IPEndPoint)listener.LocalEndpoint).Port; var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop(); listener.Stop();
return port; return port;
} }
@@ -33,9 +33,7 @@ public static class SystemHelper
public static bool IsAdministrator() public static bool IsAdministrator()
{ {
if (!OperatingSystem.IsWindows()) if (!OperatingSystem.IsWindows())
{
throw new PlatformNotSupportedException("IsAdministrator method is only supported on Windows platform."); throw new PlatformNotSupportedException("IsAdministrator method is only supported on Windows platform.");
}
var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
var principal = new System.Security.Principal.WindowsPrincipal(identity); var principal = new System.Security.Principal.WindowsPrincipal(identity);
+15 -12
View File
@@ -158,8 +158,8 @@ public static class ValidationHelper
// 校验码对应值 // 校验码对应值
char[] checkCodes = { '1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2' }; char[] checkCodes = { '1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2' };
int sum = 0; var sum = 0;
for (int i = 0; i < 17; i++) for (var i = 0; i < 17; i++)
{ {
if (!char.IsDigit(idCard[i])) if (!char.IsDigit(idCard[i]))
return false; return false;
@@ -167,9 +167,9 @@ public static class ValidationHelper
sum += (idCard[i] - '0') * weights[i]; sum += (idCard[i] - '0') * weights[i];
} }
int mod = sum % 11; var mod = sum % 11;
char expectedCheckCode = checkCodes[mod]; var expectedCheckCode = checkCodes[mod];
char actualCheckCode = char.ToUpperInvariant(idCard[17]); var actualCheckCode = char.ToUpperInvariant(idCard[17]);
return expectedCheckCode == actualCheckCode; return expectedCheckCode == actualCheckCode;
} }
@@ -186,9 +186,9 @@ public static class ValidationHelper
try try
{ {
int year = int.Parse(idCard.Substring(6, 4)); var year = int.Parse(idCard.Substring(6, 4));
int month = int.Parse(idCard.Substring(10, 2)); var month = int.Parse(idCard.Substring(10, 2));
int day = int.Parse(idCard.Substring(12, 2)); var day = int.Parse(idCard.Substring(12, 2));
return new DateTime(year, month, day); return new DateTime(year, month, day);
} }
@@ -210,7 +210,7 @@ public static class ValidationHelper
try try
{ {
int genderCode = int.Parse(idCard.Substring(16, 1)); var genderCode = int.Parse(idCard.Substring(16, 1));
return genderCode % 2 == 0 ? "Female" : "Male"; return genderCode % 2 == 0 ? "Female" : "Male";
} }
catch catch
@@ -281,7 +281,8 @@ public static class ValidationHelper
/// <summary> /// <summary>
/// 验证浮点数在指定范围内(Result 模式) /// 验证浮点数在指定范围内(Result 模式)
/// </summary> /// </summary>
public static Result<double> ValidateRange(double value, double min, double max, string fieldName = "值", long errorCode = 400) public static Result<double> ValidateRange(double value, double min, double max, string fieldName = "值",
long errorCode = 400)
{ {
if (value < min || value > max) if (value < min || value > max)
return Result.Failure<double>(errorCode, $"{fieldName}必须在 {min} 到 {max} 之间"); return Result.Failure<double>(errorCode, $"{fieldName}必须在 {min} 到 {max} 之间");
@@ -302,7 +303,8 @@ public static class ValidationHelper
/// <param name="fieldName">字段名称</param> /// <param name="fieldName">字段名称</param>
/// <param name="errorCode">错误码(默认 400)</param> /// <param name="errorCode">错误码(默认 400)</param>
/// <returns>验证结果</returns> /// <returns>验证结果</returns>
public static Result<string> ValidateLength(string? value, int minLength, int maxLength, string fieldName = "字段", long errorCode = 400) public static Result<string> ValidateLength(string? value, int minLength, int maxLength, string fieldName = "字段",
long errorCode = 400)
{ {
if (string.IsNullOrEmpty(value)) if (string.IsNullOrEmpty(value))
return Result.Failure<string>(errorCode, $"{fieldName}不能为空"); return Result.Failure<string>(errorCode, $"{fieldName}不能为空");
@@ -420,7 +422,8 @@ public static class ValidationHelper
/// <param name="errorMessage">错误消息</param> /// <param name="errorMessage">错误消息</param>
/// <param name="errorCode">错误码(默认 400)</param> /// <param name="errorCode">错误码(默认 400)</param>
/// <returns>验证结果</returns> /// <returns>验证结果</returns>
public static Result<string> ValidateRegex(string? value, string pattern, string errorMessage = "格式无效", long errorCode = 400) public static Result<string> ValidateRegex(string? value, string pattern, string errorMessage = "格式无效",
long errorCode = 400)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
return Result.Failure<string>(errorCode, "值不能为空"); return Result.Failure<string>(errorCode, "值不能为空");
+25 -19
View File
@@ -1,24 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup> <!-- 程序集元数据 -->
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" /> <Description>Sharp Framework 配置管理库 - 支持 JSON/XML/INI/TOML 多格式配置,提供热重载和验证功能</Description>
<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> <!-- NuGet 包元数据 -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" /> <PackageTags>sharp;config;configuration;hotreload;json;xml;ini;toml</PackageTags>
</ItemGroup> </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.DependencyInjection.Abstractions" Version="8.0.1"/>
</ItemGroup>
</Project> </Project>
-2
View File
@@ -29,9 +29,7 @@ public static class ConfigBuilder
foreach (var f in files) foreach (var f in files)
{ {
if (!ConfigSource.TryGuess(f, out var src)) if (!ConfigSource.TryGuess(f, out var src))
{
throw new NotSupportedException($"Unsupported config extension: {f}"); throw new NotSupportedException($"Unsupported config extension: {f}");
}
opt.Sources.Add(src); opt.Sources.Add(src);
} }
}); });
@@ -0,0 +1,56 @@
using Microsoft.Extensions.Configuration;
namespace Config;
public static class ConfigBuilderExtensions
{
/// <summary>
/// 创建默认配置(约定优于配置)
/// 自动查找 appsettings.json,启用热重载和环境变量
/// </summary>
/// <param name="basePath">基础路径,默认为应用程序目录</param>
/// <param name="environmentPrefix">环境变量前缀,默认为 "APP_"</param>
/// <returns>配置管理器</returns>
public static ConfigManager CreateDefault(
string? basePath = null,
string? environmentPrefix = "APP_")
{
basePath ??= AppContext.BaseDirectory;
// 查找配置文件(appsettings.json, appsettings.Development.json 等)
var configFiles = new List<string> { "appsettings.json" };
// 根据环境添加特定配置
var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
?? "Production";
var envConfigFile = $"appsettings.{environment}.json";
var envConfigPath = Path.Combine(basePath, envConfigFile);
if (File.Exists(envConfigPath)) configFiles.Add(envConfigFile);
// 构建配置
return ConfigBuilder.BuildFromFiles(
configFiles.ToArray(),
true,
true,
environmentPrefix,
basePath
);
}
/// <summary>
/// 创建最小化配置(只有 appsettings.json)
/// </summary>
public static ConfigManager CreateMinimal(string? basePath = null)
{
basePath ??= AppContext.BaseDirectory;
return ConfigBuilder.BuildFromFiles(
new[] { "appsettings.json" },
false,
false,
basePath: basePath
);
}
}
+34 -37
View File
@@ -6,16 +6,16 @@ namespace Config;
/// <summary> /// <summary>
/// 描述默认配置构建时的约定。 /// 描述默认配置构建时的约定。
/// </summary> /// </summary>
public sealed class ConfigDefaultsOptions public sealed class ConfigDefaultsOptions
{ {
private readonly IList<ConfigSource> _fluentSources = new List<ConfigSource>(); private readonly IList<ConfigSource> _fluentSources = new List<ConfigSource>();
public ConfigDefaultsOptions() public ConfigDefaultsOptions()
{ {
BasePath = AppContext.BaseDirectory; BasePath = AppContext.BaseDirectory;
EnvironmentName = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") EnvironmentName = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
} }
/// <summary> /// <summary>
@@ -66,26 +66,34 @@ namespace Config;
/// <summary> /// <summary>
/// 通过流式 API 添加 JSON 配置文件(默认 optional)。 /// 通过流式 API 添加 JSON 配置文件(默认 optional)。
/// </summary> /// </summary>
public ConfigDefaultsOptions WithJson(string path, bool optional = true) => public ConfigDefaultsOptions WithJson(string path, bool optional = true)
AddFluentSource(path, ConfigFormat.Json, optional); {
return AddFluentSource(path, ConfigFormat.Json, optional);
}
/// <summary> /// <summary>
/// 通过流式 API 添加 INI 配置文件(默认 optional)。 /// 通过流式 API 添加 INI 配置文件(默认 optional)。
/// </summary> /// </summary>
public ConfigDefaultsOptions WithIni(string path, bool optional = true) => public ConfigDefaultsOptions WithIni(string path, bool optional = true)
AddFluentSource(path, ConfigFormat.Ini, optional); {
return AddFluentSource(path, ConfigFormat.Ini, optional);
}
/// <summary> /// <summary>
/// 通过流式 API 添加 XML 配置文件(默认 optional)。 /// 通过流式 API 添加 XML 配置文件(默认 optional)。
/// </summary> /// </summary>
public ConfigDefaultsOptions WithXml(string path, bool optional = true) => public ConfigDefaultsOptions WithXml(string path, bool optional = true)
AddFluentSource(path, ConfigFormat.Xml, optional); {
return AddFluentSource(path, ConfigFormat.Xml, optional);
}
/// <summary> /// <summary>
/// 通过流式 API 添加 TOML 配置文件(默认 optional)。 /// 通过流式 API 添加 TOML 配置文件(默认 optional)。
/// </summary> /// </summary>
public ConfigDefaultsOptions WithToml(string path, bool optional = true) => public ConfigDefaultsOptions WithToml(string path, bool optional = true)
AddFluentSource(path, ConfigFormat.Toml, optional); {
return AddFluentSource(path, ConfigFormat.Toml, optional);
}
private ConfigDefaultsOptions AddFluentSource(string path, ConfigFormat format, bool optional) private ConfigDefaultsOptions AddFluentSource(string path, ConfigFormat format, bool optional)
{ {
@@ -98,7 +106,10 @@ namespace Config;
return this; return this;
} }
internal IEnumerable<ConfigSource> GetFluentSources() => _fluentSources; internal IEnumerable<ConfigSource> GetFluentSources()
{
return _fluentSources;
}
} }
/// <summary> /// <summary>
@@ -146,10 +157,7 @@ public static class ConfigDefaults
internal static void ApplyDefaults(ConfigOptions target, ConfigDefaultsOptions defaults) internal static void ApplyDefaults(ConfigOptions target, ConfigDefaultsOptions defaults)
{ {
if (defaults.BasePath is not null) if (defaults.BasePath is not null) target.BasePath = defaults.BasePath;
{
target.BasePath = defaults.BasePath;
}
target.IncludeEnvironmentVariables = defaults.IncludeEnvironmentVariables; target.IncludeEnvironmentVariables = defaults.IncludeEnvironmentVariables;
target.EnvironmentPrefix = defaults.EnvironmentPrefix; target.EnvironmentPrefix = defaults.EnvironmentPrefix;
@@ -159,31 +167,20 @@ public static class ConfigDefaults
if (defaults.IncludeEnvironmentSpecificFile && !string.IsNullOrWhiteSpace(defaults.EnvironmentName)) if (defaults.IncludeEnvironmentSpecificFile && !string.IsNullOrWhiteSpace(defaults.EnvironmentName))
{ {
var envFile = BuildEnvironmentSpecificFile(defaults.MainFile, defaults.EnvironmentName!); var envFile = BuildEnvironmentSpecificFile(defaults.MainFile, defaults.EnvironmentName!);
target.Sources.Add(CreateSource(envFile, optional: true)); target.Sources.Add(CreateSource(envFile, true));
} }
foreach (var file in defaults.AdditionalFiles) foreach (var file in defaults.AdditionalFiles) target.Sources.Add(CreateSource(file, true));
{
target.Sources.Add(CreateSource(file, optional: true));
}
foreach (var source in defaults.GetFluentSources()) foreach (var source in defaults.GetFluentSources()) target.Sources.Add(source);
{
target.Sources.Add(source);
}
foreach (var source in defaults.AdditionalSources) foreach (var source in defaults.AdditionalSources) target.Sources.Add(source);
{
target.Sources.Add(source);
}
} }
private static ConfigSource CreateSource(string path, bool optional) private static ConfigSource CreateSource(string path, bool optional)
{ {
if (!ConfigSource.TryGuess(path, out var guessed)) if (!ConfigSource.TryGuess(path, out var guessed))
{
throw new NotSupportedException($"Unsupported config extension: {path}"); throw new NotSupportedException($"Unsupported config extension: {path}");
}
return new ConfigSource return new ConfigSource
{ {
+32 -59
View File
@@ -24,18 +24,11 @@ public sealed class ConfigManager
{ {
var builder = new ConfigurationBuilder(); var builder = new ConfigurationBuilder();
if (!string.IsNullOrWhiteSpace(options.BasePath)) if (!string.IsNullOrWhiteSpace(options.BasePath)) builder.SetBasePath(options.BasePath!);
{
builder.SetBasePath(options.BasePath!);
}
if (options.FileProvider is not null) if (options.FileProvider is not null) builder.SetFileProvider(options.FileProvider);
{
builder.SetFileProvider(options.FileProvider);
}
foreach (var src in options.Sources) foreach (var src in options.Sources)
{
switch (src.Format) switch (src.Format)
{ {
case ConfigFormat.Json: case ConfigFormat.Json:
@@ -46,7 +39,7 @@ public sealed class ConfigManager
// 额外规则:将全局键复制到所有已声明的一级节中(若该节未显式定义该键), // 额外规则:将全局键复制到所有已声明的一级节中(若该节未显式定义该键),
// 以便像 "User=..." 可同时影响 "Database:User" 这类常见节。 // 以便像 "User=..." 可同时影响 "Database:User" 这类常见节。
var iniPath = options.BasePath is { Length: > 0 } var iniPath = options.BasePath is { Length: > 0 }
? System.IO.Path.Combine(options.BasePath!, src.Path) ? Path.Combine(options.BasePath!, src.Path)
: src.Path; : src.Path;
if (!File.Exists(iniPath)) if (!File.Exists(iniPath))
{ {
@@ -64,7 +57,7 @@ public sealed class ConfigManager
case ConfigFormat.Toml: case ConfigFormat.Toml:
// 解析 TOML 并以内存提供器注入(当前测试未要求 TOML 热更新) // 解析 TOML 并以内存提供器注入(当前测试未要求 TOML 热更新)
var tomlPath = options.BasePath is { Length: > 0 } var tomlPath = options.BasePath is { Length: > 0 }
? System.IO.Path.Combine(options.BasePath!, src.Path) ? Path.Combine(options.BasePath!, src.Path)
: src.Path; : src.Path;
if (!File.Exists(tomlPath)) if (!File.Exists(tomlPath))
{ {
@@ -76,9 +69,8 @@ public sealed class ConfigManager
var text = File.ReadAllText(tomlPath, new UTF8Encoding(false)); var text = File.ReadAllText(tomlPath, new UTF8Encoding(false));
var parse = Toml.Parse(text); var parse = Toml.Parse(text);
if (parse.HasErrors) if (parse.HasErrors)
{ throw new InvalidDataException($"Failed to load configuration from file '{tomlPath}'.",
throw new InvalidDataException($"Failed to load configuration from file '{tomlPath}'.", new FormatException("TOML parse failed")); new FormatException("TOML parse failed"));
}
var model = parse.ToModel(); var model = parse.ToModel();
var pairs = FlattenTomlModel(model); var pairs = FlattenTomlModel(model);
builder.AddInMemoryCollection(pairs); builder.AddInMemoryCollection(pairs);
@@ -86,7 +78,6 @@ public sealed class ConfigManager
default: default:
throw new NotSupportedException($"Unsupported format: {src.Format}"); throw new NotSupportedException($"Unsupported format: {src.Format}");
} }
}
if (options.IncludeEnvironmentVariables) if (options.IncludeEnvironmentVariables)
{ {
@@ -97,10 +88,7 @@ public sealed class ConfigManager
// - 同时兼容 "APP__Name" 这类紧跟双下划线的写法(用户容易误用),通过再注册一次 "APP__" 前缀捕获 // - 同时兼容 "APP__Name" 这类紧跟双下划线的写法(用户容易误用),通过再注册一次 "APP__" 前缀捕获
var pfx = options.EnvironmentPrefix; var pfx = options.EnvironmentPrefix;
builder.AddEnvironmentVariables(pfx); builder.AddEnvironmentVariables(pfx);
if (pfx.EndsWith("_")) if (pfx.EndsWith("_")) builder.AddEnvironmentVariables(pfx + "_");
{
builder.AddEnvironmentVariables(pfx + "_");
}
} }
else else
{ {
@@ -109,9 +97,7 @@ public sealed class ConfigManager
} }
if (options.ConfigureBuilderHook is not null) if (options.ConfigureBuilderHook is not null)
{
builder = (ConfigurationBuilder)options.ConfigureBuilderHook(builder); builder = (ConfigurationBuilder)options.ConfigureBuilderHook(builder);
}
var configuration = builder.Build(); var configuration = builder.Build();
return new ConfigManager(configuration, options); return new ConfigManager(configuration, options);
@@ -123,27 +109,20 @@ public sealed class ConfigManager
{ {
var target = new T(); var target = new T();
if (string.IsNullOrWhiteSpace(section)) if (string.IsNullOrWhiteSpace(section))
{
Configuration.Bind(target); Configuration.Bind(target);
}
else else
{
Configuration.GetSection(section).Bind(target); Configuration.GetSection(section).Bind(target);
}
if (validate) if (validate) ValidateObject(target, true);
{
ValidateObject(target, throwOnError: true);
}
return target; return target;
} }
public bool TryGet<T>(out T instance, string? section = null, bool validate = false) where T : new() public bool TryGet<T>(out T instance, string? section = null, bool validate = false) where T : new()
{ {
instance = Get<T>(section, validate: false); instance = Get<T>(section, false);
if (!validate) return true; if (!validate) return true;
var results = ValidateObject(instance, throwOnError: false); var results = ValidateObject(instance, false);
return results.Count == 0; return results.Count == 0;
} }
@@ -153,10 +132,7 @@ public sealed class ConfigManager
var visited = new HashSet<object>(ReferenceEqualityComparer.Instance); var visited = new HashSet<object>(ReferenceEqualityComparer.Instance);
RecursiveValidate(instance, results, visited); RecursiveValidate(instance, results, visited);
if (throwOnError && results.Count > 0) if (throwOnError && results.Count > 0) throw new ConfigurationValidationException(results);
{
throw new ConfigurationValidationException(results);
}
return results; return results;
} }
@@ -170,7 +146,7 @@ public sealed class ConfigManager
if (IsSimple(type)) return; if (IsSimple(type)) return;
var context = new ValidationContext(instance); var context = new ValidationContext(instance);
Validator.TryValidateObject(instance, context, results, validateAllProperties: true); Validator.TryValidateObject(instance, context, results, true);
foreach (var prop in type.GetProperties()) foreach (var prop in type.GetProperties())
{ {
@@ -180,30 +156,27 @@ public sealed class ConfigManager
if (IsSimple(pType)) continue; if (IsSimple(pType)) continue;
if (value is System.Collections.IEnumerable enumerable && value is not string) if (value is System.Collections.IEnumerable enumerable && value is not string)
{
foreach (var item in enumerable) foreach (var item in enumerable)
{ {
if (item is null) continue; if (item is null) continue;
RecursiveValidate(item, results, visited); RecursiveValidate(item, results, visited);
} }
}
else else
{
RecursiveValidate(value, results, visited); RecursiveValidate(value, results, visited);
}
} }
} }
private static bool IsSimple(Type t) private static bool IsSimple(Type t)
{ {
t = Nullable.GetUnderlyingType(t) ?? t; t = Nullable.GetUnderlyingType(t) ?? t;
return t.IsPrimitive || t.IsEnum || t == typeof(string) || t == typeof(decimal) || t == typeof(DateTime) || t == typeof(Guid) || t == typeof(DateTimeOffset) || t == typeof(TimeSpan); return t.IsPrimitive || t.IsEnum || t == typeof(string) || t == typeof(decimal) || t == typeof(DateTime) ||
t == typeof(Guid) || t == typeof(DateTimeOffset) || t == typeof(TimeSpan);
} }
// Save APIs // Save APIs
public void Save<T>(T instance, string path, ConfigFormat format, bool indented = true, Encoding? encoding = null) public void Save<T>(T instance, string path, ConfigFormat format, bool indented = true, Encoding? encoding = null)
{ {
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(path)) ?? "."); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path)) ?? ".");
var serializer = SerializerFactory.Get(format); var serializer = SerializerFactory.Get(format);
serializer.SerializeToFile(path, instance!, indented, encoding); serializer.SerializeToFile(path, instance!, indented, encoding);
} }
@@ -247,12 +220,12 @@ public sealed class ConfigManager
yield break; yield break;
case TomlArray arr: case TomlArray arr:
for (var i = 0; i < arr.Count; i++) for (var i = 0; i < arr.Count; i++)
{ foreach (var p in WalkValue(key + ":" + i, arr[i]))
foreach (var p in WalkValue(key + ":" + i, arr[i])) yield return p; yield return p;
}
yield break; yield break;
default: default:
yield return new KeyValuePair<string, string?>(key, Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)); yield return new KeyValuePair<string, string?>(key,
Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture));
yield break; yield break;
} }
} }
@@ -275,6 +248,7 @@ public sealed class ConfigManager
current = sections[name] = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase); current = sections[name] = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
continue; continue;
} }
var idx = line.IndexOf('='); var idx = line.IndexOf('=');
if (idx <= 0) continue; if (idx <= 0) continue;
var key = line.Substring(0, idx).Trim(); var key = line.Substring(0, idx).Trim();
@@ -293,35 +267,34 @@ public sealed class ConfigManager
foreach (var (sectionName, map) in sections) foreach (var (sectionName, map) in sections)
{ {
foreach (var kv in root) foreach (var kv in root)
{
if (!map.ContainsKey(kv.Key)) if (!map.ContainsKey(kv.Key))
{
yield return new KeyValuePair<string, string?>(sectionName + ":" + kv.Key, kv.Value); yield return new KeyValuePair<string, string?>(sectionName + ":" + kv.Key, kv.Value);
}
}
foreach (var kv in map) foreach (var kv in map)
{
yield return new KeyValuePair<string, string?>(sectionName + ":" + kv.Key, kv.Value); yield return new KeyValuePair<string, string?>(sectionName + ":" + kv.Key, kv.Value);
}
} }
} }
} }
public static class SerializerFactory public static class SerializerFactory
{ {
public static IConfigSerializer Get(ConfigFormat format) => format switch public static IConfigSerializer Get(ConfigFormat format)
{ {
ConfigFormat.Json => new JsonConfigSerializer(), return format switch
ConfigFormat.Xml => new XmlConfigSerializer(), {
ConfigFormat.Ini => new IniConfigSerializer(), ConfigFormat.Json => new JsonConfigSerializer(),
ConfigFormat.Toml => new TomlConfigSerializer(), ConfigFormat.Xml => new XmlConfigSerializer(),
_ => throw new NotSupportedException($"Unsupported format: {format}") ConfigFormat.Ini => new IniConfigSerializer(),
}; ConfigFormat.Toml => new TomlConfigSerializer(),
_ => throw new NotSupportedException($"Unsupported format: {format}")
};
}
} }
public sealed class ConfigurationValidationException : Exception public sealed class ConfigurationValidationException : Exception
{ {
public IReadOnlyList<ValidationResult> Errors { get; } public IReadOnlyList<ValidationResult> Errors { get; }
public ConfigurationValidationException(IReadOnlyList<ValidationResult> errors) public ConfigurationValidationException(IReadOnlyList<ValidationResult> errors)
: base($"Configuration validation failed: {string.Join("; ", errors.Select(e => e.ErrorMessage))}") : base($"Configuration validation failed: {string.Join("; ", errors.Select(e => e.ErrorMessage))}")
{ {
+4 -1
View File
@@ -8,7 +8,10 @@ public sealed class ConfigSource
public required ConfigFormat Format { get; init; } public required ConfigFormat Format { get; init; }
public bool Optional { get; init; } = true; public bool Optional { get; init; } = true;
public override string ToString() => $"{Format}:{Path} (Optional={Optional})"; public override string ToString()
{
return $"{Format}:{Path} (Optional={Optional})";
}
public static bool TryGuess(string path, [NotNullWhen(true)] out ConfigSource? source) public static bool TryGuess(string path, [NotNullWhen(true)] out ConfigSource? source)
{ {
@@ -35,7 +35,10 @@ internal sealed class ConfigAccessor<T> : IConfigAccessor<T>, IDisposable where
public T Value => _current; public T Value => _current;
public void Dispose() => _subscription.Dispose(); public void Dispose()
{
_subscription.Dispose();
}
} }
public static class ConfigServiceCollectionExtensions public static class ConfigServiceCollectionExtensions
+31 -25
View File
@@ -1,22 +1,24 @@
# Config 模块 # 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` - JSON / XML:使用 Microsoft 官方 provider,并支持 `ReloadOnChange`
- INI / TOML:内置轻量解析,注入为内存配置源(当前默认不启用文件级热更新) - INI / TOML:内置轻量解析,注入为内存配置源(当前默认不启用文件级热更新)
- 多格式配置写入:支持将强类型对象写入为 JSON / XML / INI / TOML 文件 - 多格式配置写入:支持将强类型对象写入为 JSON / XML / INI / TOML 文件
- 强类型绑定:`Get<T>()` / `TryGet<T>()` 直接从 IConfiguration 绑定到 POCO - 强类型绑定:`Get<T>()` / `TryGet<T>()` 直接从 IConfiguration 绑定到 POCO
- 数据注解验证:`[Required]` / `[Range]` 等;支持递归验证嵌套对象与集合 - 数据注解验证:`[Required]` / `[Range]` 等;支持递归验证嵌套对象与集合
- 环境变量合并:可设置前缀,合并到配置树(优先级最高) - 环境变量合并:可设置前缀,合并到配置树(优先级最高)
- 热更新(可选):JSON / XML 支持文件监控自动重载;回调 `OnReload` - 热更新(可选):JSON / XML 支持文件监控自动重载;回调 `OnReload`
- 可扩展: - 可扩展:
- `ConfigOptions.ConfigureBuilderHook`:注入外部对 `IConfigurationBuilder` 的自定义构建(可与 Common 模块对接) - `ConfigOptions.ConfigureBuilderHook`:注入外部对 `IConfigurationBuilder` 的自定义构建(可与 Common 模块对接)
- `AddConfig(IServiceCollection, ...)`:便捷注册到 DI 容器 - `AddConfig(IServiceCollection, ...)`:便捷注册到 DI 容器
## 架构与设计 ## 架构与设计
@@ -27,12 +29,13 @@
- `ConfigSource`:单个配置源描述(路径、格式、是否可选) - `ConfigSource`:单个配置源描述(路径、格式、是否可选)
- `ConfigBuilder`:便捷构建器(从文件列表快速构建) - `ConfigBuilder`:便捷构建器(从文件列表快速构建)
- 序列化器: - 序列化器:
- `Serialization/JsonConfigSerializer` - `Serialization/JsonConfigSerializer`
- `Serialization/XmlConfigSerializer` - `Serialization/XmlConfigSerializer`
- `Serialization/IniConfigSerializer` - `Serialization/IniConfigSerializer`
- `Serialization/TomlConfigSerializer` - `Serialization/TomlConfigSerializer`
注意:当前 INI / TOML 的读取在构建期解析为键值对注入,不启用文件级热更新;JSON / XML 使用官方 provider,支持 `ReloadOnChange`。 注意:当前 INI / TOML 的读取在构建期解析为键值对注入,不启用文件级热更新;JSON / XML 使用官方 provider,支持
`ReloadOnChange`。
## 使用方式 ## 使用方式
@@ -73,7 +76,8 @@ var cm = Config.ConfigBuilder.BuildFromFiles(
); );
``` ```
环境变量键:使用双下划线分隔层级,例如 `APP__Database__Host`。如果设置了 `environmentPrefix: "APP_"`,也兼容 `APP__Name` 这类书写,但推荐前缀不以 `_` 结尾,例如 `"APP"`。 环境变量键:使用双下划线分隔层级,例如 `APP__Database__Host`。如果设置了 `environmentPrefix: "APP_"`,也兼容 `APP__Name`
这类书写,但推荐前缀不以 `_` 结尾,例如 `"APP"`。
### 2) 强类型绑定与验证 ### 2) 强类型绑定与验证
@@ -175,33 +179,35 @@ var cm = provider.GetRequiredService<ConfigManager>();
- 配置源合并策略:后添加覆盖先添加(与官方一致) - 配置源合并策略:后添加覆盖先添加(与官方一致)
- INI 读取: - INI 读取:
- 将“根键(未在任何节中)”与“节内键”一起展平成键路径 - 将“根键(未在任何节中)”与“节内键”一起展平成键路径
- 当前默认行为:将根键复制到所有已声明的一级节(若该节未定义同名键)。此举旨在方便简化配置,但可能与部分 INI 规范理解有差异;如需关闭或自定义,可在后续版本提供开关 - 当前默认行为:将根键复制到所有已声明的一级节(若该节未定义同名键)。此举旨在方便简化配置,但可能与部分 INI
- INI 写入:支持简单类型与一层嵌套对象 规范理解有差异;如需关闭或自定义,可在后续版本提供开关
- INI 写入:支持简单类型与一层嵌套对象
- TOML 读取: - TOML 读取:
- 解析为键值对注入,默认不启用文件级热更新 - 解析为键值对注入,默认不启用文件级热更新
- 直接 `LoadFromFile<T>(..., Toml)` 暂不支持;请通过配置管线绑定获取强类型 - 直接 `LoadFromFile<T>(..., Toml)` 暂不支持;请通过配置管线绑定获取强类型
- 环境变量前缀: - 环境变量前缀:
- 推荐使用不带下划线的短前缀(如 `APP`)+ 双下划线分隔层级(`APP__Section__Key`) - 推荐使用不带下划线的短前缀(如 `APP`)+ 双下划线分隔层级(`APP__Section__Key`)
- 模块对 `"APP_"` 这类带下划线结尾的前缀做了兼容处理,但仍建议遵循推荐写法 - 模块对 `"APP_"` 这类带下划线结尾的前缀做了兼容处理,但仍建议遵循推荐写法
- 验证: - 验证:
- 使用 DataAnnotations,递归验证嵌套对象与集合 - 使用 DataAnnotations,递归验证嵌套对象与集合
- 性能: - 性能:
- INI/TOML 读取为一次性解析(非流式);适合小中等规模配置。大型配置建议 JSON/XML - INI/TOML 读取为一次性解析(非流式);适合小中等规模配置。大型配置建议 JSON/XML
## 常见问题(FAQ) ## 常见问题(FAQ)
1. 为什么 INI/TOML 没有热更新? 1. 为什么 INI/TOML 没有热更新?
- 当前实现为轻量解析注入内存,未挂载文件监控。JSON/XML 已可直接使用热更新。若需要对 INI/TOML 启用热更新,可按 `FileConfigurationProvider` 模式扩展(可在后续版本提供)。 - 当前实现为轻量解析注入内存,未挂载文件监控。JSON/XML 已可直接使用热更新。若需要对 INI/TOML 启用热更新,可按
`FileConfigurationProvider` 模式扩展(可在后续版本提供)。
2. 环境变量用法推荐? 2. 环境变量用法推荐?
- 使用 `environmentPrefix: "APP"`,并设置 `APP__Section__Key`。尽量避免在前缀尾部加 `_`,可减少歧义。 - 使用 `environmentPrefix: "APP"`,并设置 `APP__Section__Key`。尽量避免在前缀尾部加 `_`,可减少歧义。
3. TOML 的写入后如何强类型读取? 3. TOML 的写入后如何强类型读取?
- 通过配置构建(Add... + 绑定)获取强类型对象;`LoadFromFile<T>` 暂不支持 TOML 直接反序列化。 - 通过配置构建(Add... + 绑定)获取强类型对象;`LoadFromFile<T>` 暂不支持 TOML 直接反序列化。
4. 能否与 Common 模块集成? 4. 能否与 Common 模块集成?
- 可以,通过 `ConfigOptions.ConfigureBuilderHook` 把 Common 的扩展注入到构建管线。 - 可以,通过 `ConfigOptions.ConfigureBuilderHook` 把 Common 的扩展注入到构建管线。
## 变更建议与后续计划 ## 变更建议与后续计划
@@ -9,18 +9,13 @@ public sealed class IniConfigSerializer : IConfigSerializer
{ {
var (root, sections) = ObjectToIniMaps(instance); var (root, sections) = ObjectToIniMaps(instance);
var sb = new StringBuilder(); var sb = new StringBuilder();
foreach (var kv in root) foreach (var kv in root) sb.Append(kv.Key).Append('=').Append(kv.Value).Append('\n');
{
sb.Append(kv.Key).Append('=').Append(kv.Value).Append('\n');
}
foreach (var section in sections) foreach (var section in sections)
{ {
sb.Append('\n').Append('[').Append(section.Key).Append(']').Append('\n'); sb.Append('\n').Append('[').Append(section.Key).Append(']').Append('\n');
foreach (var kv in section.Value) foreach (var kv in section.Value) sb.Append(kv.Key).Append('=').Append(kv.Value).Append('\n');
{
sb.Append(kv.Key).Append('=').Append(kv.Value).Append('\n');
}
} }
return sb.ToString(); return sb.ToString();
} }
@@ -48,7 +43,8 @@ public sealed class IniConfigSerializer : IConfigSerializer
return Deserialize<T>(text); return Deserialize<T>(text);
} }
private static (Dictionary<string, string> root, Dictionary<string, Dictionary<string, string>> sections) ObjectToIniMaps<T>(T instance) private static (Dictionary<string, string> root, Dictionary<string, Dictionary<string, string>> sections)
ObjectToIniMaps<T>(T instance)
{ {
var root = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var root = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var sections = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase); var sections = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
@@ -71,15 +67,10 @@ public sealed class IniConfigSerializer : IConfigSerializer
{ {
var innerVal = inner.GetValue(value); var innerVal = inner.GetValue(value);
if (innerVal is null) continue; if (innerVal is null) continue;
if (IsSimple(inner.PropertyType)) if (IsSimple(inner.PropertyType)) sec[inner.Name] = Convert.ToString(innerVal) ?? string.Empty;
{
sec[inner.Name] = Convert.ToString(innerVal) ?? string.Empty;
}
}
if (sec.Count > 0)
{
sections[prop.Name] = sec;
} }
if (sec.Count > 0) sections[prop.Name] = sec;
} }
} }
@@ -110,13 +101,9 @@ public sealed class IniConfigSerializer : IConfigSerializer
var value = line.Substring(idx + 1).Trim(); var value = line.Substring(idx + 1).Trim();
if (currentSection is null) if (currentSection is null)
{
dict[key] = value; dict[key] = value;
}
else else
{
currentSection[key] = value; currentSection[key] = value;
}
} }
return dict; return dict;
@@ -125,24 +112,17 @@ public sealed class IniConfigSerializer : IConfigSerializer
private static IEnumerable<KeyValuePair<string, string?>> FlattenToPairs(Dictionary<string, object?> dict) private static IEnumerable<KeyValuePair<string, string?>> FlattenToPairs(Dictionary<string, object?> dict)
{ {
foreach (var (key, value) in dict) foreach (var (key, value) in dict)
{
if (value is Dictionary<string, object?> nested) if (value is Dictionary<string, object?> nested)
{
foreach (var inner in nested) foreach (var inner in nested)
{
yield return new KeyValuePair<string, string?>(key + ":" + inner.Key, inner.Value?.ToString()); yield return new KeyValuePair<string, string?>(key + ":" + inner.Key, inner.Value?.ToString());
}
}
else else
{
yield return new KeyValuePair<string, string?>(key, value?.ToString()); yield return new KeyValuePair<string, string?>(key, value?.ToString());
}
}
} }
private static bool IsSimple(Type t) private static bool IsSimple(Type t)
{ {
t = Nullable.GetUnderlyingType(t) ?? t; t = Nullable.GetUnderlyingType(t) ?? t;
return t.IsPrimitive || t.IsEnum || t == typeof(string) || t == typeof(decimal) || t == typeof(DateTime) || t == typeof(Guid) || t == typeof(DateTimeOffset) || t == typeof(TimeSpan); return t.IsPrimitive || t.IsEnum || t == typeof(string) || t == typeof(decimal) || t == typeof(DateTime) ||
t == typeof(Guid) || t == typeof(DateTimeOffset) || t == typeof(TimeSpan);
} }
} }
@@ -25,7 +25,9 @@ public sealed class JsonConfigSerializer : IConfigSerializer
} }
public T? Deserialize<T>(string content) public T? Deserialize<T>(string content)
=> JsonSerializer.Deserialize<T>(content, _options); {
return JsonSerializer.Deserialize<T>(content, _options);
}
public void SerializeToFile<T>(string path, T instance, bool indented = true, Encoding? encoding = null) public void SerializeToFile<T>(string path, T instance, bool indented = true, Encoding? encoding = null)
{ {
-503
View File
@@ -1,503 +0,0 @@
using System.Data;
using System.Data.OleDb;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.Logging;
using Sharp.Data.Access.Connections;
using Sharp.Data.Access.Initialization;
using Sharp.Data.Access.Internal;
using Sharp.Data.Access.Options;
using Sharp.Data.Access.Security;
namespace Sharp.Data.Access;
/// <summary>
/// Access 数据库事务实现
/// </summary>
internal class AccessTransaction : IAccessTransaction
{
private readonly OleDbConnection _connection;
private readonly OleDbTransaction _transaction;
private readonly ILogger? _logger;
private readonly bool _ownsConnection;
private bool _disposed;
public AccessTransaction(OleDbConnection connection, bool ownsConnection, ILogger? logger)
{
_connection = connection;
_ownsConnection = ownsConnection;
_transaction = connection.BeginTransaction();
_logger = logger;
}
public int Execute(string sql, params OleDbParameter[] parameters)
{
using var command = new OleDbCommand(sql, _connection, _transaction);
if (parameters.Length > 0)
command.Parameters.AddRange(parameters);
return command.ExecuteNonQuery();
}
public List<T> Query<T>(string sql, params OleDbParameter[] parameters) where T : new()
{
using var command = new OleDbCommand(sql, _connection, _transaction);
if (parameters.Length > 0)
command.Parameters.AddRange(parameters);
var results = new List<T>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var item = MapReaderToObject<T>(reader);
results.Add(item);
}
return results;
}
public object? ExecuteScalar(string sql, params OleDbParameter[] parameters)
{
using var command = new OleDbCommand(sql, _connection, _transaction);
if (parameters.Length > 0)
command.Parameters.AddRange(parameters);
return command.ExecuteScalar();
}
public void Commit()
{
_transaction.Commit();
_logger?.LogDebug("事务已提交");
}
public void Rollback()
{
_transaction.Rollback();
_logger?.LogDebug("事务已回滚");
}
public void Dispose()
{
if (_disposed) return;
try
{
_transaction.Dispose();
}
finally
{
if (_ownsConnection)
{
_connection.Dispose();
}
}
_disposed = true;
}
private static T MapReaderToObject<T>(IDataReader reader) where T : new()
{
var obj = new T();
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
// 创建字段名到索引的映射(大小写不敏感)
var fieldMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < reader.FieldCount; i++)
{
var fieldName = reader.GetName(i);
fieldMap[fieldName] = i;
// Access 列名有时包含末尾空格,提前做一次修剪以便映射
var trimmed = fieldName.Trim();
if (!fieldMap.ContainsKey(trimmed))
{
fieldMap[trimmed] = i;
}
}
foreach (var property in properties)
{
if (!property.CanWrite) continue;
try
{
// 尝试查找匹配的字段(大小写不敏感)
if (fieldMap.TryGetValue(property.Name, out int ordinal))
{
var value = reader.GetValue(ordinal);
if (value != DBNull.Value && value != null)
{
// 尝试类型转换
if (property.PropertyType == value.GetType())
{
property.SetValue(obj, value);
}
else if (property.PropertyType == typeof(string))
{
property.SetValue(obj, value.ToString());
}
else
{
// 尝试使用 Convert.ChangeType
var convertedValue = Convert.ChangeType(value, property.PropertyType);
property.SetValue(obj, convertedValue);
}
}
}
}
catch
{
// 字段不存在或类型转换失败,保持默认值
}
}
return obj;
}
}
/// <summary>
/// Access 数据服务实现(基于 System.Data.OleDb)
/// </summary>
public class AccessDataService : IAccessDataService
{
private readonly AccessDatabaseOptions _options;
private readonly IAccessConnectionStringFactory _connectionFactory;
private readonly IAccessDatabaseInitializer _initializer;
private readonly IAccessPasswordDecryptor _decryptor;
private readonly ILogger<AccessDataService>? _logger;
private readonly string _connectionString;
public AccessDataService(
AccessDatabaseOptions options,
IAccessConnectionStringFactory? connectionFactory = null,
IAccessDatabaseInitializer? initializer = null,
IAccessPasswordDecryptor? decryptor = null,
ILogger<AccessDataService>? logger = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_options.Validate();
_connectionFactory = connectionFactory ?? new AccessConnectionStringFactory(decryptor ?? new PassthroughAccessPasswordDecryptor());
_initializer = initializer ?? new AccessDatabaseInitializer();
_decryptor = decryptor ?? new PassthroughAccessPasswordDecryptor();
_logger = logger;
try
{
// 确保数据库存在(如果配置了模板复制)
_initializer.EnsureDatabase(_options, NullServiceProvider.Instance);
// 构建连接字符串
_connectionString = _connectionFactory.Create(_options, NullServiceProvider.Instance);
_logger?.LogInformation("AccessDataService 已初始化,数据库: {DatabaseFile}, Provider: {Provider}",
_options.DatabaseFile, _options.ProviderName);
_logger?.LogDebug("实际连接字符串: {ConnectionString}", _connectionString);
}
catch (Exception ex)
{
_logger?.LogError(ex, "AccessDataService 初始化失败");
// 提供详细的错误诊断信息
if (ex.Message.Contains("找不到") || ex is FileNotFoundException)
{
throw new InvalidOperationException(
$"找不到 Access 数据库文件: {_options.DatabaseFile}。" +
$"请确保文件存在或配置 TemplateFile 以自动创建。", ex);
}
throw;
}
}
public List<T> Query<T>(string sql, params OleDbParameter[] parameters) where T : new()
{
_logger?.LogDebug("执行查询: {Sql}", sql);
return ExecuteWithRetry(() =>
{
using var connection = CreateConnection();
connection.Open();
using var command = new OleDbCommand(sql, connection);
if (parameters.Length > 0)
{
// 克隆参数以避免重试时的"已包含在另一个集合中"错误
var clonedParams = parameters.Select(p => CloneParameter(p)).ToArray();
command.Parameters.AddRange(clonedParams);
}
var results = new List<T>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var item = MapReaderToObject<T>(reader);
results.Add(item);
}
_logger?.LogDebug("查询返回 {Count} 条记录", results.Count);
return results;
});
}
public T? QueryFirstOrDefault<T>(string sql, params OleDbParameter[] parameters) where T : new()
{
_logger?.LogDebug("执行单条查询: {Sql}", sql);
return ExecuteWithRetry(() =>
{
using var connection = CreateConnection();
connection.Open();
using var command = new OleDbCommand(sql, connection);
if (parameters.Length > 0)
{
// 克隆参数以避免重试时的"已包含在另一个集合中"错误
var clonedParams = parameters.Select(p => CloneParameter(p)).ToArray();
command.Parameters.AddRange(clonedParams);
}
using var reader = command.ExecuteReader();
if (reader.Read())
{
return MapReaderToObject<T>(reader);
}
return default;
});
}
public object? ExecuteScalar(string sql, params OleDbParameter[] parameters)
{
_logger?.LogDebug("执行标量查询: {Sql}", sql);
return ExecuteWithRetry(() =>
{
using var connection = CreateConnection();
connection.Open();
using var command = new OleDbCommand(sql, connection);
if (parameters.Length > 0)
{
// 克隆参数以避免重试时的"已包含在另一个集合中"错误
var clonedParams = parameters.Select(p => CloneParameter(p)).ToArray();
command.Parameters.AddRange(clonedParams);
}
var result = command.ExecuteScalar();
_logger?.LogDebug("标量查询返回: {Result}", result);
return result;
});
}
public int Execute(string sql, params OleDbParameter[] parameters)
{
_logger?.LogDebug("执行命令: {Sql}", sql);
return ExecuteWithRetry(() =>
{
using var connection = CreateConnection();
connection.Open();
using var command = new OleDbCommand(sql, connection);
if (parameters.Length > 0)
{
// 克隆参数以避免重试时的"已包含在另一个集合中"错误
var clonedParams = parameters.Select(p => CloneParameter(p)).ToArray();
command.Parameters.AddRange(clonedParams);
}
var rowsAffected = command.ExecuteNonQuery();
_logger?.LogDebug("命令影响了 {RowsAffected} 行", rowsAffected);
return rowsAffected;
});
}
public IAccessTransaction BeginTransaction()
{
_logger?.LogDebug("开始事务");
var connection = CreateConnection();
connection.Open();
return new AccessTransaction(connection, ownsConnection: true, _logger);
}
public void ExecuteInTransaction(Action<IAccessTransaction> action)
{
_logger?.LogDebug("在事务中执行操作");
using var transaction = BeginTransaction();
try
{
action(transaction);
transaction.Commit();
_logger?.LogDebug("事务执行成功");
}
catch (Exception ex)
{
_logger?.LogError(ex, "事务执行失败,正在回滚");
transaction.Rollback();
throw;
}
}
public bool TestConnection()
{
try
{
_logger?.LogDebug("测试数据库连接");
using var connection = CreateConnection();
connection.Open();
_logger?.LogDebug("数据库连接成功");
return true;
}
catch (Exception ex)
{
_logger?.LogError(ex, "数据库连接失败");
return false;
}
}
private OleDbConnection CreateConnection()
{
return new OleDbConnection(_connectionString);
}
private T ExecuteWithRetry<T>(Func<T> operation)
{
var maxRetries = _options.MaxRetryCount;
var retryDelay = _options.RetryDelayMilliseconds;
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
return operation();
}
catch (OleDbException ex) when (attempt < maxRetries && IsRetriableError(ex))
{
_logger?.LogWarning(ex, "数据库操作失败,正在重试 ({Attempt}/{MaxRetries})...", attempt + 1, maxRetries);
Thread.Sleep(retryDelay);
}
catch (OleDbException ex)
{
// 提供详细的错误诊断信息
var errorMessage = $"Access 数据库操作失败: {ex.Message}";
// 检查是否是 Provider 相关的错误
if (ex.Message.Contains("找不到") || ex.Message.Contains("未注册") ||
ex.Message.Contains("OLE DB") || ex.Message.Contains("多步"))
{
errorMessage += $"\n\n可能的原因:" +
$"\n1. 未安装 Access Database Engine:当前使用 Provider = {_options.ProviderName}" +
$"\n - 如果使用 16.0,需要安装 Access Database Engine 2016 或 Office 2016+" +
$"\n - 如果使用 12.0,需要安装 Access Database Engine 2010 或 Office 2010+" +
$"\n - 建议:优先使用 12.0 版本,兼容性最好" +
$"\n2. 32位/64位不匹配:进程架构必须与 Access Driver 架构一致" +
$"\n3. 数据库文件损坏或被其他程序锁定" +
$"\n4. 数据库文件路径不正确:{_options.DatabaseFile}";
_logger?.LogError(ex, "数据库连接失败,Provider: {Provider}", _options.ProviderName);
}
throw new InvalidOperationException(errorMessage, ex);
}
}
// 不会到这里,因为最后一次尝试会抛出异常
throw new InvalidOperationException("重试逻辑错误");
}
private static bool IsRetriableError(OleDbException ex)
{
// Access 数据库常见的可重试错误
// -2147467259: 数据库被锁定
// -2147217865: 无法打开数据库(可能正在被其他进程使用)
return ex.ErrorCode == -2147467259 || ex.ErrorCode == -2147217865;
}
/// <summary>
/// 克隆 OleDbParameter 以避免重试时参数被重复添加的问题
/// </summary>
private static OleDbParameter CloneParameter(OleDbParameter original)
{
var clone = new OleDbParameter
{
ParameterName = original.ParameterName,
Value = original.Value,
OleDbType = original.OleDbType,
Size = original.Size,
Direction = original.Direction,
IsNullable = original.IsNullable,
Precision = original.Precision,
Scale = original.Scale,
SourceColumn = original.SourceColumn,
SourceVersion = original.SourceVersion
};
return clone;
}
private static T MapReaderToObject<T>(IDataReader reader) where T : new()
{
var obj = new T();
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
// 创建字段名到索引的映射(大小写不敏感)
var fieldMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < reader.FieldCount; i++)
{
var fieldName = reader.GetName(i);
fieldMap[fieldName] = i;
// Access 列名有时包含末尾空格,提前做一次修剪以便映射
var trimmed = fieldName.Trim();
if (!fieldMap.ContainsKey(trimmed))
{
fieldMap[trimmed] = i;
}
}
foreach (var property in properties)
{
if (!property.CanWrite) continue;
try
{
// 尝试查找匹配的字段(大小写不敏感)
if (fieldMap.TryGetValue(property.Name, out int ordinal))
{
var value = reader.GetValue(ordinal);
if (value != DBNull.Value && value != null)
{
// 尝试类型转换
if (property.PropertyType == value.GetType())
{
property.SetValue(obj, value);
}
else if (property.PropertyType == typeof(string))
{
property.SetValue(obj, value.ToString());
}
else
{
// 尝试使用 Convert.ChangeType
var convertedValue = Convert.ChangeType(value, property.PropertyType);
property.SetValue(obj, convertedValue);
}
}
}
}
catch
{
// 字段不存在或类型转换失败,保持默认值
}
}
return obj;
}
}
@@ -1,118 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Sharp.Data.Access.Options;
using Sharp.Data.Access.Security;
using Sharp.Data.Access.Connections;
using Sharp.Data.Access.Initialization;
namespace Sharp.Data.Access;
/// <summary>
/// Access 数据服务的依赖注入扩展
/// </summary>
public static class AccessDataServiceExtensions
{
/// <summary>
/// 添加 Access 数据服务(最简单方式 - 仅指定数据库路径)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="databasePath">数据库文件路径(绝对或相对路径)</param>
/// <param name="password">数据库密码(可选)</param>
/// <param name="provider">OLE DB 提供程序,默认:Microsoft.ACE.OLEDB.16.0</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddAccessDataService(
this IServiceCollection services,
string databasePath,
string? password = null,
string? provider = null)
{
return services.AddAccessDataService(options =>
{
options.DatabaseFile = databasePath;
if (!string.IsNullOrEmpty(password))
options.Password = password;
if (!string.IsNullOrEmpty(provider))
options.ProviderName = provider;
});
}
/// <summary>
/// 添加 Access 数据服务(使用配置委托)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="configureOptions">配置委托</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddAccessDataService(
this IServiceCollection services,
Action<AccessDatabaseOptions> configureOptions)
{
if (services == null) throw new ArgumentNullException(nameof(services));
if (configureOptions == null) throw new ArgumentNullException(nameof(configureOptions));
var options = new AccessDatabaseOptions();
configureOptions(options);
options.Validate();
// 注册核心服务
services.TryAddSingleton<IAccessPasswordDecryptor, PassthroughAccessPasswordDecryptor>();
services.TryAddSingleton<IAccessConnectionStringFactory>(sp =>
new AccessConnectionStringFactory(sp.GetRequiredService<IAccessPasswordDecryptor>()));
services.TryAddSingleton<IAccessDatabaseInitializer, AccessDatabaseInitializer>();
// 注册配置
services.AddSingleton(options);
// 注册 AccessDataService
services.AddSingleton<IAccessDataService>(sp =>
new AccessDataService(
options,
sp.GetRequiredService<IAccessConnectionStringFactory>(),
sp.GetRequiredService<IAccessDatabaseInitializer>(),
sp.GetRequiredService<IAccessPasswordDecryptor>(),
sp.GetService<Microsoft.Extensions.Logging.ILogger<AccessDataService>>()));
return services;
}
/// <summary>
/// 添加 Access 数据服务(使用配置对象)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="options">配置选项</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddAccessDataService(
this IServiceCollection services,
AccessDatabaseOptions options)
{
if (options == null) throw new ArgumentNullException(nameof(options));
return services.AddAccessDataService(_ => { });
}
/// <summary>
/// 注册自定义密码解密器
/// </summary>
/// <typeparam name="TDecryptor">解密器类型</typeparam>
/// <param name="services">服务集合</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddAccessPasswordDecryptor<TDecryptor>(this IServiceCollection services)
where TDecryptor : class, IAccessPasswordDecryptor
{
services.AddSingleton<IAccessPasswordDecryptor, TDecryptor>();
return services;
}
/// <summary>
/// 注册自定义密码解密器(使用工厂方法)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="implementationFactory">解密器工厂</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddAccessPasswordDecryptor(
this IServiceCollection services,
Func<IServiceProvider, IAccessPasswordDecryptor> implementationFactory)
{
services.AddSingleton(implementationFactory);
return services;
}
}
@@ -1,58 +0,0 @@
using System;
using System.Data.OleDb;
using Sharp.Data.Access.Internal;
using Sharp.Data.Access.Options;
using Sharp.Data.Access.Security;
namespace Sharp.Data.Access.Connections;
internal sealed class AccessConnectionStringFactory : IAccessConnectionStringFactory
{
private readonly IAccessPasswordDecryptor _decryptor;
public AccessConnectionStringFactory(IAccessPasswordDecryptor decryptor)
{
_decryptor = decryptor ?? throw new ArgumentNullException(nameof(decryptor));
}
public string Create(AccessDatabaseOptions options, IServiceProvider services)
{
var configuration = AccessDatabaseConfiguration.FromOptions(options, services);
if (!string.IsNullOrWhiteSpace(configuration.ConnectionString))
{
return configuration.ConnectionString;
}
if (string.IsNullOrWhiteSpace(configuration.DatabaseFile))
{
throw new InvalidOperationException("Access 数据库需要提供 DatabaseFile 或 ConnectionString。");
}
// 基础连接字符串
// 注意:Connect Timeout 参数在某些 ACE 版本中不稳定,已移除
var builder = new OleDbConnectionStringBuilder
{
["Provider"] = configuration.ProviderName,
["Data Source"] = AccessPathResolver.Resolve(configuration.DatabaseFile, configuration.DataDirectory)
};
var password = configuration.Password;
if (string.IsNullOrEmpty(password) && !string.IsNullOrWhiteSpace(configuration.EncryptedPassword))
{
password = _decryptor.Decrypt(configuration.EncryptedPassword);
}
if (!string.IsNullOrEmpty(password))
{
builder["Jet OLEDB:Database Password"] = password;
}
foreach (var parameter in configuration.AdditionalParameters)
{
builder[parameter.Key] = parameter.Value;
}
return builder.ConnectionString;
}
}
@@ -1,12 +0,0 @@
using System;
using Sharp.Data.Access.Options;
namespace Sharp.Data.Access.Connections;
/// <summary>
/// 构建 Access 连接字符串的工厂。
/// </summary>
public interface IAccessConnectionStringFactory
{
string Create(AccessDatabaseOptions options, IServiceProvider services);
}
-20
View File
@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Sharp.Data.Access</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EntityFrameworkCore.Jet" Version="8.0.0-preview1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="System.Data.OleDb" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Data\Data.csproj" />
</ItemGroup>
</Project>
@@ -1,72 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Sharp.Data.Access.Connections;
using Sharp.Data.Access.Initialization;
using Sharp.Data.Access.Internal;
using Sharp.Data.Access.Options;
using Sharp.Data.Access.Security;
using Sharp.Data.Context;
namespace Sharp.Data.Access.DependencyInjection;
/// <summary>
/// Access 模块的依赖注入扩展。
/// </summary>
public static class AccessServiceCollectionExtensions
{
public static IServiceCollection AddAccessDataContext<TContext>(this IServiceCollection services,
Action<AccessContextOptions<TContext>> configure,
bool registerDataContext = true,
ServiceLifetime dataContextLifetime = ServiceLifetime.Scoped)
where TContext : DbContext
{
if (configure == null) throw new ArgumentNullException(nameof(configure));
return services.AddAccessDataContext(_ =>
{
var options = new AccessContextOptions<TContext>();
configure(options);
return options;
}, registerDataContext, dataContextLifetime);
}
public static IServiceCollection AddAccessDataContext<TContext>(this IServiceCollection services,
Func<IServiceProvider, AccessContextOptions<TContext>> configure,
bool registerDataContext = true,
ServiceLifetime dataContextLifetime = ServiceLifetime.Scoped)
where TContext : DbContext
{
if (services == null) throw new ArgumentNullException(nameof(services));
if (configure == null) throw new ArgumentNullException(nameof(configure));
services.TryAddSingleton<IAccessPasswordDecryptor, PassthroughAccessPasswordDecryptor>();
services.TryAddSingleton<IAccessConnectionStringFactory>(sp =>
new AccessConnectionStringFactory(sp.GetRequiredService<IAccessPasswordDecryptor>()));
services.TryAddSingleton<IAccessDatabaseInitializer, AccessDatabaseInitializer>();
services.TryAddSingleton(provider =>
{
var options = configure(provider) ?? throw new InvalidOperationException("AccessContextOptions 不能为空。");
options.Validate();
return options;
});
services.AddScoped<IAccessDataContextFactory<TContext>>(sp =>
new AccessDataContextFactory<TContext>(
sp,
sp.GetRequiredService<AccessContextOptions<TContext>>(),
sp.GetRequiredService<IAccessConnectionStringFactory>(),
sp.GetRequiredService<IAccessDatabaseInitializer>()));
if (registerDataContext)
{
services.Add(new ServiceDescriptor(typeof(IDataContext),
sp => sp.GetRequiredService<IAccessDataContextFactory<TContext>>().Create(),
dataContextLifetime));
}
return services;
}
}
@@ -1,66 +0,0 @@
using System;
using EntityFrameworkCore.Jet;
using EntityFrameworkCore.Jet.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Sharp.Data.Access.Connections;
using Sharp.Data.Access.Initialization;
using Sharp.Data.Access.Internal;
using Sharp.Data.Access.Options;
using Sharp.Data.Access.Security;
using Sharp.Data;
namespace Sharp.Data.Access.Extensions;
/// <summary>
/// 手动使用 DataContextBuilder 时的 Access Helper。
/// </summary>
public static class AccessDataContextBuilderExtensions
{
/// <summary>
/// 通过现成的连接字符串配置 Access。
/// </summary>
public static DataContextBuilder<TContext> UseAccessDatabase<TContext>(
this DataContextBuilder<TContext> builder,
string connectionString,
Action<JetDbContextOptionsBuilder>? configure = null)
where TContext : DbContext
{
if (builder == null) throw new ArgumentNullException(nameof(builder));
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentException("连接字符串不能为空。", nameof(connectionString));
}
builder.ConfigureDbContext(options => options.UseJet(connectionString, configure));
return builder;
}
/// <summary>
/// 通过 AccessDatabaseOptions 配置 Access。
/// </summary>
public static DataContextBuilder<TContext> UseAccessDatabase<TContext>(
this DataContextBuilder<TContext> builder,
Action<AccessDatabaseOptions> configureOptions,
IServiceProvider? services = null,
IAccessPasswordDecryptor? decryptor = null,
Action<JetDbContextOptionsBuilder>? configure = null)
where TContext : DbContext
{
if (builder == null) throw new ArgumentNullException(nameof(builder));
if (configureOptions == null) throw new ArgumentNullException(nameof(configureOptions));
var options = new AccessDatabaseOptions();
configureOptions(options);
options.Validate();
var serviceProvider = services ?? NullServiceProvider.Instance;
var initializer = new AccessDatabaseInitializer();
var connectionFactory = new AccessConnectionStringFactory(decryptor ?? new PassthroughAccessPasswordDecryptor());
initializer.EnsureDatabase(options, serviceProvider);
var connectionString = connectionFactory.Create(options, serviceProvider);
builder.ConfigureDbContext(o => o.UseJet(connectionString, configure));
return builder;
}
}
@@ -1,12 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Sharp.Data.Context;
namespace Sharp.Data.Access;
/// <summary>
/// Access 数据上下文工厂,避免直接依赖具体实现。
/// </summary>
public interface IAccessDataContextFactory<TContext> where TContext : DbContext
{
IDataContext Create();
}
@@ -1,75 +0,0 @@
using System.Data.OleDb;
namespace Sharp.Data.Access;
/// <summary>
/// Access 数据库事务接口
/// </summary>
public interface IAccessTransaction : IDisposable
{
/// <summary>
/// 执行非查询命令(INSERT, UPDATE, DELETE)
/// </summary>
int Execute(string sql, params OleDbParameter[] parameters);
/// <summary>
/// 查询多条记录
/// </summary>
List<T> Query<T>(string sql, params OleDbParameter[] parameters) where T : new();
/// <summary>
/// 执行标量查询(COUNT, SUM等)
/// </summary>
object? ExecuteScalar(string sql, params OleDbParameter[] parameters);
/// <summary>
/// 提交事务
/// </summary>
void Commit();
/// <summary>
/// 回滚事务
/// </summary>
void Rollback();
}
/// <summary>
/// Access 数据服务接口(基于 System.Data.OleDb)
/// </summary>
public interface IAccessDataService
{
/// <summary>
/// 查询多条记录
/// </summary>
List<T> Query<T>(string sql, params OleDbParameter[] parameters) where T : new();
/// <summary>
/// 查询单条记录
/// </summary>
T? QueryFirstOrDefault<T>(string sql, params OleDbParameter[] parameters) where T : new();
/// <summary>
/// 执行标量查询(COUNT, SUM等)
/// </summary>
object? ExecuteScalar(string sql, params OleDbParameter[] parameters);
/// <summary>
/// 执行非查询命令(INSERT, UPDATE, DELETE)
/// </summary>
int Execute(string sql, params OleDbParameter[] parameters);
/// <summary>
/// 开始事务
/// </summary>
IAccessTransaction BeginTransaction();
/// <summary>
/// 在事务中执行操作(自动提交/回滚)
/// </summary>
void ExecuteInTransaction(Action<IAccessTransaction> action);
/// <summary>
/// 测试数据库连接
/// </summary>
bool TestConnection();
}
@@ -1,49 +0,0 @@
using System;
using System.IO;
using Sharp.Data.Access.Internal;
using Sharp.Data.Access.Options;
namespace Sharp.Data.Access.Initialization;
internal sealed class AccessDatabaseInitializer : IAccessDatabaseInitializer
{
public void EnsureDatabase(AccessDatabaseOptions options, IServiceProvider services)
{
var configuration = AccessDatabaseConfiguration.FromOptions(options, services);
if (!string.IsNullOrWhiteSpace(configuration.ConnectionString) ||
string.IsNullOrWhiteSpace(configuration.DatabaseFile))
{
return;
}
var databasePath = AccessPathResolver.Resolve(configuration.DatabaseFile, configuration.DataDirectory);
var directory = Path.GetDirectoryName(databasePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
if (File.Exists(databasePath))
{
return;
}
if (configuration.CopyTemplateWhenMissing && !string.IsNullOrWhiteSpace(configuration.TemplateFile))
{
var templatePath = AccessPathResolver.Resolve(configuration.TemplateFile, configuration.DataDirectory);
if (!File.Exists(templatePath))
{
throw new FileNotFoundException($"Access 模板文件 \"{templatePath}\" 不存在。", templatePath);
}
File.Copy(templatePath, databasePath);
return;
}
if (configuration.ThrowIfMissing)
{
throw new FileNotFoundException($"Access 数据库 \"{databasePath}\" 不存在。", databasePath);
}
}
}
@@ -1,12 +0,0 @@
using System;
using Sharp.Data.Access.Options;
namespace Sharp.Data.Access.Initialization;
/// <summary>
/// Access 数据库初始化器(确保目录/数据库文件准备就绪)。
/// </summary>
public interface IAccessDatabaseInitializer
{
void EnsureDatabase(AccessDatabaseOptions options, IServiceProvider services);
}
@@ -1,48 +0,0 @@
using System;
using EntityFrameworkCore.Jet;
using Microsoft.EntityFrameworkCore;
using Sharp.Data;
using Sharp.Data.Access.Connections;
using Sharp.Data.Access.Initialization;
using Sharp.Data.Access.Options;
using Sharp.Data.Context;
namespace Sharp.Data.Access.Internal;
internal sealed class AccessDataContextFactory<TContext> : IAccessDataContextFactory<TContext>
where TContext : DbContext
{
private readonly IServiceProvider _services;
private readonly AccessContextOptions<TContext> _options;
private readonly IAccessConnectionStringFactory _connectionFactory;
private readonly IAccessDatabaseInitializer _initializer;
public AccessDataContextFactory(
IServiceProvider services,
AccessContextOptions<TContext> options,
IAccessConnectionStringFactory connectionFactory,
IAccessDatabaseInitializer initializer)
{
_services = services;
_options = options;
_connectionFactory = connectionFactory;
_initializer = initializer;
}
public IDataContext Create()
{
var builder = new DataContextBuilder<TContext>();
_initializer.EnsureDatabase(_options.Database, _services);
var connectionString = _connectionFactory.Create(_options.Database, _services);
builder.ConfigureDbContext(options =>
{
options.UseJet(connectionString, jet => _options.ConfigureJet?.Invoke(jet));
});
_options.ConfigureDataContext?.Invoke(_services, builder);
return builder.Build();
}
}
Loaded 100 of 480 files, more files were not shown because too many files have changed in this diff. Show more