XiHan.Framework.Persistence.EntityFrameworkCore
persistence.relational@1 的 Entity Framework Core Provider:应用持有自己的 DbContext,框架在它之上提供仓储、事务型工作单元与协议约定。适合新项目与使用 EF Core 迁移管理表结构的应用。
| 项 | 内容 |
|---|---|
| 包 ID | XiHan.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。
安装与启用
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)。
- 定义
DbContext:登记实体后调用ApplyPersistenceConventions(Database)。 - 登记实体:
AddRepository<TEntity, TKey>()。 - 装配特性,在回调里用绑定
db配置连接。
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 | 正整数 | 1024 | SQLite 上核对通过的租户分片库最多记多少个(见「已有 SQLite 库的时间列」);记满淘汰最早记下的,被淘汰的分片下次绑定时重新核对一次 |
XiHan:Persistence:EntityFrameworkCore:Connections:<name> | 字符串 | — | 命名连接,值是连接串;绑定它的工作单元所在作用域的 DbContext 建在这个连接上(见「行为与限制」) |
XiHan:Bindings:db | 字符串 | — | 连接绑定的主值,作为 BindingValue.Value 交给回调 |
{
"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。它与仓储共享同一个实例和同一个事务,但不带软删除与租户过滤,要自己加:
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 位小数的值往返一致:
(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"):
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";
-- 再建回这张表原有的索引相关
- 持久化
- 工作单元与事务
- 多租户
- Persistence.Abstractions
- Persistence.SqlSugar
- Persistence.Generators
- 规范:RFC-0005《领域协议与持久化》
- 决策:ADR-0005、ADR-0078、ADR-0101
