WebAssembly をウェブブラウザーで活用する(2)

この記事はゆるWeb勉強会@札幌 Advent Calendar 2021の 3日目の記事です。

去年のアドベントカレンダーより…

自分は、去年のゆるWebアドベントカレンダーで書きました…

WebAssembly をウェブブラウザーで活用する(1)

次の回では、C/C++ もしくは Rust でかかれたプログラムを WebAssembly に移植して動作させるデモをやってみたいと思います!(続く

次の回になるまで 1年かかりました…(すいませんw

というわけで、今年の記事はここ数ヶ月取り組んでいました C/C++/Rust のプログラムを WebAssembly で動作させた顛末を例に、Wasm(WebAssembly の略です) の周辺技術事情やキーワードを書いていきたいと思います。

WebAssembly とは

WebAssembly はバイナリーの実行コード形式です。C/C++/Rust/AssemblyScript をはじめとするさまざまな言語を .wasm 拡張子のバイトコードにコンパイルすることができ、それらの言語でかかれたプログラムを、ウェブブラウザーや WebAssembly ランタイム上で動作させることができます。

画像

特にウェブブラウザーは、これまでコンピュータ言語として JavaScript のみをネイティブでサポートしていましたが、WebAssembly の仕組みを使うことで、別の言語でかかれたソフトウェアやライブラリーを活用することができるようになりました。

中でも C/C++/Rust はシステムやミドルウェアよりのライブラリーの実装が多くありますので、画像・動画処理、文字列パーサー、仮想マシン、3D 処理、ファイル圧縮展開、そして今回取り上げる音声処理など従来 JavaScript で再実装が必要になっていた部分を、WebAssembly にビルドするという手順だけで手軽にウェブブラウザーに持ち込めます。

まずは、今年2021年に気になった WebAssembly の実装をいくつか紹介します。

⏩ Adobe Photoshop の WebAssembly による実装:

おそらく画像処理部分の C/C++ コードをクライアント版と共有していると思います。

Adobe PhotoshopにWebブラウザ版が登場。何ができる?

配信中では宇宙飛行士のヘルメットガラスの反射を修正ツールで取り除き、また色味を調整するなどの内容が披露。操作に伴うレスポンス遅延等もみられない、スムーズな編集を実現していることが示された。

ちなみに、Chromium 系のブラウザーの開発者ツールに、直接 C/C++ ソースのデバッグができる仕組み(と拡張)が追加されたのは、Web 版 Photoshop をつくっていたからという話がありました。

https://twitter.com/h1romas4/status/1348242121631830017

⏩ WebAssembly で動作可能な Rust でかかれた形態素解析 Goya:

日本語形態素解析がウェブブラウザー上で動作します。形態素解析系はビルドが大変だったりしますので、.wasm 版があるのは大変ありがたいです。

WebAssemblyの形態素解析器GoyaをRustで作った

Goyaという形態素解析器を Rust で作りました。本記事は利用者目線で Goya の紹介をします。技術的な詳細については別途記事を書きます。

⏩ Python の WebAssembly 版の Pyodide:

CPython が C/C++ で実装されていることを利用して、インタープリタ・ランタイムごと Wasm にビルドし、ウェブブラウザー上で Python を実行し、導入の操作なしに Python を使うことができます。

Try Pyodide (no installation needed)

Try Pyodide in a REPL directly in your browser. For further information, see the documentation.

⏩ PHP の WebAssembly 版:

Pyodide と同様に PHP のインタープリタも C/C++ ですので Wasm にビルドしてしまえば、あろうことか PHP がウェブブラウザーで動作します。 😀

PIB: PHP in Browser (and Node.js) aka php-wasm

https://seanmorris.github.io/php-wasm/

WebAssembly で実装されたシンセサイザー

今回の Wasm 実装例とするプログラムは WebAssembly にビルドされたシンセサイザーです。

次のリンクから動作を確認できます。(後述しますがマルチスレッド(SharedArrayBuffer) を使っている関係で iOS/Safari では現在動作しません)

https://chipstream.netlify.app/

画面をクリックするとサンプルの音楽が再生されたと思いますが、この音声波形生成(シンセサイズ)を C++/Rust でかかれた WebAssembly で行っています。

シンセサイザーとは “音” を電子機器を使って生成するハードウェア・ソフトウェアの総称です。

音を電子機器でつくる方式はいろいろあり、現在は PCM と呼ばれる録音した波形をコンピュータで変化させる方式が主流で、イメージとしてはピアノのラの音(440Hz)をマイクで収録し、それをコンピュータの演算で 880Hz にして高いラにして発音させるようなことをしています。(実際にはもっと複雑です)

ここで作成したシンセサイザーは FM 音源と呼ばれるシンセサイザーの方式で、サイン波(口で発声するのなら、”ポー”みたいな電子音)をサイン波で変調することで倍音を加え、デモのような電子音のような、そうでないような美しい不思議な音を発音させることができます。

画像

1980年代に非常に多く使われたシンセサイザーの方式で、日本で一番なじみがあるのが山手線のホームで流れる発車音のエレピの音だと思います。また 1990年台のゲーム基板や、2000年台初頭のガラケーにも搭載されていましたので、ゲームセンターや着メロで聞き覚えがある方もいらっしゃるかもしれません。

WebAssembly でできること

よくWebAssembly は計算しかできないと言われますが、まずはその通りで今回のシンセサイザーの例でも波形の計算を C++/Rust で行っています。

音声波形計算の単純な例をあげると、1秒を 44100(サンプリング周波数)で割った配列をつくり、16bit(量子化ビット数)の -32768 〜 32767 の値で 440Hz のサイン波をつくれば”ポー(ラー)”波形の計算となります。このシンセサイザーに、ラを0.5秒鳴らせといった演奏データ(シーケンス)を渡すことで音楽となる手はずです。

WebAssembly が計算しかできないと言われるゆえんは、WebAssembly からホストとなる(この場合はウェブブラウザー)の機能には直接アクセスできないことで(ユーザと対話できない)、今回の場合も計算された波形はブラウザーの JavaScript を経由して WebAudio API に渡して発音してもらうようなプログラムとなっています。

ソースコードを以下に公開していますので興味がある方は覗いてみてください。

実際には ymfm という C++ でかかれた YAMAHA FM 音源チップのエミュレーションライブラリーを、演奏データの制御やサウンドチップのサンプリングレートのコンバートを行う Rust のプログラムとリンクする形で動作しています。

https://github.com/h1romas4/libymfm.wasm

This repository is an experimental WebAssembly build of the [ymfm](https://github.com/aaronsgiles/ymfm) Yamaha FM sound cores library.

使わせてもらっている ymfm ライブラリーのソースコードは一切触れることなく、そのまま WebAssembly にビルドしています。Rust と C++ をリンクして動作する Wasm の例としても見ることができるかもしれません。

その他、リアルタイム音声処理に関しては処理の遅れが音切れという形で現実世界に悪影響を及ぼしますので、マルチスレッド(WebWorker)を使っていたり、同期するために SharedArrayBuffer を使っていたり、従来から使われている ScriptAudioProcesser ではなく AudioWorklet を使っているなど、 WebAssembly との組み合わせもあまり見ないサンプルになっていますので、JavaScript 的にも、もしかすると参考になるかもしれません。

なお、スレッド同期に使っている SharedArrayBuffer の iOS/Safari での実装が 2021年 12月現在テクニカルプレビュー中です。次のリリースあたりで使えるようになると思いますが、現在は Safari で動作しません。そのうちに動作し始めると思います。

ウェブブラウザーを超えて

さて、JavaScript がウェブブラウザーから飛び出し Node.js や Deno といった形で取り出されたように、WebAssembly にも同様なランタイムがあります。

いろいろな実装があり群雄割拠している状態ですが、自分が使っているものをいくつか紹介します。

wasmtime

Standalone JIT-style runtime for WebAssembly, using Cranelift

Wasmer

Run any code on any client. With WebAssembly and Wasmer.

Wasm3

The fastest WebAssembly interpreter, and the most universal runtime

wasmtime がリファレンス実装的な印象で、Wasmer は高速で各言語バインディングが充実、Wasm3 は JIT を使わないインタープリタ型で JIT 禁止の iOS 環境でも動作可能、また最小構成で Arduino などのマイコンでも動作といった特徴があります。

WebAssembly ランタイムはもちろん単体で .wasm を動作させることができますが、さまざまな言語をホストとして .wasm を実行をする仕組みが備わっています。

“wasmer-python” を利用して、この記事でウェブブラウザーで呼び出したシンセサイザーの .wasm ファイルとまったく同じものを Python から呼び出してサウンドプログラミングした動作例が次の動画となります。(ソースコード: https://github.com/h1romas4/libymfm.wasm/tree/main/examples/python

.wasm は OS を選びませんので、ランタイムと組み合わせることでウェブブラウザー(JavaScript)に限らずさまざまな言語から呼び出して活用することが可能です。

Wasmer の言語対応:

特に C/C++ でかかれたライブラリーのスクリプト言語からの呼び出しは、使われる範囲的にインターネット経由の入力が伴うことが多く、セキュリティーの面で気になる部分がでてきますが、Wasm をかぶせることでサンドボックスとなり安全性が高まり、また OS の依存もなくなりますので扱いが簡単です。 .wasm がひとつあればどこでも動作します。

PHP で画像処理したいと思ったら wasmer-php で Wasm ビルドした C/C++ ライブラリーを安全に使う…なんていうのもアイディアかもしれません。

最近ではソフトウェアのプラグインシステムとしても WebAssembly を活用し、そのソフトウェアがつくられた言語以外でプラグインをかけるようにしているしている例もみられるようになりました。

zellij

Zellij includes a layout system, and a plugin system allowing one to create plugins in any language that compiles to WebAssembly.

まとめ

  • WebAssembly はいろいろな言語でつくられたソフトウェア・ライブラリーを、ウェブブラウザーやランタイム上で OS を選ぶことなく動作させることができる。
  • WebAssembly のランタイムと各言語のバインディングを使うことで、言語を他の言語で拡張して使うこともできる。

WebAssembly の今後は「計算しかできない」を解決していく Interface Type や WASI といった仕様の策定・実装、GC やマルチスレッドの対応の強化が進み、活用範囲や対応言語も増え、目にする機会も増えるかなと思っています。クラウドの Lambda 的な部分での採用も増えていきそうです。

面白いことがあればぜひ教えてください。てなわけで、また来年(!?)…!

ツイッターやってます:

https://twitter.com/h1romas4/status/1460572622475841542

ゆるWeb勉強会

ゆるWeb勉強会とは札幌で開催している @tacck さん主催のWeb系勉強会で、現在はオンライン配信も行っていてどなたでも参加できます。いろんな方の発表を聴き、またわいわい質問もすることができますので、興味がある方は覗いてみてください!

ゆるWeb勉強会@札幌

主に「Web」というキーワードに関する、ゆるい勉強会です。 プログラムを始めたばかりの人も、仕事でバリバリやっている人も、どなたでも歓迎です!

「勉強し初めて、次に何をやればいいんだろう?」 「普段使っていない言語やフレームワーク、知っている人に導入部分を聞いてみたい。」 「いつもはバックエンドだけど、たまに触るフロントエンドの話も知りたい。」 「実際、みんな現場ではどうやって開発してるの?」

仕事・趣味を問わず、普段の仕事の内容も問わず、「Web系」に関することを、ゆるっとみんなで話してみたいです。

関連

M5Stack Core2 SDK でメガドライブエミュレーターをビルドする

はじめに

しばらく品切れが続いていました M5Stack Core2 を買うことができました。嬉しいです。 😀

Core2 には PSRAM が 8M ついているということで、そのあたりを確認しつつ、ハローワールドがてらメガドライブエミュレーター(Genesis Plus GX)を移植してブートさせてみました。

ソースコードを github で公開しています。

Genesis-Plus-GX M5Stack Core2 porting (no optimize and super slow)

https://github.com/h1romas4/m5stack-genplus

まだビルドしてエミュレーターの起動を確認したところまでのソース断面となっていますので、最適化はまったくしておらずとっても遅いです。

後述しますが、単純に不足したメモリーを SRAM から拡張のクロックが 80MHz PSRAM にメモリーを逃したのと、LCD への仮想 VRAM 転送で何も工夫をしていないためです。

(速くする方法を思いついたらちょこちょこいじるかもしれません。また、いい方法があったらぜひお教えください!)

この記事では M5Stack Core2 上でこういった少し大きめのソースをビルドするノウハウと、PSRAM の使い方を紹介してみたいと思います。

esp-idf のビルドシステムを使った M5Stack Core2 アプリのビルド

M5Stack Core2 の標準の C/C++ 開発キットは Arduino IDE となっていますが、移植元となっている Genesis-Plus-GX のソースツリーが比較的大きいのと、各コンパイルオプションなども細かく修正しながら作業したかったので、esp-idf ビルドシステムを使って M5Stack Core2 向けのバイナリーの作成を行っています。

esp-idf が提供する Makefile を用いるとプロジェクトの構成を次のようにすることができます。なお、esp-idf の 4系のバージョンでは cmake を使うように変わっていますが、ここでは esp-idf 3.3 系を使うため Makefile 方式としています。

+ components
    + arduino (https://github.com/espressif/arduino-esp32)
    + m5stack
        + M5Core2 (https://github.com/m5stack/M5Core2)
        component.mk
    + genplus 
        [ソース]
        component.mk
+ esp-idf (https://github.com/espressif/esp-idf)
+ main
    main.cpp
    component.mk
sdkconfig
Makefile

maincomponents ディレクトリの構造と各コンポーネントしたに配置された components.mk とルートの sdkconfigMakefile が esp-idf のビルドシステムが認識する要素です。

そのまま使いたい時は、m5stack-genplusgit clone --recursive していらないファイル消すのが簡単かもです。:D

プロジェクトテンプレートリポジトリーと How to create のドキュメントをつくりました。また、github actions で自動ビルドもかけれるようにしています。使う場合は github にログインして以下から Use This Template ボタンを押して、自身のリポジトリーを作成してプログラミングを開始すると便利です。

https://github.com/h1romas4/m5stack-core2-template

esp-idf build system template for M5Stack Core2.

さて、M5Core2 ライブラリーが依存するプロジェクトは esp-idf と arduino-esp2 ですが、現在 Arduino IDE で提供されている一式がどの断面を使っているのかが分からなかったので、ビルドが通るものを試行錯誤して、以下のバージョンでソースツリーに git submodule で固定して導入しています。

現在リリース版の arduino-esp32 は v1.0.4 の esp-idf 3.2 依存ですが、これではビルドが通らず未リリースの arduino-esp32 v1.0.5 相当で、esp-idf は 3.3 の最終コミットを使っています。 (どうも esp-idf v3.2 系及び v3.3.5 タグだと必要なファイルがなかったり、コンパイルエラーになるなどビルドが通りませんでした)

(2021/6)現在の最新版の組み合わせは次のようになっています。

nameversionhash
esp-idf3.3.5
arduino-esp321.0.6
M5Core20.0.3(latest)54b958b

(2022/10 追記) esp-idf 4系 / Arduino 2 系に依存を変更したプロジェクトを以下に作成していますので参考にしてみてください。

https://github.com/h1romas4/m5stack-core2-wasm3-as

M5Stack Core2 With Wasm3/AssemblyScript Demo


git submodule で導入しておくと、バージョンが切り替わったときの上げ下げやヒストリーの確認も容易かと思います。(このようにしておくとプロジェクトごとに依存のバージョンを固定できます)

ビルドは、各 OS に対応した xtensa-esp32 のツールチェイン(gcc)を導入後、make するだけで M5Stack Core2 向けのバイナリーが作成できます。 m5stack-genplus の github をビルドする手順は次のようになります。 (Windows では MSYS2 の窓より…)

# submodule があるので --recursive 指定 
git clone --recursive https://github.com/h1romas4/m5stack-genplus.git
cd m5stack-genplus
# This repository includes eps-idf
export IDF_PATH=$(pwd)/esp-idf
# CPU 4コア CPU の場合 -j4 とコアすると速い
make -j4
# 書き込み & 実行
make flash monitor

sdkconfig ファイル

sdkconfig ファイルは esp-idf や esp-idf に対応した各コンポーネントが使う define の定義です。これは make menuconfig コマンドでメニューから生成することができます。

m5stack-genplus リポジトリーには sdkconfig がコミットされていますので各種設定済みで、変えるのは転送先のシリアルポートの設定くらいですが、esp-idf や arduino-esp32 のどの機能を使うかなどなどの設定ができます。

保存すると sdkconfig に値が書き込まれますが、基本的に make や C/C++ 中の define 定義集のようなものなので、キー名でソースコードを grep すると何が有効になったのかが分かります。

component.mk ファイル

各 component.mk の中にはそのコンポーネントで使われるソースファイルやインクルードファイルの位置、CFLAGS などのコンパイルオプションが定義できます。

Genesis Plus GX モジュールでは次のように定義しています。

COMPONENT_SRCDIRS := core core/z80 core/m68k core/input_hw core/sound core/cart_hw core/cart_hw/svp core/ntsc m5stack
COMPONENT_ADD_INCLUDEDIRS := core m5stack core/cart_hw core/cart_hw/svp core/cd_hw core/debug core/input_hw core/m68k core/ntsc core/sound core/themor core/z80

CFLAGS := \
    -DLSB_FIRST \
    -DUSE_16BPP_RENDERING \
    -DMAXROMSIZE=131072 \
    -DHAVE_ALLOCA_H \
    -DALT_RENDERER \
    -DALIGN_LONG \
    -DM5STACK \
    -fomit-frame-pointer \
    -Wno-strict-aliasing \
    -mlongcalls

利用できるオプション値は次のドキュメントから参照できます。

https://docs.espressif.com/projects/esp-idf/en/v3.3.3/api-guides/build-system.html#optional-project-variables

Optional Project Variables

ライブラリーなどを M5Stack に移植して動作させる時は、CFLAGSCPPFLAGS などが容易に設定できますので便利に使えるのではないかと思います。components ディレクトリに任意の名前でディレクトリを作成してソースをがばっと入れ、component.mk を書いてあげればビルド対象になります。

またライブラリーにユーザー設定がある場合は Kconfig.projbuild をつくって配置することで、make menuconfig(sdkconfig) 用のメニュー定義も入れることができるようです。

PSRAM の使い方 (.bss セグメントを逃がす)

esp-idf で大きめのプログラムをリンクすると、

region `dram0_0_seg' overflowed by 1995968 bytes

このようなエラーメッセージでリンクできない場合があります。これは static の領域を確保する .bss セグメントが足りない場合に出力されます。Genesis Plus GX のビルドでは当初、上記のように 2MB 近く足りませんでした。

この .bss セグメントを PSRAM にもっていくオプションがあり、次の menuconfig の設定から有効にすることができます。

https://docs.espressif.com/projects/esp-idf/en/v3.3.4/api-guides/external-ram.html

→ Component config → ESP32-specific → SPI RAM config 
→ Allow .bss segment placed in external memory

define 値的には CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY となりますので esp-idf を grep してみると分かりやすいです。このオプションを有効にした後、ソースファイル上の static 変数に EXT_RAM_ATTR をつけると、PSRAM に領域を持っていくことができます。

#ifdef M5STACK
#include "esp_attr.h"
#endif

#ifdef M5STACK
EXT_RAM_ATTR static uint32 bp_lut[0x10000];
#else
static uint32 bp_lut[0x10000];
#endif

イメージ的にはこんな感じになります。 EXT_RAM_ATTR は未定義はブランクなので、これでも大丈夫です。

#ifdef M5STACK
EXT_RAM_ATTR
#endif
static uint32 bp_lut[0x10000];

なお、PSRAM は CPU から最大 80MHz の SPI で接続されており、通常の SRAM より速度が遅く、転送レートは最大で SRAM 960MB/sec に対して 40MB/sec ほどのようです。このため、主要なロジックで使われるメモリーを載せると処理速度が低下してしまうはずです。

How slow is PSRAM vs SRAM (anyone have quantitative info?)

Internal SRAM is 32bit @ 240MHz max, so 960MByte/second. PSRAM is 4-bit @ 80MHz, so 40MByte/second.

PSRAM の使い方 (malloc)

PSRAM がコンフィグレーションで有効になっていると、通常の malloc 関数で PSRAM も使ってくれます。

https://docs.espressif.com/projects/esp-idf/en/v3.3.4/api-guides/external-ram.html

Support for external RAM

PSRAM ではなく速い SRAM 側に確定で確保したいなど、明示的にどちらから取得するかを決めたい場合は、次の設定ができます。

→ Component config → ESP32-specific → SPI RAM config

また、M5Stack Core2 には 8MB の PSRAM が搭載されていますが、使えるのは 4MB までのようで、それより上の 4MB を使う場合は himem API で別途取得するようです。(今回は未検証)

https://docs.espressif.com/projects/esp-idf/en/v3.3.4/api-reference/system/himem.html

The himem allocation API

VS Code の設定(おまけ)

あんまり関係ありませんが、開発は VS Code + C/C++ Extention で行いました。インテリセンスの効きもよく大変良いです。

VS Code ではプロジェクトに .vscode/c_cpp_properties.json を配置して次のように設定すると便利です。

{
    "configurations": [
        {
            "name": "Linux",
            "includePath": [
                "${workspaceFolder}/build/include",
                "${workspaceFolder}/components/arduino/cores/**",
                "${workspaceFolder}/components/arduino/libraries/**",
                "${workspaceFolder}/components/arduino/variants/esp32",
                "${workspaceFolder}/components/m5stack/M5Core2/**",
                "${workspaceFolder}/esp-idf/components/**"
            ],
            "defines": [
                "ESP32=1",
                "ARDUINO_ARCH_ESP32=1",
                "BOARD_HAS_PSRAM",
                "ARDUINO=10800",
                "M5STACK",
                "LSB_FIRST",
                "USE_16BPP_RENDERING",
                "MAXROMSIZE=131072",
                "HAVE_ALLOCA_H",
                "ALT_RENDERER",
                "ALIGN_LONG"
            ],
            "intelliSenseMode": "gcc-x64",
            "compilerPath": "~/devel/toolchain/xtensa-esp32-elf/bin/xtensa-esp32-elf-gcc",
            "cStandard": "c11",
            "cppStandard": "c++17"
        }
    ],
    "version": 4
}

defines にはコンパイルオプションで指定した define を定義しておくといい感じにパーサーが感知して色分けしてくれます。sdkconfig の define 値については、自動的に生成された build/include/sdkconfig.h を読むことで自動的に反映するようになっています。

例えばですが、上記のようにコンパイルオプションで定義された M5STACK 値の ifdef が有効になって else がグレーアウトしますので分かりやすいです。

おわりに

というわけで、M5Stack Core2 ハローハッピーワールドでした。

他にもいくつかプログラムを動かしてみましたが、タッチセンサーもよく動いて M5Stack Core2 楽しいです。ビルドも固まりましたので、引き続き何かつくってみたいです!

関連記事

Netlify Functions で検索エンジン API をつくる

はじめに

ビシバシチャンプ…。

ふと思いつきまして Netlify で使える Lambda なサービス、Netlify Functions を用いて参照系検索 API を作成してみました。無料枠での挑戦です。

この作業中にいくつか Netlify の知見がたまりましたので、ここに掲載したいと思います。全ソースコードへのリンクを文末につけています。何かの参考になればと思います。

Netlify Functions

Netlify Functions は静的ファイルホスティングサービス Netlify が提供する、くだけて言えば Amazon AWS Lambda に対するプログラムの自動デプロイの仕組みで、Netlify のアカウントにて(クレジットカード登録が必要な AWS アカウントなしに)Lambda を利用することができます。

無料枠でも比較的制限が少なく、現在のスペックは次のようになります。

  • us-east-1 AWS Lambda region
  • 1024MB of memory
  • 10 second execution limit

自分が注目したのは 1024MB(1GB) ものメモリーがアサインされる点で、これを参照データーベースとして使えないかと思い立ったのが事の始まりでした。

Netlify からは DynamoDB などのデーターストアは提供されていませんので、JavaScript のオブジェクト(JSON) を key-value ストアにして全てメモリーで処理させる作戦です。

いったん JSON がメモリーにロードさえできてしまえば連想配列の検索処理時間はないに等しいですので、鍵は Lambda 初期起動が実行制限となっている 10 second execution limit(10秒)以内に巨大な辞書を読み終えられるかどうかということになります。

今回実装した検索 API について

530個、総容量約 8MB に及ぶ日本語テキストファイルを形態素解析し全文検索する API です。

対象のテキストファイルは専門用語がたくさん書かれたメタ情報のない自由形式となっており、改行の位置などを含めて機械にはコンピューターには処理しにくい形式です。具体的にはあるフリーソフトウェアの 20年分のリリースノート日本語訳を対象としています。

検索リクエストの都度、Lambda でテキストファイルの改行処理や形態素解析をしながら検索結果を返却するのは処理が厳しいため、このあたりの部分は Github Action で上で動作するバッチにしています。

参考までに手順としては、

  1. 全テキストファイルをパースし必要な部分では改行を次の行に接続するなどの正規化後、
  2. これもバッチ中に生成した専門用語を辞書登録した MeCab で形態素解析
  3. 名詞やサ変動詞などを抽出し「検索ワード」(12000語程度)としストップワードなどを除外(10000語程度)
  4. 「検索ワード」でテキストファイルを再検索 grep や awk や Python を使い「検索結果」として出現した前後の行を加え { "検索ワード" : [検索結果], } な巨大 JSON を生成

この手順で生成されたキーバリューの JSON は 100MB以上、.bz2 圧縮で 10MB という大きさになります。 また、リリースノートテキストファイルが追加される都度、Github Action の push フックからスクリプトが動作し辞書の再生成が行われます。

スクリプトの一部

######################################################################
## 検索ワード辞書作成
######################################################################

# mecab で英単語・記号を除外する名詞(一般、固有、サ変接続)を抽出する
SEARCH_DIC_TMP_WORDS=$(mktemp)
find ${WHATSNEW_DIR} -maxdepth 1 -name ${WHATSNEW_NAME} | while read path
do
    # テキストファイルで、日本語改行後の行頭にスペースがある場合は
    # 連続する文章として連結する。英単語の場合は連結しない。
    SEARCH_DIC_TMP_REGEX=$(mktemp)
    sed -z -r 's/([亜-熙ぁ-んァ-ヶー])\n[ | ]*([亜-熙ぁ-んァ-ヶー]+)/\1\2/g' ${path} > ${SEARCH_DIC_TMP_REGEX}
    # MeCab による形態素解析
    mecab ${SEARCH_DIC_TMP_REGEX} \
        -u ${MECAB_DIC} \
        | egrep -v '^[!-~]+.+\*$' \
        | egrep '^.+[[:space:]]名詞,(一般|固有|サ変接続)' \
        | awk '{ print $1 }' \
        | sort \
        | uniq \
        >> ${SEARCH_DIC_TMP_WORDS}
    rm ${SEARCH_DIC_TMP_REGEX}
done

次のリンクから実際に API が動いている様子をみることができます。画面は API のテスト用に Vue(Vuetify) で暫定的につくったもので Github Pages においていますが、裏側では Netlify Functions が呼ばれています。

https://h1romas4.github.io/e2j-api-web/

継続でアクセスがない場合は Lambda が寝てしまいますので 1度目の検索に数秒かかりますが、2回目以降の検索は高速に動くハズです。(なんといっても Lambda 上の検索処理としては key-value 引き return dictionary[query] しているだけですので…)

「あ」とか「い」とか適当に日本語を入れていただけるとオートコンプリートが走りますので、この API が検索する内容がよく分からなくても動きは分かると思います。オートコンプリート部分が "検索ワード" で結果のリストが [検索結果], となっているイメージです。

Lambda 上で動作するソースコードイメージ

import * as common from "../common";
// (でかい)辞書 JSON import
import whatsnewj from "../json/whatsnewj.json";

exports.handler = async (event) => {
  let answer = [];
  let query = event.queryStringParameters.q;
  if(whatsnewj[query]) {
    answer = whatsnewj[query]; // 検索(これだけ)
  }
  return {
    statusCode: 200,
    headers: common.jsonHeader,
    body: JSON.stringify(answer)
  }
};

Lambda のインスタンス

Netlify Functions から起動する Lambda のインスタンスは一定の時間起動し続け、(無料枠では?)ひとつのインスタンスに固定されていそう。ファイルシステムとして /tmp を使うことも可能。もちろんアルゴリズムとしてはステートの性質をあてにしてはならない。

インスタンス数については、開発中にプログラムを変更するも新旧のプログラムが交互に値を返してくるような挙動を何度か見せて最初デバッグで混乱しました。

CDN のキャッシュなのかインスタンスが 2つ起動したのか判断がしにくかったのですが、http header の age の値が 1〜2 (秒) となっていて、またURL に影響のないユニークな引数をつけても同様の結果でしたので、古いインスタンスが残ったように見えました。何度かデプロイをしていると消えるような気がします。


本検索 API は、いまや世界中のありとあやゆる機械をエミュレートするに至った「MAME」という歴史の長いエミュレーターソフトウェアのリリースノートテキストファイル(.txt)日本語版を形態素解析し全文検索します。コードネームは mametan と名付けました。

テキストデータの元となっているのは、20年に渡り MAME 文書の日本語翻訳しておられます、MAME E2J さんの翻訳テキストファイルで大変貴重な情報です。

Netlify Functions のデプロイの仕組み

Netlify はプロジェクトのルートに配置された、netlify.toml を認識してサイトのビルドを行います。 以下がこのプロジェクトで使った設定の一部です。

[build]
publish = "generator/public"
functions = "dist/api"
command = "npm run build"

publish が通常のウェブサイトのコンテントルート(今回の API では未使用)で、functions が Lambda に配置する JavaScript を配置するディレクトリです。 1 JavaScript が 1エンドポイントになります。

今回の dist/api の配下は次のようになっていて、それぞれのファイル名が /.netlify/functions/ファイル名 という形式でエンドポイント URL となります。

mametan.js // 検索ワード返却 API
whatsnewj.js // 検索結果返却 API
release.js // リリース日返却 API

というわけで、Netlify Functions ビルド的には netlify.toem に指定した functions キーのディレクトリに AWS Lambda 形式の JavaScript を配置すれば良いだけということになります。

netlify.toemcommand キーは Netlify サイトデプロイ時に Netlify 上で自動的に実行されるコマンドを書くことができます。

典型的には AWS Lambda をかくためには、npm パッケージや webpack のバンドルを活用することになると思いますので、commandnpm run build などのビルドスクリプトを指定することになると思います。


Netlify 上のビルドで使えるコマンド

command や npm の script キー内で UNIX のコマンドを使うことができる。 cptar などが利用可能であるため比較的自由にビルドを組める。

パッケージの追加はできないと思われる(未確認)が、スタティックサイトジェネレートで使われる zolarubynode などなどが事前に導入済みで netlify.tomlバージョン指定が可能。Netlify 管理画面の Deploy log から見ると分かりやすい。

要はなんらかの形でファイルが publishfunctions に入ればそれぞれにデプロイされる。

Functions の URL マッピング

標準のままであると URL のエンドポイントが /.netlify/functions/ファイル名 と長くなってしまうので netlify.toml/v1/ファイル名/ 形式に rewrite しています。

redirectsstatus = 200設定すると rewrite になるようです

[[redirects]]
    from = "/v1/*"
    to = "/.netlify/functions/:splat"
    status = 200
    force = true

[[redirects]]
    from = "/v1/whatsnewj/ja/*"
    to = "/.netlify/functions/whatsnewj?q=:splat"
    status = 200
    force = true

ただ、/v1/*/v1/whatsnewj/ja/* のような URL を重ねるような設定はうまく効かないみたいです。何か間違ってるかな。。要調査。

追記。どうも from から to にクエリーが渡ってこないようなので event.path から取得できる関数をつくって対応しています。

/**
 * parseQueryArgs
 *
 * Workaround Netlify args perser.
 * https://community.netlify.com/t/querystringparameters-not-working-with-redirect-on-production/3828
 *
 * @param {String} rewriteUrl
 * @param {Object} queryString
 * @param {String} path
 * @param {Object} format
 * @returns {Object} extract args
 */
export function parseQueryArgs(rewriteUrl, queryString, urlpath, format) {
    let parse = Path.createPath(rewriteUrl).test(urlpath);

    // extract args
    let result = {};
    for(const key in format) {
        if(parse && parse[key]) {
            // clean url
            result[key] = parse[key];
        } else if(!netlify && queryString[key]) {
            // local query string
            result[key] = queryString[key];
        } else {
            // default value
            result[key] = format[key]
        }
    }

    return result;
}

netlify-lambda

というわけで .js をビルドして functions に指定したディレクトリに入れれば Lambda が動き出すわけですが、そもそもビルドをつくるのが大変だったり、ローカルでも動かしたりしたいというわけで、Netlify さんが netlify-lambda という便利な npm パッケージを準備してくれています。

というわけで、プロジェクトの package.json に記載する内容は次のような形になります。

{
  "name": "e2j-api",
  "version": "0.1.0",
  "scripts": {
    "devel": "netlify-lambda --timeout 20 --config ./webpack.functions.js serve script/api/endpoint",
    "build": "netlify-lambda --config ./webpack.functions.js build script/api/endpoint"
  },
  "dependencies": {
    "axios": "^0.19.2",
    "decompress": "^4.2.0",
    "decompress-tarbz2": "^4.1.1"
  },
  "devDependencies": {
    "netlify-lambda": "^1.6.3"
  }
}

devDependencies として netlify-lambda を入れることにより netlify-lambda コマンドを準備し、script のキーのところでコマンドを呼び出しビルドしています。dependencies では自分のプログラムで利用するパッケージが指定できます。


netlify-lambda の webpack 設定

netlify-lambda コマンドでは --config ./webpack.functions.js オプションでビルドで使われている webpack.config.js への追加設定が可能(なお netlify-lambda コマンドのソースからビルドで動作する webpack config が参照できる)

このプロジェクトでは、Lambda プログラムに対してミニマイズしても意味がないという理由で optimization: { minimize: false } を追加指定。

// webpack.functions.js
module.exports = {
  optimization: { minimize: false },
};

しかも、今回の場合巨大な 100M 超え .json を次のように webpack で import しているため、、

/**
 * e2j whatsnewj API
 */
import * as common from "../common";
// (でかい 100MB)辞書 JSON import
import whatsnewj from "../json/whatsnewj.json";

ミニマイズ時に JS のパーサーがダウンしてしまうという凄惨な事故が起きたため必須設定となっております。。

netlify-lambda の webpack や babel のバージョン

この辺から使われる webpack や babel などのバージョンを知ることができます。ビルドを修正したい場合は、このリポジトリをフォークして変更。自分の package.json にてそのフォークしたリポジトリーを使うようにすると簡単かも知れません。

{
  // ....
  "devDependencies": {
    // "netlify-lambda": "^1.6.3"
    "netlify-lambda": "git+https://github.com/h1romas4/netlify-lambda"
  }
}

Netlify Functions の性能リミット

というわけで、当初 120M を超える .json を抱えた .js を Netlify Functions に載せ恐る恐る動かした所… 10.01sec timeout という悲しいメッセージが…

  • でっかい .json を webpack で固めたものは Lambda 上でもそもそもパースに時間がかかる。これは webpack v4.35.3 でかなり高速化されているようです。ちなみに netlify-lambda のデフォルト webpack は現在 package-lock.json なしの ^4.17.1 指定。
  • AWS Lambda のデフォルト nodejs は 10 系。netlify.tomlAWS_LAMBDA_JS_RUNTIME でより高速になった 12系に変更可能。
  • あと辞書を 100MB ほどに縮小

いろいろ試した見たところ無事メモリー 522MB の Init Duration 6.9 秒で初期起動成功。

8:20:30 PM: 2020-03-27T11:20:30.079Z	5b35fc60-b9f0-4a43-a642-38a6764950a7	INFO	q=test
8:20:30 PM: Duration: 3.22 ms	Memory Usage: 522 MB	Init Duration: 6934.64 ms	

初回以降は、

8:22:08 PM: 2020-03-27T11:22:08.133Z 0b548212-c6b6-4621-b0ec-75747a9dff1a INFO q=麻雀
8:22:08 PM: Duration: 331.08 ms Memory Usage: 522 MB 8:22:53 PM: 2020-03-27T11:22:53.071Z ee639944-7168-4c74-8240-3630fba6da89 INFO q=麻雀
8:22:53 PM: Duration: 2.83 ms Memory Usage: 522 MB

一度目の検索ワード「麻雀」でのアクセスが 331ms で、同じ検索ワードだと 2.83ms ということで CDN? キャッシュ? が効いたのかな…?という感じの思ったとおりの動きになってくれました。

おそらく .json のサイズ感としてはこれくらいが限界の印象です。今回はこれで 20年分のデータとなっていますので、あと 10 年位動くかな。。よぼよぼ。。


Netlify のネットワーク帯域

実は当初、辞書のデーターはこのような .json を import する無理な方法は採らず、JavaScritp の配置したキャッシュ変数が空なら、 http リクエストで自身のコンテントルートに配置した .json.bz2 ファイルをネットワークから取得してキャッシュ変数にロードしようと考えていました。

/**
 * response cache on AWS Lambda
 */
let jsonCache = {};

/**
 * async http request
 *
 * @param url
 * @return JSON
 */
export async function getJsonHttp(url) {
  if(url in jsonCache) return jsonCache[url];
  try {
    if(url.match(/^.+\.tar\.bz2$/)) {
      // for .tar.bz2 arcive
      const res = await axios.get(url, { responseType: "arraybuffer" });
      const filename = crypto.createHash('sha1').update(url).digest('hex');
      const tar = "/tmp/" + filename + ".tar.bz2";
      await fsp.writeFile(tar, res.data);
      let ret = await decompress(tar, null, { plugins: [ decompressTarBz2() ] });
      let whatsnewjson = JSON.parse(ret[0].data.toString('utf-8'));
      await fsp.unlink(tar);
      jsonCache[url] = whatsnewjson;
    } else {
      // for plane json
      const res = await axios.get(url);
      jsonCache[url] = res.data;
    }
    return jsonCache[url];
  } catch (error) {
    console.log(error);
    throw Error(error);
  }
}

.bz2 圧縮であればファイルサイズは 10MB 程度になっていまいたので、同じ AWS 内の通信なので間に合うかなという皮算用でしたが残念ながら間に合わずあえなく断念。

1MB 未満の小さな .bz2 ファイルや .json であれば時間内に動作しロードすることができました。あまり関係ないですが decompress-tarbz2 は WebAssembly で動作しているようでプラットフォームを選ばず高速に動作することを確認。 decompress-tarxz はネイティブを呼んでる模様。

Netlify 外の仕組みになってしまいますが、自前の S3 などにファイルを配置して aws-sdk で取り出すと巨大なデーターのロードもすんなりいくのかもしれません(未確認)

リクエスト回数・時間制限

Netlify Functions 無料枠には API 12,500 回リクエスト、処理時間 100時間の制限があります。

二晩くらい API たたいてテストしていた気がしますが、大人気 API にでもならない限り制限にかかることはなさそうです。 😀

Counts every time a function was invoked in the current billing period.
832/125,000
124,168 requests left

Run time Combined run time of all function requests in the current billing period.
15 minutes/100 hours
100 hours left

終わりに

というわけで、API としては無茶方式のサンプルになってしまいましたが、内容中に何か役立つ知見があったら幸いです。

この作業のきっかけとなりました、20年に渡り MAME 文書の翻訳をされている MAME E2J さんに感謝です。ちなみに先日サイトを WordPress でリビルドされておりアーカイブが大変見やすくなっていますので、お好きな方はぜひ。

MAME E2J
a bridge across cultures

最後にソースファイルへのリンクを貼っておきます!

Github Actions による形態素解析 & Netlify Lambda プロジェクト

https://github.com/h1romas4/e2j-api

Vue の API テスト用画面

https://github.com/h1romas4/e2j-api-web