🎲

WASMで実現する実行環境に依存しないビジネスロジック設計 ― おみくじアプリの紹介

に公開

こんにちは、@glassmonekeyです。

あけましておめでとうございます。今年は年男なので、色々と挑戦していきたいと思います。正月といえば皆さん何を思い浮かべますか?やっぱりおみくじですよね!

今回は、RustとWASMの勉強を兼ねて作成したおみくじWebアプリケーションを紹介します。

WebAssembly(WASM)は「ブラウザで高速に動く技術」というイメージが強いかもしれませんが、それだけではありません。今回は数ある特徴の中からポータビリティに注目しました。1つのビジネスロジックを複数の実行環境で同じように動かせる特性は、ブラウザ・サーバー・エッジといった異なる環境間でのコード再利用を可能にします。このポータビリティを活かすアプリケーション設計に挑戦しました。

UUIDおみくじアプリのトップページ

https://uuid-kuji.app/

🚀 目標

RustとWASMの入門としてシンプルなWebアプリケーションを作ることにしました。特にWASMのポータビリティに魅力を感じていたので、同じビジネスロジックをブラウザ・サーバー・エッジの3つの環境で動かす価値があるアプリケーションを目標に据えました。

従来のアプローチの問題点

通常、異なる実行環境では別々の実装が必要になります:

  • ブラウザ用:JavaScript/TypeScriptで実装
  • サーバー用:Node.jsで実装
  • エッジ用:専用ランタイムで実装

この場合、コードの重複が発生し、以下の問題が生じます:

  • メンテナンスコストが3倍になる
  • ロジックの不整合が発生しやすい
  • 環境ごとにテストが必要になる
  • 型の一貫性を保証しにくい

特にedge runtimeは技術的制約が強いため一貫性を保つことは難しいです。

このような問題を解決するため、1つのビジネスロジックを複数の実行環境で再利用できる設計を目指しました。これにより、コードの重複を排除し、保守性を向上させることができます。

🎯 実際に作ったアプリ:UUIDでおみくじ

この目標を実現するため、UUIDで運勢を占うおみくじアプリを作成しました。

https://uuid-kuji.app/

ちなみに結果はこのようになります。

UUIDおみくじアプリの結果表示画面

簡単ながらも各運勢も乗せています(笑)
UUIDおみくじアプリの各運勢の詳細表示

概要

UUID(Universally Unique Identifier)は、理論的に世界中で重複しない識別子として知られています。このランダム性を活かし、UUIDから運勢を決定論的に計算するのがこのアプリのコンセプトです。

⚠️ このアプリは完全にエンターテインメント目的です。UUIDと運勢には科学的根拠は一切ありません。

このアプリでは、運勢計算のビジネスロジックをWASMで実装し、ブラウザ・サーバー・エッジの3つの環境で実行しています。

使用技術・環境

このアプリは以下の技術スタックで構築しています:

  • フロントエンド: Next.js 14 (App Router) + TypeScript
  • スタイリング: Tailwind CSS
  • WASM: Rust + wasm-pack
  • パッケージマネージャー: pnpm (workspaces)
  • ホスティング: Vercel

モノレポ構成で、WASMパッケージとWebアプリケーションを分離して管理しています。

ソースコードはglassmonkey/ojikujiにあるので良かったらご覧ください。

https://github.com/glassmonkey/omikuji

アーキテクチャ

この目標を実現するために、WebAssembly (WASM) を活用しました。WASMを使うことで、1つのビジネスロジックから3つの環境向けの実行バイナリを生成できます。

環境 実行形式 用途
ブラウザ WASM + JavaScriptバインディング(動的インポート) インタラクティブUI、即時レスポンス
Node.js WASM + Node.jsバインディング(静的インポート) SSR、SEO対応、メタデータ生成
エッジ WASM + Edge Runtime(?module構文) OGP画像生成、グローバル配信

単一のビジネスロジックから生成されたWASMモジュールを3つの異なる環境で実行しています。以下、各環境での実行例を紹介します。

