XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

XiHan.Framework.Persistence.EntityFrameworkCore ​

persistence.relational@1 的 Entity Framework Core Provider:应用持有自己的 DbContext,框架在它之上提供仓储、事务型工作单元与协议约定。适合新项目与使用 EF Core 迁移管理表结构的应用。

项内容
包 IDXiHan.Framework.Persistence.EntityFrameworkCore
环层Ring 3(实现)
能力提供 persistence.relational@1(role: provider);依赖 kernel.hosting@1,可选依赖 multitenancy.sharding@1;绑定 db(connection:relational,可选)
特性入口EntityFrameworkCorePersistenceFeature<TContext>
配置节XiHan:Persistence:EntityFrameworkCore
依赖XiHan.Framework.Persistence.Abstractions、XiHan.Framework.MultiTenancy.Abstractions、Microsoft.EntityFrameworkCore;数据库驱动包由应用引用
AOT不兼容(EF Core 的运行时模型构建不满足 AOT 分析器)
稳定性Preview

概述 ​

特性以 AddDbContext<TContext> 注册应用的 DbContext,每次构建选项时调用应用给的配置回调;同一作用域里的仓储、工作单元与应用自己注入的 TContext 是同一个实例。

工作单元在第一次写入时开启数据库事务,只读调用不开事务。仓储的每次写入立即 SaveChanges,所以同一事务里随后的查找与查询读得到它;提交即提交事务,回滚即回滚事务并清空变更跟踪器。批量方法用一次 IN 查询取回目标行、逐项校验后一次 AddRange / 跟踪修改,再一次 SaveChanges。

软删除与租户过滤由仓储用 EF.Property 显式施加在每个查询上,不用 EF Core 的全局查询过滤器,FindAsync 与 LINQ 查询行为一致;应用直接用 DbContext 查询时不带这两项过滤。更新游离实体时先比对并发戳,跟踪中的实体由 EF Core 的并发令牌保护。创建时间、租户与删除标记只由仓储写入:更新与删除时它们回到跟踪实例的原值,调用方改了也不生效。

何时使用 ​

  • 新项目,或已经用 EF Core 的应用。
  • 表结构用 EF Core 迁移管理,复杂查询直接写在 DbContext 上。

已有大量 SqlSugar 代码、需要一次调用写多个库或按时间分表时用 Persistence.SqlSugar;两个关系型 Provider 的 IsAotCompatible 都是 false,标注为 AOT 兼容的持久化实现只有 Persistence.InMemory,AOT 发布的整体要求见序列化与 Native AOT。

安装与启用 ​

bash
dotnet add package XiHan.Framework.Persistence.EntityFrameworkCore --prerelease
dotnet add package Microsoft.EntityFrameworkCore.Sqlite

