package.jsonのリリースバージョンをGitHub Actionsで更新する

こんにちは、フロントエンドエンジニアの堀江(@nandemo_3_)です。

フロントエンドチームの業務改善の観点から、以前からissueに上がっていた本件を調査、対応しましたので備忘録として紹介いたします。

背景

フロントエンドチームが開発、運用しているSYNMNアプリの管理画面では、HTMLヘッダーのメタ情報に、package.jsonのversionを指定しております。

現状、こちらのバージョン管理は手動で行なっており、以前から手間という認識をしておりました。

緊急性は高くありませんが、業務改善となるので取り組んでみました。

現状

以下のように、package.jsonのversionをHTMLヘッダーのメタ情報にバージョンを指定しています。

// package.json
{
  "name": "app",
  "version": "0.1.2",
  "scripts": {
    "build": "cross-env NEXT_PUBLIC_APP_PACKAGE_VERSION=$npm_package_version next build",
    // 省略....
}
// _app.tsx
      <Head>
        <title key="title">{siteTile}</title>
        <meta name="viewport" content="initial-scale=1, width=device-width" />
        <link rel="icon" type="image/png" href="/favicon.png" />
        <meta name="synmn:version" content={`${process.env.NEXT_PUBLIC_APP_PACKAGE_VERSION}`} />
      </Head>

また、管理画面は企業様向けのPlanet機能と、アドミン権限向けのProvider機能で分かれており、

ソースコードおよびpackage.json(今回更新する対象)もフォルダで分割されております。

.
└── apps
    ├── planet-app
    │   └── package.json
    └── provider-app
        └── package.json

ゴール

  • GitHub Actionsを用いて、実現する
  • 作業ブランチからmainにPRした際に、package.jsonのversionを自動でインクリメントし、commitする(その後、手動でマージ)
  • planet-appおよびprovider-app配下のpackage.jsonどちらも更新する

参考にさせていただいたテック記事

こちらのテック記事を参考させていただきました。

使い方はPRのラベルに、release:major release:minor release:patchを付与することで、それぞれのバージョンが更新されます。

zenn.dev

ルート直下のpackage.jsonのversionを更新する

テック記事のコードが動作するか検証します。(大事)

エラー発生

GitHub Actionsのワークフローでエラーが発生。 そう簡単にはいきません。

エラー①

ワークフローのログにはpnpm: command not foundと表示されております。

package.jsonのversionをpnpm versionを用いて更新しているので、

uses: pnpm/action-setup@v2を追加してpnpmを使えるようにします。

# 省略...
  update_version:
    # 省略...
    steps:
      # 省略...
      - uses: pnpm/action-setup@v2
        with:
          version: 7.1.0

# 省略...

エラー②

package.jsonのversionが変わりません。

pnpm version --majorの動作確認をローカルで実施しましたが、やはり変わっていません。

% pnpm version --major --no-git-tag-version 
v3.0.0
% pnpm version --major --no-git-tag-version
{
  'gh-action-release-ver-increment': '3.0.0',
  npm: '9.4.2',
  node: '16.14.2',
  v8: '9.4.146.24-node.20',
  uv: '1.43.0',
  zlib: '1.2.11',
  brotli: '1.0.9',
  ares: '1.18.1',
  modules: '93',
  nghttp2: '1.45.1',
  napi: '8',
  llhttp: '6.0.4',
  openssl: '1.1.1n+quic',
  cldr: '40.0',
  icu: '70.1',
  tz: '2021a3',
  unicode: '14.0',
  ngtcp2: '0.1.0-DEV',
  nghttp3: '0.1.0-DEV'
}

pnpm version --majorだとバージョンが変わらないので、pnpm version majorに変更しました。(minorとpatchも同様です)

ローカルでの動作確認でversionが変わっていることを確認。

% pnpm version major --no-git-tag-version 
v4.0.0
% pnpm version --major --no-git-tag-version
{
  'gh-action-release-ver-increment': '4.0.0',
  npm: '9.4.2',
  node: '16.14.2',
  v8: '9.4.146.24-node.20',
  uv: '1.43.0',
  zlib: '1.2.11',
  brotli: '1.0.9',
  ares: '1.18.1',
  modules: '93',
  nghttp2: '1.45.1',
  napi: '8',
  llhttp: '6.0.4',
  openssl: '1.1.1n+quic',
  cldr: '40.0',
  icu: '70.1',
  tz: '2021a3',
  unicode: '14.0',
  ngtcp2: '0.1.0-DEV',
  nghttp3: '0.1.0-DEV'
}

こちら、ソース上での差分となります。

エラー③

ワークフローのGitコマンドを実行した際に、403エラーが発生しました。

GitHub ActionsのWorkflowにリポジトリに対する書き込み権限がないため、エラーとなっているので、権限を付与しました。

リポジトリのSettings -> Actions -> General -> Workflow permissions の順に遷移し、

Read and write permissionsに変更しました。

無事成功

これで、ルート直下のpackage.jsonがバージョンアップできました。

ソースコード

ここまでのソースコード全文はこちらです。

GitHub - nandemo3/gh-action-release-version-increment at package_in_root

name: Update release version in package.json
on:
 pull_request:
   branches:
     - main
   types:
     - labeled
     - unlabeled
jobs:
  check_release_label:
    runs-on: ubuntu-latest
    steps:
      - name: Check release label
        if: |
          !contains(github.event.pull_request.labels.*.name, 'release:patch') &&
          !contains(github.event.pull_request.labels.*.name, 'release:minor') &&
          !contains(github.event.pull_request.labels.*.name, 'release:major')
        run: |
          echo "::error::リリースラベルを付与してください。labels: release:patch, release:minor, release:major"
          exit 1
  version_diff:
    if: |
      contains(github.event.pull_request.labels.*.name, 'release:patch') ||
      contains(github.event.pull_request.labels.*.name, 'release:minor') ||
      contains(github.event.pull_request.labels.*.name, 'release:major')
    runs-on: ubuntu-latest
    outputs:
      chagned: ${{ steps.get_diff.outputs.changed }}
    steps:
      - uses: actions/checkout@v3

      - name: Get branch to marge
        run: git fetch origin ${{ github.base_ref }} --depth=1

      - name: Keep version changes
        id: get_diff
        run: echo "changed=$(git diff origin/${{ github.base_ref }} HEAD --relative "./package.json" | grep "^+.\+version" | wc -l)" >> $GITHUB_OUTPUT
  update_version:
    runs-on: ubuntu-latest
    needs: [version_diff]
    if: needs.version_diff.outputs.chagned == '0'
    steps:
      - uses: actions/checkout@v3
        with:
          ref: ${{ github.event.pull_request.head.ref }}

      - uses: actions/setup-node@v3
        with:
          node-version: 16

      - uses: pnpm/action-setup@v2
        with:
          version: 7.1.0

      - name: Set git information
        if: steps.diff.outputs.changed == '0'
        run: |
          git config --global user.name 'github-actions[bot]'
          git config --global user.email 'github-actions[bot]@users.noreply.github.com'
          git remote set-url origin https://github-actions:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Update version(patch)
        if: contains(github.event.pull_request.labels.*.name, 'release:patch')
        run: pnpm version patch --no-git-tag-version

      - name: Update version(minor)
        if: contains(github.event.pull_request.labels.*.name, 'release:minor')
        run: pnpm version minor --no-git-tag-version

      - name: Update version(major)
        if: contains(github.event.pull_request.labels.*.name, 'release:major')
        run: pnpm version major --no-git-tag-version

      - name: Commit and push to PR
        run: |
          git add .
          git commit -m "v$(grep version package.json | awk -F \" '{print $4}')"
          git push origin HEAD

実環境に合わせて実装

フォルダ構成に合わせ、PlanetとProviderのそれぞれのpackage.jsonを更新します。

デモのため、便宜上各フォルダをApp1とApp2にしています。

元々のyamlでは、ルートのpackage.jsonをターゲットにしているので、各フォルダ配下をターゲットにするよう変更します。

具体的には、実行コマンドの前にworking-directoryを入れて、ディレクトリを指定します。

# 省略...
jobs:
  version_diff_app1:
    if: |
      contains(github.event.pull_request.labels.*.name, 'release:patch') ||
      contains(github.event.pull_request.labels.*.name, 'release:minor') ||
      contains(github.event.pull_request.labels.*.name, 'release:major')
    runs-on: ubuntu-latest
    outputs:
      chagned: ${{ steps.get_diff.outputs.changed }}
    steps:
      - uses: actions/checkout@v3

      - name: Get branch to marge
        run: git fetch origin ${{ github.base_ref }} --depth=1

      - name: Keep version changes
        id: get_diff
        working-directory: ./App1
        run: echo "changed=$(git diff origin/${{ github.base_ref }} HEAD --relative package.json | grep "^+.\+version" | wc -l)" >> $GITHUB_OUTPUT
  version_diff_app2:
    if: |
      contains(github.event.pull_request.labels.*.name, 'release:patch') ||
      contains(github.event.pull_request.labels.*.name, 'release:minor') ||
      contains(github.event.pull_request.labels.*.name, 'release:major')
    runs-on: ubuntu-latest
    outputs:
      chagned: ${{ steps.get_diff.outputs.changed }}
    steps:
      - uses: actions/checkout@v3

      - name: Get branch to marge
        run: git fetch origin ${{ github.base_ref }} --depth=1

      - name: Keep version changes
        id: get_diff
        working-directory: ./App2
        run: echo "changed=$(git diff origin/${{ github.base_ref }} HEAD --relative package.json | grep "^+.\+version" | wc -l)" >> $GITHUB_OUTPUT
  update_version:
    runs-on: ubuntu-latest
    needs: [version_diff_app1, version_diff_app2]
    if: |
      needs.version_diff_app1.outputs.chagned == '0' ||
      needs.version_diff_app2.outputs.chagned == '0'
    steps:
    # 省略...
      - name: Update version App1(patch)
        if: contains(github.event.pull_request.labels.*.name, 'release:patch')
        working-directory: ./App1
        run: pnpm version patch --no-git-tag-version
    # 省略...
      - name: Update version App2(patch)
        if: contains(github.event.pull_request.labels.*.name, 'release:patch')
        working-directory: ./App2
        run: pnpm version patch --no-git-tag-version
# 省略...

ワークフロー実行中に競合が発生

各package.jsonごとに、変更をコミットしており、

片方のコミット後に、もう片方が変更しようとして競合が起きているようです。

# 省略...
  update_version_app1:
    # 省略...
      - name: Commit and push to PR
        working-directory: ./App1
        run: |
          git add .
          git commit -m "v$(grep version package.json | awk -F \" '{print $4}')"
          git push origin HEAD
  update_version_app2:
    # 省略...
      - name: Commit and push to PR
        working-directory: ./App2
        run: |
          git add .
          git commit -m "v$(grep version package.json | awk -F \" '{print $4}')"
          git push origin HEAD
# 省略...

特に、コミットを分ける理由がないので、1回にまとめるようにします。

# 省略...
      - name: Commit and push to PR
        working-directory: ./
        run: |
          git add .
          git commit -m "App1 v$(grep version './App1/package.json' | awk -F \" '{print $4}'), App2 v$(grep version './App2/package.json' | awk -F \" '{print $4}')"
          git push origin HEAD
# 省略...

無事、2つとも更新することができました。

おまけ

参考にさせていただいたテック記事ですと、PRにラベルがないとワークフローがエラーとなりますが、

私としては、バージョンを上げたといときだけバージョンをあげ、それ以外は失敗せずマージさせたいので

ラベルのチェック処理は削除しました。

ソースコード

今回対応したソースコード全文はこちらです。

gh-action-release-version-increment/gh-action-release-version-increment.yml at package_in_two_folder · nandemo3/gh-action-release-version-increment · GitHub

name: Update release version in package.json
on:
 pull_request:
   branches:
     - main
   types:
     - labeled
jobs:
  version_diff_app1:
    if: |
      contains(github.event.pull_request.labels.*.name, 'release:patch') ||
      contains(github.event.pull_request.labels.*.name, 'release:minor') ||
      contains(github.event.pull_request.labels.*.name, 'release:major')
    runs-on: ubuntu-latest
    outputs:
      chagned: ${{ steps.get_diff.outputs.changed }}
    steps:
      - uses: actions/checkout@v3

      - name: Get branch to marge
        run: git fetch origin ${{ github.base_ref }} --depth=1

      - name: Keep version changes
        id: get_diff
        working-directory: ./App1
        run: echo "changed=$(git diff origin/${{ github.base_ref }} HEAD --relative package.json | grep "^+.\+version" | wc -l)" >> $GITHUB_OUTPUT
  version_diff_app2:
    if: |
      contains(github.event.pull_request.labels.*.name, 'release:patch') ||
      contains(github.event.pull_request.labels.*.name, 'release:minor') ||
      contains(github.event.pull_request.labels.*.name, 'release:major')
    runs-on: ubuntu-latest
    outputs:
      chagned: ${{ steps.get_diff.outputs.changed }}
    steps:
      - uses: actions/checkout@v3

      - name: Get branch to marge
        run: git fetch origin ${{ github.base_ref }} --depth=1

      - name: Keep version changes
        id: get_diff
        working-directory: ./App2
        run: echo "changed=$(git diff origin/${{ github.base_ref }} HEAD --relative package.json | grep "^+.\+version" | wc -l)" >> $GITHUB_OUTPUT
  update_version:
    runs-on: ubuntu-latest
    needs: [version_diff_app1, version_diff_app2]
    if: |
      needs.version_diff_app1.outputs.chagned == '0' ||
      needs.version_diff_app2.outputs.chagned == '0'
    steps:
      - uses: actions/checkout@v3
        with:
          ref: ${{ github.event.pull_request.head.ref }}

      - uses: actions/setup-node@v3
        with:
          node-version: 16

      - uses: pnpm/action-setup@v2
        with:
          version: 7.1.0

      - name: Set git information
        if: steps.diff.outputs.changed == '0'
        run: |
          git config --global user.name 'github-actions[bot]'
          git config --global user.email 'github-actions[bot]@users.noreply.github.com'
          git remote set-url origin https://github-actions:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Update version App1(patch)
        if: contains(github.event.pull_request.labels.*.name, 'release:patch')
        working-directory: ./App1
        run: pnpm version patch --no-git-tag-version

      - name: Update version App1(minor)
        if: contains(github.event.pull_request.labels.*.name, 'release:minor')
        working-directory: ./App1
        run: pnpm version minor --no-git-tag-version

      - name: Update version App1(major)
        if: contains(github.event.pull_request.labels.*.name, 'release:major')
        working-directory: ./App1
        run: pnpm version major --no-git-tag-version

      - name: Update version App2(patch)
        if: contains(github.event.pull_request.labels.*.name, 'release:patch')
        working-directory: ./App2
        run: pnpm version patch --no-git-tag-version

      - name: Update version App2(minor)
        if: contains(github.event.pull_request.labels.*.name, 'release:minor')
        working-directory: ./App2
        run: pnpm version minor --no-git-tag-version

      - name: Update version App2(major)
        if: contains(github.event.pull_request.labels.*.name, 'release:major')
        working-directory: ./App2
        run: pnpm version major --no-git-tag-version

      - name: Commit and push to PR
        working-directory: ./
        run: |
          git add .
          git commit -m "App1 v$(grep version './App1/package.json' | awk -F \" '{print $4}'), App2 v$(grep version './App2/package.json' | awk -F \" '{print $4}')"
          git push origin HEAD

最後に

package.jsonのversion更新をGitHub Actionsを用いて行なってみました。

弊社プロダクトのフォルダ構成が少し特殊だったので、ちょっと苦戦しましたが、無事できました。

どこかの誰かに参考になれば幸いです。

参考文献

GitHub Actions を使ってラベルで package.json の version を更新する

GitHub - pnpm/action-setup: Install pnpm package manager

npm-version | npm Docs

python - Permission denied to github-actions[bot] - Stack Overflow

C#からC/C++のネイティブプラグインを使用するときに注意すること

こんにちは、エンジニアリングマネージャーの渡辺(@mochi_neko_7)です。

本記事では、Unity/C#からC/C++で作られたネイティブプラグインを呼び出す、いわゆるC# Bridgeなどを作成する際に知っておくべきこと、注意すべきことを紹介します。

ネイティブプラグインを自分で触っているとUnityをクラッシュさせてしまうことも多いと思いますが、通常のように分かりやすいログが出るわけではないため何が原因なのか分かりにくいことも多いのではないでしょうか?

最近はUnityはC#のIL2CPPビルドでパフォーマンスも改善されていますし、そこまでC/C++を触るケースが多くないのかネットにあまり情報も多くはない印象ですが、知らないとデバッグ自体しづらいことも多いです。

自分が雰囲気でコードを書いていたらすぐにUnityをクラッシュさせてしまったので、そのデバッグ過程で得られた理解を整理して共有できればと思います。

もし自分と同じように何かのC/C++ライブラリのC# Bridgeを自分で作りたい・作らざるを得ないけどよく分からないという方の参考になれば幸いです。

背景

先日、WebAssemblyのランタイムを触ってUnity上でWebAssemblyのプログラムを動かしてみる記事を書きました。

synamon.hatenablog.com

記事を書き始めた時点では結局どのランタイムを使うべきか悩んでいたのですが、Unityで使用することを想定するならきちんと主要なプラットフォームには対応しているべきだと思い、最も理想的な選択肢「Wasmerの最新版をネイティブプラグインとして組み込み、C# Bridgeの部分を自作する」を取るべきだと思いました。

少しずつWasmのAPIの対応を進めているのがこちらのRepositoryです。

github.com

ところがいざ自分で0からコードを書いていると、これまで基本的にはC#と3rd Partyのライブラリを使うだけで完結していたことがほとんどで、C/C++製のライブラリを自分で組み込んだり、一からUnsafeなコードを書いたりした経験がないことを改めて思い知らされました。

ここ3週間ほどで何度も何度も、もう余裕で100回以上はUnityをクラッシュさせました...

テストコードを書きながら試行錯誤しつつ改めてC/C++やネイティブプラグインの基礎知識を勉強し、理解できたことを整理して紹介します。

前提

まず今回紹介する内容を検証していた状況を書きます。

ご自身で開発される環境とは異なる場合もあると思いますのでご注意ください。

環境

  • PC: M2 MacBook Air
  • OS: macOS 13.1
  • Unity: 2021.3.0f1(Apple Silicon版)
  • Editor: Rider

対象

対象とするライブラリは、WebAssembly(Wasm)のランタイムの一つであるWasmer v3.1.1で、これはRustで書かれているものですが、WasmのC-APIに準拠したWasmerのC-APIも提供されています。

github.com

公式のReleaseにAppleSilicon版macOS(Darwin ARM64bit)向けのビルドが用意されているので、この .dylib を利用してUnity Editor上で動作検証をしています。

github.com

もし自分の環境のビルドがない場合は、自分でライブラリのビルドをするか、Dockerなどの環境を使用することになりますが、今回は触れません。

今回はこのCライブラリをC#のP/Invoke(プラットフォーム呼び出し)で利用する形になります。

参考となる実装

今回WasmerのC# Bridgeを自分で実装するにあたって参考になる実装は2つありました。

  • 古いバージョンのWasmerのC# Bridge
    • 一応公式からリンクが貼られているものです
    • バージョンの問題でAPIが異なることに注意が必要です
    • 良くも悪くも1スクリプトにまとめられているので、そのまま参考にするのは難しいです
  • 最新版のWasmtimeのC# Bridge
    • 前回の記事で自分がUnityに組み込んだものです
    • WasmerとWasmtimeで少しAPIが異なること注意が必要です
    • 実装は比較的綺麗に分けられているため、コピペではうまくいかないもののかなり参考になりました

どういう意図でその実装方法をとっているのかまで理解できないと自分のコードには落とせない(分かったつもりで適当に書くとすぐクラッシュする)ので、特に後者の実装を参考にしつつも最終的には自分で0から書いています。

マーシャリング

よく知られているようにC#ではGarbage Collectionの動的なメモリ管理がされていてManagedと呼ばれる一方、C/C++では自身でメモリ管理をする必要がありUnmanagedと呼ばれます。

これらのメモリ管理の境界は厳格に引かれているため、これらの間で適切なデータの受け渡し(マーシャリング)をきちんとする必要があります。

こちらの記事の後半の図が分かりやすいです。

tech.blog.aerie.jp

他にもこれらの内容が参考になると思いますので、初めての方は目を通しておくと良いかもしれません。

learn.microsoft.com

Unityで使うC#/DLLマーシャリング事典 (技術の泉シリーズ(NextPublishing)) | 山田 英伸 | 工学 | Kindleストア | Amazon

プリミティブ型などでは自動的にマーシャリングができるものもありますが、C/C++側でstructなどで定義されている独自のデータ型をP/Invokeで扱う場合には自分でマーシャリングをする必要があります。

マーシャリングを不適切に行うとOutOfMemoryエラー、アプリケーションのクラッシュに繋がるため細心の注意が必要です。

具体例

structをマーシャリングする具体例として、配列があります。

Wasmのライブラリとのbyte配列のやり取りには、wasm_byte_vec_t という型を使用します。

C:

#define WASM_DECLARE_VEC(name, ptr_or_none) \
  typedef struct wasm_##name##_vec_t { \
    size_t size; \
    wasm_##name##_t ptr_or_none* data; \
  } wasm_##name##_vec_t; \
  \
  // 省略

