4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RustAdvent Calendar 2024

Day 22

Schemeをスクリプト言語としてRustで使う

Last updated at Posted at 2024-12-23

こんにちは.今日は12月22日ですね!これはRustアドベントカレンダー2024、12月22日目の記事です.

Rustはコンパイル系言語で動的にプログラムの動作を変更することが難しいです.この記事ではStak SchemeというRustで書かれた小さなScheme処理系をRustのプログラムに組み込んで、Rustで書かれたプログラムの動作を動的に(プロセスを止めずに)変更します.

以下のコードはStak Schemeのレポジトリのexamples/hot-reloadディレクトリ内にあります.

Schemeとは

SchemeはLisp方言の一つで、一級継続が言語機能として使えることが特徴です.コミュニティベースで仕様の策定が行われており、最新の仕様であるR7RS-smallは約90ページと言語仕様が比較的小さいです.

Stak Schemeとは

Stak SchemeRibbit Schemeをフォークして作られたR7RS標準互換のScheme処理系で、以下の特徴があります.

  • Rustプログラムの中に組み込めるRustで書かれたScheme処理系
  • 小さなメモリフットプリント
  • Capability-based security
    • Stak SchemeのインタプリタはデフォルトでOS等外部に対するAPIを扱えません.
    • I/OやファイルのAPIを有効化するには、それらをインタプリタの仮想マシン(VM)の初期化時に有効化する必要があります.
  • 儂が書いた

RustのプログラムにSchemeスクリプトを埋め込む

今回の例では、RustでHTTPサーバのプログラムを書き、その中にSchemeのスクリプトを組み込みます.

クレートの初期化

初めに、以下のコマンドでHTTPサーバを作るためのバイナリクレートを初期化します.

cargo init http-server
cd http-server

ライブラリの依存関係追加

Stak SchemeをライブラリとしてRustのクレートに追加するためには、以下のコマンドを実行します.

cargo add stak
cargo add --build stak-build

stakクレートはSchemeインタプリタをRustから呼ぶライブラリです.stak-buildクレートはSchemeのスクリプトをRustのコードに埋め込めるようにbuild.rsビルドスクリプト(後述)の中でコンパイルするライブラリです.

HTTPサーバの準備

次に、Rustで書かれたHTTPサーバを準備します.今回は非同期ランタイムであるTokio純正のHTTPライブラリaxumを使ってHTTPサーバを構築します.まず、以下のコマンドで依存関係を追加します.

cargo add --features rt-multi-thread tokio
cargo add axum

以下のコードをsrc/main.rsに追加します.

use axum::{routing::post, serve, Router};
use core::error::Error;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    serve(
        tokio::net::TcpListener::bind("0.0.0.0:3000").await?,
        Router::new().route("/calculate", post("Hello, world!")),
    )
    .await?;

    Ok(())
}

curlコマンドでHTTPリクエストを送り、動作を確認します.

cargo run &
curl -f -X POST http://localhost:3000/calculate # -> Hello, world!
kill %1

ビルドスクリプトの追加

Stak SchemeではSchemeスクリプトを.scmファイル拡張子を付けてsrcディレクトリ内に追加します.このとき、これらのスクリプトファイルがRustのプログラムに直接埋め込まれる訳ではなく、一度これらのファイルをバイトコードに変換する必要があります.そのために、先述したstak-buildクレートを使い、以下のコードをbuild.rsファイルに追加します.

use stak_build::{build_r7rs, BuildError};

fn main() -> Result<(), BuildError> {
    build_r7rs()
}

これでcargo build実行時にSchemeファイルがバイトコードに変換されtargetディレクトリ内に保存されます.

Schemeスクリプトによるリクエストハンドラの作成

次に、Schemeのスクリプトをsrcディレクトリに追加し、それをHTTPリクエストのハンドラとして使います.以下のコードをsrc/handler.scmファイルに追加します.

(import
  (scheme base)
  (scheme read)
  (scheme write))

(write (apply + (read)))

readはS式を標準入力からパースする関数、writeは値を標準出力に書き出す関数です.(apply + xs)の式でリストxs内の数値の和を計算します.

次に、Rustから上記のスクリプトを参照し実行します.以下のコードをsrc/main.rsファイルに追加します.

// 他の`use`ステートメント...
use axum::{http::StatusCode, response};
use stak::{
    device::ReadWriteDevice,
    file::VoidFileSystem,
    include_module,
    module::{Module, UniversalModule},
    process_context::VoidProcessContext,
    r7rs::{SmallError, SmallPrimitiveSet},
    time::VoidClock,
    vm::Vm,
};

// `main`関数など...

// Scheme実行時のヒープサイズ
const HEAP_SIZE: usize = 1 << 16;

// Schemeスクリプトをインポートする.
// 実際には、Rustのプログラム内にバイトコードとして埋め込まれる.
static MODULE: UniversalModule = include_module!("handler.scm");

