Repository Wiki
BeyondDimension/SteamTools

数据持久化与仓储层

SteamTools 客户端的数据持久化建立在泛型仓储模式(Generic Repository Pattern)之上:所有本地数据通过 Repository<TEntity, TPrimaryKey> 基类族统一访问 sqlite-net 异步连接(SQLiteAsyncConnection),并以接口(IRepository<,> 派生)的形式注入到服务与 ViewModel 层。

Purpose and Scope(目的与范围)

本页覆盖客户端本地数据持久化子系统的完整机制:

  • 泛型仓储基类 Repository<TEntity, TPrimaryKey> 的连接管理与表初始化约定;
  • 缓存仓储抽象 CacheRepository<TEntity, TPrimaryKey> 与独立缓存数据库 cache.dbf 的隔离设计;
  • 主程序中的具体仓储实现(UserRepository、NotificationRepository、RequestCacheRepository);
  • 各插件(加速器、令牌验证器、反向代理)如何复用同一基类实现自己的仓储;
  • 并发、AOT/裁剪(DynamicallyAccessedMembers)、连接生命周期等专业注意事项。

以下内容有意留给兄弟页面,不在本页展开:

  • 实体类型本身的字段定义与业务语义(见实体/模型相关页面);
  • HTTP 请求与网络栈(RequestCache 仅作为持久化示例出现);
  • 各插件的业务逻辑(如加速器脚本管理、Authenticator 令牌计算)。

Overview(概述)

客户端所有本地状态(用户信息、通知、请求缓存、加速器脚本、平台验证器账号、安全存储键值等)都以"实体 + 仓储"的方式落盘:

关注点实现
实体契约IEntity<TPrimaryKey>,实体必须具有主键且可 new()
仓储契约IRepository<TEntity, TPrimaryKey>(如 INotificationRepository)
通用实现Repository<TEntity, TPrimaryKey> 泛型基类(共享库提供)
缓存型实现CacheRepository<TEntity, TPrimaryKey>,指向 cache.dbf
数据访问技术sqlite-net 的 SQLiteAsyncConnection(全异步 API)
注入方式接口注入到服务/ViewModel,插件内自建仓储(如反向代理进程)

设计意图:业务层只依赖接口 + 实体,不感知数据库文件、连接与建表细节;数据库连接按"数据库文件"维度以 Lazy<T> 单例化,避免每次操作重复打开文件句柄;易失性缓存数据被隔离到独立的 cache.dbf,与主数据文件分开管理(清理缓存不会伤及业务数据)。

Architecture(架构)

Loading diagram...

要点说明:

  • 接口与实现分离:每个具体仓储同时声明"继承基类 + 实现对应接口",例如 internal sealed class NotificationRepository : Repository<Notification, Guid>, INotificationRepository,业务层因此可以面向 INotificationRepository 编程并在测试中替换实现。
  • 两类数据库文件:直接继承 Repository<,> 的仓储使用主数据库;继承 CacheRepository<,> 的仓储固定使用 IOPath.CacheDirectory 下的 cache.dbf,实现"缓存数据与业务数据物理隔离"。
  • 插件复用:加速器插件(ScriptRepository)、Authenticator 插件(AccountPlatformAuthenticatorRepository)乃至反向代理独立进程(RepositorySecureStorage,直接把键值存储实现为 Repository<KeyValuePair, string> + ISecureStorage)都建立在同一基类之上,保证跨模块的持久化行为一致。

核心实现(Main Content)

1. 缓存仓储基类 CacheRepository<TEntity, TPrimaryKey>

该基类把"指向哪个数据库"这一差异封装起来,其余全部复用通用 Repository<,> 的能力:

csharp
1public abstract class CacheRepository<[DynamicallyAccessedMembers(IEntity.DynamicallyAccessedMemberTypes)] TEntity, TPrimaryKey> : Repository<TEntity, TPrimaryKey> 2 where TEntity : class, IEntity<TPrimaryKey>, new() 3 where TPrimaryKey : IEquatable<TPrimaryKey> 4{ 5 const string fileName = "cache.dbf"; 6 7 static SQLiteAsyncConnection GetConnection() 8 { 9 var dbPath = Path.Combine(IOPath.CacheDirectory, fileName); 10 return GetConnection(dbPath); 11 } 12 13 static readonly Lazy<SQLiteAsyncConnection> dbConnection = new(GetConnection); 14 15 protected sealed override async ValueTask<SQLiteAsyncConnection> GetDbConnection() 16 { 17 var connection = dbConnection.Value; 18 await GetDbConnection<TEntity>(connection); 19 return connection; 20 } 21}