// Byte vectors

typedef byte_t wasm_byte_t;
WASM_DECLARE_VEC(byte, )

C#では例えば下記のようなstructとして実装を用意してマーシャリングすることができます。

C#:

[StructLayout(LayoutKind.Sequential)]
internal readonly unsafe struct ByteVector : IDisposable
{
    internal readonly nuint size;
    internal readonly byte* data;

    public void Dispose()
    {
         // 省略
    }

    // 省略
}
  • size ... C:size_t <-> C#:nuintUIntPtr
  • data ... C:byte_t <-> C#:byte* or IntPtr

の対応関係になっています。

このC#で定義した ByteVector は例えば下記のようにP/Invokeの引数に直接使用することが可能です。

C:

WASM_API_EXTERN void wasm_##name##_vec_new_empty(own wasm_##name##_vec_t* out);

C#:

[DllImport(NativePlugin.LibraryName)]
public static extern void wasm_byte_vec_new_empty(out ByteVector vector);

ここではデータ構造のみ抜粋していますが、全文はこちらです。

wasmer-unity/ByteVector.cs at 8f81e9559951e965c933a9a73ee539a7185553bd · mochi-neko/wasmer-unity · GitHub

失敗例

例えば下記のように sizedata の順番を入れ変えると、マーシャリングに失敗し、意図しないデータになります。

