TL; DR
WebAssemblyでもCGIが作れる!
はじめに
先日、WasmerがWebAssemblyでCGIを実装できる WCGI
を発表しました。
既存のソースコードはそのままに、wasmへコンパイルすることで可搬性、安全性を向上できるとのことです。
ということで、今回はWCGIを使い 令和の技術スタックでアクセスカウンターを作ってみたいと思います。
おことわり
CGIエアプのため、「CGIはそんな風に設計しない!」という箇所があるかもしれません。生暖かい目で見て頂ければと思います...
作ったもの
WAPMを使うと、wasmファイルを直接ダウンロードできます。Wasmerで実行すると、localhost:8000
でcgiが起動します。
$ wasmer run-unstable --mapdir /tmp/counter:$(pwd)/counter --env SERVER_PROTOCOL=HTTP/1.1 --env SCRIPT_NAME=pageviews --env REQUEST_METHOD=GET .
エンドポイントにリクエストするたびに現在のアクセス数を返します。
$ curl -s localhost:8000/pageviews | jq
{
"pageviews": 1
}
$ curl -s localhost:8000/pageviews | jq
{
"pageviews": 2
}
後は、フロントエンド(というかただのHTMLファイル)からcgiへリクエストするようにすれば、アクセスカウンターの完成です。
fetch("http://localhost:8000/pageviews")
.then(res => res.json())
.then(body => {
const elem = document.getElementById("pageviews"); // DOMのカウンター要素
elem.textContent = body.pageviews;
})
.catch(err => {
console.log(err);
});
虹色の文字はこちらの記事を参考にさせていただきました1。
実装
続いて実装方法について紹介します。といっても、特別なライブラリは必要ありません。
プロジェクトの作成
テンプレートを使えば、WAPMの設定が揃った状態ですぐに開発できます。今回はRustのテンプレートを使用しました。
コンパイル手順もREADMEに載っているので、必要なツールをインストールしましょう。
cgiクレートでhello worldを返す実装が書かれている2ため、これを改造します。
実装
処理の流れは以下の通りです。
- リクエストが来たらファイルからこれまでのアクセス数を読み込む
- アクセス数をインクリメントしファイルに書き込む
- 更新後のアクセス数をレスポンスとして返す
こちらの記事を参考にさせていただきました。(排他制御は未実装です...)
実装はいたってシンプルです。serdeを使うほどでもないのでレスポンスのJSONはべた書きしています。
use cgi::{http::StatusCode, Request, Response};
mod counter;
// アクセス数記録ファイル
const COUNTER_FILE_PATH: &str = "/tmp/counter/counter.txt";
fn main() {
// サーバー起動。リクエストが来るたびハンドラーが呼ばれる
cgi::handle(handler);
}
fn handler(request: Request) -> Response {
// `favicon.ico` へのリクエストでインクリメントされないよう、パスでフィルタをかける
if request.uri() != "pageviews" {
return cgi::string_response(StatusCode::NOT_FOUND, "{\"error\": \"page not found\"}".to_string());
}
let pageviews = match counter::increment_counter(COUNTER_FILE_PATH) {
Ok(pageviews) => pageviews,
Err(e) => {
eprintln!("error: {e}");
return cgi::string_response(StatusCode::INTERNAL_SERVER_ERROR, format!("{{\"error\": \"{e}\"}}"));
}
};
cgi::string_response(StatusCode::OK, format!("{{\"pageviews\": {pageviews}}}"))
}
アクセス数管理の関数はこうなっています。
use std::fs;
pub fn increment_counter(file_path: &str) -> Result<i64, Box<dyn std::error::Error>> {
let file_str = fs::read_to_string(file_path)?;
let mut pageviews = file_str.parse::<i64>()?;
pageviews += 1;
fs::write(file_path, format!("{pageviews}"))?;
Ok(pageviews)
}
テスト
アクセス数管理関数がちゃんと動くかテストしてみます。テストを冪等にするために、毎回記録ファイルをtempfile で新規生成しています。
#[cfg(test)]
mod tests {
use crate::counter::increment_counter;
use std::fs;
use std::fs::File;
use std::io::Write;
use tempfile::{tempdir, TempDir};
#[test]
fn first_access() {
// HACK: _dir, _fileはテスト上不要だが、返さないとライフタイムが切れてファイル、ディレクトリ自体が削除されてしまう
// (_dir, _fileのデストラクタはファイル、ディレクトリそのものを削除するため)
let (_dir, _file, file_path) = prepare_tempfile("counter_zero.txt", "0");
let actual = increment_counter(&file_path).unwrap();
assert_eq!(actual, 1);
let actual_recorded = fs::read_to_string(file_path).unwrap();
assert_eq!(actual_recorded, "1");
}
fn prepare_tempfile(file_name: &str, content: &str) -> (TempDir, File, String) {
let dir = tempdir().unwrap();
let file_path = dir.path().join(&file_name);
let mut file = File::create(&file_path).unwrap();
write!(file, "{content}").unwrap();
(dir, file, file_path.to_str().unwrap().to_string())
}
}
無事、リクエストするとアクセス数が1増えることが確認できました。
起動
続いて、wcgiをコンパイルして起動します。いくつかハマりどころがありました。
環境変数の設定
残念ながら、テンプレートのREADME通りに起動してもリクエストを受けたタイミングでpanicしてしまいます。
$ cargo build --target=wasm32-wasi --release
$ wasmer run-unstable .
...
WCGI Server running at http://127.0.0.1:8000/
thread 'main' panicked at 'no entry found for key', /home/syuparn/.cargo/registry/src/github.com-1ecc6299db9ec823/cgi-0.6.0/src/lib.rs:263:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
2023-04-26T23:43:02.203249Z ERROR ThreadId(16) wasmer_wasix::runners::wcgi::handler: Unable to drive the request to completion error=Runtime error error.sources=[RuntimeError: unreachable
...
これはcgiクレートの仕様で、CGI起動に必要な環境変数が指定されていないのが原因でした。
It will parse & extract the CGI environmental variables, and the HTTP request body to create
Request<u8>
, call your function to create a response, and convert yourResponse
into the correct format and print to stdout. If this programme is not called as CGI (e.g. missing required environmental variables), it will panic.これ(cgi_main関数)はCGI環境変数とHTTPリクエストボディをパース、展開し
Request<u8>
を作成し、あなたの関数(handler)を呼びレスポンスを生成し、そのResponse
を適切な形式に変換してから標準出力へ表示します。このプログラムがCGIとして呼ばれなかった場合(例:必須環境変数が足りない)panicします。
RFCで決まっている仕様なので、CGI開発者にとってはわざわざ書くほどのことでもないのでしょうか...?エアプの弊害
一旦は必須パラメータだけを環境変数に設定します。Wasmerは --env
オプションでサンドボックス内の環境変数を設定可能です。
$ wasmer run-unstable --env SERVER_PROTOCOL=HTTP/1.1 --env SCRIPT_NAME=pageviews --env REQUEST_METHOD=GET .
ディレクトリのマッピング
しかし、残念ながらまだカウンターは動きませんでした。記録ファイルが見つからないようです。
$ curl -i localhost:8000/pageviews
HTTP/1.1 500 Internal Server Error
content-length: 52
access-control-allow-origin: *
vary: origin
vary: access-control-request-method
vary: access-control-request-headers
access-control-expose-headers: *
date: Thu, 27 Apr 2023 00:01:53 GMT
{"error": "No such file or directory (os error 44)"}
これはWASIのサンドボックス環境の仕様で、明示的に許可したディレクトリ以外にはアクセスできないためです。
--map-dir
で記録ファイルがあるディレクトリをサンドボックス環境へマウントします。ゲスト側の /tmp
や /
に直接マウントしようとするとなぜか失敗したので、一階層掘っています。
# --mapdir ${guest}:${host} の形式で指定
$ wasmer run-unstable --mapdir /tmp/counter:$(pwd)/counter --env SERVER_PROTOCOL=HTTP/1.1 --env SCRIPT_NAME=pageviews --env REQUEST_METHOD=GET .
ようやく動きました。
$ curl -s localhost:8000/pageviews | jq
{
"pageviews": 1
}
$ curl -s localhost:8000/pageviews | jq
{
"pageviews": 2
}
WAPMへ公開
最後に、できたものをWAPMへ公開します。
WAPMの使い方について以前書いた記事がありますので、詳細はこちらをご覧ください。
wapm.toml
テンプレートに初めから wapm.toml
が含まれているので、このプロジェクト用に修正します。
[package]
- name = "Michael-F-bryan/wcgi-rust-template"
+ name = "Syuparn/wcgi-access-counter"
version = "0.1.0"
- description = "A template for WCGI applications"
+ description = "access counter cgi created by https://github.com/wasmerio/wcgi-rust-template"
license = "MIT OR Apache-2.0"
readme = "README.md"
- repository = "https://github.com/wasmerio/wcgi-rust-template"
+ repository = "https://github.com/Syuparn/wcgi-access-counter"
[[module]]
- name = "server"
+ name = "access-counter"
- source = "target/wasm32-wasi/release/wcgi-rust-template.wasm"
+ source = "target/wasm32-wasi/release/wcgi-access-counter.wasm"
abi = "wasi"
[[command]]
- name = "server"
+ name = "access-counter"
- runner = "https://webc.org/runner/wcgi"
- module = "wcgi"
+ runner = "wcgi"
+ module = "access-counter"
annotations = { wcgi = { dialect = "rfc-3875" } }
+ # 記録ファイルとサンプルHTMLをパッケージに追加(デフォルトではwasm, README, wapm.tomlしか含まれない)
+ [fs]
+ "counter" = "counter"
+ "frontend" = "frontend"
バージョンの相性
最新のRust(1.69) でビルドしたWASMがWAPM非対応だったため、バージョンを1.64に下げています。
/home/runner/.wasmer/bin/wapm publish
Error: WASM file "/home/runner/work/wcgi-access-counter/wcgi-access-counter/target/wasm32-wasi/release/wcgi-access-counter.wasm" detected as invalid because bulk memory support is not enabled (at offset 122086)
(node:1986) UnhandledPromiseRejectionWarning: Error: The process '/home/runner/.wasmer/bin/wapm' failed with exit code 255
at ExecState._setResult (/home/runner/work/_actions/wasmerio/wapm-publish/v1/dist/index.js:1:16562)
at ExecState.CheckComplete (/home/runner/work/_actions/wasmerio/wapm-publish/v1/dist/index.js:1:16122)
at ChildProcess.<anonymous> (/home/runner/work/_actions/wasmerio/wapm-publish/v1/dist/index.js:1:14963)
at ChildProcess.emit (events.js:314:20)
at maybeClose (internal/child_process.js:1022:16)
at Process.ChildProcess._handle.onexit (internal/child_process.js:287:5)
(node:1986) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
(node:1986) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
どうやらコンパイルフラグのデフォルトが変わったのが原因のようです。
おわりに
以上、WCGIでアクセスカウンターを作ってみた紹介でした。数十行の実装でできたので、APIを作るほどでもないときに手軽に使えそうです。
今回はファイルの排他制御を無視してしまったので、こちらもこれから実装していきたいと思います。