XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

EF Core Provider ​

这一页讲 XiHan.Framework.Persistence.EntityFrameworkCore:怎样让应用自己的 DbContext 接入框架的仓储与工作单元、ApplyPersistenceConventions 与生成的 ApplyGeneratedEntities 各做什么、事务为什么在首次写入时才开、命名连接怎样声明、SQLite 上有哪些要注意的地方。读完它,你能把前几页在内存实现上跑通的应用切到 EF Core,并知道直接用 DbContext 时要自己补上什么。前置知识是持久化与 Provider与工作单元与事务。

为什么需要 ​

EF Core 是 .NET 上最常用的 ORM,有迁移、变更跟踪与完整的 LINQ 翻译。框架不重新发明这些,而是在应用自己的 DbContext 之上提供仓储、事务型工作单元与协议约定:仓储按实体协议维护公共字段并施加软删除与租户过滤,工作单元在第一次写入时开事务、处理器成功后提交;应用仍然持有 DbContext,复杂查询直接写 LINQ,表结构用 EF Core 迁移管理。同一作用域里仓储、工作单元与应用注入的 DbContext 是同一个实例、同一个事务。

概念 ​

项内容
包XiHan.Framework.Persistence.EntityFrameworkCore
能力提供 persistence.relational@1(role: provider);依赖 kernel.hosting@1,可选依赖 multitenancy.sharding@1
特性入口EntityFrameworkCorePersistenceFeature<TContext>,构造参数是配置回调 Action<Maybe<BindingValue>, DbContextOptionsBuilder>
绑定db(connection:relational,可选),配置键 XiHan:Bindings:db
配置节XiHan:Persistence:EntityFrameworkCore
健康项persistence.entityframeworkcore.database
Native AOT不兼容:EF Core 的运行时模型构建不满足 AOT 分析器
主要类型EntityFrameworkCorePersistenceFeature<TContext>(以 AddDbContext<TContext> 注册应用的 DbContext)、ModelBuilderExtensions.ApplyPersistenceConventions(Database)、EntityFrameworkCoreUnitOfWork(Context、EnsureTransactionAsync)、EntityFrameworkCoreRepository<TEntity, TKey>

最小示例 ​

安装本包与一个数据库驱动(本包不依赖任何 SQLite 包,版本由应用的驱动引用决定;SQLite 用最新版的 Microsoft.EntityFrameworkCore.Sqlite,它修复了并发打开连接时的连接池竞态):

bash
dotnet add package XiHan.Framework.Persistence.EntityFrameworkCore --prerelease
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
  1. 定义 DbContext:登记实体后调用 ApplyPersistenceConventions(Database)。
  2. 登记实体:AddRepository<TEntity, TKey>()。
  3. 装配特性,在回调里用绑定 db 配置连接。
csharp
using Microsoft.EntityFrameworkCore;
using SimpleApp.Orders;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Persistence.Abstractions.Repositories;
using XiHan.Framework.Persistence.EntityFrameworkCore;
using XiHan.Framework.Profiles.Standard;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRepository<Order, Guid>();
builder.AddXiHan(x => x
    .UseStandardProfile()
    // 显式 Provider 顶替 Standard 里的内存默认实现
    .UseFeature(new EntityFrameworkCorePersistenceFeature<AppDbContext>(
        static (binding, options) => options.UseSqlite(binding.ValueOrThrow().Value))));

var app = builder.Build();
app.UseXiHan();
app.Run();

public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>();
        // 在登记全部实体、配置完表名之后调用:并发戳设为并发令牌,租户与软删除标记建索引,[EntityIndex] 映射为 HasIndex
        modelBuilder.ApplyPersistenceConventions(Database);
    }
}
json
{
  "XiHan": {
    "Bindings": {
      "db": "Data Source=app.db"
    },
    "Persistence": {
      "EntityFrameworkCore": {
        "EnsureCreated": true
      }
    }
  }
}

运行后应用在 app.db 里建出 Order 表(EnsureCreated 只用于开发与测试,生产用迁移),POST /orders 的写入进入 SQLite,重启后数据仍在。操作代码与内存实现时完全相同。回调的第一个参数是 Maybe<BindingValue>:绑定缺失时为空,回调自己决定连接;上例的 ValueOrThrow() 在缺失时抛出。

应用自有 DbContext 与约定 ​