[StructLayout(LayoutKind.Sequential)]
internal readonly unsafe struct ByteVector : IDisposable
{
    internal readonly byte* data;
    internal readonly nuint size;

    public void Dispose()
    {
         // 省略
    }

    // 省略
}

ネイティブオブジェクトの生成・破棄

ネイティブ(C/C++)側で管理されるオブジェクトをC#側で触る時には、APIを通してネイティブ側で初期化を行い、不要になった時にもやはりAPIを呼び出してネイティブ側でリソースの解放をしなくてはなりません。

ネイティブ側のオブジェクトを勝手にC#側で作ってもネイティブに渡すとエラーになりますし、逆にネイティブ側でのメモリ解放を明示的に行うことを怠るといわゆるメモリーリークにつながります。

WasmのAPIは特にライブラリの利用者側で色々セットアップをする仕組みになっているため、ネイティブ側のオブジェクトをC#で色々触ることになります。

具体例

初期化時に使用する Config というオブジェクトを、ランタイム固有の設定を省略した空の実装は例えばこのように書くことができます。

    [OwnPointed]
    public sealed class Config : IDisposable
    {
        [return: OwnReceive]
        public static Config New()
        {
            return new Config(WasmAPIs.wasm_config_new(), hasOwnership: true);
        }

        private Config(IntPtr handle, bool hasOwnership)
        {
            this.handle = new NativeHandle(handle, hasOwnership);
        }

        public void Dispose()
        {
            handle.Dispose();
        }

        private readonly NativeHandle handle;

        internal NativeHandle Handle
        {
            get
            {
                if (handle.IsInvalid)
                {
                    throw new ObjectDisposedException(typeof(Config).FullName);
                }

                return handle;
            }
        }

        internal sealed class NativeHandle : SafeHandleZeroOrMinusOneIsInvalid
        {
            public NativeHandle(IntPtr handle, bool ownsHandle)
                : base(ownsHandle)
            {
                SetHandle(handle);
            }

            protected override bool ReleaseHandle()
            {
                WasmAPIs.wasm_config_delete(handle);
                return true;
            }
        }

        private static class WasmAPIs
        {
            [DllImport(NativePlugin.LibraryName)]
            [return: OwnReceive]
            public static extern IntPtr wasm_config_new();

            [DllImport(NativePlugin.LibraryName)]
            public static extern void wasm_config_delete(
                [OwnPass] [In] IntPtr handle);
        }
    }

wasmer-unity/Config.cs at 8f81e9559951e965c933a9a73ee539a7185553bd · mochi-neko/wasmer-unity · GitHub

Own〇〇 のようなAttributeは独自に定義しているものですが、その意図は所有権の話で説明します。

生成に

public static extern IntPtr wasm_config_new();

破棄に

public static extern void wasm_config_delete(IntPtr handle);

を使用しているのが分かると思います。

SafeHandle でラップしている部分は別の節で紹介します。

このような生成・破棄のAPIを基本として、各オブジェクト固有の生成方法や関数を適宜実装していくイメージです。

失敗例

下記のように delete を呼ぶのを忘れるとネイティブ側でメモリーリークが発生します。

  public sealed class Config : IDisposable
  {
      [return: OwnReceive]
      public static Config New()
      {
            return new Config(WasmAPIs.wasm_config_new(), hasOwnership: true);
      }

      private Config(IntPtr handle, bool hasOwnership)
      {
          this.handle = new NativeHandle(handle, hasOwnership);
      }

      public void Dispose()
      {
          // handle.Dispose();
      }
  }

オブジェクトの所有権

WasmerのC-APIにはオブジェクトの所有権の概念があります。

github.com

所有権の概念はWasmの仕様書には見られないですが、元のライブラリがRustで実装されているのを考えると、Rustの言語仕様のOwnership(所有権)やそのBorrow(借用)をそのまま引きずっているのでしょうか?

下記はWamserのビルドの wasm.h に書かれているものです。

// Ownership

#define own

// The qualifier `own` is used to indicate ownership of data in this API.
// It is intended to be interpreted similar to a `const` qualifier:
//
// - `own wasm_xxx_t*` owns the pointed-to data
// - `own wasm_xxx_t` distributes to all fields of a struct or union `xxx`
// - `own wasm_xxx_vec_t` owns the vector as well as its elements(!)
// - an `own` function parameter passes ownership from caller to callee
// - an `own` function result passes ownership from callee to caller
// - an exception are `own` pointer parameters named `out`, which are copy-back
//   output parameters passing back ownership from callee to caller
//
// Own data is created by `wasm_xxx_new` functions and some others.
// It must be released with the corresponding `wasm_xxx_delete` function.
//
// Deleting a reference does not necessarily delete the underlying object,
// it merely indicates that this owner no longer uses it.
//
// For vectors, `const wasm_xxx_vec_t` is used informally to indicate that
// neither the vector nor its elements should be modified.
// TODO: introduce proper `wasm_xxx_const_vec_t`?

このため、WasmのAPIを呼び出す際には own のキーワードの有無を見てそのパラメータと戻り値の所有権が移るかどうかを考えながら実装をする必要があります。

ただこの own キーワードが書かれているのは当然C側で、C#側の実装を書いている途中にいちいち確認するのは手間かつ事故の元になるため、自作のAttributeをマーカーとして付けています。

具体例

自分が所有権で一番最初に躓いたのは、Configを使用したEngineの初期化処理でした。

    [OwnPointed]
    public sealed class Engine : IDisposable
    {
        [return: OwnReceive]
        public static Engine New()
        {
            return new Engine(
                WasmAPIs.wasm_engine_new(),
                hasOwnership: true);
        }

        [return: OwnReceive]
        public static Engine New([OwnPass] Config config)
        {
            if (config is null)
            {
                throw new ArgumentNullException(nameof(config));
            }

            var engine = new Engine(
                WasmAPIs.wasm_engine_new_with_config(config.Handle),
                hasOwnership: true);

            // Passes ownership to native.
            config.Handle.SetHandleAsInvalid();

            return engine;
        }

        private Engine(IntPtr handle, bool hasOwnership)
        {
            this.handle = new NativeHandle(handle, hasOwnership);
        }

        public void Dispose()
        {
            handle.Dispose();
        }

        private readonly NativeHandle handle;

        internal NativeHandle Handle
        {
            get
            {
                if (handle.IsInvalid)
                {
                    throw new ObjectDisposedException(typeof(Engine).FullName);
                }

                return handle;
            }
        }

        internal sealed class NativeHandle : SafeHandleZeroOrMinusOneIsInvalid
        {
            public NativeHandle(IntPtr handle, bool ownsHandle)
                : base(ownsHandle)
            {
                SetHandle(handle);
            }

            protected override bool ReleaseHandle()
            {
                WasmAPIs.wasm_engine_delete(handle);
                return true;
            }
        }

        private static class WasmAPIs
        {
            [DllImport(NativePlugin.LibraryName)]
            [return: OwnReceive]
            public static extern IntPtr wasm_engine_new();

            [DllImport(NativePlugin.LibraryName)]
            [return: OwnReceive]
            public static extern IntPtr wasm_engine_new_with_config(
                [OwnPass] [In] Config.NativeHandle config);

            [DllImport(NativePlugin.LibraryName)]
            public static extern void wasm_engine_delete(
                [OwnPass] [In] IntPtr handle);
        }
    }

wasmer-unity/Engine.cs at 8f81e9559951e965c933a9a73ee539a7185553bd · mochi-neko/wasmer-unity · GitHub

Configを渡してEngineを初期化するAPI

C#:

[DllImport(NativePlugin.LibraryName)]
public static extern IntPtr wasm_engine_new_with_config(Config.NativeHandle config);

はCでは下記のように定義されています。

C:

WASM_API_EXTERN own wasm_engine_t* wasm_engine_new_with_config(own wasm_config_t*);

この引数に own が付いているのが所有権を気にすべき目印で、説明の

// - an `own` function parameter passes ownership from caller to callee

にあるように、関数の引数に own が付く場合には所有権を caller(C#)から callee(C)に渡すことになります。

所有権を渡してしまったConfigはC#側ではもう使用できなくなるため、

// Passes ownership to native.
config.Handle.SetHandleAsInvalid();

のようにConfigの SafeHandle をCloseして利用できなくします。

するとConfigの Dispose() が呼ばれても ReleaseHandle() が呼ばれない、つまり void wasm_config_delete(IntPtr handle) も呼ばれなくなります。

失敗例

逆に下記のように Config をInvalidにせずに Dispose() を呼ぶだけで、所有権を持っていないオブジェクトのAPIを叩くことになりクラッシュします。

        [return: OwnReceive]
        public static Engine New([OwnPass] Config config)
        {
            if (config is null)
            {
                throw new ArgumentNullException(nameof(config));
            }

            var engine = new Engine(
                WasmAPIs.wasm_engine_new_with_config(config.Handle),
                hasOwnership: true);

            // Passes ownership to native.
            // config.Handle.SetHandleAsInvalid();

            return engine;
        }eturn engine;
    }

これはシンプルな例ですが、複数のパラメータを持ったり、オブジェクトを各所で複雑に受け渡しをしていると流石に追うのが難しくなってきますので目印のAttributeを付けるようにしています。

SafeHandleによるリソース解放

ネイティブ側で確保されたUnmanagedメモリを必ず解放するために、System.IDisposable を用いたDisposeパターンの実装が必要になります。

learn.microsoft.com

その際に生の IntPtr を扱うのではなく、それをラップしてくれる System.Runtime.InteropServices.SafeHandle を使用するとエラーハンドリング等をよしなに行ってくれます。

learn.microsoft.com