🌐 クライアントサイド実行(ブラウザ)

トップページからおみくじを引く場合、滑らかなUXを実現するためにクライアント側でWASMを実行しています。サーバーへのリクエストなしにブラウザ内で即座に運勢計算ができるため、ユーザーは待ち時間なく結果を確認できます。

実装では、トップページでUUIDを生成した後にclient=1パラメータを付けて結果ページへ遷移します。結果ページではサーバー計算結果がない場合にクライアント側でWASMを実行して運勢を計算します。

apps/web/src/hooks/useOmikuji.ts

// トップページ: UUID生成のみ
const uuid = crypto.randomUUID();
router.push(`/result/${uuid}?client=1`); // client=1パラメータで遷移

// 結果ページ: クライアント側でWASM実行
export function useFortune(uuid: string, serverFortune: FortuneResult | null) {
  useEffect(() => {
    if (!serverFortune) {
      // サーバー結果がない場合、クライアント側でWASM実行
      const fortune = await calculateFortuneClient(uuid);
      setFortune(fortune);
    }
  }, [uuid, serverFortune]);
}

クライアント側でのWASM実行は、動的インポートを使用してWASMモジュールを初期化します:

apps/web/src/lib/fortune-client.ts

// WASMモジュールのキャッシュ
let wasmModuleCache: any = null;
let wasmInitialized = false;

// ブラウザ環境でWASMモジュールを初期化
async function initWasmClient() {
  if (wasmModuleCache && wasmInitialized) {
    return wasmModuleCache;
  }

  // ブラウザ環境でWASMを動的インポート
  // wasm-pack build --target web で生成されたモジュールを使用
  const wasmModule = await import("uuid-omikuji-wasm");
  
  // webターゲットの場合、初期化が必要
  if (!wasmInitialized && typeof wasmModule.default === 'function') {
    await wasmModule.default();
    wasmInitialized = true;
  }
  
  wasmModuleCache = wasmModule;
  return wasmModule;
}

// UUIDから運勢を計算(WASMを使用)
export async function calculateFortuneClient(uuid: string): Promise<FortuneResult> {
  const wasm = await initWasmClient();
  // WASMの関数を呼び出して運勢を計算
  const result = wasm.calculate_fortune(uuid);
  return result as FortuneResult;
}

特徴:

  • ✅ トップページから遷移時はサーバー負荷ゼロ
  • ✅ クライアント側でWASM実行して即座に結果表示
  • ✅ ユーザー体験最優先

🖥️ サーバーサイド実行(Node.js)

URLに直接アクセスした場合や、SNSでシェアされた際に検索エンジンのクローラーがアクセスする場合、メタ情報・クローラー対策のためにサーバー側でWASMを実行する必要があります。

実装では、generateMetadata関数で常にサーバー側でWASMを実行してOGP/メタデータを生成します。また、ResultPageコンポーネントではclient=1パラメータがない場合(直接アクセス時)にSSRでWASMを実行してHTMLを生成します。

apps/web/src/app/result/[uuid]/page.tsx

import { calculateFortune } from "@/lib/fortune-server";

// メタデータ生成: 常にサーバー側でWASM実行
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { uuid } = params;
  
  // サーバーサイドで運勢を計算(WASM使用)
  // OGPとメタデータ生成のために必要
  // client=1パラメータがあってもOGPのために計算は実行
  const fortune = calculateFortune(uuid);

  return {
    title: `${fortune.overall_level} - UUID おみくじの結果`,
    description: `あなたのUUID ${uuid} の運勢は ${fortune.overall_level} です!`,
    openGraph: {
      title: `${fortune.overall_level} - UUID おみくじの結果`,
      description: `あなたの運勢は ${fortune.overall_level} です!`,
      images: [{ url: `${baseUrl}/api/og/${uuid}` }],
    },
  };
}

