實作玩家數據和購買系統

*此內容是使用 AI(Beta 測試版)翻譯,可能含有錯誤。若要以英文檢視此頁面,請按一下這裡

背景

Roblox 提供了一組 API 來通過 DataStoreService 與數據存儲進行交互。這些 API 的最常見用例是保存、加載和複製 玩家數據。即與玩家的進度、購買和其他在各次遊玩會話之間持續存在的特徵相關聯的數據。

Roblox 上的大多數體驗使用這些 API 實作某種形式的玩家數據系統。這些實作在方法上有所不同,但一般上希望解決相同的一系列問題。

常見問題

以下是玩家數據系統試圖解決的一些最常見問題:

  • 內存訪問: DataStoreService 請求進行網絡請求,這些請求是異步運作的且受到速率限制。這適合於會話開始時的初始加載,但不適用於在正常遊戲過程中高頻率的讀取和寫入操作。大多數開發者的玩家數據系統將這些數據存儲在 Roblox 服務器的內存中,將 DataStoreService 請求限制在以下情景中:

    • 會話開始時的初始讀取
    • 會話結束時的最終寫入
    • 以一定間隔進行定期寫入,以減輕最終寫入失敗的情況
    • 在處理購買過程中確保數據已保存的寫入
  • 高效存儲: 將所有玩家的會話數據存儲在一個表中,可讓您原子性地更新多個值,並在更少的請求中處理相同的數據量。這還消除了值之間不同步的風險,使回滾更容易理解。

    一些開發者還實作自定義序列化以壓縮大型數據結構(通常是用來保存遊戲中的用戶生成內容)。

  • 複製: 客戶端需要定期訪問玩家的數據(例如,用於更新 UI)。將玩家數據複製到客戶端的通用方法使您能夠傳輸這些信息,而無需為每個數據組件創建定制的複製系統。開發者通常希望能夠選擇性地決定哪些數據被複製到客戶端,哪些不被複製。

  • 錯誤處理: 當無法訪問數據存儲時,大多數解決方案會實作重試機制和回退到“默認”數據。需要特別小心,以確保回退數據不會在後來覆寫“真實”數據,並且適當地將這一點傳達給玩家。

  • 重試: 當數據存儲無法訪問時,大多數解決方案會實作重試機制和回退到默認數據。特別小心地確保回退數據不會在後來覆寫“真實”數據,並適當地與玩家溝通此情況。

  • 會話鎖定: 如果單個玩家的數據在多個服務器上加載且存儲於內存中,可能會發生一個服務器保存過時信息的問題。這可能導致數據丟失和常見的物品重複漏洞。

  • 原子購買處理: 驗證、獎勵和記錄購買時必須原子性地進行,以防止物品丟失或被多次授予。

範例代碼

Roblox 提供了參考代碼來協助您設計和構建玩家數據系統。本頁的其餘部分將檢查背景、實作細節和一般警告。


將模型導入 Studio 後,您應該能看到以下文件夾結構:

顯示購買系統模型的資源管理器窗口。

架構

這個高層次的圖示說明了範例中的關鍵系統及其如何與其餘體驗中的代碼進行交互。

代碼範例的架構圖。

重試

Class: DataStoreWrapper

背景

由於 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 請求很重要,因為它們與狀態進行交互。考慮以下情景:

  1. 請求 A 被發送以將鍵 K 的值設置為 1。
  2. 請求失敗,因此安排了在 2 秒後重試。
  3. 在重試發生之前,請求 B 將 K 的值設置為 2,但請求 A 的重試立即覆寫此值,並將 K 設置為 1。

即使 UpdateAsync 在鍵值的最新版本上操作,UpdateAsync 請求仍然必須按順序處理,以避免無效的瞬態狀態(例如,購買在處理過程中先扣除金幣,然後再處理金幣增加,導致金幣變為負數)。

我們的玩家數據系統使用一個新類,DataStoreWrapper,該類提供經過保證以按照鍵的順序處理的重試。

方法

說明重試系統的流程圖

DataStoreWrapper 提供的方法與 DataStore 方法相對應:DataStore:GetAsync()DataStore:SetAsync()DataStore:UpdateAsync()DataStore:RemoveAsync()

