sukawasatoru.com

Wasm で Rust と Node.js に同じ実装を使用する

Markdown で書いた文章を GitHub Pages (で使われている Jekyll) で表示していましたが Next.js に興味があったのでそれを使用して HTML に変換するようにしました。また Stork を組み込みましたが、一部処理に WebAssembly (wasm) を使える個所があったのでこちらもついでに使ってみました。

Markdown から HTML を自動生成する

Next.js は getStaticPathsgetStaticProps を使用することで SSG により任意の値を埋め込んだ HTML を生成することができます。この仕組みを利用して Markdown で書いた文章を walk し Parse することで Index を自動生成するようにしました。

// src/pages/[stem].tsx

export const getStaticPaths: GetStaticPaths<StaticPath> = async () => {
  // paths に渡した配列の数だけ HTML を生成することができる。
  const docs = await retrieveDocs();
  const paths: GetStaticPathsResult<StaticPath>["paths"] = docs.map(value => ({
    params: {
      stem: value.stem,
    }
  }));

  return {
    paths,
    fallback: false,
  };
};

export const getStaticProps: GetStaticProps<Props, StaticPath> = async context => {
  // getStaticPaths から渡された context.params を使用して FunctionComponent の
  // props として渡す値を生成する。
  const stem = context.params!.stem;
  const docs = await retrieveDocs();
  const filepath = docs.find(value => value.stem === stem)!.filepath;
  const matterFile = matter((await readFile(filepath)));
  const mdxSource = await renderToString(matterFile.content,
  	{scope: matterFile.data});

  return {
    props: {
      mdxSource,
      stem,
    },
  };
};

// src/function/docs.ts
export const retrieveDocs = async (): Promise<DocEntry[]> => {
  // src/docs にある markdown を parse する。
  const docsPath = `${process.cwd()}/src/docs`;
  const names = await readdir(docsPath);

  const ret: DocEntry[] = [];

  for (const filename of names) {
    if (!filename.endsWith(".md")) {
      continue;
    }

    const filepath = `${docsPath}/${filename}`;
    const entryStat = await stat(`${docsPath}/${filename}`);
    if (!entryStat.isFile()) {
      continue;
    }

    ret.push({
      filepath,
      stem: filename.substring(0, filename.length - ".md".length),
    });

  }

  return ret;
};

このようなコードを書くことで SSG に対応することができます。簡単ですね。

Stork で全文検索に対応する

Next.js への移行のついでに、興味があったけど使う機会がなかった Stork を組み込みました。 Stork は HTML / Markdown を読み込ませることで生成したファイルを JavaScript + wasm に読み込ませることで Server 無しに検索機能を使用することができます。 GitHub Pages を使えば GitHub にファイルを Push するだけで全文検索することができます。

const Stork: FunctionComponent<unknown> = () => {
  useEffect(() => {
    const waitStork = async () => {
      const timer = (millis: number) =>
        new Promise(resolve => window.setTimeout(resolve, millis));
      while (!stork) {
        await timer(100);
      }
    };

    (async () => {
      await waitStork();
      stork!.register(storkName, '/blog.st', {minimumQueryLength: 2});
    })();
  }, []);

  return <>
    <Head>
      <link rel="stylesheet" href="https://files.stork-search.net/basic.css"/>
    </Head>
    <div className="stork-wrapper">
      <input className="stork-input" data-stork={storkName} placeholder="Search..."/>
      <div className="stork-progress" style={{width: '100%', opacity: 0}}></div>
      <div className="stork-output" data-stork={`${storkName}-output`}></div>
    </div>
    <script src="https://files.stork-search.net/stork.js"></script>
  </>;
};

React の使い方としてあっているか分かりませんがやっていることは特定の className を設定した div を用意して css と javascript を読み込み CLI から生成したファイルを stork.register() で読み込ませているだけです。

これでめでたしめでたし、で終われば良かったのですが、 Stork の CLI でファイルを生成するためには検索対象のすべてのページそれぞれに設定を記述する必要があります。 README のサンプルの設定 を一部引用すると

