Galosys.Foundation.EntityFrameworkCore 26.9.14.1

There is a newer version of this package available.
See the version list below for details.
dotnet add package Galosys.Foundation.EntityFrameworkCore --version 26.9.14.1
                    
NuGet\Install-Package Galosys.Foundation.EntityFrameworkCore -Version 26.9.14.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Galosys.Foundation.EntityFrameworkCore" Version="26.9.14.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Galosys.Foundation.EntityFrameworkCore" Version="26.9.14.1" />
                    
Directory.Packages.props
<PackageReference Include="Galosys.Foundation.EntityFrameworkCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Galosys.Foundation.EntityFrameworkCore --version 26.9.14.1
                    
#r "nuget: Galosys.Foundation.EntityFrameworkCore, 26.9.14.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Galosys.Foundation.EntityFrameworkCore@26.9.14.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Galosys.Foundation.EntityFrameworkCore&version=26.9.14.1
                    
Install as a Cake Addin
#tool nuget:?package=Galosys.Foundation.EntityFrameworkCore&version=26.9.14.1
                    
Install as a Cake Tool

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

安装

<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);
}

执行策略配置

通过 [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 自动生成,DbContext OnModelCreating 时自动路由到对应表。需配合 [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
EfCoreUnitOfWork<TContext> EF Core 工作单元实现
ConcurrencyEntity<TID> 乐观并发实体基类(RowVersion)
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"

依赖

  • 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 隔离抽象(spec multi-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:返回 master TDbContext 配置的共享连接
  • 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):

# 安装(本地源)
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
Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.10.8.1 41 10/8/2026
26.9.23.1 159 9/23/2026
26.9.16.1 154 9/16/2026
26.9.15.1 145 9/15/2026
26.9.14.1 153 9/14/2026
26.9.10.1 141 9/10/2026
26.9.3.1 148 9/3/2026
26.8.29.1 151 8/31/2026
26.8.26.1 163 8/26/2026
26.8.23.1 170 8/23/2026
26.8.21.1 164 8/21/2026
26.8.20.1 163 8/20/2026
26.8.18.1 169 8/18/2026
26.8.17.1 173 8/17/2026
26.8.13.2 163 8/13/2026
26.8.13.1 168 8/13/2026
26.8.12.2 168 8/12/2026
26.8.12.1 169 8/12/2026
26.8.10.1 165 8/10/2026
Loading failed