這些方法在被調用時:

  1. 將請求添加到隊列中。每個鍵都有自己的隊列,請求按照順序和系列處理。請求線程在請求完成之前會被讓步。

    此功能是基於 ThreadQueue 類的,這是一個基於協程的任務調度器和速率限制器。ThreadQueue 不是返回一個 promise,而是讓當前線程直到操作完成並在失敗時拋出錯誤。這更符合典型的異步 Luau 模式。

  2. 如果請求失敗,則以可配置的指數退避進行重試。這些重試是提交給 ThreadQueue 的回調的一部分,因此在此鍵的下一個請求開始之前它們必然會完成。

  3. 當請求完成時,請求方法返回 success, result 的模式。

DataStoreWrapper 還公開了一些方法,以獲取特定鍵的隊列長度並清除過期的請求。在服務器關閉且沒有時間處理除最新請求之外的任何請求的情境中,後者選項特別有用。

警告

DataStoreWrapper 遵循的原則是,在極端情境之外,每個數據存儲請求都應允許完成(無論成功與否),即使更近期的請求使其變得冗餘。當發生新請求時,過期請求不會從隊列中移除,而是被允許在啟動新請求之前完成。這一點的理由根植於該模塊作為通用數據存儲工具的適用性,而不是玩家數據的特定工具,如下所示:

  1. 確定什麼時候安全移除請求的直觀規則是困難的。考慮以下隊列:

    Value=0, SetAsync(1), GetAsync(), SetAsync(2)

    預期的行為是 GetAsync() 會返回 1,但如果我們出於與最近請求的冗餘性原因從隊列中移除了 SetAsync() 請求,則它會返回 0

    合理的推進是,當添加新寫請求時,只修整最近讀請求之前的過期請求。UpdateAsync() 是迄今為止最常見的操作(也是這一系統唯一用到的操作),因其可以同時進行讀取和寫入,因此在此設計內調解將變得困難而增加額外複雜性。

    DataStoreWrapper 可能要求您指定 UpdateAsync() 請求是否被允許進行讀取和/或寫入,但對於我們的玩家數據系統來說,這一點並無應用性,因為由於會話鎖定機制(稍後將詳細介紹),這一點無法提前確定。

  2. 一旦從隊列中移除,決定 如何 處理這一點的直觀規則也很困難。當發出 DataStoreWrapper 請求時,當前線程會讓步,直到請求完成。如果我們從隊列中移除了過期請求,我們必須決定是返回 false, "Removed from queue" 還是從不返回並丟棄活動線程。這兩種處理方式都有自己的缺陷,並將額外的複雜性轉嫁給消費者。

最終,我們認為簡單的方法(處理每個請求)在這裡是更可取的,並在面對複雜問題(例如會話鎖定)時創造了一個更清晰的環境。唯一的例外是當 DataModel:BindToClose() 時,此時在及時保存所有用戶數據的情況下,清空隊列是必要的,單個函數調用的返回值也不再是一個持續關心的問題。為了解決這一點,我們公開了 skipAllQueuesToLastEnqueued 方法。更多上下文參見 玩家數據

會話鎖定

Class: SessionLockedDataStoreWrapper

背景

玩家數據存儲在服務器內存中,只有在必要時才會從底層數據存儲中進行讀取和寫入。您可以在不需要網絡請求的情況下瞬時讀取和更新內存中的玩家數據,並避免超過 DataStoreService 限制。

為了使此模型如預期工作,必須確保不會有多於一個服務器能夠同時從 DataStore 加載玩家數據。

例如,如果服務器 A 加載玩家的數據,則服務器 B 不能加載該數據,直到服務器 A 在最終保存過程中釋放對它的鎖定。沒有鎖定機制時,服務器 B 可能會在服務器 A 有機會保存其在內存中的更近期版本之前加載過時的玩家數據。然後如果服務器 A 的更新數據在服務器 B 加載過時數據後被保存,服務器 B 將在下一次保存中覆寫該更新數據。

