Roblox でのデータ永続化を学ぶ

Roblox でゲームを作るにあたり、適当にチュートリアルをこなした後に「まずはデータの永続化だろう」ということでそこから押さえていくことにします。

基本

公式の中級チュートリアルにある以下のドキュメントが基本になると思います。

create.roblox.com

ほぼ同じ内容になるかもしれませんが一つ一つ見ていきたいと思います。

Enabling Studio Access

デフォルトでは Studio から data stores にはアクセスできないので有効化します。

Creating a Data Store

Data stores は DataStoreService で管理されるようです。

DataStoreService を確認するにあたり、 Data stores のページが分かりやすいので見ていきます。

Data Stores

DataStoreService lets you store data that needs to persist between sessions, such as items in a player's inventory or skill points.
Data stores are consistent per experience, so any place in an experience can access and change the same data, including places on different servers.

per experience と書かれている通りで、この永続化は experience 単位での保存であり、 experience 内の複数の place からアクセス可能のようです。
experience は以下の最初のチュートリアルにあるように一つのバーチャル空間(ゲーム)と考えておけばいいと思います。
Creating Your First Experience | 文書 - Roblox クリエーターハブ

適切ではないかもしれません、UE で言い換えれば、一つの UE のプロジェクトが experience、プロジェクト内のレベルが各 place に相当します。

Enabling Studio Access

先ほど有効化したように、ゲーム設定のセキュリティから有効化しました

Accessing a Data Store

導入は以下のように GetService で行い、名前で個別の Data store にアクセスできます。

