すぎしーのXRと3DCG

主にXR, Unity, 3DCG系の記事を投稿していきます。

【VRM, glTF】3Dアバターファイルフォーマット "VRM" の構造をのぞいてみよう

こちらは クラスター Advent Calendar 2023 の1ページ目の6日目の記事です!

qiita.com

前日は @neguse_kさんの「Blenderでポーズを作ってUnityに取り込む」でした!Blenderでポーズを作成されている方はこちらの記事を参考にぜひclusterにも組み込んでみてください!


こんにちは、すぎしーです。 クラスター株式会社のUnityエンジニアになってちょうど1年が経ちました。エンジニアとしてできることも増え、やりがいのある日々を送っています!

さて、本記事のテーマは「"VRM" の構造をのぞいてみよう」です。ガチな解説ではなくおおまかにVRMの中身をイメージできるぐらいで紹介したいと思います。

使用するツール

今回はUnityとUniVRMは使用しません!

使用するVRM

VRMについて

VRMとは、VRMコンソーシアムが提唱している人型アバターを定義するファイルフォーマットとなっていて、clusterでもアバターの形式として使用されています。 VRMに対応した様々なアプリケーションでアバターを表示することができます。

例: VMagicMirrorでアリシア・ソリッドちゃんのVRMを読み込んで表示

VRMアバターを作ったり使ったりするだけならいろいろなツールが提供されているので構造を知る必要はないんですが、知っておくとさらにVRMと仲良くなれるかも?

VRMはglTF-2.0がベースになっている

VRMコンソーシアムから公開されているVRM仕様を見ると以下のように記載されています。

glTF-2.0のバイナリ形式glbをベースにした、VR向けモデルフォーマットです。

つまり結論を言ってしまうと "VRMの構造" ≒ "glTF-2.0の構造" と言えます! ということでまずはglTF-2.0の構造について簡単に紹介します。

glTF-2.0 はバイナリとJSONの2つで構成されている

glTFは3Dコンテンツ向けのファイルフォーマットでKhronos Groupから提供されています。仕様書は glTF™ 2.0 Specificationにあります。glTFファイルには主に以下の情報が1ファイル内に格納できるように設計されています。

  • オブジェクトのヒエラルキー
  • メッシュ (法線やウェイトなども含む)
  • テクスチャ
  • マテリアル
  • etc...

glTFはバイナリファイルですが、中身は「ヘッダ領域」を除いて「JSON領域」と「バイナリ領域」のチャンクで構成されています。

AliciaSolid_vrm-0.51.vrmをHex Editorで見ると、JSON文字列が格納されていることが確認できます。

そして、ある境界からテキスト部分ではなくバイナリ形式で格納された領域を確認することができます。

ここでJSON領域とバイナリ領域を少し深堀りします。

JSON領域

JSON領域には以下のような情報が格納されています。

項目名 説明
buffer シンプルなバイナリ領域のアドレス情報。ほとんどのVRMは1つだけ持っている。
bufferView bufferをさらに区分けしたもの。
image あるbufferViewをどの画像形式でロードするかの情報を持つ。画像形式はmimeTypeで表現される。
accessor どのbufferViewをどうやって読み込むかの情報を持つ。
mesh メッシュを構成するための情報を持つ。プリミティブ(マテリアルを割り当てる単位)毎に頂点位置(POSITION)、法線(NORMAL)、UV座標(TEXCOORD_0)などをaccessorを介して取得する。
etc... -

例として、以下はAliciaSolid_vrm-0.51.vrmのノード情報(nodes)のJSONの一部を整形表示したものになります。

ノードは位置や回転のほかにメッシュの有無の情報を持ちます。

Unityを知っている方向けに説明すれば、ノードはGameObjectのようなものでボーンとしても活用されたり、meshがあればMeshRenderer、さらにskinがあればSkinnedMeshRendererを持つようなイメージとなります。

"nodes": [
  {
    "name": "mesh",
    "children": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ],
    "translation": [ 0, 0, 0 ],
    "rotation": [ 0, 0, 0, 1 ],
    "scale": [ 1, 1, 1 ],
    "extras": {}
  },
  {
    "name": "body_top",
    "translation": [0, 0, 0],
    "rotation": [0, 0, 0, 1],
    "scale": [1, 1, 1],
    "mesh": 0,
    "skin": 0,
    "extras": {}
  },
  // ...
  {
    "name": "Head",
    "children": [ 77, 78, 79, 80, 81, 89, 97, 99, 101, 103, 104, 108, 109, 113, 114 ],
    "translation": [ -9.235208e-9, 0.0388788, -0.00014353916 ],
    "rotation": [ 0, 0, 0, 1 ],
    "scale": [ 1, 1, 1 ],
    "extras": {}
  },

※↑のJSONは成形したもので、VRM内のJSON領域ではスペースや改行は基本的に除外されています

バイナリ領域

バイナリ領域(Binary Buffer)は何を表しているかというと、メッシュ頂点やテクスチャ、モーフがバイナリデータとして格納されています。

実はバイナリ領域単体では何のデータかはわかりません。バイナリ領域の読み込み方ですが、JSON項目のbuffer、bufferView + 各情報から確定 させる流れになります。

JSON項目からバイナリ領域を読み込む流れ

buffer

bufferはバイナリ領域の一番大きな単位です。以下のように、バイトの長さのみでとてもシンプルです。

前述したとおり、ほとんどのVRMは1つだけ持っています。

  "buffers": [
    {
      "byteLength": 7772784
    }
  ],

bufferView

bufferViewはbufferを区切ったもので、大雑把なイメージは以下です。

