跳至主要內容
蒼時弦也
蒼時弦也
資深軟體工程師
發表於

Kobako:執行腳本

這篇文章是 Kobako 系列的一部分。

當我們有了標準輸出能力後,執行腳本是否正常就可以順利地被驗證,這表示我們可以開始真正的來把 Ruby 跑在 Kobako 的沙盒(Sandbox)環境下。

介面設計

最初使用了 #run 來跑一段 Ruby 的腳本,但是在經過幾次使用之後,我開始思考這樣真的適合嗎?如果每次想在 Kobako 跑腳本,卻需要組合一大段程式碼到裡面,這樣真的合理嗎?

舉例來說,我有一個常用的模組。

1module Formatter
2  module_function
3
4  def tag(name, *messages)
5    "[#{name}] #{messages.join(" ")}"
6  end
7end

假設用 #run 來跑,就會變成

 1sandbox.run(<<~SANDBOX)
 2module Formatter
 3  module_function
 4
 5  def tag(name, *messages)
 6    "[#{name}] #{messages.join(" ")}"
 7  end
 8end
 9
10puts Formatter.tag("info", "Hello World")
11SANDBOX

這表示我需要每次都「複製」一次 Formatter 的程式碼,這並不方便,經過評估後,決定加入 #preload 的概念。

 1sandbox.preload(<<~SANDBOX)
 2module Formatter
 3  module_function
 4
 5  def tag(name, *messages)
 6    "[#{name}] #{messages.join(" ")}"
 7  end
 8end
 9SANDBOX
10
11sandbox.run("puts Formatter.tag('info', 'Hello World')")

這樣一來,每次跑的時候,就不用反覆的把 Formatter 放到 #run 裡面,只需要先用 #preload 放到裡面就可以。

片段機制

會這樣設計,實際上也是非常合理的。在原本的 Kobako 設計中之所以只有 #run 的理由,是因為 Kobako 的設計是採取「拋棄式使用」的角度思考的。

1# 樣板
2sandbox = Sandbox.new
3# ...
4
5# mrb_state(獨立)
6sandbox.run("true")
7
8# mrb_state(獨立)
9sandbox.run("true")

一個 mrb_state 就是一個完整的 mruby VM(虛擬機器,Virtual Machine)因此兩次 #run 之間不會互相繼承,這是造成需要每一次執行都複製 Formatter 的理由。

以給 AI 使用的沙盒來說是相對乾淨的處理,因此不會互相污染也沒有狀態問題,那麼就不容易發生問題,但相對的使用起來就不那麼好用。

因此 #preload 實際上非常簡單,基本上是這樣實作的。

 1class Sandbox
 2  attr_reader :snippets
 3
 4  def initialize
 5    # ...
 6    @snippets = []
 7  end
 8
 9  def preload(snippet)
10    @snippets << snippet
11  end
12
13  def run(code)
14    vm.run([*@snippets, code].join("\n\n"))
15  end
16end

只要事先把要預先載入的程式碼片段保存起來,真的要執行的時候再組合回去就可以達到類似的效果。

Run 和 Eval

在使用了 #preload 後,我再次調整為 #eval#run 兩個方法,理由是我們可以有更多不同的應用方式,像是模擬 Rack 的設計。

1sandbox.preload(<<~SANDBOX)
2class MyApp
3  def self.call(env)
4    puts "Hello, #{env[:name]}"
5  end
6end
7SANDBOX
8
9sandbox.run(:MyApp, name: "Aotoki")

相對的,原本的 #run 則改為 #eval 更貼近執行一段程式碼的意義。

1sandbox.eval(<<~SANDBOX)
2  puts "Hello World"
3SANDBOX

但這樣明顯會有一個問題,我們怎麼讓 WebAssembly 內的 mrb_state 知道要呼叫 MyApp.call(...) 呢?

這就會應用到 WebAssembly 的記憶體交換的技巧來處理,但是是從相反的方向來呼叫,利用 WebAssembly 的 Host Function 能力,在 #run 或者 #eval 呼叫時,都引導到一個「啟動 mruby 環境」的方法,來統一處理。

因為需要借助 Rust 來處理,因此實際上是將 #run#eval 對應到 Ruby 的方法,並且共用一個 boot 啟動方法,把登記的片段(Snippet)一次就全部載入到裡面。

 1fn boot(snippets: &[Snippet]) -> Result<Mrb, Exc> {
 2    let mrb = Mrb::open();
 3    for s in snippets {
 4        mrb.load(&format!("(snippet:{})", s.name()), s.body());
 5        if let Some(e) = mrb.take_exception() { return Err(e); }
 6    }
 7    Ok(mrb)
 8}
 9
10fn outcome(mrb: &Mrb, value: Val) -> Outcome {
11    match mrb.take_exception() {
12        Some(e) => Outcome::Panic(e),
13        None => Outcome::Value(mrb.to_wire(value)),
14    }
15}
16
17fn eval(snippets: &[Snippet], source: &[u8]) -> Outcome {
18    let mrb = match boot(snippets) { Ok(m) => m, Err(e) => return Outcome::Panic(e) };
19
20    let value = mrb.load("(eval)", source);
21    outcome(&mrb, value)
22}
23
24fn run(snippets: &[Snippet], target: &str, args: Vec<Val>, kwargs: Option<Val>) -> Outcome {
25    let mrb = match boot(snippets) { Ok(m) => m, Err(e) => return Outcome::Panic(e) };
26
27    let Some(entrypoint) = mrb.top_level_const(target) else {
28        return Outcome::Panic(Exc::sandbox_error(format!("undefined entrypoint: {}", target)));
29    };
30    if !mrb.respond_to(&entrypoint, "call") {
31        return Outcome::Panic(Exc::sandbox_error(format!("entrypoint {} does not respond to :call", target)));
32    }
33
34    let mut argv = args;
35    argv.extend(kwargs);
36    let value = mrb.funcall(&entrypoint, "call", &argv);
37    outcome(&mrb, value)
38}

#eval 不同的地方在於 #run 會直接透過 mruby 的 C API 去確認使用者指定的那個物件,是否具備一個可以呼叫(#call)的方法存在,如果存在的話就把宿主(Host)帶入的參數,轉換成 mruby 可以讀懂的格式,傳遞進去。

到目前為止,Kobako 的設計並沒有太多複雜或者困難的設計,都是基於常見的方法呼叫、變數保存等機制,但就是這樣一步一步評估每一個使用情境,才得以逐漸成為可以使用的沙盒環境。