XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

SqlSugar Provider ​

这一页讲 XiHan.Framework.Persistence.SqlSugar:两种创建客户端的方式、SqlSugarConventions.Apply 施加了哪些约定、InitTables 建表、多库落点与命名连接、按时间分表、读写分离、差异日志、命令超时,以及可空值类型判空与 PostgreSQL 的两个注意事项。读完它,你能把应用切到 SqlSugar,并用上它独有的多库与分表能力。前置知识是持久化与 Provider与工作单元与事务。

为什么需要 ​

已经用 SqlSugar 的项目不该为了接入框架推倒重来;有些场景 EF Core 也做不到:一次调用既写平台表又写租户分片库、日志类实体按月分表、写入时拿到前后值做差异日志、读走从库写走主库、运行时按记录连外部库。SqlSugar Provider 以作用域内的 ISqlSugarClient 实现仓储与事务型工作单元,实体不继承 SqlSugar 基类、不必标 [SugarColumn],映射由协议约定推出;原生查询继续用自己的客户端。

概念 ​

项内容
包XiHan.Framework.Persistence.SqlSugar,依赖 SqlSugarCore(自带各数据库驱动)与 Microsoft.Data.Sqlite;应用改用 SqlSugarCoreNoDrive 加单个驱动时要整体替换
能力提供 persistence.relational@1(role: provider);依赖 kernel.hosting@1,可选依赖 multitenancy.sharding@1
特性入口SqlSugarPersistenceFeature:无参构造用应用注册的客户端,带回调的构造 Action<Maybe<BindingValue>, ConnectionConfig> 由特性创建客户端;DiffLog 选出开启差异日志的实体
绑定与配置绑定 db(connection:relational,可选);配置节 XiHan:Persistence:SqlSugar
健康项persistence.sqlsugar.database、persistence.sqlsugar.replicas
Native AOT不兼容:SqlSugar 的实体映射与表达式翻译基于运行时反射
主要类型SqlSugarConventions(Apply、InitTables)、SqlSugarConnections(Default、Shard)、IEntityConnectionSelector、SqlSugarUnitOfWork(Client、ConnectionFor(Type)、EnsureTransactionAsync)、SqlSugarDiffLogData

最小示例 ​

方式一,特性按回调创建客户端,连接串来自绑定 db:

csharp
using SimpleApp.Orders;
using SqlSugar;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Persistence.Abstractions.Repositories;
using XiHan.Framework.Persistence.SqlSugar;
using XiHan.Framework.Profiles.Standard;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRepository<Order, Guid>();
builder.AddXiHan(x => x
    .UseStandardProfile()
    .UseFeature(new SqlSugarPersistenceFeature(static (binding, config) =>
    {
        config.DbType = DbType.PostgreSQL;
        config.ConnectionString = binding.ValueOrThrow().Value;
    })));

var app = builder.Build();
app.UseXiHan();
app.Run();
json
{
  "XiHan": {
    "Bindings": {
      "db": "Host=localhost;Database=app;Username=app"
    },
    "Persistence": {
      "SqlSugar": {
        "InitTables": true
      }
    }
  }
}

回调设置数据库类型与连接串,之后特性施加协议约定;默认连接的 ConfigId 由特性定为 default。运行后 InitTables 按已登记的实体建出 Order 表,POST /orders 的写入进入 PostgreSQL,操作代码与内存实现时完全相同。

方式二,应用自己注册带多个连接的客户端(替换上例里的装配),每个 ConnectionConfig 在创建客户端前先经 SqlSugarConventions.Apply,默认连接的 ConfigId 用 SqlSugarConnections.Default:

csharp
builder.Services.AddScoped<ISqlSugarClient>(_ => new SqlSugarClient(
[
    SqlSugarConventions.Apply(new ConnectionConfig { ConfigId = SqlSugarConnections.Default, DbType = DbType.PostgreSQL, ConnectionString = appDb, IsAutoCloseConnection = true }),
    SqlSugarConventions.Apply(new ConnectionConfig { ConfigId = "audit", DbType = DbType.PostgreSQL, ConnectionString = auditDb, IsAutoCloseConnection = true }),
]));
builder.AddXiHan(x => x.UseStandardProfile().UseFeature(new SqlSugarPersistenceFeature()));

两种方式下仓储、工作单元与查询语义相同。方式二要自己保证每个作用域用一份配置副本(SqlSugarScope 自己会复制),CommandTimeoutSeconds 与 Connections 配置节对它不起作用,框架也不探测它的从库。

协议约定 ​

