好きな言語でプラグインが実装できる？Extismを触ってみた

# TL; DR

- [Extism](https://github.com/extism/extism) で、手軽にアプリケーションに3rd-partyのプラグインを挿せるようになる！
- プラグインはアプリケーション本体と異なる言語で書いてもOK
    - プラグインをWASMへコンパイルすることで実現

# はじめに

ネットを徘徊していたら、WASMを使った面白いプロダクトを見つけました。

https://extism.org/

アプリケーションの機能を追加するためのプラグインを、（アプリケーションの利用者が）作成できるようにするためのシステムです。
特徴的なのは、WASMを使うことで **アプリケーション本体とプラグインを別の言語で実装できる** 点です。利用者は自分の好きな言語を使って機能拡張が可能です。

開発の背景として、アプリケーションのコミッターが新機能の要望でつぶれてしまわないように、利用者側で3rd-partyのプラグインを開発できる土壌を用意したいという思いがあるようです。

（[公式ページ](https://extism.org/docs/overview#why-use-a-plug-in-system)より引用）

> プラグインシステムは、ユーザーや顧客があなたのアプリケーションの特定の箇所に新たな機能を追加できるようにするためのシステムです。あなた（アプリケーション開発者）はどこでその機能を走らせるかを決め、ユーザーはプラグインが何をするかを決めます。
>
> 多くのエンジニアリングチームは増え続けるfeature requestのリスト（しばしば彼らの「帯域幅」を何倍も超えてしまう）に直面します。どうすれば追い付けるでしょうか？プロダクトをエンドユーザーによって拡張可能にすることは、これらの機能をコアの外側へと移し、顧客がソフトウェアをより便利に使えるようにするための素晴らしい方法です。
>
> 実際、ユーザーがソフトウェアをどのように使いたいかを全て予想することはできません。そのため、プラグインシステムはそれ自身が理想的な機能です。

# Extismの仕組み

プラグインをWASMにコンパイルして、アプリケーションからSDK経由で呼び出します。
プラグイン内のメモリ管理やアプリケーションとのデータ授受等はAPIが用意されており、こちらもPDK(Plugin Development Kits)で手軽に実装可能です。

2023/1/23現在、 SDKは15言語、PDKは6言語対応しています。

# インストール

インストールガイドに従いました。今回はアプリケーションをElixir、プラグインをGoで開発します[^langs]。

https://extism.org/docs/install

アプリケーション、プラグインの開発言語以外にも以下が必要でした。

- Python (Extism CLIを動かすため)
- Rustup (SDKをコンパイルするため)

## 手順
（ハマったところはおま環の可能性もあるので参考程度に...）

### Extism CLIをインストール

```bash
# ローカルのPoetryが上手くインストールできていなかったので、curlを使用
$ sh <(curl https://raw.githubusercontent.com/extism/cli/main/install.sh) /usr/local/bin
$ pip3 install extism
```

### Extismをインストール

CLIでインストールができるので楽でした。1/20にリリースされたばかりの `v0.2.0` が入ります。

```bash
$ extism install latest
$ extism version
0.2.0
```

# アプリケーションにプラグインの差込口？を作成

続いて、アプリケーションを作成し、SDKを使ってプラグインを導入可能にします。こちらもドキュメントがあるのでなぞっていきます。

https://extism.org/docs/integrate-into-your-codebase/elixir-or-erlang-host-sdk

今回は、できあいのサンプルプラグイン `count_vowels` (入力された文字列の母音の数を返す) を使用しています。

```elixir:lib/pluggable.ex
defmodule Pluggable do
  def pluggable do
    require Extism

    ctx = Extism.Context.new()
    # プラグインの定義
    manifest = %{wasm: [%{path: "code.wasm"}]}
    # プラグインを読み込み。第3引数はWASIを使用するかどうか
    {:ok, plugin} = Extism.Context.new_plugin(ctx, manifest, false)
    # プラグイン呼び出し。第2引数が関数名、第3引数が入力
    {:ok, output} = Extism.Plugin.call(plugin, "count_vowels", "this is a test")
    JSON.decode!(output)
  end
end
```

```elixir:test/pluggable_test.exs
defmodule PluggableTest do
  use ExUnit.Case
  doctest Pluggable

  test "run vowel_count" do
    assert Pluggable.pluggable() == %{"count" => 4}
  end
end
```

今回は決め打ちで `code.wasm` を読み込み関数 `count_vowels` を呼び出していますが、実際のアプリケーションではconfigから読み込むようにしてユーザーが指定可能にするのがよさそうです。

また、実装からもわかるように、**プラグインの入力、出力はバイト列**(binary)です。どのような形式のデータをやり取りするか、Extismでは規定していないようでした。入力は文字列として扱い、出力にJSON文字列 `{"count": {文字数}}` を要求するというのはアプリケーション側の仕様で決めていることです。

この形式もOpenAPIのように指定出来たらプラグイン側の開発がもっと楽になるので、もう少し機能を深掘りしてみたいです。

## ハマったところ：コンパイルエラー

`mix deps.compile` で以下のエラーが出た場合、Rustのバージョンが古い可能性があります...

<details><summary>エラー</summary><div>

```
$ mix deps.compile
   Compiling extism-runtime v0.2.0
error[E0599]: no method named `cast_mut` found for raw pointer `*const VMMemoryDefinition` in the current scope
   --> /home/syuparn/.cargo/registry/src/github.com-1ecc6299db9ec823/wasmtime-runtime-4.0.0/src/instance.rs:977:41
    |
977 |                 ptr::write(ptr, def_ptr.cast_mut());
    |                                         ^^^^^^^^ help: there is an associated function with a similar name: `as_mut`
    |
    = note: try using `<*const T>::as_ref()` to get a reference to the type behind the pointer: https://doc.rust-lang.org/std/primitive.pointer.html#method.as_ref
    = note: using `<*const T>::as_ref()` on a pointer which is unaligned or points to invalid or uninitialized memory is undefined behavior

For more information about this error, try `rustc --explain E0599`.
error: could not compile `wasmtime-runtime` due to previous error
warning: build failed, waiting for other jobs to finish...

== Compilation error in file lib/extism/native.ex ==
** (RuntimeError) Rust NIF compile error (rustc exit code 101)
    (rustler 0.26.0) lib/rustler/compiler.ex:41: Rustler.Compiler.compile_crate/2
    lib/extism/native.ex:6: (module)
could not compile dependency :extism, "mix compile" failed. Errors may have been logged above. You can recompile this dependency with "mix deps.compile extism", update it with "mix deps.update extism" or clean it with "mix deps.clean extism"
```

```bash
# バージョンが古かった
$ cargo version
cargo 1.62.1 (a748cf5a3 2022-06-08)
$ rustc --version
rustc 1.62.1 (e092d0b6b 2022-07-16)
```
</div></details>

最新版のRustにアップデートしたらコンパイルエラーが解消しました。

```bash
$ rustup update
$ cargo version
cargo 1.66.1 (ad779e08b 2023-01-10)
$ rustc --version
rustc 1.66.1 (90743e729 2023-01-10)
```

~~成功した場合でもコンパイルには数分かかります。気長に待ちましょう :clock:~~

# プラグインを作成

続いてプラグインを作成します。こちらもドキュメント参照。

https://extism.org/docs/write-a-plugin/go-pdk

PDKを使いプラグイン関数を実装します。

```go:main.go
package main

import (
	"github.com/extism/go-pdk"
)

// wasm関数 http_get を定義
func http_get() int32 {
	// HTTPリクエスト関数はWASIにはまだ無いが、Extismが先行して実装
	req := pdk.NewHTTPRequest("GET", "https://jsonplaceholder.typicode.com/todos/1")
	req.SetHeader("some-name", "some-value")
	req.SetHeader("another", "again")
	// NOTE: ステータスコードは res.Status() で確認可能
	res := req.Send()

	// 出力としてアプリケーションに返すためメモリに格納
	pdk.OutputMemory(res.Memory())

	return 0
}

// 実行ファイルとして出力するために付けなければならない?
// https://github.com/tinygo-org/tinygo/issues/2703
func main() {}
```

入出力は `pdk.Input()`, `pdk.OutputMemory()` を使いメモリ経由でやり取りします。PDKのAPIはWASMで実装されており、CGo経由で呼び出しているようです。

実装箇所(Rustで書かれている)

https://github.com/extism/extism/blob/c94c221854154336901ac47233cbe16365dcf365/runtime/src/plugin.rs#L164

https://github.com/extism/extism/blob/c94c221854154336901ac47233cbe16365dcf365/runtime/src/pdk.rs

実装できたらtinygoでコンパイルします。

```bash
$ tinygo build -o request.wasm -target wasi main.go
```

アプリケーションに組み込む前に、Extism CLIで動作確認が可能です。プラグイン内でテストが完結するので良いですね。

```bash
# 動作確認（--wasiを忘れずに！）
$ extism call --wasi request.wasm http_get
{
  "userId": 1,
  "id": 1,
  "title": "delectus aut autem",
  "completed": false
}
```

## 使ってみる

先ほどのElixirのアプリケーションに組み込みます。

```elixir:lib/pluggable.ex
defmodule Pluggable do
  def pluggable2 do
    require Extism

    ctx = Extism.Context.new()
    # さっき作ったプラグインを指定
    manifest = %{wasm: [%{path: "request.wasm"}]}
    # NOTE: WASIを呼び出すので第三引数をtrueにする
    {:ok, plugin} = Extism.Context.new_plugin(ctx, manifest, true)
    {:ok, output} = Extism.Plugin.call(plugin, "http_get", "")
    JSON.decode!(output)
  end
end
```

```elixir:test/pluggable_test.exs
defmodule PluggableTest do
  use ExUnit.Case
  doctest Pluggable

  test "run http_get" do
    assert Pluggable.pluggable2() == %{"completed" => false, "id" => 1, "title" => "delectus aut autem", "userId" => 1}
  end
end
```

想定通り、HTTPリクエストを行うことができました。

ちなみに、`Extism.Context.new_plugin` の第三引数をfalseにしたままだと、以下のエラーで失敗します。

```
# WASIを参照できないためエラー
{:error, "unknown import: `wasi_snapshot_preview1::fd_write` has not been defined"}
```

# おわりに

以上、Extismの紹介でした。環境を整えるのが少し大変でしたが、SDK/PDKが使いやすいのですぐになじめそうです。
現在まさに新機能が追加されてきているところなので、今後も触っていきたいと思います。~~今始めればつよつよになれるかも！？~~

- 参考にさせていただいた記事

https://zenn.dev/k41531/articles/3e935bd04968d6

[^langs]: 言語に深い意図はありません。勉強中のElixirと使い慣れているGoを選びました。