少し手間ではありますがSafeHandleの実装を各オブジェクト毎に用意しておき、delete以外のAPIの引数に指定しておくとAPIの見通しも良くなります。

ただし前節で説明したように、所有権を持っていないオブジェクトを破棄してしまうとクラッシュを引き起こしてしまうのでした。

そのような時に便利なのが SafeHandle.SetHandleAsInvalid()SafeHandle.ownsHandle です。

learn.microsoft.com

learn.microsoft.com

所有権をネイティブに渡してしまったオブジェクトをSafeHandle.SetHandleAsInvalid() によってCloseしておくと、Dispose() が呼ばれても ReleaseHandle() が呼ばれなくなり、不正な操作を防ぐことができます。

また、オブジェクト生成時に所有権を受け取っていない場合にもやはりdeleteのAPIを呼び出しはできないため、Constructorの引数の ownsHandlefalse を渡しておくことで、同様に ReleaseHandle() の呼び出しを抑制できます。

このような所有権をC# Bridgeの利用者側に意識させるのは流石に無理があるため、表面的には IDisposable を必ず呼ぶようにしておき、内部的には所有権を適切に扱っておく、という対処がベストでしょう。

具体例 / 失敗例

既に紹介した具体例で SafeHandle を使用しているため、改めて確認してみてください。

SafeHandle を利用する際は、用意しているPropertyの Handle を利用し、オブジェクトが破棄されているかのチェックが事前にかかるようにします。

まとめ

Unity/C#からC/C++のネイティブプラグインのAPIを呼び出して利用する際に注意すべきことを紹介しました。

  • データのマーシャリング、メモリレイアウトに細心の注意を払うこと
  • ネイティブ側で利用されているオブジェクトはAPIを通してネイティブ側で生成・破棄を行うこと
  • (Wasmの場合は)オブジェクトの所有権を考慮して適切に破棄処理を行うこと
  • SafeHandleを利用してなるべく安全にリソース解放をする方が良いこと

これらの取り扱いに失敗するとすぐにアプリケーションがクラッシュし、知らないとなかなか原因が分かりくいことも多いためとっつきづらいのですが、逆にこれらの仕組みが見えてくればちゃんとデバッグしていけると思います。

改めてC/C++のメモリ管理の大変さ、C#などのGarbage Collectionのありがたさを感じるとともに、RustのOwnershipは難しい概念ではありますがうまくできているんだなと思いました。

おわりに

自分がここ3週間ほど趣味で取り組んでいるWasmerのC# Bridgeの開発もまだ途中で、いったんHello Worldを動かすところまで行けるのですが、Native WasmのAPIもまだカバーしきれておらず、まだまだやることが残っています。

引き続いて実装していく中でVector系をSafeHandleで扱えないかやネイティブ側のメモリーリークの検出方法などまだちゃんと理解しきれていないことも進展があればまた何かの記事にしたいと思います。

Wasm/Wasmerの表面的なAPIだけではなく内部の仕様まで踏み込んで実装をしているので、Wasmの仕様そのものの理解に繋がっているのは大きい収穫でした。

Native Plugin! Unsafe! Pointer! Unmanaged! Crash! と初心者には近寄りがたいキーワードばかりの領域ですが、怖がらずに触ってみると意外と低レイヤーの基本的な仕組みの理解にもつながるため、もし触ってみたいネイティブのライブラリがありましたらぜひ挑戦してみてはいかがでしょうか。

Kinesis Data Firehose+S3を使ったログ基盤をTerraformで構築する

はじめに

こんにちは、エンジニアのクロ(@kro96_xr)です。

今回はサーバレスなログ基盤を構築、検証してみたため、その内容について書きたいと思います。

検証を実施した背景

弊社はSYNMNというアプリを提供していますが、将来的にアプリ内でのユーザー行動ログを収集して分析を行いたいという要望がありました。
イメージとしてはGoogle Analiticsを使ってWebサイトのユーザー動線や滞在時間を行うような感じでしょうか。

元々サーバログをCloudWatch Logsに流していたため、サーバ側で検知できるもの(API実行ログ、エラーログ等)については取得できています。
しかし、アプリ内の行動ログとなるとサーバを経由しない情報もあり、アプリから直接ログをPUTする方法を検討する必要がありました。
AWS SDKを使えばアプリから直接ログをPUTできるということで、Kinesis Firehose(以下Firehose)を検証することになりました。

インフラ構成と検証の流れ

検証した構成と流れは以下のようになります

  • 検証スクリプトからFirehoseにテスト用のログデータをPUT
  • FirehoseからLambda関数を呼び出してデータを変換する
    • 今回はログにタイムスタンプを追加します
  • 変換されたデータをS3に保存
  • (S3に保存したデータをAthenaで取得する)今回の記事では対象外とさせていただきます。

また、データ保存時は動的パーティショニングを使い、データのグループ化を行いたいと思います。

Terraformでの環境構築

それでは各リソースを作成するためのTerraformのコードを見ていきます。
以下の内容は社内検証用に書いたコードに手を加えたものになります。

S3

生データを保存するためのバケットを作成します。特に特殊なことはないかと思います。

# ログ用のバケット
resource "aws_s3_bucket" "main" {
  bucket = "analitics-logs"
}

# バージョニング設定
resource "aws_s3_bucket_versioning" "main" {
  bucket = aws_s3_bucket.main.id
  versioning_configuration {
    status = "Enabled"
  }
}

# サーバサイド暗号化設定
resource "aws_s3_bucket_server_side_encryption_configuration" "main" {
  bucket = aws_s3_bucket.main.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

Lambda

データ変換をかけるためのLambda関数を作成します。

今回はPythonを使ってデータが送信された時間をログに付与する処理を書いています。
Firehoseのメッセージイベントの中にある"approximateArrivalTimestamp"という項目をログに追加しています。
Amazon Kinesis Data Firehose で AWS Lambda を使用する - AWS Lambda

import json
import base64
import datetime

def lambda_handler(event, context):

    results = []
    records = event["records"]
    for record in records:
        recordId = record["recordId"]
        data = record["data"]
        unixtime_micro = record["approximateArrivalTimestamp"]
        unixtime_milli = unixtime_micro//1000
        # Base64からデコード
        decoded_data = base64.b64decode(data).decode("utf-8")

        # JSONの処理
        payload = json.loads(decoded_data)        
        timestamp = datetime.datetime.fromtimestamp(unixtime_milli)
        payload['server_timestamp_utc'] = timestamp
        decoded_data = json.dumps(payload, default=str)
        
        # Base64に再エンコード
        data = base64.b64encode(decoded_data.encode())

        results.append({
            "result":"Ok",
            "recordId":recordId,
            "data":data
        })
        
    return {
        "records":results
    }

Terraformのコードは以下になります。

# ファイルのzip化をplan時に行う
data "archive_file" "add_timestamp_zip" {
  type        = "zip"
  source_dir  = "Zip化するソースディレクトリ"
  output_path = "Zipファイルの出力先"
}

# Function
resource "aws_lambda_function" "add_timestamp" {
  function_name = "firehose_add_timestamp"

  handler                        = "function.lambda_handler" #ファイル名.関数名
  filename                       = "${data.archive_file.add_timestamp_zip.output_path}"
  runtime                        = "python3.9"
  role                              = "${aws_iam_role.lambda_iam_role.arn}"
  source_code_hash        = "${data.archive_file.add_timestamp_zip.output_base64sha256}"
  timeout                        = 60 # 推奨値が60秒以上、サポートは5分未満
}

# IAMロール
resource "aws_iam_role" "lambda_iam_role" {
  name = "lambda_iam_role"

  assume_role_policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
POLICY
}

# Policy
resource "aws_iam_role_policy" "lambda_access_policy" {
  name   = "lambda_access_policy"
  role   = "${aws_iam_role.lambda_iam_role.id}"
  policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogStream",
        "logs:CreateLogGroup",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    }
  ]
}
POLICY
}

Firehose

続いてFirehose関連のリソースを作成します。
コンソールからFirehoseを作成する場合はIAMロールとポリシーの自動作成ができますが、Terraformから作成する場合は手動作成が必要です。
ポリシーの内容は使用する機能によって異なりますが、今回の構成では以下のようになります。変数は適宜設定してください。

また、ダイナミックパーティショニングの設定は、Athenaでスキャンするときの条件に影響するので分析の粒度に応じて設定した方が良さそうです。S3内をフルスキャンする羽目になります。

# ポリシーの作成とアタッチ
resource "aws_iam_policy" "main" {
  name   = "KinesisFirehoseServiceRole"
  policy = <<-EOT
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": [
            "s3:AbortMultipartUpload",
            "s3:GetBucketLocation",
            "s3:GetObject",
            "s3:ListBucket",
            "s3:ListBucketMultipartUploads",
            "s3:PutObject"
          ],
          "Resource": [
            "${var.s3_bucket_arn}",
            "${var.s3_bucket_arn}/*"
          ]
        },
        {
          "Effect": "Allow",
          "Action": [
            "kinesis:DescribeStream",
            "kinesis:GetShardIterator",
            "kinesis:GetRecords",
            "kinesis:ListShards"
          ],
          "Resource": "arn:aws:kinesis:ap-northeast-1:${var.aws_account}:stream/${var.stream_name}"
        },
        {
            "Effect": "Allow",
            "Action": [
                "lambda:InvokeFunction",
                "lambda:GetFunctionConfiguration"
            ],
            "Resource": "arn:aws:lambda:ap-northeast-1:${var.aws_account}:function:${var.lambda_function_name}:$LATEST"
        },
        {
          "Effect": "Allow",
          "Action": [
            "logs:PutLogEvents"
          ],
          "Resource": [
            "arn:aws:logs:ap-northeast-1:${var.aws_account}:log-group:/aws/kinesisfirehose/${var.stream_name}:log-stream:*"
          ]
        }
      ]
    }
  EOT
}

# ロール作成
resource "aws_iam_role" "main" {
  name = "FirehosePutLogs"
   managed_policy_arns = [
    aws_iam_policy.main.arn
  ]
 
  assume_role_policy = <<EOF
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Action": "sts:AssumeRole",
          "Principal": {
            "Service": "firehose.amazonaws.com"
          },
          "Effect": "Allow",
          "Sid": ""
        }
      ]
    }
  EOF
}