SqlSugarConventions.Apply 在连接配置上登记的约定(保留配置里已有的实体服务与表服务回调,先执行它们):

  • 名为 Id 的属性是主键,缺省不自增;ConcurrencyStamp 长度 32;[StringLength(n)] 映射为长度 n,[LongText] 映射为长文本类型,实体自己的 [SugarColumn] 已给长度或类型时不动。
  • DateTimeOffset 经转换器按 UTC 时刻往返;可空类型的列可空;没有公开 setter 的属性与集合属性不映射。
  • [EntityIndex] 经表服务回调转成 SugarIndexAttribute;软删除实体的唯一索引由 InitTables 建表后补建成部分唯一索引。
  • 字符串的 Contains / StartsWith / EndsWith 以同名外部函数接管翻译,按字面匹配,直接用施加了约定的客户端写的查询也一样;要通配用 SqlFunc.Like。
  • PostgreSQL 连接串的启动参数追加 standard_conforming_strings=on,见下文。

软删除与租户过滤以表达式显式施加在每个查询上,不用 SqlSugar 的全局过滤器。列与索引的完整约定见建表约定与索引。

建表:InitTables ​

XiHan:Persistence:SqlSugar:InitTables 为 true 时,启动按已登记的实体建缺表、加缺列,经 SqlSugarConventions.InitTables,软删除实体的部分唯一索引随之建出。按落点分组:租户实体在默认连接建表(启动时没有分片),带 [Tenant] 或登记到命名连接的实体在各自的连接上建表;分表实体走 CodeFirst.SplitTables()。删列、改名、改类型不在范围内,生产库的结构演进走迁移脚本;索引按名字查不到才建,表里已有重复数据时唯一索引建不出来、启动失败。应用自己建表(租户独立库、迁移工具)也要调 SqlSugarConventions.InitTables(client, entityTypes),直接调 CodeFirst.InitTables 建不出部分唯一索引。

多库落点 ​

客户端可以带多个连接,每种实体落在哪个连接由 IEntityConnectionSelector 决定。默认实现 SqlSugarConnectionSelector 的规则按顺序:

  1. 实体带 SqlSugar 的 [Tenant(configId)]:落到该 ConfigId 的连接;客户端里没有这个连接是装配错误(PERSISTENCE_VALIDATION_004),不回退到默认连接。
  2. 实体以 AddRepository<TEntity, TKey>(connection) 登记到命名连接:落到同名 ConfigId 的连接,校验同上;与 [Tenant] 不一致同样是 PERSISTENCE_VALIDATION_004。
  3. 实体实现 IMultiTenant,且客户端里有 shard 连接:落到分片连接。
  4. 其余:根(默认)连接。

装配了 multitenancy.sharding@1 时,带分片的调用把分片绑定交给同一个回调,再建一个 ConfigId = shard 的连接:租户实体落分片库,租户目录、授予、发件箱这类平台表仍落默认库,同一调用里一起提交或回滚。方式二由应用自己提供名为 shard 的连接。见租户分片与分库。

把审计类实体固定到 audit 库(配合上面方式二的客户端),实体类上标 SqlSugar 的 [Tenant("audit")] 即可,其余照常实现协议接口。替换规则时,在装配特性之前注册自己的实现:builder.Services.AddSingleton<IEntityConnectionSelector, MySelector>()。同一工作单元里对同一实体类型的选择结果必须稳定。工作单元在第一次写入时经 AsTenant() 在客户端的全部连接上开事务,提交逐连接提交,回滚逐连接回滚。

跨库提交是顺序提交,不是两阶段提交

一次调用写了多个连接时,提交逐连接进行;后一个连接提交失败时,前一个已经提交。需要严格原子性的写入放在同一个库。

命名连接 ​

方式一在 XiHan:Persistence:SqlSugar:Connections:<name> 声明命名连接,回调对每个命名连接各调用一次,config.ConfigId 是连接名;方式二由应用把同名 ConfigId 的连接加进自己注册的客户端。每个作用域的客户端都带上命名连接,工作单元首次写入时只在默认连接与已经取用过的连接上开事务,此后首次取用的连接再补开:业务调用不碰命名连接,那个库就不被业务事务锁住。普通工作单元里,登记到命名连接的实体照常参与整体事务(与 EF Core 不同);绑定连接的工作单元(UnitOfWorkOptions(Connection: name))只用这一个连接,操作落在别处的实体抛 PERSISTENCE_VALIDATION_004。配置与代码示例见连接与运行时数据源。

分表 ​

带 SqlSugar [SplitTable] 的实体自动走分表入口:表名含 {year}{month}{day} 占位符,[SplitField] 是按 UTC 写入的 DateTime 列(SqlSugar 按 DateTime 推表名,不能用 DateTimeOffset)。

csharp
using System.ComponentModel.DataAnnotations;
using SqlSugar;
using XiHan.Framework.Domain.Entities;

namespace SimpleApp.Auditing;