特性以 AddDbContext<TContext> 注册应用的 DbContext,所以模型完全由应用定义:表名、列类型、关系、值对象的 OwnsOne、迁移,都按 EF Core 的常规做法。框架只要求在 OnModelCreating 末尾调用一次 ApplyPersistenceConventions(Database),它对模型里已有的每个实体:

约定内容
ConcurrencyStamp设为并发令牌,最大长度 32
TenantId、IsDeleted建索引
[EntityIndex]映射为 HasIndex(列).IsUnique(…).HasDatabaseName(名字),名字与 SqlSugar 建出的相同;软删除实体的唯一索引加 HasFilter,条件按 Database.ProviderName 写方言(Npgsql、SQLite、SQL Server),其余 Provider 建模时抛出 PERSISTENCE_UNAVAILABLE_008
DateTimeOffset(SQLite)与可空 DateTimeOffset 属性存成 UTC ticks 整数,应用已配转换的不动
字符串匹配(SQLite)Contains / StartsWith / EndsWith 一律译成 LIKE … ESCAPE '\',按字面匹配、只对 ASCII 字母不区分大小写

索引名取调用时的 GetTableName(),所以 ToTable 要在它之前配置;从属类型与共享类型实体跳过。索引的声明方式见建表约定与索引。

引用了 Persistence.Generators 时,OnModelCreating 里换成生成的 ApplyGeneratedEntities(Database),它登记本程序集的全部实体并接着调用 ApplyPersistenceConventions(Database):

csharp
using Microsoft.EntityFrameworkCore;

namespace SimpleApp;

public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    // 登记本程序集全部实体,并应用持久化约定
    protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.ApplyGeneratedEntities(Database);
}

需要额外配置(值对象、关系、表名)时,先写自己的配置,最后再调 ApplyGeneratedEntities(Database)。

整数主键按 EF Core 约定由数据库生成;应用自己用 IIdGenerator<long> 生成整数标识时,在模型里配 ValueGeneratedNever(),见标识生成。

事务与仓储的行为 ​

  • 延迟开启事务:只读过数据的调用不开事务,多个只读调用与一个写入调用能在 SQLite 这类单写者数据库上并存。第一次 EnsureTransactionAsync(仓储每次写入前、提交时保存残余变更前都经它)开事务后 HasTransaction 为真,提交、回滚后复位为假。
  • 读己之写:每次写入立即 SaveChanges,在本事务内可见。批量方法用一次 IN 查询取回目标行、逐项校验后一次 SaveChanges。
  • 软删除与租户过滤由仓储用 EF.Property 显式施加在每个查询上,不用 EF Core 的全局查询过滤器;应用直接用 DbContext 查询时不带这两项过滤。
  • 游离实例更新先比对并发戳,通过后把值复制到跟踪实例上,再把创建时间、租户与删除标记恢复为原值;提交时还有残余变更(绕过仓储直接改了跟踪实体)会先保存再提交,并发令牌不匹配返回 PERSISTENCE_CONFLICT_001 并回滚。
  • 并发新增同一标识撞上主键约束时,SaveChanges 自动建的保存点让事务仍可用,仓储在同一事务里核对标识,确已存在就返回 PERSISTENCE_CONFLICT_002;不是标识重复则核对模型里的唯一索引,撞上返回 PERSISTENCE_CONFLICT_003。之后工作单元只能回滚。
  • 只读工作单元不开事务:仓储写入返回 PERSISTENCE_UNEXPECTED_001;直接用 DbContext 写入前调用的 EnsureTransactionAsync 抛出同码异常;绕过仓储改了跟踪实体而未保存的,提交时以它回滚。作用域结束时事务仍未提交,释放即回滚。

直接使用 DbContext ​

仓储覆盖不了的查询直接注入应用的 DbContext。它与仓储共享同一个实例和同一个事务,但不带软删除与租户过滤,要自己加(或用生成的 <实体>Filters 规约):

csharp
using Microsoft.EntityFrameworkCore;

namespace SimpleApp.Orders;

public sealed class OrderReports(AppDbContext db)
{
    // 直接查询绕过仓储的过滤:软删除与租户条件要自己写
    public Task<int> TotalAmountAsync(string tenantId, CancellationToken cancellationToken)
        => db.Set<Order>()
            .Where(o => !o.IsDeleted && o.TenantId == tenantId)
            .SumAsync(static o => o.Amount, cancellationToken);
}

