こんにちは、CyberFight DX 事業本部で web フロントエンドエンジニアをしている久保です。CyberFight DX 事業本部は複数のエンタメサービスを開発、運用するFANTECH本部に所属しています。今回は、管理画面を対象に WebAssembly ( Wasm ) を導入した事例をご紹介します。

FANTECH 本部では技術ブログでの発信を強化しており、最近では下記のような記事を投稿しています。ぜひご一読ください。

 

私たちのプロダクト WRESTLE UNIVERSE では、ユーザー対象のキャンペーンを実施する際に特定のユーザーグループを複雑な条件で指定する場合があります。このプロセスを簡略化して開発と汎用的な条件を、複雑な UI やターゲティングルールなしに実装するために CEL( Common Expression Language )式を管理画面で直接利用したいと考えました。一方で、 CEL は 2024 年 4 月時点では JavaScript に正式に対応していない ( CEL の構文解析器に ANTRL が利用されているのでめちゃ頑張れば JavaScript でも対応させられそうですが… ) ため、 管理画面上で実行したい機能を Wasm としてビルドして Web ブラウザを通して利用できるようにしました。

 

Wasm の導入

Wasm は Rust をはじめとした様々な言語でサポートされており、Go 言語でも実験的に Wasm がサポートされています。

今回我々が Wasm を採用する決め手となった点は、本来サーバーサイドで用意するようなロジックを Web API を介さずに直接フロントエンド上に持ち込むことができる点です。我々はサーバーサイドの実装に Go 言語を採用しており Go 言語上で CEL を利用していましたが、一方でフロントエンドからは利用ハードルが高い問題があります。

また、ユーザーキャンペーンのための複雑な条件を指定するロジックを都度開発者が実装するのは開発コスト・運用コストともに膨らんでしまう可能性があります。こういった前提を踏まえて、

  • Web API を介して CEL を都度サーバーに送って評価する
  • Wasm を導入して CEL を管理画面上で評価する

の2つの手段を検討しました。どちらも実現は可能ですが、Wasm であればフロントエンドの開発者だけでなくサーバーサイドの開発者も管理画面のロジックを実装できるため、こちらを採用しました。

 

Wasm でできること

Wasm では大きく2つの操作を行うことができます。

  • DOM 操作: ブラウザ API を介して DOM 操作が可能です。JavaScript での getElementBy** や jQuery を使った経験があるとイメージしやすいと思います。 下記の例では p タグを生成して document 配下にアタッチしています。
    package main
    
    import (
    	"syscall/js" // syscall/js を使って window オブジェクトを取得する
    )
    
    func main() {
    	document := js.Global().Get("document")
    	body := document.Get("body")
    	p := document.Call("createElement", "p")
    	p.Set("innerHTML", "Wasm with Go")
    	body.Call("appendChild", p)
    	select {} // プロセスが終了しないようにします
    }
    

    また、HTML 上では Go 言語のビルド時に生成されるランタイムサポートファイル wasm_exec.js を script タグで読み込み、その後に生成された wasm バイナリを fetch を使って読み込むようにします。

    <html>
      <head>
        <meta charset="utf-8"/>
          <script src="wasm_exec.js"></script>
          <script>
            const go = new Go();
            WebAssembly.instantiateStreaming(fetch("build.wasm"), go.importObject).then((result) => {
              go.run(result.instance);
            });
          </script>
      </head>
      <body></body>
    </html>
    

 

  • 関数ブリッジ: WasmとJavaScript間でデータの交換や関数の呼び出しが可能です。これには主に number 型が使われ、その他の型にはポインタ操作が必要です。下記の例では簡単なカウンタ関数を Wasm で作成して JavaScript 側からコールしています。
    package main
    
    import (
      "syscall/js"
    )
    
    func add(this js.Value, inputs []js.Value) interface{} {
      sum := inputs[0].Int() + inputs[1].Int()
      return js.ValueOf(sum)
    }
    
    func registerCallbacks() {
      js.Global().Set("add", js.FuncOf(add))
    }
    
    func main() {
      registerCallbacks()
      select{}
    }
    
    // wasm_exec.js を HTML 側で読み込む必要がありますが、この例では割愛しています
    const go = new Go();
    
    async function loadWasm() {
      const response = await fetch('main.wasm');
      const buffer = await response.arrayBuffer();
      const { instance } = await WebAssembly.instantiate(buffer, go.importObject);
      go.run(instance);
    }
    
    window.onload = () => {
      loadWasm().then(() => {
        // caluculate という id を持ったボタンがあることを想定しています
        const btn = document.getElementById('calculate') as HTMLButtonElement;
        // ボタンをクリックすると Wasm 側のメソッドを呼び出して console に書き込んでいます
        btn.onclick = () => {
          const result = (window as any).add(2, 3);
          console.log(`Result from WASM: ${result}`);
        };
      });
    };
    