[SugarTable("login_logs_{year}{month}{day}")]
[SplitTable(SplitType.Month)]
public sealed class LoginLog : IEntity<Guid>, IHasCreationTime, IHasConcurrencyStamp
{
    public Guid Id { get; init; }

    // 决定落在哪张分表;写入后不要再改,更新与删除按它定位表
    [SplitField]
    public DateTime LoggedAt { get; set; }

    public DateTimeOffset CreatedAt { get; set; }

    public string ConcurrencyStamp { get; set; } = string.Empty;
}

查询用 EntityQuery.All<LoginLog>().Within(from, to) 限定扫描的表(两端都含,只是扫描提示、不过滤行),没有提示时扫全部分表。写入按 [SplitField] 的值落表,缺少的分表由 SqlSugar 建出。分表查询是多表 UNION ALL,不能加行锁,分表实体的更新与删除靠并发戳防并发。分表实体不用 [EntityIndex],直接用 [SugarIndex] 与 {split_table} 占位符。

读写分离 ​

从库在配置回调里给,回调对每个连接都调用,所以要看 ConfigId,只给它所属的连接:

csharp
new SqlSugarPersistenceFeature((binding, config) =>
{
    config.DbType = DbType.PostgreSQL;
    config.ConnectionString = binding.ValueOrThrow().Value;
    if (Equals(config.ConfigId, SqlSugarConnections.Default))
    {
        config.SlaveConnectionConfigs =
        [
            new SlaveConnectionConfig { ConnectionString = replicaA, HitRate = 10 },
            new SlaveConnectionConfig { ConnectionString = replicaB, HitRate = 10 },
        ];
    }
});

HitRate 是字段,配置绑定绑不上,从配置读时自己赋值;不大于 0 的权重按 Replicas:DefaultHitRate(缺省 10)归一化。

只读工作单元(动词 get、list,或 UnitOfWorkOptions(IsReadOnly: true))的读走从库,设了 ReadsPrimary 的读主库;其余工作单元的读写都走主库,第一次写入之前的读也一样。工作单元之外直接用客户端时,事务内与写入走主库,事务外的读走从库。分片连接默认没有从库。跨调用读己之写不保证:写完马上发起的只读调用可能读到落后的从库,框架读授予和租户目录时因此读主库。

周期探测把不可读的从库摘除,全部不可读时读回到主库,恢复之后过了冷却窗口才放回读流量;健康项 persistence.sqlsugar.replicas 有从库不可读时是 Degraded(PERSISTENCE_UNAVAILABLE_009),应用仍然就绪,只探连通、不看复制延迟。主库不同的连接共用同一个从库是装配错误(PERSISTENCE_VALIDATION_009):回调不看 ConfigId 时,租户的读会读到平台库。

差异日志 ​

DiffLog 选出的实体,仓储的插入、更新(含软删除)、物理删除与批量方法都带 SqlSugar 的 EnableDiffLogEvent,应用在连接配置的 AopEvents.OnDiffLogEvent 上收到操作类型、表、主键与前后值,BusinessData 是 SqlSugarDiffLogData:

csharp
var feature = new SqlSugarPersistenceFeature(static (binding, config) =>
{
    config.DbType = DbType.PostgreSQL;
    config.ConnectionString = binding.ValueOrThrow().Value;
    config.AopEvents = new AopEvents
    {
        OnDiffLogEvent = static diff =>
        {
            if (diff.BusinessData is SqlSugarDiffLogData data)
            {
                // 回调在写入后同步触发、不等提交:只复制需要的值进本调用的缓冲,提交成功后再落库
                data.Services.GetRequiredService<ChangeBuffer>().Add($"{data.EntityType.Name}:{diff.DiffType}");
            }
        },
    };
})
{
    // 只给需要前后值的实体开:每次写入多一次按主键的读取
    DiffLog = static type => type == typeof(Order),
};

ChangeBuffer 是应用自己的作用域服务;feature 照常 UseFeature。缺省一个实体都不开;开启的实体必须有公开的无参构造函数。回调在写入执行后同步触发、不等提交,事务回滚由应用按调用缓冲后再落库。分表实体同样支持,但回调里不要保存 DiffLogModel 的引用(分表插入的各组之间复用同一个)。审计子系统本身见审计。

命令超时 ​

XiHan:Persistence:SqlSugar:CommandTimeoutSeconds(缺省 300)设在特性建的客户端的每个连接上;不是正整数即启动失败,0 不接受(ADO.NET 的 0 是无限等待)。方式二要在自己的客户端上设 Ado.CommandTimeOut,配了这个键启动失败。个别长任务在调用点用 Ado.CommandTimeOut 临时调大。

可空值类型判空 ​