当前是预览版,安装要带 --prerelease;从源码或本地包源引用见快速上手。数据库驱动按所用数据库引用(Sqlite、Npgsql、SqlServer 等)。SQLite 用最新版的 Microsoft.EntityFrameworkCore.Sqlite:它带来的 Microsoft.Data.Sqlite.Core 修复了连接池激活的竞态(dotnet/efcore#39008:并发打开连接时两个连接可能共用一个原生连接),修复之前的版本带着它。本包不依赖任何 SQLite 包,版本由应用的驱动引用决定(ADR-0097)。

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

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

配置 ​

键类型缺省说明
XiHan:Persistence:EntityFrameworkCore:EnsureCreated布尔false启动时对默认连接调用 EnsureCreated 建表;只用于开发与测试,生产用迁移
XiHan:Persistence:EntityFrameworkCore:HealthProbeIntervalSeconds正数15健康项的重探间隔,也是单次探测的超时
XiHan:Persistence:EntityFrameworkCore:VerifiedShardCapacity正整数1024SQLite 上核对通过的租户分片库最多记多少个(见「已有 SQLite 库的时间列」);记满淘汰最早记下的,被淘汰的分片下次绑定时重新核对一次
XiHan:Persistence:EntityFrameworkCore:Connections:<name>字符串—命名连接,值是连接串;绑定它的工作单元所在作用域的 DbContext 建在这个连接上(见「行为与限制」)
XiHan:Bindings:db字符串—连接绑定的主值,作为 BindingValue.Value 交给回调
json
{
  "XiHan": {
    "Bindings": {
      "db": "Data Source=app.db"
    },
    "Persistence": {
      "EntityFrameworkCore": {
        "EnsureCreated": true
      }
    }
  }
}

回调签名是 Action<Maybe<BindingValue>, DbContextOptionsBuilder>。绑定缺失时 Maybe 为空,回调自己决定连接;上例的 ValueOrThrow() 在缺失时抛出。

主要类型 ​

类型说明
EntityFrameworkCorePersistenceFeature<TContext>特性入口;构造参数是配置回调;常量 ConnectionBinding(db)与 HealthName(persistence.entityframeworkcore.database)
EntityFrameworkCorePersistenceOptions选项:EnsureCreated、HealthProbeInterval、VerifiedShardCapacity、Connections(命名连接);FromConfiguration(configuration)
ModelBuilderExtensions.ApplyPersistenceConventions(Database)对模型里已有的每个实体:ConcurrencyStamp 设为并发令牌、最大长度 32;TenantId 与 IsDeleted 建索引;实体上的 [EntityIndex] 映射为 HasIndex(列).IsUnique(…).HasDatabaseName(名字),名字与 SqlSugar 建出的相同(ADR-0072);软删除实体的唯一索引加 HasFilter,条件按 Database.ProviderName 写方言(Npgsql、SQLite、SQL Server),其余关系型 Provider 建模时抛出 PERSISTENCE_UNAVAILABLE_008(ADR-0081);SQLite 上 DateTimeOffset 与可空 DateTimeOffset 属性存成 UTC ticks 整数,应用已配转换的不动(ADR-0091)
EntityFrameworkCoreUnitOfWork事务型工作单元;Context 是本作用域的 DbContext(开始时建出:绑定了命名连接就建在那个连接上);由容器创建,构造不公开
EntityFrameworkCoreRepository<TEntity, TKey>仓储实现,由特性按登记注册

使用示例 ​

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

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>() 注册后在操作里注入。在管道外使用时同样要先开工作单元,见工作单元与事务。

行为与限制 ​

  • 读己之写:每次写入立即 SaveChanges,在本事务内可见,提交前对其他连接不可见。
  • 延迟开启事务:只读过数据的调用不开事务,多个只读调用与一个写入调用能在 SQLite 这类单写者数据库上并存。第一次 EnsureTransactionAsync(仓储每次写入前、提交时保存残余变更前都经它)开事务后 HasTransaction 为真,提交、回滚成功后立即释放事务对象、复位为假(ADR-0109)。
  • 游离实例更新:先比对并发戳,不符返回 PERSISTENCE_CONFLICT_001;通过后把游离实例的值复制到跟踪实例上,再把创建时间、租户与删除标记恢复为原值(OriginalValues,即存储里的值)。保存成功后把修改时间、并发戳、创建时间与租户同步回游离实例。
  • 读出后直接改了跟踪实例再更新:同样恢复这几个字段,写入路径也按原值判定可见性,所以读出后改了租户的实例照常更新,不会被当成不存在,也改不了租户。
  • 提交时还有残余变更(绕过仓储直接改了跟踪实体)会先保存再提交,其中的创建时间与租户同样恢复为原值;并发令牌不匹配返回 PERSISTENCE_CONFLICT_001 并回滚。
  • 并发新增同一标识:检查之后别的事务提交了同一标识,插入撞上主键约束。SaveChanges 在已有事务里会自动建保存点,失败时已回到保存点,事务仍可用。仓储把这次新增的实体移出跟踪,在同一事务里核对这些标识:确已存在就返回 PERSISTENCE_CONFLICT_002,批量时这次写入的项统一报告;否则异常原样抛出。之后工作单元只能回滚,处理器忽略失败返回成功时,提交回滚并返回同一错误。数据库是 MySQL 的可重复读这类快照隔离时,同一事务看不到对方刚提交的行,异常照常抛出。
  • 只读工作单元(只读操作或 UnitOfWorkOptions(IsReadOnly: true))不开事务:仓储写入返回 PERSISTENCE_UNEXPECTED_001 并把工作单元记为失败,处理器忽略这次失败时提交仍返回它;直接用 DbContext 写入之前调用的 EnsureTransactionAsync 抛出携带它的 FrameworkException(Transaction 阶段转为同码结果);绕过仓储改了跟踪实体而未保存的,提交时以它回滚——只读操作里不要把读出的实体当草稿改。
  • 唯一键重复:新增、更新、删除的 SaveChanges 失败又不是标识重复时,在保存点之后的同一事务里核对模型里的唯一索引(这次保存里两项同键,或库里另一行同键;软删除实体的部分唯一索引只比较未删除的行),撞上就把这些实体移出跟踪、返回 PERSISTENCE_CONFLICT_003(参数 index 是索引名),工作单元随后只能回滚;核对不到的异常原样抛出。
  • 依赖 Microsoft.EntityFrameworkCore.Relational(索引名与表名是关系型元数据)。
  • 软删除实体的唯一索引只约束未删除的行(ADR-0081):HasFilter 的条件 Npgsql "IsDeleted" = false、SQLite "IsDeleted" = 0、SQL Server [IsDeleted] = 0(键含可空列另加 IS NOT NULL);Pomelo MySQL、Oracle 等不支持部分唯一索引的 Provider 建模即失败,要唯一约束就把 IsDeleted 显式写进列。
  • SQLite 上 ApplyPersistenceConventions(Database) 把模型里全部 DateTimeOffset 与可空 DateTimeOffset 属性(含从属类型与复杂类型里的,框架表与应用实体一样)存成 UTC ticks 整数(ADR-0091):EF Core 的 SQLite Provider 把 DateTimeOffset 存成文本,翻译不了大小比较与排序(SQLite does not support expressions of type 'DateTimeOffset' in ORDER BY clauses),到期书签、按时间过滤与分页都要用到。写入 UtcTicks、读回偏移为零的同一时刻,100 纳秒精度与 SqlSugar 在 SQLite 上的 UTC 日期时间文本一致,整数的比较与排序就是时刻的比较与排序。应用已配了值转换或存储类型的属性(HasConversion、ConfigureConventions 的 HaveConversion)不动;其余 Provider 不动。已有的 SQLite 库:原来没配转换的列存的是 EF Core 的 DateTimeOffset 文本,照读会被静默读成错误的时刻;特性启动时核对,旧库即启动失败(PERSISTENCE_VALIDATION_008,ADR-0094),怎么修见下文已有 SQLite 库的时间列;原来自己配了转换(如 DateTimeOffsetToBinaryConverter)的照旧,库不用动、也不核对。
  • SQLite 上字符串的 Contains / StartsWith / EndsWith 一律译成 LIKE … ESCAPE '\'(ADR-0101):EF Core 的 SQLite Provider 原来把 Contains 译成区分大小写的 instr,StartsWith / EndsWith 的实参是值时译成只对 ASCII 字母不区分大小写的 LIKE、是列或表达式时译成区分的 substr 比较,同一个库上三种规则。特性在 DbContext 选项上加一个查询表达式拦截器(IQueryExpressionInterceptor,查询编译之前把这三个方法换成本包的标记方法,只在 SQLite 上改写)与一个方法翻译插件(IMethodCallTranslatorPlugin,经选项扩展登记到 EF Core 内部服务容器,把标记方法译成 LIKE):常量实参在翻译时转义并拼上 %;参数、列或表达式在 SQL 里以三层 replace 转义(反斜杠、%、_)、以 || 拼上 %,并要求实参不为 NULL(NULL 实参不匹配,与原来一样)。结果只对 ASCII 字母不区分大小写、其余字母区分,%、_、反斜杠按字面,与 SqlSugar 在 SQLite 上同一套规则;字符实参与字符串一样。范围是特性建出的 DbContext 上的全部 LINQ(仓储的规约、应用直接写的查询、投影、子查询、参数化集合),投影里落到客户端求值的部分按 .NET 原方法计算;应用自己的全局查询过滤器(HasQueryFilter)、不经特性建出的 DbContext 不经过改写,应用用 UseInternalServiceProvider 自给 EF Core 内部服务容器的不受支持。SQLite 的 LIKE 模式最长 50000 字节,Contains 的实参超过即报错(原来的 instr 没有这个限制)。PostgreSQL、SQL Server 等其余数据库照 EF Core 数据库 Provider 的翻译,本来就一套规则、按字面,不改写。
  • 启动时在独立作用域里取一次模型,建模错误(索引声明不合法、Provider 不支持部分唯一索引)在启动时失败,不论是否开着 EnsureCreated;SQLite 上随后逐连接核对时间列的存储形式(见下文已有 SQLite 库的时间列)。
  • 作用域结束时事务仍未提交(中间件之外直接使用),释放即回滚。
  • 租户分库:装配了 multitenancy.sharding@1 时,DbContext 选项按作用域构建;带分片的调用把分片绑定(主值是租户连接串,属性含 tenant、provider 与透传属性)交给同一个回调,整个 DbContext 连到分片库,其余调用用默认连接。分片库的表结构由应用管理,EnsureCreated 只作用于默认连接。没有 SqlSugar Provider 那样按实体分库的落点规则。
  • 健康项 persistence.entityframeworkcore.database:启动时同步探一次,之后按间隔在后台重探并缓存结果;探测在独立作用域里对默认连接 CanConnect,不开工作单元、不经管道。连不上为 Degraded(PERSISTENCE_UNAVAILABLE_003),应用仍在服务。
  • 整数主键按 EF Core 约定由数据库生成;应用自己生成整数标识时配 ValueGeneratedNever(),见标识生成。
  • 实体目录(ADR-0078)取 DbContext 模型(GetTableName()、GetColumnName(表),影子属性不列);库结构读取不支持,返回 PERSISTENCE_UNAVAILABLE_008——跨数据库读结构在 EF Core 里是设计期脚手架服务,DbConnection.GetSchema 各驱动不一致、SQLite 驱动不实现。
  • 索引与实体目录的补充:ApplyPersistenceConventions(Database) 的索引名取调用时的 GetTableName(),ToTable 要在它之前配置;从属类型与共享类型实体跳过。框架的数据库记录(授予、翻译、审计、调度、工作流、发件箱)在 modelBuilder.Entity<T>() 之后调用它即得到索引,不用再手写 HasIndex。实体目录的表名在没有映射到表时取类型名,从属类型不列,登记了却不在模型里的实体抛 InvalidOperationException;库结构读取在 EF Core 里对应的设计期脚手架服务是 Microsoft.EntityFrameworkCore.Design 的 IDatabaseModelFactory(按数据库 Provider 程序集反射发现)。
  • 命名连接(ADR-0087):在 XiHan:Persistence:EntityFrameworkCore:Connections:<name> 声明,值是连接串(名字合 ^[a-z0-9][a-z0-9-]{0,63}$,不能是 default 或 shard,值不能空白、不能写成子节,否则装配即 ArgumentException)。一个 DbContext 一个连接、建出后不换:UnitOfWorkOptions(Connection: name) 开启的工作单元先记下连接、再建本作用域的 DbContext,回调收到的绑定主值是那个连接串、属性沿用 db 绑定,事务只开在那个库上。所以登记到命名连接的实体(AddRepository<TEntity, TKey>(connection))只能在绑定那个连接的工作单元里读写:普通工作单元里取用它的仓储抛出携带 PERSISTENCE_UNAVAILABLE_008 的 FrameworkException,绑定连接的工作单元里取用落在别处的实体抛 PERSISTENCE_VALIDATION_004。这一半是已定的(ADR-0091):EF Core 不做「每个连接一个 DbContext、跨上下文顺序提交」的多上下文工作单元,普通工作单元里取用命名连接上的实体维持明确报错;要在业务事务里一起读写命名连接上的实体,用 Persistence.SqlSugar。登记到没有声明的连接装配即失败、绑定没有声明的连接 BeginAsync 抛出,都是 PERSISTENCE_VALIDATION_004;作用域里已经按别的连接建出 DbContext(先取用了它)再绑定,BeginAsync 抛 PERSISTENCE_VALIDATION_002,在新作用域里绑定即可。EnsureCreated 对登记了实体的每个命名连接各执行一次,按整个模型建表(那个库里多出的空表不用)。命名连接与默认连接用同一个配置回调,是同一种数据库 Provider。工作流的租约表就这样放到另一个库:SQLite 上 XiHan:Workflow:Database:LockConnection 指向这里声明的连接,调用方事务里取锁不再等自己的事务。
  • 运行时数据源(ADR-0078)不支持:EF Core 的数据库 Provider 由应用选、本包不引用,按数据库类型建不出连接。注册表 DataSourceRegistry 照样登记,取用与测试连接返回 PERSISTENCE_UNAVAILABLE_008。
  • 这两项(运行时数据源、库结构读取)不计划支持,已定(ADR-0084):需要代码生成的读结构、外部数据源或零代码运行时的应用用 Persistence.SqlSugar。

已有 SQLite 库的时间列 ​

约定把时刻映射成 UTC ticks 整数(ADR-0091)之前建出的 SQLite 库,没配转换的列是 TEXT,存 EF Core 的 DateTimeOffset 文本(如 2026-11-01 08:00:00+08:00);按整数读时 SQLite 把文本开头的年份当整数,旧行会被静默读成 0001-01-01T00:00:00.0002026 这样的错误时刻,不报错。特性启动时因此核对库里的存储形式,不符即启动失败(ADR-0094):

  • 核对什么:默认连接一次,登记了实体的每个命名连接各一次;落在这个连接上的实体的表里、由约定映射成 UTC ticks 的列。列的声明类型不是 INTEGER(旧库的 TEXT,空表也算——TEXT 列会把之后写入的整数也存成文本)即不符;声明是 INTEGER 时抽样表里最早写入的 1000 行,查到非整数的值(重建表时没换算)即不符。
  • 失败的样子:特性启动失败,原因是 PERSISTENCE_VALIDATION_008,消息列出连接与不符的列,如 TimedRow.At (TEXT)(声明类型)、TimedRow.Due (INTEGER, text)(声明类型与抽样查到的存储类)。核对只读,不改库。
  • 不核对什么:SQLite 之外的数据库;应用自己配了转换的列;库里没有的表或列(迁移没跑到,读写时 EF Core 自己报错);库文件还不存在(不打开连接,不会因此建出空库)。
  • 租户分片库(ADR-0097):启动时还不知道有哪些分片,改在首次绑定时核对——工作单元开始时 DbContext 建在租户分片上(Tenant 阶段解析出的分片,或 TenantShardScope 显式绑定的),而这个分片库还没有核对通过,就先按同样的范围与判定核对一次。不符时那次工作单元开不起来:抛出携带 PERSISTENCE_VALIDATION_008 的异常,经管道的调用是 KERNEL_UNEXPECTED_001(HTTP 500)、原因是它,消息带租户与不符的列(消息键 persistence.schema.shard_timestamp_format_mismatch,不带连接串);这个分片上的调用都失败,别的租户照常。报 500 而不是 400 是已定的(ADR-0099):008 是校验类的码,转成结果会按 400 回给调用方,而这是服务端库里的数据问题,调用方改不了;也不另立错误码,与工作单元开始时其余装配类错误(PERSISTENCE_VALIDATION_002、004)一样经管道是 500,原因码在 Inner 里,排查看日志里的内层错误。核对通过的分片按连接串记在进程内,之后每次开工作单元只看这条记录,同一分片的并发首绑排队、只查一次;记录至多 VerifiedShardCapacity 条(默认 1024),记满淘汰最早记下的,被淘汰的分片下次绑定时重新核对一次(并发再绑同样只查一次),SQLite 分片库很多的应用可以调大;不符、库文件还不存在、核对抛出异常(打不开库、调用取消)都不记下,按下面的办法修好或建出库之后,下一次绑定即重新核对,不用重启。不经工作单元、直接解析 DbContext 读写分片的不在范围内。
  • 开销:每个连接一次读表结构,每张这类表一次至多 1000 行的抽样,与表的大小无关:1 张 100 万行的表约 1 毫秒,200 张表各 1 万行约 100 毫秒。租户分片是每个分片库首次绑定时一次,算在那次调用里。
  • SqlSugar 不需要这项核对:它在 SQLite 上一直把 DateTimeOffset 存成 UTC 日期时间文本、读取时整段解析,存储形式没有变过;库里是整数(如 EF Core 建的库)时读取抛出 InvalidCastException,不会静默读错。

怎么修:

  • 数据可以丢的开发、测试库:删库重建(EnsureCreated 或重新跑迁移)。
  • 要留数据:重建表,列建成 INTEGER,搬数据时把文本换算成 UTC ticks。SQLite 改不了列类型,EF Core 迁移改列类型本来就是重建表,但它搬数据是原样 INSERT … SELECT,文本搬进 INTEGER 列仍是文本,启动核对照样失败——要把迁移里搬数据的语句换成带换算式的(migrationBuilder.Sql(...)),或手工执行。只就地 UPDATE 不重建表时列仍声明为 TEXT,整数按文本存,启动核对同样失败。

换算式(col 换成列名;对 NULL 得 NULL),把 EF Core 的文本 yyyy-MM-dd HH:mm:ss[.fffffff]±hh:mm 换成 UTC ticks,实测不同偏移、带 7 位小数的值往返一致:

sql
(unixepoch(substr(col, 1, 19))
 - (CASE substr(col, -6, 1) WHEN '-' THEN -1 ELSE 1 END) * (CAST(substr(col, -5, 2) AS INTEGER) * 3600 + CAST(substr(col, -2, 2) AS INTEGER) * 60)) * 10000000
+ CASE WHEN substr(col, 20, 1) = '.' THEN CAST(substr(substr(col, 21, length(col) - 26) || '0000000', 1, 7) AS INTEGER) ELSE 0 END
+ 621355968000000000

重建一张表(以 TimedRow(Id, At NOT NULL, Due NULL) 为例,<At 的换算式> 是上式把 col 换成 "At"):

sql
CREATE TABLE "ef_temp_TimedRow" ("Id" TEXT NOT NULL CONSTRAINT "PK_TimedRow" PRIMARY KEY, "At" INTEGER NOT NULL, "Due" INTEGER NULL);
INSERT INTO "ef_temp_TimedRow" ("Id", "At", "Due") SELECT "Id", <At 的换算式>, <Due 的换算式> FROM "TimedRow";
DROP TABLE "TimedRow";
ALTER TABLE "ef_temp_TimedRow" RENAME TO "TimedRow";
-- 再建回这张表原有的索引

相关 ​

Released under The MIT License