UnityのAddressableアセットをビルドしてからCloudflare R2へとアップロードするエディタ拡張を作成する

## 前置き

アセットのビルドを行ったとき、そのアセットで動作確認をしたいことがあります。その場合、ビルドしたら自動でアップロードしてくれるとありがたいですよね。

ということで、今回はAddressableAssetsSystem(以降AAS)のアセットをビルドして、その後そのままCloudflare R2(以降R2)へとアップロードするためのエディタ拡張を作成したいと思います。

R2はエグレス料金が無料となっておりますので、ユースケースにあうのであればなるべく使っていきたいと思い、検証のためにも採用しました。

なお、R2を使うにはサブスクリプション登録のためにクレジットカードなど支払い方法が必要なる点にご注意ください。ただし、サブスクリプション登録をしても実際に請求されるかどうかは使用量次第になります。
（今回の動作確認の範囲内であればほぼほぼ請求されないとは思いますが、Cloudflareのダッシュボードを確認しましょう。）

## 対象

- AASを使ったことがある方
    - 基本的な紹介は省くので「導入記事とかは読んだ」以降のレベル感です
- R2に興味がある方
    - 「S3や他のサービスは使っている」という方も参考にどうぞ

## やること

1. R2のセットアップ
2. スクリプトからAASのビルドを実行する処理を追加
3. スクリプトからR2へのアップロードを行う処理を追加
4. ビルド処理とアップロード処理をまとめる

## 1. R2のセットアップ

アカウント作成処理は省略します

#### 1. R2の支払いの設定
- すいません。スクショ撮影し忘れました。
- 必要な支払い情報を入力するだけだったかと思います。

#### 2. バケットを作成
Cloudflareのダッシュボードより`R2 オブジェクトストレージ`の`概要`から作成できます
<img width="512" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/8fe95dd5-6efb-c610-ff7e-961921ce4ea5.png">

バケット名はなんでもよいので適当に入力して`バケットを作成する`を選びましょう
<img width="512" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/728bff4e-626d-ee68-e96e-15570f0bd99f.png">

#### 3. R2 APIトークンを作成
`R2 オブジェクトストレージ`の`概要`から`R2 API トークンの管理`から作成できます
<img width="512" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/c5209d3c-41a9-c40e-71f0-510062827e1c.png">
<img width="512" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/f2a2f4d4-af2b-65aa-b877-d6d640c791b8.png">

`トークン名`はなんでもよいです。今回は管理者用の設定にしています。TTLは好きなものでよいです。今回は記事用なので適当に1週間(で書けるだろうというてい)にしておきました。
<img width="512" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/8a83b137-146e-2c14-dd86-d7a26ff902b3.png">

<img width="512" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/648213d9-4f23-50c6-1b1f-3a57a8b0da69.png">

作成後の画面にて表示される`アクセスキーID`、`シークレットアクセスキー`、`エンドポイント`は後ほど使用しますので覚えておいてください。また、このとき表示される情報は1回限りなのでご注意ください。（とはいえ、メモするのを忘れたら消して作り直せばOKです）
<img width="512" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/b62e6e25-7a55-cf58-d7de-73434102df3f.png">

これでR2へのアップロードを行う準備が整いました。

## 2. スクリプトからAASのビルドを実行する処理を追加

ビルド処理自体は単純です。

```C#
AddressableAssetSettings.BuildPlayerContent(out var result);
```

上記のコードを実行することで`AddressableAssetSettings`の`Build & Load Paths`に指定されているパスへと出力されます。（`Remote`に指定している場合は`ServerData/[BuildTarget]`と、デフォルトで入力されているかと思います。）

![スクリーンショット 2024-12-04 14.26.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/8be95f4f-ab13-46aa-d543-b9108f7bccd0.png)