[input]
files = [
    {path = "federalist-1.txt", url = "/federalist-1/", title = "Introduction"},
    {path = "federalist-2.txt", url = "/federalist-2/", title = "Concerning Dangers from Foreign Force and Influence"},
      # snip.
    {path = "federalist-9.txt", url = "/federalist-9/", title = "The Union as a Safeguard Against Domestic Faction and Insurrection"},
    {path = "federalist-10.txt", url = "/federalist-10/", title = "The Union as a Safeguard Against Domestic Faction and Insurrection 2"}
]

といった感じですので Markdown ファイルを作成するたびに同期する必要があるので、できれば避けたいですね。

幸い Stork は Rust で実装されているため Rust で CLI を作成し設定ファイルを walk する実装をすることでこの手間は解決することができます。ある程度 Rust を書いている人はこの toml を見て「多分 toml-rs 使っているから struct 作ってやればいいんじゃないかな」と思うかもしれませんが、その通りで walk する crate とかを使ってあとは .md のファイルを Vec にすればほぼやりたいことは実現できます。

これでやりたいことができてめでたしめでたし、と思いましたがこのあと Node.js で書いた Index を生成する実装に機能追加していくと Rust で書いた Stork 向けの設定ファイルを生成する実装と同じことをしていることに気付きました。

今後変更を加える場合 Node.js / Rust それぞれに同じ変更をしなければならないため、ミスによりそれぞれの動きに差分を出してしまうことは避けたいです。

せめて Markdown を Parse する部分の実装だけでも共通化できたら、、、

WebAssembly で Rust と Node.js で処理を共通化する

ところで Rust は wasm に対応しています。ということは Rust で実装すればもちろん Rust に使えますし Rust から wasm で出力すれば Node.js に読み込ませて実行することができます。最高。

前置きが長くなりましたがここから wasm の環境構築から実際に動くものを作るまでをやっていきます。

実装したものは GitHub: sukawasatoru/blog に push しました。

wasm-bindgen という有名な crate があるので、初手それのみを使用して実装しましたがいろいろ気をつけなければならない点があり面倒でしたので (Node.js に load させるには、あたりまで進めてあきらめた) wasn-bindgen に加えいろいろ気を使ってくれるツールの wasm-pack を使用します。

wasm-pack を使うに当たって覚えることは

  1. Releases から Download するもしくは cargo install で Install する
  2. 通常の Project で wasm-pack build を実行して Build する

の 2点です。しばらくはこれだけで十分開発できます。

ちなみに Guide を読むと wasm-pack build の Option に --target があり bundler / nodejs / web / no-module を指定できるので nodejs を使おうと思うかもしれませんが、今回は Next.js (内部で Webpack) で開発しているので bundler を使う必要があります。

wasm-bindgen の Guidewasn-pack の Guide を一通り読むと次の Cargo.toml と lib.rs をベースにすればよさそうでした。

[lib]
crate-type = ["cdylib", "rlib"]

[features]
wasm = []

[dependencies.wasm-bindgen]
version = "=0.2.70"
features = ["serde-serialize"]

[dev-dependencies]
wasm-bindgen-test = "=0.3.20"

[profile.release]
opt-level = "s"
lto = true
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);
}

macro_rules! console_log {
    ($($t:tt)*) => {
        if cfg!(feature="wasm") {
            log(&format_args!($($t)*).to_string())
        } else {
            // ここでは tracing を使っているが env_logger など
            // 好みの log crate を使って良い。
            #[cfg(not(feature="wasm"))]
            tracing::info!($($t)*)
        }
    }
}

#[wasm_bindgen]
pub fn greet() {
    console_log!("Hello {}", "world");
}

簡単な説明をすると extern "C" は Rust から使用する Node.js 側の IF を記述します。この例では Rust から console.log() を実行します。

macro_rules! console_log は log を wrap したものです。 CLI 向けと Node.js の両方で log を出力するために features を使って log の向きを変えるよう wrap しています。