即使 Roblox 僅允許客戶端同時連接到一個服務器,您也不能假設來自一個會話的數據總是保存了在下一個會話開始之前。考慮以下情況:

  1. 服務器 A 發出請求以保存其數據,但請求失敗並需要多次重試才能成功完成。在重試期間,玩家加入服務器 B。
  2. 服務器 A 對同一鍵發出了太多的 UpdateAsync() 調用並被限制。最終的保存請求被放入隊列。在請求被排入隊列時,玩家加入服務器 B。
  3. 在服務器 A 上,與 PlayerRemoving 事件相關的某些代碼在保存玩家數據之前讓出。在這個操作完成之前,玩家加入服務器 B。
  4. 服務器 A 的性能下降到延遲最終保存的地步,直到玩家加入服務器 B 之後。

這些場景應該是罕見的,但確實會發生,特別是在玩家迅速從一個服務器斷開並連接到另一個服務器的情況下(例如,在傳送時)。一些惡意用戶可能甚至會試圖濫用此行為以完成不持久的操作。這對允許玩家進行交易的遊戲來說特別具有影響力,並且是物品複製漏洞的常見來源。

會話鎖定通過確保當玩家的 DataStore 鍵首次被服務器讀取時,服務器會在同一 UpdateAsync() 呼叫中原子地寫入鎖定到鍵的元數據來解決這一漏洞。如果當其他任何服務器嘗試讀取或寫入該鍵時此鎖定值存在,則服務器不會繼續。

方法

說明會話鎖定系統的流程圖

SessionLockedDataStoreWrapperDataStoreWrapper 類的元包裝器。DataStoreWrapper 提供了隊列和重試功能,而 SessionLockedDataStoreWrapper 則通過會話鎖定進行補充。

SessionLockedDataStoreWrapper 會將每個 DataStore 請求(無論是 GetAsyncSetAsyncUpdateAsync)都通過 UpdateAsync。因為 UpdateAsync 允許對鍵進行原子性讀取和寫入。根據已讀取的值返回 nil 也可以放棄寫入。

傳遞給每個請求的 UpdateAsync 的轉換功能執行以下操作:

  1. 驗證鍵是否安全訪問,如果不安全則放棄操作。“安全訪問” 意味著:

    • 鍵的元數據對象不包括上次更新少於鎖定過期時間的未識別的 LockId 值。這意味著尊重另一服務器施加的鎖定,並在鎖定過期時忽略該鎖定。

    • 如果該服務器之前在鍵的元數據中施加了自己的 LockId 值,則該值應仍在鍵的元數據中。這意味著另一服務器在您鎖定該鍵後,可能已接管此服務器的鎖定(因過期或強制)並在稍後釋放它。換句話說,即使 LockIdnil,另一服務器仍然可以在您鎖定鍵之後用另一鎖定替代並移除鎖定。

  2. UpdateAsync 執行 Consumer 請求的 DataStore 操作。例如,GetAsync() 轉換為 function(value) return value end

  3. 根據請求中傳遞的參數,UpdateAsync 要么鎖定要么解鎖該鍵:

    1. 如果要鎖定鍵,UpdateAsync 將在鍵的元數據中設置 LockId 為 GUID。這個 GUID 存儲在服務器的內存中,以便在下次訪問該鍵時進行驗證。如果服務器已經對該鍵鎖定,則不會進行更改。它還安排了一項任務,以提醒您如果您未能在鎖定過期時間內再次訪問此鍵以維持鎖定。

    2. 如果要解鎖鍵,UpdateAsync 將刪除鍵的元數據中的 LockId

一個自定義的重試處理程序被傳遞給底層的 DataStoreWrapper,因此如果因會話鎖定在步驟 1 中中止,該操作將進行重試。

還會向 Consumer 返回一個自定義錯誤消息,允許玩家數據系統在會話鎖定情況下向客戶端報告替代錯誤。

警告

會話鎖定制度依賴於服務器在完成操作時始終釋放對鍵的鎖定。這應該始終通過在 PlayerRemoving 的最終寫入過程中提出的解鎖指令實現,或通過BindToClose()

不過,解鎖在某些情況下可能會失敗。例如:

  • 服務器崩潰或 DataStoreService 所有嘗試訪問該鍵都無法操作。
  • 由於邏輯錯誤或類似的錯誤,未能提出解鎖鍵的指令。