# Firehoseの作成
resource "aws_kinesis_firehose_delivery_stream" "main" {
  name        = var.stream_name
  destination = "extended_s3"
 
  extended_s3_configuration {
    role_arn            = aws_iam_role.main.arn
    bucket_arn          = var.s3_bucket_arn

    buffer_size = 64 # Dynamic Partitioningの場合は64MB以上
    buffer_interval = 60 # デフォルトは300

    # Dynamic Partitioningを有効化
    dynamic_partitioning_configuration {
      enabled = "true"
    }

    # カテゴリ/時間ごとにグルーピングする
    prefix              = "logs/category=!{partitionKeyFromQuery:category}/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/hour=!{timestamp:HH}/"
    error_output_prefix = "errors/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/hour=!{timestamp:HH}/!{firehose:error-output-type}/"

    processing_configuration {
      enabled = "true"

      # Multi-record deaggregation processor example
      processors {
        type = "RecordDeAggregation"
        parameters {
          parameter_name  = "SubRecordType"
          parameter_value = "JSON"
        }
      }
      # New line delimiter processor example
      processors {
        type = "AppendDelimiterToRecord"
      }
      # JQ processor example
      processors {
        type = "MetadataExtraction"
        parameters {
          parameter_name  = "MetadataExtractionQuery"
          parameter_value = "{category:.category}"
        }
        parameters {
          parameter_name  = "JsonParsingEngine"
          parameter_value = "JQ-1.6"
        }
      }
      # Lambda
      processors {
        type = "Lambda"

        parameters {
          parameter_name  = "LambdaArn"
          parameter_value = "${var.lambda_function_arn}:$LATEST"
        }
      }
    }
  }
}

以上でログのPUT先としてのFirehoseと、データ変換処理用のLambda、そしてログ保存用のS3の構築ができました。

テストデータをPUTする

それでは構築した環境に実際にデータをPUTしていきます。

検証用に雑に書いたコードを修正して掲載しています。色々許してください。

  • 環境
    • Python 3.10.0
    • Boto3 1.26.74
    • python-dotenv

python-dotenvでアクセスキーやシークレットキーを設定しておいてください。

import os
from boto3.session import Session
import json
from dotenv import load_dotenv
import random, string

class Logger:
    file = None
    def __init__(self, file_name):
        self.f = open(file_name, 'w')
    def __del__(self):
        self.f.close()
    def write(self, log):
        self.f.write(log.decode())

class AWSLogger:
    client = None
    stream_name = None
    def __init__(self):
        # AWS SDK情報の設定
        session = Session(
        aws_access_key_id=os.environ['ACCESS_KEY'],
        aws_secret_access_key=os.environ['SECRET_ACCESS_KEY'],
        region_name='ap-northeast-1')

        self.client = session.client('firehose')
        self.stream_name = os.environ['STREAM_NAME']
    def write(self, log):
        response = self.client.put_record(
        DeliveryStreamName = self.stream_name,
        Record={'Data': log})
        print(response)

def randomstring(n):
   return ''.join(random.choices(string.ascii_letters + string.digits, k=n))

def encode_to_json(data) -> str:
  json_str = json.dumps(data, default=str)
  return json_str

# ここから処理開始
# 環境変数の読込
load_dotenv()

# ロガー
# logger = Logger('random_log.csv')
logger = AWSLogger()

# ログを繰り返しPUTする
for i in range(50):
    log = {"id": i, "category": "test_blog","message": randomstring(32)}
    logger.write((encode_to_json(log) + "\n").encode())

上記を実行するとダイナミックパーティショニングによりS3のファイルが作成され

ログファイルが出力されます。 以下一部抜粋です。Lambdaの処理によりタイムスタンプが付与できていることが確認できます。(処理が早すぎて同時刻ですが)

{"id": 0, "category": "test_blog", "message": "gYS4lmMDij0OuO9S8gAcVCtwe3g85YEn", "server_timestamp_utc": "2023-02-24 03:25:57"}
{"id": 1, "category": "test_blog", "message": "VKqyuY28vG5VujDG9xRfNBSGI7p76cyo", "server_timestamp_utc": "2023-02-24 03:25:57"}
{"id": 2, "category": "test_blog", "message": "hWYxVdGUelprtcZLYcE8Ych85XC9ubvA", "server_timestamp_utc": "2023-02-24 03:25:57"}
{"id": 3, "category": "test_blog", "message": "kuwI9MtC62zFoj5fyldz92rMfT9M0VlH", "server_timestamp_utc": "2023-02-24 03:25:57"}
{"id": 4, "category": "test_blog", "message": "dNiVp1Ig2ay91E71VlSQkUceBlfqKxP9", "server_timestamp_utc": "2023-02-24 03:25:57"}
{"id": 5, "category": "test_blog", "message": "EMQZY0BaM5qwihLdc4qzJ3kaI7CsGaLj", "server_timestamp_utc": "2023-02-24 03:25:57"}
{"id": 6, "category": "test_blog", "message": "GzrIBKRXWrp6Gpau3PtscUXwukz1TBSQ", "server_timestamp_utc": "2023-02-24 03:25:57"}
{"id": 7, "category": "test_blog", "message": "HULj9qpgKPjxZ4nxOCD1KOnYORJ77WCU", "server_timestamp_utc": "2023-02-24 03:25:57"}
{"id": 8, "category": "test_blog", "message": "BgCwK4V0YUb772Eijx8ifln8uWo3hjdB", "server_timestamp_utc": "2023-02-24 03:25:57"}
{"id": 9, "category": "test_blog", "message": "tf79TKZJoYjKz8S9dpxxsfTWhevX6jMQ", "server_timestamp_utc": "2023-02-24 03:25:57"}
{"id": 10, "category": "test_blog", "message": "gfiuuWZYj3ZArADrNqqWz6yS2gL66twu", "server_timestamp_utc": "2023-02-24 03:25:57"}

おわりに

以上、比較的簡単にログを収集かつグルーピングまで行うことが出来ました。
このあとは、Athenaを使って必要なデータのみ取得し、BIツールに突っ込むような動きを想定しています。

しかし、実際にログを収集するにはプライバシーポリシーの訂正や同意の管理、GDPR対応の検討などシステム外でも色々やることがありますし、有用な分析のためにはログの内容の設計も必要になってきます。
また、アプリ側でのログ送信についてもバッチでまとめて送ったり、エラーハンドリングなど色々考慮することが多そうです。
まだまだやることはありそうですが引き続き色々検証していきます。

Unityのアプリ上でWebAssemblyを動かしてみる

こんにちは、エンジニアリングマネージャーの渡辺(@mochi_neko_7)です。

唐突ですが、自分は「WebAssembly(Wasm)」という技術はほとんどよく知らずに「Webブラウザ上でJavaScript以外のコードが高パフォーマンスで動かせる」くらいのものだと勝手に想像していて、UnityやC#でアプリケーションの開発をしている自分とは関わりが薄いだろうと思っていました。

ところが、たまたま別の調べ物をしていた際に次の記事を見つけ、Unityのアプリケーション上でWasmのコードを動かすことができることに衝撃を受けました。

zenn.dev

特に驚いたポイントは下記でした。

  • Wasmがブラウザだけではなくアプリの上でも動作すること
  • Wasmの作成は様々な言語で可能なこと
  • WasmのバイナリはOSやCPUアーキテクチャ別に用意する必要がないこと*1

通常のソフトウェア開発ではこれらの壁を意識しながら開発をするのが当たり前だと思っていたものが、その壁を取り払う壮大な(それに比例して大変な)取り組みだと認識しました。

それをきっかけにWasmがどんなものか、UnityでWasmを動かすためにはどうすればいいかなどが気になり、趣味で色々触るようになりました。

いったんの区切りとして、UnityにWasmランタイムをネイティブプラグインとして組み込み、macOSのUnityEditor上でWasmを動かすところまでできました。

今回はその過程で得られたWasmのランタイムとC#(.NET)周辺のエコシステム、Hello World、WasmのAoTコンパイルの理解、考えられるUnityでの活用方法などをご紹介します。

検証等に使用した環境は下記になります。

  • macOS 13.1 M2(Apple Silicon)
  • Unity 2021.3.0f1(Apple Silicon版)
  • Wasmtime 5.0.0

目次:

WebAssemblyの概要

WebAssembly(Wasm)についての詳しい説明は公式やMozillaのドキュメントをはじめとして様々な情報が出てくると思いますのであまり詳しくは説明しません。

Wasmの特徴を一部挙げるなら、下記でしょうか。

  • ネイティブ並みの実行パフォーマンス*2
  • セキュリティの担保*3
  • 特定の開発言語に依存しない*4
  • 動作環境に依存しない
  • オープンソース

Wasmの基本的な開発・実行フローは大まかに説明するとこのようなイメージです。

  1. Wasmへのコンパイルに対応している好きな開発言語でプログラムを作成する
  2. 作成したプログラムをコンパイルしてWasmバイナリ(.wasm)にビルドする
  3. Wasmランタイムを組み込んだ環境(対応しているブラウザ、もしくはWasmランタイムをライブラリとして組み込んだアプリケーション)で .wasm をJITコンパイルして実行する*5

Wasmは通常サンドボックス化されているため外部環境へアクセスするには一手間必要なのですが、WebAssembly System Interface(WASI)を利用することで直接OSの機能へのアクセスも可能になっています。

wasi.dev

最近はLinux Kernel内にWebAssemblyのラインタイムが実装されたりもしていました。

www.publickey1.jp

自分が当初イメージしていた「Webブラウザ上でJavaScript以外のコードが高パフォーマンスで動かせる」とは既にかけ離れていて、ブラウザには止まっていませんし、JavaScript以外どころかメジャーな言語はほぼ対応していることは驚きでした。

Javaの「Write once, run anywhere」を超えて、好きな言語で書いたコードがどんな環境でも動作する、という世界観が実現しうるのではないでしょうか。

Unityで利用可能なWasmランタイムは?

Wasmは規格なので実際に利用するためにはその実装としてのWasmランタイムを利用することになります。

ざっと調べて出てくる有名そうなWasmランタイムは下記でした。

  • ブラウザでのサポート*6
  • Node.jsでのサポート*7
  • Wasmtime
    • WasmランタイムのReference実装
  • Wasmer
    • Unstableな機能や広い対応言語、周辺ツールなども含んでいる
  • WasmEdge
    • 組み込みなどの環境向け
  • WAMR
    • Wasmランタイムの最小限の実装

他にもランタイム自体はたくさんあるようです。

github.com

これらを含め、Unityで利用可能なランタイムを調べたり触ってみたりしました。

Wasmer (WasmerSharp)

Wasmを調べるきっかけとなった記事ではWasmerを使っていたため、まずWasmerから触ってみました。

WasmerのGitHubを見ると、C#実装のWasmerSharpも用意されているようです。

github.com

github.com

ところがWasmerSharpをよく調べてみると、組み込まれているWasmerのRivisionが v0.5.7 前後(2019/7頃)のもので、2023/02/05現在の最新版の v3.1.1 とは大きく離れていることが分かりました。

APIの変更も入っているため最新版のWasmerを利用することはそのままでは難しく、自分でネイティブプラグインのBridgeを書く必要がありそうです。

v0.5.7 前後当時はARM64(AppleSilicon)のmacOSの対応もないためReleaseには自分の環境で使用できるプラグインもなく、該当コミットをチェックアウトして自分でRustのライブラリをビルドすることも試みたのですが、依存しているCrateが古すぎて無理でした。

