👨‍💻 niltor    📆 2026-08-27 17:44

数据访问

数据库是应用服务开发的基础与核心。模板使用Entity Framework Core作为数据访问的ORM框架,同时使用BulkExtensions来优化大批量数据操作的性能。

使用Entity Framework Core不仅是为了方便,更重要的是规范了数据开发和访问的方式。

建议使用Code First的方式来定义数据模型,然后使用DbContext来访问数据库。

数据库上下文

模板默认使用DefaultDbContext作为数据访问的上下文,它继承ContextBase。数据库上下文集中在Definition/EntityFramework/AppDbContext目录下;需要新增上下文时,也应在此目录中定义,并继承合适的基类。

默认、只读与分析上下文

类型 职责 适用场景
DefaultDbContext 应用主业务库的读写上下文。实体集合和迁移通常以它为中心维护。 常规业务的查询、创建、修改、删除,以及需要参与主库事务的操作。
ReadonlyDbContext 只读上下文的抽象基类。它关闭自动变更检测,并会在调用SaveChanges或SaveChangesAsync时直接抛出异常。 创建只读库、只读副本或报表库的专用上下文;不能直接实例化。
AnalysisDbContext 继承ContextBase的租户感知读写上下文。 报表、统计、导出以及需要保存分析结果的数据访问。

AnalysisDbContext同时支持查询和保存。它是否连接独立数据库由连接字符串决定:配置了ConnectionStrings:Analysis时使用该连接;未配置时会回退到ConnectionStrings:Default。数据库账号权限仍由部署环境决定;如果某个上下文必须只读,应从ReadonlyDbContext派生并使用只读数据库账号或只读副本。

ReadonlyDbContext只限制通过 EF Core 的保存操作,不能替代数据库权限控制;不要在其中执行原生写入 SQL。若只读上下文需要访问除基类已有实体外的模型,可从ReadonlyDbContext派生自己的上下文,并按需要声明DbSet和模型配置。

AppDbFactory与UniversalDbFactory

模板注册了两个用途不同的工厂。应根据连接目标和租户边界选择,而不是把它们作为可互换的创建方式。

工厂 创建内容 连接选择规则 使用场景
AppDbFactory DefaultDbContext或AnalysisDbContext null仅用于系统租户目录上下文并使用配置默认连接;正常租户必须提供非空TenantId并能从租户目录解析。空GUID或未知租户直接失败;已解析租户的主库或分析库连接字段为空时,才回退到对应默认连接。 绝大多数应用业务。ManagerBase通过它取得当前租户的主库上下文。
UniversalDbFactory 任意继承自DbContext的上下文 用上下文类型名去掉DbContext后缀后的名称查找连接字符串,例如OrdersDbContext对应ConnectionStrings:Orders;调用方同时指定数据库提供程序。 明确需要访问另一套独立数据库,或需要按上下文类型创建多个数据库时。

在普通业务 Manager 中无需自行创建主库上下文,继承ManagerBase<DefaultDbContext, TEntity>即可。仅在直接进行分析查询时,才显式调用AppDbFactory.CreateAnalysisDbContext:

public class TenantReportManager(
    AppDbFactory dbFactory,
    IUserContext userContext,
    ILogger<TenantReportManager> logger) : ManagerBase(logger)
{
    public async Task<List<TenantReportItem>> GetAsync()
    {
        await using var db = dbFactory.CreateAnalysisDbContext(userContext.TenantId);
        return await db.Tenants
            .AsNoTracking()
            .Select(x => new TenantReportItem(x.Id, x.Name))
            .ToListAsync();
    }
}

工厂创建的上下文不由依赖注入容器跟踪。在使用CreateDbContext或CreateAnalysisDbContext后,应使用using或await using及时释放它。

租户上下文与后台任务

ContextBase中的租户过滤和保存校验默认作用于所有继承EntityBase的业务实体。Tenant是全局租户目录根实体,它的TenantId会被忽略,不参与租户过滤;没有绑定租户的上下文可以读写Tenant目录。

普通业务实体则必须绑定租户:

  • ManagerBase<DefaultDbContext, TEntity>在构造时会读取IUserContext.TenantId;当TEntity不是Tenant且支持租户时,TenantId为空会直接抛出异常。
  • 普通查询自动带上当前租户的全局过滤器,不需要在每个查询中重复写TenantId条件。
  • 新增、更新、批量插入和删除都会限制在当前租户内。不要通过修改实体上的TenantId来切换租户。

HTTP请求中,认证 Claims、UserContext和租户解析中间件会准备当前租户。后台任务没有HTTP请求,也不应复用请求作用域中的DbContext或假设IUserContext已经有TenantId。推荐为每个租户创建独立的、短生命周期的DbContext实例:先使用不绑定租户的上下文查询全局Tenant目录,再使用租户Id创建业务上下文。

public sealed class TenantJob(AppDbFactory dbFactory)
{
    public async Task RunAsync(CancellationToken cancellationToken)
    {
        // Tenant是全局目录,不需要TenantId。
        await using var catalogDb = dbFactory.CreateDbContext(null);
        var tenants = await catalogDb.Tenants
            .AsNoTracking()
            .Where(tenant => !tenant.Disabled && !tenant.IsDeleted)
            .ToListAsync(cancellationToken);
        foreach (var tenant in tenants)
        {
            // AppDbFactory会从租户缓存或全局Tenant目录解析连接字符串。
            await using var tenantDb = dbFactory.CreateDbContext(tenant.Id);
            await tenantDb.Set<Order>()
                .Where(order => order.IsDeleted == false)
                .ExecuteUpdateAsync(
                    update => update.SetProperty(order => order.UpdatedTime, DateTimeOffset.UtcNow),
                    cancellationToken);
        }
    }
}