為了保持對鍵的鎖定,您必須在它加載到內存中時定期訪問它。這通常是通過大多數玩家數據系統在背景中運行的自動保存循環完成的,但如果您需要手動執行此操作,該系統還公開了 refreshLockAsync 方法。

如果鎖定過期時間已過但鎖定未更新,則任何服務器都可以自由接管鎖定。如果另一個服務器接管鎖定,則當前服務器的讀取或寫入鍵的嘗試將失敗,除非建立新的鎖定。

開發者產品處理

單例: ReceiptHandler

背景

ProcessReceipt 回調執行確定何時最終確定購買的關鍵任務。ProcessReceipt 在非常特定的情境中被調用。有關其保證集的詳細信息,請參見 MarketplaceService.ProcessReceipt

雖然“處理”購買的定義可能因體驗而異,但我們使用以下標準

  1. 購買尚未被處理過。

  2. 購買在當前會話中得到了體現。

  3. 購買已經保存到 DataStore

    每次購買,即使是一次性消耗品,也應反映在 DataStore 中,以便用戶的購買歷史包含在其會話數據中。

這要求進行以下操作,然後返回 PurchaseGranted

  1. 驗證 PurchaseId 尚未被記錄為已處理。
  2. 在玩家的內存玩家數據中獎勵這次購買。
  3. 在玩家的內存玩家數據中記錄 PurchaseId 為已處理。
  4. 將玩家的內存玩家數據寫入 DataStore

會話鎖定簡化了此流程,因為您不再需要擔心以下場景:

  • 當前服務器中的內存玩家數據可能過時,因此需要從 DataStore 獲取最新值,然後驗證 PurchaseId 歷史
  • 來自另一服務器的不斷運行的相同購買的回調,要求您同時讀取和寫入 PurchaseId 歷史並以原子方式保存更新後的玩家數據以防止競爭條件

會話鎖定保證,如果向玩家的 DataStore 寫入嘗試成功,則在這一服務器的數據加載和保存之間,沒有其他服務器成功讀取或寫入玩家的 DataStore。簡而言之,此服務器內存中的玩家數據是可用的最新版本。有一些警告,但它們不影響此行為。

方法

ReceiptProcessor 中的註釋概述了該方法:

  1. 驗證玩家的數據當前已在此服務器上加載且沒有錯誤。

    由於該系統使用會話鎖定,這一檢查還驗證了內存中的數據是最新版本。

    如果玩家的數據尚未加載(這在玩家加入遊戲時是預期的),則等待玩家的數據加載。系統還監聽玩家在其數據加載之前離開遊戲,因為它不應無限期讓出,並阻止此回調在玩家重新加入時再次在此服務器上被調用。

  2. 驗證 PurchaseId 尚未在玩家數據中被記錄為已處理。

    由於會話鎖定,系統在內存中擁有的 PurchaseIds 陣列是最新版本。如果 PurchaseId 被記錄為已處理並反映在加載或保存到 DataStore 的值中,則返回 PurchaseGranted。如果它被記錄為已處理,但 反映在 DataStore 中,則返回 NotProcessedYet

  3. 在此服務器中本地更新玩家數據以“獎勵”購買。

    ReceiptProcessor 採用了通用回調方法,並為每個 DeveloperProductId 指派不同的回調。

  4. 在此服務器中本地更新玩家數據以存儲 PurchaseId

  5. 提交請求以將內存數據保存到 DataStore,如果請求成功則返回 PurchaseGranted。如果不成功,則返回 NotProcessedYet

    如果此保存請求未成功,稍後保存玩家的內存會話數據仍可能成功。在下一次 ProcessReceipt 調用中,第 2 步處理此情況並返回 PurchaseGranted

玩家數據

單例: PlayerData.ServerPlayerData.Client

背景

提供接口的模塊,用於讓遊戲代碼同步地讀取和寫入玩家會話數據,在 Roblox 體驗中很常見。本節涵蓋 PlayerData.ServerPlayerData.Client

方法

PlayerData.ServerPlayerData.Client 負責以下工作:

  1. 將玩家的數據加載到內存中,包括處理失敗加載的情況
  2. 提供接口,讓服務器代碼查詢和更改玩家數據
  3. 將玩家數據的變更複製到客戶端,以便客戶端代碼可以訪問
  4. 將加載和/或保存錯誤的消息複製到客戶端,以顯示錯誤對話框
  5. 在玩家離開時定期保存玩家數據,以及在服務器關閉時保存玩家數據