以下のJSONを例にするとbufferViewの0番目は「0番目のbufferのオフセット0から344698バイト」、bufferViewの1番目は「0番目のbufferのオフセット344698から136063バイト」、...のように表現されます。

  "bufferViews": [
    {
      "buffer": 0,
      "byteOffset": 0,
      "byteLength": 344698
    },
    {
      "buffer": 0,
      "byteOffset": 344698,
      "byteLength": 136063
    },
    {
      "buffer": 0,
      "byteOffset": 480761,
      "byteLength": 1708146
    },
    // ...

あくまでbufferを区切っただけなので、bufferView単体でも何のデータとして読めばいいかはわかりません。後述するimageやaccessorなどから活用方法を確定させることになります。

image

テクスチャに使用される画像ファイルはimage項目から参照できます。
例えば"Alicia_body"というテクスチャ画像は「bufferViewの0番目をpng形式でロードする」となります。

"bufferView": 0, "mimeType": "image\/png"の場合は、「bufferViewの0番目をpng画像として読み込む」という解釈になります。

  "images": [
    {
      "name": "Alicia_body",
      "bufferView": 0,
      "mimeType": "image\/png"
    },
    {
      "name": "Sphere",
      "bufferView": 1,
      "mimeType": "image\/png"
    },
    // ...

accessor

accessorは以下のように情報を扱います。

  • typeで整数(int) or 浮動小数点(float)を決定
  • componentTypeでScalar(1個毎) or Vector3(3個毎) or Matrix4x4(16個毎) or etcを決定

accessorのみでは明確な用途は不明なため、さらに別の項目から使用されます。

  "accessors": [
    {
      "bufferView": 7,
      "byteOffset": 0,
      "type": "VEC3",
      "componentType": 5126,
      "count": 4804,
      "max": [
        0.614650965,
        1.31239367,
        0.150282308
      ],
      "min": [
        -0.614748538,
        0.9991788,
        -0.0840013
      ],
      "normalized": false
    },
    {
      "bufferView": 8,
      "byteOffset": 0,
      "type": "VEC3",
      "componentType": 5126,
      "count": 4804,
      "normalized": false
    },
    // ...

mesh

primitivesのattributesとして頂点位置(POSITION)や法線(NORMAL)を持っており、それらの数値がaccessorのインデックス値を表しています。
"POSITION": 0 の場合は、「accessorsの0番目を頂点位置として使用する」という解釈になります。頂点位置なのでそのaccessorのtypeはVector3(VEC3)になっているはずです。

  "meshes": [
    {
      "name": "body_top.baked",
      "primitives": [
        {
          "mode": 4,
          "indices": 5,
          "attributes": {
            "POSITION": 0,
            "NORMAL": 1,
            "TEXCOORD_0": 2,
            "JOINTS_0": 4,
            "WEIGHTS_0": 3
          },
          "material": 0
        },
    // ...

VRMはglTF拡張にVRM特有の情報を持つ

glTFは前述のとおりJSON領域を持っていますが、このJSONに追加情報を格納しても良いことになっています。この追加情報のことをglTF拡張(glTF Extensions)と呼びます。

VRMはこのglTF拡張部分にVRM特有の情報を持たせることで実現されていて、VRM0.xの場合はextensions.VRM項目に格納されています。

以下はAliciaSolid_vrm-0.51.vrmから抽出した情報ですが、一部紹介します。

拡張項目名 説明
humanoid VRMヒューマノイド(人型)のボーン情報
secondaryAnimation VRMのSpringBoneの情報
etc... -
"extensions": {
  "VRM": {
    "exporterVersion": "UniVRM-0.51.0",
    "specVersion": "0.0",
    "meta": {
      "title": "Alicia Solid",
      "version": "1.10",
      "author": "© DWANGO Co., Ltd.",
      // ...
    },
    "humanoid": { // ...
    },
    "firstPerson": { // ...
    },
    "blendShapeMaster": { // ...
    },
    "secondaryAnimation": { // ...
    },
    "materialProperties": [
    ]
  }
  // ...

ということでVRMの構造を簡単に説明してみました。もう少しglTFに詳しくなりたいな~という方は gltf20-reference-guide.pdf が図もあったりして参考になるかと思います!

VRMはglTFの仕組みをうまく利用したファイルフォーマットだったんですね~。

おまけ

バイナリエディタVRMを編集してみよう

さて、glTFの大まかな構造が分かったところで Hex Editor でAliciaSolid_vrm-0.51.vrmを編集してみましょう!

今回は編集結果がはっきりとわかるようにマテリアルの色の乗算値を赤色に変更してみます。

VRM0.xのMToonの色の乗算値は extensions.VRM.materialProperties[*].vectorProperties._Color です。Hex Editorで文字列"_Color"を検索すると見つけられます。

乗算値は白([1,1,1,1])になっていようなので、置換処理で "_Color":[1,1,1,1],"_Color":[1,0,0,1],に変更保存して、VMagicMirrorで確認してみましょう。

  1. AliciaSolid_vrm-0.51.vrmからAliciaSolid_vrm-0.51_edited.vrmをコピーして作成
  2. Hex Editorを開く
  3. Ctrl+F → 置換モードに切替 → 置換を実施
  4. Ctrl+Sで保存する
  5. VMagicMirrorでAliciaSolid_vrm-0.51_edited.vrmをロード

以下が読み込んだ結果です。しっかり赤色になっていますね。

実際にバイナリエディタVRMを編集することはほとんどないと思いますが、「VRMの構造を知ってるとこんなこともできるよ~」という紹介でした。

VRM0.x と VRM1.0 について

まだclusterではVRM1.0に対応はしていませんが、せっかくなのでVRM1.0についても少し触れようと思います。

VRM1.0はVRM0.xよりさらにglTFに準拠したフォーマットになっています。具体的に言うと以下のような変更が入りました。

  • UniVRM以外の一般的なglTFライブラリでもロードしやすいようにbufferViewの扱いが変更された
  • glTF拡張での名前は extensions.VRMC_vrmextensions.VRMC_springBoneVRMC_materials_mtoonなどに変更された
  • マテリアルのパラメータはglTF標準のmaterials項目を使用するようになった
    • 色の乗算値を例にすると extensions.VRM.materialProperties[*].vectorProperties._Colormaterials[*].pbrMetallicRoughness.baseColorFactor のようにglTF標準のパラメータが使用されるようになった
    • Unity特有のシェーダープロパティ名は使用しなくなった

変更点の詳細については VRM-1.0の変更点 で確認できます。

VRM1.0はvrm-specificationにて仕様がより明確に記載され、MToonについても項目が定義されたりとUnity製アプリ以外でもVRMを利用できるように見直されています。

ハロクラで発表されているようにclusterもVRM1.0対応を進めていますため、リリースされたらぜひVRM1.0アバターでclusterを楽しんでください!

参考

雑感

VRMの構造について僕なりに紹介してみましたがいかがでしたでしょうか?
clusterのエンジニアになってプロダクト開発でもVRMに触れることも増えたので、せっかくなので知見の共有も兼ねて記事にしてみました。

VRMVRM Meetupも開催されたりと盛り上がりを見せているので、今後も注目していきたいです!

記事をご覧いただきありがとうございました!

明日のAdvent Calendar 2023 7日目は @uzzuさんの「UnityのPlay Asset DeliveryをtargetSdk34に対応させる」です。clusterのAndroidスペシャリストの記事をどうぞお楽しみに!(Play Asset Deliveryについてはuzzuさんにとても助けられました)

それでは~

UnityでMoqを使う (Unity2021バージョン)

こちらは クラスター Advent Calendar 2022(2ページ目)の17日目の記事です!

前日はスワンマンさん (@Swanman) の「Unityのエディタ拡張で動的にメニューを追加・削除する」でした!

まさかエンジニアではなくカスタマーサポートの方からReflectionを使ったツールの作り方を教えてもらえるとは!
Unity上でツールを作るときに知っておくと便利なテクニックになると思いますのでぜひ参考にしてください。


こんにちは、すぎしーです。 クラスター株式会社のUnityエンジニアをなりました!

改めてよろしくお願いします。

概要

今回の内容は2年前に書いた 「UnityでのMoq導入方法」のUnity2021版です。
この2年でUnityもMoqもアップデートされているので、導入も前回より内容を強化した方法で紹介します!

記事の最後の方に、導入までをある程度自動化した方法も載せておきます。

ソフトウェアエンジニア向け の記事になります。

変更履歴

  • 2022/12/18 .NET Framework向けの依存dllを追加 及び "Moq 4.18.2以上にする理由"の説明を一部修正

Moqとは

Moqとは C#(.Net) 向けのモックオブジェクト作成ライブラリです。

モッククラスはUnitTestなどで依存interfaceと同じふるまい(モック)になるクラスですが、自作で実装するのはなかなか骨が折れる作業になります。
そんなときにモックライブラリを用いることで簡単にモックオブジェクトを用意でき、より高度なUnitTest (クラスの単体テスト) が可能になります。


UnityでMoqを導入

※Unity2020でも可能と思いますが、Unity2021以上推奨です!

最初に手作業でのやり方紹介します。

1. MoqとCastle.Coreのnupkgをダウンロード

NuGetからnupkgをダウンロードします。

Moq 4.18.2以上にする理由は後述します。

ダウンロードはページ横の "Download package" から可能です。

2. nupkgを展開

nupkgの実態はzipなので7-zipなどで直接展開できます。
拡張子を .zip に変えてOS標準のzip展開でも可能です。

3. dll を Unityプロジェクト内に配置

展開したファイルのうち、以下のファイルをUnity以下に配置しましょう。
個人的なオススメのフォルダは Plugins/Moq です。

  • "Api Compatibility Level" が ".Net Standard 2.1"の場合
    • moq.4.18.3/lib/netstandard2.1/Moq.dll
    • castle.core.5.1.0/lib/netstandard2.1/Castle.Core.dll
    • system.diagnostics.eventlog.7.0.0/lib/netstandard2.0/System.Diagnostics.EventLog.dll
  • "Api Compatibility Level" が ".Net Framework"の場合
    • moq.4.18.3/lib/net462/Moq.dll
    • castle.core.5.1.0/lib/net462/Castle.Core.dll
    • system.threading.tasks.extensions.4.5.4/lib/net461/System.Threading.Tasks.Extensions.dll
    • system.runtime.compilerservices.unsafe.6.0.0/lib/netstandard2.0/System.Runtime.CompilerServices.Unsafe.dll

以下のように配置してください。

4. Unity上でdllをTestRunner向けに調整

Moqはあくまでテスト用なので、ビルドしたアプリには含まれないように設定しましょう。

※ビルドしたアプリに混ぜる場合は再頒布となるため各dllのライセンス表記が必要となります。

以下は Moq.dll, Castle.Core.dll, System.Diagnostics.EventLog.dll全てで実施してください。

  • Inspectorを表示
  • Auto Reference を無効化
  • Validate References を有効化
  • "Define Constraints" に UNITY_INCLUDE_TESTS を指定
  • "Apply" ボタンをクリック

特に UNITY_INCLUDE_TESTS を指定することでEditMode及びPlayMode Test Runnerで使用できる状態で、
ビルドしたアプリ本体にはMoqと依存dllが除外されます。

以上で導入は完了です!


UnityでMoqを使用

次は導入したMoqを使ってみましょう!

Unity Test Framework の詳細は省略します

テスト用Assembly Definitionを作成

  • テスト用スクリプトを配置したいフォルダで右クリック
  • Create -> Testing -> Tests Assembly Folder をクリック
  • Assembly名を指定してasmdefを作成

テスト用Assembly DefinitionにMoqの参照を追加

  • テスト用asmdefのInspectorを開く
  • "Assembly References"に Moq.dll を追加
    • Castle.Core.dllSystem.Diagnostics.EventLog の指定は基本的に不要 (テスト用スクリプトで直接参照することは稀なため)

テストを書いて実行

あとは普段どおりテストスクリプトを作成して、Test Runnerで実行するだけです!
Moqを使った簡易的なテストコードの例を載せておきます。

using System.Collections.Generic;
using NUnit.Framework;
using Moq;

public class TestFuncProxy
{
    public interface IFunc
    {
        bool Invoke(int number);
    }

    [Test]
    public void Invoke_ReturnsFalse_IfFuncReturnsFalse()
    {
        // Arrange
        var mock = new Mock<IFunc>();
        var target = new FuncProxy(mock.Object);

        // Note: Moqの仕様でSetupなしの場合はdefaultを返す (bool Invoke(...) の場合はfalse)
        // FYI: 実際のテストではテストパターンを明確にするために明示しましょう!

        // Act
        bool actual = target.Invoke(3);

        // Assert
        Assert.That(actual, Is.False);
    }

    [Test]
    public void Invoke_ReturnsTrue_IfFuncReturnsTrue()
    {
        // Arrange
        var mock = new Mock<IFunc>();
        var target = new FuncProxy(mock.Object);

        // Note: 引数3を渡されたらtrueを返す
        mock.Setup(m => m.Invoke(3)).Returns(true);

        // Act
        bool actual = target.Invoke(3);

        // Assert
        Assert.That(actual, Is.True);
    }
}

以下は実行結果です。

使用方法の紹介は以上です!

Moqの使用例

Moqでできることをちょっと紹介します!

SetupSequenceでコールごとの挙動を指定

SetupSequence で指定するとコールごとの戻り値を指定できます。

    [Test]
    public void Example_SetupSequence()
    {
        var mock = new Mock<IFunc>();

        // 渡された引数に関係なくfalse -> true -> false -> throw Exception
        mock.SetupSequence(m => m.Invoke(It.IsAny<int>()))
            .Returns(false)
            .Returns(true)
            .Returns(false)
            .Throws(new System.Exception("Unexpected Call"));

        Assert.That(mock.Object.Invoke(default), Is.False);
        Assert.That(mock.Object.Invoke(default), Is.True);
        Assert.That(mock.Object.Invoke(default), Is.False);
        Assert.Throws<System.Exception>(() => mock.Object.Invoke(default));
    }

コール時の引数と回数の検査

Verify を使用するとコールされたときの引数やその引数でのコール回数を検査することができます。
Moqを使う場合は一番使う機能ではないかと!

    [Test]
    public void Example_Verify()
    {
        var mock = new Mock<IFunc>();

        mock.Object.Invoke(2);
        mock.Object.Invoke(5);
        mock.Object.Invoke(2);

        // 引数2で2回コールされたことの検証
        mock.Verify(m => m.Invoke(2), Times.Exactly(2));

        // 引数関係なく3回以上コールされたことの検査
        mock.Verify(m => m.Invoke(It.IsAny<int>()), Times.AtLeast(3));
    }

不正の場合は例外(MockException)が出て、テストが失敗します。

コール時の処理を設定

Callback を使用するとコールされたときの処理を設定できます。
複数オブジェクトのコールされた順番を検査するときなどに利用できます。

    [Test]
    public void Example_Callback()
    {
        var messageList = new List<string>();

        var mock1 = new Mock<IFunc>();
        var mock2 = new Mock<IFunc>();
        var mock3 = new Mock<IFunc>();

        // コールされたら messageList に文字列を追加
        mock1.Setup(m => m.Invoke(It.IsAny<int>())).Callback(() => messageList.Add("From 1"));
        mock2.Setup(m => m.Invoke(It.IsAny<int>())).Callback(() => messageList.Add("From 2"));
        mock3.Setup(m => m.Invoke(It.IsAny<int>())).Callback(() => messageList.Add("From 3"));

        mock3.Object.Invoke(0);
        mock1.Object.Invoke(0);
        mock2.Object.Invoke(0);
        mock1.Object.Invoke(0);

        // 合計のコール回数 及び コールされた順番を検査
        Assert.That(messageList.Count, Is.EqualTo(4));
        Assert.That(messageList[0], Is.EqualTo("From 3"));
        Assert.That(messageList[1], Is.EqualTo("From 1"));
        Assert.That(messageList[2], Is.EqualTo("From 2"));
        Assert.That(messageList[3], Is.EqualTo("From 1"));
    }


モックライブラリを使用するメリット

改めてモックライブラリを使うメリットを紹介します。

モックオブジェクトを簡単に生成可能

これまで説明した通りですが、モッククラスを独自に実装する必要がなくなります。

interface を使ったモッククラスを自作する場合は結構な行数を書くことになり、何より保守コストが発生して大変です。
テストの品質を考える場合はモッククラスのテストも必要になってきます。

ちなみにちょっと実装してみましたが、実際に自作する場合はもっと機能が必要になっていきます。

using System.Linq;
...
    public class MyMockFunc : IFunc
    {
        // コールされた戻り値の設定 (直近のコールのみ)
        public bool RetNumber { get; set; }

        // コールされたときの引数の格納用リスト
        public List<int> CallHistory = new();

        // 対象の引数でコールされた回数の検査
        public bool Verify(int targetNumber, int expected)
            => CallHistory.Where(number => number == targetNumber).Count() == expected;

        // メソッドをコールされたときの処理
        public bool Invoke(int number)
        {
            CallHistory.Add(number);
            return RetNumber;
        }
    }

特別な事情がない限り、早めにモックライブラリを導入しておくとUnitTestが億劫にならなくて良いかと思います。

IDEのリファレンス検索に余計な候補がでない

モックライブラリ使う場合はモッククラスを実装するわけではないので、IDEのinterface継承クラスの検索結果にモッククラスが並びません。

以下はモッククラスが定義されたプロジェクトでinterface継承クラスの検索結果イメージです。

(interfaceがGeneric型だった場合はさらに大変なことに)

高度な検証がより簡単に実現可能

「Moqの使用例」で紹介した通り、モックライブラリを使用すると複雑な検証もやりやすくなります。

  • 特定の引数で依存クラスのメソッドをN回コールすること
  • 依存クラスが OperationCanceledException を返すときにはエラーにならないこと
  • etc...


モックライブラリの導入はUnitTest自体の敷居を下げることができるので、是非活用してみてください。


簡易導入方法

以下にある程度自動化した方法を紹介しています。

github.com

オススメは実施環境に依存しない 「GitHub Acrtionsを使用する場合」 です! (Actionsのyaml設定ファイルも作成済みです)

生成されたフォルダをそのまま Assets 以下に配置すれば使用できる状態になっています。


余談

Moq 4.18.2以上にする理由

理由は".Net Standard 2.1"の依存するライブラリが削減されており、導入がより簡単になるためです。

実は4.18.1以前では System.Threading.Tasks.Extensions とその依存 System.Runtime.CompilerServices.Unsafe も一緒に入れる必要がありましたが、 4.18.2で依存が削除されました。

Removed dependency on System.Threading.Tasks.Extensions for netstandard2.1 and net6.0 (@tibel, #1274)

moq4/CHANGELOG.md at main · moq/moq4 · GitHub

追記

".NET Framework 4.x"では System.Threading.Tasks.Extensions の依存は残っているため引き続き必要となるようです。
簡易導入方法で紹介しているツールも修正済みです。


雑感

実装したクラスすべてにUnitTestが必要になるわけでは有りませんが、
恒久的に機能を保証したい場合などは強力な武器になるので、Unity開発でもMoqを活用してみてください。

クラスター株式会社にジョインして業務にも慣れてきましたが、
エンジニアに限らず様々な分野のスペシャリストやジェネラリストの方がいて、刺激的な日々を送っています。

これからもバーチャルにのめり込んでいきます!


クラスター Advent Calendar 2022 明日の記事の紹介

明日は Soraさん (@BlueRose_Sora) の「Tips:clusterで大規模な展示会をする」です!

お楽しみに!

【GitHub Actions】Composite ActionのTipsと注意点

概要

今回はGitHub Actionsの機能の一つである "Composite Action" について紹介します。

今回の記事は、GitHub Actionsに多少知見がある人向けの記事になります。

Composite Actionはいわゆる再利用性のあるステップをyamlファイルに集約して再利用可能にする機能です。
テンプレート的な機能、もしくはプログラミングにおける関数的なものと考えてもらっても良いと思います。

docs.github.com

Composite Actionは便利ですが、注意点もあるため紹介しようと思います。

ついでにPrivate Action (Privateなリポジトリに作成したAction) の使用方法も合わせて紹介します。

記事の最後にサンプルリポジトリも記載しておきます。

動作環境

用語

  • Public Action
    • PublicなリポジトリにあるAction (例、 actions/checkout, actions/upload-artifactなど)
  • Private Action

使用するプロジェクト

本題では有りませんが、ビルドのサンプル用に以下を入れています。

  • C# プロジェクトのビルド用サンプルプロジェクト
    • Lottery という実行するたびにtrue or falseを返すだけのプログラム
    • LotteryTests はUnitTest

Composite Actions の実装

Composite Actions を組み込むワークフロー

まずは Composite Actionなしのworkflowを例にしたいと思います。

# .github/workflows/build-dotnet-without-composite-actions.yml
name: "Build Dotnet without Composite Actions"

on:
  workflow_dispatch: {}

jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          lfs: true

      - uses: actions/cache@v3
        with:
          path: ./Lottery/obj
          key: dotnet-${{ runner.os }}-${{ github.ref_name }}

      - uses: actions/setup-dotnet@v2
        with:
          dotnet-version: '6.0.x'
          include-prerelease: false

      - name: Restore Packages
        shell: bash
        run: dotnet restore ./GitHubActionsTestbed.sln

      - name: Build Projects
        shell: bash
        run: dotnet build ./GitHubActionsTestbed.sln --configuration Release

      - name: Test Projects
        shell: bash
        run: dotnet test ./GitHubActionsTestbed.sln --blame

      - uses: actions/upload-artifact@v3
        with:
          name: Lottery
          path: ./Lottery/bin/Release/net6.0
          retention-days: 3

ワークフローの詳細

  • リポジトリのcheckout (actions/checkout)
  • .Net 6.0のビルド環境の構築 (actions/setup-dotnet)
  • dotnet コマンドを使ったビルド (パッケージの取得、テストを含む)
  • Artifactとしてアップロード

実行結果は以下です。

https://github.com/tsgcpp/GitHubActionsTestbed/actions/runs/3117273807

Composite Actions 対応

Composite Actionのファイル構成

以下のようなファイル構成を取ります。

<path to action>/<composite action name>/action.yml

<composite action name> はフォルダで、 ステップは action.yml に定義します。
フォルダ名はステップの流れがわかる名前にすると良いです

例えば、「.Netのビルドの一連の流れを集約」するComposite Actionを作りたい場合は以下のようにします。

.github/composite/dotnet-build/action.yml

自分はリポジトリ専用のComposite Actionは .github/composite に置くようにしていますが、 別に.github/composite 以下でなくとも問題ありません。

そして呼び出すときは以下のように uses にフォルダを指定します

- uses: ./.github/composite/dotnet-build

後述しますが、with により入力(inputs)を与えることも可能です。

      - uses: ./.github/composite/upload-artifact
        with:
          name: Lottery
          path: ./Lottery/bin/Release/net6.0

Composite Actionの組込方針

Composite Action は個人的には以下の活用方法があると考えています。

  • 複数のステップを1つに集約
  • 入力のデフォルト値を独自に定義

.Netのビルドの一連の流れを集約 (複数のステップを1つに集約)

.NetのビルドをComposite Action対応します。
フォルダ構成は固定として入力(inputs)はありません。

# .github/composite/dotnet-build/action.yml
name: 'Dotnet Build'
description: 'Restore packages, Build and Test'
runs:
  using: "composite"
  steps:
    - uses: actions/setup-dotnet@v2
      with:
        dotnet-version: '6.0.x'
        include-prerelease: false

    - name: Restore Packages
      shell: bash
      run: dotnet restore ./GitHubActionsTestbed.sln

    - name: Build Projects
      shell: bash
      run: dotnet build ./GitHubActionsTestbed.sln --configuration Release

    - name: Test Projects
      shell: bash
      run: dotnet test ./GitHubActionsTestbed.sln --blame

upload-artifactの有効日数3日をデフォルト化 (入力のデフォルト値を独自に定義)

公式の actions/upload-artifact@v3 ですが、デフォルトが90日となかなか長いです。

Composite Actionsは独自の入力 (inputs) を定義することが可能です。

ArtifactはPrivateなリポジトリの場合、使いすぎると従量課金の対象となるためデフォルトで3日ぐらいにしたい場合などは、
Composite Actionの inputs を使用することで独自のデフォルト値を定義できます。

# .github/composite/upload-artifact/action.yml
name: 'Upload Artifact'
description: 'An action to create a artifact'
inputs:
  name:
    required: true
    default: 'Artifact'
  path:
    required: true
  retention-days:
    required: false
    default: 3
runs:
  using: "composite"
  steps:
    - uses: actions/upload-artifact@v3
      with:
        name: ${{ inputs.name }}
        path: ${{ inputs.path }}
        retention-days: ${{ inputs.retention-days }}
  • name (Artifact名)のデフォルトを"Artifact"
  • retention-days (有効期限)をデフォルトを3 (3日)
  • path (対象のファイル群)はデフォルトなしで指定を必須化

required 一応指定しておきましょう。(ただ、個人的にはComposite Actionだと微妙にrequired機能していない印象です)

Composite Action を使用

改めて「Composite Actions を使用していないワークフロー」を改修したいと思います。

# .github/workflows/build-dotnet.yml
name: "Build Dotnet"

on:
  workflow_dispatch: {}

jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          lfs: true

      - uses: actions/cache@v3
        with:
          path: ./Lottery/obj
          key: dotnet-${{ runner.os }}-${{ github.ref_name }}

      - uses: ./.github/composite/dotnet-build

      - uses: ./.github/composite/upload-artifact
        with:
          name: Lottery
          path: ./Lottery/bin/Release/net6.0

上記のようにビルドの流れがスッキリした見た目になりました。
また、upload-artifact は有効期限を指定していなくてもデフォルトの3日が設定されるようになっています。

実行結果は以下です。

https://github.com/tsgcpp/GitHubActionsTestbed/actions/runs/3117435297

Composite Actionの細かな仕様

以下に記載されています。

github.com

デフォルトのshellは指定できないなど、細かい仕様が書いてあります。

Actionの補足

通常のActionとComposite Actionは構成自体は同じ

実はComposite Actionのファイル構成 (<path to action>/<composite action name>/action.yml) ですが、
特殊に見えて、実は通常のActionと同じ構成になっています。

例えば、公式の actions/checkout のルートのファイルを見ると action.ymlが存在しています。

github.com

つまりGitHub Actionsで使用されるActionは、必ずaction.ymlを持ったファイル群となっています。

Actionはcheckoutしてからフォルダを指定しても実行可能

実はActionは特定のフォルダにcheckoutして、usesに指定しても使用可能です。

例えば actions/upload-artifactは一旦 ./.github/repos/actions/upload-artifactというフォルダにcheckoutして、
usesでそのフォルダを指定する形をとっても、同様の機能を得ることができます。

      - uses: actions/upload-artifact@v3
        with:
          name: Lottery
          path: ./Lottery/bin/Release/net6.0
          retention-days: 3

      - uses: actions/checkout@v3
        with:
          repository: 'actions/upload-artifact'
          ref: v3.1.0
          path: ./.github/repos/actions/upload-artifact

      - uses: ./.github/repos/actions/upload-artifact
        with:
          name: Lottery
          path: ./Lottery/bin/Release/net6.0
          retention-days: 3

PrivateリポジトリのActionもcheckoutしてフォルダを指定すれば実行可能

前項と同じ原理でPrivateリポジトリもcheckoutして実行が可能です。

社内専用のActionを作って使用したい場合などにご活用ください。

      - name: Checkout tsgcpp/upload-artifact-private
        uses: actions/checkout@v3
        with:
          # actions/upload-artifact をコピーしてPrivate化したリポジトリ
          repository: 'tsgcpp/upload-artifact-private'
          ref: main
          path: ./.github/repos/tsgcpp/upload-artifact-private
          token: ${{ secrets.PAT_TOKEN }}

      - uses: ./.github/repos/tsgcpp/upload-artifact-private
        with:
          name: Lottery
          path: ./Lottery/bin/Release/net6.0
          retention-days: 3

対象のリポジトリにアクセス可能なPersonal Access Tokenを作成してsecretsに登録して使用する必要があるなど、多少手間があります。

Private Actionを直接 uses に指定できない理由

GitHub Actionsのワークフローでデフォルトで発行される GITHUB_TOKEN があるのですが、
GITHUB_TOKEN は ワークフローを実行したリポジトリのみアクセス可能なトークなので他のリポジトリにはアクセスできません。

そのため、Private Actionの場合はアクセス可能なトークンを使ってcheckoutしてから、usesに指定する必要があります。

GitHub様、Private Actionに特化したトークンの機能つくってほしいなー

ダウンロード済みのActionは再利用される

全く同じバージョンやSHAのActionがダウンロード済みの場合は、ダウンロード済みのものが再利用されます。

ダウンロード済みのActionはComposite Actionなどの外部yamlでも共有されます

    - name: Cache actions/cache
      uses: actions/checkout@v3
      with:
        repository: 'actions/cache'
        ref: v3.0.8
        path: ${{ inputs.pathRoot }}/actions/cache

    - uses: ./.github/composite/checkout-actions
# .github/composite/checkout-actions
    # Compsite Action側
    - name: Cache actions/upload-artifact
      uses: actions/checkout@v3  # ダウンロード済みの `actions/checkout` を使用
      with:
        repository: 'actions/upload-artifact'
        ref: v3.1.0
        path: ${{ inputs.pathRoot }}/actions/upload-artifact

ちなみにデバッグモードを有効化すると、以下のログで再利用されていることが確認できます。

Getting action download info
##[debug]Action 'actions/upload-artifact@v3' already downloaded at '/home/runner/work/_actions/actions/upload-artifact/v3'.

https://github.com/tsgcpp/GitHubActionsTestbed/actions/runs/3117276928/jobs/5055819436#step:7:9

Composite Actionの注意点

Composite Action自体のcheckoutが必要

ワークフロー実行時はリポジトリの内容はcheckoutされていません。
Composite Actionは外部yamlに定義する関係であらかじめcheckoutで他ソースと一緒に取得する必要があります。

...
    steps:
      - uses: actions/checkout@v3
        with:
          lfs: true
...
      # actions/checkoutで取得したComposite Actionを使用
      - uses: ./.github/composite/dotnet-build

Publicリポジトリの場合はリポジトリ指定で実行可能

Publicなリポジトリに配置されたComposite Actionであれば、以下の様に指定できます。

      - uses: <org>/<repository>/<path to action directory>@<ref(tag or branch)>

以下は指定例です。

      - uses: tsgcpp/GitHubActionsTestbed/.github/composite/dotnet-build@main

ワークフロー本体のyamlusesで指定されたActionは事前ダウンロードされる

ワークフロー本体のyaml内の uses に定義したActionですが、 ワークフローの最初 (Set up job) で事前ダウンロードされます。

こちらもデバッグモードを有効化すると確認できます。

Getting action download info
Download action repository 'actions/upload-artifact@v3' (SHA:3cea5372237819ed00197afe530f5a7ea3e805c8)
##[debug]Download 'https://api.github.com/repos/actions/upload-artifact/tarball/3cea5372237819ed00197afe530f5a7ea3e805c8' to '/home/runner/work/_actions/_temp_e6a3dde4-ca19-4d00-af3a-9a6c772ea0ec/241095c2-ea32-481b-83fe-d1b6af6915ac.tar.gz'

https://github.com/tsgcpp/GitHubActionsTestbed/actions/runs/3117276928/jobs/5055819436#step:1:45

外部yamlに定義されたActionはステップ実行時に遅延ダウンロードされる

本記事の本題といっても過言ではありません!

Composite Actionを含む外部yaml内のActionは実行されるタイミングでダウンロードされます!

つまり、外部yamlのActionは遅延処理的な性質があります

.github/workflows/build-dotnet.yml を例に取ると

  • actions/cacheはワークフローのはじめにダウンロードされる
    • ワークフロー本体のyaml内で定義されているため
  • actions/setup-dotnetactions/upload-artifactは各ステップ実行時にダウンロードされる
    • Composite Actionのyaml内に定義されているため
# withは省略
      - uses: actions/cache@v3
...
      # 内部で uses: actions/setup-dotnet@v2
      - uses: ./.github/composite/dotnet-build

      # 内部で uses: actions/upload-artifact@v3
      - uses: ./.github/composite/upload-artifact
...

Actionのログを見てみると、Set up jobactions/cacheはダウンロードされていますが、 actions/setup-dotnetactions/upload-artifactはダウンロードされていないことがわかります。

actions/setup-dotnetactions/upload-artifactは各種ステップの実行時にダウンロードされています。

ログの全体は以下です。

github.com

遅延ダウンロードの何が問題なのか?

「大した問題じゃなくね?」って思った方もいると思いますし、実際大した問題にならないパターンも多いです。

問題になりやすい例として、完了に長時間を要するワークフローがあります

例えば以下のようなワークフローです。

  • 5時間かかるアプリのビルド実行
  • ビルド完了後に Composite Actionを使ってアプリをストアへアップロード
    • Composite Action内でアプリのストアアップロード用Actionを取得して使用

ワークフロー開始時にはGitHubは正常だったのに、
5時間後のビルド時にGitHubAPIが一部死んでいてストア用のActionのダウンロード(checkout)が失敗してビルドがパーになっちゃうパターンです。

ストア側のAPIは問題がなかった場合、予めストア用のActionをダウンロードできていれば回避できた問題ですね。。。

昨今クラウドベンダー(AWSなど)の一時インスタンスでビルドすることも多くなっていて、ビルド成果物をどこかに退避していないとサルベージも困難だったりします。

対策1 あらかじめ使用するActionすべてのcheckoutを済ませる (オススメ)

事前ダウンロードされてないなら、明示的に事前ダウンロードしてしまおうという発想です。

1例として、以下のようなセットアップ用Composite Actionを用いる方法があります。

name: 'Set Up Actions'
inputs:
  pathRoot:
    required: true
    description: 'Relative path the actions will be into'
    default: ./.github/repos
  patToken:
    required: true
    description: 'GitHub Personal Access Token to checkout private repositories.'
runs:
  using: "composite"
  steps:
    - name: Cache actions/checkout
      uses: actions/checkout@v3
      with:
        repository: 'actions/checkout'
        ref: v3.0.2
        path: actions/checkout@v3

    - name: Cache actions/cache
      uses: actions/checkout@v3
      with:
        repository: 'actions/cache'
        ref: v3.0.8
        path: ${{ inputs.pathRoot }}/actions/cache

    - name: Cache actions/upload-artifact
      uses: actions/checkout@v3
      with:
        repository: 'actions/upload-artifact'
        ref: v3.1.0
        path: ${{ inputs.pathRoot }}/actions/upload-artifact

    - name: Cache actions/download-artifact
      uses: actions/checkout@v3
      with:
        repository: 'actions/download-artifact'
        ref: v3.0.0
        path: ${{ inputs.pathRoot }}/actions/download-artifact

    - name: Cache actions/setup-dotnet
      uses: actions/checkout@v3
      with:
        repository: 'actions/setup-dotnet'
        ref: v2.1.0
        path: ${{ inputs.pathRoot }}/actions/setup-dotnet

    - name: Checkout tsgcpp/upload-artifact-private
      uses: actions/checkout@v3
      with:
        # Same with actions/upload-artifact
        repository: 'tsgcpp/upload-artifact-private'
        ref: main
        path: ${{ inputs.pathRoot }}/tsgcpp/upload-artifact-private
        token: ${{ inputs.patToken }}

後は、usesにダウンロード済みのActionを指定するだけです。

name: "Build Dotnet with Set Up Actions"

on:
  workflow_dispatch: {}

jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          lfs: true

      - uses: ./.github/composite/setup-actions
        with:
          patToken: ${{ secrets.PAT_TOKEN }}

      - uses: actions/cache@v3
        with:
          path: ./Lottery/obj
          key: dotnet-${{ runner.os }}-${{ github.ref_name }}

      - uses: ./.github/composite/dotnet-build-with-setup-actions

      - uses: ./.github/composite/upload-artifact-with-setup-actions
        with:
          name: Lottery
          path: ./Lottery/bin/Release/net6.0
# .github/composite/dotnet-build-with-setup-actions/action.yml
name: 'Dotnet Build with Set Up Actions'
description: 'Restore packages, Build and Test'
runs:
  using: "composite"
  steps:
    - uses: ./.github/repos/actions/setup-dotnet
      with:
        dotnet-version: '6.0.x'
        include-prerelease: false

    - name: Restore Packages
      shell: bash
      run: dotnet restore ./GitHubActionsTestbed.sln

    - name: Build Projects
      shell: bash
      run: dotnet build ./GitHubActionsTestbed.sln --configuration Release

    - name: Test Projects
      shell: bash
      run: dotnet test ./GitHubActionsTestbed.sln --blame
# .github/composite/upload-artifact-with-setup-actions/action.yml
name: 'Upload Artifact with Set Up Actions'
description: 'An action to create a artifact'
inputs:
  name:
    required: true
    default: 'Artifact'
  path:
    required: true
  retention-days:
    required: false
    default: 3
runs:
  using: "composite"
  steps:
    - uses: ./.github/repos/actions/upload-artifact
      with:
        name: ${{ inputs.name }}
        path: ${{ inputs.path }}
        retention-days: ${{ inputs.retention-days }}

このやり方の利点は以下があると思っています。

  • 複数のWorkflow間で使用するActionのバージョンを統一できる
    • 特に同じActionを使う場合でも、v2とv3で指定を間違えるなども回避しやすい
  • Public Action, Private Actionどちらも使用時の uses への指定方法が統一される
    • どちらもダウンロード済みのActionになっているため

対策2 usesで使用するActionを宣言 (非推奨)

「ダウンロード済みのActionは再利用される」の性質を利用したやり方ですね。

ただ、このやり方は公式ドキュメントにはないやり方で、仕様の裏をついたやり方なので非推奨です。
また、usesで宣言した限りステップ自体は実行されてしまうため、Actionによっては予期しない副作用が発生する可能性もあります。

事前ダウンロード時に失敗してもワークフローを継続させるために continue-on-error: true を宣言しています。

jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
      # actions/upload-artifactを事前ダウンロードさせる
      - uses: actions/upload-artifact@v3
        continue-on-error: true

      # actions/setup-dotnetを事前ダウンロードさせる
      - uses: actions/setup-dotnet@v2
        continue-on-error: true
        with:
          dotnet-version: '6.0.x'
          include-prerelease: false

      - uses: actions/checkout@v3
        with:
          lfs: true

      - uses: actions/cache@v3
        with:
          path: ./Lottery/obj
          key: dotnet-${{ runner.os }}-${{ github.ref_name }}

      # ダウンロード済みのactions/setup-dotnetが使用される
      - uses: ./.github/composite/dotnet-build

      # ダウンロード済みのactions/upload-artifactが使用される
      - uses: ./.github/composite/upload-artifact
        with:
          name: Lottery
          path: ./Lottery/bin/Release/net6.0

GitHub Actions側に事前ダウンロード機能として、usesの pre-download的なオプションを要望として出しても良さそうな気はしてます。

Reusing Workflows との違い

GitHub ActionsにはReusing Workflowsという機能があります。
こちらは名前の通りワークフロー全体を再利用する形になります。

docs.github.com

一方でComposite Actionは数ステップを集約して、ワークフロー(ジョブ)にステップとして組み込む機能になります。
もしワークフロー全体を再利用する場合は、Composite ActionではなくReusing Workflowsのほうが最適と言えます。

余談、筆者が遭遇した事象

「外部yamlに定義されたActionはステップ実行時に遅延ダウンロードされる」の仕様を認識するきっかけになった事象がありました。

Composite Actionを使ったCIのワークフローを運用していて、半年以上問題が発生していなかったのですが、
ある日大事な提出でビルドマシンがいつも以上にガンガン回っているときでした。

初回のcheckoutは問題なく実行されましたが、数時間のビルドを終わらせた後のComposite Action内でActionのダウンロード(checkout)が発生したとき、
なぜか急にUnauthorizedになってcheckout不可になる現象が発生するようになりました。

checkout対象はPublic Actionの uses: actions/upload-artifact だったので、なぜUnauthorizedになったのかは本当に謎でした。。。
ただ、今回の現象に関わらず外部APIにアクセスできなくなる可能性は十分に考えられます。

一番の問題は失敗時の時間的損失が大きかったことのため、
外部APIなどが関係するステップをワークフローの初回に集約し、失敗しても時間的損失を極力回避できるように組み直しました

今回紹介した setup-actions がその一例となります。

完全なネットワーク障害などに対応しきれるわけではありませんが、あらかじめActionをダウンロードしておくことは一部ワークフローでは有効かと思いますため、
良かったら参考にしていただければと!

サンプルプロジェクト

github.com

雑感

直近はかなりドタバタしていたので、これまた久々の記事です。。。

最近はXR、非XRに限らずインフラはやはり重要だなと痛烈に感じています。
XRアプリの開発もどんどん規模が大きくなっているため、開発基盤の重要性もかなり上がっています。

Unityに限らずAndroid, iOS, Dockerを含むサーバーサイドのCI/CDを経験してきた身としては、
インフラをもっと強化していきたいと常に考えるようになりました。

そういえば「すぎしーのXRと3DCG」というブログ名ですが、そろそろ改名を考えています。
XR開発はインタラクションやグラフィックスももちろん重要ですが、それに負けないくらいクリエイターが開発に注力できる環境を用意することも大事だと思います。

これからもよろしくです!

それでは~

【Unity Localization】 GCPのサービスアカウントでプライベートSheetsと連携 (Cronジョブ対応含む)

概要

今回も前回に引き続きUnity Localizationに関するTipsです。
GCPのサービスアカウントでプライベートSheetsと連携する方法を紹介します。

Unity Localization標準のOAuth認証を使用すれば一応プライベートSheetsにアクセスすることは可能ですが、Cronジョブなどで定期更新に対応したいときに不都合があります。

そんな問題をGCPのサービスアカウントを用いて解決したいと思います!

おまけでCronジョブでGoogle SheetsからStringTableCollectionを定期更新するTipsも紹介します。

前回の記事

良かったら合わせてどうぞ!

tsgcpp.hateblo.jp

動作環境

  • Unity 2021.3.6f1
    • Unity 2020 でも可
  • Unity Localization 1.3.2

備考

今回紹介する機能は拡張パッケージとしても用意しています。

記事の最後にGitHubへのリンクを記載していますため、よろしければどうぞ。

GCPのサービスアカウント対応手順

サービスアカウントの作成

  • GCPのCredentialsページにてサービスアカウントを作成
    • GCP自体のアカウント作成などは各自調べてください

console.cloud.google.com

サービスアカウントの認証用JSON形式の鍵をダウンロード

以下のようなJSON形式の鍵がダウンロード出来ます。
JSON文字列は後述するクラスに渡すために使用します。

{
  "type": "service_account",
  "project_id": "xxx-workspace",
  "private_key_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "private_key": "-----BEGIN PRIVATE KEY-----\n...",
  "client_email": "unity-localization-example@xxx.xxx.gserviceaccount.com",
  "client_id": "121212121212121212121",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "..."
}

※アクセス権限に使用する鍵のため、厳重に管理してください

対象のSheetsにサービスアカウントのアクセス権限を付与

Pullのみの場合は閲覧権限、Pushも行う場合は編集権限をサービスアカウントに与えてください。

GCPのサービスアカウント向けSheetsServiceProviderを作成

1.3.2時点のUnity LocalizationにはGCPのサービスアカウント連携用クラスが用意されていなかったため用意します。
いずれ公式から提供される気がしますが、今回は実装します。

    public class ServiceAccountSheetsServiceProvider : IGoogleSheetsService
    {
        private readonly string _serviceAccountKeyJson;
        private readonly string _applicationName;

        public ServiceAccountSheetsServiceProvider(
            string serviceAccountKeyJson,
            string applicationName)
        {
            _serviceAccountKeyJson = serviceAccountKeyJson;
            _applicationName = applicationName;
        }

        public SheetsService Service => GetSheetsService();

        private SheetsService GetSheetsService()
        {
            var credential = GoogleCredential.FromJson(_serviceAccountKeyJson);
            var initializer = new BaseClientService.Initializer
            {
                HttpClientInitializer = credential,
                ApplicationName = _applicationName,
            };
            var sheetsService = new SheetsService(initializer);
            return sheetsService;
        }
    }
  • serviceAccountKeyJson には、先程作成したサービスアカウントのJSON文字列を渡す
  • applicationName には GoogleSheetsService (ScriptableObject) に設定した ApplicationName を指定

GCPのサービスアカウント向けSheetsServiceProviderを使用してPull or Push

  • 先程作成した ServiceAccountSheetsServiceProvider を用いてPull or Pushを実施
    • ※以下の実装は、前回の記事で紹介したコードをServiceAccountSheetsServiceProviderに入れ替えたもの
            // 対象のStringTableCollectionを取得
            var collection = AssetDatabase.LoadAssetAtPath<StringTableCollection>("Assets/<path to StringTableCollection>");

            // GoogleSheetsExtensionをStringTableCollectionから取得
            var sheetsExtension = collection.Extensions.OfType<GoogleSheetsExtension>().FirstOrDefault();

            // *前回の記事と異なり、ここでServiceAccountSheetsServiceProviderを使用
            var serviceProvider = new ServiceAccountSheetsServiceProvider(
                serviceAccountKeyJson: "<GCPサービスアカウントのJSON形式の鍵の文字列>",
                applicationName: "<GoogleSheetsService (ScriptableObject) に設定したApplicationName>");

            // Google Sheetsアクセス用インスタンスを生成
            var sheets = new GoogleSheets(serviceProvider);

            // ※必ずSpreadSheetIdをGoogleSheetsインスタンスに指定してください!
            sheets.SpreadSheetId = sheetsExtension.SpreadsheetId;

            // 対象のStringTableCollection内の全言語(全Locale)のPullを実施
            sheets.PullIntoStringTableCollection(
                sheetId: sheetsExtension.SheetId,
                collection: collection,
                columnMapping: sheetsExtension.Columns);

上記の方法を使用することで、GCPのサービスアカウントでPull及びPushが可能となります。

以上がGCPのサービスアカウントを使用した連携方法となります。


CronによるGoogle SheetsからのStringTableCollectionの定期更新

さて、今回の実質的な本題に移りたいと思います!
今回、GCPサービスアカウントを用いた本当の理由はCron(定期ジョブ)を使ったStringTableCollection更新に対応させるためです!

そもそもなぜOAuthが使えないのか

Cron対応なしで運用する場合はそもそもGCPサービスアカウント対応は不要です。
概要で少し触れましたがプライベートのSheetsには標準のOAuth認証でも連携可能です。

Unity Localization標準のOAuthの問題は、主に以下の2点です。

  • 認証にUnityエディタ上でのUI操作が必要
    • batchモード(CLI)での認証が困難
  • 認証後に生成される認証ファイルが Library/Google/GoogleSheetsService 以下で管理
    • LibraryフォルダはUnityにおけるキャッシュフォルダでもあるため、クリーンビルドを行うCIなどと相性が悪い

というわけで、
batchモードで認証を完結させるために、JSON形式の鍵文字列を渡すだけで認証が可能なServiceAccountSheetsServiceProvider を用意した感じです。

鍵文字列をServiceAccountSheetsServiceProviderに渡す方法

サービスアカウントの鍵はアクセス権限を握るセンシティブなデータファイルなので、鍵の文字列をgitリポジトリ内で管理することはタブーです!

セキュリティを考慮して使用後はキャッシュとして残らない方法と取りましょう。

1. 環境変数で渡す

CIとも相性がよくジョブプロセス単位で管理可能なため、筆者としても環境変数を利用する方法がオススメです!

            const string EnvironmentGoogleServiceAccountKey = "UNITY_LOCALIZATION_GOOGLE_SERVICE_ACCOUNT_KEY";
            var serviceAccountKeyJson = Environment.GetEnvironmentVariable(EnvironmentGoogleServiceAccountKey);

            var serviceProvider = new ServiceAccountSheetsServiceProvider(
                serviceAccountKeyJson: serviceAccountKeyJson,
                applicationName: "<GoogleSheetsService (ScriptableObject) に設定したApplicationName>");

2. ジョブ中のみ鍵ファイルを生成

環境変数を使用できない場合は、ジョブ中のみJSON形式の鍵ファイルを生成する対応が一つの選択肢になります。

以下はbashを用いて環境変数からJSON鍵ファイルを復元する方法です。

$ echo "${UNITY_LOCALIZATION_GOOGLE_SERVICE_ACCOUNT_KEY}" > "<path to key>/service-account-key.json"

あとは、上記鍵ファイルからJSON文字列を取り出して渡すだけです。

            const string JsonKeyPath = "<path to key>/service-account-key.json";
            string serviceAccountKeyJson = File.ReadAllText(keyJsonPath);

            var serviceProvider = new ServiceAccountSheetsServiceProvider(
                serviceAccountKeyJson: serviceAccountKeyJson,
                applicationName: bundle.SheetsServiceProvider.ApplicationName);

ジョブの成否に関わらず、不要になったら生成した鍵ファイル削除を忘れずに!

$ rm "<path to key>/service-account-key.json"

余談、現時点の game-ci/unity-builder では独自の環境変数をメソッドに渡せない

GitHub ActionsでGameCIを使っている人は結構いるかと思いますが、
残念ながらgame-ci/unity-builderbuildMethodでUnity側のメソッドを叩くときに独自の環境変数を渡せないようでした。。。 (知っている方がいたらぜひ教えてください!)

そんな経緯もあって、「ジョブ中のみ鍵ファイルを生成」という方法を紹介しました。

Pull or Push

あとは「GCPのサービスアカウント向けSheetsServiceProviderを使用してPull or Push」と同様に生成したserviceProviderを使用するだけです。

            // Google Sheetsアクセス用インスタンスを生成
            var sheets = new GoogleSheets(serviceProvider);

            // ※必ずSpreadSheetIdをGoogleSheetsインスタンスに指定してください!
            sheets.SpreadSheetId = sheetsExtension.SpreadsheetId;

            // 対象のStringTableCollection内の全言語(全Locale)のPullを実施
            sheets.PullIntoStringTableCollection(
                sheetId: sheetsExtension.SheetId,
                collection: collection,
                columnMapping: sheetsExtension.Columns);

おまけ、GitHub ActionsでSheets連携をCronジョブ対応

サンプルを用意したので、よければ参考にして下さい。

大まかな流れは以下です。

  • GitHub ActionsのSecrets (GOOGLE_SERVICE_ACCOUNT_KEY_JSON_BASE64) から鍵文字列をデコードしてJSONファイルを生成
    • 鍵文字列のBase64対応は必須ではないですが、環境依存な文字(かっこやカンマなど)があっても対応しやすいので入れています
  • Unity側のメソッド PullAllLocalizationTablesFromTempKeyJsongame-ci/unity-builderbuildMethod経由で実行
    • game-ci/unity-builderbuildMethodの使用方法はGameCIのドキュメントを参照
  • Pull完了後は生成した鍵ファイルを削除
  • Commit, Push して Pull Request

ちなみに、サンプルでは以下のようなプルリクエストが発行されます。

github.com

拡張パッケージ (Example含む)

使用方法はREADME.mdの "Feature" を参照

github.com

雑感

Unity Localization第2弾でした。
また時間が空いてしまいました。。。

UnityとGitHub Actionsを活用したワークフローに関するノウハウも結構溜まってきたので、機会があれば紹介したいですね。
次回の記事は決まってませんが、状況が落ち着いたらまた何か書きます。

それでは~

【Unity Localization】 複数のStringTableCollectionを一括PullのTips (拡張ツール提供有り)

概要

今回は Unity Localization に関するTipsです。
ついでに拡張パッケージの作ってみたのでよかったら使ってみてください。

docs.unity3d.com

最近 Unity Localization を触りだしたのですが、リリースされてまだ日も浅いパッケージのせいかまだまだ機能に物足りなさも感じられます。
今回はUnity LocalizationでGoogle Sheetsから 複数のStringTableCollection をまとめて更新するTipsを紹介します。

Unity Localization に関しては デニックさんの以下の記事をご覧ください!

xrdnk.hateblo.jp

環境

  • Unity 2021.3.6f1
  • Unity Localization 1.3.2

補足

StringTableCollection

StringTableCollectionはKey, Value形式で全言語分の翻訳テーブルをまとめているScriptableObjectです。
StringTableはRuntimeで使用可能に対して、StringTableCollectionはEditor限定の機能となります。

Google Sheetsとの対応は Extensions 項目の GoogleSheets を追加及び設定することで実現できます。
SpreadSheetsId, SheetsId (gid) を設定することで、Googleドライブ上のスプレッドシートと対応させます。

GoogleSheetsService

GoogleSheetsService (SheetsServiceProvider) は Googleのサービスとの認証関連を設定するScriptableObjectです。
今回はこちらを使用するため設定をお願いします。

認証の設定については以下のデニックさんの記事をどうぞ!

xrdnk.hateblo.jp

Tips

スクリプトStringTableCollection をPullする方法

簡単に説明すると GoogleSheets.PullIntoStringTableCollection に認証済みのSheetsServiceProvider, SpreadSheetsId, SheetsId を渡すことで可能となります。

結構説明が難しいのでサンプルコードを記載します。

    // 対象のStringTableCollectionを取得
    StringTableCollection collection = AssetDatabase.LoadAssetAtPath<StringTableCollection>("Assets/<path to StringTableCollection>");

    // GoogleSheetsExtensionをStringTableCollectionから取得
    var sheetsExtension = collection.Extensions.OfType<GoogleSheetsExtension>().FirstOrDefault();

    // Google認証設定を持つSheetsServiceProviderを取得
    SheetsServiceProvider serviceProvider = AssetDatabase.LoadAssetAtPath<SheetsServiceProvider>("Assets/<path to SheetsServiceProvider>");

    // Google Sheetsアクセス用インスタンスを生成
    var sheets = new GoogleSheets(serviceProvider);

    // ※必ずSpreadSheetIdをGoogleSheetsインスタンスに指定してください!
    sheets.SpreadSheetId = sheetsExtension.SpreadsheetId;

    // 対象のStringTableCollection内の全言語(全Locale)のPullを実施
    sheets.PullIntoStringTableCollection(
        sheetId: sheetsExtension.SheetId,
        collection: collection,
        columnMapping: sheetsExtension.Columns);

sheets.SpreadSheetId = sheetsExtension.SpreadsheetId; は必ず実施してください。
どうやら1.3.2時点ではInspectorのPullを押したときしか実施されていないようです。

上記コードが StringTableCollection のInspector上のPullボタンと似た処理を実施しています。

複数のStringTableCollectionを一括でPullする方法

スクリプトでPullする方法がわかったので、あとは対象のStringTableCollectionを取得して for ループで実行するのみです。

StringTableCollectionを一括で取得するEditorコード

自分は以下のような指定のフォルダ以下のアセットをまとめて取得するスクリプトを使用しています。

AssetFinding.cs

余談、公式サンプルコードについて

上記のコードのヒントですが、Unity Localization に含まれている DocCodeSamples.Tests/GoogleSheetsSamples.cs に記載されています。

また、その他のサンプルコードも DocCodeSamples.Tests/ 以下にいくつかありますため参考にすると良いと思います!

拡張ツールの紹介

今回、以下のようなUnity Localitionの拡張ツールを用意しました。
いずれ提供される機能だとは思いますが、それまでの代用やコードの参考としてどうぞ。

github.com

StringTableCollectionBundle というScriptableObjectを設定することでまとめてPull, Pushできるようにしています。
Pullには閲覧権限、Pushには編集権限が必要となります。

詳細な使用方法は README.md を参照ください。

サンプルコード

github.com

Assets/Example 以下に実装例があります。

雑感

まさかのローカライズ系の記事になりました。

前回の記事で絶対にXR系の記事を書くと述べたのですが、
どうもいい感じのネタになっておらず一旦別の記事に逃げることにしました。。。

さて、次回は今回の延長で「Unity LocalizationでGCPのサービスアカウントを使ってプライベートなSheetsと連携」みたいな記事を書く予定です。
(ちなみにこの機能はすでに UnityLocalizationExtension に組み込んであったりします。)

それでは~

GitHub ActionsのWindows self-hostedでpwshとbashを使う方法

概要

今回は GitHub ActionsのWindows self-hostedでpwshとbashを使う方法を紹介します。

残念ながら素のWindowsマシンでshellとしてpwshやbashを指定しても使用することができません。

Windows上でpwshとbashが使用できるとCIで行えるフローの選択肢が大幅に増えると思いますので、ぜひ参考にしていただければと思います!

動作環境

  • Windows10 Pro (Homeでも可)

pwshとbashを使用する条件

簡単に言えば、以下を満たせば良いです

  • Git for Windows (Git Bash) と PowerShell7 (pwsh付属) をインストール
  • Windowsのシステム環境変数のPATHに bash.exepwsh.exe があるフォルダを追加

手順

詳細はあとで記載します。

1. Git for Windows と PowerShell7 をインストール

以前紹介した記事を参考にGit for WindowsとPowerShell7をインストールしてください

tsgcpp.hateblo.jp

tsgcpp.hateblo.jp

2. システム環境変数のPATHに登録

Git for WindowsとPowerShell7をインストールしたフォルダをPATHに登録してください。
PATHを通すps1ファイル (set-git-path.ps1, set-powershell-path.ps1)も上記の記事内にて記載しています。

システム環境変数に直接以下を追加しても大丈夫です(標準のインストールフォルダの場合)。

C:\Program Files\Git\cmd;C:\Program Files\Git\bin;C:\Program Files\PowerShell\7

3. self-hostedのrunnerプロセスを再起動

環境変数をプロセスに反映させるために再起動してください。
再起動方法は以下の公式ドキュメントを参考にしてください。

docs.github.com

もし、PowerShell上でGitHub Actionsのself-hostedの .\run.cmd で起動している場合はPowerShell自体も再起動してください。

手順は以上となります!


詳細

確認用のworkflow

以下のworkflowを使って確認します。

name: Windows Shell Check

on: workflow_dispatch

jobs:
  shell-check-all:
    runs-on: self-hosted
    steps:
      - name: Check cmd
        shell: cmd
        run: |
          echo cmd ok
        if: ${{ always() }}

      - name: Check pwsh
        shell: pwsh
        run: |
          echo pwsh ok
        if: ${{ always() }}

      - name: Check bash
        shell: bash
        run: |
          echo bash ok
        if: ${{ always() }}

インストール前

以下のように cmd のみ使用可能で、それ以外は使用できません。。。

インストール後

問題なさそうですね。

活用例

せっかくなので、活用例を紹介します。

日付を文字列として取得

Bashdateコマンドを使って取得します。

name: Get Date String

on: workflow_dispatch

jobs:
  get-date-string:
    runs-on: self-hosted
    steps:
      - name: Get Date String
        run: echo "::set-output name=DATE::$(date +%Y%m%d%H%M)"
        shell: bash
        id: get-date-string
      - name: Use Date String
        run: echo "${{ steps.get-date-string.outputs.DATE }}"

Bashは手軽に文字列を生成可能なコマンドがたくさんあります!

レジストリから値を取得

以下はレジストリに登録されたUnity 2021.3.2f1 のインストールフォルダを取得する方法です。

name: Get Unity Folder

on: workflow_dispatch

jobs:
  get-unity-folder:
    runs-on: self-hosted
    steps:
      - name: Get Unity Folder
        run: Write-Host -NoNewline ('::set-output name=UNITY_FOLDER::' + (Get-ItemProperty 'HKCU:SOFTWARE\Unity Technologies\Installer\Unity 2021.3.2f1').'Location x64')
        shell: pwsh
        id: get-unity-folder
      - name: Use Unity Folder
        run: echo "${{ steps.get-unity-folder.outputs.UNITY_FOLDER }}"

Windowsのシステムに関わる情報を取得したい場合はPowerShellのほうが適している気がします。

余談、GitHub Actionsで提供されているWindowsのRunnerでは標準でpwshとbashが使用可能

PowerShellはともかく、Bashも使用できるように用意してくれていたりします。

ドキュメントでも以下のように記載されています。
GitHub ActionsもGit for Windowsを使用しているようですね。

When specifying a bash shell on Windows, the bash shell included with Git for Windows is used.

docs.github.com

windows-latestでpwshとbashが使用可能なことを確認

先程のworkflowのrunnerにwindows-latestを指定して確認してみましょう。

jobs:
  shell-check-all:
    strategy:
      matrix:
        runner: [self-hosted, windows-latest]
    runs-on: ${{ matrix.runner }}
    steps:

結果は以下のように問題なく使用可能です!

雑感

最近Git for WindowsとPowerShell7のインストール方法を紹介した本当の目的はこちらだったりします。

私個人では開発基盤を用意する機会が増えているんですが、
pwshとbash (特にbash!)をGitHub Actionsで使えるようにしておくと何かと捗ったり、self-hostedとwindows-latestでworkflowの互換性が確保できたりします。

わざわざGitHub Marketplaceや独自アクションを使用しなくてもできることが増えると思いますので、ぜひご活用ください!

それでは~

pwshをコマンドラインでインストールしてPATHを通す方法

概要

前回 に続いて、今回はWindowsコマンドラインで PowerShell7 をインストールする方法を紹介します。

github.com

PowerShell5はWindowsに標準で搭載されていますが、pwsh コマンドは実行できません。 そこでPowerShell7を別途インストールすることで pwshコマンドとして使用可能になります。

そんなPowerShell7をコマンドライン経由でインストールしPATHを登録する方法を紹介します。

動作環境

  • Windows 10
    • 筆者はPro版ですが、Homeでも問題ないと思います

変更履歴

  • 2022/05/18
  • 2023/02/05
    • 「install-powershell.ps1 を実行」を PowerShell -ExecutionPolicy Bypass を使ったコマンドに修正

注意事項

  • 以下を試す場合は自己責任でお願いします!
  • 以下の処理は管理者権限で実行しますが、当方は事故や故障などの一切の責任を負いません!
  • 環境変数 PATH を変更するため、変更前のPATHを記録しておくことを推奨!

上記の件、ご留意ください

インストール方法

前回の記事 (Git for Windowsをコマンドラインからインストールする方法) と流れはほとんど同じです

Gitからps1ファイルをクローン

github.com

PowerShellを管理者権限で開く

※開き方は好きなやり方で問題ありません

Windows 10標準搭載のPowerShell5を使用

  • Windows キーを押して、「powershell」を検索
  • 「管理者として実行する」をクリック

PowerShellでクローンしたWindowsInfrastructureExample上に移動

cd <path>\WindowsInfrastructureExample

PowerShell-7.x.x-win-x64.msi を配置

コマンドでダウンロードする場合

Invoke-WebRequest https://github.com/PowerShell/PowerShell/releases/download/v7.2.3/PowerShell-7.2.3-win-x64.msi -OutFile PowerShell-7.2.3-win-x64.msi

※インフラ構築のスクリプトを書く場合は、このコマンド自体をps1スクリプトに組み込んでも良い

ブラウザでダウンロードする場合

以下より PowerShell-7.x.x-win-x64.msiをダウンロードし、WindowsInfrastructureExample フォルダ以下に配置

github.com

ps1の実行を許可

Set-ExecutionPolicy RemoteSigned -Scope Process

install-powershell.ps1 を実行

> PowerShell -ExecutionPolicy Bypass .\install-powershell.ps1

エラーログが出なければ問題なく PowerShell7 のインストール完了

インストールの確認 (コマンドラインの場合)

  • Dir 'C:\Program Files\PowerShell\7\pwsh.exe'でエラーが出なければインストール完了
Dir 'C:\Program Files\PowerShell\7\pwsh.exe'


    ディレクトリ: C:\Program Files\PowerShell\7


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----        2022/04/15     20:07         287664 pwsh.exe

(任意)PATHの追加

ローカルPCの環境変数 PATH が変更されます!ご注意ください!

PowerShell -ExecutionPolicy Bypass .\set-powershell-path.ps1

以上でインストール及び設定は完了です。


スクリプト解説

install-powershell.ps1

msiexecによるインストール

PowerShell-7.x.x-win-x64.msi のインストールオプション

詳細は以下のページに記載されています

docs.microsoft.com

INSTALLFOLDER

名前の通りインストール先のフォルダを指定。
以下のように指定すると良いです。

$InstallFolder = 'C:\Program Files\PowerShell'
(略)
msiexec.exe `
    ...
    INSTALLFOLDER=`"$InstallFolder`" `
    ...

ちなみに、C:\Program Files\PowerShell を指定した場合、pwsh‪C:\Program Files\PowerShell\7\pwsh.exeに配置されます。

また、スクリプト内でフォルダ名を`"XXX`" のように囲むのは不思議な感じがしますが、これで問題ありません。

Windowsコマンドラインはスペースの扱いがめんどくさいですね。。。それなのに "Program Files" はスペースが有るという。。。

ADD_PATH=0

PATHに追加するかのオプションです。ADD_PATH=1 にすればパスを通してくれます。
今回はコマンドラインで諸々完結させたかったので0指定(登録しない)にしています。

USE_MU=0, ENABLE_MU=0

いわゆるMicrosoft Updateによる自動更新の設定です。
毎回コマンドラインで更新することを想定し、オフにしています。

解説は以上です!


雑感

Git for Windows, PowerShell7 のインストール方法を紹介したのは、bash, pwsh をインストールするためだったりします。

次回はこの2つを活用したCIについてお話します!
もしかしたらピンと来た方がいらっしゃるかもですね。

さらにその次は絶対にUnity + XR関連にします(そっちのほうが断然楽しいし、何よりブログのタイトル的に。。。)

PATHを変更するような手順の紹介は、試した人の環境を壊しかねないので結構神経を使いますね。。。

それでは~