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
が用意されています。
用意する方法は以下のように取得方法を変えるだけです。
local characterAgeStore = DataStoreService:GetDataStore("CharacterAges")
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 もサポートされず、 DataStoreKeyInfo
は nil
になるようです。
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 が用途にあげられているので、まあ名前でソートしても意味がないので当然といえば当然かもしれません。
まず、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"
local dataStore = DataStoreService:GetDataStore(dataStoreName)
local dataStoreScopedHouse = DataStoreService:GetDataStore(dataStoreName, "house")
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)
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"
local dataStore = DataStoreService:GetDataStore(dataStoreName)
local dataStoreScopedHouse = DataStoreService:GetDataStore(dataStoreName, "house")
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")
DataStoreSample.SetDataToHouse(userId, "Test in House")
DataStoreSample.SetData(`house/{userId}`, "Test in Global 2")
DataStoreSample.SetDataToAll(`house/{userId}_2`, "Test in House 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}"`)
print(`dataInHouse is "{dataInHouse}"`)
print(`dataInGlobalFromAll is "{dataInGlobalFromAll}"`)
print(`dataInHouseFromAll is "{dataInHouseFromAll}"`)
print(DataStoreSample.ListData())
print(DataStoreSample.ListDataWithPrefix("hou"))
print(DataStoreSample.ListDataWithPrefix("glo"))
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
GetAsync
の DataStoreGetOptions
で UseCache
を無効化することでキャッシュを使わずにデータを取得できます。
が、制限もあるので用法用量を守ってということですね。
Best Practices
いくつかポイントがあるようですが、
- Data store はできるだけ少ない数にし、Data store 間に関連を持たせる
- DB のテーブルと似ていると言及されていますが、どれぐらいの数が適切かまでは書かれていません
- 保存できるオブジェクトサイズは 4MB までであり、関連データは一つのオブジェクトとして保存すると良さそうです
- 関連データに分けすぎると、フェッチの回数が多くなってしまうのでそれを避けられます
- key の prefix を使ってデータを organize する
この辺を見ていると、状況は違いますが Firestore の設計に多少似ている部分もありそうです。
いったんまとめ
さて、データーストアの「概要」ページをなぞっていったわけですが、結構なボリュームでした。
ただ、おかげでだいたいのことは把握できましたし、簡単なサンプルコードも試せました。
自分には比較的馴染みがある Firestore の設計も参考にできそうなことも分かって良かったです。