上記の図の場合`[ProjectHome]/ServerData/[BuildTarget]`以下にアセットが出力されるようになります。
(図の場合`[BuildTarget]`の部分が`StandaloneOSX`となっていますが、これは自動的に今のプラットフォームに合わせて表示が切り替えて表示されているからです）

なお、`ContentStateData`を使用した更新の場合は下記の通りです。

```C#
ContentUpdateScript.BuildContentUpdate(AddressableAssetSettingsDefaultObject.Settings, "addressables_content_state.bin");
```

`addressables_content_state.bin`には前回実行時に生成されているはずのファイルへのパスを指定してください。

ちなみに、実行するには`Addressables`のパッケージが必要ですので、`Package Manager`より`Addressables`をインストールしておきましょう。（今更感ありますが）

<img width=512 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/913f1488-d33d-9894-1918-2335283e3a4f.png">
<img width=512 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/b283bc20-ca40-0260-8ab6-8b8304a407d6.png">


## 3. スクリプトからR2へのアップロードを行う処理を追加

`AWS SDK for .NET`を使用してアップロードを行います。
「ん？AWS？R2では？」となりますが、R2はAWSのS3互換のAPIを有しているためAWS SDKのS3用の処理を利用して操作を行うことが出来ます。

### 1. `AWS SDK for .NET`のインストール

#### 1-a. `NuGetForUnity`のインストール
DLLの用意が面倒なので、`NuGetForUnity`を使用します。
インストール手順は[公式githubのインストール方法](https://github.com/GlitchEnzo/NuGetForUnity?tab=readme-ov-file#how-do-i-install-nugetforunity)をご確認ください。
以下は`Install as GIT dependency via Package Manager`を使うパターンの例です。

<img width=512 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/8e11dc60-225b-7ced-0566-8b72ec167588.png">

<img width=512 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/7ed1556c-8911-7d82-c346-a6d5451525c1.png">

<img width=512 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/a83ac226-1843-edfa-29cf-1acf612d6b6a.png">

#### 1-b. `AWSSDK.S3`のインストール

1.NuGetを選択します
<img width=512 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/ec8bc8fe-e126-2544-76e8-f431fea22fd8.png">

2.`NuGet For Unity`のウィンドウより`AWSSDK.S3`を`Search`して`Install`をします
<img width=512 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/54a647d4-2062-530a-e9a1-f04ebccf8843.png">

3.正常に完了すると`Installed`となります。
<img width=512 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/f5f98cf4-1a40-651a-ee9d-5c0e4a1b4819.png"> 

### 2. アップロードを行うEditor拡張用のクラスを作成

1. UnityプロジェクトディレクトリのAssetsディレクトリ配下の任意の場所に`Editor`ディレクトリを作成します
2. 作成した`Editor`ディレクトリ以下に任意の名前のC#ファイルを作成します（今回は仮に`AssetBuildAndUploader.cs`とします）
3. 以下のコードを追加します（このソースの説明は次で解説します）
<details><summary>アップロード処理のサンプル</summary>

```C#
using System;
using System.IO;
using System.Threading.Tasks;
using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
using UnityEditor;
using UnityEngine;
using Directory = System.IO.Directory;
using File = System.IO.File;

public class AssetBuildAndUploader 
{
    // R2への接続設定がかかれたJSONへのパス
    private static string R2_CONFIG_JSON_PATH => Application.dataPath + "/../r2_config.json";
    // ビルドされたアセットの出力先
    private static string ServerAssetsPath => Application.dataPath + "/../ServerData/";

    // R2への接続設定がかかれたJSONに対応するObject
    [Serializable]
    class R2Config 
    {

        public string accessKey;  // R2 APIのアクセスキー
        public string secretAccessKey; // R2 APIのシークレット
        public string r2Url;  // R2へのエンドポイント
        public string bucketName; // アップロード先のBucket名

    }
    
    [MenuItem("MyTools/AAS Upload To R2")]
    public static async void UploadAssets() 
    {
        Debug.Log("UploadAssets Start.");
        // 出力先のディレクトリにあるアセットを全て取得してアップロードする
        var files = Directory.GetFiles(ServerAssetsPath, "*.*", SearchOption.AllDirectories);
        foreach (var file in files) 
        {
            if (file.EndsWith(".DS_Store") || file.EndsWith("Thumbs.db")) 
            {
                continue; // OS固有の不要なファイルは無視する
            }
            var fileInfo = new FileInfo(file);
            // 保存先のキーは出力先の親ディレクトリの名前を全て除外したものにする
            var directoryName = Path.GetDirectoryName(file).Replace(ServerAssetsPath, "");
            Debug.Log($"Local: {file} ---> Remote: {directoryName}/{fileInfo.Name}");
            // 実際のアップロード処理
            await UploadToR2(file, $"{directoryName}/{fileInfo.Name}");
        }
        Debug.Log("UploadAssets Finished.");
    }

    private static async Task UploadToR2(string filePath, string keyName) 
    {
        // アップロード先のR2の情報を読み込み
        var r2Config = JsonUtility.FromJson<R2Config>
        (
            await File.ReadAllTextAsync(R2_CONFIG_JSON_PATH)
        );
        // R2のアップロード処理はS3と互換性があるのでS3Clientを利用する
        var s3 = new AmazonS3Client
        (
            new BasicAWSCredentials(r2Config.accessKey, r2Config.secretAccessKey),
            new AmazonS3Config 
            {
                ServiceURL = r2Config.r2Url
            }
        );
        try 
        {
            // 指定されたファイルをアップロード
            await s3.PutObjectAsync(new PutObjectRequest 
            {
                BucketName = r2Config.bucketName,
                Key = keyName,
                InputStream = new FileStream(filePath, FileMode.Open),
                DisablePayloadSigning = true
            });
        }
        catch (AmazonS3Exception e) 
        {
            Debug.Log($"e: {e.Message}");
        }
    }
       
}
```
</details>
 4. `r2_config.json`という名前のファイルをプロジェクト直下に配置します。内容はサンプルを参照してください。
<details><summary>r2_config.jsonのサンプル</summary>

```json
{
	"accessKey": "XXXXX(ここにR2 APIのアクセスキーID)",
	"secretAccessKey": "YYYYY(ここにR2 APIのシークレットアクセスキー)",
	"r2Url": "https://[account_id].r2.cloudflarestorage.com(ここにR2 APIのエンドポイント)",
	"bucketName": "your-bucket-name(作成したバケットの名前)"
}
```
</details>
5. Unityのメニューより`MyTools` > `AAS Upload To R2`を実行すると、プロジェクト直下の`ServerData`以下のファイルがR2の指定バケットにアップロードされます

### 3. アップロード処理の説明

```C#
// R2への接続設定がかかれたJSONへのパス
private static string R2_CONFIG_JSON_PATH => Application.dataPath + "/../r2_config.json";

// 〜略〜

// R2への接続設定がかかれたJSONに対応するObject
[Serializable]
class R2Config 
{

    public string accessKey;  // R2 APIのアクセスキー
    public string secretAccessKey; // R2 APIのシークレット
    public string r2Url;  // R2へのエンドポイント
    public string bucketName; // アップロード先のBucket名

}

// 〜略〜

var r2Config = JsonUtility.FromJson<R2Config>
(
    await File.ReadAllTextAsync(R2_CONFIG_JSON_PATH)
);
```

上記はR2へのアップロード用の署名を保存しておくためのJSONファイルへのパスを定義し、それを読み込んでいます。このJSON仕様は今回の拡張機能用の独自仕様となります。`ServiceURL`を書き換えるのが面倒だったのでこのようにしています。実運用の際にはファイルの取り扱いにはご注意ください。


```C#
// ビルドされたアセットの出力先
private static string ServerAssetsPath => Application.dataPath + "/../ServerData/";
```

上記は`AASのビルド処理`にて記載した通り、プロジェクトフォルダ直下の`ServerData`以下に出力されているというていでの設定になります。ここはプロジェクトのAASのビルド後の出力先の設定を元に書き換えてください。

```C#
var s3 = new AmazonS3Client
(
    new BasicAWSCredentials(r2Config.accessKey, r2Config.secretAccessKey),
    new AmazonS3Config 
    {
        ServiceURL = r2Config.r2Url    
	}
);
```

上記の`BasicAWSCredentials`には`R2のセットアップ`にて取得したR2 APIトークンのアクセスキーIDとシークレットアクセスキーを指定しています。また、`ServiceURL`には`R2のセットアップ`にて取得した`エンドポイント`を指定しています。


```C#
await s3.PutObjectAsync(new PutObjectRequest 
{
	BucketName = r2Config.bucketName,
	Key = keyName,
	InputStream = new FileStream(filePath, FileMode.Open),
	DisablePayloadSigning = true
});
```

上記では最終的に指定のファイルパスにあるデータを指定のbucketへとアップロードしています。

`BucketName`はアップロード先のバケット名、`Key`はバケット以下に配備されるときの名前、`InputStream`はアップロードする対象のファイルのStreamです。

`DisablePayloadSigning`は`R2`では使えない署名を、`AWS SDK`が使わないようにするため設定しています。これが`true`となっていない場合`STREAMING-AWS4-HMAC-SHA256-PAYLOAD not implemented`のエラーが発生しました。


## 4. ビルド処理とアップロード処理をまとめる


ビルド処理が成功した場合に、アップロード処理を行うようにすればよいでしょう。
単純に、以下のようになるでしょう。

```C#
AddressableAssetSettings.BuildPlayerContent(out var result);
if (!string.IsNullOrEmpty(result.Error)) 
{
    Debug.LogError(result.Error);
}
else 
{
    AssetUploader.UploadAssets();
}
```

## まとめ

以下は全体のソースコードになります

<details><summary>全体ソースコードのサンプル</summary>

```C#
using System;
using System.IO;
using System.Threading.Tasks;
using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
using UnityEditor;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
using Directory = System.IO.Directory;
using File = System.IO.File;

public class AssetBuildAndUploader 
{
    // R2への接続設定がかかれたJSONへのパス
    private static string R2_CONFIG_JSON_PATH => Application.dataPath + "/../r2_config.json";
    // ビルドされたアセットの出力先
    private static string ServerAssetsPath => Application.dataPath + "/../ServerData/";

    // R2への接続設定がかかれたJSONに対応するObject
    [Serializable]
    class R2Config 
    {

        public string accessKey;  // R2 APIのアクセスキー
        public string secretAccessKey; // R2 APIのシークレット
        public string r2Url;  // R2へのエンドポイント
        public string bucketName; // アップロード先のBucket名

    }

    [MenuItem("MyTools/AAS Build And Upload")]
    private static async void BuildAndUpload() 
    {
        AddressableAssetSettings.BuildPlayerContent(out var result);
        if (!string.IsNullOrEmpty(result.Error)) 
        {
            Debug.LogError(result.Error);
        }
        else 
        {
            await UploadAssets();
        }
    }
    
    private static async Task UploadAssets() 
    {
        Debug.Log("UploadAssets Start.");
        // 出力先のディレクトリにあるアセットを全て取得してアップロードする
        var files = Directory.GetFiles(ServerAssetsPath, "*.*", SearchOption.AllDirectories);
        foreach (var file in files) {
            if (file.EndsWith(".DS_Store") || file.EndsWith("Thumbs.db")) 
            {
                continue; // OS固有の不要なファイルは無視する
            }
            var fileInfo = new FileInfo(file);
            // 保存先のキーは出力先の親ディレクトリの名前を全て除外したものにする
            var directoryName = Path.GetDirectoryName(file).Replace(ServerAssetsPath, "");
            Debug.Log($"Local: {file} ---> Remote: {directoryName}/{fileInfo.Name}");
            // 実際のアップロード処理
            await UploadToR2(file, $"{directoryName}/{fileInfo.Name}");
        }
        Debug.Log("UploadAssets Finished.");
    }

    private static async Task UploadToR2(string filePath, string keyName) 
    {
        // アップロード先のR2の情報を読み込み
        var r2Config = JsonUtility.FromJson<R2Config>
        (
            await File.ReadAllTextAsync(R2_CONFIG_JSON_PATH)
        );
        // R2のアップロード処理はS3と互換性があるのでS3Clientを利用する
        var s3 = new AmazonS3Client
        (
            new BasicAWSCredentials(r2Config.accessKey, r2Config.secretAccessKey),
            new AmazonS3Config 
            {
                ServiceURL = r2Config.r2Url
            }
        );
        try 
        {
            // 指定されたファイルをアップロード
            await s3.PutObjectAsync(new PutObjectRequest 
            {
                BucketName = r2Config.bucketName,
                Key = keyName,
                InputStream = new FileStream(filePath, FileMode.Open),
                DisablePayloadSigning = true
            });
        }
        catch (AmazonS3Exception e) 
        {
            Debug.Log($"e: {e.Message}");
        }
    }
       
}
```
</details>

ビルドしてアップロードを行うメソッドを新たに追加し、MenuItemの属性を追加しています。Unityのメニューより`MyTools` > `AAS Build And Upload`を実行すると、ビルドしてアップロードが行われるようになります。

なお、今回で作成したR2のバケットは削除しておくと安心かと思います。
（ストレージ容量が超えていたり、外部アクセスをONにしてたりすると勝手に課金されてしまうかもしれないので）

以上となります。

## バージョン情報

- Unity6000.0.25f1
- Addressables 2.2.2
- NuGetForUnity 4.1.1 
- AWSSDK.S3 3.7.408.1

## 補足

R2へは初期設定のままだとURLでアクセスできません。
AASからアクセスするにはR2のバケットを`パブリックアクセス`(要ドメイン)にするか`Cloudflare Workers`などからR2へと経由させる必要があります。

開発中の動作確認用などに関しては`r2.dev サブドメイン`が利用できます。
こちらはバケットの`設定`から`r2.dev サブドメイン`の項目から設定可能です。

<img width=512 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/736d59ce-3406-66f6-2e65-b9a15a3b0525.png">

<img width=512 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/b9b69edf-d1e2-bf59-5fc2-32360453084c.png">

あとは`Addressables Profiles`より`Remote`を`Costum`にして、`Remote.LoadPath`に`パブリック r2.dev バケットURL`を指定すればOKです。
<img width=512 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939417/6e7f5d4a-d85d-2538-01dd-b3fbb0a97547.png">