Source: CacheRepository.cs

逐点解读:

  • const string fileName = "cache.dbf":所有缓存型实体共用一个文件,文件名硬编码为常量,避免多个缓存仓储各自创建零散数据库。
  • Lazy<SQLiteAsyncConnection>:进程内首次访问 dbConnection.Value 时才建立连接,之后所有请求复用同一个连接对象。sqlite-net 的异步连接本身可安全地并发使用其异步 API,配合单例连接避免了重复打开文件句柄与连接风暴。
  • sealed override GetDbConnection():子类(如 RequestCacheRepository)不允许再改写数据库来源——sealed 保证缓存仓储永远落在 cache.dbf,这是一个刻意的防误用约束。
  • await GetDbConnection<TEntity>(connection):在返回连接前对当前实体执行"确保表存在/迁移"的初始化(由基类提供),这是仓储首次使用某个实体时的惰性建表机制。
  • [DynamicallyAccessedMembers(IEntity.DynamicallyAccessedMemberTypes)]:面向 Native AOT/裁剪的标注,告诉裁剪器保留实体上会被 sqlite-net 反射读取的成员(属性、构造等),否则发布裁剪版时表结构会丢失字段。

2. 具体仓储实现:一行式继承 + 接口声明

主程序中的仓储实现几乎零样板代码,全部 CRUD 细节由基类吸收:

csharp
internal sealed class UserRepository : Repository<User, Guid>, IUserRepository { }

Source: UserRepository.cs

csharp
internal sealed class NotificationRepository : Repository<Notification, Guid>, INotificationRepository { }

Source: NotificationRepository.cs

csharp
internal sealed class RequestCacheRepository : CacheRepository<RequestCache, string>, IRequestCacheRepository { }

Source: RequestCacheRepository.cs

对应接口同样是最小化声明,仅"实例化"泛型参数即可:

csharp
public interface INotificationRepository : IRepository<Notification, Guid> { }

Source: INotificationRepository.cs

设计意图:Repository<T, P> 中的两个类型参数正是接口契约的形状(实体 + 主键),新增一张表通常只需"实体 + 空仓储 + 空接口"三处一行式声明,扩展成本极低。

3. 插件与跨进程复用

插件项目直接引用共享库中的基类,在各自命名空间下建立仓储:

csharp
internal sealed class ScriptRepository : Repository<Script, int>, IScriptRepository { }

Source: ScriptRepository.cs

csharp
internal sealed class AccountPlatformAuthenticatorRepository : Repository<AccountPlatformAuthenticator, ushort>, IAccountPlatformAuthenticatorRepository { }

Source: AccountPlatformAuthenticatorRepository.cs

反向代理插件(独立运行的代理进程)则把仓储直接用作安全存储后端:

csharp
1sealed class RepositorySecureStorage : Repository<KeyValuePair, string>, ISecureStorage 2{ 3 ... 4}

Source: Program.cs

这说明仓储基类是进程无关的:主客户端 UI 进程与代理子进程都能以相同方式持久化数据,代理进程无需链接整个客户端的 DI 容器,仅需实体与基类所在的共享程序集。

Core Flow(核心执行流:一次仓储访问的时序)

Loading diagram...

时序中的关键顺序:先确定连接(惰性单例)→ 再确保当前实体的表结构 → 最后执行业务 CRUD。这保证了任意仓储方法首次被调用时数据库与表已就绪,业务代码无需显式"初始化数据库"步骤,也不会因遗忘建表而在运行时抛出 no such table 错误。

API Reference(API 参考)

基类 Repository<TEntity, TPrimaryKey> 与接口 IRepository<TEntity, TPrimaryKey> 定义于共享库(本仓库各客户端项目引用的通用程序集),其在本仓库代码中被消费的公开面如下:

