GoogleSpreadSheet用のモデルクラスを自動生成するエディタ拡張を作った

# はじめに

[グレンジ AdventCalendar](https://qiita.com/advent-calendar/2022/grenge) 20日目担当のzonuです
業務ではUnityを利用してモックを開発をしています。

モック開発では、以下のような理由からGoogleSpreadSheetをマスタデータとして利用しています。

- マスタデータ編集のための環境構築が(ほぼ)不要
- マスタデータの変更をアプリに反映するためのビルドやデプロイなどの手間が省ける
- [さまざまな関数](https://support.google.com/docs/table/25273?hl=ja)を利用可能

その上で、毎回モデルクラスのコーディングをするのが面倒だったので、それを自動生成するエディタ拡張を作りました。
本記事ではその使い方と実装について紹介します。

# なにができるのか

このシートから、
![スクリーンショット 2022-12-18 15.02.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2269069/cfe90072-2982-0115-0347-7f06ec5f94d0.png)

このようなクラスを自動生成。
```StudentProfileMaster.cs
using SpreadSheetMaster;

namespace Sample
{
	public partial class StudentProfileMaster : ImportableSpreadSheetMasterBase<StudentProfileMasterData>
	{
		public override string spreadSheetId => "1IIr1UEyKDNy0toLjYC1KzBwpgLwvzoZTNvjSDd7wdk4";
		public override string sheetName => "student_profile";
	}
}
```

```StudentProfileMasterData.cs
using SpreadSheetMaster;
using System.Collections.Generic;

namespace Sample
{
	public partial class StudentProfileMasterData : ImportableSpreadSheetMasterDataBase
	{
		private const int COLUMN_ID = 0;
		private const int COLUMN_NAME = 1;
		private const int COLUMN_SCHOOL_ID = 3;
		private const int COLUMN_SCHOOL_GRADE = 5;
		private const int COLUMN_HEIGHT = 6;
		private const int COLUMN_IS_ONLY_CHILD = 7;

		public int id { get; private set; }
		public string name { get; private set; }
		public int schoolId { get; private set; }
		public int schoolGrade { get; private set; }
		public float height { get; private set; }
		public bool isOnlyChild { get; private set; }

		public override int GetId()
		{
			return id;
		}
		public override void SetData(IReadOnlyList<string> record)
		{
			id = GetInt(record, COLUMN_ID);
			name = GetString(record, COLUMN_NAME);
			schoolId = GetInt(record, COLUMN_SCHOOL_ID);
			schoolGrade = GetInt(record, COLUMN_SCHOOL_GRADE);
			height = GetFloat(record, COLUMN_HEIGHT);
			isOnlyChild = GetBool(record, COLUMN_IS_ONLY_CHILD);
		}
	}
}
```

マスタ読み込み
```
yield return _importer.ImportFromSpreadSheetAsync(sampleMaster, (error) => {/* エラー時処理 */});
```

マスタデータ取得
```
int id = 1;
SampleMasterData data = sampleMaster.GetData(id);
```

# 動作環境

- mac OS Monterey (バージョン 12.6)
- Unity 2021.3.12f1

# 使い方

## 1. スプレッドシートでcsvを作成

- 不要な行, 列は削除する
- シート名(=マスタ名)はスネークケースとする。(生成されるスクリプトではアッパーキャメルに変換)
- カラム名はスネークケースとする。(生成されるスクリプトではローワーキャメルに変換)
- シートの1行目にはカラム名を入力する。
    - 出力しないカラムは `#`{カラム名} としておく
- シートの2行目以降にデータを入力する。(仮でもOK)
    - 入力内容に応じて、カラムのデータ型を自動で判別する。 (例: 1.5 -> float, TRUE -> bool など)
- シートの1列目は `id` カラム(一意の数値)とする。
- スプレッドシートは公開設定にする。
    - 右上の「共有 > 一般的なアクセス」を「リンクを知ってる全員」に変更。 ※ 機密情報などは含めないよう注意


![スクリーンショット 2022-12-18 15.02.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2269069/cfe90072-2982-0115-0347-7f06ec5f94d0.png)

[参考](https://docs.google.com/spreadsheets/d/1IIr1UEyKDNy0toLjYC1KzBwpgLwvzoZTNvjSDd7wdk4/edit#gid=265902471) ※ ↑のスプシが開きます

## 2. `Spread Sheet Master Generator` でマスタクラスのスクリプトを生成

- [ここ](https://github.com/ducchy/spread-sheet-master/releases/tag/v1.0.0)からUnityPackageをダウンロードし、プロジェクトにimport。
- 公式パッケージである `EditorCoroutine` を利用しているため、PackgeManagerから追加する。
- Window > Spread Sheet Master Generator からウィンドウを開く。
- 「スプレッドシートID」「シート名」を入力し、「ダウンロード」。
    - スプレッドシートID:  スプシのURLの `https://docs.google.com/spreadsheets/d/{スプレッドシートID}/edit#gid=xxxxxxxxx` の部分
- ダウンロードに成功すると、マスタ構成情報が表示されるので確認し、必要に応じて修正する。
    - マスタ名: クラス名、スクリプト名に使用する名前。(シート名のアッパーキャメルケース)+"Master"が自動で入力される。
    - 使用: OFFにするとスクリプトに書き出されなくなる。
        - カラム名に`#`をつけると読み込まれないカラムと判定するので、使わないカラムにはあらかじめつけておくと楽。
    - カラム名: メンバプロパティ名に使用する名前。カラム名のローワーキャメルケースが自動で入力される。
    - データ型: シート2行目に入力されたデータを元に型を判別する。Enumを数値で入力している場合は `Enum` を指定し、「Enum名」を指定する。
    - Enum名: データ型に `Enum` を指定した場合に入力。Enum名(※名前空間含む)を入力して「適用」を押す。
- 「出力先フォルダ」「名前空間」(任意) を入力し、「生成」。
    - 出力先フォルダ: `Assets/` 以下を指定すること。
    - 名前空間: マスタクラスを定義する名前空間を入力。
    - 存在しないフォルダを出力先に指定した場合、出力時にフォルダを生成。
    - すでに同じパスのファイルが存在する場合は上書き。

![スクリーンショット 2022-12-18 15.34.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2269069/5aca575c-884c-ab3e-73f3-f678321ed248.png)

## 3. csvを読み込むためのコードを書く

### 読み込み、再読み込み

以下のようにしてスプシからマスタデータを読み込みます。

```MasterManager.cs
private SpreadSheetMasterImporter _importer = new SpreadSheetMasterImporter();
public SampleMaster sampleMaster { get; private set; } = new SampleMaster();

public IEnumerator ImportMasterAsync()
{
	yield return _importer.ImportFromSpreadSheetAsync(sampleMaster, (error) => {/* エラー時処理 */});
}
```

スプシのデータを変更→再読み込み をすることで、変更をすぐに反映できます。
デバッグ用コマンドなどで任意のタイミングでマスタの再読み込みができるようにしておくと捗ります。

`IImportableSpreadSheetMaster` を実装すれば、dbなどを参照してるマスタクラスの読み込み先をスプシに差し替えたりすることも一応できます。

### マスタデータの取得

idを元にマスタデータを取得する関数が用意されています。

```cs
SampleMasterData data = _sampleMaster.GetData(1); // ID=1のデータを取得
```

マスタクラスにメンバを追加する場合は`partial` キーワードを使用し、別のスクリプト上で追加することを推奨します。
(コード生成のたびに追加した処理が消えるのを防ぐため)

```SampleMaster.Manual.cs
public partial SampleMaster {
	public SampleMasterData GetDatasByValue(int value)
	{
		foreach (var data in _datas)
			if (data.value == value)
				return data;
		return null;
	}
}
```

生成されたマスタクラスは `ImportableSpreadSheetMasterBase` を継承しています。
`ImportableSpreadSheetMasterBase` は以下の仮想関数を持っているので、必要に応じてoverrideしてください。

- public virtual void PreImport()
- public virtual void Import(IReadOnlyList<IReadOnlyList<string>> records)
- public virtual void PostImport()

# 実装について

## シートの取得

以下のような処理でシートを取得

```SheetDownloader.cs
public class SheetDownloader
{
	private const string URI_FORMAT = "https://docs.google.com/spreadsheets/d/{0}/gviz/tq?tqx=out:csv&sheet={1}";

	public IEnumerator DownloadSheetAsync(string spreadSheetId, string sheetName, System.Action<string> onSuccess, System.Action<string> onError)
	{
		UnityWebRequest request = UnityWebRequest.Get(string.Format(URI_FORMAT, spreadSheetId, sheetName));
		yield return request.SendWebRequest();

		if (request.result == UnityWebRequest.Result.Success)
			onSuccess?.Invoke(request.downloadHandler.text);
		else
			onError?.Invoke(request.error);
	}
}
```

## データ型の判定

Parseできるかどうかで判定してるだけ。

```SpreadSheetMasterGeneratorWindow.cs
private DataType GetDataTypeFromString(string dataString)
{
	if (string.IsNullOrEmpty(dataString))
		return DataType.String;

	if (int.TryParse(dataString, out int intValue))
		return DataType.Int;
	if (float.TryParse(dataString, out float floatValue))
		return DataType.Float;
	if (bool.TryParse(dataString, out bool boolValue))
		return DataType.Bool;

	return DataType.String;
}
```

Enumについては`Type.GetType()`で有効かどうか判定しています。
エディタスクリプトからランタイム側のTypeを取得する場合は、カンマ区切りでアセンブリ名(`Assembly-CSharp.dll`)を指定する必要があります。

```SpreadSheetMasterGeneratorWindow.cs
column.enumTypeName = EditorGUILayout.TextField(column.enumTypeName, GUILayout.MaxWidth(240));

if (GUILayout.Button("適用", GUILayout.MaxWidth(40)))
{
	System.Type type = System.Type.GetType(column.enumTypeName + ", Assembly-CSharp.dll");
	bool isEnum = type != null && type.IsEnum;
	column.validFlag = isEnum;
	column.enumType = isEnum ? type : null;
}
```

## CSスクリプトの生成について

`File.WriteAllText("ファイル出力先", "ファイルの内容");` で生成できます。
StringBuilderにひたすらAppendする感じで内容を作成してます。

```SpreadSheetMasterGeneratorWindow.cs
private void GenerateMasterScript(string directoryPath, MasterConfigData configData, string namespaceName)
{
	string exportPath = string.Format("{0}/{1}.cs", directoryPath, configData.masterName);
	File.WriteAllText(exportPath, CreateMasterScriptContent(configData, namespaceName));
}

private string CreateMasterScriptContent(MasterConfigData configData, string namespaceName)
{
	StringBuilder sb = new StringBuilder();
	int tabCount = 0;

	bool namespaceExistFlag = !string.IsNullOrEmpty(namespaceName);

	AppendTab(sb, tabCount).Append("using SpreadSheetMaster;").AppendLine();
	sb.AppendLine();

	if (namespaceExistFlag)
	{
		AppendTab(sb, tabCount).AppendFormat("namespace {0}", namespaceName).AppendLine();
		AppendTab(sb, tabCount++).Append("{").AppendLine();
	}

	AppendTab(sb, tabCount).AppendFormat("public partial class {0} : ImportableSpreadSheetMasterBase<{1}>", configData.masterName, configData.masterDataName).AppendLine();
	AppendTab(sb, tabCount++).Append("{").AppendLine();
	AppendTab(sb, tabCount).AppendFormat("public override string spreadSheetId => \"{0}\";", configData.spreadSheetId).AppendLine();
	AppendTab(sb, tabCount).AppendFormat("public override string sheetName => \"{0}\";", configData.sheetName).AppendLine();
	AppendTab(sb, --tabCount).Append("}").AppendLine();

	if (namespaceExistFlag)
		AppendTab(sb, --tabCount).Append("}").AppendLine();

	return sb.ToString();
}

private StringBuilder AppendTab(StringBuilder sb, int tabCount)
{
	return sb.Append('\t', tabCount);
}
```

# 参考にしたもの

- https://github.com/fluncle/UtilityAssets/tree/main/Assets/GoogleSheetsMaster : 弊社の先輩エンジニアのリポジトリ。
- [UnityCsReference](https://github.com/Unity-Technologies/UnityCsReference) : エディタ拡張の実装を参考にさせていただきました。