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

Rust 製スタティックサイトジェネレーター Zola をつかう

はじめに

Rust 製のスタティックサイトジェネレーターの Zola を使ってウェブサイトをひとつつくってみました。本記事はその時に書いた Zola の使い方メモになります。

スタティックサイトジェネレーターを使ったウェブサイト製作に至った経緯ですが、

  • ジオシティーズが 2020/3末に完全にサービス終了する。
  • 伴って、自分が持っていたジオシティーズのサイトのファイルが消えてしまうので救出。
  • せっかくだから内容はそのままに、昨今のモバイルや SNS 対応をしてウェブに再登場させておこう。

といったところです。 😀

今回復活させようとしている旧式サイトは、同じフォーマットの雑多なメモがたくさんおいてあるタイプで、このような形式のウェブサイトをつくる(修正する)場合は、テンプレートととなる .html をひとつ作成し、記事を流し込む方式を取りたくなります。

そこで Zola “スタティックサイトジェネレーター” を用いて、記事となる部分を旧式サイトから Markdown として抽出作成し、最後にビルドをかけ .html をたくさん生成する方式をとることにしました。

WordPress などの CMS を用いなかったのは、そもそも古のコンテンツのため手離れをよくしたかったというのが理由です。関連して、このサイトはドメインもジオシティーズに習い、自分のものを使わず無料のホスティングサービス(ここでは Netlify を利用)に依存したものを使うことにしました。

この記事の内容で Zola でリニューアルしたウェブサイトは次から見ることができます。

適当メモ – maple4estry –

適当に作ったり書いたりしたものを、もしかしたら誰かの役に立つかもと、 記載してみているサイトです。

あ、あと関係ないですが、もちろんリンクフリーです。 こんなところでよければ好きなページにはってください。

ご覧の通り、ページテンプレートはモバイル対応や SNS OGP 対応など最新化してみたものの、コンテンツは15年以上前の古い芸風ですゆえ、内容は気にしないでください。。。

Zola とは

Zola はスタティックサイトジェネレーターと呼ばれるソフトウェアのひとつです。

Zola

Your one-stop static site engine
Forget dependencies. Everything you need in one binary.

Rust でつくられていて(自分が少し Rust をやっていることもあり)勉強も兼ねて選択してみました。同様のソフトでは Go 製の Hugo が有名でしょうか。

なお、現時点 Zola はまだバージョン 0.10.0 で API フリーズしていませんので、この記事の内容は将来変わる可能性があります。ご了承ください。

自分が気がついた Zola の特徴を少しあげておきます。

  • Rust でつくられており高速性を重視。
    • ちなみに、今回のような 100 記事未満の小さなサイトであれば測る時間もないくらいで処理が終了しました。
  • html テンプレートエンジンに、同じく Rust でできた Tera を用いている。
    • “Used to Jinja2, Django templates, Liquid or Twig? You will feel right at home.”とのことで構文は Jinja2 ベースのようです。
    • 神社と寺? 🙂
    • Zola と Tera はプロジェクトリードが同じ方です。
  • Markdown のパーサーを、これもまた Rust でできた CommonMark 互換の pulldown-cmarkを使っている。
    • ソースコードのシンタックスハイライトも標準で対応。
    • ### などに日本語を書いた場合も id を英語スラッグに変換する rust-unidecode が有効になっている。

Zola の導入

macOS、Linux、Windows 版の各導入方法がドキュメントにあります。

Installation

Zola provides pre-built binaries for MacOS, Linux and Windows on the GitHub release page.

自分は Ubuntu に snap のパッケージがありましたので、こちらで導入しています。

snap install --edge zola

初期プロジェクトの作成

Zola を入れると zola コマンドが使えるようになり、新規プロジェクトの作成やファイル監視からの自動ビルドの機能が使えるようになります。

まずは任意のフォルダーで次のコマンドで、ひとつのウェブサイトに対応する初期プロジェクトを作成します。ここでは仮に example.comexample の という名称にしました。

いくつか質問がありますので適宜答えていきます。ここでは次のように設定しています。

  • CSS プリプロセッサーである Sass の利用を有効化。
  • Markdown 中のソースコードブロックを色付けする syntax highlighting を有効化。
  • サイト検索用の JSON インデックスの生成を無効化。