protected virtual ValueTask<SQLiteAsyncConnection> GetDbConnection()

返回当前仓储应使用的 sqlite 异步连接,并在返回前完成对 TEntity 的建表/迁移检查。子类通过覆写它来改变数据库来源;CacheRepository 将其 sealed override 为 cache.dbf 单例。

约束:

  • TEntity 必须实现 IEntity<TPrimaryKey> 且具有无参构造(class ... new());
  • TPrimaryKey 必须实现 IEquatable<TPrimaryKey>(主键比较语义)。

static SQLiteAsyncConnection GetConnection(string dbPath)

由 CacheRepository.GetConnection() 调用的静态助手(定义于 Repository<,> 基类),根据数据库文件路径构造 SQLiteAsyncConnection。仓库内可直接观测到它在缓存仓储中的使用(见上文 CacheRepository 摘录)。

static ValueTask GetDbConnection<TEntity>(SQLiteAsyncConnection connection)

对指定连接执行"确保 TEntity 表存在/结构同步"的初始化助手,CacheRepository.GetDbConnection() 在返回连接前对其 await。具体的建表语句与迁移策略位于共享库实现中,仓库内未见重复实现(实现细节不在本仓库源码范围内)。

各具体仓储暴露的 CRUD 面

仓储实体主键接口所属数据库
UserRepositoryUserGuidIUserRepository主数据库
NotificationRepositoryNotificationGuidINotificationRepository主数据库
RequestCacheRepositoryRequestCachestringIRequestCacheRepositorycache.dbf
ScriptRepositoryScriptintIScriptRepository主数据库(加速器插件)
AccountPlatformAuthenticatorRepositoryAccountPlatformAuthenticatorushortIAccountPlatformAuthenticatorRepository主数据库(Authenticator 插件)
RepositorySecureStorageKeyValuePairstringISecureStorage反向代理进程独立数据库

增删改查方法(Insert/Update/Delete/Query/Table<T> 等)继承自 sqlite-net 风格的基类契约,本仓库内的子类未覆写任何数据方法(全部为空实现体),因此行为完全由共享基类决定。

Usage Examples(用法示例)

基本用法:声明一个新仓储

以通知仓储为例,新增持久化能力只需三步——定义实体、定义空接口、定义空实现:

csharp
public interface INotificationRepository : IRepository<Notification, Guid> { }

Source: INotificationRepository.cs

csharp
internal sealed class NotificationRepository : Repository<Notification, Guid>, INotificationRepository { }

Source: NotificationRepository.cs

高级用法:把数据路由到缓存数据库

当实体属于可再生缓存(如 HTTP 请求缓存 RequestCache,主键为 string)时,改继承 CacheRepository<,> 即可让数据落入 IOPath.CacheDirectory 下的 cache.dbf,与业务库完全隔离:

csharp
internal sealed class RequestCacheRepository : CacheRepository<RequestCache, string>, IRequestCacheRepository { }

Source: RequestCacheRepository.cs

其背后的路由逻辑全部位于 CacheRepository 中:

csharp
1static SQLiteAsyncConnection GetConnection() 2{ 3 var dbPath = Path.Combine(IOPath.CacheDirectory, fileName); 4 return GetConnection(dbPath); 5}

Source: CacheRepository.cs

跨进程用法:在反向代理进程中将仓储用作安全存储

csharp
sealed class RepositorySecureStorage : Repository<KeyValuePair, string>, ISecureStorage {

Source: Program.cs

KeyValuePair 实体(string 主键)+ ISecureStorage 接口的组合,使代理子进程无需引入完整客户端基础设施即可获得持久化键值存储。

Configuration Options(配置项)

仓储层本身的"配置"以代码常量与平台目录约定形式存在,而非运行时配置文件:

项类型默认值/来源说明
缓存数据库文件名const string"cache.dbf"(硬编码)所有 CacheRepository 派生仓储共用的文件
缓存数据库目录平台路径IOPath.CacheDirectory由共享库按平台(Windows/macOS/Linux/Android/iOS)解析的系统缓存目录
主数据库位置平台路径共享库 Repository<,> 基类决定仓库内具体实现未见(位于共享程序集)
连接创建时机代码策略Lazy<SQLiteAsyncConnection>首次访问时惰性建立,进程内单例复用
表初始化时机代码策略首次 GetDbConnection()GetDbConnection<TEntity>(connection) 确保表结构就绪

Failure Modes、Edge Cases 与并发

