Unity WebGLからGoogle スプレッドシートのデータを取得する

本記事は[QualiArts アドベントカレンダー](https://qiita.com/advent-calendar/2020/qualiarts)15日目の記事になります。

昨日は@yuuyatu090さんの[Google Cloud Storage(GCS)にIP制限をかける with Terraform](https://qiita.com/yuuyatu090/items/13b34a363928243c31cb)でした

# はじめに
ちょっとしたデータの管理にGoogleのスプレッドシートは便利ですよね。
今回はそのスプレッドシートのデータをUnity WebGLから取得する方法を記述します。

スプレッドシートからデータを取るにあたってGoogle APIのOAuth2認証を行なっています。
その際、Google公式のC#ライブラリを使用してみるとどうやら**WebGLでビルドした場合に使えない**ようでしたので（後述）、**JS側のライブラリで認証->アクセストークンをC#側に渡してUnityWebRequestでの取得**を試みたのが本記事になります。

# 環境
* Unity: 2020.1.15f1

#### 必須ライブラリとか
* [JSON .NET For Unity | 入出力管理 | Unity Asset Store](https://assetstore.unity.com/packages/tools/input-management/json-net-for-unity-11347?locale=ja-JP)
	* 公式のものでなく無料アセット（公式はエラーになる）
* https://apis.google.com/js/api.js
	* htmlで記述するので特に落とすとかはない
* Google account
	* 認証とかキー作成で使います

#### 他ライブラリ（個人的に使ってる。無くてもかける）
* [UniRx](https://github.com/neuecc/UniRx/releases)
* [UniTask](https://github.com/Cysharp/UniTask/releases)

# 実装

処理は特に拘らず、 公式のquickstartのコードを組み合わせて実装します。

### 認証キーの作成

認証はブラウザでやるのでJS側のライブラリで行います。
まず認証キーとかを作らないといけないので、公式のQuickstartから「Enable the Google Sheets Api」をクリックし、プロジェクトとSheet用のApiを有効にします。
[Browser Quickstart  |  Sheets API  |  Google Developers](https://developers.google.com/sheets/api/quickstart/js?authuser=2)

<img width="560" alt="enable" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/0aa68c84-8f6c-9d4f-73f9-6b46cbb0b43a.png">
<img width="560" alt="name" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/e1e706b7-952e-7fbc-07e2-fd8f28487287.png">
<img width="560" alt="enable" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/c047150a-f5ba-2e7b-35ee-8184e796b66c.png">
①は後から使います。次のプラットフォーム上からも確認できます。

「Create API Key」を押すともう1個プロジェクトが増えそうなので、Google Cloud Platformプラットフォーム上からAPI Keyを追加します。
[Google Cloud Platform](https://console.cloud.google.com/apis/credentials)
プロジェクトは先ほど作成したQuickStartを選択
<img width="560" alt="プロジェクト" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/00816203-f6ed-8fb9-16d9-2379ae4b8ded.png">

左上の「認証情報を作成」から「APIキー」を選択し
<img width="560" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/48598e8a-1a16-4db1-99d0-521bf14e21f2.png">


作成した後、右下の「キーの制限」から必要分のみの設定を行います
<img width="560" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/8c8f0cee-ed62-115e-4aff-518f405f48e8.png">


WebGLからのアクセスを想定してますので「HTTP リファラー」を選択し、ローカル確認ように「localhost:8000」を追加します。（実際に使う際は自分のサイトのアドレスを追加しましょう。
<img width="560" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/2ac9c34f-3f16-d1d9-b494-bffb2f083219.png">

APIの制限では、今回スプレッドシートからのデータ取得のみなので「Google Sheets API」を選択します。
<img width="560" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/bade4c28-fece-666f-921e-e24a31a40261.png">

保存を押したらAPIキー作成完了です。

## WebGLテンプレートの作成
今回C#とJSを記述する必要があるので、生成されるindex.htmlに適当にJavaScriptを書き加えてます
（本当はちゃんとした書き方をした方がいいのですが、記事主の時間的都合から雑に書いてます。綺麗な書き方誰か教えてください🙇‍♂️

テンプレートについての説明は省略します
[Using WebGL templates - Unity マニュアル](https://docs.unity3d.com/ja/2020.1/Manual/webgl-templates.html)

まずは適当にビルド設定してWebGLビルドを行い、基準となるindex.htmlを作成します。
WebGLプラットフォームで2Dプラットフォームとかで作ったプロジェクトを想定しています。

*  Edit -> PlayerSettings -> WebGL 
  * Other Settings -> Api Compatibility Level を「.Net 4.x」に
      * UniRxとか使わないなら実はいらないかも
  * Publishing Settings -> Decompression FallbackをチェックON
      * いつからかCompression Formatがデフォルト「Gzip」になってるので、ローカル実行でエラーがでないようにチェックが必要です。

<img width="320" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/2bab85ba-f404-cee5-7744-4b78762f2593.png"> <img width="320" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/2eb5ed68-b7b3-a6ec-258d-c05b52b23d12.png">

設定が完了したら一度ビルドします。
ビルドが出来たら、生成されたindex.htmlをテンプレートに使います
[WebGL テンプレートの使用 - Unity マニュアル](https://docs.unity3d.com/ja/2020.1/Manual/webgl-templates.html)

UnityプロジェクトのAssets配下に「WebGLTemplates」フォルダを作成し、その中に好きな名前のフォルダを作成、最後にその中に生成されたindex.htmlを入れます
今回はSampleGoogleSheetという名前で作ってます
<img width="320" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/d6233021-1b6a-f377-f3b2-59bb55490f4e.png">

これでPlayerSettingsのResolution and Presentationから選択出来るようになりました
（画像は準備してないのでアレですが
<img width="320" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/b92a0a81-82b0-4e65-ac53-c7e71bdf740c.png">


## JS記述
認証キー作成で使用した[quickstart](https://developers.google.com/sheets/api/quickstart/js?authuser=2)のサンプルの記述をいくつかテンプレートのindex.htmlに移します。

```html:index.html
    // ~~ 上省略　</script>の手前に追記します 
    document.body.appendChild(script);

    // ここから追記

    // 認証キーたち
    var CLIENT_ID = '<YOUR_CLIENT_ID>';   // ①
    var API_KEY = '<YOUR_API_KEY>';     // ②

    // サンプルからそのまま
    var DISCOVERY_DOCS = ["https://sheets.googleapis.com/$discovery/rest?version=v4"];
    var SCOPES = "https://www.googleapis.com/auth/spreadsheets.readonly";

    /**
     *  ライブラリ読み込み時に呼ばれる
     */
    function handleClientLoad() {
        gapi.load('client:auth2', initClient);
    }

    /**
     *  APIライブラリの初期化
     */
    function initClient() {
        gapi.client.init({
            apiKey: API_KEY,
            clientId: CLIENT_ID,
            discoveryDocs: DISCOVERY_DOCS,
            scope: SCOPES
        }).then(function () {
            // Listen for sign-in state changes.
            gapi.auth2.getAuthInstance().isSignedIn.listen(updateSigninStatus);

            // Handle the initial sign-in state.
            updateSigninStatus(gapi.auth2.getAuthInstance().isSignedIn.get());
        }, function (error) {
            console.log(error);
        });
    }

    /**
     *  認証成功し、ログイン状態が変わった時に呼ばれる
     */
    function updateSigninStatus(isSignedIn) {
        if (isSignedIn) {
            // C#を呼び出すコードを書く
        } else {
            // C#を呼び出すコードを書く      
        }
    }

    /**
     *  ログイン状態フラグ C#から呼ぶ（サンプルでは使わない
     */
    function isSignedIn() {
        return gapi.auth2.getAuthInstance().isSignedIn.get();
    }

    /**
     *  認証 C#側から呼ぶ
     */
    function auth() {
        gapi.auth2.getAuthInstance().signIn();
    }

    /**
     *  サインアウト C#側から呼ぶ
     */
    function signOut() {
        gapi.auth2.getAuthInstance().signOut();
    }

</script>

<!-- // ライブラリの読み込み処理 -->
<script async defer src="https://apis.google.com/js/api.js"
        onload="this.onload=function(){};handleClientLoad()"
        onreadystatechange="if (this.readyState === 'complete') this.onload()">
<!-- // 追記終わり -->
</script>
</body>
</html>
```

## C#からJSを呼べるように
C#からJSの関数を呼べるようにjslibを作成します。
以下のサイトが参考になると思います
公式：[WebGL: ブラウザースクリプトとの相互作用 - Unity マニュアル](https://docs.unity3d.com/ja/2020.1/Manual/webgl-interactingwithbrowserscripting.html)
参考サイト：[Unity(WebGL)のC#とブラウザのJavaScriptの連携｜npaka｜note](https://note.com/npaka/n/nca1e35a649e3)

生成したjslibはPluginsフォルダ配下におきましょう
本記事では Assets > Plugins > Jslib > GoogleApi.jslib として作りました

```js:GoogleApi.jslib
mergeInto(LibraryManager.library, {
    IsGoogleSignedIn: function () {
        return isSignedIn();
    },
    GoogleAuth: function () {
        auth();
    },
    GoogleSignOut: function () {
        signOut();
    }
});
```

Jslibと連携したC#のコードも作ります

```c#:GoogleSheetManager
using System.Runtime.InteropServices;
using UnityEngine;
 
namespace AdventCalendar2020
{
    public class GoogleSheetManager : MonoBehaviour
    {
#if UNITY_WEBGL
        /// <summary>
        /// jslibと名前を合わせる
        /// </summary>
        [DllImport("__Internal")]
        private static extern bool IsGoogleSignedIn();

        [DllImport("__Internal")]
        private static extern void GoogleAuth();

        [DllImport("__Internal")]
        private static extern void GoogleSignOut();
#endif
        /// <summary>
        /// google認証
        /// </summary>
        public void Auth()
        {
#if UNITY_WEBGL && !UNITY_EDITOR
            GoogleAuth();
#endif
        }

        /// <summary>
        /// ログアウト
        /// </summary>
        public void SignOut()
        {
#if UNITY_WEBGL && !UNITY_EDITOR
            GoogleSignOut();
#endif
        }
    }
}
```

## JSからC#を呼べるように
### Unity側
JS側で認証成功時にアクセストークンを送ってこれるように処理を追加します
今回はトークンの更新をReactivePropertyで通知できるようにしました
UniRxライブラリを導入：[Releases · neuecc/UniRx · GitHub](https://github.com/neuecc/UniRx/releases)

GoogleSheetManagerはScene上に同じ名前のオブジェクトを作り、アタッチしましょう。

```c#:GoogleSheetManager
// ~~ 
using UniRx;

namespace AdventCalendar2020
{
    public class GoogleSheetManager : MonoBehaviour
    {
        // ~~ 省略 ~~

        /// <summary>
        /// JS側から渡されるアクセストークン
        /// </summary>
        private string _accessToken;

        private readonly ReactiveProperty<bool> _isSignedInProperty = new ReactiveProperty<bool>();
        public IReactiveProperty<bool> IsSignedInProperty => _isSignedInProperty;
        public bool IsSignedIn => IsSignedInProperty.Value;

        // ~~ 省略 ~~
        private void OnDestroy()
        {
            _isSignedInProperty.Dispose();
        }

        #region From JavaScript

        public void SetAccessToken(string token)
        {
            _accessToken = token;
            IsSignedInProperty.Value = string.IsNullOrEmpty(_accessToken) == false;
        }

        #endregion
    }
}
```
<img width="560" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/bb0bc9a6-aafb-e3b0-ef3b-a0e28396b8b2.png">

### index.html側

```html:index.html
// ~~ 省略 ~~
// gapiで使用できるように
var globalUnityInstance; // 追記

var script = document.createElement("script");
script.src = loaderUrl;
script.onload = () => {
    createUnityInstance(canvas, config, (progress) => {
        progressBarFull.style.width = 100 * progress + "%";
    }).then((unityInstance) => {
        globalUnityInstance = unityInstance; // 追記

        loadingBar.style.display = "none";
        fullscreenButton.onclick = () => {
            unityInstance.SetFullscreen(1);
        };
    }).catch((message) => {
        alert(message);
    });
};
document.body.appendChild(script);

// ~~ 省略 ~~

/**
 *  認証成功し、ログイン状態が変わった時に呼ばれる
 */
function updateSigninStatus(isSignedIn) {
    // gapi初期化時にUnityInstanceがなければ止める
    if (!globalUnityInstance) return; // 追記
    
    if (isSignedIn) {
        // 追記
        // サインインしてる場合はアクセストークンを渡す
        var user = gapi.auth2.getAuthInstance().currentUser.get();
        var accessToken = user.getAuthResponse().access_token;

        // GoogleSheetManagerのSetAccessTokenを呼ぶ
        globalUnityInstance.SendMessage("GoogleSheetManager", "SetAccessToken", accessToken);
    } else {
        // 追記
        // してない場合は空文字
        console.log("未認証");

        globalUnityInstance.SendMessage("GoogleSheetManager", "SetAccessToken", "");
    }
}
```

## スプレッドシートデータの取得
お世話になってる[quickstart](https://developers.google.com/sheets/api/quickstart/js?authuser=2) にあるスプシデータの読み込みを参考にc#側に記述します。
使用するAPIはこちら：[Method: spreadsheets.values.get  |  Sheets API  |  Google Developers](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/get)

`https://sheets.googleapis.com/v4/spreadsheets`
の後ろに
`/{スプレッドシートID}/values/{範囲}`
でそのスプシの範囲のデータが配列で返ってきます。
最後にクエリで
`?access_token={取得してきたアクセストークン}`
を追加してプライベートなスプシでも取ってこれるようにします。

また、返ってくるJsonデータですが、[公式のJson.NET](https://www.newtonsoft.com/json) だとWebGL上でエラーが出たのでUnity用の無料のアセットを使います
[JSON .NET For Unity](https://assetstore.unity.com/packages/tools/input-management/json-net-for-unity-11347?locale=ja-JP#content)
Package Managerからインポートできます


また、本記事では個人的にリクエストの待機にUniTaskを使用してます（なくても書けます
UniTaskライブラリ：[Releases · Cysharp/UniTask · GitHub](https://github.com/Cysharp/UniTask/releases)

```c#:GoogleSheetManager
public class GoogleSheetManager : MonoBehaviour
{
    /// <summary>
    /// Jsonの変換用クラス
    /// </summary>
    public class ValueRange
    {
        public string range;

        public string majorDimension;

        public string[][] values;
    }

    private const string GoogleSpreadsheetURL = "https://sheets.googleapis.com/v4/spreadsheets";

    // ~~ 省略 ~~ // 

    /// <summary>
    /// スプレッドシートのレンジを取得
    /// </summary>
    /// <param name="spreadsheetId">スプレッドシートのユニークID</param>
    /// <param name="range">レンジ Ex: "シート１!A1:B2" はシート１のA1からB2までという意味</param>
    public async UniTask<ValueRange> GetSpreadsheetAsync(string spreadsheetId, string range, CancellationToken ct)
    {
        var url = $"{GoogleSpreadsheetURL}/{spreadsheetId}/values/{range}";
        try
        {
            var responseText = await SendGetRequestAsync(url, ct);
            Debug.Log(responseText);
            return JsonConvert.DeserializeObject<ValueRange>(responseText);
        }
        catch (UnauthorizedAccessException)
        {
            Debug.Log("未認証");
            return null;
        }
        catch
        {
            throw;
        }
    }

    /// <summary>
    /// UnityWebRequest.Getで送信する
    /// </summary>
    private async UniTask<string> SendGetRequestAsync(string url, CancellationToken ct)
    {
        var finalUrl = AppendAccessTokenToURL(url, _accessToken);
        using (var uwr = UnityWebRequest.Get(finalUrl))
        {
            await uwr.SendWebRequest().WithCancellation(ct);

            if (uwr.error != null)
            {
                var errorDefinition = new
                {
                    error = new
                    {
                        status = ""
                    }
                };
                var errorData = JsonConvert.DeserializeAnonymousType(uwr.downloadHandler.text, errorDefinition);
                // 未認証エラー
                if (errorData.error.status == "UNAUTHENTICATED") throw new UnauthorizedAccessException();
                // その他エラー
                throw new Exception(uwr.downloadHandler.text);
            }

            return uwr.downloadHandler.text;
        }
    }

    /// <summary>
    /// URLの最後にアクセストークンクエリを追加
    /// </summary>
    private string AppendAccessTokenToURL(string url, string token)
    {
        var delimiter = url.Contains("?") ? "&" : "?";
        return $"{url}{delimiter}access_token={token}";
    }

    private void OnDestroy()
    {
        _isSignedInProperty.Dispose();
    }

    #region From JavaScript

    public void SetAccessToken(string token)
    {
        _accessToken = token;
        IsSignedInProperty.Value = string.IsNullOrEmpty(_accessToken) == false;
    }

    #endregion
}

```

## 実行用クラスの作成と画面
最後に認証orサインインボタンとゲットボタンを作ってコードを呼びます
### コード

```C#:SampleGoogleSheet.cs
using System.Threading;
using Cysharp.Threading.Tasks;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace AdventCalendar2020
{
    public class SampleGoogleSheet : MonoBehaviour
    {
        [SerializeField]
        private GoogleSheetManager _sheetManager;

        [SerializeField]
        private Button _authButton;

        [SerializeField]
        private Button _singOutButton;

        [SerializeField]
        private Button _getDataButton;

        private void Start()
        {
            // 最初は何も表示しない
            _authButton.gameObject.SetActive(false);
            _singOutButton.gameObject.SetActive(false);
            _getDataButton.gameObject.SetActive(false);
            Bind();
        }

        private void Bind()
        {
            // サインイン状態が更新されたら検知
            _sheetManager.IsSignedInProperty
                .Subscribe(UpdateButtonActive) // isSigned => UpdateButtonActive(isSigned);
                .AddTo(gameObject);
        }

        /// <summary>
        /// サインイン状態に応じてボタンの表示切り替え
        /// </summary>
        private void UpdateButtonActive(bool isSigned)
        {
            _authButton.gameObject.SetActive(isSigned == false);
            _singOutButton.gameObject.SetActive(isSigned);
            _getDataButton.gameObject.SetActive(isSigned);
        }

        /// <summary>
        /// AuthButtonのEventから呼ぶ
        /// </summary>
        public void GoogleAuth() => _sheetManager.Auth();

        /// <summary>
        /// SignOutButtonのEventから呼ぶ
        /// </summary>
        public void GoogleSignOut() => _sheetManager.SignOut();

        /// <summary>
        /// GetDataButtonのEventから呼ぶ
        /// </summary>
        public void GetSheetData() => GetSheetDataAsync(gameObject.GetCancellationTokenOnDestroy()).Forget();

        /// <summary>
        /// スプシのデータをとりコンソールに出力
        /// </summary>
        private async UniTask GetSheetDataAsync(CancellationToken ct)
        {
            if (_sheetManager.IsSignedIn == false) return;

            // プライベートなスプシIDでも可能（認証した際のアカウントが見れるなら取れる
            // 今回はサンプルそのままを書きます
            // https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit
            var spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms";
            var range = "Class Data!A2:E";

            var data = await _sheetManager.GetSpreadsheetAsync(spreadsheetId, range, ct);
            if (data != null && data.values.Length > 0)
            {
                foreach (var row in data.values)
                {
                    Debug.Log($"{row[0]}, {row[4]}");
                }
            }
            else
            {
                Debug.Log("No data found.");
            }
        }
    }
}
```

### Editor上
<img width="320" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/97ec4d81-1cc1-186d-49ca-2f98429b8fe4.png"> <img width="320" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/90060a94-1d98-ba94-2026-0cc5822282d1.png"> <img width="320" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/5e1fffb0-bb69-4a7f-9dd6-c5da6e5846a7.png"> 

<img height="320" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/2a1d2e9f-4d21-b64a-29bd-829f8ed4ae6f.png"> <img height="320" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/2b3e4060-96f0-2e89-f6c6-14c43b43f1e1.png">
CanvasScalerはScale With Screen Sizeで x: 960 y: 600
AuthとSingOutは適当にy:120に、GetDataはy:-120です。
ボタンサイズはx: 320 y: 80

### 実行
ビルドして実行し、動作を確認します
この時、Build And Runをするとポート番号がランダムになるので認証する際に失敗します。
そのため、ビルド成果物のindex.htmlのある階層でコンソールからPythonを使って8000ポートのローカルサーバーを立てて実行しましょう
`$ python -m SimpleHTTPServer 8000`
ブラウザを開き localhost:8000 にアクセスすると開けます

Authボタンを押すと認証用ブラウザが開きます
<img width="560" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/25b2797c-9d71-e594-c31c-e830b1c0318d.png">

認証後、GetDataボタンでログが流れていたら成功です。
![GetSheetData.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/bd55f1ac-7cee-52dc-364d-3b7454206a0f.gif)

お疲れ様でした。

# その他
### 公式ライブラリを使えなかった理由
1. Google公式ライブラリだと内部でGoogle認証時のリダイレクト用ローカルサーバーを立てようとするため、ブラウザのWebGLだと失敗するようです。
2. Jsonの変換で失敗します（多次元配列がダメっぽい）。エディタ上だと実行できるので、WebGLだけ何かコンパイル時に変わってるのかもしれません（詳しくは調べてない）

### 最終コード

#### Unity

```c#:SampleGoogleSheet
using System.Threading;
using Cysharp.Threading.Tasks;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace AdventCalendar2020
{
    public class SampleGoogleSheet : MonoBehaviour
    {
        [SerializeField]
        private GoogleSheetManager _sheetManager;

        [SerializeField]
        private Button _authButton;

        [SerializeField]
        private Button _singOutButton;

        [SerializeField]
        private Button _getDataButton;

        private void Start()
        {
            // 最初は何も表示しない
            _authButton.gameObject.SetActive(false);
            _singOutButton.gameObject.SetActive(false);
            _getDataButton.gameObject.SetActive(false);
            Bind();
        }

        private void Bind()
        {
            // サインイン状態が更新されたら検知
            _sheetManager.IsSignedInProperty
                .Subscribe(UpdateButtonActive) // isSigned => UpdateButtonActive(isSigned);
                .AddTo(gameObject);
        }

        /// <summary>
        /// サインイン状態に応じてボタンの表示切り替え
        /// </summary>
        private void UpdateButtonActive(bool isSigned)
        {
            _authButton.gameObject.SetActive(isSigned == false);
            _singOutButton.gameObject.SetActive(isSigned);
            _getDataButton.gameObject.SetActive(isSigned);
        }

        /// <summary>
        /// AuthButtonのEventから呼ぶ
        /// </summary>
        public void GoogleAuth() => _sheetManager.Auth();

        /// <summary>
        /// SignOutButtonのEventから呼ぶ
        /// </summary>
        public void GoogleSignOut() => _sheetManager.SignOut();

        /// <summary>
        /// GetDataButtonのEventから呼ぶ
        /// </summary>
        public void GetSheetData() => GetSheetDataAsync(gameObject.GetCancellationTokenOnDestroy()).Forget();

        /// <summary>
        /// スプシのデータをとりコンソールに出力
        /// </summary>
        private async UniTask GetSheetDataAsync(CancellationToken ct)
        {
            if (_sheetManager.IsSignedIn == false) return;

            // プライベートなスプシIDでも可能（認証した際のアカウントが見れるなら取れる
            // 今回はサンプルそのままを書きます
            // https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit
            var spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms";
            var range = "Class Data!A2:E";

            var data = await _sheetManager.GetSpreadsheetAsync(spreadsheetId, range, ct);
            if (data != null && data.values.Length > 0)
            {
                foreach (var row in data.values)
                {
                    Debug.Log($"{row[0]}, {row[4]}");
                }
            }
            else
            {
                Debug.Log("No data found.");
            }
        }
    }
}
```

```c#:GoogleSheetManager
using System;
using System.Runtime.InteropServices;
using System.Threading;
using Cysharp.Threading.Tasks;
using Newtonsoft.Json;
using UniRx;
using UnityEngine;
using UnityEngine.Networking;

namespace AdventCalendar2020
{
    public class GoogleSheetManager : MonoBehaviour
    {
        /// <summary>
        /// Jsonの変換用クラス
        /// </summary>
        public class ValueRange
        {
            public string range;

            public string majorDimension;

            public string[][] values;
        }

        private const string GoogleSpreadsheetURL = "https://sheets.googleapis.com/v4/spreadsheets";

#if UNITY_WEBGL
        /// <summary>
        /// jslibと名前を合わせる
        /// </summary>
        [DllImport("__Internal")]
        private static extern bool IsGoogleSignedIn();

        [DllImport("__Internal")]
        private static extern void GoogleAuth();

        [DllImport("__Internal")]
        private static extern void GoogleSignOut();
#endif

        /// <summary>
        /// JS側から渡されるアクセストークン
        /// </summary>
        private string _accessToken;

        private readonly ReactiveProperty<bool> _isSignedInProperty = new ReactiveProperty<bool>();
        public IReactiveProperty<bool> IsSignedInProperty => _isSignedInProperty;
        public bool IsSignedIn => IsSignedInProperty.Value;

        /// <summary>
        /// google認証
        /// </summary>
        public void Auth()
        {
#if UNITY_WEBGL && !UNITY_EDITOR
            GoogleAuth();
#endif
        }

        /// <summary>
        /// ログアウト
        /// </summary>
        public void SignOut()
        {
#if UNITY_WEBGL && !UNITY_EDITOR
            GoogleSignOut();
#endif
        }

        /// <summary>
        /// スプレッドシートのレンジを取得
        /// </summary>
        /// <param name="spreadsheetId">スプレッドシートのユニークID</param>
        /// <param name="range">レンジ Ex: "シート１!A1:B2" はシート１のA1からB2までという意味</param>
        public async UniTask<ValueRange> GetSpreadsheetAsync(string spreadsheetId, string range, CancellationToken ct)
        {
            var url = $"{GoogleSpreadsheetURL}/{spreadsheetId}/values/{range}";
            try
            {
                var responseText = await SendGetRequestAsync(url, ct);
                Debug.Log(responseText);
                return JsonConvert.DeserializeObject<ValueRange>(responseText);
            }
            catch (UnauthorizedAccessException)
            {
                Debug.Log("未認証");
                return null;
            }
            catch
            {
                throw;
            }
        }

        /// <summary>
        /// UnityWebRequest.Getで送信する
        /// </summary>
        private async UniTask<string> SendGetRequestAsync(string url, CancellationToken ct)
        {
            var finalUrl = AppendAccessTokenToURL(url, _accessToken);
            using (var uwr = UnityWebRequest.Get(finalUrl))
            {
                await uwr.SendWebRequest().WithCancellation(ct);

                if (uwr.error != null)
                {
                    var errorDefinition = new
                    {
                        error = new
                        {
                            status = ""
                        }
                    };
                    var errorData = JsonConvert.DeserializeAnonymousType(uwr.downloadHandler.text, errorDefinition);
                    // 未認証エラー
                    if (errorData.error.status == "UNAUTHENTICATED") throw new UnauthorizedAccessException();
                    // その他エラー
                    throw new Exception(uwr.downloadHandler.text);
                }

                return uwr.downloadHandler.text;
            }
        }

        /// <summary>
        /// URLの最後にクエリを追加
        /// </summary>
        private string AppendAccessTokenToURL(string url, string token)
        {
            var delimiter = url.Contains("?") ? "&" : "?";
            return $"{url}{delimiter}access_token={token}";
        }

        private void OnDestroy()
        {
            _isSignedInProperty.Dispose();
        }

        #region From JavaScript

        public void SetAccessToken(string token)
        {
            _accessToken = token;
            IsSignedInProperty.Value = string.IsNullOrEmpty(_accessToken) == false;
        }

        #endregion
    }
}
```

#### jslib

```js:GoogleApi.jslib
mergeInto(LibraryManager.library, {
    IsGoogleSignedIn: function () {
        return isSignedIn();
    },
    GoogleAuth: function () {
        auth();
    },
    GoogleSignOut: function () {
        signOut();
    }
});
```

#### html, js

```html:index.html
<!DOCTYPE html>
<html lang="en-us">
<head>
    <meta charset="utf-8">
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>Unity WebGL Player | advent-calendar2020</title>
    <link rel="shortcut icon" href="TemplateData/favicon.ico">
    <link rel="stylesheet" href="TemplateData/style.css">
</head>
<body>
<div id="unity-container" class="unity-desktop">
    <canvas id="unity-canvas"></canvas>
    <div id="unity-loading-bar">
        <div id="unity-logo"></div>
        <div id="unity-progress-bar-empty">
            <div id="unity-progress-bar-full"></div>
        </div>
    </div>
    <div id="unity-footer">
        <div id="unity-webgl-logo"></div>
        <div id="unity-fullscreen-button"></div>
        <div id="unity-build-title">advent-calendar2020</div>
    </div>
</div>
<script>
    var buildUrl = "Build";
    var loaderUrl = buildUrl + "/WebGL.loader.js";
    var config = {
        dataUrl: buildUrl + "/WebGL.data.unityweb",
        frameworkUrl: buildUrl + "/WebGL.framework.js.unityweb",
        codeUrl: buildUrl + "/WebGL.wasm.unityweb",
        streamingAssetsUrl: "StreamingAssets",
        companyName: "DefaultCompany",
        productName: "advent-calendar2020",
        productVersion: "1.0",
    };

    var container = document.querySelector("#unity-container");
    var canvas = document.querySelector("#unity-canvas");
    var loadingBar = document.querySelector("#unity-loading-bar");
    var progressBarFull = document.querySelector("#unity-progress-bar-full");
    var fullscreenButton = document.querySelector("#unity-fullscreen-button");

    if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
        container.className = "unity-mobile";
        config.devicePixelRatio = 1;
    } else {
        canvas.style.width = "960px";
        canvas.style.height = "600px";
    }
    loadingBar.style.display = "block";

    // gapiで使用できるように
    var globalUnityInstance;

    var script = document.createElement("script");
    script.src = loaderUrl;
    script.onload = () => {
        createUnityInstance(canvas, config, (progress) => {
            progressBarFull.style.width = 100 * progress + "%";
        }).then((unityInstance) => {
            globalUnityInstance = unityInstance;
            loadingBar.style.display = "none";
            fullscreenButton.onclick = () => {
                unityInstance.SetFullscreen(1);
            };
        }).catch((message) => {
            alert(message);
        });
    };
    document.body.appendChild(script);


    // 認証キーたち
    var CLIENT_ID = '<YOUR_CLIENT_ID>';   // ①
    var API_KEY = '<YOUR_API_KEY>';     // ②

    // サンプルからそのまま
    var DISCOVERY_DOCS = ["https://sheets.googleapis.com/$discovery/rest?version=v4"];
    var SCOPES = "https://www.googleapis.com/auth/spreadsheets.readonly";

    /**
     *  ライブラリ読み込み時に呼ばれる
     */
    function handleClientLoad() {
        gapi.load('client:auth2', initClient);
    }

    /**
     *  APIライブラリの初期化
     */
    function initClient() {
        gapi.client.init({
            apiKey: API_KEY,
            clientId: CLIENT_ID,
            discoveryDocs: DISCOVERY_DOCS,
            scope: SCOPES
        }).then(function () {
            // Listen for sign-in state changes.
            gapi.auth2.getAuthInstance().isSignedIn.listen(updateSigninStatus);

            // Handle the initial sign-in state.
            updateSigninStatus(gapi.auth2.getAuthInstance().isSignedIn.get());
        }, function (error) {
            console.log(error);
        });
    }

    /**
     *  認証成功し、ログイン状態が変わった時に呼ばれる
     */
    function updateSigninStatus(isSignedIn) {
        // gapi初期化時にUnityInstanceがなければ止める
        if (!globalUnityInstance) return;
        
        if (isSignedIn) {
            // サインインしてる場合はアクセストークンを渡す
            var user = gapi.auth2.getAuthInstance().currentUser.get();
            var accessToken = user.getAuthResponse().access_token;

            globalUnityInstance.SendMessage("GoogleSheetManager", "SetAccessToken", accessToken);
        } else {
            // してない場合は空文字
            console.log("未認証");
            globalUnityInstance.SendMessage("GoogleSheetManager", "SetAccessToken", "");
        }
    }

    /**
     *  ログイン状態フラグ C#から呼ぶ（サンプルでは使わない
     */
    function isSignedIn() {
        return gapi.auth2.getAuthInstance().isSignedIn.get();
    }

    /**
     *  認証 C#側から呼ぶ
     */
    function auth() {
        gapi.auth2.getAuthInstance().signIn();
    }

    /**
     *  サインアウト C#側から呼ぶ
     */
    function signOut() {
        gapi.auth2.getAuthInstance().signOut();
    }

</script>

<!-- // ライブラリの読み込み処理 -->
<script async defer src="https://apis.google.com/js/api.js"
        onload="this.onload=function(){};handleClientLoad()"
        onreadystatechange="if (this.readyState === 'complete') this.onload()">
</script>

</body>
</html>
```

# まとめ
UnityEditorは公式のライブラリを使用することで比較的楽に取得できましたが、WebGLだといろいろな箇所でエラーが発生して結構ドツボにはまりました。。
予想より時間がかかり、担当日ももう少し後だと思ってたのでちょっと説明不足なところもあるかもです:bow:

応用すればスプシに限らずドライブのファイルや画像なども取ってこれるようになるので何かと便利になると思います。
少しでも参考になりましたら幸いです。

明日は@mitsumitsu1128さんです！

# 参考サイト様
[【Unity】UnityエディタでGoogle APIのOAuth2認証をする（ライブラリを使う版）](https://light11.hatenadiary.com/entry/2020/05/07/210800
)
[【Unity】UnityエディタでGoogle APIのOAuth2認証をする（ライブラリ使わない版）](https://light11.hatenadiary.com/entry/2020/05/05/183325)
[JSON .NET For Unity](https://assetstore.unity.com/packages/tools/input-management/json-net-for-unity-11347?locale=ja-JP#content)
[Unity(WebGL)のC#とブラウザのJavaScriptの連携](https://note.com/npaka/n/nca1e35a649e3)