蒼時弦也
蒼時弦也
資深軟體工程師
發表於

深入 magnus 以 Rust 為基底的 Ruby Gem 開發

最近因為工作的關係稍微回顧了一下 Open Policy Agent 而發現了 AWS 推出的 Cedar Language 更適合在軟體應用上實現類似 AWS IAM 的 Policy(政策)機制,因為是以 Rust 為基底,為了讓 Ruby 可以使用,就決定嘗試使用 Rust 來撰寫 Extension。

Magnus

magnus 基本上可以視為目前撰寫 Ruby 的 Rust Extension 首選,在使用 Bundler 的 Gem 樣板時,預設也會使用 magnus 來做為基底。

1bundle gem --ext=rust demo

產生的 demo/ 目錄除了原本 Ruby Gem 會有的 lib/ 目錄外,也會有一個用於存放 Rust 程式碼的 ext/ 目錄,會有多少使用 Rust 來撰寫,則取決於功能而定,有時候太依賴 Rust 可能會是缺點。

magnus 對 CRuby 的 API 實作基本上是一致的,如果已經有經驗並不會感覺到太陌生,預設的樣板會在 Ruby 裡面定義一個 Demo.hello("Aotoki") 的方法。

 1use magnus::{function, prelude::*, Error, Ruby};
 2
 3fn hello(subject: String) -> String {
 4    format!("Hello from Rust, {subject}!")
 5}
 6
 7#[magnus::init]
 8fn init(ruby: &Ruby) -> Result<(), Error> {
 9    let module = ruby.define_module("Demo")?;
10    module.define_singleton_method("hello", function!(hello, 1))?;
11    Ok(())
12}

因為有許多方便的設計,讓使用 Rust 撰寫 Extension 並不會太複雜,我們通常只會用到 define_method 將我們需要的方法定義上去即可。

物件

若要讓 Ruby 可以使用 Rust 的功能,最簡單的就是透過 define_method 或者 define_singleton_method 來實現,然而 Ruby 仍是物件導向語言,因此我們通常都會以物件為基礎來進行開發,那麼就需要能讓 Ruby 理解某個 Rust 物件的存在。

在 Rust 中沒有類別的概念,是以 Struct(結構)跟對某個結構實作的 Trait(特性)為主,這樣的情境我們可以把它視為等同於 Ruby 物件。

magnus 利用 Rust 的 Macro(巨集)機制預先幫我們處理好了大量 Ruby 物件定義相關的實作跟行為,因此我們只需要利用以下的方式就可以定義一個 Ruby 物件。

 1#[magnus::wrap(class = "Demo::User")]
 2pub struct RUser {
 3	name: String;
 4};
 5
 6// ...
 7#[magnus::init]
 8fn init(ruby: &Ruby) -> Result<(), Error> {
 9    let module = ruby.define_module("Demo")?;
10    module.define_class("User", ruby.class_object())?;
11    Ok(())
12}

首先我們利用 magnus::wrap 這個 Macro 讓 magnus 幫我們將 Rust 轉換成一個 Ruby Value 的相關特性都實作出來,並且在轉換時會使用 Ruby 中登記的 Demo::User 這個 Class(類別)

然而,Ruby 實際上並不知道有 Demo::User 的存在,因此我們仍需要在 magnus::init 這個 Rust Extension 初始化的位置,定義 Demo Module(模組)以及 User Class 才能在後續的行為中應用。

同時對 Ruby 來說無法直接知道 RUser 在 Rust 中需要有一個 name: String 的記憶體,因此我們需要對 Ruby 的 .new 方法,也就是 Demo::User.new 的時候進行定義。

 1// ...
 2impl RUser {
 3    fn new(name: String) -> Self {
 4        Self { name }
 5    }
 6}
 7
 8// ...
 9#[magnus::init]
10fn init(ruby: &Ruby) -> Result<(), Error> {
11    let module = ruby.define_module("Demo")?;
12    module.define_class("User", ruby.class_object())?;
13    class.define_singleton_method("new", function!(RUser::new, 1))?;
14    Ok(())
15}

上述的範例我們對 Demo::User.new 進行定義,覆蓋掉原本 Ruby 所定義的 .new 方法,改為我們在 Rust 定義的方法,此時 magnus 的 function Macro 會幫我們自動的將 Ruby Value 轉換為 Rust 的 String 並且傳進來給我們,那麼就可以利用這個參數來初始化 Rust Sturct 的記憶體。

