
SpacetimeDB 客户端 SDK API 实战指南订阅、本地缓存查询与实时行级回调【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB本文是 SpacetimeDB 客户端 SDK 的 API 使用指南面向已经生成客户端绑定并建立连接的开发者。文章围绕订阅Subscriptions、本地缓存查询Querying the Local Cache与行更新回调Row Update Callbacks三大核心 API 展开覆盖 TypeScript、C#、Rust、Unreal Engine 四种官方客户端 SDK。读完本文你将掌握如何用 SQL 订阅数据库子集、以零网络往返读取本地缓存、通过回调实时响应插入/更新/删除事件以及如何利用SubscriptionBuilder与SubscriptionHandle动态管理订阅生命周期并优化数据传输开销。前置条件先绑定再连接在调用任何 SDK API 之前必须完成两步准备生成客户端绑定使用spacetime generate --lang typescript|cs|rust|unrealcpp --out-dir 输出目录 --project-path 模块目录为模块生成类型安全的绑定代码。绑定会为每张表生成类型定义与缓存访问器为每个 reducer 生成可调用函数与回调注册方法。生成的代码不会随模块变更自动更新修改 schema 后必须重新执行spacetime generate。建立连接通过DbConnection的 Builder 模式创建持久化 WebSocket 连接例如DbConnection.builder().withUri(wss://maincloud.spacetimedb.com).withModuleName(my_module)可选.withToken(...)传入身份令牌。注意 C#含 Unity与 Unreal 客户端必须在游戏循环中手动调用FrameTick()推进消息处理否则收不到任何服务器数据。完成上述两步后SDK 提供了一套跨语言一致的 API让你能够查询数据、调用服务器函数并实时观察数据变化。订阅Subscriptions把数据库子集复制到客户端订阅是 SpacetimeDB 客户端模型的核心机制订阅会把数据库的一个子集复制到客户端在客户端维护一份本地缓存local cache并随服务器状态变化自动更新。正确的用法是先订阅需要的数据再查询本地缓存——所有读取都命中本地内存不产生网络往返。订阅的完整生命周期从服务器视角看一次订阅遵循固定的消息流程详见订阅语义客户端 SDK 发送包含 SQL 查询集的Subscribe消息服务器捕获两笔事务之间的一致数据库状态快照并据此评估查询、确定匹配行服务器回发SubscribeApplied消息携带全部初始匹配行客户端 SDK 加锁本地缓存、原子性地插入所有行然后依次触发每行的on_insert回调与订阅整体的on_applied回调注意这两类回调之间的相对调用顺序没有保证。此后每笔已提交事务都会产生一个状态增量state delta服务器把增量中与订阅查询匹配的行通过TransactionUpdate消息推送下来客户端原子应用删除与插入并触发on_insert、on_delete、on_update以及 reducer 回调。若同时存在多组订阅它们的更新会被打包进同一条TransactionUpdate消息。服务器对 WebSocket 通道保证三点请求响应按序返回A 先于 B则 A 的响应也先于 B、事务更新原子且按提交顺序到达每笔事务至多产生一条更新消息、订阅初始化原子恰好一条响应包含一致的快照数据。客户端缓存则始终维护已提交数据库状态的一致且正确的子集回调执行期间读到的缓存状态完整反映触发事件的交易之后的状态处理TransactionUpdate时回调会被排队直到缓存更新全部应用完毕才触发从而避免回调观察到中间态。创建订阅通过subscriptionBuilder()注册 SQL 查询并挂上onApplied与onError回调// TypeScript conn .subscriptionBuilder() .onApplied(ctx { console.log(Subscription ready with ${ctx.db.User.count()} users); }) .onError((ctx, error) { console.error(Subscription failed: ${error}); }) .subscribe([SELECT * FROM user]);// C# conn.SubscriptionBuilder() .OnApplied(ctx { Console.WriteLine($Subscription ready with {ctx.Db.User.Count()} users); }) .OnError((ctx, error) { Console.WriteLine($Subscription failed: {error}); }) .Subscribe(SELECT * FROM user);// Rust conn.subscription_builder() .on_applied(|ctx| { println!(Subscription ready with {} users, ctx.db().user().count()); }) .on_error(|ctx, error| { eprintln!(Subscription failed: {}, error); }) .subscribe([SELECT * FROM user]);// Unreal Engine FOnSubscriptionApplied AppliedDelegate; AppliedDelegate.BindDynamic(this, AMyActor::OnSubscriptionApplied); FOnSubscriptionError ErrorDelegate; ErrorDelegate.BindDynamic(this, AMyActor::OnSubscriptionError); TArrayFString Queries { TEXT(SELECT * FROM user) }; Conn-SubscriptionBuilder() -OnApplied(AppliedDelegate) -OnError(ErrorDelegate) -Subscribe(Queries); // 回调函数必须是 UFUNCTION UFUNCTION() void OnSubscriptionApplied(const FSubscriptionEventContext Ctx) { int32 UserCount Ctx.Db-User-Count(); UE_LOG(LogTemp, Log, TEXT(Subscription ready with %d users), UserCount); } UFUNCTION() void OnSubscriptionError(const FErrorContext Ctx) { UE_LOG(LogTemp, Error, TEXT(Subscription failed: %s), *Ctx.Error); }几点补充subscribe()是异步非阻塞的调用后立即返回数据到达前不会写入DbConnection回调在服务器数据返回后触发同一批查询的数据会同时返回。onError可能在两种时机触发应用订阅时失败此时onApplied永远不会执行或订阅存续期间模块接口发生变化此时onApplied可能已经执行过。可以订阅表获取行数据也可以订阅视图获取计算结果。查询的 SQL 语法细节见订阅详解。一键订阅全部表TypeScript 与 Rust SDK 还提供subscribeToAllTables()便捷方法。从 TypeScript 实现subscription_builder_impl.ts可以看到它会把模块中所有表展开成SELECT * FROM 表名查询再统一订阅。它的适用场景是客户端内存与网络带宽都不是约束的简单应用对资源敏感的应用应该用subscribe精确订阅所需子集。此外同一个DbConnection上不能混用subscribe与subscribeToAllTables否则可能导致订阅被丢弃、客户端缓存损坏甚至抛错。查询本地缓存零网络往返的数据读取订阅应用之后数据就存在于客户端本地缓存中。所有语言都提供三组核心操作遍历、计数、按唯一列查找前提是该列已建索引还可以自行做过滤。// TypeScript // 遍历所有缓存行 for (const user of conn.db.user.iter()) { console.log(${user.id}: ${user.name}); } // 统计缓存行数 const userCount conn.db.user.count(); // 按唯一列查找需要索引 const user conn.db.user.name.find(Alice); if (user) { console.log(Found: ${user.email}); } // 过滤缓存行 const adminUsers [...conn.db.user.iter()].filter(u u.isAdmin);// C# foreach (var user in conn.Db.User.Iter()) { Console.WriteLine(${user.Id}: {user.Name}); } var userCount conn.Db.User.Count; var user conn.Db.User.Name.Find(Alice); if (user ! null) { Console.WriteLine($Found: {user.Email}); } var adminUsers conn.Db.User.Iter() .Where(u u.IsAdmin) .ToList();// Rust for user in conn.db().user().iter() { println!({}: {}, user.id, user.name); } let user_count conn.db().user().count(); if let Some(user) conn.db().user().name().find(Alice) { println!(Found: {}, user.email); } let admin_users: Vec_ conn.db().user() .iter() .filter(|u| u.is_admin) .collect();// Unreal TArrayFUserType Users Conn-Db-User-Iter(); for (const FUserType User : Users) { UE_LOG(LogTemp, Log, TEXT(%lld: %s), User.Id, *User.Name); } int32 UserCount Conn-Db-User-Count(); FUserType User Conn-Db-User-Name-Find(TEXT(Alice)); if (!User.Name.IsEmpty()) { UE_LOG(LogTemp, Log, TEXT(Found: %s), *User.Email); } TArrayFUserType AllUsers Conn-Db-User-Iter(); TArrayFUserType AdminUsers; for (const FUserType User : AllUsers) { if (User.IsAdmin) { AdminUsers.Add(User); } }这些方法在 Rust SDK 中由 table.rs 的TableLike/Tabletrait 统一定义count()返回客户端缓存中已订阅行数iter()返回缓存行的迭代器二者对表、视图、事件表三类数据源都成立。由于读取的是本地内存数据速度与网络无关读多写少的场景如游戏内展示层尤其受益。行更新回调实时响应数据变化订阅建立后每当本地缓存因订阅更新而变化对应表的回调就会触发这是客户端实现实时响应的关键。每个表都支持三类回调插入onInsert、更新onUpdate、删除onDelete。// TypeScript conn.db.User.onInsert((ctx, user) { console.log(User inserted: ${user.name}); }); conn.db.User.onUpdate((ctx, oldUser, newUser) { console.log(User ${newUser.id} updated: ${oldUser.name} - ${newUser.name}); }); conn.db.User.onDelete((ctx, user) { console.log(User deleted: ${user.name}); });// C# conn.Db.User.OnInsert (ctx, user) { Console.WriteLine($User inserted: {user.Name}); }; conn.Db.User.OnUpdate (ctx, oldUser, newUser) { Console.WriteLine($User {newUser.Id} updated: {oldUser.Name} - {newUser.Name}); }; conn.Db.User.OnDelete (ctx, user) { Console.WriteLine($User deleted: {user.Name}); };// Rust conn.db().user().on_insert(|ctx, user| { println!(User inserted: {}, user.name); }); conn.db().user().on_update(|ctx, old_user, new_user| { println!(User {} updated: {} - {}, new_user.id, old_user.name, new_user.name); }); conn.db().user().on_delete(|ctx, user| { println!(User deleted: {}, user.name); });// Unreal Conn-Db-User-OnInsert.AddDynamic(this, AMyActor::OnUserInsert); Conn-Db-User-OnUpdate.AddDynamic(this, AMyActor::OnUserUpdate); Conn-Db-User-OnDelete.AddDynamic(this, AMyActor::OnUserDelete); UFUNCTION() void OnUserInsert(const FEventContext Context, const FUserType User) { UE_LOG(LogTemp, Log, TEXT(User inserted: %s), *User.Name); } UFUNCTION() void OnUserUpdate(const FEventContext Context, const FUserType OldUser, const FUserType NewUser) { UE_LOG(LogTemp, Log, TEXT(User %lld updated: %s - %s), NewUser.Id, *OldUser.Name, *NewUser.Name); } UFUNCTION() void OnUserDelete(const FEventContext Context, const FUserType User) { UE_LOG(LogTemp, Log, TEXT(User deleted: %s), *User.Name); }回调语义的细节值得注意更新 删除 插入从 Rust SDK 的 trait 文档table.rs可以看到on_update回调在事务内同一主键的旧行被删除、新行被插入时触发参数为(ctx, old, new)。回调可注销on_insert/on_delete/on_update会返回一个回调 IDRust SDK 提供对应的remove_on_insert/remove_on_delete/remove_on_update用于取消回调避免已销毁对象的回调被继续触发。回调看到的缓存永远一致事务更新先整体应用、后触发回调回调执行期间读到的是触发该事件的事务之后的完整数据库状态见订阅语义的Pending Callbacks and Cache Consistency小节。SubscriptionBuilder 与 SubscriptionHandleAPI 面面观SubscriptionBuilder与SubscriptionHandle是订阅系统的两个主要接口完整 API 定义见订阅详解。SubscriptionBuilderBuilder 负责注册订阅查询与回调各语言接口一一对应方法作用onApplied(callback)订阅成功应用后执行回调携带SubscriptionEventContext此时初始数据已进入缓存onError(callback)订阅失败时执行可能发生在应用阶段此时onApplied不会执行也可能发生在订阅存续期如模块接口变化subscribe(querySqls)按一组 SQL 查询订阅立即返回一个SubscriptionHandle所有查询的数据同时返回subscribeToAllTables()便捷订阅全部表的所有行适用于内存与带宽无约束的应用不可与subscribe混用以 Rust 接口为例subscription.rspub struct SubscriptionBuilderM: SpacetimeModule { /* private fields */ } implM: SpacetimeModule SubscriptionBuilderM { pub fn on_applied(mut self, callback: impl FnOnce(M::SubscriptionEventContext) Send static); pub fn on_error(mut self, callback: impl FnOnce(M::ErrorContext, crate::Error) Send static); pub fn subscribeQueries: IntoQueries(self, query_sql: Queries) - M::SubscriptionHandle; pub fn subscribe_to_all_tables(self); }在 TypeScript 实现中Builder 内部的subscribe会为每个查询集注册一个SubscriptionHandleImplapplied事件将activeState置为trueerror事件则同时置endedState true、activeState false见 subscription_builder_impl.ts这构成了 handle 状态机的基础。SubscriptionHandlesubscribe返回的 handle 负责管理单个订阅的生命周期。因为每个订阅拥有独立生命周期客户端可以在运行期动态增删不同数据库子集方法作用isActive()订阅当前是否处于激活状态已成功应用且未结束isEnded()订阅是否已结束因取消或错误终止unsubscribe()取消订阅重复调用会抛异常Rust 中返回错误unsubscribeThen(onEnded)取消订阅并在其行从客户端缓存移除后触发onEnded回调一个典型场景游戏客户端根据玩家等级展示商店道具与折扣。玩家 5 级时订阅required_level 5的数据升到 6 级后先建立新查询的订阅再取消旧订阅// TypeScript玩家 5 级时的订阅 const shopItemsSubscription conn .subscriptionBuilder() .onApplied((ctx) { /* handle applied state */ }) .onError((ctx, error) { /* handle error */ }) .subscribe([ SELECT * FROM shop_items WHERE required_level 5, SELECT * FROM shop_discounts WHERE required_level 5, ]); // 玩家升到 6 级建立新订阅 const newShopItemsSubscription conn .subscriptionBuilder() .subscribe([ SELECT * FROM shop_items WHERE required_level 6, SELECT * FROM shop_discounts WHERE required_level 6, ]); // 再取消旧订阅 if (shopItemsSubscription.isActive()) { shopItemsSubscription.unsubscribe(); }其它订阅不受影响、继续生效。Rust 版对应写法为shop_items_subscription.is_active()unsubscribe().expect(...)C# 版为IsActive属性 Unsubscribe()。订阅性能最佳实践优化服务器计算与序列化开销订阅虽然便利但不加节制的查询会放大服务器处理与序列化成本。订阅详解给出了四条经过验证的实践准则。1. 编写高效的 SQL 查询尽量让查询命中索引、缩小结果集。性能敏感的订阅 SQL 写法可参考文档站点的 SQL 参考中的最佳实践章节docs站点对应 SQL Reference 的 Best Practices for Performance and Scalability。2. 生命周期相同的订阅分组将应用存续期内始终需要的数据与仅部分时段需要的数据拆成两组独立订阅。例如全局公告与徽章永不取消商店道具随等级变化前者长期有效、后者按需增删避免每次切换都重复订阅/退订全局数据从而减少数据库到客户端的数据传输量。// 全局订阅应用生命周期内始终有效 const globalSubscriptions conn .subscriptionBuilder() .subscribe([ SELECT * FROM announcements, SELECT * FROM badges, ]); // 商店订阅随玩家等级变化 const shopSubscription conn .subscriptionBuilder() .subscribe([ SELECT * FROM shop_items WHERE required_level 5, ]);3. 先订阅、后取消需要换一组订阅时务必先订阅新集合再取消旧集合。SpacetimeDB 的订阅是零拷贝的同一查询被多次订阅不会产生额外的处理或序列化开销同样对重复订阅的查询取消也不会触发服务器处理与数据序列化。因此新旧订阅短暂并存即使查询重叠几乎无成本却能保证数据流不中断。4. 避免重叠查询重叠查询指返回数据有交集的不同查询它会让服务器对同一行做重复处理与序列化。例如-- 代价低id 是唯一/主键列第二条查询走索引只多序列化一行 SELECT * FROM User SELECT * FROM User WHERE id 5 -- 代价高第二条无法走索引User 表每行被处理两次 -- 除一行外几乎全部行都被序列化两次 SELECT * FROM User SELECT * FROM User WHERE id ! 5判断标准在于查询能否走索引、交集数据量有多大。遵循上述准则就能在保持实时性的同时把数据复制开销控制在最低。完整示例与延伸阅读本文所有代码片段均已按语言整理在官方参考页中可直接复制到真实项目中运行Rust SDK 参考完整的 Rust API 文档C# SDK 参考C# 与 Unity 专属模式TypeScript SDK 参考浏览器与 Node.js 示例Unreal SDK 参考Unreal C 与蓝图模式相关主题文档生成客户端绑定如何生成类型安全绑定连接到 SpacetimeDB连接建立与生命周期订阅详解订阅查询语法与语义订阅语义消息顺序、原子性与缓存一致性保证Reducers服务端事务型函数Procedures具备外部能力的服务端函数表数据库 schema 与存储如果希望深入底层实现可直接阅读仓库中的 SDK 源码Rust SDK 的表访问与回调 trait、Rust SDK 的订阅管理器、TypeScript SDK 的订阅 Builder 实现。【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考