local DataStoreService = game:GetService("DataStoreService")
local experienceStore = DataStoreService:GetDataStore("PlayerExperience")`

The server can only access data stores through Scripts. Attempting client-side access in a LocalScript causes an error.

NOTE にある通り、サーバーからのみアクセスできます

Scopes

以下のような形式で scope を付与でき、GetDataStore の第二引数で絞り込みができます

ただ、NOTE にある通り、この方法は現在は Listing and Prefixes の方法に置き換えることが推奨されています。
ただ、既存のコードで見かけることがあると思うので知識としておさえておくと良さそうです。

For new experiences, it is not recommended to use the legacy scopes feature. Instead, use listing and prefixes to organize keys in your data store. If you have an existing experience that uses scopes, you can continue using them.

Managing a Data Store

Setting Data

A data store is essentially a dictionary, similar to a Lua table. A unique key indexes each value in the data store, such as a player's unique Player.UserId or a named string for a game promo.

NOTE にある通りで、data store は辞書データであり、プレイヤーの id やゲーム内で使う文字列をキーとして使います。

local success, errorMessage = pcall(function()
    experienceStore:SetAsync("User_1234", 50)
end)
if not success then
    print(errorMessage)
end

SetAsync に key / value を与えることで簡単に保存ができます。
以下の NOTE にある通り、SetAsync はネットワーク呼び出しであるので失敗の可能性があり、よって pcall を使ってエラーハンドリングを行います。

Functions like SetAsync() that access a data store's contents are network calls that may occasionally fail. As shown above, it's recommended that these calls be wrapped in pcall() to catch and handle errors.

Reading Data
local success, currentExperience = pcall(function()
    return experienceStore:GetAsync("User_1234")
end)
if success then
    print(currentExperience)
end

GetAsync に key を与えることで value が簡単に取得できます。

The values you retrieve using GetAsync() sometimes can be out of sync with the backend due to the caching behavior. To opt out of caching, see Disabling caching.

上記の NOTE にある通りで、 cache の仕組みで同期された値を取得できない場合があり、場合によっては cache 自体を無効化することもできます。
cache に関しては後ほど詳細が出てきます。

pcall でエラーハンドリングすることに関しては SetAsync と同じようです。

Incrementing Data
local success, newExperience = pcall(function()
    return experienceStore:IncrementAsync("Player_1234", 1)
end)
if success then
    print(newExperience)
end

数値データに関しては、キーに対して変更値を与えることで直接値を更新できるようです。

Updating Data
local function makeNameUpper(currentName)
    local nameUpper = string.upper(currentName)
    return nameUpper
end

local success, updatedName = pcall(function()
    return nicknameStore:UpdateAsync("User_1234", makeNameUpper)
end)
if success then
    print("Uppercase Name:", updatedName)
end

UpdateAsync はデータの更新ですが、SetAsync と違い、key に対して新しい value を渡すのではなく、現在値を更新する callback function を渡します。
通常は更新後の値を return しますが、nil を返すと更新処理がキャンセルされます。

The callback function passed into UpdateAsync() is not permitted to yield, so it cannot contain any yielding function like task.wait().

また、上記の NOTE にある通りで、 task.wait() を含むような yield 処理が入る関数にすることはできません。

Set vs. Update

この辺は一般的なデータ保存であることですが、Set の場合は、GetAsync で取得したデータが必ずしも最新のデータかは分からず(他のサーバーで更新がされるなど)SetAsync するときには意図せず直前の更新を無視した更新になってしまう可能性があります。

一方で Update の場合は、更新時に直前のデータを読み出すのでより安全にデータの更新ができます。

まあ、この辺はデータの性質(例えば確実に一人のユーザーからしか更新されない or いろんなユーザー・サーバーから更新される)を考慮して選択すればいいでしょう。

Removing Data
local success, removedValue = pcall(function()
    return nicknameStore:RemoveAsync("User_1234")
end)
if success then
    print(removedValue)
end

削除は key を指定して行います。

Ordered Data Stores

Data store は通常ソートされていませんが、一部の用途でソートされたデータ必要になるため、OrderedDataStore が用意されています。

用意する方法は以下のように取得方法を変えるだけです。

-- 通常の Data store
local characterAgeStore = DataStoreService:GetDataStore("CharacterAges")
-- Ordered Data Store
local characterAgeStore = DataStoreService:GetOrderedDataStore("CharacterAges")

これだけで取得される型が OrderedDataStore に変更されています。

OrderedDataStore | 文書 - Roblox クリエーターハブ

OrderedDataStore の仕様は上記にあります。

A OrderedDataStore is essentially a GlobalDataStore with the exception that stored values must be positive integers.
It exposes a method GetSortedAsync() which allows inspection of the entries in sorted order using a DataStorePages object.

ドキュメントを見ると、まず Data store は GlobalDataStore であり、それに対して **positive integersのみを value にとるという制限がOrderedDataStoreにはあるようです。 また、GetSortedAsyncというメソッドが用意されており、DataStorePages` というオブジェクトを使ってソートされた結果を表現するようです。

Ordered data stores do not support versioning and metadata, so DataStoreKeyInfo is always nil for keys in an OrderedDataStore. If you need versioning and metadata support, use a DataStore.

また、versioning や metadata もサポートされず、 DataStoreKeyInfonil になるようです。

Ordered data stores do not support the optional userIds parameter for SetAsync() or IncrementAsync().

加えて、SetAsync, IncrementAsync で利用できる userIds パラメータも利用できないとあります。

認識していませんでしたが、これらのメソッドには userIds というパラメータが設定でき、おそらくその処理を実行したユーザーの記録に使われるのだと思います。

OrderedDataStore | 文書 - Roblox クリエーターハブ

こう見ると、GetDataStore 時にスコープを指定できたりと細かい設定はありそうですね。

何でソートされるか?

value に integers しか設定できないという仕様からも、おそらく value でのソートが行われるのだと思います。 leaderboard が用途にあげられているので、まあ名前でソートしても意味がないので当然といえば当然かもしれません。

Metadata

まず、Service-defined, User-defined の 2 種類があり前者は DB にもありそうな update time や creation time です。
後者はユーザーが指定できるもので、ちょうど先程 SetAsync の引数としても出てきていた DataStoreSetOptions を通して設定するようです。

SetAsync の場合
local experienceStore = DataStoreService:GetDataStore("PlayerExperience")

local setOptions = Instance.new("DataStoreSetOptions")
setOptions:SetMetadata({["ExperienceElement"] = "Fire"})