這是比較簡單的方法,在 Ruby 初始化物件是兩階段的,第一階段在 .new 的時候會呼叫 .allocate 這個方法分配記憶體,然後再去呼叫物件定義的 #initialize 初始化,因為我們的物件實際上是在 Rust 上設計的記憶體,因此會像這樣覆蓋掉原有呼叫 .allocate 的行為

方法

當我們可以順利的在 Ruby 使用 Demo::User 物件後,要拿到保存在 Rust 中的資料,還需要做一些處理,此時我們會需要定義一些方法到物件上。

 1// ...
 2impl RUser {
 3    fn new(name: String) -> Self {
 4        Self { name }
 5    }
 6
 7    fn name(&self) -> String {
 8        self.name.clone()
 9    }
10}
11
12#[magnus::init]
13fn init(ruby: &Ruby) -> Result<(), Error> {
14    let module = ruby.define_module("Demo")?;
15    let class = module.define_class("User", ruby.class_object())?;
16    class.define_singleton_method("new", function!(RUser::new, 1))?;
17    class.define_method("name", method!(RUser::name, 0))?;
18    Ok(())
19}

跟實作 .new 的方式類似,我們新增一個 name() 方法,然後利用 define_methodDemo::User 上定義 #name 方法即可。

因為 Rust 預設是 Immutable(不可變)的,在我們回傳 name 的時候使用 &str 指標就會遇到生命週期的問題,通常會直接使用 clone 的方式複製一份。

method Macro 跟 function 不同的地方在於他會考量到 &self 指標,也就是指向 RUser 這個 Struct 的指標,因此我們第一個參數就必須是 &self 從這點來看,就很類似 Ruby 的 Singleton Method 和 Class Method 的差異。

上述這些都是相對基本的應用方式,因為 magnus 已經幫我們做完許多隱含的處理,因此 String 這類資料類型都可以很簡單的轉換,然而在一些比較複雜的情境就不一定那麼好處理。

TryConvert & IntoValue

要讓 Ruby 和 Rust 可以溝通,magnus 設計了兩種特性來處理這個行為,能夠將 Ruby Value 變成某種 Rust Struct 或者將這個 Struct 轉換成一個 Ruby Value。

Ruby Value 在目前的 CRuby 的設計中,變數是一個叫做 VALUE 的指標,會指向一個 Ruby 變數,可能會是一個 Class(RClass) 或者某個字串(RString)因此在我們進行 Ruby 的方法呼叫時是以 VALUE 這個指標進行傳遞,再由方法本身解析。

那麼,我們在定義物件和方法時,直接使用 Rust 的 String 為什麼沒有出錯呢?這是因為 magnus 已經事先幫我們定義好 String 的 TryConver 和 IntoValue 特性。

 1// TryConvert for String
 2impl TryConvert for String {
 3    #[inline]
 4    fn try_convert(val: Value) -> Result<Self, Error> {
 5        debug_assert_value!(val);
 6        RString::try_convert(val)?.to_string()
 7    }
 8}
 9
10// TryConvert for RString
11impl TryConvert for RString {
12    fn try_convert(val: Value) -> Result<Self, Error> {
13        match Self::from_value(val) {
14            Some(i) => Ok(i),
15            None => protect(|| {
16                debug_assert_value!(val);
17                unsafe { Self::from_rb_value_unchecked(rb_str_to_str(val.as_rb_value())) }
18            }),
19        }
20    }
21}

此時,當我們使用 function 或者 method Macro 時,就會呼叫 TryConvert::try_convert 特性,幫我們自動以 Value -> RString -> String 的順序轉換成 Rust 可以直接使用的資料結構。

同樣的 IntoValue 特性則是反向的處理,當我們需要一個 Value 時,則會以 String -> RString -> Value 的順序轉換為 Ruby 的 String。

非 Ruby 物件轉換

有些情境我們並不需要將所有 Rust Struct 封裝成一個 Ruby Object 來使用,這會讓使用者操作過多的低階行為,不但增加使用的複雜度也會讓我們浪費大量運算資源在 Rust 和 Ruby 之間的轉換。