この例ですと wasm-pack build -- --features wasm で Build することで console_log!("Helllo {}, "world") としたときに console.log("Hello world") が実行され cargo build で Build することで tracing::info!("Hello world") が実行されます。

#[wasm_bindgen] pub fn greet() は Rust から公開する関数です。 Node.js からは

// foo は wasm-pack が生成する。
const {greet} = await import("foo");
greet();

とすることで使用できます。 ここで 1点ルールが有り wasm-pack が生成した JS は Dynamic import で Import する必要があります。

ではここから Rust / Node.js 共通で利用したい関数を実装していきます。今回は Markdown のフォーマットの String からタイトル・作成日時・更新日時を Parse する関数を作成します。重要な部分を抜き出すと次のような Code になります:

#[wasm_bindgen]
#[derive(serde::Serialize)]
pub struct DocEntry {
	// 文章のタイトル。
    title: String,
    // 文章の作成日時。
    first_edition: String,
    // 文章の更新日時。
    last_modify: Option<String>,
}

#[wasm_bindgen]
impl DocEntry {
    #[wasm_bindgen(getter)]
    pub fn title(&self) -> String {
        self.title.to_string()
    }

    #[wasm_bindgen(getter = firstEdition)]
    pub fn first_edition(&self) -> String {
        self.first_edition.to_string()
    }

    #[wasm_bindgen(getter = lastModify)]
    pub fn last_modify(&self) -> Option<String> {
        self.last_modify.as_ref().map(|data| data.to_string())
    }
}

#[wasm_bindgen(js_name = parseDocs)]
pub fn parse_docs(doc: &str) -> Option<DocEntry> {
    console_log!("parse_docs");

    let mut title = None;
    let mut first_edition = None;
    let mut last_modify = None;
    let first_regex = regex::Regex::new(r#"^([-0-9]*) \(First edition\)"#).unwrap();
    let modify_regex = regex::Regex::new(r#"^([-0-9]*) \(Last modify\)"#).unwrap();
    let title_sep_regex = regex::Regex::new(r#"^=*$"#).unwrap();

    for entry in doc.lines() {
    	// parse content.
    	// snip.
    	match chrono::NaiveDate::from_str(&data[1]) {
    		Some(data) => {}
    		None => {}
    	}
    	// snip.
    }

    if title.is_none() {
        console_log!("the title is None");
        return None;
    }

    if first_edition.is_none() {
        console_log!("the first_efition is None");
        return None;
    }

    Some(DocEntry {
        title: title.unwrap(),
        first_edition: first_edition.unwrap().to_string(),
        last_modify: last_modify.map(|data| data.to_string()),
    })
}

pub fn parse_docs() が Rust と Node.js に公開する関数です。この関数は日時のために chrono carte とタイトルや日付を抜き出すために regexp crate を使用していますが wasm で出力する場合でも普段使用している crate を使用することができます。

この関数の戻り値に struct を使用していますが Public な #[wasm_bindgen] Attribte のある struct は Node.js で使用することができます。使用する struct にはルールがあり Public な field は Copy 可能でなければなりません。今回は (memory leak とか慣習とか正しいか分かりませんが) #[wasm_bindgen(getter)] のある method を実装することで Copy 可能のルールを回避しました。

手っ取り早く戻り値に scruct を使いたい場合は #[wasm_bindgen] とか全く考慮しない struct を

JsValue::from_serde(&value).unwrap()

とすることができます。 struct で返す場合と JsValue で返す場合の大きな違いとしては wasm-pack build したときに TypeScript の型が生成されるのですが、関数の戻り値が明示的な型になるか any になるかがあります。面倒なので全部 JsValue で済ませたいですがこれは悩みますね、、、

まとめ

wasm を使用して Rust と Node.js の実装を共通化することができました。

refs.
https://github.com/jameslittle230/stork
https://nextjs.org/docs
https://rustwasm.github.io/docs/wasm-bindgen/
https://rustwasm.github.io/wasm-pack/book/
https://github.com/sukawasatoru/blog/tree/master/tools/docs-parser


timestamp
2021-02-11 (First edition)