local success, errorMessage = pcall(function()
    experienceStore:SetAsync("User_1234", 50, {1234}, setOptions)
end)
if not success then
    print(e

そのままではありますが、DataStoreSetOptionsインスタンスを作成し、指定したいメタデータを用意したら、それを SetAsync の第四引数に設定します。

ここでは先程紹介した userIds も第三引数に指定されていることが分かります。

Table of UserIds, highly recommended to assist with content copyright and intellectual property tracking/removal.

となっているので、 userIds は基本的には設定した方がいいんでしょうか。

GetAsync(), IncrementAsync(), and RemoveAsync() の場合
local experienceStore = DataStoreService:GetDataStore("PlayerExperience")

local success, currentExperience, keyInfo = pcall(function()
    return experienceStore:GetAsync("User_1234")
end)
if success then
    print(currentExperience)
    print(keyInfo.Version)
    print(keyInfo.CreatedTime)
    print(keyInfo.UpdatedTime)
    print(keyInfo:GetUserIds())
    print(keyInfo:GetMetadata())
end

これまでは 実行結果としては単一の結果 (success は pcall の結果であり除外) しか受け取っていなかったのに対して、 keyInfo を追加で受け取ることができます。

GetAsync の仕様を確認すると、戻り値は Tuple になっており、今回の DataStoreKeyInfo が取得できることが分かります。

DataStoreKeyInfo に metadata だけでなく userIds や version の情報も格納されていることが分かります。

UpdateAsync の場合
local nicknameStore = DataStoreService:GetDataStore("Nicknames")

local function makeNameUpper(currentName, keyInfo)
    local nameUpper = string.upper(currentName)
    local userIDs = keyInfo:GetUserIds()
    local metadata = keyInfo:GetMetadata()
    return nameUpper, userIDs, metadata
end

local success, updatedName, keyInfo = pcall(function()
    return nicknameStore:UpdateAsync("User_1234", makeNameUpper)
end)
if success then
    print(updatedName)
    print(keyInfo.Version)
    print(keyInfo.CreatedTime)
    print(keyInfo.UpdatedTime)
    print(keyInfo:GetUserIds())
    print(keyInfo:GetMetadata())
end

特筆すべき点はありませんが、同様に戻り値に keyInfo を取得できています。

その他

まず NOTE として

You must always update metadata definitions with a value, even if there are no changes to the current value, otherwise, you lose the current value. This applies to SetAsync(), IncrementAsync(), and UpdateAsync().

とあります。つまり、metadata に変更がなくても常に更新しないと現在値が失われるということです。まあ、wrapper 関数を用意して常に値をいれるようにすべきですね。

また、ユーザー定義のメタデータには以下の制限があります。

  • Key length: up to 50 characters.
  • Value length: up to 250 characters.
  • No limit for the total number of key-value pairs but the total size cannot exceed 300 characters.

Versioning

更新系のメソッドを利用した際に priodically にバージョン付けられたバックアップを作成してくれるようです。
UTC hour 単位となっており、その単位時間内で最初の書き込みにはバックアップが作成され、その時間内の2度目以降の書き込みは新しいバックアップではなく上書きになるようです。
まあ、一定時間ごとの最終データが記録されていると考えておけば良さそうです。

また、保持期限は 30 日で最新データに関してだけは無期限に保持されるとのことです。

Snapshots

Snapshot Data Stores - Data Stores | 文書 - Roblox クリエーターハブ

上記の API を通して、experience 内の全データのスナップショットを取得できます。これは、前述の Versioning のデータと同様なのですが、versioning が上書きになる単位 UTC 時間内であっても、新しくバックアップが作成されます。

experience に大きな更新をかけるときなどに、データが壊れて戻せなくなることを防ぐために直前に snapshots を取得するのが良さそうです。

Listing and Prefixes

ListDataStoresAsync(), ListKeysAsync() を使って、それぞれ Data store 、data store 内のキーの一覧を取得できます。

AllScopes Property

GetDataStore の部分で scope に関する言及がありました。そこで、scope で絞り込むよりも prefix で一覧することが推奨されていました。

ドキュメントだけだとパッと理解できなかったのでコードを書いて確認します。

local DataStoreService = game:GetService("DataStoreService")

local options = Instance.new("DataStoreOptions")
options.AllScopes = true

local dataStoreName = "DataStoreSample"

-- normal
local dataStore = DataStoreService:GetDataStore(dataStoreName)
-- scoped
local dataStoreScopedHouse = DataStoreService:GetDataStore(dataStoreName, "house")
-- all scopes
local dataStoreAllScopes = DataStoreService:GetDataStore(dataStoreName, "", options)

local DataStoreSample = {}

function DataStoreSample.GetData(key)
    local success, data = pcall(function()
        return dataStore:GetAsync(key)
    end)
    
    if success then
        return data
    end
end

function DataStoreSample.SetData(key, data)
    local success, data = pcall(function()
        return dataStore:SetAsync(key, data)
    end)

    if success then
        print("success")
    end
end

function DataStoreSample.GetDataFromHouse(key)
    local success, data = pcall(function()
        return dataStoreScopedHouse:GetAsync(key)
    end)
    
    if success then
        return data
    end
end

function DataStoreSample.SetDataToHouse(key, data)
    local success, data = pcall(function()
        return dataStoreScopedHouse:SetAsync(key, data)
    end)

    if success then
        print("success")
    end
end

function DataStoreSample.GetDataFromAll(key, scope)
    local success, data = pcall(function()
        return dataStoreAllScopes:GetAsync(`{scope}/{key}`)
    end)
    
    if success then
        return data
    end
end

function DataStoreSample.SetDataToAll(key, data)
    local success, data = pcall(function()
        return dataStoreAllScopes:SetAsync(key, data)
    end)

    if success then
        print("success")
    end
end

function DataStoreSample.ListData()
    local listSuccess, pages = pcall(function()
        return dataStoreAllScopes:ListKeysAsync()
    end)

    local results = {}
    if listSuccess then
        local items = pages:GetCurrentPage()
        if #items > 0 then
            for i, item in ipairs(items) do
                table.insert(results, item.KeyName) -- https://create.roblox.com/docs/ja-jp/reference/engine/classes/DataStoreKey
            end
        end
    end
    return results
end

function DataStoreSample.ListDataWithPrefix(prefix)
    local listSuccess, pages = pcall(function()
        return dataStoreAllScopes:ListKeysAsync(prefix)
    end)

    local results = {}
    if listSuccess then
        local items = pages:GetCurrentPage()
        if #items > 0 then
            for i, item in ipairs(items) do
                table.insert(results, item.KeyName)
            end
        end
    end
    return results
end

return DataStoreSample

名前は同じで、3 つの Data store を取得しています。

local options = Instance.new("DataStoreOptions")
options.AllScopes = true

local dataStoreName = "DataStoreSample"

-- normal
local dataStore = DataStoreService:GetDataStore(dataStoreName)
-- scoped
local dataStoreScopedHouse = DataStoreService:GetDataStore(dataStoreName, "house")
-- all scopes
local dataStoreAllScopes = DataStoreService:GetDataStore(dataStoreName, "", options)

When you use this property, the second parameter of GetDataStore() must be an empty string ("").

とあるように、AllScopes の場合は必ず第二引数は "" にします。

これを使って SetAsync, GetAsync そして ListKeysAsync を使うメソッドを定義しています。

これを使う側は以下の ServerScript です。

local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")
local DataStoreSample = require(ServerScriptService.DataStoreSample)

Players.PlayerAdded:Connect(function(player)
    local userId = "dummy_user_id"

    DataStoreSample.SetData(userId, "Test in Global") -- key is "global/dummy_user_id"
    DataStoreSample.SetDataToHouse(userId, "Test in House") -- key is "house/dummy_user_id"
    DataStoreSample.SetData(`house/{userId}`, "Test in Global 2") -- key is "global/house/dummy_user_id"
    DataStoreSample.SetDataToAll(`house/{userId}_2`, "Test in House 2") -- key is "house/dummy_user_id_2"

    local data = DataStoreSample.GetData(userId)
    local dataInHouse = DataStoreSample.GetDataFromHouse(userId)
    local dataInGlobalFromAll = DataStoreSample.GetDataFromAll(userId, "global")
    local dataInHouseFromAll = DataStoreSample.GetDataFromAll(userId, "house")

    -- それぞれの先頭のデータが取得できている
    print(`data is "{data}"`) --Output is "Test in Global"
    print(`dataInHouse is "{dataInHouse}"`) --Output is "Test in House"
    print(`dataInGlobalFromAll is "{dataInGlobalFromAll}"`) --Output is "Test in Global"
    print(`dataInHouseFromAll is "{dataInHouseFromAll}"`) --Output is "Test in House"

    print(DataStoreSample.ListData())
    -- {
    --    [1] = "global/house/dummy_user_id",
    --    [2] = "house/dummy_user_id",
    --    [3] = "house/dummy_user_id_2",
    --    [4] = "global/dummy_user_id"
    --  }
    print(DataStoreSample.ListDataWithPrefix("hou"))
    -- {
    --    [1] = "house/dummy_user_id",
    --    [2] = "house/dummy_user_id_2"
    -- }
    print(DataStoreSample.ListDataWithPrefix("glo"))
    -- {
    --    [1] = "global/house/dummy_user_id",
    --    [2] = "global/dummy_user_id"
    -- }
end)

確認できた通りで、AllScopes の場合 は SetAsync は scope を直接 house/ のように prefix を付けるだけで指定できます。
そして、同様に GetAsync も prefix を付けることで取り出すことができます。

また、ListKeysAsync に関しても期待通り prefix で絞り込むことができています。

ちなみに、AllScopes の場合は GetAsync の引数は必ず scope_name/key_name の形式で渡さないとエラーになります。

GetAsync - GlobalDataStore | 文書 - Roblox クリエーターハブ

Error Codes

これまでも pcall を使ってアクセスすることが言及されていますが、細かくエラーコードが網羅されていてこれは素晴らしいです。

Limits

Server Limits, Data Limits, Throughput Limits と制限が設けられています。

これらは experience 単位の Data store になります。制限に到達した場合は、4 つのキューに振り分けられるようです。
それは、set, ordered set, get, ordered get の 4 つになります。

案外 Data Limits にある文字列の長さ制限は気をつけなければいけないかもしれません。50 文字ですね。

Caching

データのキャッシュ機能がついています。GetAsync は 4 秒のキャッシュ時間があるようです。Data store はそもそもサーバーでしか実行されないと思いますが、サーバーからさらに backend に対してのリクエストのキャッシュということでしょうか。

キャッシュは更新系のメソッドで適用されるようですが、GetAsync でも取得時にキャッシュにデータがなければ作成されるようです。

また、GetVersionAsync, ListVersionsAsync, ListKeysAsync, ListDataStoresAsync は常に最新のデータを取得するようです。

Disabling Caching

GetAsyncDataStoreGetOptionsUseCache を無効化することでキャッシュを使わずにデータを取得できます。

が、制限もあるので用法用量を守ってということですね。

Best Practices

いくつかポイントがあるようですが、

  • Data store はできるだけ少ない数にし、Data store 間に関連を持たせる
    • DB のテーブルと似ていると言及されていますが、どれぐらいの数が適切かまでは書かれていません
  • 保存できるオブジェクトサイズは 4MB までであり、関連データは一つのオブジェクトとして保存すると良さそうです
    • 関連データに分けすぎると、フェッチの回数が多くなってしまうのでそれを避けられます
  • key の prefix を使ってデータを organize する

この辺を見ていると、状況は違いますが Firestore の設計に多少似ている部分もありそうです。

いったんまとめ

さて、データーストアの「概要」ページをなぞっていったわけですが、結構なボリュームでした。

ただ、おかげでだいたいのことは把握できましたし、簡単なサンプルコードも試せました。

自分には比較的馴染みがある Firestore の設計も参考にできそうなことも分かって良かったです。