// 結果ページ: client=1パラメータがない場合のみSSR
export default async function ResultPage({ params, searchParams }: Props) {
  const { uuid } = params;
  const isClientRendered = searchParams.client === "1";

  if (isClientRendered) {
    return <ResultClient uuid={uuid} serverFortune={null} />;
  }
  
  // 直接アクセスの場合: サーバー側でWASM実行
  const fortune = calculateFortune(uuid);
  return <ResultClient uuid={uuid} serverFortune={fortune} />;
}

サーバー側では、Node.js環境用のWASMモジュールを静的インポートします:

apps/web/src/lib/fortune-server.ts

// Node.js環境用のWASMモジュールを静的インポート
// uuid-omikuji-wasm/node エクスポートを使用して pkg-node を参照
import * as wasm from "uuid-omikuji-wasm/node";

// UUIDから運勢を計算(WASMを使用)
export function calculateFortune(uuid: string): FortuneResult {
  if (!isValidUuid(uuid)) {
    throw new Error("Invalid UUID format");
  }

  // WASMの関数を呼び出して運勢を計算
  const result = wasm.calculate_fortune(uuid);
  return result as FortuneResult;
}

特徴:

  • ✅ SEO完全対応(メタデータ生成で常にサーバー計算)
  • ✅ 完全なHTML配信
  • ✅ 初回ロードで結果が表示される

⚡ エッジ実行(Vercel Edge Runtime)

SNSでシェアされた際に表示されるOGP画像をエッジ環境で動的に生成するためにWASMを使用しています。同じビジネスロジックをエッジでも実行できることで、一貫性のあるOGP画像を生成できます。

実装では、/api/og/[uuid]エンドポイントでUUIDから運勢を計算し、その結果をもとにOGP画像(1200x630px)を動的に生成します。

apps/web/src/app/api/og/[uuid]/route.tsx

import { ImageResponse } from "@vercel/og";
import { calculateFortune } from "@/lib/fortune-edge";

export const runtime = "edge";