规约在交给 SqlSugar 前先改写:可空值类型与 null 的 == 改写成 SqlFunc.EqualsNull(x, null),!= 改写成 HasValue,避免译成 = NULL 后静默返回错误的结果;逻辑非下推到叶子(德摩根律展开、!(a < b) 换成 a >= b),因为 SqlSugar 在 A && (!x.HasValue || B) 这类位置会漏写 OR / AND。应用直接用客户端写查询时没有这层改写,判空要自己写 SqlFunc.EqualsNull 或 HasValue。

PostgreSQL 注意事项 ​

standard_conforming_strings ​

SqlSugar 在多行插入、批量更新、按实体删除、In、集合 Contains 里内联的字符串字面量只在 standard_conforming_strings = on 时正确;库或角色设成 off 时含反斜杠的值会让语句报错或被静默改掉。SqlSugarConventions.Apply 在连接串(含从库)的启动参数 Options 追加 -c standard_conforming_strings=on,服务器在建连时施加它,优先于库与角色的缺省值;连接串已显式写成别的值时 Apply 抛 ArgumentException。经这个配置的全部连接(含应用的原生 SQL)都是 on,原生 SQL 里要转义用 E'…'。PgBouncer 要能透传启动参数的版本;运行时数据源不加这个参数。

时间戳的 Kind ​

SqlSugar 的 PostgreSQL 提供器自己打开 Npgsql 的 legacy 时间戳模式,timestamptz 列读出来是换算成本地时间、Kind = Local 的 DateTime。约定的 DateTimeOffset 转换器按 Kind 处理:Local 先换回 UTC,Unspecified 按写入时的 UTC 解释,最终都交出偏移为零的 DateTimeOffset。应用直接用客户端读 DateTime 列时没有这层转换,要自己看 Kind:把 Local 值直接当 UTC 会多出本地时区偏移,经实体回写后逐次累加。

MySQL 的已知限制 ​

MySQL 上 SqlSugar 对两个及以上值的 In(…) 与按实体删除把字符串内联成只转义单引号的字面量,缺省 sql_mode 下含反斜杠的字符串主键会静默漏行(开了 NO_BACKSLASH_ESCAPES 时集合 Contains 反过来漏行)。仓储里受影响的是 FindManyAsync、批量方法的标识预查与加锁取回、物理删除;Guid 与整数主键不受影响。应用的字符串主键首选不含反斜杠(写入前按 ^[A-Za-z0-9][A-Za-z0-9._-]*$ 这类规则校验),做不到时改用参数化写法(逐个 == 比较、FindAsync)。这是 SqlSugar 上游缺陷,待上游修复。

配置 ​

键类型缺省说明
XiHan:Persistence:SqlSugar:InitTables布尔false启动时建缺表、加缺列;只宜在开发与测试开启
XiHan:Persistence:SqlSugar:HealthProbeIntervalSeconds正数15健康项的重探间隔,也是单次探测的超时
XiHan:Persistence:SqlSugar:Connections:<name>字符串—方式一的命名连接;方式二写了这一节装配即失败
XiHan:Persistence:SqlSugar:CommandTimeoutSeconds正整数300命令超时(秒)
XiHan:Persistence:SqlSugar:Replicas:DefaultHitRate正整数10权重不大于 0 的从库归一化成的权重
XiHan:Persistence:SqlSugar:Replicas:HealthCheck、HealthCheckIntervalSeconds、FailureCooldownSeconds布尔、正数、非负数true、30、120是否周期探测从库、探测间隔、最后一次失败后恢复读流量前的冷却窗口
XiHan:Bindings:db字符串—方式一的连接绑定主值

健康项 persistence.sqlsugar.database 对默认连接走一次数据库往返,连不上或超时为 Degraded(KERNEL_UNAVAILABLE_005),应用仍在服务。

常见问题 ​

原有的 SqlSugar 实体要改成什么样 ​

实现 IEntity<TKey> 并把主键属性命名为 Id,其余 [SugarTable]、[SugarColumn] 照旧。想让仓储维护创建时间、租户、软删除就实现对应的协议接口;不实现的字段仓储不碰。原生查询继续用自己的客户端。

更新与删除为什么要先读一遍 ​

仓储在事务内加行锁取回当前行(TranLock),比对并发戳后把调用方的实例整行写入,其中创建时间、租户与删除标记先换成当前行的值,保证读取与写入之间别人改不了这一行、协议字段不被覆盖。SQLite 不支持行锁,由数据库级写锁保证;分表实体不加行锁。

从库有延迟,刚写的数据读不到 ​

只读调用走从库,跨调用的读己之写不保证。需要立即读到刚写数据的只读路径,开工作单元时设 ReadsPrimary = true;或者把读写放进同一个可写调用。

相关包与下一步 ​

Released under The MIT License