🐿️

WebAssembly・PWAの習作アプリを作成して感じたこと

2024/06/04に公開

はじめに

demo

この記事では、個人の習作としてwasm-image-resizerという簡易な画像リサイズ・フォーマット変換ツールをWebAssembly + PWA + Reactで作成したので、そのときに私の得た知見を記載したいと思います。
アプリとしては実に単純であんまり面白みもないのですが、半ばちょっとした好奇心と勢いで自分なりに作ってみました。手探りで拙いですが、ソースコードは以下に配布していますので、よかったら見てみてください。

https://github.com/kawana77b/wasm-image-resizer

アプリの技術目標

このアプリを通して私が考えた技術目標は以下のとおりです。

  • フロントエンドとしては言語はTypeScript、Viteをベースにする
  • UIのフレームワークとしてReactを使用
  • 画像変換などのアプリでやりたいコアな処理はRustで記述
  • wasm-bindgenwasm-packでWebAssembly化・javascriptパッケージ化を行う
  • Vite PWAを利用して簡単なアプリのPWA化を試す
  • Webホストはしないが、誰でもDockerでアプリを試せるようにする

私は今の所WebAssemblyやPWA、Rustにはあまり深い知識はなく(ちょっとだけかいつまんだのでほんの少し読める)、今回記載する技術群は初めてトライしてみたものが多いです。

このアプリを作った理由

以前の私の記事のElectronでWindows/Linux向けアプリの習作開発を通して感じた注意点・感想で書いているのですが、使っている依存パッケージにはC/C++依存の関係があると動かないことがLinux環境下だと時々ありました。その中で、クロスプラットフォームを探求する上での表現の方法の1つとして、WebAssemblyとPWAの組み合わせは良い技術なのではないかと思いました。

WebAssemblyはブラウザやNode.jsでは標準サポートされており、組み込みのランタイムがあります。PWAは気軽にインストールしたり消そうと思えばすぐ消せて、デスクトップアプリ感覚でアプリを使えます。そのような点で、比較的多くの人が多くのプラットフォームで使えるようなことが期待できることから、学んでみても良いのではないか、と思いました。

WebAssemblyとPWAはどんな特性があるのか

細かに理解している訳ではありませんが、現状の私の解釈と実際にアプリを作ってみてWebAseemblyとPWAについて、特性だと思ったことを記載します。

WebAssembly

WebAssemblyとはブラウザで標準実行可能なアセンブリ風コードであり、一般的に.wasmで示されるバイナリにより様々なことを実行できます。

OSおよびCPUアーキテクチャに依存することがない

私はこれが最も重要な利点だと感じており、1つWebAssemblyを使ったものを作った動機にもなっています。「依存の関心」がランタイムに移すことができます。このランタイムとは、基本的にブラウザ、Node.js、wasmtimeなどで、実質javascriptワールドならかなりの場所で動くことが期待されます。これは結構画期的なんじゃ、と思いました。

バイナリは基本的に依存関係が内部で静的リンクされており、サンドボックスで実行されます。閉じた関係で動作する保証がかなり効くところが良いところだと感じました。特にC/C++系の依存関係があると、OSごとにdllだとかsoだとか色々な要素が事前にないといけなかったりすることがあります。ですので、このライブラリないから動かない!ってことが少ないことが期待できます。特にマルチメディア・AI処理の分野においてはC/C++関係のライブラリへの依存が顕著なので、そういった分野の処理を展開しやすくなるだろうと思います。

今後色々なライブラリがwasm化した上で、使えるようなものを作ろう、と思う人が出てくると、こういった問題が少なくなるメリットがあるだろうと思います。

色々な言語から生成できる

