2020年7月9日更新:実装変更でメソッド名や使い方の紹介などの修正を行いました。
前書き
unity1weekをきっかけに、Unity+Googleスプレッドシート+GASで簡易ランキング機能を作ってみました。この仕組みで、ランキングだけではなく、任意のデータをアップロード・ダウンロードできれば、一応汎用的なデータベースとして使えるのではないかと思いました。例えばゲームのマスタデータをGoogleスプレッドシートに保存することで、アプリのバージョン更新なしでゲームの調整ができてまうとか、ゲームの最新バージョンをGoogleスプレッドシートに書き込んで、アプリ起動時にそれを取得して古ければ強制アップデートポップアップを出すとか、さらにチャットやユーザー情報の管理もGoogleスプレッドシートでやりとりするなど、ユースケースがどんどん湧いてきます。
ということで、実装してみました。
使い方
プロジェクトはUmbrella(トトロなので)という名でGithubに公開しました。最新パッケージのダウンロードはこちら。ちなみに、Umbrellaには単純なデータ通信管理システムDatabase以外に、簡易ランキングシステムRankingも含まれています。興味ある方はぜひ合わせていじってみてください。
GAS側
- 新しいGoogleスプレッドシートを作成する。
- メニューのツールからスクリプト エディタをクリックする。
- Assets/Umbrella/Database/Database.gsの内容をコード.gsにコピーする。
- メニューのファイル > 保存でプロジェクトに名前をつけて保存する(保存にちょっと2、3秒ぐらい掛かるかも)。
- メニューの公開 > *ウェブアプリケーションとして導入...*をクリックする。
- ウェブアプリケーションとして導入のポップアップにて、次のユーザーとしてアプリケーションを実行に自分のアカウントを、アプリケーションにアクセスできるユーザーに*全員(匿名ユーザーを含む)*を設定する。
- 導入をクリックして、現在のウェブアプリケーションのURLの下に書いてあるURLをコピーする。
- 「認証が必要です」のポップアップが出たら@zk_phiさんの記事を参考にして認証を行ってください。
Unity側
- Assets/Umbrella/Database/DatabaseManagerプレハブを通信するシーンのヒエラルキーにドラッグ&ドロップする。
- Assets/Umbrella/Database/DatabaseSettingsスクリタブルオブジェクトのインスペクターから、App URLのフィールドに先ほどコピーしたGASのウェブアプリケーションURLをペーストする。
- Default Sheet Nameフィールドに使いたいGoogleスプレッドシートのデフォルトシート名を入力する。
- 任意のスクリプトで、データを送信したい場合は
DatabaseManager.Instance.SendDataAsync(data, sheetName)
を呼び、データを取得したい場合はDatabaseManager.Instance.GetDataAsync(keys, responseHandler, sheetName)
を呼ぶ。sheetName
を省略した場合は、Default Sheet Nameが使われる。 - うち、
responseHandler
はGAS側からのレスポンスを処理するコールバックで、通信が終わってから呼ばれる。GAS側のレスポンスはobject
型のリストに変換され、それの表示処理などは自分で実装できる。 - また、
DatabaseManager.Instance.SendDataAsync(data, sheetName)
やDatabaseManager.Instance.GetDataAsync(keys, responseHandler, sheetName)
の前にyield return
を付ければresponseHandler
が実行完了するまで待つことができる。 - 具体的な使い方はサンプルシーンとスクリプトを参照してください。
デモ
-
他のクライアントとしてデータを送信します。Umbrellaではクライアントを識別するユニークIDをUnity側で生成してPlayerPrefsに保存しているため、PlayerPrefsをクリアしないまま送信すると既存のデータを上書きすることになります。
実装の抜粋
GAS側の処理
GASはUnityから送ってきたデータに基づき、スプレッドシートの中身を更新します。
function doPost(e) {
var request = e.parameter;
var method = request[CONST.Method];
if(method == CONST.SaveDataMethod){
return saveData(request[CONST.Payload]);
}else if(method == CONST.GetDataMethod) {
return getData(request[CONST.Payload]);
}
return ContentService.createTextOutput("Error: Invalid method");
}
saveData
とgetData
の実装詳細はここでは省略しますが、GASのAPIコールはコスト高いため、処理速度を高めるにはできる限りAPIコールの回数を減らす必要があります。例えばセルデータの取得で、各セルでsheet.getRange().getValue()
の代わりに、予めvar data = sheet.getDataRange().getValues()
で指定範囲のセルデータを一括で配列に格納し、後で配列から値を取るなどの策が考えられます。また、データの書き込みがある場合、排他処理(ロック)を入れる必要がありますが、一行のデータをまとめて一括でappendRow()を使えばセル単位でのロック処理をなくすテクニックもあります。appendRow()は不可分操作(Atomic Operation)なので排他処理が要らないからです。
UnityからGASへの送信
簡易のデータベース機能なので、特に通信の仕様とかは決めなく(暗号化??)、完全にJSON形式で送信しています。JSON解析は軽量のMiniJsonを導入しています。
public CustomYieldInstruction SendDataAsync(MonoBehaviour context, string methodName, IDictionary<string, object> data, Action<object> responseHandler = null)
{
var strData = Json.Serialize(data);
var formData = new List<IMultipartFormSection>();
formData.Add(new MultipartFormDataSection(Const.Method, methodName));
formData.Add(new MultipartFormDataSection(Const.Payload, strData));
bool complete = false;
context.StartCoroutine(CT_SendData(formData, status => complete = status, responseHandler));
return new WaitUntil(() => complete);
}
WWWForm
がLegacyになったので、IMultipartFormSection
でフォームデータを作成しています。フォームデータにGAS側で呼び出したいメソッド名とデータ内容を入れています。また、外部でyield return
をつけて待たせられるように、返り値のタイプをCustomYieldInstruction
にしてcomplete
がtrueになるまで処理を止めることを可能にしています。
UnityWebRequest
のPost
メソッドでフォームデータを送信しています。
private IEnumerator CT_SendData(List<IMultipartFormSection> formData, Action<bool> updateStatus, Action<object> responseHandler = null)
{
updateStatus(false);
var www = UnityWebRequest.Post(_appURL, formData);
Debug.Log("<color=blue>[GSSDataService]</color> Start sending data to Google Sheets.");
yield return www.SendWebRequest();
if (www.isNetworkError || www.isHttpError)
{
Debug.LogError($"<color=blue>[GSSDataService]</color> Sending data to Google Sheets failed. Error: {www.error}");
}
else
{
Debug.Log("<color=blue>[GSSDataService]</color> Sending data to Google Sheets completed");
try
{
var response = Json.Deserialize(www.downloadHandler.text);
string message = response as string;
if (message != null && message.Contains("Error")) Debug.LogError($"<color=blue>[GSSDataService]</color> Getting data from Google Sheets failed. {message}");
else responseHandler?.Invoke(response);
}
catch (InvalidCastException e)
{
Debug.LogError($"<color=blue>[GSSDataService]</color> Parsing result from Google Sheets failed. Error: {e.Message}");
}
}
updateStatus(true);
}
返ってきた結果にError文字列(GAS側で入れている)が含まれたらエラーログを書き出し、なければ結果を処理するコールバックresponseHandler
を呼び出します。
後書き
Unity+Googleスプレッドシート+GASでサーバーレスの簡易データベース機能を作ってみました。当然いくつか問題もあります。
- 完全JSON形式でデータのやりとりをしているため、複雑なデータ構造に対応できない(自分でシリアライザとデシリアライザを書くなどの工夫が要る)。
- 安全性一切考えていない(Google神がいい感じにしてくれるはず)。
- 負荷検証や処理速度を計測・比較していないので不明(使った肌感だと耐えられレベルの遅延)。
- そもそもGoogleのサービスに制限がある。URL Fetch callsだと、無料のGmailアカウントで1日2万回までしか呼べないので、大規模や非常に頻繁な通信に向いていない。
しかしながら個人プロジェクトレベルのものとしては十分機能できるのではないかと思います。何より、Googleスプレッドシートならではの機能が使えて、直接データを一目瞭然で見たり、気軽にデータを修正したりすることができる点から、普通のSQLデータベースよりも便利かもしれません?(?)
おまけ
Umbrellaにランキング機能も付いているので、それの使い方も紹介します。
- Assets/Umbrella/Ranking/RankingManagerプレハブをランキングを表示したいシーンのヒエラルキーに置いておく。
- Assets/Umbrella/Ranking/RankingSettingsスクリタブルオブジェクトのインスペクターから、App URLフィールドにGASのウェブアプリケーションURLをコピーする。
-
Default Ranking Request Dataにデフォルトのランキング取得設定を入力する。
- Ranking Name:ランキングの名前(Googleシートのシート名でもある)。
-
Top Ranking List Settings
- Take Number:上位何位までデータを取得する。ゼロならランキングリストがソートされず、空のリストが返ってくる。
- Order By:昇順(ASC)あるいは降順(DESC)にソートする。
-
Around Me Ranking List Settings
- Take Number:自分の順位の周りの何個データを取得する(自分のデータが出来る限り中心にされる)。ゼロならランキングリストがソートされず、空のリストが返ってくる。
- Order By:昇順(ASC)あるいは降順(DESC)にソートする。
- スコアを送信したい時は、まず
SendScoreRequestData
というオブジェクトを作る必要がある。このオブジェクトでは送信するスコアとランキング取得設定をプロパティとして持つことになっている。ちなみに、RankingManager.Instance.CreateDefaultSendScoreRequest(score)
を呼ぶことで、デフォルトのランキング取得設定でオブジェクトを作ることができる。ランキング取得時も同様で、RankingRequestData
というオブジェクトを先に作っておく必要がある。あとはRankingManager.Instance.SendScoresAsync(requestDataList, responseHandler)
でスコアを送信し、RankingManager.Instance.GetRankingListsAsync(requestDataList, responseHandler)
でランキングリストを取得する。 - うち、
responseHandler
はGAS側からのレスポンスを処理するコールバックで、通信が終わってから呼ばれる。GAS側のレスポンスはRankingResponseData
型のリストに変換され、それの表示処理などは自分で実装できる。 - こちらも
RankingManager.Instance.SendScoresAsync(requestDataList, responseHandler)
やRankingManager.Instance.GetRankingListsAsync(requestDataList, responseHandler)
の前にyield return
を付ければresponseHandler
の実行完了を待つことができる。 - 具体的な使い方はサンプルシーンとスクリプトを参照してください。