Help us understand the problem. What is going on with this article?

Rustで動的ロードによるプラグインシステムを作る

はじめに

この記事はRustその3 Advent Calendar 2019 23日目です。(空いていたので飛び入り参加で)

Rustでプラグインシステムを実装する必要があったので、その時の知見をまとめてサンプルプロジェクトを作ってみました。

ここで言うプラグインシステムとは、例えばGIMPのプラグインのように、バイナリ形式のプラグインを実行時に読み込んで機能追加するものを指します。最近この手の拡張はスクリプト言語でやることが多く、この形式はあまり見かけなくなりました。しかしRustで実装したいならバイナリの動的ロードをするしかないだろう、ということでやってみました。

注意

以下のコードは手元の環境のRust1.40.0では動作していますが、Rustコンパイラが保証していない仮定をいくつか置いているので、動く保証はありません。不具合が致命的になるような環境では使わない方がいいと思います。
詳細はコメント欄をご覧ください。

サンプルプロジェクト

リポジトリはこちらになります。

https://github.com/dalance/rust-plugin-sample

ツール本体とプラグイン二つを含んだワークスペースになっていて、calcが本体でplugin_add/plugin_mulがプラグインです。
電卓プログラムで演算子をプラグインで追加できる、という感じになっています。

実際にプラグインを使っている様子はcalc/src/main.rsにあります。

calc/src/main.rs
fn main() -> Result<()> {
    let mut pm = PluginManager::new();
    pm.load(&PathBuf::from("./target/debug/libplugin_add.so"))?;
    pm.load(&PathBuf::from("./target/debug/libplugin_mul.so"))?;

    for plugin in pm.plugins() {
        println!("Plugin: {}", plugin.name());
        println!("Calc: 1 {} 2 = {}", plugin.operator(), plugin.calc(1, 2));
    }

    Ok(())
}