結論としては現時点ではWasmerを手軽に利用することは難しそうです。

ちなみに参照先の記事ではWasmerを使ってたのでは?と思われるかもしれませんが、あれはWasmerそのものを組み込んでいるわけではなく、Wasmerを利用して指定のWasmを動かすネイティブライブラリをRustで作っているものでしたので汎用的なものではありませんでした。

AppleSiliconのmacOSなどでWasmerを動かすには、自分で最新のWasmerのC# Bridgeを改修する必要がありそうです。

cs-wasm

「Unity WebAssembly」で検索していると、たるこすさんが cs-wasm というライブラリを使用して実際にUnity上でWasmを動かしていることを知りました。

zenn.dev

github.com

Repositoryを覗いてどのような実装になっているのか調べてみたのですが、何かネイティブプラグインを組み込んでいる様子もなく、C#で独自実装しているように見えます。

動作させるだけなら問題はないのかもしれませんが、WasmtimeやWasmerなどの活発なOSSと比較すると開発スピードや実績などで不安な点も残るため、できれば他の選択肢がない場合の最終手段としたいのが個人的な所感です。

Wasmtime (wasmtime-dotnet)

Wasmerばかり触っていたのでWasmtimeも触ってみようと調べてみると、WasmtimeもC#(.NET)実装のwasm-dotnetがありました。

github.com

github.com

wasm-dotnetはWasmtimeの最新版にも追従していて、Wasmerであったバージョンの心配はなさそうです。

実際にUnityに組み込んでHello Worldのサンプルを移植して動かしてみたところ、Editor上では問題なく動作しました。

一応Unityから利用しやすいようUPMで参照できる形に整備をしました

github.com

ただしREADMEのトップに書いているように、IL2CPPビルドをすると実行時にエラーが出てしまう不具合が残っているため、まだビルドに組み込むことはできません...!

ですのでまだEditor上でしか触れませんが、それでも自分で触ってみたい方はこちらのRepositoryを参照してください。

結論としてはWasmtimeと言いたいところですが、後述するようにAndroid/iOSをサポートしていない問題もあり、まだ断言できない状況です。

今すぐ組み込んで使用したい方はcs-wasmを、Wasmの最新のProposalの機能なども利用したい方はWasmtimeやWasmerなどの環境が整うのを待つ or 自分で整える、といったところでしょうか。

Hello World

WasmtimeのAPIの触り方を理解するには公式のサンプルやドキュメントを読み込むのが一番おすすめなのですがRustで書かれているので、C#に慣れている方だとwasmtime-dotnetのサンプルの方が読みやすいと思います。

これらサンプルと併せて、MozillaのドキュメントなどのWasm固有のドメイン用語に関しての説明を読むと理解が早いと思います。

developer.mozilla.org

Engine、Store、Module、Instance、Import、Export辺りがわかるとWasmerなどの他のランタイムも触れるようになると思います。

Hello WorldのサンプルをUnity向けに少し調整するとこのような感じになります。

using UnityEngine;
using Wasmtime;

