【WCGI】令和のアクセスカウンター【WASM】

# TL; DR

WebAssemblyでもCGIが作れる！

![ezgif-5-38939d3824.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239887/001a025f-c967-3c16-9560-e233cf5415f1.gif)

https://wapm.io/Syuparn/wcgi-access-counter

# はじめに

先日、WasmerがWebAssemblyでCGIを実装できる `WCGI` を発表しました。

https://wasmer.io/posts/announcing-wcgi

既存のソースコードはそのままに、wasmへコンパイルすることで可搬性、安全性を向上できるとのことです。
ということで、今回はWCGIを使い **令和の技術スタックでアクセスカウンターを作ってみたい**と思います。

## おことわり

CGIエアプのため、「CGIはそんな風に設計しない！」という箇所があるかもしれません。生暖かい目で見て頂ければと思います... :innocent:

# 作ったもの

https://github.com/Syuparn/wcgi-access-counter

WAPMを使うと、wasmファイルを直接ダウンロードできます。Wasmerで実行すると、`localhost:8000` でcgiが起動します。

```bash
$ wasmer run-unstable --mapdir /tmp/counter:$(pwd)/counter --env SERVER_PROTOCOL=HTTP/1.1 --env SCRIPT_NAME=pageviews --env REQUEST_METHOD=GET .
```

エンドポイントにリクエストするたびに現在のアクセス数を返します。

```bash
$ curl -s localhost:8000/pageviews | jq
{
  "pageviews": 1
}
$ curl -s localhost:8000/pageviews | jq
{
  "pageviews": 2
}
```

後は、フロントエンド（~~というかただのHTMLファイル~~）からcgiへリクエストするようにすれば、アクセスカウンターの完成です。

```js
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);
  });
```

![pages_jp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239887/573f7b9c-54d6-d976-f495-5b50c172ab9d.png)

虹色の文字はこちらの記事を参考にさせていただきました[^gradation]。

https://kouhekikyozou.com/css_text_gradation

# 実装

続いて実装方法について紹介します。といっても、特別なライブラリは必要ありません。

## プロジェクトの作成

https://github.com/wasmerio/wcgi-rust-template

テンプレートを使えば、WAPMの設定が揃った状態ですぐに開発できます。今回はRustのテンプレートを使用しました。
コンパイル手順もREADMEに載っているので、必要なツールをインストールしましょう。

[cgi](https://github.com/amandasaurus/rust-cgi)クレートでhello worldを返す実装が書かれている[^cgi]ため、これを改造します。

## 実装

処理の流れは以下の通りです。

- リクエストが来たらファイルからこれまでのアクセス数を読み込む
- アクセス数をインクリメントしファイルに書き込む
- 更新後のアクセス数をレスポンスとして返す

こちらの記事を参考にさせていただきました。(排他制御は未実装です...)

http://cms.phys.s.u-tokyo.ac.jp/~naoki/CIPINTRO/CCGI/acounter.html

実装はいたってシンプルです。serdeを使うほどでもないのでレスポンスのJSONはべた書きしています。

```rust:src/main.rs
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}}}"))
}
```

アクセス数管理の関数はこうなっています。

```rust:src/counter.rs
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](https://docs.rs/tempfile/latest/tempfile/) で新規生成しています。

```rust:src/counter.rs
#[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してしまいます。

```bash
$ 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起動に必要な環境変数が指定されていないのが原因でした。

https://github.com/amandasaurus/rust-cgi/blob/v0.6.0/src/lib.rs#L36

> 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 your `Response` 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開発者にとってはわざわざ書くほどのことでもないのでしょうか...？~~エアプの弊害~~

https://tex2e.github.io/rfc-translater/html/rfc3875.html#4-1--Request-Meta-Variables

一旦は必須パラメータだけを環境変数に設定します。Wasmerは `--env` オプションでサンドボックス内の環境変数を設定可能です。

```bash
$ wasmer run-unstable --env SERVER_PROTOCOL=HTTP/1.1 --env SCRIPT_NAME=pageviews --env REQUEST_METHOD=GET .
```

### ディレクトリのマッピング

しかし、残念ながらまだカウンターは動きませんでした。記録ファイルが見つからないようです。

```bash
$ 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` や `/` に直接マウントしようとするとなぜか失敗したので、一階層掘っています。

```bash
# --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 .
```

ようやく動きました。

```bash
$ curl -s localhost:8000/pageviews | jq
{
  "pageviews": 1
}
$ curl -s localhost:8000/pageviews | jq
{
  "pageviews": 2
}
```

# WAPMへ公開

最後に、できたものをWAPMへ公開します。
WAPMの使い方について以前書いた記事がありますので、詳細はこちらをご覧ください。

https://qiita.com/Syuparn/items/00e9cb4a66bc9398bc88

## wapm.toml

テンプレートに初めから `wapm.toml` が含まれているので、このプロジェクト用に修正します。

```diff: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に下げています。

```bash:発生したエラー
/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.
```

どうやらコンパイルフラグのデフォルトが変わったのが原因のようです。

https://github.com/wasmerio/wasmer/discussions/3446

# おわりに

以上、WCGIでアクセスカウンターを作ってみた紹介でした。数十行の実装でできたので、APIを作るほどでもないときに手軽に使えそうです。
今回はファイルの排他制御を無視してしまったので、こちらもこれから実装していきたいと思います。

[^gradation]: おしゃれなテクニックをダサいデザインに使ってすみません... :sweat_smile: 
[^cgi]: これはwcgi専用ではなく汎用的なcgiライブラリです。