加載玩家數據

說明加載系統的流程圖
  1. SessionLockedDataStoreWrapper 對數據存儲發出 getAsync 請求。

    如果此請求失敗,則使用默認數據並將該檔案標記為“錯誤”,以確保不會在稍後寫入數據存儲。

    另一個選擇是踢出玩家,但我們建議讓玩家使用默認數據進行遊玩,並清晰地傳達發生的情況,而不是將其從體驗中移除。

  2. PlayerDataClient 發送初始有效載荷,包含加載的數據和錯誤狀態(如果有)。

  3. 任何使用 waitForDataLoadAsync 對該玩家的線程均會恢復。

提供服務器代碼的接口

  • PlayerDataServer 是一個單例,可以通過在同一環境中運行的任何服務器代碼要求和訪問。
  • 玩家數據組織成鍵和值的字典。您可以使用 setValuegetValueupdateValueremoveValue 方法在服務器上操作這些值。這些方法全部同步運行,無需讓步。
  • hasLoadedwaitForDataLoadAsync 方法可用於確保在訪問數據之前數據已加載。我們建議在啟動其他系統之前於加載屏幕進行此操作一次,以避免在每次與客戶端數據互動之前檢查加載錯誤。
  • hasErrored 方法可查詢玩家的初始加載是否失敗,這使得他們使用默認數據。在允許玩家進行任何購買之前檢查此方法,因為在未成功加載的情況下,無法將購買保存到數據中。
  • 每當玩家的數據發生更改時,playerDataUpdated 信號都會發出,包含 playerkeyvalue。各個系統可以訂閱此信號。

複製變更到客戶端

  • PlayerDataServer 中對玩家數據的任何變更都會複製到 PlayerDataClient,除非該鍵已使用 setValueAsPrivate 標記為私人
    • setValueAsPrivate 用於標示不應發送到客戶端的鍵
  • PlayerDataClient 包括一個方法以獲取鍵的值(get)和當其更新時發出的信號(updated)。還包括 hasLoaded 方法和 loaded 信號,以便客戶端在啟動其系統之前等待數據加載和複製
  • PlayerDataClient 是一個單例,可以被任何在同一環境中運行的客戶端代碼要求和訪問

複製錯誤到客戶端

  • 保存或加載玩家數據時遇到的錯誤狀態會複製到 PlayerDataClient
  • 使用 getLoadErrorgetSaveError 方法訪問這些信息,以及 loadedsaved 信號。
  • 錯誤有兩種:DataStoreErrorDataStoreService 請求失敗)和 SessionLocked(見 會話鎖定)。
  • 使用這些事件禁用客戶端購買提示並實作警告對話框。此圖像顯示了可能在玩家數據加載失敗時顯示的示例對話框:
玩家數據加载失败时可能显示的警告示例截屏

保存玩家數據

說明保存系統的流程圖
  1. 當玩家離開遊戲時,系統採取以下步驟:

    1. 檢查是否安全地將玩家數據寫入數據存儲。在玩家的數據加載失敗或仍在加載中等情況下,則將其視為不安全。
    2. 通過 SessionLockedDataStoreWrapper 發出請求,將當前的內存數據值寫入數據存儲並在完成後移除會話鎖定。
    3. 清除服務器內存中的玩家數據(以及其他變數,如元數據和錯誤狀態)。
  2. 在定期循環中,服務器將每個玩家的數據寫入數據存儲(前提是保存是安全的)。這種冗餘的預防措施有助於在服務器崩潰的情況下減少損失,同時也是維護會話鎖定所必需的。

  3. 當收到關閉服務器的請求時,以下操作發生在 BindToClose 回調中:

    1. 發出請求以保存服務器中的每個玩家數據,遵循通常在玩家離開服務器時經過的過程。這些請求是並行發出的,因為 BindToClose 回調最多只有 30 秒的時間來完成。
    2. 為了加速保存,清除每個鍵的隊列中所有其他請求(參見 重試)。
    3. 此回調在所有請求完成之前不會返回。