本記事は「WebAssembly連載」の第三回目の記事です。
皆さんこんにちは。虎の穴ラボのY.Fです。
前回、前々回記事では、web-sysやjs-sysを使ってWebAssemblyでWeb APIを使う方法を紹介しました。
今回は、より具体的にWebAssemblyを利用してアプリケーションを作ってみたいと思います。
WebAssemblyの特徴である、ポータビリティと既存資産の活用で、フロントエンド、サーバーサイド共通ロジックで動作するマークダウンライブラリを作ってみたいと思います。
利用技術
サーバーサイドでは手軽にWebAssemblyを利用できるDenoを使います。 フロント側はいつもどおりRustでWebAssemblyを作ります
- Deno: 1.29.4
- fresh: 1.1.2
- Rust: 1.66.1
- wasmbuild: 0.10.3
- Denoとwasmの連携用ツールです。以下サンプルを見ていただくと分かる通り、実態はwasm-bindgenになっています。
準備
上記のwasmbuildの記事と、freshの初期化手順でプロジェクトを作成します。プロジェクト名はmarkdown-wasmとしておきます。
deno run -A -r https://fresh.deno.dev markdown-wasm
出来上がったプロジェクトの、deno.jsonに以下を追記します。
{
"tasks": {
"start": "deno run -A --watch=static/,routes/ dev.ts",
+ "wasmbuild": "deno run -A https://deno.land/x/wasmbuild@0.8.5/main.ts"
},
"importMap": "./import_map.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
以下コマンドでwasmbuildの準備を行います。
deno task wasmbuild new
これで準備完了です。
サーバーサイド、フロントエンド同一のロジックをWebAssemblyで作成する
試しに、最初から用意されている足し算関数をサーバーサイド、フロントエンド共通ロジックにしてみます。
初期化した状態では、rs_lib
配下のRust関数は以下のように定義されているかと思います。
use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn add(a: i32, b: i32) -> i32 { return a + b; } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let result = add(1, 2); assert_eq!(result, 3); } }
このadd関数を利用します。
まずは、WebAssemblyをビルドします。
deno task wasmbuild
これで、 lib/rs_lib.generated.js
ができるので、これをエントリポイントとして利用します。
main.tsでサーバー起動時にwasm初期化してみます。
/// <reference no-default-lib="true" /> /// <reference lib="dom" /> /// <reference lib="dom.iterable" /> /// <reference lib="dom.asynciterable" /> /// <reference lib="deno.ns" /> import {start} from '$fresh/server.ts'; import manifest from './fresh.gen.ts'; + import {instantiate} from './lib/rs_lib.generated.js'; + await instantiate(); await start(manifest);
サーバー側の処理を作成するにはroutes/index.tsxでaddを呼び出します。
import { Head } from "$fresh/runtime.ts";
import Counter from "../islands/Counter.tsx";
import { add } from '../lib/rs_lib.generated.js';
export default function Home() {
+ const addResult = add(100, 1);
return (
<>
<Head>
<title>Fresh App</title>
</Head>
<div>
<span>wasm add result: {addResult}</span>
<img
src="/logo.svg"
width="128"
height="128"
alt="the fresh logo: a sliced lemon dripping with juice"
/>
<p>
Welcome to `fresh`. Try updating this message in the ./routes/index.tsx
file, and refresh.
</p>
<Counter start={3} />
</div>
</>
);
}
次に、フロント側の処理を作成するにはislandフォルダ以下にtsxファイルを作成します。
今回はAdd.tsxというファイル名で以下の内容を記述します。
import { useEffect, useState } from "preact/hooks"; import { JSX } from "preact/jsx-runtime"; import { Button } from "../components/Button.tsx"; import { add, instantiate } from "../lib/rs_lib.generated.js"; interface State { count: number; addLeft: number; addRight: number; isWasmReady: boolean; } export default function Add() { const [state, setState] = useState<State>({ count: 0, addLeft: 0, addRight: 0, isWasmReady: false, }); useEffect(() => { instantiate({ url: new URL("/rs_lib_bg.wasm", location.origin) }).then( () => { setState({ ...state, isWasmReady: true }); }, ); }, []); const onClick = () => { if (!state.isWasmReady) return; const count = add(state.addLeft, state.addRight); setState({ ...state, count: count, }); }; const onLeftChange = (e: JSX.TargetedEvent<HTMLInputElement>) => { setState({ ...state, addLeft: Number(e.currentTarget.value) || 0 }); }; const onRightChange = (e: JSX.TargetedEvent<HTMLInputElement>) => { setState({ ...state, addRight: Number(e.currentTarget.value) || 0 }); }; return ( <div> <div> <Button disabled={!state.isWasmReady} onClick={onClick}> 足し算を実行する </Button> </div> <input type="number" value={state.addLeft} onInput={onLeftChange} /> + <input type="number" value={state.addRight} onInput={onRightChange} /> = <span>{state.count}</span> </div> ); }
フロント側では、*.wasmファイルは instantiate
時にdynamic importして読み込まれます。単に instantiate
してしまうと、相対パスで読み込まれるため、パスが _frsh/js/hogehoge.wasm
などになってしまい、正常に読み込めません。
そこで、 instantiate
関数の引数にオプションとしてwasmのURLを記述します。また、freshで静的ファイルを配布するには、staticフォルダ配下にwasmファイルを何らかの形で配布しておく必要があります。今回は lib
配下に生成されるwasmへのハードリンクを作成することにしました。
他にも、wasmbuildの設定で出力先を lib
以外に変えることもできるので、 static
配下にする方法も取れるかと思います。
WebAssemblyでマークダウンを扱う
次に、Rustで作っているWebAssemblyでマークダウンを扱えるようにしてみます。
既存の資産を利用したいため、今回は以下のライブラリを利用します。
https://crates.io/crates/pulldown-cmark
まずは、rs_lib/Cargo.tomlに以下追記します。
[package]
name = "rs_lib"
version = "0.0.0"
edition = "2021"
[lib]
crate_type = ["cdylib"]
[profile.release]
codegen-units = 1
incremental = true
lto = true
opt-level = "z"
[dependencies]
+ pulldown-cmark = "0.9.2"
wasm-bindgen = "=0.2.83"
rs_lib/src/lib.rsで上記ライブラリを利用します。
use pulldown_cmark::{html, Options, Parser}; use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn add(a: i32, b: i32) -> i32 { return a + b; } #[wasm_bindgen] pub fn parse_markdown(markdown_input: &str) -> String { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); let parser = Parser::new_ext(markdown_input, options); let mut html_output = String::new(); html::push_html(&mut html_output, parser); html_output } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let result = add(1, 2); assert_eq!(result, 3); } #[test] fn parse_test() { let html_output = parse_markdown( "Hello world, this is a ~~complicated~~ *very simple* example.", ); let expected_html = "<p>Hello world, this is a <del>complicated</del> <em>very simple</em> example.</p>\n"; assert_eq!(expected_html, &html_output); } }
add関数に加えて、parse_markdown及びそれに対するテストを追加しました。テストの実行はrs_libフォルダで cargo test
とすることで実行できます。
これで、deno task wasmbuild
ができればWebAssemblyの生成は無事完了です。
WebAssemblyで作ったマークダウンライブラリをサーバーサイドでもフロントエンドでも扱う
では、上記作ったマークダウンライブラリを組み込んでみます。
routes/md.tsxを作ります。
import { Head } from "$fresh/runtime.ts"; import Markdown from "../islands/Markdown.tsx"; import { parse_markdown } from "../lib/rs_lib.generated.js"; const README = ` # WebAssemblyでマークダウンライブラリを作る この文章はサーバーサイドでWebAssemblyを利用して、マークダウンから作られたものです。 利用技術は以下になります。 - Rust - [pulldown_cmark](https://crates.io/crates/pulldown-cmark) - Deno - fresh `; export default function Md() { const mdHTML = parse_markdown(README); return ( <> <Head> <title>Fresh App</title> </Head> <div dangerouslySetInnerHTML={{__html: mdHTML}}> </div> <Markdown /> </> ); }
ここでは、サーバー側の処理として、説明文をマークダウンで記述し、HTMLを生成しています。
次に、上記で利用しているislands/Markdown.tsxを作成します。
import { useEffect, useState } from "preact/hooks"; import { JSX } from "preact/jsx-runtime"; import { instantiate, parse_markdown } from "../lib/rs_lib.generated.js"; interface State { md: string; html: string; isWasmReady: boolean; } const TEXTAREA_STYLE = { width: 500, height: 800 }; export default function Add() { const [state, setState] = useState<State>({ md: "", html: "", isWasmReady: false, }); useEffect(() => { instantiate({ url: new URL("/rs_lib_bg.wasm", location.origin) }).then( () => { setState({ ...state, isWasmReady: true }); }, ); }, []); const onInput = (e: JSX.TargetedEvent<HTMLTextAreaElement>) => { if (!state.isWasmReady) return; const md = e.currentTarget.value; const html = parse_markdown(md); setState({ ...state, html, md }); }; return ( <div style={{display: "flex"}}> <textarea style={TEXTAREA_STYLE} disabled={!state.isWasmReady} value={state.md} onInput={onInput} /> <div dangerouslySetInnerHTML={{ __html: state.html }}></div> </div> ); }
フロントエンドの処理として、textareaに入力されたマークダウンがリアルタイムで右側に表示されるようにしてみました。
実際に出来上がったものは以下になります。
注意点
dangerouslySetInnerHTMLを使っているのを見ていただくと分かる通り、出来上がったHTMLに安全な保証が無い限りは、xssなど発生する危険性があります。
HTMLのサニタイズ処理もWebAssembly側に任せることで、こちらもフロント、サーバーで処理を共有できます。
ちょうど pulldown_cmark
と合わせた利用方法が紹介されていたので、今回は以下を利用します。
https://crates.io/crates/ammonia
では組み込んでみます。
use ammonia::clean; use pulldown_cmark::{html, Options, Parser}; use wasm_bindgen::prelude::*; // ... #[wasm_bindgen] pub fn parse_markdown(markdown_input: &str) -> String { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); let parser = Parser::new_ext(markdown_input, options); let mut html_output = String::new(); html::push_html(&mut html_output, parser); clean(&*html_output) } #[cfg(test)] mod tests { // ... #[test] fn parse_xss_test() { let html_output = parse_markdown("XSS<script>alert(1);</script>"); let expected_html = "<p>XSS</p>\n"; assert_eq!(expected_html, &html_output); } }
基本的にはammoniaのサンプル通りです。
テストケースにあるように、危険な部分は消される形になります。
実際に、危険なタグ要素を入れてみても以下のように消されます。
まとめ
今回の記事では、WebAssemblyのポータビリティと、既存資産の流用を活かしてアプリを作ってみました。
今回はマークダウンでしたが、ものによってはWysiwygエディタをサーバーサイドレンダリングしたい時なども利用できるかと思います。
WebAssemblyは高速化の面が強調されがちですが、ポータビリティ的な面でも注目できる点があると思います。
P.S.
採用
虎の穴では一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。
yumenosora.co.jp