SpacetimeDB C# SDK 内部架构与开发指南:代码生成、线程模型与客户端缓存深度解析
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
本篇技术指南以 SpacetimeDB 仓库中 sdks/csharp/DEVELOP.md 为骨架,系统讲解 C# 客户端 SDK 的迁移状态、本地开发联调方法、双层代码生成机制、运行时结构与单线程模型,并结合 sdks/csharp/src/SpacetimeDBClient.cs、sdks/csharp/src/Table.cs、sdks/csharp/src/MultiDictionary.cs 等源码,深入剖析订阅去重、网络协议与回调分发的底层实现。读完你将掌握如何用spacetime generate生成客户端、理解DbConnection.FrameTick()的正确用法,以及为什么 C# SDK 要求连接对象只能在单个线程上访问。
一、迁移说明:SDK 代码去向
C# SDK 正在从独立的com.clockworklabs.spacetimedbsdk仓库迁移到 SpacetimeDB 主仓库的 sdks/csharp 子目录。当前规则如下:
- 所有新改动都应提交到
sdks/csharp子目录; - 旧的
com.clockworklabs.spacetimedbsdk仓库仅在发版时同步更新; - 迁移期间可能存在一些尚未打磨的边角("sharp edges"),开发者应以当前仓库为准。
二、基于 SpacetimeDB 本地克隆进行开发
当需要针对本地 SpacetimeDB 克隆进行联调时,必须保证 C# SDK 项目能拿到最新版本的BSATN.Codegen与BSATN.Runtime包——它们源自 SpacetimeDB 仓库内的 crates/bindings-csharp/BSATN.Runtime 等目录,而不是 NuGet 上的旧版本。
假设本地 SpacetimeDB 克隆位于../SpacetimeDB,运行:
dotnet pack ../SpacetimeDB/crates/bindings-csharp/BSATN.Runtime && ./tools~/write-nuget-config.sh ../SpacetimeDB这条命令会做两件事:
dotnet pack把BSATN.Runtime打成本地包;- 执行 sdks/csharp/tools~/write-nuget-config.sh 写出一个(已被
.gitignore忽略的)NuGet.Config文件,使 SDK 项目优先使用本地构建的包,而不是 NuGet 上的发布包。
查看该脚本源码可以看到,它实际生成了两份NuGet.Config:一份写入sdks/csharp/NuGet.Config,一份写入 SpacetimeDB 克隆根目录。两份配置的核心都是通过packageSourceMapping将SpacetimeDB.BSATN.Runtime与SpacetimeDB.Runtime两个包显式映射到本地 Release 输出目录:
<add key="Local SpacetimeDB.BSATN.Runtime" value="${SPACETIMEDB_REPO_PATH}/crates/bindings-csharp/BSATN.Runtime/bin/Release" /> <add key="Local SpacetimeDB.Runtime" value="${SPACETIMEDB_REPO_PATH}/crates/bindings-csharp/Runtime/bin/Release" />同时保留nuget.org作为*通配回退源,确保测试依赖等其余包仍可从公共源解析。这样做的目的是避免在测试时悄悄拉取到过时的 NuGet 版本。
注意:每当你更新了BSATN.Codegen或BSATN.Runtime,都必须重新运行上述命令,让本地包重新打包并刷新 NuGet 配置。
三、内部架构:两层代码生成
SDK 的代码生成分为两层,职责截然不同。
3.1 编译期序列化生成:SpacetimeDB.BSATN.Codegen
SpacetimeDB.BSATN.Codegen是 SDK 的依赖库,其源码位于 SpacetimeDB 仓库的 crates/bindings-csharp。它提供[SpacetimeDB.Type]注解:当 C# 编译器遇到该注解时,会调用此库为被注解的类型生成 BSATN 序列化代码。
关键特性:
- 对任何兼容的 C# 类型都有效;
- 不涉及任何非 C# 代码;
- 生成的代码不会出现在文件系统里(由 Roslyn 编译器在内存中完成)。
如果需要调试SpacetimeDB.BSATN.Codegen生成的代码,可以在.csproj的<PropertyGroup>中设置:
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>然后构建项目,进入obj/Debug/.../generated目录即可查看 Roslyn 生成的 C# 代码。
3.2 网络层客户端生成:spacetimedb-codegen
第二层由 SpacetimeDB 仓库中的spacetimedb-codegenRust 库(源码位于 crates/codegen/src,C# 生成逻辑在其中的csharp.rs)负责,由spacetime generateCLI 命令调用。它生成的代码负责与 SpacetimeDB 模块通过网络通信,这些代码是用户真实可见的(直接落在文件系统里),而关联的模块可以用任意语言编写,不限于 C#。
spacetime generate产出的代码:
- import SpacetimeDB SDK,并扩展其各类以构成一个完整的 SpacetimeDB 客户端;
- import
SpacetimeDB.BSATN.Codegen以满足序列化需求。
完整示例参见 templates/chat-console-cs/module_bindings 目录,其中SpacetimeDBClient.g.cs(见 templates/chat-console-cs/module_bindings/SpacetimeDBClient.g.cs)是客户端类的主文件。该文件顶部标注了 "THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB",并记录了生成时使用的 CLI 版本,例如spacetimedb cli version 2.6.0。
3.3 DbConnection 与继承模式
使用该 SDK 创建的客户端,其根对象是一个DbConnection,该类位于生成代码SpacetimeDBClient.g.cs中(注:SpacetimeDBClient是一个历史遗留命名,未来可能被淘汰)。
生成的DbConnection继承自 SDK 中的DbConnectionBase<...>(位于 sdks/csharp/src/SpacetimeDBClient.cs)。这是一种通用模式:
- 生成的代码实现尽可能少的逻辑,把大部分行为留给 SDK。这样更利于升级——改 SDK 代码通常比改生成代码容易得多;
- SDK 需要引用生成类型时有两种选择:一是把 SDK 代码做成泛型,在生成代码中实例化泛型参数,例如
DbConnectionBase<...>(SDK,泛型)→DbConnection(生成,非泛型);二是干脆把代码整体搬进生成代码,例如ReducerEventContext已经完全从 SDK 中移出。
从 sdks/csharp/src/SpacetimeDBClient.cs 可以看到DbConnectionBase<DbConnection, Tables, Reducer>的签名,以及DbConnectionBase.Builder()返回DbConnectionBuilder<DbConnection>的构建器入口。构建器提供WithUri、WithDatabaseName、WithToken、WithCompression、WithLightMode、WithConfirmedReads等链式配置,以及OnConnect、OnConnectError、OnDisconnect回调注册。
SDK 中最重要的生成类型有两个:
RemoteTables(即客户端缓存 client cache):保存从数据库订阅来的数据的本地视图。对DbConnection conn来说,conn.Db就是RemoteTables的一个实例。其基类RemoteTablesBase位于 sdks/csharp/src/RemoteTablesBase.cs,内部用Dictionary<string, IRemoteTableHandle>按远端表名索引各表句柄,conn.Db通过AddTable注册每张表;RemoteReducers:允许在客户端调用服务端 reducer,通过conn.Reducers访问。此外生成代码还会为表/模块引用的所有服务端类型生成对应类型。
四、运行时结构:DbConnectionBase 的核心职责
SDK 的大部分核心逻辑集中在 sdks/csharp/src/SpacetimeDBClient.cs 的DbConnectionBase<...>中,它负责:
- 启动后台线程与网络通信并解析消息(构造函数中创建名为
"SpacetimeDB Network Thread"的解析线程,见 SpacetimeDBClient.cs); - 接收更新、维护客户端缓存、触发回调。
用户创建一个DbConnection,然后通过SubscriptionBuilder创建若干SubscriptionHandle。每个订阅由若干条 SQL 查询组成,由远端服务器跟踪。用户也可以直接用该DbConnection调用 reducer。
服务端通过WebSocket周期性推送更新。DbConnection负责:
- 在后台线程用
ParseMessages从_parseQueue取出原始字节、解压并解码为ServerMessage,生成ParsedMessage投入_applyQueue; - 在主线程
FrameTick()中从_applyQueue取出消息并ApplyMessage,更新本地视图conn.Db,并调用用户注册的回调。
4.1 表句柄与回调机制
Codegen 还会为每张表生成实现ITable接口的代码(接口定义见 sdks/csharp/src/Table.cs)。DbConnection只把表当作ITable看待,不了解每张表的具体实现。
RemoteTableHandle<...>(位于 Table.cs)结合生成代码实现ITable接口,内部维护一个MultiDictionary<object, Row> Entries作为行缓存(无主键时以整行作为键)。它通过OnInternalInsert/OnInternalDelete内部事件维持索引——即 Table.cs 中的UniqueIndexBase<Column>(Dictionary<Column, Row>,提供Find)与BTreeIndexBase<Column>(Dictionary<Column, HashSet<Row>>,提供Filter),索引对象在构造时订阅这两个内部事件,把行增删同步到自己的字典。
用户可见的回调包括持久表的OnInsert、OnDelete、OnBeforeDelete、OnUpdate,以及事件表(RemoteEventTableHandle)仅有的OnInsert。事件表不把行持久化到客户端缓存,只触发插入回调。
4.2 三阶段应用:PreApply / Apply / PostApply
一次数据库更新的应用被拆成三阶段(见 Table.cs),由 SpacetimeDBClient.cs 的ApplyUpdate统一调度:
- PreApply:先对所有受影响表调用,触发
OnBeforeDelete,让用户能在行真正被删除前读到旧值; - Apply:把
MultiDictionaryDelta应用到Entries并更新索引;此阶段不得触发用户回调(因为并非所有表都已更新完毕),同时完成索引修复,为 PostApply 做准备; - PostApply:所有表都 Apply 完成后,才真正触发用户的
OnInsert/OnUpdate/OnDelete回调。
五、线程模型:单线程访问约束
与 Rust SDK 不同,C# SDK假定一个DbConnection只在单个线程上被访问。这个线程被称为"主线程",即不断循环调用DbConnection.FrameTick()的那个线程。约束非常严格:
- 只能从单个线程调用
FrameTick(); - 只能从这个线程访问
DbConnection。
FrameTick()的实现(见 SpacetimeDBClient.cs)先调用webSocket.Update()驱动网络层,然后循环从_applyQueue取出已解析消息并ApplyMessage。
为什么这样设计?本质上是用主线程自身充当conn.Db上的"锁":
- 当
FrameTick()运行期间,conn.Db的状态是未定义的; - 其余任何时候,
conn.Db都保证处于单一、良构的状态,对应服务器在过去某个时刻的状态(严格说是"因果过去",即 SDK 已收到服务器消息,其状态对应服务器发该消息前的某个状态——这一表述成立的前提是服务器事务全序,目前成立); - 只有在主线程访问
RemoteTables时上述保证才成立。从其他线程访问conn.Db可能读到不一致的数据或抛出ConcurrentModificationException。
最重要的推论:用户永远观察不到"部分应用"的事务。事务更新是原子的、一次性发生的。如果一个事务修改了多行/多张表,用户永远不会看到只应用了其中一部分更新的conn.Db(前提是不在后台线程访问它)。此外,FrameTick()可能会调用用户回调,SDK 同样保证在回调执行期间conn.Db处于良构状态。
代价是:这种设计让 SDK 在多线程场景下难以使用,但换来了相对简单的用户心智模型。
六、网络协议:WebSocket + BSATN
客户端与服务器通过 WebSocket 通信,消息使用BSATN(Binary SATS)编码。具体消息类型位于SpacetimeDB.ClientApi命名空间,源码存放在 sdks/csharp/src/SpacetimeDB/ClientApi 目录——其中的.g.cs文件(如ServerMessage.g.cs、ClientMessage.g.cs、TransactionUpdate.g.cs、ReducerResult.g.cs等)都是自动生成的。
6.1 重新生成 ClientApi
该命名空间由一份用 Rust 编写的规范自动生成。重新生成的入口脚本为 sdks/csharp/tools~/gen-client-api.sh(Windows 对应 sdks/csharp/tools~/gen-client-api.bat)。从脚本源码可以看到完整链路:
cargo build --manifest-path crates/standalone/Cargo.toml:先构建 standalone 服务器;- 运行 crates/client-api-messages 的
get_ws_schema_v2示例,导出 WebSocket v2 协议 schema(即仓库根目录下的ws_schema-2.json); - 将该 schema 通过
spacetime generate -l csharp --namespace SpacetimeDB.ClientApi生成 C# 消息类型; - 把生成的
Types/*移动到 sdks/csharp/src/SpacetimeDB/ClientApi,并清理临时目录。
6.2 双重编码问题
注意消息实际上是双重编码的:SpacetimeDB.ClientApi消息内部存有大量byte[],这些字节必须再解码一次才能得到真实的表行、reducer 参数等。文档明确指出这不可避免地涉及大量拷贝("This unfortunately involves a lot of copying"),这是网络路径上值得关注的性能特征。
从解析源码看(SpacetimeDBClient.cs),ParseMessage先解压解码外层消息(CompressionHelpers.DecompressDecodeMessage),再按消息类型分别处理SubscribeApplied、TransactionUpdate、ReducerResult、ProcedureResult、OneOffQueryResult等分支,把行数据解析成ParsedDatabaseUpdate。
七、重叠订阅:客户端去重与 MultiDictionary
用户可能以多种方式订阅同一行,例如同时执行:
SELECT * FROM students WHERE student.age > 5 SELECT * FROM students WHERE student.class = 4如果两个查询都订阅,服务器会对"班级为 4 且年龄大于 5"的所有学生发送多份拷贝。理论上可以在服务器端去重,但那是一大块工作量,因此出于性能考虑,去重放在客户端。
7.1 MultiDictionary
客户端依赖 sdks/csharp/src/MultiDictionary.cs 中的MultiDictionary<TKey, TValue>完成去重。它像一个普通字典,但可以存储同一 (key, value) 对的多个"拷贝":内部用Dictionary<TKey, (TValue Value, uint Multiplicity)>记录每个键的多重度(multiplicity),同一个键只能映射到同一个值(插入不同值属于逻辑错误,调试模式下用Debug.Assert校验)。
值得注意的实现细节(见 MultiDictionary.cs):
- 它是 struct(性能考虑),但必须用带两个比较器的构造函数创建,默认构造会处于非法状态;
Count返回含多重度的总数,CountDistinct返回不含多重度的键数;- 对于没有主键的表,
MultiDictionary的键退化为整行对象——这之所以可行,是因为任何[SpacetimeDB.Type]都自动具备正确的Equals与哈希实现。
MultiDictionaryTests.cs(见 sdks/csharp/tests~/MultiDictionaryTests.cs)提供了针对其行为的随机化测试。
7.2 MultiDictionaryDelta 与后台预处理
MultiDictionaryDelta表示对MultiDictionary的一批预处理过的变更。从 MultiDictionary.cs 的注释可以确认它的关键性质:
- 它是"无冲突复制数据类型"(CRDT):无论 Add/Remove 的调用顺序如何,只要每个 (key, value) 的增减次数一致,应用结果就相同;
- 每个键在 delta 中最多关联两个值(旧值 + 新值),超过两个值视为非法;
- 应用时 delta 必须维持"每个键恰好映射一个值"的不变量。
架构上,SDK在后台线程准备MultiDictionaryDelta,在主线程Apply(MultiDictionary.cs),从而在不阻塞主线程的前提下完成尽可能多的工作。
7.3 依赖的协议保证
当多个订阅指向同一行时,若服务器端事务更新了该行,网络会恰好发送对应份数的更新,封装在单个ServerMessage中。MultiDictionary与MultiDictionaryDelta的正确性依赖这一保证——若该保证未被满足,调试模式下会抛出异常。也就是说,订阅去重机制与服务器"每订阅一份就发一份更新"的行为是严格耦合的。
八、小结与进一步阅读
本文梳理的 C# SDK 设计可以用几句话概括:
- 双层代码生成:
BSATN.Codegen负责编译期序列化,spacetimedb-codegen负责网络客户端,两者由spacetime generate串联; - 单线程心智模型:
DbConnection只在主线程(持续调用FrameTick()的线程)上访问,换来事务级原子可见性; - 客户端去重:重叠订阅通过
MultiDictionary/MultiDictionaryDelta在客户端完成多重度记账,与服务器单消息多份更新的协议保证严格对应。
如果你要继续深入,推荐按以下路径阅读仓库源码:
- 客户端核心:sdks/csharp/src/SpacetimeDBClient.cs(
DbConnectionBase、FrameTick、消息解析与分发); - 表与索引:sdks/csharp/src/Table.cs(
ITable、RemoteTableHandle、PreApply/Apply/PostApply); - 去重数据结构:sdks/csharp/src/MultiDictionary.cs 及随机化测试 sdks/csharp/tests~/MultiDictionaryTests.cs;
- 协议消息类型:sdks/csharp/src/SpacetimeDB/ClientApi(自动生成,勿手改);
- 生成代码样例:templates/chat-console-cs/module_bindings(
SpacetimeDBClient.g.cs、RemoteTables等); - 代码生成器 Rust 实现:crates/codegen/src,以及协议规范与再生成脚本 crates/client-api-messages 与 sdks/csharp/tools~/gen-client-api.sh。
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考