ricomintea’s diary

好きなものを好きでいる

Go+WebAssemblyでfetchした結果を返す

Node.js 20.5.0, Remix 1.19.1

色々やる前にNode.jsのバージョンが18系だったせいで、Goが用意している wasm_exec.js で使われている crypto がないよと言われて動かなかった。気づいた時に泣いた。

u

入力されたURLからHTMLを取得して、OGPのデータを抽出するGoのプログラムがあるとする。

type OGP struct {
  // OGPとして返すデータの型定義
}

func getOGP(url: string) (ogp OGP, err error) {
  // net/httpでフェッチしてレスポンスのHTMLからデータを引っこ抜く
  return ogp, err
}

Goの関数をJavaScript側から呼び出せるようにバインドする。

バインドする関数は、JavaScript側から呼び出し可能なインターフェースにする必要があるため、 getOGP をラップした関数を用意する。

import (
  // wasmが動作する環境の機能にアクセスするためのパッケージ
  "syscall/js"
)

// バインドされる関数は第一引数に呼び出したJavaScript側のコンテキスト(this)、
// 第二引数に関数呼び出し時の引数が渡される
func getOGPForWasm(this js.Value, args []js.Value) {}

func main() {
  // JavaScriptのグローバルコンテキスト(globalThis)にogperという名前で値を設定する
  // 今回はogperに対してキーバリューで関数を持つオブジェクトを設定している
  // キーに対して直接関数や数値・文字列を設定することもできる
  js.Global().Set("ogper", js.ValueOf(map[string]any{
      "getOGP": js.FuncOf(getOGPForWasm),
  }))
}

上のコードでバインディング自体は可能だが、呼び出そうとすると Error: Go program has already exited というエラーになる。

こちらの記事の『めんどく3』にあるがmain関数を動かし続けないといけない。

GoのWASMがライブラリではなくアプリケーションであること - 株式会社カブク

func main() {
  c := make(chan struct {})
  js.Global().Set("ogper", js.ValueOf(map[string]any{
      "getOGP": js.FuncOf(getOGPForWasm)
    }))

  // wasmがJavaScriptからのリクエストに応答できるように、
  // チャネルでプログラムを動作させ続ける
  <-c
}

普通に呼び出したいだけなのに生かし続けないといけない。何でこんなことをという感じ。

ここら辺でRustとかで書いてたらなという気持ちになった。

Go で1行でgoroutineデッドロックを起こす方法

公式ドキュメントとかでmain() のことを current goroutine と表現しているってところでなるほどになった。goroutine終わってないよ!止まるな!をしないといけない。うーん

getOGPForWasm の実装をする。

普通にGoを書く感じでいけるかなと思ったらダメだったやつ。

func getOGPForWasm(this js.Value, args []js.Value) string {
    url := "https://github.com/ihch"

  ogp, err := getOGP(url)
  if err != nil {
    panic(err)
  }

  // オブジェクトそのままでは渡せないのでJSONにシリアライズする
  json, err := json.Marshal(ogp)
  if err != nil {
    panic(err)
  }

  return string(json)
}

js package - syscall/js - Go Packages

Invoking the wrapped Go function from JavaScript will pause the event loop and spawn a new goroutine. Other wrapped functions which are triggered during a call from Go to JavaScript get executed on the same goroutine.

As a consequence, if one wrapped function blocks, JavaScript's event loop is blocked until that function returns. Hence, calling any async JavaScript API, which requires the event loop, like fetch (http.Client), will cause an immediate deadlock. Therefore a blocking function should explicitly start a new goroutine.

曰く、JavaScriptからWASMを介してGoの関数を呼ぶと、JavaScript側のメインスレッドのイベントループを止めてgoroutineを動かしてその中で処理が行われる。イベントループを必要とするような非同期で行われる処理、例えばfetch (http.Get)をしようとすると、そこから進まないデッドロックの状態になる。

こちらの記事では、HTTPリクエストを含む処理をさらにgoroutineで囲い、その中でDOMの書き換えまで行っている。