$ zola init example
Welcome to Zola!
Please answer a few questions to get started quickly.
Any choices made can be changed by modifying the `config.toml` file later.
> What is the URL of your site? (https://example.com): https://example.com
> Do you want to enable Sass compilation? [Y/n]: Y
> Do you want to enable syntax highlighting? [y/N]: y
> Do you want to build a search index of the content? [y/N]: N

Done! Your site was created in "/home/hiromasa/web/example"

Get started by moving into the directory and using the built-in server: `zola serve`
Visit https://www.getzola.org for the full documentation.

処理が終わると次のような構造でディレクトリとファイルが作成されます。

  • content/
    • 記事になる Markdown (.md)ファイルや記事の画像リソースなどを格納します。フォルダーも作成可能で、この配下の構造とサイトの URL の構造が一致します。
  • templates/
    • 記事を挿入する Tera 記述を使った .html テンプレートファイルを格納します。
  • sass/
    • CSS をつくつための .scss を格納します。Zola には Sass コンパイラが含まれているためここにつくった .scss は自動でビルドがかかり .css になります。
  • static/
    • サイトの .html で使うヘッダー画像や .js などを格納します。この配下のファイルは。サイトの URL の / 直下に配置されます。
  • themes/
    • Zola サイトなどで提供されているテーマを格納します。既存のテーマを活用することにより templates/ テンプレートを自らつくることなくcontent/ 内の記事を流し込み、手軽にサイトをつくることができます。
  • public/
    • Zola でビルドした結果でできるたくさんの .html が格納されるデフォルトフォルダです。この中身をインターネットに公開します。
  • config.toml
    • サイト名や URL などのサイトの設定を記述します。zola init 時の設定が保存されています。
$ cd exmaple
$ ls -laF
合計 32
drwxr-xr-x 7 hiromasa hiromasa 4096  3月 14 17:12 ./
drwxr-xr-x 5 hiromasa hiromasa 4096  3月 14 17:12 ../
drwxr-xr-x 2 hiromasa hiromasa 4096  3月 14 17:12 content/
drwxr-xr-x 2 hiromasa hiromasa 4096  3月 14 17:12 sass/
drwxr-xr-x 2 hiromasa hiromasa 4096  3月 14 17:12 static/
drwxr-xr-x 2 hiromasa hiromasa 4096  3月 14 17:12 templates/
drwxr-xr-x 2 hiromasa hiromasa 4096  3月 14 17:12 themes/
-rw-r--r-- 1 hiromasa hiromasa  471  3月 14 17:12 config.toml

この記事では Zola を知る上で最初の取り掛かりになるであろう、templates/ テンプレートファイルと content/ のコンテンツの作成方法について解説します。

Tera テンプレートエンジン

Zola に内蔵された Tera テンプレートエンジンは(Zola 専用というわけではなく)汎用的なものです。資料確認の順番的にはまずは Tera テンプレート仕様ドキュメントからみると理解しやすいと思います。

Tera の基本的な構文:

  • {{ 変数名 }} − 変数の内容を html に出力
  • {% 命令 %}ifforblock などの命令
  • {# コメント #} − コメント

典型的な Tera テンプレートを含んだ Zola の .html は次のようになると思います。

  • {{ config.title }} のようにして変数の内容を出力。config.title などの変数は Zola によって設定される。後述。
  • {{ get_url(path="book.css") | safe }} のようにして関数を呼び出し値を出力。get_url 関数は Zola 提供。safe は Tera 提供の値エスケープフィルター処理。
  • {% block 名称 %} で名称ブロックを生成。後述。

次のファイルをつくって templates/ に格納します。

base.html(任意の名前) − サイトの基本的な構造をつくる(空の{% block 名称 %} ブロックを事前にあちこち定義しているのがポイントです)

{# このファイルは base.html #}
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta http-equiv="content-type" content="text/html; charset=utf-8">
    <meta name="viewport" content="width=device-width">
    <title>
        {% block title %}{% endblock title %}
    </title>
    {% block js %}
    {% endblock js %}
    {% block css %}
    <link rel="stylesheet" href="{{ get_url(path="style.css") | safe }}">
    {% endblock css %}
    {% block extra_head %}
    {% endblock extra_head %}
</head>

<body>
    <div class="page">
        <div class="page__content">
            <div class="book-content">
                {% block content %}
                {% endblock content %}
                <footer><a href="{{ config.base_url }}">{{ config.title }}</a></footer>
            </div>
        </div>
    </div>
    {% block js_body %}
    {% endblock js_body %}
</body>
</html>

index.htmlbase.html を Tera の extends 機能で継承指定し、親となった base.html 内で設定した block に出力したいコンテンツを合成する。

{# このファイルは index.html #}
{% extends "base.html" %}

{% block title %}
    {{ config.title }}
{% endblock title %}

{% block content %}
<header>最終更新日 <time datetime="{{ section.extra.date }}">{{ section.extra.date }}</time></header>
<h1>{{ section.title }}</h1>
{{ section.content | safe }}
{% endblock content %}

config.titlesection.titlesection.content などは Zola から設定される変数で、具体的に言えば content/ に配置した .md で設定した内容が挿入されます。

この例のおおざっぱな Zola の処理の流れとしては、.md 内のコンテンツが変数として index.html に挿入され、それが base.html に合成され最終的な .html が生成されるということになります。

Zola コンテンツとテンプレートファイル

templates/ に格納する Tera テンプレートは、Zola のファイル命名規約によりウェブサイト上の次の位置にコンテンツを出力します。

  • index.html − ホームページ(いわゆるサイトのトップ・フロントページ)
  • section.html − 各フォルダ階層のセクション(主に目次・アーカイブ)
  • page.html − ページ

前項「Tera テンプレートエンジン」でつくった index.html は、サイトのホームページ(フロントページ)で出力されるテンプレートファイルということになります。

index.html が継承で使用しているテンプレートファイル base.html は、Zola の命名規約から”外れていれば”なんでもかまいません。Tera テンプレートの機能でモジュール分割などを行う場合もこれらをファイル名を除けば、自由にファイルをつくることができます。

次に、テンプレートファイルとコンテンツとなる .md の対応の基本は次のようになっています。

  • index.htmlcontent/_index.md
  • section.htmlcontent/**/_index.md(フォルダの中の _index.md)
  • page.htmlcontent/ 配下の任意名の .md ファイル

そしてサイトの URL と content/ のフォルダーツリーが対応します。

Zola を使ったウェブサイトの構成の基本はこれらの動きを利用し、

  • content/ 配下内の .md ファイルとフォルダー配置でサイトのツリーをつくる。
  • content/ 直下及びその配下のフォルダーに配置した _index.md でそれらの目次(アーカイブ)をつくる。
  • それぞれのコンテンツは index.htmlsection.htmlpage.html で出力する。(content/ 内にフォルダーがない場合は section.html は不要)

という思想になっています。

index.htmlsection.html テンプレートはセクション系の仲間です。index.html のほうがホームページに配置される特殊な section.html と考えたほうがいいかもです。本で言えば目次とセクションの関係ですね。

さて、テンプレートファイルで使える変数についてですが、上記のセクション系のテンプレートでは sectionpage.html では page を前置した名称でアクセスできます。

index.html 抜粋 − 変数に section.titlesection.content が使われている。

{# このファイルは index.html #}
{% block content %}
<header>最終更新日 <time datetime="{{ section.extra.date }}">{{ section.extra.date }}</time></header>
<h1>{{ section.title }}</h1>
{{ section.content | safe }}
{% endblock content %}

そして、これらの変数は各 .md 内の上部 +++ ブロック「フロントマター」で設定できます。

content/_index.mdindex.html に対応)

+++
title = "適当メモ - maple4estry -"
extra.date = 2020-03-14
+++

適当に作ったり書いたりしたものを、もしかしたら誰かの役に立つかもと、
記載してみているサイトです。

また、フロントマター外の通常の Markdown コンテンツについては content という変数に取得することができます。

フロントマターで設定することができる変数及び、テンプレートから読み出せる変数は次のドキュメントに記載があります。

なお、{{ __tera_context }} とテンプレートに書くとその場で使える変数一覧が見えます。ビルドで変数がないというエラーになった場合は確認してみましょう。

Overview

If you are not sure what variables are available in a template, you can place {{ __tera_context }} in the template to print the whole context.

注意点として、各テンプレートでフロントマターの変数を取得する場合はそれぞれ sectionpage を前置するのを忘れないようにします。またコンフィグ値は config を前置します。

またフロントマター内で extra. を変数に前置するとユーザー定義変数となり、テンプレートで読み出すことが出来ます。

これらを利用したページのテンプレートとフロントマターを示します。

content/test.md

+++
title = "へごへごメモ"
date = 2020-03-13
extra.image = "./hegohego.png"
+++

## はじめに

や゛め゛て゛く゛た゛さ゛い゛よ゛~

page.html

{# このファイルは page.html #}
{% extends "base.html" %}

{% block title %}
    {{ page.title }}
{% endblock title %}

{% block content %}
  <header>最終更新日 <time datetime="{{ page.date }}">{{ page.date }}</time></header>
  <h1>{{ page.title }}</h1>
  {{ page.content | safe }}
{% endblock content %}

{% block extra_head %}
  <link rel="canonical" href="{{ current_url | safe }}" />
  {% if page.extra.image is defined %}
    {% set image = resize_image(path=page.extra.image, width=640, height=360, op='fit_width') %}
    <meta name="twitter:text:title" content="{{ page.title }}" />
    <meta name="twitter:image" content="{{ image.url | safe }}" />
    <meta name="twitter:card" content="summary_large_image" />
  {% endif %}
{% endblock extra_head %}

このテンプレートには次のような要素が入っています。

  • {% block 名称 %} により base.html 内に配置された titlecontentextra_head の3ブロックの内容を定義している。
  • page.html のページテンプレートなので変数の前に page. を前置して取得している。
  • .md のフロントマターで extra.image ユーザー変数を定義し、テンプレート内で page.extra.image で取得し、Tara テンプレートの条件分岐機能で、変数が存在すれば twitter カードの出力をしている。この際にアイキャッチ画像を resize_image 関数を使い任意の大きさで生成している。

その他の機能

以上の仕組みが分かると残りはドキュメントを読めば知恵と勇気でなんとかなると思います。

主なものにリンクをしておきます。

  • 特定の .md ファイルで特例的に別なテンプレートファイルを指定したい。
  • _index.md でアーカイブを出力したい。
    • section.pages 変数に配下のページの配列が入っているのでループして出力。
  • いろいろなサイト内の URL を取得したい。使える関数が知りたい。
  • 画像リサイズしたい
  • sitemap.xml を出力したい。
  • よく分からなくなってしまった。
    • 配布されているテーマのソースコードが大変参考になります。
    • Zola のテーマファイルは、themes 配下とプロジェクトルート配下の構造を同じに持ち、プロジェクトルートのファイルがあれば優先されるタイプの実装になっています。(親で上書きできる WordPress の子テーマと同じ感じです)

ビルド

コンテンツの .mdtemplates/ が準備できたらビルドしてみます。以下のコマンドで Zola 内蔵の http サーバーが起動され確認もすることができます。

$ zola serve
Building site...
-> Creating 57 pages (0 orphan), 0 sections, and processing 0 images
Done in 237ms.

Listening for changes in /home/hiromasa/web/maple4estry/{content, config.toml, static, templates, themes, sass}
Press Ctrl+C to stop

Web server is available at http://127.0.0.1:1111

zola serve で起動後はファイルの修正にウォッチがかかり、テンプレートやコンテンツまた Sass の修正による自動ビルドがかかるようになっています。

なお、サンプルのソースで行っているような resize_image などの画像リサイズ処理はウォッチ対象ではなさそうです。その場合は build コマンドで別途ビルドしてあげると出力されます。

$ zola build

ウェブサイトの公開

うまくできたら public 配下に生成されたファイルをウェブサーバーに載せるだけです。

ホスティングサービス Netlify 使う場合は Zola のビルドに対応していますので、以下のドキュメントどおりプロジェクトルートディレクトリに netlify.toml を配置して、public を除外して、git リポジトリーにコミットしてあげれば自動でデプロイしてくれます。便利ですね。

Netlify | Zola

Netlify provides best practices like SSL, CDN distribution, caching and continuous deployment with no effort. This site is hosted by Netlify and automatically deployed on commits.

Zola はここであげた以外にも、Markdown 内で利用可能なショートコードやフィルターなど便利な機能がまだまだあるようです。

導入も簡単で高速にビルドでき、特に大きなドキュメント系のウェブサイトで威力を発揮しそうです。個人的には AsciiDoc にも対応してくれると嬉しいですが(まだ Rust でのパーサーはなさそうですが)、、引き続き使っていきたいと思います。

マイコン MCU で AssemblyScript + WebAssembly/Wasm3 を動かす

WebAssembly の登場でウェブブラウザー上で C/C++/Rust といった高速で動作する言語を使うことができるようになりましたが、一方でそのコンパクトな実装は、マイコンなどの小型のコンピューターでスクリプト言語を省メモリーで素早く実行する環境ももたらすことになりそうです。

この記事では WebAssembly のインタープリター実装のひとつである Wasm3 を活用して、ESP32 / K210 MPU で TypeScript のサブセットである AssemblyScript を動かす方法を解説しています。

ウェブブラウザーとマイコンで同じスクリプトが動作するのは感動的です。 🙂

https://raw.githubusercontent.com/h1romas4/maixduino-wasm3-testing/master/docs/images/maixduino-wasm3-02.jpg
Maixduino マイコンとウェブブラウザーで動作する同じ Conway’s Game of Life スクリプト

本稿は 2020年2月 の AssemblyScript 0.9.2、Wasm3 は 0.4.6 時点の情報です。API フリーズはしていませんので、今後のバージョンアップで変わる部分がある可能性があることだけご留意ください。


(2023年1月追記) 以下の GitHub リポジトリーと記事に、より新しいバージョンの利用例があります。

https://github.com/h1romas4/m5stamp-c3dev

This is a development board for the M5Stamp C3 (RISC-V/FreeRTOS).

また M5Stack Core2 バージョンもあります。

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

M5Stack Core2 with WebAssembly. Wasm3/AssemblyScript Demo

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

ソースコードやビルド方法は以下の github リポジトリーにコミットしてあります。Wasm3 と AssemblyScript や各マイコンの SDK のバージョンは git のタグなどで固定していますので、どの時期でもビルドし動作させられると思います。

M5Stack (ESP32) 版:

https://github.com/h1romas4/m5stack-wasm3-testing
WebAssembly interpreter Wasm3 on M5Stack (work in progress)

Maixduino (K210) 版:

https://github.com/h1romas4/maixduino-wasm3-testing
WebAssembly interpreter Wasm3 on Maixduino (work in progress)

Wasm3

Wasm3 は C言語でかかれた WebAssembly のインタープリター実装です。非常にコンパクトで高速に動作し、ESP32 や K210、ARM などの MCU(マイコン)を含む、さまざまな実行環境で動作を検証しながらつくられています。

https://github.com/wasm3/wasm3
The fastest WebAssembly interpreter

マイコンの C/C++ ツールチェイン/SDK により Wasm3 をビルドし、(例えば)main.c の中から Wasm3 の実行環境コールし、事前に AssemblScript などでかかれたプログラムをビルドして出力しておいた .wasm バイナリーを読ませることで WebAssembly を実行することが可能です。

Wasm3 は小さなメモリーで動くことも特徴となっており、公式ドキュメントで

Minimum useful system requirements: ~64Kb for code and ~10Kb RAM

となっています。 Limited support ですが最少で flash 128KB、RAM 16KB の AVR マイコンでも動作するようです。

なお、この記事で紹介している M5Stack と Maixduino のスペックは次のようになっています。

NameMCUClockFlashRAM
M5Stack BasicESP32240MHz4MB520KB
MaixduinoK210400MHz(600MHz)16MB6MB(8MB)

M5Stack は RAM がいくつかのエリアに分かれていて大きなメモリーの malloc に少々コツがいりますので、M5Stack Basic よりも 追加で PSRAM が 4MB ついている M5Stack Fire のほうが試しやすいかもしれません。

Maixduino については AI コア使用可否とクロック設定により括弧内の性能で動作させています。

AssemblyScript

AssemblyScript は WebAssembly 向けのコンパイラー言語です。TypeScript のサブセットとしてつくられており、WebAssembly アセンブラ命令へのバインディングと、それを活用してつくられた JavaScript の標準関数によく似た Standard library を持ちます。 Map や Array といった関数をスクリプトで利用可能です。

一部 Math や Date 関数、ウェブブラウザーでよく使われる console.log() 関数などを使う場合は、ホスト環境上に定義された関数に依存があり、マイコンで動作させる場合はそれらを C言語の関数として準備してあげます。実行に必要なバインディングは std/bindings にて export declare function 定義されています。

assemblyscript/std/assembly/bindings/

一点不明だったのが、env.abort() 関数で、Standard library を使おうとすると export されるようです。ドキュメントに記載がありました!

assemblyscript/std/assembly/builtins.ts

// @ts-ignore: decorator
@external("env", "abort")
declare function abort(
  message?: string | null,
  fileName?: string | null,
  lineNumber?: u32,
  columnNumber?: u32
): void;

ちょっとあれこれやってみたのですが、うまく C言語の関数にバインドできなかったので AssemblyScript の –use オプションでブランクを設定し export しないようにしています。

asc assembly/index.ts -b build/app.wasm -t build/app.wat --runtime full --use abort=

さて、同じ原理で、AssemblyScript のユーザー関数も export declare function としてホスト環境上の関数にリンクすることができますので、マイコンのハードウェア操作を行う関数を準備しておけば、AssemblyScript 内からマイコンの機能を呼び出すことができます。

Wasm3 で AssemblyScript から Arduino の digitalWrite 関数を呼び出す例:

AssemblyScript – arduino.ts

@external("arduino", "digitalWrite")
export declare function digitalWrite(pin: u32, value: u32): void;

// C側の関数呼び出し
arduino.digitalWrite(2, 1);

Arduino ホスト – main.cpp (m3 が Wasm3 です)

#include <m3_api_defs.h>
#include <m3_env.h>
#include <Arduino.h>

m3ApiRawFunction(m3_arduino_digitalWrite)
{
  // 引数取得
  m3ApiGetArg(uint32_t, pin)
  m3ApiGetArg(uint32_t, value)
  // Arduino 関数呼び出し
  digitalWrite(pin, value);
  m3ApiSuccess();
}

M3Result m3_LinkArduino(IM3Runtime runtime)
{
  IM3Module module = runtime->modules;
  const char *arduino = "arduino";
  // arduino.digitalWrite 関数を m3_arduino_digitalWrite にリンク
  m3_LinkRawFunction(module, arduino, "digitalWrite", "v(ii)", &m3_arduino_digitalWrite);
}

また、WebAssembly からホストするマシンのファイルシステムやネットワークにアクセスする WASI API への対応も進められているようです。WASI はまだ策定段階ですが、これらの API も将来マイコンで使えるようになるかもしれません。(まだ関数名が違う部分もありそうですが Wasm3 でも一部対応しています

AssemblyScript とホスト間のインターフェースは関数呼び出し以外にもメモリーを共有する方法が準備されており、AssemblyScript のコンパイルオプションの -memoryBase が Wasm3 との組み合わせで便利でした。(そして、WASM ネイティブ命令でアクセスできるため恐らく高速です)

Static memory

Memory starts with static data, like strings and arrays (of constant values) the compiler encountered while translating the program. Unlike in other languages, there is no concept of a stack in AssemblyScript and it instead relies on WebAssembly’s execution stack exclusively.

A custom region of memory can be reserved using the --memoryBase option. For example, if one needs an image buffer of exactly N bytes, instead of allocating it one could reserve that space, telling the compiler to place its own static data afterwards, partitioning memory in this order:

これは WebAssembly でアロケートするメモリーの 0番地から指定した任意のバイト数をリザーブするオプションで、AssemblyScript の load / store 命令によりアクセスすることができます。

package.json

asc assembly/index.ts -b build/app.wasm -t build/app.wat --memoryBase 57600 --runtime none --validate --sourceMap --optimize

後述のサンプルではこの機能を使い、アロケートしたメモリーを仮想 VRAM として見立て、AssemblyScript からメモリー書き込み後、マイコン側で LCD に転送することで画面描画を行っています。

index.ts

@inline
function pget(x: u32, y: u32): u8 {
    return load<u8>(y * width + x);
}

@inline
function pset(x: u32, y: u32, v: u8): void {
    store<u8>(y * width + x, v);
}

main.cpp – Wasm3 の m3_GetMemory 関数で memoryBase のポインターを取得して LCD に転送する例:

// bitblt
uint8_t* vram = (uint8_t*)(m3_GetMemory(runtime, 0, 0));
M5.Lcd.pushImage(40, 0, 240, 240, vram, true);

WebAssembly interpreter Wasm3 on M5Stack 編

ESP32/M5Stack で、AssemblyScript/Wasm3 にてフィボナッチ数列の計算と仮想 VRAM の転送による LCD 描画のサンプルを作成してみました。プログラムやビルドの方法などは以下のリンクを参照ください。

https://github.com/h1romas4/m5stack-wasm3-testing

ESP32 特有な部分としては、Wasm3 の関数が高速に動作する IRAM 上に配置されるように Wasm3 のコンパイルオプションを構成しています。

component.mk

CFLAGS += -DESP32
# CFLAGS += -DM3_IN_IRAM
CFLAGS += -Dd_m3LogOutput=true
CFLAGS += -Dd_m3VerboseLogs=true
CFLAGS += -O3
CFLAGS += -freorder-blocks
# CFLAGS += -Dd_m3FixedHeap=96000
# CFLAGS += -Dd_m3MaxFunctionStackHeight=128
# CFLAGS += -Dd_m3CodePageAlignSize=1024
# CFLAGS += -Dd_m3EnableOptimizations=1

# COMPONENT_ADD_LDFRAGMENTS += linker.lf

本来 linker.lf の指定で IRAM 上に載るはずなのですが、指定の仕方が悪いのかうまく効かなかったため M3_IN_IRAM を無効にして関数に IRAM_ATTR を付けています。

なお、フィボナッチ数列のサンプルについては、fib(19) くらいまでいくとおそらく再起が深くなりすぎメモリーが足りなくなります。@wasm3_engine さんより ESP32 の動作は今後さらに改善されるというコメントをいただいています。このような深い再起のない通常のプログラムであれば問題なく動作します。

VRAM 転送で円を描画しているサンプルは、240x240x8bit の領域を前述の memoryBase コンパイルオプションを使って AssemblyScript で確保しています。

M5Stack は本来 320×240 解像度ですが残念ながらそのサイズを指定すると malloc に失敗してしまいました。おそらく memoryBase ではなくて C側で malloc してポインターを受け渡せばいける気がしますが、方法について調査中です。

なお、描画速度ですが、このサンプルは LCD SPI に対して VRAM を最適化なしに M5.Lcd.pushImage 関数で単純に送信しているため速くありません。DMA などを活用すれば改善しそうです。

WebAssembly interpreter Wasm3 on Maixduino 編

Maixduino(K210) 上で M5Stack と同じ VRAM テストと、AssemblyScript の公式サンプルにありました Conway’s Game of Life を移植してみました。プログラムやビルドの方法などは以下のリンクを参照ください。

https://github.com/h1romas4/maixduino-wasm3-testing

Conway’s Game of Life デモは、AssemblyScript の Math.random() 関数を使っており、前述の通り Standard library の Math を使うためには binding を実装しなくてはなりませんが、使っているのが random 関数だけでしたので、ちょっとずるをしてその部分だけ実装して使うようにしています。

Math.ts

export declare function random(): f32;

index.ts

import * as Math from "./Math";

  for (let y = 0; y < h; ++y) {
    for (let x = 0; x < w; ++x) {
      set(x, y, Math.random() > 0.1 ? BGR_DEAD & 0x00ffffff : BGR_ALIVE | 0xff000000);
    }
  }

main.c

m3ApiRawFunction(math_randome) {
    m3ApiReturnType (float_t)
    m3ApiReturn     ((float_t)rand() / RAND_MAX);
}

M3Result LinkFunction(IM3Runtime runtime) {
    IM3Module module = runtime->modules;
    const char* math = "Math";
    m3_LinkRawFunction(module, math, "random", "i()",  &math_randome);
    return m3Err_none;
}

なお M5Stack と同様に、マイコンからの VRAM の LCD 転送については最適化しておらず、Conway’s Game of Life に関してはウェブブラウザーの canvas 形式である 32bit ARGB 形式を 16bit RGB に単純にループで変換していますので、かなり速度改善の余地があると思います。


Wasm3 公式サイトの Wasm3 vs other languages によりますと、Wasm3 の実行速度はマイコンでよく用いられる Micropython よりも 20倍以上高速な結果がでています。

                                             fib(40)
-----------------------------------------------------------------------------------------
LuaJIT             jit                         1.15s
Node v10.15        jit                         2.97s ▲ faster
Wasm3              interp                      3.83s
Lua 5.1            interp                     16.65s ▼ slower
Python 2.7         interp                     34.08s
Python 3.4         interp                     35.67s
Micropython v1.11  interp                     85,00s
Espruino 2v04      interp                       >20m

自分の所感でも非常にコンパクトで速いことが確認でき、マイコン上のユーザーインターフェース構築やルール定義など、柔軟性がありかつ安全でなければいけない領域で大いに活用できるのはないかと感じました。

引き続きチャレンジしたいと思います!

関連