背景
Roblox 提供一组 API 通过 DataStoreService 与数据存储商店交互。 最常见的使用例子是保存、加载和复制 玩家数据 。 那是,数据与玩家的进度、购买和其他会话特性之间的数据关联。
大多数 Roblox 体验使用这些 API 来实现某些玩家数据系统的某些形式。这些实现方式不同,但一般来说,它们都是通过解决相同的问题来解决的。
常见问题
以下是玩家数据系统的一些常见问题,它们尝试解决:
在内存访问中: DataStoreService 请求使用异步的网络请求,受到速率限制。 这是适合在会话开始时存储初始载入的适合的,但不是高频率的读取和写入操作在正常游戏时间的需求。 大多数开发者的玩家数据系统在 Roblox 服务器上存
- 在会话开始时初始读取
- 在会话结束时进行最终写入
- 定期在间隔写入故障发生的场景
- 写入以确保处理购买时数据已保存
高效存储: 将所有玩家的会话数据存储在一个表中,让您更好地更新多个值原子并在更少的请求中处理相同的数据。它还移除了交叉值非同步风险并使更好地理解回滚。
一些开发人员还实现了自定义 serialization 来压缩大数据结构 (通常用于保存游戏中生成的用户生成内容)。
复制: 客户端需要常规访问玩家的数据(例如更新界面)。 使用客户端复制玩家数据的通用方法可以让您传递此信息,而无需为每个数据组件创建自定义复制系统。 开发人员通常想要选择地更新界面的更新。
错误处理: 当DataStores无法访问时,大多数解决方案将实现重试机制和恢复到“默认”数据。 特别需要注意,以确保后备数据不会后写“实际”数据,并且这些数据通过适当的方式向玩家通知。
重试: 当数据存储无法访问时,大多数解决方案实现重试机制并且设置为默认数据。 请特别小心,以确保重试数据不会覆盖“实”数据,并且能够正确地向玩家通知情况。
锁定会话: 如果单个玩家的数据在多个服务器上存储,会话锁定可能会导致其中一个服务器保存旧时间的信息。这可能会导致数据丢失和常见物品重复。
原子购买处理: 验证,奖励和记录原子购买以防止物品丢失或多次奖励。
示例代码
Roblox 有参考代码来帮助您设计和构建玩家数据系统。 此页面的剩余部分考虑到背景、实现细节和一般 cave 。
导入模型到 Studio 后,您应该看到以下文件夹结构:
架构
此高级图表显示了样本中的关键系统以及它们如何与体验中的代码交互。
重试
背景
作为 DataStoreService 在隐藏下发出网页请求时,其请求不保证成功。当这种情况发生时,DataStore 方法会发生错误,允许您处理它们。
如果您尝试处理数据存储失败,例如此,您可能会遇到以下常见错误:
local function retrySetAsync(dataStore, key, value)
for _ = 1, MAX_ATTEMPTS do
local success, result = pcall(dataStore.SetAsync, dataStore, key, value)
if success then
break
end
task.wait(TIME_BETWEEN_ATTEMPTS)
end
end
虽然这是一个完全有效的重试机制为一般函数,但它不适合 DataStoreService 请求,因为它不保证请求的顺序。保留请求的顺序是重要的 DataStoreService 请求,因为它们与状态交互。考虑以下场景:
- 请求 A 设置键值 K 为 1。
- 请求失败,因此 2 秒后将进行重试。
- 在重试发生之前,请求 B 将值 K 设置为 2,但请求 A 的重试立即覆盖此值并将 K 设置为 1。
即使 UpdateAsync 在最新版本的钥键值上运行,但 UpdateAsync 请仍然处理,以避免无效的暂时状态(例如,购买时减少硬币,导致负面硬币)。
我们的玩家数据系统使用了一个新的类,DataStoreWrapper,该类可提供产生重试的机会,保证按键顺序处理。
接近
DataStoreWrapper 提供与 DataStore 方法相对应的方法:DataStore:GetAsync()、0> Class.GlobalDataStore:SetAsync()|DataStore:SetAsync()</
这些方法,当调用时:
将请求添加到队列。每个钥匙都有自己的队列,在哪里请求处理顺序和连续。请求完成后,请求线程会返回。
此功能基于 ThreadQueue 类,它是一个协程基于任务的时间表和限制器。而不是返回一个促进式, ThreadQueue 生成当前线程直到操作完成并且抛出错误,如果它失败。这与 idiomatic 非同步 Lua 模式更一致。
如果请求失败,它将重试以使用可配置的 exponential 后退。这些重试形成部分在 ThreadQueue 提交的回调,因此它们保证在下一个请求开始时完成。
当请求完成时,请求方法将返回 success, result 模式
DataStoreWrapper 还暴露了方法来获取给定钥匙的队列长度并清除旧请求。该后者选项特别有用,在服务器关闭并有无时间处理任何但最新请求的情况下。
洞穴
DataStoreWrapper 按照外于极端场景的数据存储请求应该允许完成(成功或否)的原则,即使最近的请求使它过时。当新请求发生时,不会移除存储请求,而是允许它在新请求开始之前完成。这是因为该模块的可用性作为一种通用数据存储工具而不是特定工具
很难确定要在提交请求时是否安全地从队列中移除时使用直观的规则。请考虑以下队列:
Value=0, SetAsync(1), GetAsync(), SetAsync(2)
期望的行为是,GetAsync() 将返回 1,但如果我们由于某个最新的请求而从队列中移除 SetAsync() 请求,它将返回 1> 01>。
当新的写入请求添加时,只有在最新的读取请求远后,才会删除旧的请求。 UpdateAsync() , 到目前为止,这是最常见的操作(且仅用于此系统),可以读取和写入,因此在此设计中添加无需添加额外复杂性来协调。
DataStoreWrapper 可能需要您指定是否允许读取/写入 UpdateAsync() 请求,但它不适用于我们的玩家数据系统,因为这无法在时间到来之前确定(由会话锁定机制覆盖)。
一旦从队列中移除,就很难决定一个直观的规则来how处理这个。当一个DataStoreWrapper请求被提出时,当前线程会生成直到它完成。如果我们从队列中移除了旧的请求,我们就必须决定是否返回 false, "Removed from queue
最终,我们的观点是,简单的方法(处理每个请求)在这里更有优势,并创建更清晰的环境来在接近复杂问题时导航。唯一的例子是在 DataModel:BindToClose() 中,清除队列成为保存所有用户数据在时间内
会话锁定
类别: SessionLockedDataStoreWrapper >
背景
玩家数据存储在服务器上的内存中,并且仅在有需要时才从和写入底层数据存储。您可以立即读取和更新在内存中的玩家数据,无需网络请求,并且可以避免超过 DataStoreService 的限制。
为了使该模型按照预期运行,它是必要的, чтобы在同时不超过一个服务器能从DataStore中载入玩家的数据。
例如,如果服务器A载入玩家的数据,服务器B在服务器A发布其锁定在其上时无法载入该数据。 无论锁定机制是否已启用,服务器B在服务器A的内存中存储着玩家数据的更新版本,直到服务器A发布其锁定在其上的数据为止。 如果服务器A保存其最新版本,服务器B在服务器A的下一
虽然 Roblox 只允许客户端同时连接到一个服务器,但您不能假设数据从一个会话结束后就会保存在下一个会话开始时。请考虑以下情况,这会导致玩家离开服务器 A 时可能发生的情况:
- 服务器 A 发出 DataStore 请求保存他们的数据,但请求失败并需要几次重试才能成功完成。在重试期间,玩家加入服务器 B。
- 服务器 A 使用 UpdateAsync() 调用过多的键,并且受到限制。最终保存请求放置在队列中。当请求处于队列中时,玩家加入服务器 B。
- 在服务器 A 上,一些与 PlayerRemoving 事件相关的代码在玩家的数据未保存之前产生。在此操作完成之前,玩家将加入服务器 B。
- 服务器 A 的性能已降级到程度,直到玩家加入服务器 B 为止,才会保存最终存档。
这些场景应该很稀有,但它们确实发生,尤其是在玩家从一个服务器连接到另一个服务器的快速连接中(例如,在传送时)。一些恶意用户甚至可能尝试滥用此行为来完成操作,而无需持续存在。这可以是在允许玩家交易并是常见源代码重复使用漏洞的游戏中特别有影响的情况。
当玩家的 DataStore 键首先读取服务器,时,服务器内的原子代码将锁定该键的属性,直到服务器在同一 UpdateAsync() 调用中读取或写入该钥匙。如果此锁值存在,当任何其他服务器尝试读取或写入钥匙时,服务器不
接近
SessionLockedDataStoreWrapper 是围绕 DataStoreWrapper 类的 meta 包。 DataStoreWrapper 提供队列和重试功能,这 0> DataStoreWrap0> 通过会话锁定补充。
SessionLockedDataStoreWrapper 通过
变形函数在 UpdateAsync 中对每个请求执行以下操作:
验证钥匙是否安全以便访1) 使用权 2)通行证 3)访问权限,如果不是,则会放弃操作。“安全以便1) 使用权 2)通行证 3)访问权限问”意味着:
钥键的メタ数据对象不包含少于锁期过期时间的未识别 LockId 值,该值最后更新于不到一个锁期前。这是对另一个服务器放置锁的尊重,以及在锁期过期时无视该锁。
如果此服务器已将自己的 LockId 值放入键键的钥匙值中,那么此值仍然在钥匙的钥匙值中。这是因为另一个服务器已经占领了此服务器的锁 (由过期或强制) 并在之后释放它。或者说,即使 LockId 是
UpdateAsync 执行 DataStore 操作要求的消费者。例如, SessionLockedDataStoreWrapper 将翻译为 0> function(value) return value en结束0>。
根据参数传入请求,UpdateAsync 将键匙锁定或解锁:
如果钥匙是锁定的,UpdateAsync 将 LockId 设置在键键的钥匙数据中,并将其存储在服务器内,以便下次访问时验证钥匙。 如果服务器已经锁定这个键匙,它不
如果需要解锁,UpdateAsync 将钥键中的 LockId 移除。
一个自定义重试处理器被传入底层 DataStoreWrapper 以便在步骤 1 因会话被锁定而导致操作的重试。
还会向消费者返回自定义错误消息,允许玩家数据系统在客户端锁定时报告替换错误。
洞穴
会话锁定机制使用服务器在键上发布锁时总是发生在键上的锁发布通过指令解锁的钥匙作为最终写入在 PlayerRemoving 或 BindToClose() 中的钥匙。
但是,解锁在某些情况下可能会失败。例如:
- 服务器崩溃或 DataStoreService 在所有尝试访问钥键的尝试中都无法使用。
- 由于逻辑或类似错误,无法解锁钥匙。
要在钥键上锁定,您必须经常访问它,直到它在内存中加载为止。这通常是在玩家数据系统的背景自动保存循环中的一部分,但此系统还会暴露一个 refreshLockAsync 方法,如果您需要手动执行它。
如果锁的有效时间已过期,但锁没有更新,那么任何服务器都可以免费接管锁。如果另一个服务器锁定,当前服务器的尝试或写入钥匙失败,除非它创建了一个新锁。
开发者产品处理
Singleton: ReceiptHandler >
背景
ProcessReceipt 回调执行了确定购买时间的关键工作。ProcessReceipt 在很具体的场景中被称为。为其集合保证,请参阅MarketplaceService.ProcessReceipt。
虽然“处理”购买体验之间的定义可能不同,但我们使用以下标准
购买尚未处理。
购买会在当前会话中反映。
这需要在返回 PurchaseGranted 之前执行以下操作:
- 验证 PurchaseId 已没有被记录为已处理。
- 奖励玩家在内存中玩家数据的购买。
- 将 PurchaseId 记录为玩家在内存玩家数据中处理的数据。
- 将玩家的内存玩家数据写入 DataStore 。
会话锁定使这个流程更简单,因为您不再需要为以下场景进行担心:
- 当前服务器中的玩家数据可能已过期,需要您从 DataStore 中获取最新值,然后验证 PurchaseId 历史记录
- 在另一个服务器上运行的同一购买,需要您读取和写入您的 PurchaseId 历史,并保存更新的玩家数据以反射原子,以防止竞速条件
锁定会话保证,如果尝试写入玩家的 DataStore 成功,在此服务器上加载和保存的数据中,无其他服务器在此服务器之间成功读取或写入玩家的 DataStore 之间。 在短暂的玩家数据在此服务器中是最新版本。
接近
在 ReceiptProcessor 中的评论概述了这种方法:
验证玩家的数据已在此服务器上加载,并且加载无任何错误。
因为此系统使用了锁定会话,此检查还验证了内存数据是最新版本。
如果玩家的数据尚未加载(这是玩家加入游戏时所期望的),请等待玩家的数据加载。系统还会列出玩家离开游戏前玩家是否要离开游戏,因为它不会在无限时间内产生并重新调用此回调,如果玩家离开,这将不会在此服务器上重新启动。
验证 PurchaseId 是否不已注册为玩家数据中已处理的交易。
由于会话锁定,系统内存中的 PurchaseIds 系列是最新版本。如果 PurchaseId 被记录为已处理并反映在已加载或保存到 Class
在此服务器中更新玩家数据以“奖励”购买。
ReceiptProcessor 使用通用回调方法,为每个 DeveloperProductId 分配不同的回调。
在此服务器中更新玩家数据,存储PurchaseId。
提交请求将内存数据保存到DataStore,返回PurchaseGranted,如果请求成功。 如果不是,请返回NotProcessedYet。
如果此存档请求失败,后续请求保存玩家在内存中的会话数据仍然可能成功。在下一个 ProcessReceipt 调用期间,步骤 2 处理此情况并返回 PurchaseGranted。
玩家数据
Singletons: PlayerData.Server >,PlayerData.Client >
背景
为游戏代码提供接口的模块在 Roblox 体验中很常见。此部分包括 PlayerData.Server 和 PlayerData.Client。
接近
PlayerData.Server 和 PlayerData.Client 处理以下内容:
- 在内存中加载玩家的数据,包括在其失败加载时处理的情况
- 提供服务器代码查询和更改玩家数据的界面
- 复制玩家数据中的更改,以便客户端代码可以访问它
- 复制加载和/或保存错误到客户端,以便它可以显示错误对话框
- 保存玩家的数据,在玩家离开,当服务器关闭
加载玩家数据
SessionLockedDataStoreWrapper 向数据存商店发出了一个 getAsync 请求。
如果此请求失败,默认数据是使用,以确保它不会写入数据存储器。
另一个选择是踢出玩家,但我们建议让玩家玩用默认数据,清除消息,以便了解发生了什么而不是将其从体验中删除。
一个初始载入包发送到 PlayerDataClient ,包含加载的数据和错误状态(如果有)。
使用 waitForDataLoadAsync 为玩家生成的任何线程都已重新启动。
提供服务器代码的界面
- PlayerDataServer 是一个可以被任意服务器代码在同一环境中运行的单独实例。
- 玩家数据被组织成一个钥匙和值的字典。您可以使用 setValue , getValue , updateValue , 2>Player2> , 5>Player5> , 8>Player8> , setValue1> 和 4>Player4> 方法来操作这些值。这些方法都是无需输出的同步无效。
- hasLoaded 和 waitForDataLoadAsync 方法可用于确保数据在您访问之前已加载。 我们建议在加载屏幕上进行此操作,以避免在客户端端口上检查加载错误之前需要进行检查。
- 一个 hasErrored 方法可以查询是否发生玩家的初始加载失败,导致他们使用默认数据。在允许玩家进行任何购买之前,请检查此方法,因为购买无法保存到数据无成功加载。
- 一个 playerDataUpdated 信号由 player 、 key 和 2> value2> 启动,每当玩家的数据发生变更。 个人系统可以订阅。
复制客户端更改
- 在 PlayerDataServer 中对玩家数据的任何更改都会复制到 PlayerDataClient ,除非使用 setValueAsPrivate 将该钥匙标记为私人
- setValueAsPrivate 用于指示不应发送给客户端的钥匙
- PlayerDataClient 包括一个方法获取钥匙的值 (get) 和一个信号发生时发射 (更新)。 一个 hasLoaded 方法和一个 loaded 方法也包括,因此客户端可以等待数据加载并复制,然后开始系统。
- PlayerDataClient 是一个可以被任意客户端运行在同一环境中访问的单独实例
复制错误到客户
- 保存或加载玩家数据时遇到错误状态。
- 使用 getLoadError 和 getSaveError 方法,以及 loaded 和 1> Saved1> 信号来访问此信息。
- 使用这些事件来禁用客户端购买提示并实现警告对话框。这张图像显示示例对话框:
保存玩家数据
当玩家离开游戏时,系统会采取以下步骤:
- 检查是否安全地将玩家的数据写入数据存商店。 在场景中,可能会出现玩家的数据无法加载或仍在加载的情况。
- 通过 SessionLockedDataStoreWrapper 请求来写入当前内存数据值到数据存储,完成后会清除会话锁。
- 从服务器内存中清除玩家的数据(和其他变量,例如 metadatos 和错误状态)。
在期间循环中,服务器将每个玩家的数据写入数据存储(提供它是安全的保存)。这种欢迎的重复性会在服务器崩溃的情况下减少损失,还必须用于维护会话锁。
当收到服务器关闭请求时,在 BindToClose 回调中发生以下情况:
- 请求保存每个玩家的数据在服务器上,跟随正常的过程,当玩家离开服务器时完成。 这些请求是并行的,因为 BindToClose 回调只有 30 秒的时间来完成。
- 为了加快保存速度,所有其他请求在每个键匙的队列中清除(请参阅<a href=\"#retries\">DataStoreWrapper</a>)。
- 在所有请求完成后才会返回调用。