用 builder.Services.AddScoped<OrderReports>() 注册后在操作里注入。直接用 DbContext 写入之前,先调用本作用域 EntityFrameworkCoreUnitOfWork 的 EnsureTransactionAsync,写入才在事务里、只读工作单元里才会被拒绝。在管道外使用时同样要先开工作单元,见工作单元与事务。

配置 ​

键类型缺省说明
XiHan:Persistence:EntityFrameworkCore:EnsureCreated布尔false启动时对默认连接调用 EnsureCreated 建表;登记了实体的每个命名连接也各执行一次;只用于开发与测试,生产用迁移
XiHan:Persistence:EntityFrameworkCore:HealthProbeIntervalSeconds正数15健康项的重探间隔,也是单次探测的超时
XiHan:Persistence:EntityFrameworkCore:VerifiedShardCapacity正整数1024SQLite 上核对通过的租户分片库最多记多少个;记满淘汰最早记下的,被淘汰的分片下次绑定时重新核对
XiHan:Persistence:EntityFrameworkCore:Connections:<name>字符串—命名连接,值是连接串
XiHan:Bindings:db字符串—连接绑定的主值,作为 BindingValue.Value 交给回调

健康项 persistence.entityframeworkcore.database 启动时同步探一次,之后按间隔在后台重探并缓存结果;探测在独立作用域里对默认连接 CanConnect,不开工作单元、不经管道。连不上为 Degraded(PERSISTENCE_UNAVAILABLE_003),应用仍在服务。

启动时在独立作用域里取一次模型,建模错误(索引声明不合法、Provider 不支持部分唯一索引)在启动时失败,不论是否开着 EnsureCreated。

命名连接 ​

在 XiHan:Persistence:EntityFrameworkCore:Connections:<name> 声明命名连接,值是连接串。名字合 ^[a-z0-9][a-z0-9-]{0,63}$,不能是 default 或 shard;值不能空白、不能写成子节,否则装配即 ArgumentException。

EF Core 的规则是一个 DbContext 一个连接、建出后不换:UnitOfWorkOptions(Connection: name) 开启的工作单元先记下连接、再建本作用域的 DbContext,回调收到的绑定主值是那个连接串、属性沿用 db 绑定,事务只开在那个库上。所以登记到命名连接的实体(AddRepository<TEntity, TKey>(connection))只能在绑定那个连接的工作单元里读写:

情况结果
普通工作单元里取用登记到命名连接的实体的仓储抛出携带 PERSISTENCE_UNAVAILABLE_008 的 FrameworkException
绑定连接的工作单元里取用落在别处的实体PERSISTENCE_VALIDATION_004
登记到没有声明的连接装配即失败,PERSISTENCE_VALIDATION_004
绑定没有声明的连接BeginAsync 抛出 PERSISTENCE_VALIDATION_004
作用域里已经按别的连接建出 DbContext 再绑定BeginAsync 抛 PERSISTENCE_VALIDATION_002,在新作用域里绑定即可

EF Core 不做「每个连接一个 DbContext、跨上下文顺序提交」的多上下文工作单元,要在业务事务里一起读写命名连接上的实体,用 SqlSugar Provider。命名连接与默认连接用同一个配置回调,是同一种数据库 Provider。典型用途是 SQLite 上把工作流的租约表放到另一个库文件,见 Workflow.Database;连接的整体说明见连接与运行时数据源。

租户分库 ​

装配了 multitenancy.sharding@1 时,DbContext 选项按作用域构建;带分片的调用把分片绑定(主值是租户连接串,属性含 tenant、provider 与透传属性)交给同一个回调,整个 DbContext 连到分片库,其余调用用默认连接。分片库的表结构由应用管理,EnsureCreated 只作用于默认连接。EF Core 没有 SqlSugar Provider 那样按实体分库的落点规则:一次调用要么全在默认库,要么全在分片库。细节见租户分片与分库。

SQLite 注意事项 ​

时间列存成 UTC ticks ​

EF Core 的 SQLite Provider 把 DateTimeOffset 存成文本,翻译不了大小比较与排序,而到期书签、按时间过滤与分页都要用到。ApplyPersistenceConventions(Database) 在 SQLite 上把模型里全部 DateTimeOffset 与可空 DateTimeOffset 属性存成 UTC ticks 整数:写入 UtcTicks、读回偏移为零的同一时刻,整数的比较与排序就是时刻的比较与排序。应用已配了值转换或存储类型的属性不动;其余数据库不动。

