数据访问
数据库是应用服务开发的基础与核心。模板使用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
多库操作难以保证数据的一致性,耦合性较高,业务理解复杂,应尽量避免该情况。建议通过服务间调用,或使用消息队列来实现跨库操作,以保持最终一致性。
In this article