はじめに
この記事はRustその3 Advent Calendar 2019 23日目です。(空いていたので飛び入り参加で)
Rustでプラグインシステムを実装する必要があったので、その時の知見をまとめてサンプルプロジェクトを作ってみました。
ここで言うプラグインシステムとは、例えばGIMPのプラグインのように、バイナリ形式のプラグインを実行時に読み込んで機能追加するものを指します。最近この手の拡張はスクリプト言語でやることが多く、この形式はあまり見かけなくなりました。しかしRustで実装したいならバイナリの動的ロードをするしかないだろう、ということでやってみました。
注意
以下のコードは手元の環境のRust1.40.0では動作していますが、Rustコンパイラが保証していない仮定をいくつか置いているので、動く保証はありません。不具合が致命的になるような環境では使わない方がいいと思います。
詳細はコメント欄をご覧ください。
コメントは消えてしまったので安全性に関しては以下の記事もご覧ください。
サンプルプロジェクト
リポジトリはこちらになります。
ツール本体とプラグイン二つを含んだワークスペースになっていて、calcが本体でplugin_add/plugin_mulがプラグインです。
電卓プログラムで演算子をプラグインで追加できる、という感じになっています。
実際にプラグインを使っている様子は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
トレイト
まず各プラグインが提供する機能をトレイトとして定義します。
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
を定義します。
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を書いて生成されるバイナリの形式を変更する必要があります。
[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を使います。
[dependencies]
libloading = "*"
まず、PluginManager
の定義です。
pub struct PluginManager {
plugins: Vec<Box<dyn Plugin>>,
libraries: Vec<Library>,
}
ロードしたPlugin
を保持するVec
と、ロードしたライブラリそのものを保持するVec
を持ちます。
実装は次のようになります。
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
の関数呼び出しに失敗します。そのためPluginManager
にLibrary
を保持しておく必要があるわけです。
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
関数を実装します。
#[no_mangle]
pub extern "C" fn load_plugin() -> Box<dyn Plugin> {
Box::new(PluginAdd)
}
Pluginをnewしてポインタで返すだけですが、cdylibで使う場合は#[no_mangle]
が必要です。
アロケータの指定
コメント欄でご指摘いただいたのですが、cdylib/staticlib以外のtypeでデフォルトのアロケータが決まっていないので明示した方が良いそうです。今回の場合本体とdylibになっているPluginMul
が該当します。
use std::alloc::System;
#[global_allocator]
static ALLOCATOR: System = System;
Rustコンパイラ間の相互運用性について
Twitterでのコメントを見ていて気付きましたが、重要な前提を書き忘れていました…。
このシステムは本体とプラグインで同じバージョンのRustコンパイラを使う前提です。
本体とプラグインで同じメモリレイアウトやメモリアロケータが採用されていることを想定している箇所がいくつかあり(例えばPlugin
トレイトオブジェクトのメモリレイアウトなど)、バージョンが食い違うと正しく動かない可能性があります。
もし任意のRustコンパイラ間で相互運用性を保証する必要があるなら、Plugin
トレイトのような仕組みは諦めて普通のC-FFIにする必要があるかもしれません。
※コメント欄にC-FFIを使ったバージョンを書いていただきました。ありがとうございます。
リンク
本記事の派生記事へのリンクを載せておきます。いずれも
まとめ
これでサンプルの電卓が実装できました。
特にcrate-typeあたりの仕様はドキュメントを読んでもいまいちよく分からなかったので、詳しい方がいたら教えてください。