已有的 SQLite 库要注意:原来没配转换的列存的是文本,照整数读会被静默读成错误的时刻。特性启动时因此核对默认连接与每个命名连接上这类列的存储形式(声明类型不是 INTEGER,或抽样最早的 1000 行查到非整数的值,即不符),不符即启动失败(PERSISTENCE_VALIDATION_008,消息列出连接与不符的列);应用自己配了转换的列、库里没有的表或列、还不存在的库文件不核对。租户分片库在首次绑定时核对,不符时那次工作单元开不起来,经管道的调用是 KERNEL_UNEXPECTED_001(HTTP 500),别的租户照常;核对通过的分片按连接串记在进程内,至多 VerifiedShardCapacity 条。

怎么修:数据可以丢的开发、测试库删库重建;要留数据就重建表,列建成 INTEGER,搬数据时把文本换算成 UTC ticks。EF Core 迁移改列类型本来就是重建表,但它搬数据是原样 INSERT … SELECT,要把搬数据的语句换成带换算式的 migrationBuilder.Sql(...)。换算式与重建表的完整 SQL 见 Persistence.EntityFrameworkCore 包。

字符串匹配 ​

EF Core 的 SQLite Provider 原来把 Contains 译成区分大小写的 instr,StartsWith / EndsWith 按实参是值还是列分别译成 LIKE 或 substr 比较,同一个库上三种规则。特性在 DbContext 选项上加了查询表达式拦截器与方法翻译插件,只在 SQLite 上把这三个方法一律译成 LIKE … ESCAPE '\':%、_、反斜杠按字面,只对 ASCII 字母不区分大小写,与 SqlSugar 在 SQLite 上同一套规则。范围是特性建出的 DbContext 上的全部 LINQ;应用自己的 HasQueryFilter、不经特性建出的 DbContext、用 UseInternalServiceProvider 自给内部服务容器的不在范围内。SQLite 的 LIKE 模式最长 50000 字节。

单写者 ​

SQLite 只有一个写者:业务事务写过之后,同一个库文件上的另一个事务要等它结束。延迟开事务让只读调用不受影响;需要在业务事务进行中另行提交的数据(工作流租约)放到命名连接指向的另一个库文件。

Native AOT 与不支持的能力 ​

本包的 IsAotCompatible 是 false:EF Core 的运行时模型构建不满足 AOT 分析器。标注为 AOT 兼容的持久化实现只有内存实现;AOT 发布的整体要求见 JSON 序列化与 Native AOT。

运行时数据源与库结构读取在 EF Core 上不支持,也不计划支持:数据库 Provider 由应用选、本包不引用,按数据库类型建不出连接;跨数据库读结构在 EF Core 里是设计期脚手架服务,各驱动不一致。DataSourceRegistry 的取用与测试连接、ISchemaReader 都返回 PERSISTENCE_UNAVAILABLE_008;实体目录 IEntityCatalog 取 DbContext 模型,可用。需要这两项的应用用 SqlSugar Provider。

常见问题 ​

我已经有一个 DbContext 了,要改多少 ​

两处:OnModelCreating 末尾加一行 modelBuilder.ApplyPersistenceConventions(Database);Program.cs 把原来的 AddDbContext 换成装配 EntityFrameworkCorePersistenceFeature<AppDbContext>,连接配置搬进回调。原来直接用 DbContext 的代码继续工作,只是写入前要经 EnsureTransactionAsync,才能与仓储在同一事务里。

为什么我直接 db.Set<Order>() 查到了别的租户的数据 ​

仓储的软删除与租户过滤是显式施加在仓储查询上的,不是全局查询过滤器,DbContext 上的 LINQ 不带它们。直接查询时自己写条件,或用生成的 OrderFilters.ActiveFor(tenantId).Criteria。

启动报 PERSISTENCE_VALIDATION_008 ​

这是 SQLite 库里的 DateTimeOffset 列还是旧的文本形式。按上文「时间列存成 UTC ticks」处理:开发库删库重建,要留数据的重建表并换算。SqlSugar 不需要这项核对,它在 SQLite 上一直存 UTC 日期时间文本。

相关包与下一步 ​

Released under The MIT License