async fn calculate(input: String) -> response::Result<(StatusCode, String)> {
    // インメモリ標準出力と標準エラーのためのバッファの準備
    let mut output = vec![];
    let mut error = vec![];

    run_scheme(
        &MODULE.bytecode(),
        input.as_bytes(),
        &mut output,
        &mut error,
    )
    .map_err(|error| error.to_string())?;

    let error = decode_buffer(error)?;

    Ok(if error.is_empty() {
        (StatusCode::OK, decode_buffer(output)?)
    } else {
        (StatusCode::BAD_REQUEST, error)
    })
}

/// Schemeのプログラムを実行する.
fn run_scheme(
    bytecodes: &[u8],
    input: &[u8],
    output: &mut Vec<u8>,
    error: &mut Vec<u8>,
) -> Result<(), SmallError> {
    // Schemeのためのヒープメモリの初期化.この場合、Rust側ではスタック上に確保される.
    let mut heap = [Default::default(); HEAP_SIZE];
    // Schemeインタプリタの仮想マシン(VM)の初期化
    let mut vm = Vm::new(
        &mut heap,
        // R7RS標準準拠のプリミティブ関数の初期化
        SmallPrimitiveSet::new(
            ReadWriteDevice::new(input, output, error),
            // 標準入出力以外のプリミティブは必要ないので今回は無効化する.
            VoidFileSystem::new(),
            VoidProcessContext::new(),
            VoidClock::new(),
        ),
    )?;

    // VMをバイトコードで初期化する.
    vm.initialize(bytecodes.iter().copied())?;
    // バイトコードをVM上で実行する.
    vm.run()
}

/// 標準出力や標準エラーのバッファを文字列に変換する.
fn decode_buffer(buffer: Vec<u8>) -> response::Result<String> {
    Ok(String::from_utf8(buffer).map_err(|error| error.to_string())?)
}

また、main関数を以下のように変更します.

  #[tokio::main]
  async fn main() -> Result<(), Box<dyn Error>> {
      serve(
          tokio::net::TcpListener::bind("0.0.0.0:3000").await?,
-         Router::new().route("/calculate", post("Hello, world!")),
+         Router::new().route("/calculate", post(calculate)),
      )
      .await?;

      Ok(())
  }

curlコマンドを使って動作を確認します.

cargo run &
curl -f -X POST --data '(1 2 3 4 5)' http://localhost:3000/calculate # -> 15
kill %1

Rustプログラムの中でSchemeのスクリプトが実行され、渡したリスト内の数値の和が計算されたことが確認できました.

Hot module reloading

JavaScriptのバンドラ(WebpackやVite等)には、hot module reloadingという機能があります.これは変更されたソースファイルの内容を動作中のHTTPサーバ等に動的に反映させる機能です.

Stak Schemeにも同様の機能があります.それを用いてHTTPサーバの動作を動的に変更します.まず、Cargo.tomlファイルの中でhot-reloadフィーチャをstakクレートに対して有効化します.

[dependencies]
stak = { version = "0.4.1", features = ["hot-reload"] }

次に、HTTPサーバを再起動します.

# 既に起動している場合、サーバのプロセスを止める.
cargo run &

試しに、curlコマンドを使って現在の動作を確認します.

curl -f -X POST --data '(1 2 3 4 5)' http://localhost:3000/calculate # -> 15

次に、先ほどのhandler.scmファイルの中で和を計算していたコードを積を計算するように変更します.

+ (write (apply + (read)))
- (write (apply * (read)))

サーバを再起動せずに、cargoコマンドを使ってSchemeスクリプトの再ビルドを行います.

cargo build

再び、curlコマンドを使って結果を確認します.

curl -f -X POST --data '(1 2 3 4 5)' http://localhost:3000/calculate # -> 720

先程と異なり、リスト内の値の積が返されたことが確認できました.

_人人人人人人_
> 突然の積 <
 ̄Y^Y^Y^Y^Y^ ̄

まとめ

  • Stak Schemeを使ってRustプログラムの動作を動的に変更しました
  • Schemeはいいぞ

今後の展望

  • RustとScheme間でのデータ型の相互運用性の改善
    • 現在は標準入出力しかRustとScheme間の通信方法がありません.
  • より簡単なhot module reloadingの有効化方法
    • 自分でcargo buildするの面倒ですね

謝辞

yharaさん、monochromeさん、プログラミング処理系Zulipコミュニティの方々にお世話になりました.ありがとうございました.

参考

  • あまりメモリフットプリント・標準準拠等を気にしないのであれば、もっとリッチなRust製Scheme処理系もあります.
  • Luaやmrubyも同様の用途によく使われます
  • 少々目的が異なりますが、小さなWASMインタプリタと静的型付言語を含む適当な高級言語のWASMコンパイラを使えば似たようなことができると思います.ただ、自分でグルーコードを書く必要があります.
4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?