export async function GET(request: NextRequest, { params }: RouteParams) {
  const resolvedParams = params instanceof Promise ? await params : params;
  const { uuid } = resolvedParams;

  // 運勢を計算(WASMを使用)
  const fortuneLevel = await calculateFortuneLevel(uuid);
  const style = fortuneStyles[fortuneLevel];

  return new ImageResponse(
    (
      <div style={{ /* OGP画像のスタイル */ }}>
        <div style={{ fontSize: 140, color: style.text }}>
          {fortuneLevel}
        </div>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

エッジ環境では、Vercelの公式ドキュメントに従い?moduleサフィックスを使用してWASMをインポートします:

apps/web/src/lib/fortune-edge.ts

// Edge RuntimeでWASMを?module構文でインポート
// Vercel公式ドキュメントに従い、?moduleサフィックスでWASMをインポート
import wasmModule from "./uuid_omikuji_wasm_bg.wasm?module";
import { initSync, calculate_fortune } from "uuid-omikuji-wasm";

let wasmInitialized = false;

// Edge Runtime環境でWASMモジュールを初期化
function initWasmEdge() {
  if (wasmInitialized) {
    return;
  }

  // initSyncはWebAssembly.Moduleを受け取る
  // ?moduleでインポートしたモジュールを直接渡す
  initSync(wasmModule);
  wasmInitialized = true;
}

// UUIDから運勢を計算(WASMを使用)
export async function calculateFortune(uuid: string): Promise<FortuneResult> {
  // WASMモジュールを初期化(?moduleでインポートしたWASMモジュールを使用)
  initWasmEdge();

  // WASMの関数を呼び出して運勢を計算
  const result = calculate_fortune(uuid);
  return result as FortuneResult;
}

特徴:

  • ✅ グローバルCDNでの高速配信
  • ✅ サーバーレスコスト最適化
  • ✅ レイテンシー最小化
  • ✅ OGP画像の動的生成

🔧 実装:1つのビジネスロジックから複数環境向けビルド

コアロジックの実装

この例ではWASMモジュールをRustで実装しています。UUIDのバイト列から運勢スコアを計算し、各カテゴリのメッセージを決定論的に選択するビジネスロジックです。

packages/wasm/src/domain/fortune.rs

/// UUIDのバイト列から運勢スコアを計算
pub fn calculate_fortune_score(bytes: &[u8]) -> u32 {
    let mut sum: u32 = 0;
    for byte in bytes {
        sum = sum.wrapping_add(u32::from(*byte));
    }
    sum
}

このファイルには決定論性やエッジケースを確認する包括的なテストコードを用意しています。代表的なテストケースを紹介します:

#[test]
fn test_calculate_fortune_deterministic() {
    // 同じUUIDは常に同じ結果を返す
    let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
    let result1 = calculate_fortune(uuid_str).unwrap();
    let result2 = calculate_fortune(uuid_str).unwrap();
    
    assert_eq!(result1, result2);
}

#[test]
fn test_fortune_score_wrapping() {
    // オーバーフローのテスト
    let bytes = &[255; 20]; // 大きな値
    let score = calculate_fortune_score(bytes);
    assert!(score > 0); // wrapping_addにより正常に計算される
}

#[test]
fn test_calculate_fortune_invalid_uuid() {
    let invalid_uuid = "invalid-uuid";
    let result = calculate_fortune(invalid_uuid);
    assert!(result.is_err());
}

複数環境対応のWASMバインディング

wasm-bindgenを使って、各環境向けのバインディングを生成します:

packages/wasm/src/lib.rs

#[wasm_bindgen]
pub fn calculate_fortune(uuid_str: &str) -> Result<JsValue, JsValue> {
    let result = domain_calculate_fortune(uuid_str)
        .map_err(|e| JsValue::from_str(&e))?;

    serde_wasm_bindgen::to_value(&result)
        .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
}

ビルド設定:複数のターゲット対応

Cargo.tomlで複数のビルドターゲットを設定します:

packages/wasm/Cargo.toml

[lib]
crate-type = ["cdylib", "rlib"]  # WASMとネイティブライブラリ両方生成

[[bin]]
name = "omikuji"  # CLIツールとしても使用可能

CLIツールとして実行する例:

# 指定したUUIDで運勢を計算
cargo run --bin omikuji -- fortune 550e8400-e29b-41d4-a716-446655440000

# 新しいUUIDを生成しておみくじを引く
cargo run --bin omikuji -- new

環境別パッケージング

  • ブラウザ用: wasm-pack build --target web
  • Node.js用: wasm-pack build --target nodejs

各環境で同じロジックを異なる形式で実行できるのがWASMの強みです。

おわりに

RustとWASMの入門を兼ねて作成したこのプロジェクトを通じて、目標としていた「同じビジネスロジックをブラウザ・サーバー・エッジの3つの環境で動かす」ことを実現できました。ビジネスロジックをWASM1本にまとめることで、テスタビリティとポータビリティの両立が可能になりました。

WASMのポータビリティに魅力を感じて始めたプロジェクトでしたが、実際に作ってみると、WASMが単なる「ブラウザ高速化技術」ではなく、実行環境に依存しないビジネスロジック設計の基盤になり得る技術だと改めて実感しました。言語の壁を超え、単一コードベースでの多環境展開が現実的になりました。


このアプリは https://uuid-kuji.app/ で公開しています。ブラウザでの高速実行と、URL直接アクセス時のSEO対応をぜひ体感してください!

実際におみくじを引いて結果をシェアしてみてください。例として、私の結果はこちらです(残念ながら凶でした...)。

https://uuid-kuji.app/result/b2951410-9620-4c69-9528-8fe567791432

WASMによる実行環境に依存しない開発に興味のある方は、ぜひglassmonkey/omikuji も覗いてみてください。

https://github.com/glassmonkey/omikuji

もし良かったら@glassmonekeyをフォローしていただけると喜びます。

https://twitter.com/glassmonekey

Discussion