【初学者向け】Go製WASMをNext.js App Routerのサーバーサイドで動かすまで
はじめに 👋
どうも、Cloud Creator Labの中の人です。Next.jsやGo言語でサーバーサイドの処理を実装しているときに、Go製のWasmをサーバーサイドで動かしたいという要望があったので、その方法をまとめてみました。
WebAssembly (Wasm) は、ブラウザだけでなくサーバーサイドでもそのパフォーマンスとポータビリティで注目を集めています。Go言語は、比較的容易にWasmを生成でき、既存のGoコード資産をNode.js環境などでも活用できる可能性があります。
この記事では、簡単な足し算を行うGoの関数をWebAssemblyにコンパイルし、Next.js 13+ (App Router) アプリケーションのサーバーサイド、具体的にはRoute Handlerから呼び出す方法をステップバイステップで解説します。
対象読者
- Next.js (App Router) でサーバーサイド処理を実装している開発者
- Go言語で書いたロジックをNode.js/Next.js環境で利用したいと考えている開発者
- WebAssemblyの基本的なコンセプトに関心がある方
この記事で学べること
- Go言語のコードをWebAssembly (
.wasm
) にコンパイルする方法 - Next.jsプロジェクトでWasmモジュールを実行するために必要なセットアップ
- App RouterのRoute HandlerからWasm関数を呼び出し、結果をAPIレスポンスとして返す方法
- Node.js環境でGo Wasmを実行する際の注意点と基本的なエラーハンドリング
前提条件 🛠️
この記事で解説する手順やコードは、以下の環境で動作確認を行っています。
ツール | バージョン | 確認コマンド / 備考 |
---|---|---|
Go言語 |
1.24.3 (1.11以降必須) |
go version |
Node.js |
v22.15.0 (LTS版推奨) |
node -v |
npm | (Node.jsに同梱) | npm -v |
Next.js |
v15 以降 (執筆時点の@latest ) |
npx create-next-app@latest でセットアップ後、package.json 内のnext のバージョンを確認してください。 |
フェーズ1: GoによるWebAssemblyモジュールの準備 📦
まずは、Next.jsから呼び出すためのWasmモジュールをGoで作成します。
1. Goの作業ディレクトリ作成 (任意)
Next.jsプロジェクトとは別の場所でGoのコードを管理する場合、作業ディレクトリを作成します。
mkdir go-wasm-module
cd go-wasm-module
2. Goモジュールの初期化
go mod init example.com/go-wasm-module
example.com/go-wasm-module
の部分は適宜ご自身のモジュール名に置き換えてください。
main.go
)
3. Wasm用のGoコード作成 (main.go
というファイル名で以下のコードを作成します。このコードは、JavaScriptから呼び出せる add
関数を定義します。
// main.go
package main
import (
"fmt"
"syscall/js" // JavaScriptとの連携に必要
)
// JavaScriptから呼び出される add 関数
// js.Value はJavaScriptの値を表す型
func add(this js.Value, args []js.Value) interface{} {
// 引数の数をチェック
if len(args) != 2 {
// エラーメッセージをJavaScriptの文字列として返す
return js.ValueOf("Invalid number of arguments")
}
// 1つ目の引数を整数に変換
arg1, ok1 := safeConvertToInt(args[0])
if !ok1 {
return js.ValueOf("Argument 1 is not a valid integer")
}
// 2つ目の引数を整数に変換
arg2, ok2 := safeConvertToInt(args[1])
if !ok2 {
return js.ValueOf("Argument 2 is not a valid integer")
}
// 加算結果をJavaScriptの数値として返す
return js.ValueOf(arg1 + arg2)
}
// js.Valueを安全にintに変換するヘルパー関数
func safeConvertToInt(val js.Value) (int, bool) {
// 型が数値でない場合はエラー
if val.Type() != js.TypeNumber {
return 0, false
}
num := val.Int() // JavaScriptのNumber型からGoのint型へ変換
// 注意: JavaScriptのNumberはfloat64なので、非常に大きな数値や精度が重要な場合は別途対応が必要
return num, true
}
// JavaScript側に関数を公開(登録)する関数
func registerCallbacks() {
// "goAdd" という名前でグローバルスコープにadd関数を登録
js.Global().Set("goAdd", js.FuncOf(add))
// (オプション) Go Wasmの準備完了をJavaScriptに通知するコールバックを設定する例
// js.Global().Set("goWasmReady", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
// fmt.Println("Go Wasm is ready to be called from JS!")
// return nil
// }))
}
func main() {
// プログラムが即座に終了しないようにチャネルを作成して待機
c := make(chan struct{}, 0)
fmt.Println("Go WebAssembly Initialized (from Go)") // サーバーログに出力される
registerCallbacks() // JavaScriptに関数を登録
// (オプション) Goの初期化完了をJavaScript側に通知する例
// if js.Global().Get("onGoWasmReady").Type() == js.TypeFunction {
// js.Global().Call("onGoWasmReady")
// }
<-c // このチャネル受信待機によりmain関数が終了せず、Wasmインスタンスがアクティブな状態を保つ
}
コード解説 (main.go
):
-
syscall/js
パッケージ: GoとJavaScript間でデータをやり取りするための基本的な機能を提供します。 -
add
関数: JavaScript側からgoAdd(a, b)
として呼び出せるようにします。引数はjs.Value
のスライスとして渡され、戻り値もjs.Value
でラップされた値(またはエラーメッセージ文字列)です。 -
safeConvertToInt
: JavaScriptからの入力が本当に数値であるかをチェックし、安全にGoのint
型に変換します。js.Value.Type()
で型を確認し、js.Value.Int()
で数値を取得します。 -
registerCallbacks
:js.Global().Set()
を使って、Goの関数 (add
) をJavaScriptのグローバルスコープに登録します。js.FuncOf()
はGoの関数をJavaScriptから呼び出し可能な形式にラップします。 -
main
関数:registerCallbacks()
を呼び出した後、チャネル (c
) を使ってプログラムをブロックします。これにより、WasmインスタンスがGoのmain
関数終了と同時に破棄されるのを防ぎ、登録したコールバック関数がJavaScriptから呼ばれるのを待ち受けます。
4. GoコードをWebAssemblyにコンパイル
作成したGoコードを .wasm
ファイルにコンパイルします。
共通の注意点:
-
GOOS=js
とGOARCH=wasm
: JavaScript環境で動作するWebAssemblyバイナリを生成するための環境変数を設定します。 -
-o main.go.wasm
: 出力ファイル名をmain.go.wasm
とします。このファイルがWasmモジュール本体です。
コマンド:
Linux/macOS (bashなど)
GOOS=js GOARCH=wasm go build -o main.go.wasm main.go
Windows (PowerShell)
$env:GOOS="js"; $env:GOARCH="wasm"; go build -o main.go.wasm main.go
コンパイルに成功すると、カレントディレクトリに main.go.wasm
が生成されます。
wasm_exec.js
の準備
5. GoでコンパイルしたWasmを実行するには、GoのSDKに含まれる wasm_exec.js
というJavaScriptファイルが必須です。このファイルは、Goのランタイム機能を提供し、WasmモジュールとJavaScript環境(この場合はNode.js)との間のブリッジとして機能します。
wasm_exec.js
は、Goのインストールパスの misc/wasm/wasm_exec.js
にあります。 (バージョンによっては ${GOROOT}/lib/wasm/wasm_exec.js
にある場合もあります。後述の注意点を参照してください。)
Goのインストールパス確認方法:
go env GOROOT
表示されたパス(例: /usr/local/go
や C:\Go
)を元に、wasm_exec.js
を見つけます。
後でこのファイルをNext.jsプロジェクトの public
ディレクトリにコピーします。
wasm_exec.js
をカレントディレクトリにコピーする例:
Linux/macOS (bashなど)
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./wasm_exec.js
# または、lib ディレクトリの場合
# cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" ./wasm_exec.js
Windows (PowerShell)
# PowerShellでは `go env GOROOT` の結果を直接コマンド内で展開するのが少し複雑なので、
# まずGOROOTのパスを取得してからコピーするのが確実です。
$goroot = go env GOROOT
Copy-Item "$goroot/misc/wasm/wasm_exec.js" -Destination "./wasm_exec.js"
# または、lib ディレクトリの場合
# Copy-Item "$goroot/lib/wasm/wasm_exec.js" -Destination "./wasm_exec.js"
# もし1行で実行したい場合 (ただし、GOROOTにスペースが含まれない前提)
# Copy-Item "$(go env GOROOT)/misc/wasm/wasm_exec.js" -Destination "./wasm_exec.js"
${GOROOT}/misc/wasm/wasm_exec.js
をコピーしてください。
最終的に public
ディレクトリは以下のようになっているはずです:
my-go-wasm-app/
├── public/
│ ├── main.go.wasm # GoからコンパイルされたWasmモジュール
│ └── wasm_exec.js # Go WasmランタイムサポートJS
│ └── (その他 Next.js のデフォルトファイル like next.svg, vercel.svg)
└── ... (その他のプロジェクトファイル)
フェーズ2: Next.js (App Router) プロジェクトのセットアップと統合 🚀
次に、Next.jsプロジェクトを作成し、先ほど準備したWasmモジュールを組み込みます。
1. Next.jsプロジェクトの作成
npx
を使って新しいNext.jsプロジェクトを作成します。プロジェクト名は my-go-wasm-app
とします。
npx create-next-app@latest my-go-wasm-app
プロジェクト作成時の質問には適宜答えてください(TypeScriptの使用を推奨します。この例ではTypeScriptを前提とします)。
2. プロジェクトディレクトリへ移動
cd my-go-wasm-app
3. Wasm関連ファイルの配置
main.go.wasm
の配置
a. フェーズ1でコンパイルした main.go.wasm
ファイルを、Next.jsプロジェクトの public
ディレクトリにコピーまたは移動します。
# public ディレクトリが存在しない場合は作成 (通常は最初からあります)
# mkdir -p public
# (go-wasm-module ディレクトリから main.go.wasm をコピーする場合の例)
# cp ../go-wasm-module/main.go.wasm ./public/
Goプロジェクトのルートでビルドした場合、生成された main.go.wasm
を my-go-wasm-app/public/
にコピーしてください。
wasm_exec.js
の配置
b. フェーズ1で準備した wasm_exec.js
ファイルを、Next.jsプロジェクトの public
ディレクトリにコピーします。
# GOROOT/misc/wasm からコピーする場合
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./public/
# GOROOT/lib/wasm からコピーする場合 (Goのバージョンによってはこちら)
# cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" ./public/
もしフェーズ1でカレントディレクトリに wasm_exec.js
をコピー済みの場合は、それを使用できます。
# cp ./wasm_exec.js ./public/
${GOROOT}/misc/wasm/wasm_exec.js
をコピーしてください。
最終的に public
ディレクトリは以下のようになっているはずです:
my-go-wasm-app/
├── public/
│ ├── main.go.wasm # GoからコンパイルされたWasmモジュール
│ └── wasm_exec.js # Go WasmランタイムサポートJS
│ └── (その他 Next.js のデフォルトファイル like next.svg, vercel.svg)
└── ... (その他のプロジェクトファイル)
app/api/calculate/route.ts
)
4. Route Handler の作成 (App Routerを使用してAPIエンドポイントを作成します。app/api/calculate/route.ts
というファイルを作成し、以下の内容を記述します。
// app/api/calculate/route.ts
import { NextResponse } from 'next/server';
import fs from 'fs/promises'; // Node.jsのファイルシステムモジュール (Promise版)
import path from 'path'; // Node.jsのパス操作モジュール
import { webcrypto } from 'crypto'; // Node.js の crypto API (Web Crypto API互換)
import { performance as nodePerformance } from 'perf_hooks'; // Node.js の performance API
// Wasm実行環境に必要なグローバルプロパティの型定義
interface GoWasmGlobal extends Window { // ブラウザ環境のWindowと互換性を持たせつつ拡張
Go: {
new (): {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
exited: boolean;
exit: (code: number) => void;
};
};
goAdd: (a: number, b: number) => number | string; // Goからエクスポートされる関数
// Node.js環境では、crypto と performance は globalThis に既に存在しうるが、
// Wasm実行や特定のライブラリが期待する型と異なる場合があるため、明示的に定義・上書きする。
crypto: typeof webcrypto;
performance: typeof nodePerformance;
}
// Node.js環境でGo Wasmを実行するために必要なグローバルオブジェクトをセットアップ
// globalThis が GoWasmGlobal の形状を持つことを TypeScript に伝える
const g = globalThis as unknown as GoWasmGlobal;
// `wasm_exec.js` は `globalThis.crypto` と `globalThis.performance` を期待するため、
// Node.js環境でこれらを提供します。
if (typeof g.crypto === 'undefined' || g.crypto !== webcrypto) {
g.crypto = webcrypto;
}
// Node.jsのperf_hooks.performanceはWeb APIのPerformanceと完全互換ではない場合があるため、
// Goが期待するであろう最低限の機能を提供しているか確認が必要なケースもある。
// ここでは、wasm_exec.jsが主にperformance.now()を利用することを想定。
if (typeof g.performance === 'undefined' || typeof g.performance.now !== 'function') {
g.performance = nodePerformance as unknown as GoWasmGlobal['performance'];
}
// WasmモジュールのインスタンスとGoのランタイムインスタンスを保持する変数
// これらは一度初期化されたら、後続のリクエストで再利用される(シングルトンパターン)
let goRuntimeInstance: InstanceType<GoWasmGlobal['Go']>; // Goランタイムのインスタンス (go.runなどを呼び出すため)
let wasmInstance: WebAssembly.Instance | null = null; // WebAssemblyのインスタンス (実際のWasmモジュール)
let wasmInitializationPromise: Promise<void> | null = null; // 初期化処理の重複実行を防ぐためのPromise
/**
* WebAssemblyモジュールを非同期で初期化します。
* 既に初期化中または初期化済みの場合は何もしません。
* この関数は、Wasmモジュール内の 'goAdd' 関数が利用可能になるまで待機します。
*/
async function initializeWasm(): Promise<void> {
// 既に初期化済み、または初期化処理が進行中の場合は、その完了を待つ
if (wasmInstance && goRuntimeInstance && !goRuntimeInstance.exited) {
console.log('Wasmは既に初期化されています。');
// goAddが利用可能か再確認 (稀なケースだが、何らかの理由で消えた場合など)
if (typeof g.goAdd === 'function') return;
console.warn("'goAdd'関数が利用できなくなっています。再初期化を試みます。");
// リセット処理(より堅牢にするなら、古いインスタンスのクリーンアップも考慮)
wasmInstance = null;
goRuntimeInstance = null as any; // 型エラーを避けるためanyを使用
wasmInitializationPromise = null;
}
if (wasmInitializationPromise) {
console.log('Wasmの初期化処理が既に進行中です。完了を待ちます。');
return wasmInitializationPromise;
}
console.log('Wasmモジュールを初期化しています...');
wasmInitializationPromise = (async () => {
try {
// 1. wasm_exec.js のパス解決と読み込み・実行
// このスクリプトはGoのWasmをブラウザやNode.jsで実行するためのランタイムを提供し、
// `globalThis.Go` コンストラクタを定義します。
const wasmExecPath = path.resolve('./public/wasm_exec.js');
const wasmExecContent = await fs.readFile(wasmExecPath, 'utf-8');
// `new Function` を使ってグローバルスコープで wasm_exec.js を実行
// これにより、`globalThis.Go` が利用可能になります。
new Function(wasmExecContent)();
// Goコンストラクタの存在確認
if (typeof g.Go === 'undefined') {
throw new Error("GoコンストラクタがglobalThis上で見つかりません。wasm_exec.jsの実行に失敗した可能性があります。");
}
goRuntimeInstance = new g.Go(); // Goランタイムの新しいインスタンスを作成
// 2. Wasmバイナリファイル (.wasm) のパス解決と読み込み
const wasmFilePath = path.resolve('./public/main.go.wasm');
const wasmBytes = await fs.readFile(wasmFilePath);
// 3. Wasmモジュールのインスタント化
// WasmバイナリとGoランタイムのインポートオブジェクトを関連付けます。
const result = await WebAssembly.instantiate(wasmBytes, goRuntimeInstance.importObject);
wasmInstance = result.instance; // インスタント化されたWasmモジュールを保持
console.log('Wasmモジュールがインスタント化されました。');
// 4. Goランタイムを開始し、Wasmモジュールを実行 (Goのmain関数が実行される)
// これは非同期処理です。完了を待たずに次の処理に進むことがあります。
// Wasm内でのエラー (panicなど) はここでキャッチされます。
// run()が完了する前にgoAddが使えるようになるので、runのPromiseはここでは待たない。
// run()がrejectした場合に備えてエラーハンドリングは行う。
goRuntimeInstance.run(wasmInstance).catch((err: unknown) => {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error("Go Wasmの実行時エラー (goRuntimeInstance.run):", errorMessage);
// goRuntimeInstance.exited が true になるはず
// エラー発生時はインスタンスを無効化し、再初期化を促す
wasmInstance = null;
wasmInitializationPromise = null; // 次回のリクエストで再初期化試行できるようにする
});
// 5. 'goAdd'関数がグローバルスコープで利用可能になるまで待機 (ポーリング)
// Wasmモジュールの初期化が完了し、Go側でエクスポートされた関数が使えるようになるのを待ちます。
await new Promise<void>((resolve, reject) => {
const timeout = 10000; // タイムアウト時間を10秒に設定 (環境により調整)
const checkIntervalMs = 50;
let elapsedTime = 0;
const checkInterval = setInterval(() => {
if (goRuntimeInstance && goRuntimeInstance.exited) {
clearInterval(checkInterval);
console.error("'goAdd' 関数の準備待機中にGoランタイムが終了しました。");
reject(new Error("Goランタイムが予期せず終了しました。Wasmモジュールの初期化に失敗した可能性があります。"));
return;
}
if (typeof g.goAdd === 'function') {
clearInterval(checkInterval);
console.log("'goAdd'関数が利用可能です。");
resolve();
} else {
elapsedTime += checkIntervalMs;
if (elapsedTime >= timeout) {
clearInterval(checkInterval);
console.error(`タイムアウト(${timeout/1000}秒): 'goAdd'関数が利用可能になりませんでした。`);
reject(new Error("タイムアウト: 'goAdd'関数の準備待機中にエラーが発生しました。Wasmモジュールの初期化に失敗した可能性があります。"));
}
}
}, checkIntervalMs);
});
console.log('Go WasmがAPIルート用に初期化されました。');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Wasm初期化中の致命的なエラー:', errorMessage);
wasmInstance = null; // 初期化失敗時はインスタンスを無効化
wasmInitializationPromise = null; // 次回のリクエストで再初期化試行できるようにする
// エラーを再スローして、呼び出し元で処理できるようにする
throw new Error(`Wasm初期化失敗: ${errorMessage}`);
}
})();
return wasmInitializationPromise;
}
// APIリクエストを処理するPOSTハンドラ
export async function POST(request: Request): Promise<NextResponse> {
try {
// Wasmモジュールが初期化されているか確認し、されていなければ初期化
// initializeWasm() はPromiseを返すので、awaitで完了を待つ
await initializeWasm();
// 初期化後、再度 'goAdd' 関数の存在とランタイムの状態を確認
if (!wasmInstance || typeof g.goAdd !== 'function' || (goRuntimeInstance && goRuntimeInstance.exited)) {
console.error("Wasmモジュールの準備ができていないか、'goAdd'関数が見つからないか、またはGoランタイムが終了しています(初期化試行後)。");
return NextResponse.json(
{ error: "Wasmモジュールが利用できません。サーバーのログを確認してください。" },
{ status: 503 } // Service Unavailable
);
}
// リクエストボディから計算する数値を取得
const body = await request.json();
const { a, b } = body;
// 入力値の型チェック (基本的なバリデーション)
if (typeof a !== 'number' || typeof b !== 'number') {
return NextResponse.json(
{ error: '無効な入力です。"a"と"b"は数値である必要があります。' },
{ status: 400 } // Bad Request
);
}
// Wasmモジュール内の 'goAdd' 関数を呼び出し
const result = g.goAdd(a, b);
// Go側で不正な入力として文字列が返された場合の対応
if (typeof result === 'string' && result.startsWith("Invalid")) {
return NextResponse.json({ error: result }, { status: 400 }); // Bad Request
}
if (typeof result !== 'number') {
// 予期せぬ戻り値の型
console.error(`'goAdd' から予期せぬ型の結果が返されました: ${typeof result}, 値: ${result}`);
return NextResponse.json({ error: "Wasm関数から予期せぬ結果が返されました。" }, { status: 500 });
}
// 計算結果をJSON形式でレスポンス
return NextResponse.json({ result });
} catch (error: unknown) {
// API処理中に発生した予期せぬエラーのハンドリング
const errorMessage = error instanceof Error ? error.message : "不明なAPIエラーが発生しました。";
console.error('/api/calculate でのエラー:', errorMessage);
// Wasm初期化エラーの場合、より具体的なメッセージを返す
if (errorMessage.startsWith("Wasm初期化失敗:")) {
return NextResponse.json(
{ error: 'Wasmモジュールの初期化に失敗しました。詳細はサーバーログをご確認ください。', details: errorMessage },
{ status: 500 } // Internal Server Error
);
}
return NextResponse.json(
{ error: '内部サーバーエラーが発生しました。処理中に予期せぬ問題が発生しました。', details: errorMessage },
{ status: 500 } // Internal Server Error
);
}
}
// 開発環境でのホットリロード時にログを出力する例
// 注意: ホットリロードによりWasmモジュールの状態がリセットされることはないため、
// 複数回の初期化試行や状態の不整合に注意が必要です。
// 上記の initializeWasm 実装では、この点をある程度考慮しています。
if (process.env.NODE_ENV === 'development') {
console.log("APIルートモジュール (app/api/calculate/route.ts) が開発モードでリロードされました。Wasmの状態は維持されます。");
}
コード解説 (app/api/calculate/route.ts
):
このTypeScriptコードは、Next.js App RouterのRoute Handlerとして機能し、POSTリクエストを受け取ってGoで書かれたWasm関数を呼び出し、その結果を返します。
-
グローバルスコープのセットアップ (
g
,g.crypto
,g.performance
):-
wasm_exec.js
は、実行されるJavaScript環境のグローバルスコープに特定のオブジェクト(crypto
,performance
など)が存在することを期待します。ブラウザ環境ではこれらは標準で提供されていますが、Node.js環境では明示的にセットアップする必要があります。 -
globalThis
(Node.jsではglobal
と同等) をg
としてキャストし、必要なプロパティ (Go
コンストラクタやgoAdd
関数など) を持つGoWasmGlobal
型として扱います。 - Node.jsの
crypto.webcrypto
とperf_hooks.performance
をそれぞれg.crypto
とg.performance
に設定し、Wasmランタイムが必要とするAPIを提供します。
-
-
Wasmインスタンスの保持 (
goRuntimeInstance
,wasmInstance
,wasmInitializationPromise
):- これらの変数はモジュールスコープ(つまり、このファイルがロードされた時点で一度だけ初期化されるスコープ)で定義されます。
-
wasmInstance
:WebAssembly.instantiate
によって生成されたWasmモジュールの実際のインスタンスです。 -
goRuntimeInstance
:new g.Go()
によって作成されるGoランタイムのインスタンスで、WasmモジュールとJavaScript間の通信やライフサイクル管理を行います。 -
wasmInitializationPromise
: Wasmの初期化処理は非同期で行われ、時間がかかる可能性があります。このPromiseは、複数のリクエストが同時に発生した場合でも初期化処理が重複して実行されるのを防ぎ、全ての処理が最初の初期化完了を待つようにします。
-
initializeWasm()
関数:-
重複実行防止:
wasmInstance
が既に存在し、かつGoランタイムが終了していない (!goRuntimeInstance.exited
) 場合は、初期化済みとみなし即座にリターンします。また、wasmInitializationPromise
を使用して、初期化処理が進行中の場合はそのPromiseを返すことで、重複実行を避けます。 -
wasm_exec.js
の読み込みと実行:fs.readFile
でpublic/wasm_exec.js
を読み込み、new Function(wasmExecContent)()
で実行します。これにより、globalThis.Go
が定義されます。 -
Goランタイムのインスタンス化:
goRuntimeInstance = new g.Go()
でGoランタイムのインスタンスを作成します。 -
Wasmモジュールの読み込みとインスタンス化:
fs.readFile
でpublic/main.go.wasm
バイナリを読み込み、WebAssembly.instantiate(wasmBytes, goRuntimeInstance.importObject)
を呼び出します。goRuntimeInstance.importObject
は、GoのコードがJavaScript側の機能を呼び出すために必要な関数群(例:syscall/js.Value.Call
の実装など)を含んでいます。 -
Goランタイムの開始:
goRuntimeInstance.run(wasmInstance)
を呼び出すと、Wasmモジュール内でGoのmain()
関数が実行されます。この呼び出しは非同期であり、Promiseを返しますが、main()
関数内でjs.Global().Set()
によって関数が登録されるのはrun()
の実行途中であるため、run()
の完了を待たずに次のステップ(goAdd
のポーリング)に進みます。run()
がエラーで終了した場合(例: Goのコード内でpanicが発生)のエラーハンドリングも行います。 -
goAdd
関数のポーリング: Goのmain()
関数が実行され、registerCallbacks()
が呼び出されてjs.Global().Set("goAdd", ...)
が完了するまでにはわずかな時間がかかります。そのため、g.goAdd
がfunction
型として定義されるまで、短い間隔でチェックを繰り返します(ポーリング)。タイムアウト処理も設けて、無限ループを防ぎます。また、ポーリング中にGoランタイムが終了 (goRuntimeInstance.exited === true
) した場合もエラーとして扱います。
-
重複実行防止:
-
POST(request: Request)
関数:-
初期化処理の呼び出し: まず
await initializeWasm()
を呼び出し、Wasmモジュールが確実に初期化され、利用可能な状態になるのを待ちます。 -
利用可能性の再確認: 初期化後も、
wasmInstance
、g.goAdd
、およびgoRuntimeInstance.exited
をチェックし、Wasmモジュールが本当に利用可能かを確認します。 -
リクエスト処理: リクエストボディから
a
とb
をJSONとして取得し、数値型であるかバリデーションを行います。 -
Wasm関数呼び出し:
g.goAdd(a, b)
を使って、Goで実装された足し算関数を呼び出します。 -
結果のハンドリング:
goAdd
関数は計算結果(数値)またはエラーメッセージ(文字列)を返すようにGo側で実装されているため、戻り値の型をチェックし、適切に応答を返します。 -
エラーハンドリング:
try...catch
ブロックで、初期化処理中のエラーやAPI処理中の予期せぬエラーを捕捉し、適切なHTTPステータスコードと共にエラーレスポンスを返します。
-
初期化処理の呼び出し: まず
-
開発モード時のログ:
process.env.NODE_ENV === 'development'
の場合、モジュールがリロードされたことを示すログを出力します。上記のinitializeWasm
の実装により、Wasmインスタンスは可能な限り再利用され、ホットリロード時にも状態が維持されることを目指しています。
このRoute Handlerは、サーバーレス環境 (Vercelなど) でデプロイされた場合、リクエストごとに起動される可能性があります。その際、initializeWasm
内のシングルトンパターンにより、Wasmの初期化コストが高い処理は最初の呼び出し時のみ(またはインスタンスが破棄された後の最初の呼び出し時)に実行されるようになっています。
フェーズ3: 動作確認と発展 🧪
1. 開発サーバーの起動
Next.jsの開発サーバーを起動します。
npm run dev
# または
yarn dev
サーバーがデフォルトで http://localhost:3000
で起動します。
コンソールには、Go側からの "Go WebAssembly Initialized (from Go)" や、route.ts
からの "Wasmモジュールを初期化しています..."、"'goAdd'関数が利用可能です。" といったログが表示されるはずです。
2. API Routeのテスト
curl
や Postman、またはブラウザの開発者ツールなどを使って、作成したAPIエンドポイントにPOSTリクエストを送信します。
成功例:
curl -X POST -H "Content-Type: application/json" -d '{"a": 15, "b": 7}' http://localhost:3000/api/calculate
期待されるレスポンス:
{"result":22}
入力値が数値でない場合 (Go側のバリデーション):
curl -X POST -H "Content-Type: application/json" -d '{"a": "hello", "b": 7}' http://localhost:3000/api/calculate
期待されるレスポンス (main.go
の safeConvertToInt
または add
でエラーを返す場合):
{"error":"Argument 1 is not a valid integer"}
または (Next.js側のバリデーションで先に捕捉される場合):
{"error":"無効な入力です。\"a\"と\"b\"は数値である必要があります。"}
入力値のキーが不足している場合 (Next.js側のバリデーション):
curl -X POST -H "Content-Type: application/json" -d '{"a": 10}' http://localhost:3000/api/calculate
期待されるレスポンス:
{"error":"無効な入力です。\"a\"と\"b\"は数値である必要があります。"}
発展的な考慮事項 🤔
この例は基本的なものですが、実際のアプリケーションではさらに多くのことを考慮する必要があります。
-
エラーハンドリングの強化:
- GoのWasm関数内で発生したエラーを、より構造化された形でJavaScript側に伝え、APIレスポンスとして返す仕組みを強化します。Go側では
panic
するのではなく、エラー情報を含むオブジェクトや複数の戻り値を返すように設計すると、JavaScript側で扱いやすくなります。 -
route.ts
でのタイムアウト処理、Wasmモジュールの初期化失敗時のリトライ戦略なども検討します。
- GoのWasm関数内で発生したエラーを、より構造化された形でJavaScript側に伝え、APIレスポンスとして返す仕組みを強化します。Go側では
-
複雑なデータ型の扱い:
- 文字列、配列、構造体(オブジェクト)などをGoとJavaScript間でやり取りする場合、
syscall/js
の機能をより深く理解し、適切に型変換を行う必要があります。 - 大きなデータや複雑な構造を持つデータは、JSON文字列にシリアライズ/デシリアライズして交換する方法が一般的で、メモリ管理の観点からも有利な場合があります。
js.ValueOf(string)
やjs.Value.String()
を活用します。
- 文字列、配列、構造体(オブジェクト)などをGoとJavaScript間でやり取りする場合、
-
パフォーマンス最適化:
-
初期化コスト:
initializeWasm
は、サーバーレス環境ではコールドスタート時に影響します。アプリケーション起動時に一度だけ実行されるように設計するのが理想的です(例: Route Handlerのトップレベルで呼び出し、Promiseを保持する)。現在の実装では、リクエストごとに初期化チェックが入りますが、実際の初期化処理は一度だけです。 -
メモリ管理: GoのWasmは独自のガベージコレクションを持ちますが、JavaScriptとの間で大きなデータを頻繁にやり取りする場合はメモリコピーのオーバーヘッドに注意が必要です。
js.CopyBytesToGo
やjs.CopyBytesToJS
といった関数を効率的に使用するか、前述のJSONシリアライズを検討します。 -
Wasmモジュールのサイズ: Goで生成されるWasmバイナリは、他の言語で生成されるものと比較して小さくない場合があります。不要なパッケージのインポートを避けたり、TinyGoのような代替コンパイラを検討することで、ファイルサイズを削減できる場合があります(ただし、TinyGoは
syscall/js
の互換性に制限がある場合があります)。
-
初期化コスト:
-
wasm_exec.js
の代替/改良:-
wasm_exec.js
はブラウザ環境を主眼に置いている部分もあるため、Node.js専用のより軽量なローダーや、Wasmモジュール自体に最低限のランタイムを組み込むアプローチ (TinyGoなど) も長期的には検討の余地があります。標準のGoコンパイラを使用する場合、wasm_exec.js
が基本的な選択肢となります。
-
-
セキュリティ:
- 前述の通り、
new Function(wasmExecContent)()
の使用はwasm_exec.js
の内容が改ざんされていないことを前提としています。 - Wasmモジュールに渡す入力値の検証は、JavaScript側 (Route Handler) とGo側の両方で行うことが望ましいです(二重検証)。
- 前述の通り、
-
デプロイ:
- VercelやAWS Lambdaなどのプラットフォームにデプロイする際、
public
ディレクトリ内の.wasm
ファイルとwasm_exec.js
が正しくデプロイパッケージに含まれ、サーバー環境で読み取り可能であることを確認してください。 - サーバーレス環境では、コールドスタート時のWasm初期化時間がAPIの応答時間に影響を与える可能性があります。初期化済みのインスタンスを維持する戦略(プロビジョンドコンカレンシーなど、プラットフォームが提供する場合)や、Wasmモジュールのサイズ削減、初期化処理の高速化が重要になります。
- VercelやAWS Lambdaなどのプラットフォームにデプロイする際、
-
GoからJavaScriptへのより高度な連携:
- Goから非同期にJavaScript関数を呼び出したり、JavaScript側でPromiseを待機したりすることも可能です。
- Goのgoroutineとチャネルを活用して、バックグラウンドタスクを実行し、結果をJavaScriptに通知するような複雑な処理も実装できます。
まとめ ✨
この記事では、Goで作成したWebAssemblyモジュールをNext.js App RouterのRoute Handlerから呼び出す基本的な手順を解説しました。
Goの計算ロジックをサーバーサイドのNode.js環境で再利用する一つの方法として、Wasmは強力な選択肢となり得ます。
重要なポイントは以下の通りです:
- Goのコードを
GOOS=js GOARCH=wasm
でコンパイルする。 -
wasm_exec.js
をGo SDKから取得し、Wasmファイルと共にNext.jsのpublic
ディレクトリに配置する。 - Route Handler内で
wasm_exec.js
を実行してGoランタイムをセットアップし、Wasmモジュールをインスタンス化する。 - Go側で
syscall/js
を使ってエクスポートした関数をJavaScript側から呼び出す。 - Node.js環境特有のグローバルオブジェクト (
crypto
,performance
) の設定に注意する。 - Wasmの初期化はコストがかかるため、インスタンスを再利用する仕組みを導入する。
今回の例はシンプルな足し算でしたが、より複雑なGoのライブラリやビジネスロジックをNext.jsバックエンドに組み込む際の第一歩として役立てば幸いです。
画像処理や複雑な計算などが発生する時はぜひ、ご自身のプロジェクトで試してみてください!🚀
Discussion