namespace Mochineko.WastimeDotNetUnity.Demo
{
    internal sealed class WasmHelloWorld : MonoBehaviour
    {
        private void Start()
        {
            const string wat = @"
(module
  (type $t0 (func))
  (import """" ""hello"" (func $.hello (type $t0)))
  (func $run
    call $.hello
  )
  (export ""run"" (func $run))
)";

            using var engine = new Engine();
            using var module = Module.FromText(engine, "hello", wat);
            using var linker = new Linker(engine);
            using var store = new Store(engine);

            linker.Define(
                "",
                "hello",
                Function.FromCallback(store, () => Debug.Log("Hello from C#, WebAssembly!"))
            );

            var instance = linker.Instantiate(store, module);

            var run = instance.GetAction("run");
            if (run is null)
            {
                Debug.LogError("error: run export is missing");
                return;
            }

            run();
        }
    }
}

github.com

他にもWasm内でUnityのCubeを生成する処理を呼び出すデモも用意してみました。

github.com

AoTコンパイル

Wasmでは基本的にはJITコンパイルが使用されることがほとんどですが、AoTコンパイルを使用することもランタイムによっては可能です。

特にJITコンパイルが使えないiOSなど*8の環境では、AoTコンパイルを使用しなくてはなりません。

ただWasmのAoTコンパイルに関しては情報が少なく(需要があまりない?)、自分の手元で動作確認もできていないため理解が怪しいところもあるかもしれません。

Unityを使用してiOS向けにアプリをリリースする場合もあると思いますので、自分の理解している範囲でWasmのAoTコンパイルの仕組みに関して簡単に説明します。

まずWasmのソースコードは2通りのフォーマットが利用できます。

  • バイナリ(.wasm
  • テキスト(.wat

これらをコンパイルしたものはModuleと呼ばれます。

このModuleをSerializeしたコンパイル済みのバイナリは、ランタイムでDeserializeすることでModuleとして利用できます。

.wasm or .wat --> (compile) --> Module --> (serialize) --> Serialized Module --> (deserialize) --> Module --> Instance

つまり Serialized Module がAoTコンパイルしたバイナリに相当するため、AoTコンパイルを利用する際はコンパイル済みのバイナリを用意して、ランタイムで直接ModuleにDeserializeして利用する流れになります。

ただこのSerialized Moduleは各ランタイムにAPIが用意されているのは確認できますが、ファイルとしての扱いに関する情報が少なくあまり理解が進んでいません。

Wasmtimeでは .cwasm (Compiled Wasm?)のCLIが用意されているようです。

https://docs.wasmtime.dev/cli-options.html?highlight=aot#compile

Wasmerでは、公式のドキュメントには情報がないのですが、下記の記事から .wasmu("u"は何の意味?)が利用できるようです。

おそらくJITコンパイルの場合と異なる下記の点に注意が必要になりそうです。

  • 一度コンパイルするためターゲットプラットフォーム別にバイナリが必要
  • 別のランタイムとの互換性はなさそう(コンパイラが違ったら動かないので仕方ないかもですが)

JITコンパイルより少し取り回しは悪くなってしまいますが、AoTコンパイルを利用すること自体は可能そうです。

UnityでのWasmの使い道

UnityでWasmを動かせるのは分かりましたが、どのような使い道がありそうでしょうか?

ざっと思いついたものを並べてみました。

ランタイムスクリプトとしてのWasm

こちらの記事にも詳しく書かれているように、アプリケーションのビルド後にロジックを加えたり変更するためのランタイムスクリプトとしてWasmを使うことができます。

zenn.dev

UnityのAPI(e.g. GameObject)やイベント(e.g. OnUpdate)などをインターフェースとしてImport/Exportに設定して、WebAssembly側からそれらを呼び出すことは可能です。

あるいはこちらのプロジェクトのように、アプリケーションを横断する汎用ロジックのフォーマットとしても活用することができます。

zenn.dev

開発環境の整備が必要ですが、ユーザーがどの言語で書くかも選択できるというのはWasmならではではないでしょうか。

あとは稀なケースかもしれませんが、利用する外部サービスの頻繁な仕様変更に耐えるためのBuffer的な立ち位置でも利用することができるかもしれません。

Unityとは関係ないですが、ブロックチェーンのスマートコントラクトでもWasmが注目されているみたいです。

ネイティブプラグインの代替としてのWasm

Unityでは大半のコードはC#で書きますが、求められるパフォーマンスが厳しい場合にはネイティブで書いたコードをネイティブプラグインとして利用することもあると思います。

ですがネイティブプラグインは動作環境ごとにバイナリを用意する必要があり、ビルドやアップデートなどの管理のコストも考慮しなくてはなりません。

その点Wasmは実行速度はネイティブ並みで、かつバイナリはJITコンパイルでいいなら1つだけで済むため、通常のネイティブプラグインより取り回しが良いです。

もちろんまだWasmもできることは限定されているため、全てのネイティブプラグインを代替することはできませんが、ハマるケースもあるかもしれません。

また、C++やPythonを始めとしたC#以外のコード資産をWasmに変換して利用することもできるため、利用できるコード資産が増えるという観点もあります。

zenn.dev

現在開発が進んでいるComponent Modelでその環境が整っていく見通しもあります。

www.publickey1.jp

Unityはクロスプラットフォーム対応をしている分、動作を想定するプラットフォームが多くなりやすいため、意外と相性は良いのではないでしょうか。

全く検証はしていませんが、WebGLもWasmで動いていますし、JavaScriptから動かすインターフェースなどあれば他のプラットフォームとのライブラリの共通化もできるかもしれません。

あとはWasmのバイナリはビルドに組み込まずにサーバーからダウンロードして利用することも可能なため、アプリケーションのビルドのサイズを減らすメリットもあるかもしれません。

サーバーとクライアントの共有コードとしてのWasm

Unityで動かせる前に、もちろんサーバーやブラウザでも同じWasmのコードが利用できますので、コアなロジックやライブラリを各環境で共有して、サービス全体のコアなコード資産を見通しよく管理する、ということもできるかもしれません。

まだ検証していないので妄想ですが、Unity上でPure C#(UnityのAPIに触らないで)で書いたコードをWasmに変換することもできるはずです。

C#→WasmといえばBlazorが有名ですが、BlazorはC#コンパイラ自体をWasm化して動かしてるみたいなのでちょっとシチュエーションが違うかもしれません。

とはいえそれだけのためにわざわざWasmを使うのは少しやり過ぎな気もするので、実際どれくらい有用なのかは分かりませんが。

実際に導入を検討する上での課題

今回Wasm周辺を触っている中で、実際にUnityでのアプリケーション開発に組み込むことを想定した場合に課題になるであると思った点も挙げます。

WasmランタイムのAndroid/iOSのサポートが弱い

WasmをAndroid/iOSで動かすための情報が調べてもあまり出てこないです。

WasmtimeのReleaseを確認しても、Windows/macOS/Linux向けのビルドしか確認できません。

github.com

公式ドキュメントを確認してみると、やはりWindows、macOS、Linuxしかサポートはしていないようです。

docs.wasmtime.dev

ただこちらのIssueやc-apiのCMakeList.txtを見る感じではAndroid向けのビルドも不可能ではなさそうです。(iOSは不明ですが)

github.com

そのためスマホ上で動作させたい場合は自分でWasmtimeをRustのライブラリとしてビルドする必要がありそうです。

試してみてはいるのですがビルド環境の構築に苦戦しています...

一方WasmerはiOSはサポートしているらしいです。

MakefileにもiOS向けのHeadless Engine(AoTコンパイルのみ搭載したもの)のビルドオプションが用意されています。

github.com

AndroidはこちらのIssueが進行中?

github.com

このAndroid/iOSの対応の状況を見て、結局どのランタイムを使うべきか悩み始めています...

Stringの扱いの難しさ

Wasmはいわゆる char や string のサポートはないため、文字列を扱う処理は工夫するしかないそうです。

ただ Reference Type というプロポーザルも進んでいるようで、最新のステータスは調べていないのでわからないのですが、仕様策定が進めば改善されるかもしれません。

www.infoq.com

Wasmtimeには先行実装とサンプルコードのようなものがあるみたいなので、もう少し触ってみたいです。

github.com

WasmやWASIのエコシステムが発展途上

Wasm、WASIの仕様策定も進行中ですし、周辺のツール等の整備もこれからだと思います。

ただComponent Modelの開発が進んだり、Docker ImageとしてWasmが利用されることが進めば良くなる見通しもあります。

www.publickey1.jp

www.publickey1.jp

所感

今回はUnityで利用することを目的に C#, .NET 向けの対応を中心に色々調べてみましたが、Wasmerの対応バージョンが古かったり、iOS/Androidのプラグインがなかったりと、まだまだ未整備な部分も見られました。

とはいえWasm自体のエコシステムはComponent Model始め改善が進むと思いますので、時間の問題かもしれません。

Unityで利用する観点では、Unityを触れるエンジニアでWasmの知識を持つ人もまだまだ少ないはずで、実際に導入するのもそれなりにハードルは高いでしょう。

この記事などをきっかけに関心を持つ人が増えたら少しはプラスになるかもしれません。

終わりに

UnityにWasmのランタイムをネイティブプラグインとして組み込み、Wasmのバイナリを実際に動かしてみることをゴールに試行錯誤して得られた学びを言語化して整理しました。

個人的にはWasmはかなり面白いのではと思っていて、自分の周辺業務での実用性があるのかは正直まだ分かりませんが、こまめに情報を追っていったり何かしらの形でコミュニティに貢献できればと思っています。

直近だとネイティブプラグインの作成の経験がないのもありWasmerの最新版のC# Bridge対応もチャレンジしています。

github.com

今回Wasm周りを自分で触っていく過程で、Wasm以外にもOSやCPUアーキテクチャ、VM、アセンブリ言語、RustとCargo、ネイティブプラグインなど低レイヤー寄りの知識が増えたのも思わぬ収穫でした。

ちなみにこういった知らない技術を触る時にはChatGPTさんが大活躍でした。

もしUnity/C#でWasmを動かしてみたい!という方の参考になれば幸いです。

最後に、自分自身まだWasmなどをキャッチアップしている途中で、低レイヤーの知識も自信があるわけではないため、もし間違っている記述などありましたらご指摘いただけますと嬉しいです。

*1:JITコンパイルを使用する場合は

*2:参考:https://postd.cc/what-makes-webassembly-fast/

*3:参考:https://zenn.dev/0kate/articles/83e48c177ff709

*4:参考:https://www.publickey1.jp/blog/21/webassemblyrustthe_state_of_webassembly_2021.html

*5:後で説明するようにAoTコンパイルを利用することも可能で、コンパイラは使用するランタイムによって異なります

*6:参考:https://www.publickey1.jp/blog/17/webassembly_browsers.html

*7:参考:https://nodejs.dev/en/learn/nodejs-with-webassembly/

*8:AppStoreは規約でJITコンパイルが禁止されています:https://developer.apple.com/jp/app-store/review/guidelines/#software-requirements

Unity 2021.3.0f1でQuest開発している時に遭遇したUIが消える不具合について

エンジニアの岡村です。

先週うぃすきーさんから障害対処の記事が出ていたので、自分も重大度としては低いですが、社内開発中に発生した不具合と、それが解決するまでの過程を作業記録として書いてみることにしました。何かの参考になれば幸いです。

結論

Unity 2021.3.0f1 は、XR Interaction Toolkit + Meta Quest2で不具合があるので、 これらの機能を使う場合はUnity 2021.3.2f1以上にアップデートすることをお勧めします。

不具合発生

少し前に、Meta Quest2向けのUIの実装を行っていました。元となるUIが既に存在していたので、それをVR空間内で触れるようにNew Input SystemやXR Interaction Toolkitの繋ぎこみをしていたのですが、一通り組み立てて動作テストをしたとき、奇妙な現象に見舞われました。

スクロールビューをポインターで操作すると、トリガーを離した瞬間にUIの表示が消滅してしまい、二度と戻ってきません。

必ず発生するわけではないのですが、仮に発生した場合UI操作が出来なくなってしまうので、修正が必要です。まずは繋いだInputSystem --> XR Interaction Toolkitの辺りに原因があるのではないかと仮定して調査を始めました。

再現

UIの不具合を検証するにはUnity Editor上で状態を確認するのが一番なので、Editor上で該当のUIを操作してみたのですが、Editor上では再現しませんでした。

仕方ないので、ログを仕込んでQuest実機でテストします。今回の症状がスクロールビューの内容が消えているという事なので、まずはScrollViewの子要素(Content)の座標をログに出してみました。

02-10 02:02:30.036  9193  9216 I Unity   : [ScrollPositionChecker] Scroll Position = (0.00, NaN, 0.00)
02-10 02:02:30.036  9193  9216 I Unity   : UnityEngine.StackTraceUtility:ExtractStackTrace () (at /Users/bokken/buildslave/unity/build/Runtime/Export/Scripting/StackTrace.cs:37)
02-10 02:02:30.036  9193  9216 I Unity   : UnityEngine.DebugLogHandler:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[])
02-10 02:02:30.036  9193  9216 I Unity   : UnityEngine.Logger:Log (UnityEngine.LogType,object)
02-10 02:02:30.036  9193  9216 I Unity   : UnityEngine.Debug:Log (object)
02-10 02:02:30.036  9193  9216 I Unity   : ScrollPositionChecker:Update () (at C:/Users/Sokuhatiku/Documents/Projects/ScrollBugTest/Assets/ScrollPositionChecker.cs:20)
02-10 02:02:30.036  9193  9216 I Unity   :

Logcatで取得したログを読んでみると、YにNaNが入っています。何かのルートでNaNが入ってしまっているようです。

原因特定

NaNの出元を辿ります。Transformに座標を入れている箇所を探し、それぞれに別のIDを振ってログを仕込み、どこの代入でNaNが出ているのかを特定します。すると、ScrollRectから発行されている、スクロールポジションが変化した時に発行される、onValueChangedイベントからNaNが渡されている事がわかりました。

docs.unity3d.com

しかも、一度NaNになってしまうと、Transform Positionなどを手動で修正してもScrollrect側から永遠にNaNが帰ってくるようになってしまっていました。どうやらScrollRect内におかしな値が残ってしまっているようです。

解決策の検索

こうなるとScrollRectを利用している側ではどうしようもありませんので、Unityの不具合であると決め打ち、解決策を探すことにします。

Issue Trackerを検索してみたところ、それらしいIssueがHitしました。

issuetracker.unity3d.com

XR Interaction Toolkitを使い、Quest実機上でスクロールビューをドラッグするとコンテンツが消えるという症状が完全に一致しています。

IssueにUnity公式の返信が付いているので読んでみると、

  • 原因は2つ
    • Quest実機にてunscaledDeltaTimeに0が入る事がある
    • unscaledDeltaTimeに0が入る事がScrollRect側で考慮されていない
  • 両方ともUnity 2021.3.2f1で修正済み

とのことです。

検証

たまたま他の検証でUnity 2021.3.16f1を入れていたので、試してみました(本当は最小限のアップデートに止めるべき)。

プロジェクトをUnity2021.3.16f1で開き直し、Androidビルドを実行、Quest2実機で確認してみたところ、問題なくスクロールが行えるようになっていました!

これにて不具合の原因と、修正方法が判明しました。ここからはUnityのアップデートによる副作用を検証するフェーズが始まります……

おまけ:どのような仕組みでNaNが発生しているのかソースコードを調べてみた

UnityのUIなど一部分はソースコードが公開されているので、中を覗いてみます。

以前はbitbucketで公開されていましたが、Unity 2019.2以降はUnity本体の Data\Resources\PackageManager\BuiltInPackages\com.unity.ugui にあるからそれを見てね、という形に変わったようです。

github.com

差分を覗いてみると、 LateUpdate 内でunscaledDeltaTimeがゼロ超であることをチェックするコードが追加されていました。

下の方で除算が行われているので、恐らくここがゼロ除算になり、NaNが生まれてしまっていたのでしょう。

unscaledDeltaTimeに0が入ることがある根本的な理由に関しては……おそらくエンジン側のソースを見ないと分からなさそうです。

Firebase AuthenticationでのサインインがiOS16.1でできなくなった際の障害対応記録

経緯

うぃすきー(@whisky_shusuky)です。

2022年10月頃にiOS16.1においてfirebase authenticationのリダイレクトを使ったサインインができない事象が発生しました。

github.com

発生直後のアプリリリースでは、

  • OS側のアップデートが原因である
  • 他のデバイスやブラウザでログイン操作が可能である(SYNMNはRFC8628に則って実装しているため)

ということでAppleの審査を通すことが出来ました。

しかし、その次のリリースでは審査が通らず対応が必要となったため、急遽クロ(@kro96_xr)と対応しました。

その際に何をやったかここに記録をここに記そうと思います。

調査と失敗

firebase側から、いくつかの対応案が出ていました。

firebase.google.com

対応オプション

  1. Firebase 構成を更新して、カスタム ドメインをauthDomainとして使用する
  2. signInWithPopup() に切り替える
  3. 認証リクエストを firebaseapp.com にプロキシする
  4. ドメインでサインイン ヘルパー コードを自己ホストする
  5. プロバイダーのサインインを個別に処理する

このうち、

  • Firebaseにサービスをホストしていないのでオプション1はNG
  • WebViewに対応しておきたいのでオプション2はNG
  • Appleサインインを使うのでオプション4はNG

ということでオプション3かオプション5で対応することになりました。

オプション5は実装に時間がかかりそう、かつFirebase Authを使う意味があまりなくなるということで、今回はオプション3を選択しました。

オプション3の説明を見るとこのように書いてありました。

302 リダイレクト経由では実行できません。

じゃあ301でリダイレクトさせるか!

そう考えてサインインに関するアクセスをALBでhttps://<project>.firebaseapp.comにリダイレクトさせて対応しましたが失敗しました...

よく読んでみる

よく文言を読むと以下のように記載してありました。

この転送がブラウザに対して透過的であることを確認してください。

「透過的って何だ?」となんとなく怪しい気持ちがして原文に当たると以下のように記載してありました。

Ensure that this forwarding is transparent to the browser

transparentと書いてありました。そこで色々と調べたら Transparent Proxy(透過型プロキシ)という単語がヒットしたりしました。

よく考えたらnginxのプロパティが書いてあるしタイトルにもプロキシすると記載してあります。 そこでブラウザ上でリダイレクトさせるのではなくてnginxなどを使ってプロキシサーバーを立てろということなのではないかという仮説を立てました。 これが結果的に当たりでした。

プロキシを立てて対処

いい感じに簡単にproxyサーバーを立てられるawsのマネージドサービスが無いか探しましたが見つかりませんでした。

そのためFargateを元々APIサーバーに使っていたのでそこにnginxを立てられないか試しました。

まずは以下のような構成でレポジトリを切ってプロキシサーバのコードを用意しました

.
├─ Dockerfile
└─ reverse-proxy/
   ├── index.html
   └── nginx.conf
  • Dockerfile
    • ヘルスチェック用のindex.htmlとnginx.confを読み込む
FROM nginx:alpine

COPY reverse-proxy/index.html /etc/nginx/html/index.html
COPY reverse-proxy/nginx.conf /etc/nginx/nginx.conf
  • index.html
    • ヘルスチェック用なのでOKだけ返す
<html>
OK
</html>
  • nginx.conf
    • 実際はproxy_passの部分にfirebaseのプロジェクトを記載したパスを設定してリダイレクトするようにする。
events {
    worker_connections  16;
}
http {
    server {
        listen 80;
        server_name localhost;
        location / {
            index  index.html;
        }
        location /__/auth {
            proxy_pass https://<project>.firebaseapp.com;
        }
    }
}

こちらをecrにpushしてfargateの起動イメージに設定してproxyサーバーを立てました。 またFargateの前段にALBを立てていたのでそちらのルールを設定してサインイン用パスが叩かれた場合proxyサーバーに転送されるようにしました。

するとうまく動作してサインインに成功するようになりました。

終わり

ブラウザの仕様変更に振り回されましたが何とか解決できました。今回の教訓としては以下のようなことが挙げられると思います。運営していれば障害はつきものなので今後に活かしたいと思います。

  • ドキュメントの日本語が怪しかったら原文を当たってみる
  • 障害時は仮説を立てたらサッサと手を動かして検証する
    • プロキシを立てるのが正解だったが立て始めた時点では不明だった
    • 結果的に手を動かしてから2時間程度で解決した

参考リンク

同様の事象に別アプローチ(オプション5)で対応されている記事です。
Firebase AuthenticationのSafari 16.1で動作しなくなる問題の解決過程 - ぱいぱいにっき

ウェブサイトのユーザー行動分析のための仕組みについて考察する(1)

はじめに

エンジニアの松原です。一時期ホームページなどでアクセスログやユーザーの行動履歴を収集、解析するためにGoogle Analyticsを活用することが流行っていましたが、私自身は実際は裏側で動いている仕組み自体を知らないまま使っていました。
以前日本では一時期ビックデータ呼ばれていたものの例の一つにアクセスログやユーザーの行動履歴も含まれていました。仕事でクラウドサービスを使うようになり、最近まではクラウドサービス上の開発に興味関心が移っていたため、アクセスログのデータがどのように作ればよいかあまり考えてきませんでした。
AWSにも AthenaQuickSight などのサービスがあり、サービスを連携させることによってほぼGoogle Analyticsと同様な解析ができるようになりました。
今回はアクセスログやユーザーの行動履歴について、一から作ってみたい場合にオレオレ仕様での仕組みを考えてみました。今回は特定のページに対するアクセス解析はどのようにできそうか、またそのデータを解析するためにどんな種類のデータがサーバーに保存されるか考えてみました。

ホームページ構成とそのグラフ構造

ホームページの構成を概念化する際、画面遷移(状態遷移)をベースにするとわかりやすいようです。また、状態遷移に関して理論立てる方法として、一般的にはグラフ構造(状態遷移図)で表すことで分かりやすくなります。
例としてSynamonのテックブログのサイトは大きく分けて3つの構造を持っています。

  • ホーム画面(またはトップページ)
  • 記事一覧画面
  • 個別の記事の画面

さらに外部からテックブログのアクセスをトリガーする、外部のウェブサイトやSNSの画面を含めた4構成になっています。トップページや記事一覧画面以外は厳密には特定、不特定含めて多数存在するため、それぞれ Zn(Z1, Z2, ..., Zn)Cn(C1, C2, ..., Cn) 、また特定の記事画面を Cp と表現しています。

ここからは画面遷移の図をベースにアクセス解析やユーザー行動についていくつかの定義を整理してみます。

特定の記事への画面遷移のパターン

先ほどのグラフの図を分解して、特定の記事へユーザーがアクセスする方法をいくつかの画面遷移のパターンに分解してみます。主に5つのパターンが考えられます。最後は具体的な画面遷移が発生しないパターンになっています。

  • ホーム画面からその記事にアクセス
  • 記事一覧画面からその記事にアクセス
  • 外部サイトの画面(埋め込まれたリンク)からその記事にアクセス
  • 同サイトの別の記事(埋め込まれたリンク)からその記事にアクセス
  • ブラウザのブックマーク機能か、URL直打ちからその記事にアクセス

実際はこのようなシンプルなものだけではなく、これらのパターンが組み合わさり、以下のように複数の画面遷移を経て特定の記事にアクセスされるケースが多いかと思います。

アクセスログを取得する方法

アクセスログ自体は、特定のページにアクセスしたという記録があればサーバーに残っていれば後から解析できますが、画面遷移の導線をたどるためにアクセスログを利用したい場合は、 どこからそのページに遷移してきたか という遷移元の情報を取得することで再現できます。
具体的な方法としてはHTTPヘッダーに含まれるリファラを利用することで遷移元の情報をURLという形で取ることができます。ただし、遷移元と遷移先のドメインの異なる場合、プライバシーやセキュリティの面からURLの一部しか提供されないケースがあります。
下図のように、リファラから簡単な画面遷移のパターンを探ることができます。

リファラを利用する以外にも取りうる方法(Cookieを利用するなど)はありますが、今回の記事の本質ではないので割愛します。

また、これらのユーザー行動に伴う画面遷移に関して、ユーザー毎の特定時間範囲での行動を一般的にセッションと名付けられていることがあります。 アクセスログからセッション単位のユーザーの行動履歴を最低限分析することはできるようになりますが、以下の課題があります。

複数記事への画面遷移を伴った状態の課題

見出しがややこしいですが、ある特定の記事へユーザーがアクセスした場合、セッションの前後関係でその記事に興味を持っているかどうかわからないケースがあります。以下の図で表します。
この図では、ユーザーの遷移前と遷移後のサイトへのアクセスログのみではどの記事を目的としてサイトを訪れているかわからない二つのケースを示しています。
一つ目はユーザーが1回のセッションで分析対象の記事画面より前に複数の記事を参照しているケースです。二つ目は同じセッションで分析対象の記事画面以外の記事にもアクセスしているケースです。

遷移前と遷移後のサイトの情報だけだと、これ以上の分析が行えないので、ほかの情報もユーザー行動として含めて記録しておく必要があります。

滞在時間、読了率、PV数について

画面ごとの 滞在時間読了率PV(ページビュー)数 をユーザー行動として記録しておくことで、後で分析する際に役に立つケースがあります。
滞在時間 はその画面にユーザーが何秒留まっていたかを記録しており、「興味関心がないものであれば滞在時間は低いだろう」という仮説を元に記録することが多いかと思います。
読了率 は対象の記事の内容がユーザーにどれぐらいの量を読まれたかを記録します。実装方法の例として、その記事の開始から終了までの画面スクロール範囲と、実際にユーザーがスクロールした範囲などを取得することで「記事中の範囲はユーザーが見ているので興味関心を持っているだろう」という仮説を元に読了率を定めています。
PV数 はそのページが 秒/分/時/日/週/年 単位ごとにどれぐらいアクセスされたかを記録したものです。PV数に関しては仮説ではなく実測値のため、扱いやすくはありますが、組み合わせて利用することによってより分析に役立てることができます。

以下にこれらの利用例を挙げます。

滞在時間、読了率からユーザーの興味関心を読み取る

先ほどの課題を例にしてみます。1回のセッションで複数の記事を表示している場合、ユーザーの行動の目的がどこにあるかわからない場合があります。ここに滞在時間や読了率を含めて比較することで見えてくる傾向があります。
基本的には先ほどの仮説の通り、滞在時間と読了率が高い記事のページがユーザー関心があると重み付けをし、1回のセッションでの対象の記事とその他の記事の滞在時間と読了率を比較することである程度目安を作ることができます。
ただし、1ユーザーのみのセッションを単純に比較するのは意味が薄く、ほかのユーザーのセッションも含めて比較する必要があるため、複数のユーザーのアクセスログやユーザー行動の記録をある程度の期間以上蓄積してから解析するのがよさそうです。

PV数を利用して記事の人気傾向を読みとる

上記課題以外にも、滞在時間、読了率、さらにPV数を比較することにより、その記事の人気傾向を読みとることができるかもしれません。
過去の記事の滞在時間、読了率、PV数の傾向を先にいくつか算出しておくことで、目的の記事が今後どのような立ち位置になるか予測することができそうです。

ただし、傾向の分類は数値を目視して決め打ちで算出するのではなく、統計的に分析されていることが必要になります。また、この方法は実際に利用していくためには時系列解析も必要になります。(時系列解析はそれだけで記事のボリュームがすごいことになるので割愛します。)

アクセスログとユーザー行動の取得タイミングと再現方法について

分析を行う前に、データとして記録されておく必要があります。ここでは記録そのものの方法と再現方法について取りあげます。

アクセスログとユーザー行動の取得と保存について

アクセスログに関しては、対象のページにアクセスした際に、Webブラウザを利用している場合は遷移前のページはリファラを参照することで取得できます。
ユーザー行動である滞在時間、読了率に関しては、スクリプトを仕込んで特定タイミングでサーバーに送る必要があります。アクセスログとユーザー行動を別に記録する方法もありますし、以下の例のようにスクリプトを利用してページ移動前にアクセスログとユーザー行動をまとめてログ保存用のサーバーに送信する方法もあります。
合わせて、あとでユーザーごとに分離できるように、ユーザー毎にユニークな情報をキーとして設定しておき、それをログに含めておくことで後で分離することもできます。ただし、プライバシーにかかわってくるため、ユーザーを特定できる情報に個人情報が含まれていることもあるため、慎重に取り扱う必要があります。

ただし、ブラウザから画面をクローズしたときは情報が取れないため、滞在時間、読了率に関しては別のタイミングで送ることも以下のように検討します。
下図ではスクリプトから画面スクロールにトリガーしてサーバー側に送信する方法を示しています。

セッションの再現について

記録したアクセスログとユーザー行動からセッションの情報を再現します。特定の時系列で保存されているデータをユーザーごとに分離することができればセッションとして再現することができます。

おわりに

今回はウェブサイトのユーザー行動分析のためにどのような構造が考えられ、どのようにデータをとらえるか、どのような方法で取得、保存できるかを考察してみました。
次回は再現したセッションのデータを用いて分析する方法について取りあげてみたいと思います。