また、Wasm の実行は CORS ポリシーをクリアする必要があるため、これらの例を実際に試す際には localhost でホストするなどの対応が必要になります。

 

最終成果物

Web ブラウザから CEL 式を入力して、式中で使用されているシンボルの一覧を抜き出してブラウザ上で表示する機能を作成します。

 

実際に作成した Go 言語コードの解説

ここからは実際に Go 言語で作成したコードの紹介と解説を行います。今回 Go 言語コードの機能として

  • JavaScript から CEL 式を入力として受け取れること
  • 入力された CEL 式を解析し、使用されているシンボルの一覧を抜き出して返却すること

この2点を実装します。

package main

import (
	"encoding/json"
	"fmt"
	"slices"
	"syscall/js"
)

type SymbolsResponse struct {
	Symbols []string `json:"symbols"`
	Error   string   `json:"error"`
}

func main() {
	js.Global().Set("symbols", js.FuncOf(symbols))
	select {}
}

// symbol 一覧を解析
func symbols(_ js.Value, args []js.Value) any {
	resp := &SymbolsResponse{}
	if len(args) < 1 {
		resp.Symbols = []string{}
		resp.Error = "expression is required"
		return encode(resp)
	}

	expr := args[0].String()
	symbols, err := symbol.ListSymbols(expr)
	if err != nil {
		resp.Symbols = []string{}
		resp.Error = err.Error()
		return encode(resp)
	}

	resp.Symbols = slices.Compact(symbols)
	return encode(resp)
}

// 解析結果をエンコード
func encode(v *SymbolsResponse) string {
	j, err := json.Marshal(v)
	if err != nil {
		return fmt.Sprintf(`{"error": "%v"}`, err)
	}
	return string(j)
}

JavaScriptでの Wasm 呼び出し解説と画面の例

管理画面上で Wasm を呼び出します。我々の管理画面では Next.js を使用しているため、React コンポーネントとして実装することで必要な画面でのみ呼び出ししつつ再利用を意識しています

import Script from "next/script";
import React, { useEffect, useState } from "react";

type Props = {
  expr: string; // 入力される CEL 式
  setSymbolList({ symbols, error }: { symbols: string[]; error: string }): void; // useState の setter などが渡せる
};

export const WasmComponent: React.FC<Props> = ({
  expr,
  setSymbolList,
}) => {
  const [isLoaded, setIsLoaded] = useState<boolean>(false);

  useEffect(() => {
    // Go オブジェクトがロードされ、かつ、スクリプトがロードされているかチェック
    if (!isLoaded || typeof Go === "undefined") return;

    const go = new Go();
    // wasm バイナリは public ディレクトリに配置
    WebAssembly.instantiateStreaming(fetch("/wasm/cel.wasm"), go.importObject)
      .then((result) => {
        go.run(result.instance);
        if (expr) {
          // 入力式が検知された場合に wasm バイナリに渡して検出を実行
          const symbolResult = symbols(expr);
          if (symbolResult != undefined && symbolResult !== "null") {
            setSymbolList(JSON.parse(symbolResult));
          } 
        } else {
          setSymbolList({
            symbols: [],
            error: "Empty expression",
          });
        }
      })
      .catch((err) => {
        console.error("Failed to load Wasm module", err);
      });
  }, [expr, isLoaded, setSymbolList]);

  // wasm_exec.js も同様に public ディレクトリに配置
  return <Script src="/wasm/wasm_exec.js" onLoad={() => setIsLoaded(true)} />;
};

Wasm を導入する上での障壁

今回のケースのように Go 言語をそのまま用いた場合、2024 年 4 月現在ではバイナリサイズが大きくなってしまう問題があります。今回作成したバイナリは約 13.7 MBと大きく、一般ユーザーが利用するプロダクトへはユーザー体験上導入することは難しいと考えますが、 Chunk の分離等の工夫で FCP などの指標やユーザー体験になるべく影響が出ないようにしています。バイナリサイズの肥大化に際して TinyGo の導入も検討しましたが、公式で今後改善されることを期待して今回は見送っています。

感想

今回のように Wasm を使うとサーバーサイド文脈のロジックを比較的簡単にフロントエンドに持ち込むことができ、ロジックの追加修正をフロントエンドの開発者だけでなくサーバーサイドの開発者も行うことが出来るため、気軽に導入を試してみてはいかがでしょうか。 TinyGo ではなく Go でビルドする場合には現在はバイナリサイズなどの懸念もありますが、例えば Dynamic Import による main chunk との分離や Service Worker による client cache、cache-control を使ったキャッシュ時間のチューニングで影響を緩和出来る可能性もありそうです。

また、我々は動画まわりでも Wasm の活用を進めており、実際に試した例や求められるマシンパワーなどについてもまたご紹介できればと思います。