例えば、Rust, Go, .NET(C#)などです。

今回画像変換処理をやった訳ですが、別にjavascriptでも出来ますし、リサイズはCanvas駆使したりする方法もあります。ここは各々で何を大事にするか、というプライオリティにも関係しますが、別言語の方が知識的に複雑なことをやりやすかったりコードを使い回しやすい、なんてことも時たまあります。例えば、CanvasはjavascriptやWebフロントエンド特有の概念なので、大事な場合はそれをガンガン使えば良いのですが、そこは大事じゃない、みたいな開発分野の人(システム分野やゲーム開発者など)にとっては「そこ頑張りどころだろうか?」という話なのです。

WebAssemblyは一般的なプログラミング言語そのものに関係したナニカではないことがポイントだろうと思います。今後ツールが充実してくることで、色々な言語からやりたいことを実現できるようになればいいな、と思っています。

WebAssemblyはパフォーマンスが注目されがちですが、結局V8速いよね、とかやり取り時のボトルネックみたいなものは問題になりやすいと思います。
そこだけに注目するとつまらなくなっちゃうと思うので、個人的には取り回し・汎用性といったところに、WebAssemblyの魅力があるかな、と感じています。

Dockerと比較して

このような点から、Dockerっぽい感じに使えるんじゃ?ということで、WASIというOSのシステムコールまでちょっと標準化で踏み込んだ規格も出てきています。WASIはwasmtimewasmerのようなランタイムで、一種の実行ファイルのように使ったりすることが出来ます。

Dockerとの比較としては、例えば社内サーバでDockerコンテナで各種アプリを運用しており、新規で業務ツールとしてある既存アプリを使いたいのだが、DockerHubを覗くとamd64のみがサポートされておりarm64では動かない、なんてことが時たまあります。そのサーバが最近のMac Miniだったり、ラズパイだったりする場合、CPUアーキテクチャの依存例として顕著な気がします。WebAssemblyはこういったことがないので、便利と言えそうですね。また、イメージサイズも数百MBなんて当たり前で、とても大きいことが多いです。この点がWebAssembly良いぞ、って言われてる所以だと思います。

ただ、今の所の仕様ではあんまり使えることも少なく、Dockerコンテナと全く同じように、と言う意識での使い方はあまり出来ない現状があると思いました。今後の発展に期待だと感じました。

グルーコードが面倒

基本的に外部とのやり取りは数値型しか扱えません。文字列やデータを本来やりとりしたいパターンが多いはずで、実質的に組み込んだAPI関数を使うには結局グルーコードが必要です。
これはなんだかんだ面倒で、C/C++のような伝統的なメモリ確保・解放操作を高階言語でも行う必要があります。メチャクチャやりたくないですね(笑)
例えば、wasm-bindgenを利用したときに生成されるグルーコードの例を以下に示します。

wasm-bindgenを利用したグルーコードの生成例
try {
    const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
    const ptr0 = passArray8ToWasm0(image, wasm.__wbindgen_malloc);
    const len0 = WASM_VECTOR_LEN;
    const ptr1 = passStringToWasm0(format, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
    const len1 = WASM_VECTOR_LEN;
    wasm.resize_image(retptr, ptr0, len0, width, height, ptr1, len1);
    var r0 = getInt32Memory0()[retptr / 4 + 0];
    var r1 = getInt32Memory0()[retptr / 4 + 1];
    var v3 = getArrayU8FromWasm0(r0, r1).slice();
    wasm.__wbindgen_free(r0, r1 * 1, 1);
    return v3;
} finally {
    wasm.__wbindgen_add_to_stack_pointer(16);
}

Rustだとwasm-bindgenwasm-packを利用して、簡単にグルーコードとjavascriptパッケージの生成ができます。これがRustの強いところだと思います。結局今の所WebAssemblyといったらRustがほぼほぼ一強、なのだと思いました。

一応恐らくはこういうことは問題だとグローバルに捉えられていて、WASI方面からComponent Modelなる仕様が登場し、wit-bindgenなどのツールが作られているそうです。また、Wasm GCのような実装も進んでいます。
今後、WebAssemblyが普及するかのカギはこういったツール群の充実だと感じました。ツールや仕様策定の充実で色々作りやすくなればな、と思いました。

PWA

PWA (Progressive web apps)はブラウザ標準の機能で、Webアプリをデスクトップアプリっぽく使えることができます。
具体的には、デスクトップやスマホのホーム画面にショートカットのアイコンを作成し、通知表示などを行うことができます。UXはネイティブに劣りますし、ブラウザが使えないネイティブ機能は使えません。一方で、クロスプラットフォームに対応している上に、あらゆる部分をブラウザが代わりに管理してくれるのでユーザ側のインストール・アンインストールが非常に容易です。ネイティブアプリではやや問題になりがちな署名も必要ありません。

その実装はWebアプリにmanifest.jsonという特定の仕様のjsonを配布するだけで済み、公共で配布する場合はhttpsで配信されていることが条件になります。この為、構築自体の手数はネイティブアプリの構築に手慣れていない場合、かなり少ないです。

特性としてService Workerというブラウザ機能を使い(後述するWeb Workerとは別物)、リソースキャッシュが活かせるようになっています。

WebAssemblyの成果物は最低結局数MBくらいは必要になったりする特性があります。これは先述のDockerと比べればかなり少ないのですが、フロントエンドのパッケージと考えれば結構大きい物量です。その為、WebAssemblyをフロントエンドで使う場合、PWAみたいなものとは親和性が良いんじゃないかな、と思いました。今回はVite PWAというVite用プラグインを使って、WebアプリのPWA化について簡単に試しています。このプラグインを使うと、Viteでのアプリ作成下において、ある程度マニフェストやアセット、ServiceWorkerのスクリプト作成について自動化することが出来ます。

個人的にはHoppscotchのようなツールでPWAを使っているのですが、日本でよく使われそうなユースケースを考えてみた場合、例えば業務端末として社内タブレットや社内スマホを渡して色々なアプリで業務管理している会社がある、というケースなどがあるのではないか、と想像しました。
何らかのタスク閲覧アプリを社内PCや社内スマホなどのマルチプラットフォームで共通で作成することは開発に手間がかかってしまい、非技術者の従業員の方が都度その使い方を覚えるのも大変です。そこでPWAのような技術を使って、タブレットやスマホからポチってするだけで同じように使えるようにすれば、とてもラクになるのでは、と思いました。

工夫した点など

WebAssemblyやPWAとあまり関係ありませんが、今回の習作を通しての私なりに工夫した点・作っていて感じた注意点など感じたことをざっくばらんに記載します。

WebWorkerのメッセージング

今回wasmを動かすことにはWebWorkerを使いました。WebWorkerとは、ブラウザ上で描画系統とは別にバックグラウンドのプロセスを動かすWebAPIまたはブラウザの機能です。描画とは別にスレッドを動かせるので、WebAssemblyとは親和性が良いだろうと思いました。
WebWorkerの基本的な使い方は簡単で、addListnerpostMessageメソッドを用いてお互いのプロセスのメッセージのリスンと送信を行います。

今回は、お互いのデータ送受信はchanelという固定の文字列を鍵に、payloadという任意のデータ型をやりとりするような構造としました。

Worker側の処理

WorkerについてはApiという型を定義し、それに準じたオブジェクトの鍵で呼び出された関数を実行するようにしました。

Worker側のスクリプト処理
type Api = {
  [key in WorkerChannel]: (
    payload: WorkerChannelMap[key]["request"]
  ) => WorkerChannelMap[key]["response"];
};

const api: Api = {
  //....
};

self.addEventListener("message", (e) => {
  const channel = e.data.channel as WorkerChannel;
  const responsePayload = api[channel](e.data.payload);
  self.postMessage({
    channel,
    payload: responsePayload,
  });
});

UI側の処理

UI側ではReactのContextとHooksの機能を使って、Workerインスタンスを取り出すWorkerProviderコンポーネントとuseWorkerフックを作ることで抽象化を狙いました。

WorkerProvider
import { type ReactNode, useEffect, useRef } from "react";

import worker from "@/feature/worker/WebWorker?worker";

import { workerContext } from "../hooks";

interface WorkerProviderProps {
  children?: ReactNode;
}

export const WorkerProvider = ({ children }: WorkerProviderProps) => {
  const workerRef = useRef<Worker | null>(null);

  useEffect(() => {
    workerRef.current = new worker();
    return () => {
      workerRef.current?.terminate();
    };
  }, []);

  return (
    <workerContext.Provider
      value={{
        worker: workerRef,
      }}
    >
      {children}
    </workerContext.Provider>
  );
};
useWorker
export function useWorker() {
  const { worker } = useContext(workerContext);
  const request = <TChannel extends WorkerChannel>(
    channel: TChannel,
    payload: WorkerRequestPayload<TChannel>
  ) => {
    return new Promise<WorkerResponsePayload<TChannel>>((resolve, reject) => {
      if (!worker.current) {
        reject(new Error("Worker is not ready"));
        return;
      }

      const listener = ({ data }: MessageEvent) => {
        if (data.channel === channel) {
          worker.current?.removeEventListener("message", listener);
          resolve(data.payload);
        }
      };
      worker.current.addEventListener("message", listener);
      worker.current.postMessage({ channel, payload });
    });
  };

  return { request };
}

工夫した点としては、useWorkerフックにて、PromiseでWorker側へのリスンと送信をひとまとめにし、requestという関数で取れるようにしたところです。レスポンス用のコールバックの事前登録と、レスポンスが来たときにPromiseの解決を行うようにして、その後にリクエストの送信を行う、というやり方をしています。

このようにしておくと、UI側で関数を使ってから、その結果を取りやすくなり、可読性の点でメリットがあると思いました。全ての部分で型による補完も効くようになり、使う側にとってメリットがあると考えています(使う人私しかいないのですが…)。使用例を以下に示します。

const { width, height, format, data_url } = await request("analyze_image", {
  data,
});

なお、WebWorkerを外部ライブラリ経由で取り扱いやすくするには、Comlinkのようなライブラリがあるようです。

tsconfigの@エイリアス

アイデアとしては個人的にはNext.jsからの引用です。tsconfig.jsonでエイリアスを定めておくと、ファイルのインポートの扱いがキレイになると思っています。

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

ディレクトリ構造は毎回悩むのですが、今回はfeatureフォルダの傘下で機能ごとに色々な要素を区切るやり方をとってみました。

このようにしておくと、親の親の親の相対パスを書いて…なんてことが少なくなるので、ファイルの位置関係が分かりやすくなったりするメリットがあると思います。

import例
import { MoonIcon, SunIcon } from "@/components/icons";

Result型

私は個人的には言語そのものの例外機構には伝統的なtry...catchを使うことが多い気がしますが、一方で「UIやCLIツールでユーザ向けに表示したいエラー」というものもあります。
私は時折、このようなシチュエーションでは結果クラスなるものを作成し、ラップしてロジックハンドリングをすることを好む傾向がある気がするのですが、今回もその手を使ってみました。元ネタはGoやRustでのResultパターンの引用です。

例えば、以下のように使います。このような条件分岐を包むクラスを作っておくと、色々なところでネストが少なくなり、都度の意図が分かりやすくなるので見やすくなると思いました。

const validateFile = (file: File) => {
  const res = FileSchema.safeParse({
    mime: file.type,
    bytes: file.size,
  } satisfies FileValue);
  return res.success
    ? NewResult.success()
    : NewResult.err([...res.error.errors.map((e) => e.message)]);
};
//....
const fileResult = validateFile(file);
if (!fileResult.ok) {
  setMessage(fileResult.error);
  return;
}

こういったものは自前で簡単なモノでも作成しておく価値があり、後々便利なことも多いと感じています。外部ライブラリを使いたい場合、Typescriptではts-resultsのようなものがあります。

安直ではありますが、今回使った私なりのResultの実装例を以下に示します。

Result型の実装例
interface IResult {
  ok: boolean;
}

export type Result<T, U> = Ok<T> | Err<U>;

class Ok<T> implements IResult {
  readonly ok = true;
  readonly value: T;
  constructor(value: T) {
    this.value = value;
  }
}

class Err<U> implements IResult {
  readonly ok = false;
  readonly error: U;
  constructor(error: U) {
    this.error = error;
  }

  throw() {
    throw this.error;
  }
}

export class NewResult {
  static ok<T>(value: T) {
    return new Ok(value);
  }

  static err<U>(error: U) {
    return new Err(error);
  }

  static success(): Ok<true> {
    return new Ok(true);
  }

  static fail(reason: string): Err<Error> {
    return new Err(new Error(reason));
  }
}

z.lazy()

今回様々な検証にzodを利用しました。zodを利用すると、スキーマ単位でのルール検証がとてもやりやすくなります。

const validateDimension = (width: number, height: number) => {
  const res = DimensionSchema.safeParse({
    width,
    height,
  } satisfies DimensionValue);
  return res.success
    ? NewResult.success()
    : NewResult.err([...res.error.errors.map((e) => e.message)]);
};

ところで、このようなスキーマは拡張したり取り出したりすることが色々できますが、例えば、あるスキーマを使いまわして新たなスキーマを作ろうとすると、Cannot access 'xxx' before initializationのエラーが出てしまうので、これに対処する必要があります。
この為には、z.lazy()メソッドを使って呼び出す必要があります。以下にすでに存在するスキーマを再利用しようとする例を以下に示します。

z.lazy()の利用例
import { z } from "zod";

import { ImageSchema } from "@/feature/image";

export const DimensionSchema = z.lazy(() =>
  ImageSchema.pick({
    width: true,
    height: true,
  })
);

export type DimensionValue = z.infer<typeof DimensionSchema>;

DockerfileでNodeイメージにRustをインストール

今回Webでホストしない代わりに、もし試したい人がいるならDockerで試せる形にしました。この際、使うイメージとしてはDebian系だと比較的環境構築はラクにいけます。今回環境再現する為には、NodeイメージをベースにRustをインストールする必要がありましたので、その為のDockerfileを作成しました。
やってることはRustの公式イメージの環境変数設定を参考にしつつ、Rust公式のインストールスクリプトを実行させるよう、ちょっと引用から書き換え、リダイレクトしたスクリプトを実行させ、そして消しているだけです。
注目したい点として、Debian系統にはbuild-essentialという便利なパッケージがあります。これはgcc, g++, makeなど、C/C++関連のビルド・コンパイルに必要な基本ツールが一式揃うようなものになっており、サイズ等を考えなければこれを指定すると、そんなに詰まることがないのでラクだと考えています。Rustに限らず、C/C++系の何かが絡んだ時は指定すると上手く行くことは結構多いのでおすすめです。

FROM node:22-bullseye-slim

ENV RUSTUP_HOME=/usr/local/rustup \
  CARGO_HOME=/usr/local/cargo \
  PATH=/usr/local/cargo/bin:$PATH

RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates build-essential  \
  && curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf > rustup.sh && sh rustup.sh -y \
  && rm rustup.sh

おわりに

結局新しいことは色々覚える必要がありますが、WebAseemblyやPWAはネイティブベースのことを覚えるよりは学習コストは少ないです。また、WebAseemblyはRustが今のところ一番作りやすいであろうことは身をもって感じました。

これらの技術はイマイチ2024年現在はパッと華がない感じがしており、日本語記事は注目度の割にそう多くはないと感じます。これは、1つとして若干クセが多く分かりにくい・取っ付きづらいことが原因でもある気がしています。PWAはUXにおいては随分ネイティブアプリに劣りますし、WebAssemblyは低レイヤの存在でもあり、仕様策定が一旦区切りがついた程度で、まだまだ進展の余地が非常に大きい(しかし、仕様策定はやや遅く色々拡張が出てきている)ことが挙げられると思います。

今後、これから色々なツールや仕様策定が深まっていって、より使いやすくなるよう盛り上がってくれれば良いな、と思います。これらの技術は、クロスプラットフォームで動作し、やや複雑な目的を探求する上で、1つの選択肢にはなるのではないでしょうか。

私は知識がまだ甘いので、より知識を深められたらな、と感じました。同様の目的を持たれている方に、この記事が少しでも参考になって頂けたら幸いです。

Discussion