news 2026/9/13 22:14:32

SpacetimeDB C SDK 内部架构与开发指南:代码生成、线程模型与客户端缓存深度解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpacetimeDB C SDK 内部架构与开发指南:代码生成、线程模型与客户端缓存深度解析

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.CodegenBSATN.Runtime包——它们源自 SpacetimeDB 仓库内的 crates/bindings-csharp/BSATN.Runtime 等目录,而不是 NuGet 上的旧版本。

假设本地 SpacetimeDB 克隆位于../SpacetimeDB,运行:

dotnet pack ../SpacetimeDB/crates/bindings-csharp/BSATN.Runtime && ./tools~/write-nuget-config.sh ../SpacetimeDB

这条命令会做两件事:

  1. dotnet packBSATN.Runtime打成本地包;
  2. 执行 sdks/csharp/tools~/write-nuget-config.sh 写出一个(已被.gitignore忽略的)NuGet.Config文件,使 SDK 项目优先使用本地构建的包,而不是 NuGet 上的发布包。

查看该脚本源码可以看到,它实际生成了两份NuGet.Config:一份写入sdks/csharp/NuGet.Config,一份写入 SpacetimeDB 克隆根目录。两份配置的核心都是通过packageSourceMappingSpacetimeDB.BSATN.RuntimeSpacetimeDB.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.CodegenBSATN.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 客户端;
  • importSpacetimeDB.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>的构建器入口。构建器提供WithUriWithDatabaseNameWithTokenWithCompressionWithLightModeWithConfirmedReads等链式配置,以及OnConnectOnConnectErrorOnDisconnect回调注册。

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负责:

  1. 在后台线程用ParseMessages_parseQueue取出原始字节、解压并解码为ServerMessage,生成ParsedMessage投入_applyQueue
  2. 在主线程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),索引对象在构造时订阅这两个内部事件,把行增删同步到自己的字典。

用户可见的回调包括持久表的OnInsertOnDeleteOnBeforeDeleteOnUpdate,以及事件表(RemoteEventTableHandle)仅有的OnInsert。事件表不把行持久化到客户端缓存,只触发插入回调。

4.2 三阶段应用:PreApply / Apply / PostApply

一次数据库更新的应用被拆成三阶段(见 Table.cs),由 SpacetimeDBClient.cs 的ApplyUpdate统一调度:

  1. PreApply:先对所有受影响表调用,触发OnBeforeDelete,让用户能在行真正被删除前读到旧值;
  2. Apply:把MultiDictionaryDelta应用到Entries并更新索引;此阶段不得触发用户回调(因为并非所有表都已更新完毕),同时完成索引修复,为 PostApply 做准备;
  3. 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.csClientMessage.g.csTransactionUpdate.g.csReducerResult.g.cs等)都是自动生成的。

6.1 重新生成 ClientApi

该命名空间由一份用 Rust 编写的规范自动生成。重新生成的入口脚本为 sdks/csharp/tools~/gen-client-api.sh(Windows 对应 sdks/csharp/tools~/gen-client-api.bat)。从脚本源码可以看到完整链路:

  1. cargo build --manifest-path crates/standalone/Cargo.toml:先构建 standalone 服务器;
  2. 运行 crates/client-api-messages 的get_ws_schema_v2示例,导出 WebSocket v2 协议 schema(即仓库根目录下的ws_schema-2.json);
  3. 将该 schema 通过spacetime generate -l csharp --namespace SpacetimeDB.ClientApi生成 C# 消息类型;
  4. 把生成的Types/*移动到 sdks/csharp/src/SpacetimeDB/ClientApi,并清理临时目录。

6.2 双重编码问题

注意消息实际上是双重编码的:SpacetimeDB.ClientApi消息内部存有大量byte[],这些字节必须再解码一次才能得到真实的表行、reducer 参数等。文档明确指出这不可避免地涉及大量拷贝("This unfortunately involves a lot of copying"),这是网络路径上值得关注的性能特征。

从解析源码看(SpacetimeDBClient.cs),ParseMessage先解压解码外层消息(CompressionHelpers.DecompressDecodeMessage),再按消息类型分别处理SubscribeAppliedTransactionUpdateReducerResultProcedureResultOneOffQueryResult等分支,把行数据解析成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 依赖的协议保证

当多个订阅指向同一行时,若服务器端事务更新了该行,网络会恰好发送对应份数的更新,封装在单个ServerMessageMultiDictionaryMultiDictionaryDelta的正确性依赖这一保证——若该保证未被满足,调试模式下会抛出异常。也就是说,订阅去重机制与服务器"每订阅一份就发一份更新"的行为是严格耦合的。

八、小结与进一步阅读

本文梳理的 C# SDK 设计可以用几句话概括:

  1. 双层代码生成BSATN.Codegen负责编译期序列化,spacetimedb-codegen负责网络客户端,两者由spacetime generate串联;
  2. 单线程心智模型DbConnection只在主线程(持续调用FrameTick()的线程)上访问,换来事务级原子可见性;
  3. 客户端去重:重叠订阅通过MultiDictionary/MultiDictionaryDelta在客户端完成多重度记账,与服务器单消息多份更新的协议保证严格对应。

如果你要继续深入,推荐按以下路径阅读仓库源码:

  • 客户端核心:sdks/csharp/src/SpacetimeDBClient.cs(DbConnectionBaseFrameTick、消息解析与分发);
  • 表与索引:sdks/csharp/src/Table.cs(ITableRemoteTableHandlePreApply/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.csRemoteTables等);
  • 代码生成器 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 22:13:44

FOC驱动实战:电流环推导与无感控制调试全解析

说实话&#xff0c;FOC这东西我接触了不少项目&#xff0c;但要从一个“升魂浩荡”这种中二感拉满的项目名开始讲&#xff0c;我估计这个项目的命名人要么是重度网文读者&#xff0c;要么就是被电机参数折磨到精神恍惚之后起的代号。FOC&#xff0c;全称Field-Oriented Control…

作者头像 李华
网站建设 2026/9/13 22:10:56

UnoCSS MDC Extractor 指南:为 Markdown 组件语法提取原子类

UnoCSS MDC Extractor 指南&#xff1a;为 Markdown 组件语法提取原子类 【免费下载链接】unocss The instant on-demand atomic CSS engine. 项目地址: https://gitcode.com/GitHub_Trending/un/unocss UnoCSS 的 unocss/extractor-mdc 是一个专用于 MDC&#xff08;Ma…

作者头像 李华
网站建设 2026/9/13 22:10:47

MySQL批量更新不同值的几种实现方案:从CASE WHEN到临时表JOIN

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华