但我們可能還是會需要在處理某個 Rust 的功能時,直接以 Ruby Value 傳入來處理,這個時候 TryConverIntoValue 特性就會變得非常有用。

舉例來說,有一個第三方套件提供了一個 User 的物件。

 1struct User {
 2    name: String,
 3}
 4
 5impl User {
 6    fn new(name: String) -> Self {
 7        Self { name }
 8    }
 9
10    fn to_upper(&self) -> User {
11        User {
12            name: self.name.to_uppercase(),
13        }
14    }
15
16    fn name(&self) -> String {
17        self.name.clone()
18    }
19}

當我們想要直接使用 to_upper 方法的特性,可以在我們的 Rust Extension 中利用 Wrapper 的技巧封裝。

因為 Rust 不允許對外部的套件實作特性,因此我們會製作一個只有外部套件的 Struct 來達成 Wrapper 的效果。

 1pub struct UserWrapper(User);
 2
 3impl TryConvert for UserWrapper {
 4    fn try_convert(value: Value) -> Result<Self, Error> {
 5        let name = TryConvert::try_convert(value)?;
 6        Ok(Self(User::new(name)))
 7    }
 8}
 9
10impl IntoValue for UserWrapper {
11    fn into_value_with(self, ruby: &Ruby) -> Value {
12        self.0.name().into_value_with(ruby)
13    }
14}

因為我們並不需要 Ruby 中有實質的 User 物件,因此我們不會使用 magnus 的 Macro 來封裝成物件,取而代之的是實作 TryConvertIntoValue 特性描述我們是怎麼轉換成 Rust 內部的 UserWrapper 的。

上述的程式碼利用 Rust 編譯器推導型別的機制,判斷需要有一個 String 的輸出,因此 TryConvert::try_convert(value) 最終會回傳一個 String 給我們,就可以用於 User::new 來製作 UserWrapper 出來。

當我們需要回傳給 Ruby 時,因為只需要轉換過的 name 內容,因此在 IntoValue 時可以直接使用 into_value_with 觸發 StringIntoValue 特性將 String 轉換成 Value 提供給 Ruby。

這些封裝準備完善後,我們在實際的方法定義就可以非常簡單。

 1fn transform(user: UserWrapper) -> UserWrapper {
 2    UserWrapper(user.0.to_upper())
 3}
 4
 5#[magnus::init]
 6fn init(ruby: &Ruby) -> Result<(), Error> {
 7    let module = ruby.define_module("Demo")?;
 8    module.define_singleton_method("transform", function!(transform, 1))?;
 9
10    Ok(())
11}

因為 TryConvert 特性的關係,Rust 可以了解到 UserWrapper 是如何從 Ruby 的變數轉換而來,我們就可以順利的拿到 UserWrapper 作為參數。

為了回傳 to_upper 的結果,我們要提取 UserWrapper 原有的 User 呼叫方法,並且重新包裝成 UserWrapper 來使用,此時 Rust 就能透過 IntoValue 的特性了解到要如何轉換成一個 Ruby Value 回傳。

透過這樣的機制,我們就不一定需要實作完整的 Ruby Object 也可以快速的跟 Ruby 溝通,這個特性被我應用在 cedar-policy-rb 上,讓他可以支援這樣的寫法。

 1entities = CedarPolicy::Entities.new([
 2    CedarPolicy::Entity.new(
 3        CedarPolicy::EntityUid.new("User", "1"),
 4        { role: "admin" },
 5        [] # Parents' EntityUid
 6    ),
 7    {
 8        uid: { type: "Image", id: "1" },
 9        attrs: {},
10        parents: []
11    }
12])

進入到 Rust 前會統一用 #to_hash 轉成普通的 Ruby Hash 後,利用 magnus_serde 序列化成 Rust 的結構,直接被 Cedar 的底層使用。

這樣的優點在於,原本 Cedar FFI 是以 JSON 來交換資料的,這表示我們需要透過 Ruby -> JSON -> Rust 這樣的順序轉換。

直接利用 Serializer 時,就是 Ruby -> Rust 的直接交換,少了一次 JSON 的處理,就能獲得更好的資料處理速度,即使不管怎樣都還是躲不掉一次處理,但比兩次處理還要好上不少。

magnus 還實現了非常多不同特性跟方法,然而只要掌握上述幾種情境,就可以很輕鬆的在 Ruby 使用各種 Rust 套件的好處