Unity+Googleスプレッドシート+GASでサーバーレスのデータベースシステムを実現する？

**2020年7月9日更新：実装変更でメソッド名や使い方の紹介などの修正を行いました。**

# 前書き
unity1weekをきっかけに、Unity+Googleスプレッドシート+GASで簡易ランキング機能を作ってみました。この仕組みで、ランキングだけではなく、任意のデータをアップロード・ダウンロードできれば、一応汎用的なデータベースとして使えるのではないかと思いました。例えばゲームのマスタデータをGoogleスプレッドシートに保存することで、アプリのバージョン更新なしでゲームの調整ができてまうとか、ゲームの最新バージョンをGoogleスプレッドシートに書き込んで、アプリ起動時にそれを取得して古ければ強制アップデートポップアップを出すとか、さらにチャットやユーザー情報の管理もGoogleスプレッドシートでやりとりするなど、ユースケースがどんどん湧いてきます。
ということで、実装してみました。

# 使い方
プロジェクトは[Umbrella](https://github.com/RINNUXEI/Umbrella)（トトロなので）という名でGithubに公開しました。最新パッケージのダウンロードは[こちら](https://github.com/RINNUXEI/Umbrella/releases)。ちなみに、Umbrellaには単純なデータ通信管理システム**Database**以外に、簡易ランキングシステム**Ranking**も含まれています。興味ある方はぜひ合わせていじってみてください。

## GAS側
1. 新しいGoogleスプレッドシートを作成する。
2. メニューの*ツール*から*スクリプト エディタ*をクリックする。
3. *Assets/Umbrella/Database/Database.gs*の内容を*コード.gs*にコピーする。
4. メニューの*ファイル* > *保存*でプロジェクトに名前をつけて保存する（保存にちょっと2、3秒ぐらい掛かるかも）。
5. メニューの*公開* > *ウェブアプリケーションとして導入...*をクリックする。
6. *ウェブアプリケーションとして導入*のポップアップにて、*次のユーザーとしてアプリケーションを実行*に自分のアカウントを、*アプリケーションにアクセスできるユーザー*に*全員（匿名ユーザーを含む）*を設定する。
7. *導入*をクリックして、*現在のウェブアプリケーションのURL*の下に書いてあるURLをコピーする。
8. 「認証が必要です」のポップアップが出たら@zk_phiさんの[記事](https://qiita.com/zk_phi/items/7220b67afa84c58cc160)を参考にして認証を行ってください。

## Unity側
1. *Assets/Umbrella/Database/DatabaseManager*プレハブを通信するシーンのヒエラルキーにドラッグ&ドロップする。
2. *Assets/Umbrella/Database/DatabaseSettings*スクリタブルオブジェクトのインスペクターから、*App URL*のフィールドに先ほどコピーしたGASのウェブアプリケーションURLをペーストする。
3. *Default Sheet Name*フィールドに使いたいGoogleスプレッドシートのデフォルトシート名を入力する。
4. 任意のスクリプトで、データを送信したい場合は`DatabaseManager.Instance.SendDataAsync(data, sheetName)`を呼び、データを取得したい場合は`DatabaseManager.Instance.GetDataAsync(keys, responseHandler, sheetName)`を呼ぶ。`sheetName`を省略した場合は、*Default Sheet Name*が使われる。
5. うち、`responseHandler`はGAS側からのレスポンスを処理するコールバックで、通信が終わってから呼ばれる。GAS側のレスポンスは`object`型のリストに変換され、それの表示処理などは自分で実装できる。
6. また、`DatabaseManager.Instance.SendDataAsync(data, sheetName)`や`DatabaseManager.Instance.GetDataAsync(keys, responseHandler, sheetName)`の前に`yield return`を付ければ`responseHandler`が実行完了するまで待つことができる。
7. 具体的な使い方はサンプルシーンとスクリプトを参照してください。

# デモ
- Googleスプレッドシートにデータを送信します。デモではデータ名と値のペアで複数データを送信しています。
![send_data.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475482/3cb9c4eb-7ebd-1c89-93de-ede3b1bf8e04.gif)

- Googleスプレッドシートにあるデータを更新します。デモではデータ名を指定して新しい値を送信しています。
![update_data.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475482/2b865e71-440b-7959-ee3a-4dae9b2f9265.gif)

- 他のクライアントとしてデータを送信します。Umbrellaではクライアントを識別するユニークIDをUnity側で生成してPlayerPrefsに保存しているため、PlayerPrefsをクリアしないまま送信すると既存のデータを上書きすることになります。
![send_another_data.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475482/87abf450-3975-c1db-98f8-ca7c0bcd0e4b.gif)

- Googleスプレッドシートにあるデータを取得します。デモではデータ名のリストで複数のデータを取得しています。
![get_data.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475482/fabd10ab-7bc4-e898-7a2a-2bd2d4a117de.gif)

- セル参照で範囲内のデータを一気に取得する方法もあります。
![get_data_by_cell.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475482/73e4c9d8-23c0-49f6-f3ba-97c8ed05b283.gif)

# 実装の抜粋

## GAS側の処理
GASはUnityから送ってきたデータに基づき、スプレッドシートの中身を更新します。

```js
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](https://gist.github.com/darktable/1411710)を導入しています。

```csharp
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`メソッドでフォームデータを送信しています。

```csharp
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のサービスに[制限](https://developers.google.com/apps-script/guides/services/quotas)がある。*URL Fetch calls*だと、無料のGmailアカウントで1日2万回までしか呼べないので、大規模や非常に頻繁な通信に向いていない。

しかしながら個人プロジェクトレベルのものとしては十分機能できるのではないかと思います。何より、Googleスプレッドシートならではの機能が使えて、直接データを一目瞭然で見たり、気軽にデータを修正したりすることができる点から、普通のSQLデータベースよりも便利かもしれません？（？）

# おまけ
Umbrellaにランキング機能も付いているので、それの使い方も紹介します。

1. *Assets/Umbrella/Ranking/RankingManager*プレハブをランキングを表示したいシーンのヒエラルキーに置いておく。
2. *Assets/Umbrella/Ranking/RankingSettings*スクリタブルオブジェクトのインスペクターから、*App URL*フィールドにGASのウェブアプリケーションURLをコピーする。
3. *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）にソートする。
4. スコアを送信したい時は、まず`SendScoreRequestData `というオブジェクトを作る必要がある。このオブジェクトでは送信するスコアとランキング取得設定をプロパティとして持つことになっている。ちなみに、`RankingManager.Instance.CreateDefaultSendScoreRequest(score)`を呼ぶことで、デフォルトのランキング取得設定でオブジェクトを作ることができる。ランキング取得時も同様で、`RankingRequestData`というオブジェクトを先に作っておく必要がある。あとは`RankingManager.Instance.SendScoresAsync(requestDataList, responseHandler)`でスコアを送信し、`RankingManager.Instance.GetRankingListsAsync(requestDataList, responseHandler)`でランキングリストを取得する。
5. うち、`responseHandler`はGAS側からのレスポンスを処理するコールバックで、通信が終わってから呼ばれる。GAS側のレスポンスは`RankingResponseData `型のリストに変換され、それの表示処理などは自分で実装できる。
6. こちらも`RankingManager.Instance.SendScoresAsync(requestDataList, responseHandler)`や`RankingManager.Instance.GetRankingListsAsync(requestDataList, responseHandler)`の前に`yield return`を付ければ`responseHandler`の実行完了を待つことができる。
7. 具体的な使い方はサンプルシーンとスクリプトを参照してください。

- Googleスプレッドシートにスコア送信します。
![send_score.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475482/3e3b68e4-6a20-a041-b941-9d15158ef21e.gif)

- Googleスプレッドシートにあるスコアを更新します。
![update_score.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475482/253c5c01-5787-55cf-4e48-a140fdb493f1.gif)

- Googleスプレッドシートのランキングリストを取得します。
![get_ranking.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475482/de1389cf-f7ff-0c3e-fc4a-5cb94119c36d.gif)


# 参考
1. [UnityのWebGL出力に簡単に無料でグローバルランキングを実装できる仕組みを考えてみた](https://qiita.com/divideby_zero/items/50897a2695aa04bfeb69)
2. [GAS で「一部のスコープへのアクセス権限がありません」と怒られたときの対処法](https://qiita.com/zk_phi/items/7220b67afa84c58cc160)