  • 并发访问:连接以 Lazy<T> 单例化,且仅当 Value 已被首次消费后才返回同一实例。极端竞态下两个线程同时首次触发 .Value 时,Lazy<T>(默认 LazyThreadSafetyMode.ExecutionAndPublication)保证工厂只执行一次,最终所有调用方拿到同一 SQLiteAsyncConnection;随后 sqlite-net 的异步 API 提供内部串行化,避免同一连接上的并发原生调用损坏句柄。
  • 表缺失:不存在"忘记初始化"这类失败模式——每次 GetDbConnection() 返回前都会对当前实体执行建表检查(await GetDbConnection<TEntity>(connection)),no such table 错误被结构性排除。
  • 误改缓存落点:CacheRepository.GetDbConnection() 被 sealed 封死,子类无法把缓存实体写到主库(或反之),防止破坏"缓存与业务数据隔离"的清理语义。
  • AOT/裁剪导致字段丢失:实体泛型参数上的 [DynamicallyAccessedMembers(IEntity.DynamicallyAccessedMemberTypes)] 是对裁剪器的契约;新增实体若不带该标注(或不满足 IEntity<TPrimaryKey> + new() 约束),将在编译期而非运行期被拒绝。
  • 跨进程数据:反向代理进程与主客户端各自持有连接实例;仓库内未见到跨进程文件锁处理,依赖 sqlite 自身的文件级并发控制,应避免两个进程同时高频写同一数据库文件(现有划分——代理进程独立存储——正是规避该问题的设计)。

Performance 与运维要点

  • 单连接复用:每数据库文件一个进程级连接,省去反复 open/close 的系统调用开销;高频小查询(如通知轮询、请求缓存命中)因此受益。
  • 惰性初始化:冷启动时未使用的仓储不产生任何 I/O,缩短启动路径;首次访问某实体的仓储方法时才付建表成本。
  • 缓存可安全清除:cache.dbf 位于 IOPath.CacheDirectory,用户/系统清缓存只会影响可再生数据,业务数据(用户、通知、脚本、令牌账号)不受影响——这是把缓存独立成文件的核心运维价值。
  • 测试替身:业务层依赖 IUserRepository/INotificationRepository 等接口,可在单元测试中注入内存实现而不触文件系统。

Extension Points(扩展点)

  1. 新增业务表:遵循"实体(实现 IEntity<TPk>)→ IRepository<,> 派生接口 → Repository<,> 派生空类"三件套(参照 NotificationRepository 的最小实现),然后在 DI/服务中面向接口使用。
  2. 新增缓存型数据:同上,但实现类继承 CacheRepository<,>(参照 RequestCacheRepository),自动获得 cache.dbf 路由。
  3. 替换数据库来源:覆写基类的 GetDbConnection()(CacheRepository 之外的派生类未被 seal)可将特定仓储指向自定义文件——仓库内的主程序实现均未这样做,说明这是保留给特殊场景的口子。
  4. 把仓储适配为其他存储接口:如反向代理所示,Repository<KeyValuePair, string> 可以直接实现 ISecureStorage 这类与"仓储"无关的存储契约,是跨能力复用的现成模式。

Tests

仓库内可观测到的最小用法验证来自调试页面:DebugPageViewModel 中内联定义了一个 Repository<Common.Entities.KeyValuePair, string> 派生类用于运行时检验仓储行为(DebugPageViewModel.cs)。未见针对仓储层的独立单元测试文件;其行为保障主要来自共享库契约与类型系统约束。

说明:Repository<,>/IRepository<,> 基础实现与 IEntity.DynamicallyAccessedMemberTypes 的完整定义位于本仓库引用的共享库程序集中,未包含在当前仓库源码内;上文对基类行为的描述以仓库内派生类的可观测用法为依据。

Sources

(1 files)