pm.loadでプラグインのバイナリをロードして、各プラグインのcalcで計算しています。
実行すると

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/calc`
Plugin: Add
Calc: 1 + 2 = 3
Plugin: Mul
Calc: 1 * 2 = 2

となります。

解説

ここから各実装の解説をしていきます。

Pluginトレイト

まず各プラグインが提供する機能をトレイトとして定義します。

calc/src/lib.rs
pub trait Plugin {
    fn name(&self) -> String;
    fn operator(&self) -> String;
    fn calc(&self, a: u32, b: u32) -> u32;
}

電卓として必要なのはcalcだけですが、表示用にStringを返すメソッドも付けてみました。

PluginAdd/PluginMul

定義したトレイトを実装したPluginAdd/PluginMulを定義します。

plugin_add/src/lib.rs
pub struct PluginAdd;

impl Plugin for PluginAdd {
    fn name(&self) -> String {
        String::from("Add")
    }

    fn operator(&self) -> String {
        String::from("+")
    }

    fn calc(&self, a: u32, b: u32) -> u32 {
        a + b
    }
}

ここまでは特に動的ロードに関する部分はない普通のRustプログラムです。

crate-type

普通のRustライブラリはコンパイルすると.rlibという拡張子のファイルが生成されますが、これは静的リンク用のライブラリであり、動的ロードはできません。
そこでCargo.tomlにcrate-typeを書いて生成されるバイナリの形式を変更する必要があります。

Cargo.toml
[lib]
crate-type = ["cdylib"]

ここで選択肢が2つあります(たぶん)。簡単に比較してみると

crate-type 意味 最小サイズ 最大サイズ
dylib Rustの動的ロードライブラリ 小さい 大きい
cdylib Cの動的ロードライブラリ 大きい 小さい

という感じのようです。
RustからRustを呼び出すのでインターフェースがRustでもCでもそれほど違いはないのですが、(現時点のRustコンパイラの実装では)サイズに関する特性が大きく違います。
サンプルではPluginAddをcdylibにPluginMulをdylibにしてみたところ、生成されたバイナリは以下のようになりました。

$ ls -sh target/debug/*.so
2.7M target/debug/libplugin_add.so  624K target/debug/libplugin_mul.so

ほとんど同じ実装なのにサイズが4倍くらい違います。これはcdylibはRustの標準ライブラリを含み、dylibは(ロードされる環境がRustなので標準ライブラリの存在は仮定して)標準ライブラリを含まないからです。
これがcdylibの最小サイズが大きい理由で、どれほど小さいプラグインでも必ず3MBくらいかかってしまいます。
(ここからさらに削る方法はありますがそれは置いておいて)

一方「dylibの最大サイズが大きい」というのは現在のRustコンパイラ実装ではdylibは依存ライブラリの全シンボルを(使っていなくても)含んでしまうためです。今回のサンプルでは依存ライブラリがほとんどないので問題になりませんが、実際のプロジェクトではプラグインのサイズが本体より大きくなったりします。

このあたりは仕様が決まっているわけでもなさそうなのでいまいち判断し難いですが、実プロジェクトの方ではプラグインが大きくなりすぎるのを避けるためにcdylibを選択しました。

PluginManager

次に本体側でプラグインをロードするためにPluginManagerを実装します。
ライブラリの動的ロードを行うクレートとしてlibloadingを使います。

Cargo.toml
[dependencies]
libloading = "*"

まず、PluginManagerの定義です。

calc/src/lib.rs
pub struct PluginManager {
    plugins: Vec<Box<dyn Plugin>>,
    libraries: Vec<Library>,
}

ロードしたPluginを保持するVecと、ロードしたライブラリそのものを保持するVecを持ちます。
実装は次のようになります。

calc/src/lib.rs
impl PluginManager {
    pub fn new() -> Self {
        PluginManager {
            plugins: Vec::new(),
            libraries: Vec::new(),
        }
    }

    pub fn load(&mut self, path: &Path) -> Result<()> {
        let lib = Library::new(path)?;

        let load_plugin: Symbol<extern "C" fn() -> Box<dyn Plugin>> =
            unsafe { lib.get(b"load_plugin") }?;
        let plugin = load_plugin();

        self.plugins.push(plugin);
        self.libraries.push(lib);
        Ok(())
    }

    pub fn plugins(&self) -> &[Box<dyn Plugin>] {
        &self.plugins
    }
}

lib.get(b"load_plugin")でプラグインからload_plugin関数を持ってきて、次の行でその関数を呼んでPluginのポインタを得ます。Pluginを得たらLibraryは不要になるように見えますが、Pluginのバイナリの実体はLibraryが保持しているので、これがDropされるとPluginの関数呼び出しに失敗します。そのためPluginManagerLibraryを保持しておく必要があるわけです。

calc/src/lib.rs
impl Drop for PluginManager {
    fn drop(&mut self) {
        // Plugin drop must be called before Library drop.
        self.plugins.clear();
        self.libraries.clear();
    }
}

最後にPluginManagerにDropを実装します。これはPluginManagerのメンバのDrop順序を制御するためで、もしlibrariesが先にDropされてしまうと、pluginsをDropするときにはすでにDrop用のバイナリは解放されてしまっているのでDropに失敗します。(手元の環境ではSEGVしました)

load_plugin

最後に各プラグインにPluginManagerが呼び出すload_plugin関数を実装します。

plugin_add/src/lib.rs
#[no_mangle]
pub extern "C" fn load_plugin() -> Box<dyn Plugin> {
    Box::new(PluginAdd)
}

Pluginをnewしてポインタで返すだけですが、cdylibで使う場合は#[no_mangle]が必要です。

アロケータの指定

コメント欄でご指摘いただいたのですが、cdylib/staticlib以外のtypeでデフォルトのアロケータが決まっていないので明示した方が良いそうです。今回の場合本体とdylibになっているPluginMulが該当します。

calc/src/main.rs
use std::alloc::System;

#[global_allocator]
static ALLOCATOR: System = System;

Rustコンパイラ間の相互運用性について

Twitterでのコメントを見ていて気付きましたが、重要な前提を書き忘れていました…。
このシステムは本体とプラグインで同じバージョンのRustコンパイラを使う前提です。

本体とプラグインで同じメモリレイアウトやメモリアロケータが採用されていることを想定している箇所がいくつかあり(例えばPluginトレイトオブジェクトのメモリレイアウトなど)、バージョンが食い違うと正しく動かない可能性があります。

もし任意のRustコンパイラ間で相互運用性を保証する必要があるなら、Pluginトレイトのような仕組みは諦めて普通のC-FFIにする必要があるかもしれません。

※コメント欄にC-FFIを使ったバージョンを書いていただきました。ありがとうございます。

まとめ

これでサンプルの電卓が実装できました。
特にcrate-typeあたりの仕様はドキュメントを読んでもいまいちよく分からなかったので、詳しい方がいたら教えてください。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away