AppDbFactory会通过租户解析服务先读取缓存,未命中时从全局Tenant目录加载并缓存,因此后台任务不需要手动写入CacheService。null只用于目录上下文;传入空GUID或未知TenantId会失败。每个租户都应使用独立的上下文并及时释放,不能在多个租户之间复用同一个DbContext。

自定义不区分租户的 DbContext

后台维护任务、租户目录查询或跨租户汇总有时需要一个不绑定租户的上下文。此时可以定义一个不继承ContextBase的专用DbContext,并限制它只包含全局目录或明确授权的维护模型:

public sealed class CatalogDbContext(DbContextOptions<CatalogDbContext> options)
    : DbContext(options)
{
    public DbSet<Tenant> Tenants => Set<Tenant>();
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        modelBuilder.Entity<Tenant>().Ignore(tenant => tenant.TenantId);
    }
}

这种上下文不会自动添加租户过滤器,也不会执行ContextBase的租户归属校验。如果把普通业务实体加入其中,就等于绕过框架的租户隔离;只能在受控的后台服务中使用,并由业务代码和数据库权限保证授权边界。需要按上下文类型创建时,可以使用UniversalDbFactory;它不会自动读取当前租户,也不会选择租户专属连接。

数据操作

数据查询是业务逻辑中的重要部分,业务代码通常在XXXManager中实现,它继承了ManagerBase<TDbContext,TEntity>,如:

public class AIAgentManager(
    AppDbFactory dbContextFactory,
    ILogger<AIAgentManager> logger,
    IUserContext userContext
) : ManagerBase<DefaultDbContext, AIAgent>(dbContextFactory, userContext, logger)
{
}

ManagerBase<TDbContext,TEntity>,提供了一些针对当前实体的封装好的数据操作方法。当然你也可以完全使用_dbContext来实现数据操作,父类提供了以下属性:

protected IQueryable<TEntity> Queryable { get; set; }
protected readonly ILogger _logger;
protected readonly TDbContext _dbContext;
protected readonly DbSet<TEntity> _dbSet;

如上,其中Queryable默认使用AsNoTracking()的查询方式,避免数据追踪。

Important

请不要在Controller中直接使用DbContext,而是通过继承ManagerBase来实现业务逻辑中的数据操作。这样可以保证业务逻辑的清晰和可维护性。

不使用数据库上下文或实体

当你的Manager不涉及到数据库操作,或不限于某个数据库上下文或实体时,你可以继承ManagerBase(ILogger logger),它不依赖任何数据库上下文或实体。

public class TestManager(MyDbContext context, MyService service, ILogger<TestManager> logger)
    : ManagerBase(logger)
{
}

Important

继承ManagerBase类,会通过源代码生成器生成注入的代码。

租户模式

模板始终按租户感知的方式设计,默认启用多租户行为。单租户和多租户都初始化Tenant目录,并让业务实体关联TenantId;区别主要在于是否为不同租户配置独立连接字符串。当前租户标识来自IUserContext.TenantId,Manager基类会将该值传给AppDbFactory:目录上下文使用null和配置默认连接,正常租户必须使用有效TenantId,已解析租户的空连接字段才回退到对应默认连接。

Tip

你可以根据实际需求修改AppDbFactory中的创建数据库上下文逻辑。

多库操作(预览)

当你的逻辑需要操作多个数据库时,可以注入UniversalDbFactory服务,然后操作不同的数据库。

public class TestManager(        
    UniversalDbFactory dbFactory,
    ILogger<TestManager> logger)
    : ManagerBase(logger)
{
    public async Task MultiDatabase()
    {
        var mssqlDb = dbFactory.CreateDbContext<MainDbContext>();
        mssqlDb.Database.SetCommandTimeout(30);
        var tenant = await mssqlDb.Tenants.FirstOrDefaultAsync();
        var pgsqlDb = dbFactory.CreateDbContext<AnotherDbContext>(DatabaseType.PostgreSql);
        pgsqlDb.Database.SetCommandTimeout(30);
         var user = await pgsqlDb.Set<User>().FirstOrDefaultAsync(u => u.TenantId == tenant.Id);
    }
}

UniversalDbFactory默认会根据你的数据库上下文名称获取对应的连接字符串,并创建对应的DbContext实例。例如OrdersDbContext会读取ConnectionStrings:Orders。它不会读取当前租户,也不会选择租户专属连接;需要租户隔离的主库或分析库访问应使用AppDbFactory。你可查看UniversalDbFactory中的代码逻辑,如:

var contextName = typeof(TContext).Name;
 if (contextName.EndsWith("DbContext"))
 {
     contextName = contextName[..^"DbContext".Length];
 }
 var connectionStrings = configuration.GetConnectionString(contextName);

你可以修改UniversalDbFactory的实现,以满足你实际创建DbContext的需求。

Note

多库操作难以保证数据的一致性,耦合性较高,业务理解复杂,应尽量避免该情况。建议通过服务间调用,或使用消息队列来实现跨库操作,以保持最终一致性。