Galosys.Foundation.EntityFrameworkCore
26.9.23.1
dotnet add package Galosys.Foundation.EntityFrameworkCore --version 26.9.23.1
NuGet\Install-Package Galosys.Foundation.EntityFrameworkCore -Version 26.9.23.1
<PackageReference Include="Galosys.Foundation.EntityFrameworkCore" Version="26.9.23.1" />
<PackageVersion Include="Galosys.Foundation.EntityFrameworkCore" Version="26.9.23.1" />
<PackageReference Include="Galosys.Foundation.EntityFrameworkCore" />
paket add Galosys.Foundation.EntityFrameworkCore --version 26.9.23.1
#r "nuget: Galosys.Foundation.EntityFrameworkCore, 26.9.23.1"
#:package Galosys.Foundation.EntityFrameworkCore@26.9.23.1
#addin nuget:?package=Galosys.Foundation.EntityFrameworkCore&version=26.9.23.1
#tool nuget:?package=Galosys.Foundation.EntityFrameworkCore&version=26.9.23.1
Galosys.Foundation.EntityFrameworkCore
成熟度: 🟢 稳定 — 生产可用,测试充分,活跃维护
简介
Galosys.Foundation.EntityFrameworkCore 基于 Entity Framework Core 提供数据访问层集成,包含自动审计、多租户、软删除、仓储模式等企业级特性。
特性
- 自动审计 - 自动填充创建者、修改者、时间戳
- 多租户支持 - 租户数据隔离和全局过滤器
- 软删除 - 标记删除而非物理删除
- 全局查询过滤器 - 自动应用删除、租户、应用过滤条件
- 雪花 ID 自动生成 - 基于 Snowflake 算法的 ID 生成
- snake_case 列名映射 - 自动将 PascalCase 属性映射到 snake_case 列名
- 分表支持 - 按年/月/日动态分表
- 仓储模式 - 通用仓储接口和实现
- 工作单元模式 - 事务管理
- 动态查询 - 基于表达式的动态查询构建
- 领域事件 - 保存时自动发布领域事件
- 乐观并发控制 - 可选的 RowVersion 并发令牌,支持冲突自动重试
- Outbox 批量处理 -
EfCoreOutboxStore实现TryAcquireBatchAsync(PostgreSQL SKIP LOCKED)、MarkManySentAsync、MarkManyFailedAsync批量 API - 多租户存储 -
EfCoreTenantStore<TDbContext>基于 EF Core 的只读租户元数据持久化存储(AddEfCoreTenantStore<TDbContext>()覆写默认 InMemory) - 连接弹性 - 四种数据库自动重试瞬态故障
- 读写分离 - DbCommandInterceptor 自动路由查询到副本
- 支持多种数据库 - SQL Server、PostgreSQL、MySQL、SQLite、Oracle
- Keyset 游标分页 -
KeysetPageAsync<T, TOrderBy>深翻页替代 OFFSETSkip/Take(常数时间翻页) - Filtered Include -
IncludeWhere子集合服务端过滤,避免"全量子表拉回内存" - 投影引导 -
SelectTrimmed字段裁剪(SQL 仅 SELECT 必要列) - 批量写入 -
AddRangeOptimizedAsync/SaveChangesOptimizedAsync分批 + 控追踪(10K-100K 行,8x 加速 / 77% 内存节省) - 动态查询 -
ApplySort/ApplyFilters(12 操作符 +[Filterable]白名单)/SelectFields - Split Query 全局默认 -
UseQuerySplittingBehavior(SplitQuery)防多集合 Include 笛卡尔爆炸 +AsSplitQuerySafe - Untracked 只读入口 -
PageUntrackedAsync/QueryUntracked/PageUntracked免追踪查询
安装
<PackageReference Include="Galosys.Foundation.EntityFrameworkCore" Version="x.x.x" />
配置
1. 连接字符串配置
在 appsettings.json 中配置连接字符串,需包含 provider 信息:
{
"ConnectionStrings": {
"Default": "Server=localhost;Database=MyDb;User Id=sa;Password=xxx;Provider=Microsoft.Data.SqlClient",
"Postgres": "Host=localhost;Database=mydb;Username=postgres;Password=xxx;Provider=Npgsql",
"MySql": "Server=localhost;Database=mydb;User=root;Password=xxx;Provider=MySqlConnector",
"Sqlite": "Data Source=mydb.db;Provider=Microsoft.Data.Sqlite"
}
}
2. 注册 DbContext
使用 [DbContext] 特性标记 DbContext 类,模块会自动发现并注册:
[DbContext("Default")]
public class AppDbContext : DbContext<AppDbContext>
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users => Set<User>();
public DbSet<Order> Orders => Set<Order>();
}
3. 模块自动注册
模块会自动扫描带有 [DbContext] 特性的类并注册到 DI 容器:
// 在 Program.cs 或 Startup.cs 中
services.AddDbContext(configuration);
前置约定:
DbContext<T>依赖 Galosys.Core 的AddCore()注册的上下文服务(ITenantContext/IAppContext/IUserContext/ITimeProvider/ApplicationMessagePublisher等),宿主须先调用AddCore()。EF Core 集成包(Actuator/Outbox/Authorization 等)采用"宿主泛型上下文 + 显式装配"约定,详见docs/adr/0001-efcore-integration-conventions.md。
使用示例
实体定义
// 完整实体(含审计、软删除)
[Table("users")]
public class User : FullEntity<long>
{
[SnowflakeId]
public override long Id { get; protected set; }
public string Name { get; set; }
[CreatorId]
public override long CreatorId { get; protected set; }
[CreatedAt]
public override DateTime CreatedAt { get; protected set; }
}
// 多租户实体
[Table("orders")]
public class Order : MtMaEntity
{
[SnowflakeId]
public override long Id { get; protected set; }
public string OrderNo { get; set; }
public decimal Amount { get; set; }
}
DbContext 使用
public class UserService
{
private readonly AppDbContext _context;
public UserService(AppDbContext context)
{
_context = context;
}
public IQueryable<User> GetActiveUsers()
{
return _context.Query<User>(); // 自动过滤已删除记录
}
public async Task AddUserAsync(User user)
{
await _context.Entity<User>().AddAsync(user);
await _context.SaveChangesAsync();
}
public async Task SaveWithEventsAsync()
{
await _context.SaveEntitiesAsync(); // 保存并发布领域事件
}
}
仓储模式
定义业务接口,继承 IRepository<T, TID>,再用 [Repository] 标记实现类:
// 1. 定义业务仓储接口
public interface IUserRepository : IRepository<User, long>
{
Task<User?> GetByNameAsync(string name);
}
// 2. 实现类继承 EfCoreRepository,用 [Repository] 标记自动注册
[Repository]
public class UserRepository : EfCoreRepository<AppDbContext, User, long>, IUserRepository
{
public UserRepository(AppDbContext ctx) : base(ctx) { }
public async Task<User?> GetByNameAsync(string name)
{
return await Query(u => u.Name == name).FirstOrDefaultAsync();
}
}
// 3. 使用时注入业务接口
public class UserService
{
private readonly IUserRepository _repository;
public UserService(IUserRepository repository)
{
_repository = repository;
}
public async Task<User?> GetByIdAsync(long id) => await _repository.FindOneAsync(id);
public async Task AddAsync(params User[] users)
{
await _repository.AddAsync(users);
await _repository.SaveAsync();
}
}
工作单元
行为变更:
CommitAsync现在会自动调用SaveChangesAsync,无需手动保存。内部通过_changesSaved标志位防止重复保存,兼容已有的手动SaveChangesAsync+CommitAsync双调用模式。
简化写法(推荐):
public class OrderService
{
private readonly IUnitOfWork _unitOfWork;
private readonly AppDbContext _context;
public OrderService(IUnitOfWork unitOfWork, AppDbContext context)
{
_unitOfWork = unitOfWork;
_context = context;
}
public async Task CreateOrderAsync(Order order)
{
await _unitOfWork.BeginTransactionAsync();
try
{
_context.Entity<Order>().Add(order);
// CommitAsync 自动调用 SaveChangesAsync,无需手动保存
await _unitOfWork.CommitAsync();
}
catch
{
await _unitOfWork.RollbackAsync();
throw;
}
}
}
兼容写法(手动 SaveChanges + CommitAsync):
public async Task CreateOrderAsync(Order order)
{
await _unitOfWork.BeginTransactionAsync();
try
{
_context.Entity<Order>().Add(order);
await _context.SaveChangesAsync(); // 手动保存
await _unitOfWork.CommitAsync(); // 不会重复保存(_changesSaved 标志位)
}
catch
{
await _unitOfWork.RollbackAsync();
throw;
}
}
ITimeProvider 注入
DbContext<T> 通过构造函数注入 ITimeProvider(Core 模块已注册 Singleton),审计字段自动使用 ITimeProvider 获取时间,确保时区一致且可测试。无需额外配置。
// 单元测试中替换时间源
services.AddSingleton<ITimeProvider>(new TestTimeProvider(fixedTime));
乐观并发控制
继承 ConcurrencyEntity<TID> 即可启用 RowVersion 并发令牌:
[Table("products")]
public class Product : ConcurrencyEntity<long>
{
public string Name { get; set; }
public decimal Price { get; set; }
}
冲突时自动重试更新:
public async Task UpdatePriceAsync(Product product)
{
await _repository.UpdateWithRetryAsync(product, maxRetries: 3);
}
更丰富的冲突处理(
DatabaseWins/MergeFields/ 三组值快照)见 § 并发冲突 3 策略。
执行策略配置
通过 [DbContext] 特性配置重试参数(默认 3 次 / 5 秒):
[DbContext("Default", MaxRetryCount = 5, MaxRetryDelaySeconds = 10)]
public class AppDbContext : DbContext<AppDbContext> { }
| 参数 | 默认值 | 说明 |
|---|---|---|
MaxRetryCount |
3 | 瞬态故障最大重试次数 |
MaxRetryDelaySeconds |
5 | 最大重试延迟(秒) |
读写分离
1. 配置主库和副本
在 appsettings.json 中添加副本连接字符串({名称}.Replica{N} 格式):
{
"ConnectionStrings": {
"Default": "Server=master-host;Database=MyDb;User Id=sa;Password=xxx;Provider=Microsoft.Data.SqlClient",
"Default.Replica1": "Server=replica1-host;Database=MyDb;User Id=sa;Password=xxx;Provider=Microsoft.Data.SqlClient",
"Default.Replica2": "Server=replica2-host;Database=MyDb;User Id=sa;Password=xxx;Provider=Microsoft.Data.SqlClient"
}
}
最小化配置仅需主库连接字符串,无副本时不启用读写分离。
2. 注册数据源
services.AddDataSources(configuration); // 自动识别副本,创建 HealthCheckedDataSourcePool
3. 使用 DataSourceContext 切换
// 定义业务仓储接口
public interface IOrderQueryRepository : IRepository<Order, long>
{
Task<List<Order>> GetRecentOrdersAsync(int count);
}
[Repository]
public class OrderQueryRepository : EfCoreRepository<AppDbContext, Order, long>, IOrderQueryRepository
{
public OrderQueryRepository(AppDbContext ctx) : base(ctx) { }
public async Task<List<Order>> GetRecentOrdersAsync(int count)
{
using (DataSourceContext.SwitchTo(DataSourceType.Replica)) // SELECT 走副本
{
return await Query()
.OrderByDescending(o => o.CreatedAt)
.Take(count)
.ToListAsync();
}
}
}
// 使用时注入业务接口
public class OrderAppService
{
private readonly IOrderQueryRepository _queryRepo;
private readonly IOrderRepository _orderRepo;
public OrderAppService(IOrderQueryRepository queryRepo, IOrderRepository orderRepo)
{
_queryRepo = queryRepo;
_orderRepo = orderRepo;
}
public async Task<Order> CreateOrderAsync(Order order)
{
await _orderRepo.AddAsync(order); // DML 自动走主库,无需手动切换
await _orderRepo.SaveAsync();
return order;
}
public Task<List<Order>> GetRecentOrdersAsync(int count)
{
return _queryRepo.GetRecentOrdersAsync(count); // 内部切换到副本
}
}
| 场景 | 行为 |
|---|---|
| 默认(Master) | 所有查询走主库 |
SwitchTo(Replica) |
SELECT 走副本 |
| DML 操作 | 强制走主库,忽略上下文 |
| 事务内 | 不切换,保持主库连接 |
using 结束 |
自动恢复之前的上下文 |
4. 嵌套切换
using (DataSourceContext.SwitchTo(DataSourceType.Master))
{
await SaveAsync(); // 写操作走主库
using (DataSourceContext.SwitchTo(DataSourceType.Replica))
{
var stats = await QueryStatsAsync(); // 读操作走副本
}
// 自动恢复为 Master
}
分表
通过 [Table] + [Sharding] 特性标记实体,框架自动拼接表名后缀:
[Table("orders")]
[Sharding(ShardingType.Month)] // 按月分表 → orders_202604
public class Order : FullEntity<long>
{
public string OrderNo { get; set; }
public decimal Amount { get; set; }
}
[Table("logs")]
[Sharding(ShardingType.Day)] // 按日分表 → logs_20260415
public class Log : FullEntity<long>
{
public string Message { get; set; }
}
[Table("reports")]
[Sharding(ShardingType.Year)] // 按年分表 → reports_2026(默认)
public class AnnualReport : FullEntity<long>
{
public string Title { get; set; }
}
| ShardingType | 示例表名 | 适用场景 |
|---|---|---|
Year(默认) |
orders_2026 |
年度汇总、报表 |
Month |
orders_202604 |
订单、交易流水 |
Day |
logs_20260415 |
日志、高频数据 |
表名后缀基于
DateTimeOffset.Now自动生成,DbContextOnModelCreating时自动路由到对应表。需配合[DbContext(Dynamic = true)]启用动态模型缓存。
读写分离 + 分表组合
[Table("order_items")]
[Sharding(ShardingType.Month)]
public class OrderItem : FullEntity<long>
{
public long OrderId { get; set; }
public string ProductName { get; set; }
public int Quantity { get; set; }
}
[Repository]
public class OrderItemRepository : EfCoreRepository<AppDbContext, OrderItem, long>, IOrderItemRepository
{
public OrderItemRepository(AppDbContext ctx) : base(ctx) { }
public async Task<List<OrderItem>> GetItemsByOrderAsync(long orderId)
{
using (DataSourceContext.SwitchTo(DataSourceType.Replica)) // 副本读取 + 按月分表
{
return await Query(i => i.OrderId == orderId).ToListAsync();
}
}
}
EntityTypeConfiguration 接口驱动配置
通过继承 EntityTypeConfigurationBase,根据实体实现的接口自动获得对应的索引和字段约束配置:
| 实体实现的接口 | 自动配置 |
|---|---|
ICreator |
CreatedAt 索引 + CreatorId 索引 + CreatorName HasMaxLength(64) |
ILastModifier |
LastModifierId 索引 + LastModifierName HasMaxLength(64) |
IMultiTenancy(仅) |
TenantId 单列索引 |
IMultiApplication(仅) |
AppId 单列索引 |
IMultiTenancy + IMultiApplication |
(TenantId, AppId) 复合索引 |
IConcurrency |
RowVersion 乐观并发令牌 |
// 用户配置 — 实体实现 IMultiTenancy,自动获得 TenantId 索引
public class SysUserConfiguration : EntityTypeConfigurationBase<SysUser, long>
{
// 基类自动配置:CreatedAt/CreatorId 索引 + LastModifierId 索引 + TenantId 索引
// 可覆写 ConfigureCore 添加自定义配置
}
// 菜单配置 — 实体实现 IMultiApplication,自动获得 AppId 索引
public class SysMenuConfiguration : EntityTypeConfigurationBase<SysMenu, long> { }
// 租户应用配置 — 实体同时实现两个接口,自动获得 (TenantId, AppId) 复合索引
public class SysTenantAppConfiguration : EntityTypeConfigurationBase<SysTenantAppConfig, long> { }
// 全局数据 — 仅 FullEntity 审计字段索引
public class SysRegionConfiguration : EntityTypeConfigurationBase<SysRegion, long> { }
说明:全局查询过滤器(软删除、租户、应用)由
DefaultGlobalFilterProvider基于IDeletable/IMultiTenancy/IMultiApplication接口统一应用;蛇形命名映射由EntityTypeConfigurationBase处理;IConcurrency自动检测并配置并发令牌。索引配置完全由接口检测驱动,无需为不同实体类型选择不同基类。
核心类
| 类/接口 | 说明 |
|---|---|
IRepository<T, TID> |
通用仓储接口,定义增删改查 |
IUnitOfWork |
工作单元接口,管理事务 |
DbContext<T> |
DbContext 基类,提供自动审计、过滤器等 |
[DbContext] |
标记 DbContext 的特性,指定连接字符串名称 |
[Repository] |
标记仓储实现类,自动注册到 DI |
EfCoreRepository<TContext, T, TID> |
EF Core 仓储实现,继承并添加 UpdateWithRetryAsync |
KeysetPagingExtensions |
Keyset 游标分页(KeysetPageAsync<T, TOrderBy> / EncodeCursor / TryDecodeCursor) |
FilteredIncludeExtensions |
子集合过滤 Include(IncludeWhere) |
ProjectionExtensions |
投影引导(SelectTrimmed) |
BulkInsertExtensions |
分批批量插入(AddRangeOptimizedAsync + BulkInsertOptions) |
DbContextBulkExtensions |
分批保存(SaveChangesOptimizedAsync) |
ChangeTrackerReloadExtensions |
重载已追踪实体(ReloadTrackedAsync<T>,ExecuteUpdate 后状态同步) |
DynamicQueryExtensions |
动态排序/过滤/字段选择(ApplySort / ApplyFilters / SelectFields) |
EfCoreUnitOfWork<TContext> |
EF Core 工作单元实现 |
ConcurrencyEntity<TID> |
乐观并发实体基类(RowVersion) |
ConflictStrategy |
并发冲突 3 策略枚举(ClientWins / DatabaseWins / MergeFields) |
ConflictResult<T> |
冲突结果(CurrentEntity + CurrentRowVersion),约束 T : class, IConcurrency |
EfCoreRepositoryConcurrencyExtensions |
并发 3 策略扩展(UpdateWithClientWinsAsync / TryUpdateWithDatabaseWinsAsync / MergeAndUpdateAsync) |
DbUpdateConcurrencyExceptionExtensions |
冲突三组值快照(CaptureSnapshotAsync → ConcurrencySnapshot) |
ConcurrencySnapshot |
冲突快照(Proposed / Original / Database 三组值) |
ICrossDbContextTransaction |
跨 DbContext 事务抽象(共享 DbConnection + DbTransaction) |
CrossDbContextTransaction |
ICrossDbContextTransaction 默认实现 |
InMemoryMergeExtensions |
跨 DbContext Join 的内存合并替代(MergeAsync) |
MigrationsHistoryTableOptions |
迁移历史表命名配置(Schema / TableName / UseDefault) |
ReadWriteSplittingInterceptor |
读写分离拦截器 |
ShardingAttribute |
分表特性 |
ShardingExtensions |
分表扩展方法(EnsureShardingTablesAsync) |
手动建表
分表场景下,应用启动时需手动创建当前周期的物理表:
// 在 Program.cs 或 Startup.cs 中调用一次
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.EnsureShardingTablesAsync(); // CREATE TABLE IF NOT EXISTS 语义
}
EnsureShardingTablesAsync扫描所有[Sharding]实体,为当前周期创建物理表。表已存在时不报错。
模型缓存策略
DynamicModelCacheKeyFactory 根据 [Sharding] 粒度优化模型缓存刷新频率:
| 粒度 | 缓存刷新周期 | 说明 |
|---|---|---|
Year |
每年 1 月 1 日 | 表名年度不变,缓存年度有效 |
Month |
每月 1 日 | 表名月度不变,缓存月度有效 |
Day |
每天 | 表名每日变化,缓存每日有效 |
混合粒度取最细(Day > Month > Year)。未标记
[Sharding]的 DbContext 不启用动态缓存。
审计属性
行为变更 (v3): 审计字段填充已从
SavingChanges事件移至SaveChanges/SaveChangesAsync重写中,确保 ShardingCore 路由评估完成后再填充。对调用方无感知。审计字段同时支持
DateTime和DateTimeOffset类型,通过ITimeProvider获取时间。
| 属性 | 说明 |
|---|---|
[SnowflakeId] |
雪花 ID 自动生成 |
[CreatedAt] |
创建时间自动填充 |
[CreatorId] |
创建者 ID 自动填充 |
[CreatorName] |
创建者姓名自动填充 |
[LastModifiedAt] |
最后修改时间自动填充 |
[LastModifierId] |
最后修改者 ID 自动填充 |
[LastModifierName] |
最后修改者姓名自动填充 |
全局过滤器
模块通过 IGlobalFilterProvider 接口自动为以下接口实现全局查询过滤器:
IDeletable- 过滤已删除记录 (e.Deleted == false)IMultiTenancy- 租户数据隔离(ITenantContext.IsSystemContext穿透 / 按TenantId过滤)IMultiApplication- 应用数据隔离(IAppContext.AppId == 0穿透 / 按 AppId 过滤)IGlobalEntity- 跳过租户过滤(标记接口,静态系统表如sys_dict/sys_menu)
fail-secure 默认行为
ITenantContext.TenantId 默认 null,过滤器采用 fail-secure 行为(依据 ADR-0004):
IsSystemContext |
TenantId |
行为 |
|---|---|---|
false |
null |
SQL TenantId = NULL 自然返回零结果(未初始化阻断) |
false |
>0 |
按 TenantId 过滤 |
true |
null |
穿透,返回所有租户数据 |
运行时切换限制
过滤器表达式捕获 ITenantContext 对象引用,由 Expression.Condition 在 SQL 翻译时分支。
EF Core 在首次 query 时缓存 HasQueryFilter 参数值——同一 DbContext 实例内运行时切换
TenantId 不会立即生效。生产标准模式:每个 HTTP 请求 scoped DbContext,
中间件在 DbContext 创建前调用 SetTenant(...),filter 即正确。
系统模式入口
// 跨租户操作(仅系统后台使用)
using (ctx.EnterSystemContext())
{
return await db.Orders.ToListAsync(); // 跨租户返回所有
}
插入穿透标记
public class SysUser : FullEntity<long>, IMultiTenancy, ITenantInsertOptOut
{
public long TenantId { get; set; } // SaveChanges 不自动填充
}
public class ImportService(ITenantContext ctx, DbContext db)
{
public async Task ImportUser(SysUser user)
{
user.TenantId = 42; // 显式指定
db.Users.Add(user);
await db.SaveChangesAsync(); // 不会覆盖为 ctx.TenantId
}
}
Outbox 存储
services.AddEfCoreOutboxStore<MyDbContext>();
自动发现 OutboxMessageEntityTypeConfiguration(表 base.base_outbox_msg,列名 snake_case),一行注册即可,无需修改 DbContext 的 OnModelCreating。
| 列名 | 属性 | 说明 |
|---|---|---|
id |
Id |
主键 |
transport_name |
TransportName |
传输组件名 |
delivery_mode |
DeliveryMode |
投递模式(Publish/Send) |
exchange |
Exchange |
交换机(发布模式使用) |
routing_key |
RoutingKey |
路由键 |
content_type |
ContentType |
内容类型 |
body |
Body |
消息体 |
message_type |
MessageType |
消息类型 |
retry_count |
RetryCount |
已重试次数 |
max_retry_count |
MaxRetryCount |
最大重试次数 |
last_error |
LastError |
最后一次错误 |
created_at |
CreatedAt |
创建时间 |
scheduled_at |
ScheduledAt |
预约发送时间 |
published_at |
PublishedAt |
实际发送时间 |
trace_id |
TraceId |
追踪 ID |
correlation_id |
CorrelationId |
关联 ID |
status |
Status |
状态(Pending/Processing/Sent/Failed) |
OutboxSaveChangesInterceptor
AddEfCoreOutboxStore 自动注册 OutboxSaveChangesInterceptor 到 DI(Singleton)。该拦截器在 SaveChanges 时从 PendingMsgCol(AsyncLocal)取出待发送消息,同事务写入 base_outbox_msg 表。
业务代码无需额外配置:
await _db.Orders.AddAsync(order);
await _bus.PublishAsync("order.created", order); // → PendingMsgCol,不入库
await _db.SaveChangesAsync(); // 拦截器同事务写入 orders + outbox_msg
DomainEventSaveChangesInterceptor
随 AddEfCoreOutboxStore<TDbContext>() 一并启用(无需额外注册):该方法在注册 OutboxSaveChangesInterceptor 的同时注册 DomainEventSaveChangesInterceptor(Singleton,EF 自动发现),并在容器缺少进程内发布器时兜底调 AddApplicationMessagePublisher()。
该拦截器在 SaveChanges 成功后扫描 ChangeTracker 中实现 IHasDomainEvents 的实体(框架 Entity 基类默认实现),把聚合根收集的领域事件经 ApplicationMessagePublisher 发布到进程内订阅者并清空。
一次启用,双通道对称:
| 拦截器 | 触发时机 | 通道 | 一致性 |
|---|---|---|---|
OutboxSaveChangesInterceptor |
SavingChanges(保存时同事务落库) | Outbox → MQ | 强(随事务回滚消失) |
DomainEventSaveChangesInterceptor |
SavedChanges(保存成功后) | 进程内 Channel | 最终(事务回滚可能幽灵事件) |
// 聚合根业务方法内收集事件
public Order Place(...) { ...; AddEvent(new OrderPlaced(this)); return this; }
// Handler 正常 SaveChangesAsync 即触发分发,无需任何标注
await _db.SaveChangesAsync(ct);
// 注册(一次启用双通道)
services.AddEfCoreOutboxStore<ApplicationDbContext>();
反模式:AddEvent 之后又在 Handler 手动 PublishAsync 同一事件 → 拦截器再次分发造成重复消费。进程内事件交给拦截器;手动 ApplicationMessagePublisher.PublishAsync 仅用于非 EF 场景。
EfCoreOutboxStore
PollAsync — LINQ AsNoTracking,跨数据库兼容(PostgreSQL 使用 FOR UPDATE SKIP LOCKED 原生 SQL)。标记操作 — 原生 SQL UPDATE,绕过 ChangeTracker。
测试覆盖
| 测试文件 | 组件 | 用例数 |
|---|---|---|
EfCoreOutboxStoreTests |
EfCoreOutboxStore 全部方法 | 12 |
OutboxSaveChangesInterceptorTests |
OutboxSaveChangesInterceptor | 4 |
DomainEventSaveChangesInterceptorTest |
DomainEventSaveChangesInterceptor | 4 |
EfCoreOutboxStoreRegistrationTests |
AddEfCoreOutboxStore 注册行为 | 4 |
dotnet test framework/test/Galosys.Foundation.EntityFrameworkCore.Tests/ --filter "FullyQualifiedName~EfCoreOutboxStoreTests"
dotnet test framework/test/Galosys.Foundation.EntityFrameworkCore.Tests/ --filter "FullyQualifiedName~OutboxSaveChangesInterceptor"
dotnet test framework/test/Galosys.Foundation.EntityFrameworkCore.Tests/ --filter "FullyQualifiedName~DomainEventSaveChangesInterceptorTest"
dotnet test framework/test/Galosys.Foundation.EntityFrameworkCore.Tests/ --filter "FullyQualifiedName~AddEfCoreOutboxStore"
性能防坑
本模块的性能优化契约(Keyset 分页 / Filtered Include / 批量写入 / 参数化 / 追踪控制)源自设计文档
docs/designs/efcore-performance-design.md。消费方按以下 5 招铁律书写查询与写入即可规避 90% 的 EF Core 性能陷阱。
招数 1:分页铁律
- 深翻页(数据量大 / 页码深)→ Keyset:
KeysetPageAsync<T, TOrderBy> - 浅翻页 →
PagedRequest/PagedResult<T>(OFTSET) - 排序键必须唯一且建索引;重复时叠加第二键做 tie-break
// Keyset 深翻页(常数时间,不随页码增长)
var next = await db.Orders
.KeysetPageAsync<Order, long>(
new KeysetPagedRequest { Cursor = prev.NextCursor, Size = 30 },
o => o.Id, ct);
// 浅翻页(PageQry/PageOutput 已被 PagedRequest/PagedResult<T> 取代)
var result = await db.Query<User>()
.ApplySort(req.SortBy) // 动态排序
.ApplyFilters(req.Filters) // 动态过滤
.PageUntrackedAsync(req.PageNo, req.PageSize, ct); // 免追踪分页
消费方迁移指引:旧
PageQry→PagedRequest(PageNum→PageNo);旧PageOutput→PagedResult<T>(Rows→Data、Total→TotalRecords、Pages→TotalPages、HasNext→HasNextPage、HasPrevious→HasPreviousPage)。PagedResult<T>是数据载体层,API 返回用UnifiedResponse.Succeed(pagedResult)包装。
招数 2:导航铁律
- Include 永远配 Where:
FilteredIncludeExtensions.IncludeWhere - 能投影就别导航全取:
ProjectionExtensions.SelectTrimmed
// 只加载未发货明细(服务端 SQL 过滤,杜绝全量子表拉回内存)
var orders = await db.Orders
.Where(o => o.Status == "NEW")
.IncludeWhere(o => o.Items, i => i.Shipped == false)
.ToListAsync(ct);
// 只 SELECT 需要的列
var titles = await db.Blogs
.SelectTrimmed(p => new { p.Id, p.Title })
.ToListAsync(ct);
招数 3:写入铁律
| 规模 | 推荐方案 |
|---|---|
| < 10K 插入 | AddRange + SaveChanges |
| 10K-100K 插入 | AddRangeOptimizedAsync(分批 + 关追踪,8x 加速 / 77% 内存节省) |
| ≥ 100K 导入 | SqlBulkCopy / EFCore.BulkExtensions(旁路 EF Core,本模块 API 不适用百万级) |
| 按条件更新 | ExecuteUpdate(单 SQL, 3-5ms) |
| 按条件删除 | ExecuteDelete(单 SQL, 2-4ms) |
| 软删除实体删除 | ExecuteUpdate 设 IsDeleted=true + DeletedAt=now(勿用 ExecuteDelete,会绕过 IDeletable 全局过滤器) |
// 10K-100K 分批插入:默认 BulkInsertOptions(BatchSize=5000 / AutoDetectChangesOff / ClearPerBatch / Atomic)
await db.Users.AddRangeOptimizedAsync(users, ct);
// 10K-100K 分批保存(DbContext 粒度)
await db.SaveChangesOptimizedAsync(ct);
// ExecuteUpdate 后重载已追踪实体,防止旧值覆盖
await db.ReloadTrackedAsync<User>(ct);
// 软删除批量"删除"推荐模式
await db.Users.Where(u => u.DeactivatedSince < cutoff)
.ExecuteUpdateAsync(s => s
.SetProperty(e => e.IsDeleted, true)
.SetProperty(e => e.DeletedAt, DateTime.UtcNow), ct);
招数 4:参数化铁律
- 永远让 EF 自动参数化(LINQ 表达式)
- 禁止
FromSqlRaw字符串拼接(SQL 注入 + 参数嗅探) - 动态条件用
FromSqlInterpolated
招数 5:追踪铁律
- 只读查询:
PageUntrackedAsync/QueryUntracked/PageUntracked(DbContext<T>已全局UseQueryTrackingBehavior(NoTracking),免追踪兜底) ExecuteUpdate后必须ReloadTrackedAsync<T>同步 ChangeTracker- Bulk 写入路径自动关
AutoDetectChangesEnabled+ 每批ChangeTracker.Clear()
Split Query 全局默认与代价
DbContext<T> 默认 UseQuerySplittingBehavior(SplitQuery),多集合 Include 自动拆多条 SQL,防笛卡尔爆炸(无需消费方配置)。可用 AsSplitQuerySafe() 显式标注意图。
代价:拆分后的查询不在同一事务;两个查询之间数据若变化会出现短暂不一致(仅只读场景可接受)。需要强一致读(事务内 / 刚写入后立即读)时显式
.AsSingleQuery()切回单查询。
依赖
- Microsoft.EntityFrameworkCore
- Microsoft.EntityFrameworkCore.Relational
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.Sqlite
- Npgsql.EntityFrameworkCore.PostgreSQL
- Pomelo.EntityFrameworkCore.MySql
- Oracle.EntityFrameworkCore
- Galosys.Foundation.Core
- Galosys.Foundation.Data
CDC EF Core 位置存储
本模块提供 EfOffsetStore<TDbContext>,基于 EF Core 持久化 CDC 位置到数据库(线程安全,使用 IDbContextFactory<TDbContext> 每操作新建上下文)。
// 注册 EF Core 位置存储替代默认的 InMemoryOffsetStore
services.RemoveAll<IOffsetStore>();
services.AddSingleton<IOffsetStore, EfOffsetStore<YourDbContext>>();
自动保存 CDC 最后读取的 LSN 位置,支持断点续传和多连接器 namespace 隔离。
旧版
EfPositionStore<TDbContext>已标记[Obsolete],请迁移到EfOffsetStore<TDbContext>。
多租户存储(EfCoreTenantStore)
EfCoreTenantStore<TDbContext> 基于 EF Core 提供租户元数据的只读持久化存储,实现 ITenantStore(Core 层接口)。默认 AddMultiTenancyCore() 注册的是 InMemoryTenantStore,调用 AddEfCoreTenantStore<TDbContext>() 会用 RemoveAll + 作用域注册替换为 EF 存储。
1. 注册租户元数据表
在 DbContext 的 OnModelCreating 中应用租户实体映射(表名 mt_tenant,列 snake_case;Items 字典不持久化):
public class MyDbContext : DbContext
{
public MyDbContext(DbContextOptions<MyDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfiguration(new TenantEntityTypeConfiguration()); // 或 ApplyConfigurationsFromAssembly(...)
}
}
TenantEntityTypeConfiguration(命名空间Microsoft.EntityFrameworkCore.Metadata.Builders)将Tenant映射到MultiTenancyStoreConstants.TableName(mt_tenant),Id为主键,Identifier建立唯一索引;Items字典被Ignore排除。P2 隔离抽象(specmulti-tenancy-p2-isolation-abstractions)新增的SchemaTemplate/DatabaseTemplate/IsolationConnectionString三个运行时元数据字段同样被Ignore(不在mt_tenant建列),仅作内存中派生,不引入迁移。
2. 注册存储
// 默认(无参):覆写默认的 InMemoryTenantStore,改为从 MyDbContext 中查询租户元数据
builder.Services.AddEfCoreTenantStore<MyDbContext>();
// 可选(带 Options):显式覆盖目标表 schema/表名(如指向平台统一租户表 uc.uc_tenant)
builder.Services.AddEfCoreTenantStore<MyDbContext>(o =>
{
o.Schema = "uc";
o.TableName = "uc_tenant";
});
- 作用域生命周期:每次请求作用域解析出绑定到该作用域
TDbContext的存储实例 - 覆写语义:调用会移除先前注册的
ITenantStore(含默认 InMemory),后一次调用生效 - 只读:目前仅实现
GetByIdAsync/GetByIdentifierAsync(大小写不敏感,空/空串标识返回null),不提供租户写入
2.1 可配置目标表(MultiTenancyStoreOptions)
MultiTenancyStoreOptions(命名空间 Microsoft.Extensions.MultiTenancy,位于 Galosys.Foundation.Core)允许消费方覆盖租户元数据表的目标 schema 与表名,默认值与历史行为一致(Schema = null,TableName = "mt_tenant"),零破坏。
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
Schema |
string? |
null |
schema 名;null/空 → 单参 ToTable(name),非空 → 双参 ToTable(name, schema) |
TableName |
string |
"mt_tenant" |
表名;未配置兜底 MultiTenancyStoreConstants.TableName |
典型用例:指向平台统一租户表 uc.uc_tenant
// Program.cs
builder.Services.AddEfCoreTenantStore<MyDbContext>(o =>
{
o.Schema = "uc";
o.TableName = "uc_tenant";
});
- ADR-0001 规则 4 单一常量来源仍由
MultiTenancyStoreConstants维护,Options 仅在显式配置时覆盖 uc.uc_tenant表由平台侧(UC 团队)负责创建与迁移,本仓库只消费
2.2 ApplyEntityTypeConfigurations DI 优先发现
DbContext<T>.ApplyEntityTypeConfigurations 在自动发现 IEntityTypeConfiguration<T> 时优先从 ServiceProvider 解析,回退到 Activator.CreateInstance(parameterless)(默认行为保留)。
- DI 命中:消费方在 DI 中显式注册
IEntityTypeConfiguration<T>时,自动使用 DI 实例(可注入IOptions<TOptions>、日志器等依赖) - DI 兜底:未注册时走
Activator.CreateInstance(parameterless),行为与历史一致 - Design-time:
ServiceProvider为null(如 EF Core CLI migration 工具)时跳过 DI 解析,走 Activator 兜底 - 测试宿主:测试运行时(
Microsoft.NET.Test.Sdk/xunit.core已加载)允许把测试程序集纳入扫描面,使测试夹具中的配置类也能被发现
3. 使用
EfCoreTenantStore 由多租户中间件/服务自动解析,也可直接注入 ITenantStore:
public class TenantProbeService
{
private readonly ITenantStore _store;
public TenantProbeService(ITenantStore store) => _store = store;
public async Task<Tenant?> FindAsync(string identifier)
=> await _store.GetByIdentifierAsync(identifier);
}
测试覆盖
| 测试文件 | 组件 | 用例数 |
|---|---|---|
EfCoreTenantStoreTests |
EfCoreTenantStore 只读查询 | 5 |
EfCoreTenantStoreRegistrationTests |
AddEfCoreTenantStore 无参注册行为 | 3 |
EfCoreTenantStoreOptionsRegistrationTests |
AddEfCoreTenantStore 带 Options 重载注册行为 | 2 |
TenantEntityTypeConfigurationTests |
TenantEntityTypeConfiguration 读取 MultiTenancyStoreOptions | 3 |
DbContextApplyEntityTypeConfigurationsTests |
DI 优先 + Activator 兜底 + ServiceProvider null | 3 |
P2 Schema 隔离
Schema 隔离是中规模 SaaS 的折中方案:共享 DB 实例 + 每租户独立 tenant_<id> schema,隔离强度高于 Row、迁移成本低于 Database(设计文档 § 五.A.2)。P2-1 已交付 Core 隔离抽象层(ISchemaTenantAccessor / ITenantIsolationContext),本节在 EFCore 层落地 Schema 路由约定与按租户迁移能力(spec multi-tenancy-p2-schema-isolation)。Row 模式零行为变化。
1. 注册
using Microsoft.EntityFrameworkCore.MultiTenancy;
builder.Services.AddEfCoreSchemaIsolation<MyDbContext>();
// 可选:覆盖模板 / DesignTime 兜底 schema
builder.Services.Configure<MultiTenancyIsolationOptions>(o =>
{
o.DefaultSchemaTemplate = "tenant_{id:00000000}";
});
AddEfCoreSchemaIsolation<TDbContext>() 一站式注册:SchemaTenantRoutingConvention(IModelFinalizingConvention)+ SchemaTenantMigrator(IHostedService,启动时按租户循环迁移)+ DesignTimeSchemaProvider 静态兜底。Row 模式下约定不注入(if 分支保护),OnModelCreating 行为不变。
2. Schema 路由约定
SchemaTenantRoutingConvention 在 EF Core 完成 OnModelCreating 后由框架自动调,仅对 TenantEntity<TID> / TenantAppEntity<TID> 派生实体的 ToTable() 加 schema(从 ITenantIsolationContext.IsolationKey 取,模板替换 {id:00000000} → 8 位补零)。消费方无需在每个 IEntityTypeConfiguration 显式 ToTable(name, schema)。
// 消费方零改动
public class OrderEntityConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> b)
{
b.HasKey(o => o.Id);
// 无需 b.ToTable("order", schema) —— 约定在 ModelFinalizing 时统一注入
}
}
模式行为:Row → 约定跳过;Schema / Hybrid → 注入 schema;Database → 约定跳过(走 P2-3 连接串路由)。
3. EF Tools 兼容(DesignTimeSchemaProvider)
dotnet ef migrations add 进程无 ITenantContext / IServiceProvider,通过静态服务兜底:
// 设计时显式指定(包装脚本设环境变量 DESIGNTIME_TENANT_ID)
DesignTimeSchemaProvider.Override("tenant_00000042");
// 或 Options 兜底:未 Override 时回退到 MultiTenancyIsolationOptions.DefaultSchemaTemplate
__EFMigrationsHistory 按 schema 分离(EF Core 默认行为),每个 schema 维护独立迁移历史。
4. Schema 迁移(SchemaTenantMigrator)
IHostedService 启动时遍历 ITenantStore.GetAllAsync(),对每个 IsolationMode = Schema 的租户在独立 schema 内 Database.MigrateAsync()。可与 SchemaTenantMigrator 并行(异步并行 + 并发上限);Schema 模式下禁用 EnsureCreated(),统一走 Migrator。
5. ITenantStore.GetAllAsync 接口扩展
P2-2 同步扩 ITenantStore.GetAllAsync(default 空实现,二进制兼容)。InMemoryTenantStore 返回全部 seed 租户;EfCoreTenantStore<TDbContext> 走现有 db.Tenants.ToListAsync()。
6. 测试覆盖
| 测试文件 | 组件 | 用例数 |
|---|---|---|
SchemaTenantRoutingConventionTests |
SchemaTenantRoutingConvention Row/Schema/Hybrid 分支 |
4 |
DesignTimeSchemaProviderTests |
Override + 兜底模板 | 2 |
SchemaTenantMigratorTests |
按租户循环迁移 + 仅 Schema 模式触发 | 2 |
P2 Database/Hybrid 隔离
Database 隔离是 P2 隔离强度最高的模式:每租户独立 DB 实例(专属连接串),适合大客户 SLA 独立(设计文档 § 五.A.2)。Hybrid 模式 = Database 隔离 + Schema 切换,共享 DB 但按租户 schema 隔离。P2-1/P2-2 已交付 Core 抽象与 Schema 路由,本节落地连接路由 + 跨租户迁移 + 跨库迁移 CLI(spec multi-tenancy-p2-database-hybrid-isolation)。
1. 注册
using Microsoft.EntityFrameworkCore.MultiTenancy;
using Microsoft.EntityFrameworkCore;
builder.Services.AddEfCoreDatabaseIsolation<MyDbContext>();
// Database 模式下必须配置 DatabaseConnectionStringResolver
builder.Services.Configure<MultiTenancyIsolationOptions>(o =>
{
o.DatabaseConnectionStringResolver = tenantId =>
$"Server=tenant-{tenantId}.db.local;Database=app;Uid=app;Pwd=***";
});
AddEfCoreDatabaseIsolation<TDbContext>() 一站式注册:ITenantConnectionRouter(Scoped)+ MultiTenantDbConnectionFactory(EF Core IDbConnectionFactory 实现)+ DatabaseTenantMigrator(IHostedService 启动时按租户循环迁移)。fail-secure:未配置 DatabaseConnectionStringResolver 且 IsolationMode = Database/Hybrid 时,Router 抛 InvalidOperationException(继承 ADR-0004)。
2. 连接路由(ITenantConnectionRouter)
GetConnection() 返回已 Open 的 DbConnection(避免每次查询重解析 + 重打开),由 ITenantIsolationContext.Mode 决定:
Row/Schema:返回 masterTDbContext配置的共享连接Database/Hybrid:返回MultiTenancyIsolationOptions.DatabaseConnectionStringResolver(tenant.Id)对应的专属连接
GetMasterConnection() 返回 ITenantStore 所在 master DB 的连接(uc.uc_tenant / mt_tenant 元数据),Database 模式下业务 DB 独立但 metadata 共享。
3. EF Core 集成
Database 模式需显式启用 IDbConnectionFactory:
services.AddDbContext<MyDbContext>(o =>
o.UseSqlite().UseConnectionFactory<MultiTenantDbConnectionFactory>());
MultiTenantDbConnectionFactory.CreateDbConnection() 内部委托 ITenantConnectionRouter.GetConnection(),EF Core 自身连接管理与 Foundation 路由解耦。
4. Hybrid 模式 = Database + Schema(复用 P2-2)
Hybrid 模式下 ITenantConnectionRouter.GetConnection() 返回 Database 隔离专属连接,同时 ITenantIsolationContext.IsolationKey 提供 schema 名 → 复用 P2-2 的 SchemaTenantRoutingConvention,无需新增约定。两者职责正交:Router 管"连哪个库",Convention 管"用哪个 schema"。
5. DatabaseTenantMigrator
IHostedService 启动时遍历 ITenantStore.GetAllAsync(),过滤 IsolationMode in (Database, Hybrid),对每租户用专属 connection 调 Database.MigrateAsync()。并行受 SemaphoreSlim(parallel) 控制(默认 4),失败单租户不阻塞其它;__EFMigrationsHistory 按租户独立 DB 各自维护。Database 模式建议租户数 < 50(连接池开销 + 启动时间线性)。
6. 跨库迁移 CLI(multi-tenancy dotnet tool)
跨库迁移能力由独立 dotnet tool 提供(见 Galosys.Foundation.MultiTenancy.Tools):
# 安装(本地源 — MultiTenancy.Tools 已设为 IsPackable=false,需先手动 pack 到 ./nupkgs)
dotnet pack framework/tools/Galosys.Foundation.MultiTenancy.Tools/ -c Release -o ./nupkgs
dotnet tool install multi-tenancy --local --add-source ./nupkgs
# 迁移所有 Database/Hybrid 租户
multi-tenancy migrate --connection "Server=master;Database=uc" --tenant all
# 试运行(仅打印计划,不执行)
multi-tenancy migrate --dry-run --connection "Data Source=:memory:"
# 迁移指定租户
multi-tenancy migrate --tenant 1,2 --parallel 2 --max-retries 5
# 数据种子
multi-tenancy seed --tenant all
CLI 进程内组装最小 DI(MultiTenancyToolHost,无 ASP.NET Core),通过 --connection 指向 master DB(uc.uc_tenant / mt_tenant)读取租户列表,遍历调用 Database.MigrateAsync()。退出码:0 全成功 / 1 参数错 / 2 存在失败租户。
7. 测试覆盖
| 测试文件 | 组件 | 用例数 |
|---|---|---|
DatabaseIsolationRegistrationTests |
AddEfCoreDatabaseIsolation 注册 + 异常路径 |
4 |
TenantConnectionRouterTests |
Router Row/Schema/Database/Hybrid 分支 + fail-secure | 4 |
DatabaseTenantMigratorTests |
按租户循环迁移 + 并发控制 + 失败隔离 | 2 |
MultiTenancyMigratorCliTests |
CLI migrate / seed 子命令端到端 |
3 |
可靠性与多 DbContext(change-2)
1. 并发冲突 3 策略
ConflictStrategy(Galosys.Foundation.Core.Concurrency)提供 3 种冲突处理:
| 策略 | 行为 | 适用场景 |
|---|---|---|
ClientWins |
最后写入者获胜(默认) | 运营后台 / 低竞争 |
DatabaseWins |
拒绝写入,返回当前值 | 用户决策类(如审批) |
MergeFields |
字段级融合 | 多端并发编辑 |
EfCoreRepositoryConcurrencyExtensions 提供对应扩展方法:
// ClientWins:等价于现有 UpdateWithRetryAsync
await repository.UpdateWithClientWinsAsync(product);
// DatabaseWins:冲突返回 ConflictResult<T>?(null = 成功)
var conflict = await repository.TryUpdateWithDatabaseWinsAsync(product);
if (conflict is not null)
{
// 返回数据库当前值 + 最新 RowVersion,由 API 层决定后续动作
return conflict.CurrentRowVersion;
}
// MergeFields:冲突时按字段融合后重试
await repository.MergeAndUpdateAsync(product, (db, client) =>
{
client.Price = db.Price; // 价格以数据库为准
client.Name = client.Name; // 名称以客户端为准
return client;
});
冲突三组值快照(DbUpdateConcurrencyExceptionExtensions.CaptureSnapshotAsync):
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.First();
var snapshot = await entry.CaptureSnapshotAsync(ct);
// snapshot.Proposed = entry.CurrentValues(客户端希望写入的值)
// snapshot.Original = entry.OriginalValues(追踪时的原值)
// snapshot.Database = await entry.GetDatabaseValuesAsync(ct)(数据库当前值,null = 已删除)
}
2. DbContext 池化(可选启用)
默认保持 AddDbContext<T>。高并发场景可选择性启用池化(吞吐 2-3x),但须先满足两个前置条件:
⚠️ 禁止在 DbContext 字段存请求级别状态。
DbContext<T>已注入 5 个 scoped 服务(Logger / Publisher / TimeProvider / TenantContext / IAppContext),池化实例跨请求复用会残留请求级状态。
DbContext<T>.OnConfiguring中动态修改DbContextOptions的配置项迁移到注册时调用(池化缓存 options,禁止OnConfiguring修改)- 构造函数解析到字段的 scoped 服务改为惰性解析
AddDbContextPool<TContext, T> 候选已保留(MiAddDbContextPool 反射路径),前置条件满足即可切换。
3. MigrationsHistoryTable 独立命名
同库多 DbContext 默认使用独立迁移历史表 __XxxMigrationsHistory(Xxx = DbContext 类型名去 DbContext 后缀),避免互相覆盖。
| 选项 | 默认值 | 说明 |
|---|---|---|
UseDefault |
true |
是否使用默认命名 __XxxMigrationsHistory |
TableName |
空 | 自定义表名(需 UseDefault=false) |
Schema |
null |
历史表所在 schema(默认与实体一致) |
- 与
SchemaTenantRoutingConvention叠加:Schema 隔离模式下历史表按 schema 路由 - 一次性破坏:已有迁移历史表需手动迁移(或
UseDefault=false保留旧名__EFMigrationsHistory)
4. 事务抽象选择
| 场景 | 抽象 | 说明 |
|---|---|---|
| 单 DbContext | IUnitOfWork |
现有工作单元,管理单上下文事务 |
| 跨 DbContext(同数据库,共享连接) | ICrossDbContextTransaction |
change-2 新增,共享 DbConnection + DbTransaction,原子提交/回滚 |
| 跨数据库(分布式) | Outbox Pattern / Saga | 不在本模块范围(见 § Outbox 存储) |
// ICrossDbContextTransaction:两个 DbContext 共享连接,同一事务内原子提交
await using var xact = scope.ServiceProvider.GetRequiredService<ICrossDbContextTransaction>();
await xact.BeginAsync(orderDbContext, userDbContext);
await orderDbContext.Orders.AddAsync(order);
await userDbContext.Users.UpdateAsync(user);
await xact.CommitAsync(); // 两个 DbContext 的变更同一事务内提交
判断准则:① 是否跨多个 DbContext?否 → IUnitOfWork;② 是否同一数据库?是 → ICrossDbContextTransaction;否 → 分布式(Outbox / Saga)。
5. 跨 DbContext Join 替代(InMemoryMergeExtensions)
EF Core 不支持跨 DbContext Join。MergeAsync 并行加载两边数据后按 key 内存合并:
IReadOnlyList<OrderWithStatus> result = await orderQuery
.MergeAsync(
statusQuery,
l => l.Id, // 左侧 key
r => r.OrderId, // 右侧 key
(l, r) => new OrderWithStatus { Order = l, Status = r.Status });
⚠️ 铁律:频繁跨实体 Join 应合并到单 DbContext。
MergeAsync是"分别查询 + 内存合并"(O(N×M)),数据量 > 10K + 频繁 Join 时推荐合并到单 DbContext 或使用物化视图。
6. 多 DbContext 拆分决策矩阵
| 问题 | 建议拆分 | 建议合并 |
|---|---|---|
| 实体属于不同数据库? | ✅ 必须拆 | — |
| 实体由不同团队/模块维护? | ✅ 拆 | 可能保持单一 |
| 需要读副本? | ✅ 拆(读写分离) | — |
| 实体之间频繁 join? | — | ✅ 保持单一(跨 DbContext join 不可行) |
| DbContext 有 100+ 实体,启动慢? | ✅ 拆 | — |
| 模块化单体有限界上下文? | ✅ 拆 | — |
判断流程:是否不同数据库 / 不同团队 / 需要读副本 / 100+ 实体 / 有限界上下文 → 拆;频繁 join → 合并。
7. 多数据库 RowVersion 适配表
| 数据库 | 类型 | EF Core 配置 | 备注 |
|---|---|---|---|
| SQL Server | rowversion(8 bytes) |
IsRowVersion() |
自动递增 |
| PostgreSQL | xmin 系统列(uint32) |
EF Core 9+ UseXminAsConcurrencyToken() |
无需迁移 |
| MySQL | 无原生 | 自定义 DateTime6 列 + trigger |
需 trigger 自增 |
| SQLite | 无 | 手动 INTEGER 列 |
手动维护 |
PostgreSQL
xmin自动检测:EF Core 9+ 使用UseXminAsConcurrencyToken()自动配置并发令牌。
8. DbUpdateException 全局捕获(Q5 占位)
⚠️ 占位:
DbUpdateException全局捕获展开(区分主键冲突 / 唯一约束 / 级联依赖)暂未实现,路径选型中(Q5 决策:A 拦截器 / B 仓储包装 / C 全局异常中间件),待后续 change 承接。当前需显式catch DbUpdateException并检查InnerException。
性能防坑章节索引(change-1/2/3/4)
| Phase | 主分类 | 章节锚点 |
|---|---|---|
| change-1 | 查询层 / 写入层 | § 性能防坑(招数 1-5 + Split Query) |
| change-2 | 可靠性 | § 可靠性与多 DbContext(并发冲突 3 策略 / 跨上下文事务 / 决策矩阵) |
| change-3 | 可观测性 / 合规 / 架构 | § 监控(OTel 指标) + § 合规运维铁律 + § JSON 列自动映射 |
| change-4 | 慢查询 / 审计增强内化 | § 慢查询日志(主模块内置,需手动启用) + § 审计增强(change-4 起内置,默认启用) |
监控(可观测性)OpenTelemetry
主模块内置 EfCoreInstrumentation(OpenTelemetry Source "Galosys.Foundation.EntityFrameworkCore"),注册后自动暴露 4 类指标:
| 指标 | 类型 | 来源 |
|---|---|---|
efcore.queries.count |
Counter | DbCommandExecuting 拦截 |
efcore.queries.duration |
Histogram | DbCommandExecuted.ElapsedMilliseconds |
efcore.savechanges.duration |
Histogram | SaveChanges / SaveChangesAsync |
efcore.tracked_entities |
ObservableGauge | ChangeTracker.Entries().Count() |
接入
services.AddEfCoreInstrumentation(); // 注册 OpenTelemetry Meter Provider
// 配合 Galosys.Foundation.OpenTelemetry 或 OpenTelemetry.Exporter.Prometheus 暴露 /metrics
Grafana 面板
可使用社区面板 ID 14968(ASP.NET Core / EF Core) 作为起点,替换指标名称为上表;或自建面板关注 4 类指标的 P99 / 速率告警。
慢查询日志(主模块内置,需手动启用)
change-4-2 起,EF Core 慢查询日志能力由原独立扩展模块 Galosys.Foundation.EntityFrameworkCore.SlowQuery 内化合并到主模块 Galosys.Foundation.EntityFrameworkCore,扩展方法名同步从 UseEfCoreSlowQueryLogging 改为 AddEfCoreSlowQueryLogging(命名统一到主模块 Add* 约定)。
services.AddEfCoreSlowQueryLogging(); // 默认阈值 500ms
services.AddEfCoreSlowQueryLogging(thresholdMs: 100); // 自定义阈值
按类别 "EfCore.Slow"(Warning 级)写入 ILogger,含 TraceId / ElapsedMs / Sql;Serilog 业务方通过类别名桥接。能力内置但不自动启用——与 AddEfCoreInstrumentation 同模式,需手动调用。
审计增强(change-4 起内置,默认启用)
change-4-2 起,EF Core 审计增强能力由原独立扩展模块 Galosys.Foundation.EntityFrameworkCore.Audit 内化合并到主模块 Galosys.Foundation.EntityFrameworkCore;修订2 进一步恢复原拦截器类名 AuditSavingInterceptor(命名空间 Microsoft.EntityFrameworkCore.Diagnostics)并由基类 DbContext<T>.OnConfiguring 默认接线启用——与 ReadWriteSplittingInterceptor 接线方式一致。
public class AppDbContext : DbContext<AppDbContext>
{
// 基类 OnConfiguring 已默认接线 AuditSavingInterceptor:
// GetService<IEnvironmentAttributeSource>() ?? new DefaultEnvironmentAttributeSource()
// + GetService<ILogger<AuditSavingInterceptor>>()
// → AddInterceptors(new AuditSavingInterceptor(auditSource, auditLogger))
}
被修改条目(Add / Update / Delete)按以下模板输出结构化日志:
EF审计: {Operation} {Entity}#{EntityId} IP={Ip} TraceId={TraceId}
- IP 来源:DI 解析
IEnvironmentAttributeSource(AspNetCore 宿主由HttpContextAttributeSource实现;Worker / 后台服务未注册源时回退"worker"占位)。 - TraceId:来自
Activity.Current?.TraceId,无 Activity 输出"no-trace"。 - 默认启用——无需任何注册代码即获得审计日志;如需关闭可从 DI 移除拦截器或重写
OnConfiguring。
合规运维铁律
1. 多 Provider 迁移目录组织
生产环境多 Provider 并存时,按 provider 分目录组织迁移文件:
Infrastructure/
└── Migrations/
├── SqlServer/ # Microsoft.EntityFrameworkCore.SqlServer 生成的迁移
├── Npgsql/ # Npgsql.EntityFrameworkCore.PostgreSQL 生成的迁移
├── MySqlConnector/ # Pomelo.EntityFrameworkCore.MySql 生成的迁移
└── Oracle/ # Oracle.EntityFrameworkCore 生成的迁移
切换 provider 时用 --output-dir 隔离迁移:
dotnet ef migrations add InitialCreate --context AppDbContext --output-dir Migrations/Npgsql
所有迁移文件放入同一程序集时,需注意 MigrationsAssembly 一致 + DesignTimeDbContextFactory 按配置选择 provider。
2. Vault / Key Vault 集成
生产环境连接字符串禁止明文存放于 appsettings.json:
| 平台 | 工具 |
|---|---|
| Azure | Key Vault + Managed Identity |
| AWS | Secrets Manager + IAM Role |
| 自建 | HashiCorp Vault Sidecar |
| 本地 | User Secrets(dotnet user-secrets) |
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:Default" "<conn>"
配置提供程序需先注入对应 Secret 注入源(Azure.Extensions.Configuration.Secrets / Amazon.Extensions.Configuration.SystemsManager),再做 AddEntityFrameworkCore。
3. GDPR 合规支持(占位,待独立 change)
本期仅文档化,完整实施在后续 OpenSpec change(业务规则挂钩):
- 数据可移植性:租户数据导出 API(JSON / CSV)
- 被遗忘权:软删除 + 定期物理清除流程
4. 跨分片聚合边界铁律
⚠️ 禁止业务库跨分片聚合
- 高频报表:导入 ClickHouse / Elasticsearch
- 实时看板:分析副本 + 物化视图
- 跨租户统计:走
EnterSystemContext()系统模式,且必须ExecuteUpdate非追踪聚合
性能灾难场景:N 租户 × 全表扫描 = O(N) 内存 + O(N) 延迟。
5. 副本延迟处理(读写分离场景)
ReplicaLagOptions:LagThresholdMs = 500,FallbackToMasterOnHighLag = true,MaxRetries = 3ForceMaster():支付后立即查询"读己之写"场景:
using (db.ForceMaster())
{
var order = await db.Orders.FindAsync(orderId);
}
- 多数据库副本延迟监控:
- SQL Server:
sys.dm_os_performance_counters的Log Send Queue KB - MySQL:
SHOW SLAVE STATUS的Seconds_Behind_Master - 超过阈值(如 5s)告警
- SQL Server:
JSON 列自动映射([OwnedJson])
[OwnedJson] 特性(Microsoft.EntityFrameworkCore.Metadata.OwnedJsonAttribute)标注 owned complex type 后,JsonColumnConvention 在模型构建末期自动执行 OwnsOne(...).ToJson() 等价映射。
启用
services.AddJsonColumnConvention(); // 注册 Scoped 约定(可空,可挂入 ConfigureConventions)
约定通过 ConfigureConventions 或 DI 注册后,对标注实体自动生效:
public class Customer
{
public int Id { get; set; }
[OwnedJson]
public Address Address { get; set; } = null!;
}
public class Address
{
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
}
适用场景
- 内嵌对象图(收货地址 / 用户偏好 / 配置字典)随主实体一起读写
- 几乎不按 JSON 内字段独立查询
不适用场景
- 频繁按 JSON 内字段查询 / 范围检索 / 聚合 → 应拆独立表
- 若需局部查询,推荐在 JSON 列上建计算列 + 索引
决策流程
- 对象是否仅由主实体读写?→ 否,拆独立表
- 是否按内嵌字段做查询/聚合?→ 是,拆独立表或计算列+索引
- 是 → 使用
[OwnedJson](顺带减少表的列数膨胀)
ADR 索引
| ADR | 主题 | 关联 change |
|---|---|---|
| ADR-0001 | EF Core 集成约定 | — |
| ADR-0003 | 多租户隔离策略 | — |
| ADR-0004 | 系统上下文 | — |
| ADR-0006 | 保留自研 [Sharding],不引入 ShardingCore |
change-3 |
⚠️ 决策:未来除非自研分表出现显著短板,否则不引入 ShardingCore。
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Galosys.Foundation.Core (>= 26.9.23.1)
- Galosys.Foundation.Data (>= 26.9.23.1)
- Galosys.Foundation.OpenTelemetry (>= 26.9.23.1)
- microsoft.entityframeworkcore (>= 10.0.9)
- microsoft.entityframeworkcore.relational (>= 10.0.9)
- microsoft.entityframeworkcore.sqlite (>= 10.0.9)
- microsoft.entityframeworkcore.sqlserver (>= 10.0.9)
- npgsql.entityframeworkcore.postgresql (>= 10.0.2)
- oracle.entityframeworkcore (>= 10.23.26200)
- pomelo.entityframeworkcore.mysql (>= 9.0.0)
NuGet packages (7)
Showing the top 5 NuGet packages that depend on Galosys.Foundation.EntityFrameworkCore:
| Package | Downloads |
|---|---|
|
Galosys.Foundation.ShardingCore
Galosys.Foundation快速开发库 |
|
|
Galosys.Foundation.Yarp.Database
Galosys.Foundation快速开发库 |
|
|
Galosys.Foundation.Actuator.EntityFrameworkCore
Galosys.Foundation快速开发库 |
|
|
Galosys.Foundation.DataPermission
Galosys.Foundation快速开发库 |
|
|
Galosys.Foundation.Agents.AI.EntityFrameworkCore
Galosys.Foundation快速开发库 |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 26.9.23.1 | 104 | 9/23/2026 |
| 26.9.16.1 | 149 | 9/16/2026 |
| 26.9.15.1 | 137 | 9/15/2026 |
| 26.9.14.1 | 144 | 9/14/2026 |
| 26.9.10.1 | 137 | 9/10/2026 |
| 26.9.3.1 | 143 | 9/3/2026 |
| 26.8.29.1 | 149 | 8/31/2026 |
| 26.8.26.1 | 161 | 8/26/2026 |
| 26.8.23.1 | 166 | 8/23/2026 |
| 26.8.21.1 | 160 | 8/21/2026 |
| 26.8.20.1 | 161 | 8/20/2026 |
| 26.8.18.1 | 165 | 8/18/2026 |
| 26.8.17.1 | 168 | 8/17/2026 |
| 26.8.13.2 | 160 | 8/13/2026 |
| 26.8.13.1 | 163 | 8/13/2026 |
| 26.8.12.2 | 164 | 8/12/2026 |
| 26.8.12.1 | 162 | 8/12/2026 |
| 26.8.10.1 | 160 | 8/10/2026 |
| 26.8.5.1 | 180 | 8/5/2026 |
| 26.8.4.1 | 158 | 8/4/2026 |