Go 1.18集中連載 net/httpのマイナーチェンジ | フューチャー技術ブログ

DOMを直接書き込むようなことはせずに、データだけ渡してどう使うかはJavaScript側に任せるみたいな使い方を考えていたので、少し違う方法で実装することになった。

Promiseを生成して、JavaScript側の世界で非同期処理の扱いを制御できるようにした。

func getOGPForWasm(this js.Value, args []js.Value) interface{} {
    url := "https://github.com/ihch"

    handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        resolve := args[0]
        reject := args[1]

        go func() {
            ogp, err := Ogper.GetOGP(url)
            if err != nil {
                reject.Invoke(err)
            }

            responseJson, err := json.Marshal(ogp)
            if err != nil {
                reject.Invoke(err)
            }

            resolve.Invoke(string(responseJson))
        }()

        return nil
    })

    return js.Global().Get("Promise").New(handler)
}

この記事を参考にしたような気がする。確か。

Go WebAssemblyでPromiseを使って非同期化してみた | RE:ENGINES

使えるようになって嬉しくはあるものの何でこんなことを(2回目)の気持ちになった。

WebAssemblyにしてRemixで使う

WebAssemblyにコンパイルしてRemixのプロジェクトにコピーする

GOOS=js GOARCH=wasm go build -o ogper.wasm
cp ~/Projects/my-remix-app/app/wasm
# Goで生成したWebAssemblyをJavaScriptで解釈するためのファイル
cp (go env GOROOT)/misc/wasm/wasm_exec.js ~/Projects/my-remix-app/app/wasm

フロントの方はRemixを使って生成した app/routes/index.tsx

import { json, type V2_MetaFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import fs from 'fs';
import path from 'path';

export const meta: V2_MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

const wasmPath = path.resolve(__dirname, '../app/wasm/ogper-goroutine.wasm');

type WasmModuleCache = {
  [path: string]: WebAssembly.Module;
};

const wasmModuleCache: WasmModuleCache = {};

require('../wasm/wasm_exec.js');
const go = new Go();

export const loader = async () => {
  const wasmModule = wasmModuleCache[wasmPath] ? wasmModuleCache[wasmPath] : await WebAssembly.compile(fs.readFileSync(wasmPath));
  const wasmInstance = await WebAssembly.instantiate(wasmModule, go.importObject);

  if (!wasmModuleCache[wasmPath]) {
    wasmModuleCache[wasmPath] = wasmModule;
    go.run(wasmInstance);
  }

  const og = await ogper.getOGP('https://github.com/ihch');

  return json({ og: JSON.parse(og) });
}

export default function Index() {
  const data = useLoaderData<typeof loader>();

  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
      <h1>Welcome to Remix</h1>
      <p>{data.og.title}</p>
      <img src={data.og.image} alt={data.og.title} />
    </div>
  );
}

結果

感想

たまたまGoで書いていたコードがあって、Remixのサーバー側で動かすと都合がよさそうと思いやってみたけど、Go + WebAssembly特有っぽい部分で微妙な部分を感じた。

  • WebAssemblyを wasm_exec.js を使って読み込むとglobalThisにものが入る
    • 気合で wasm_exec.js を書き換えたらなんとかなるのか?えー
  • 他の言語でもそうなのか分からないけど素でフェッチ処理をするとデッドロックが起きるところ

もし今後WebAssemblyにする前提で何かを書くならRustを練習してやるかなーと思った。

リポジトリ

github.com

github.com

資料

js package - syscall/js - Go Packages

this - JavaScript | MDN

イベントループとプロミスチェーンで学ぶJavaScriptの非同期処理

並行モデルとイベントループ - JavaScript | MDN

GoのWASMがライブラリではなくアプリケーションであること - 株式会社カブク

Go で1行でgoroutineデッドロックを起こす方法

Goで作ったロジックにWebUIをつけてGitHubページに公開する | フューチャー技術ブログ

Go 1.18集中連載 net/httpのマイナーチェンジ | フューチャー技術ブログ

Go WebAssemblyでPromiseを使って非同期化してみた | RE:ENGINES