最近因為工作的關係稍微回顧了一下 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_method
在 Demo::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 傳入來處理,這個時候 TryConver
和 IntoValue
特性就會變得非常有用。
舉例來說,有一個第三方套件提供了一個 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 來封裝成物件,取而代之的是實作 TryConvert
和 IntoValue
特性描述我們是怎麼轉換成 Rust 內部的 UserWrapper
的。
上述的程式碼利用 Rust 編譯器推導型別的機制,判斷需要有一個 String
的輸出,因此 TryConvert::try_convert(value)
最終會回傳一個 String
給我們,就可以用於 User::new
來製作 UserWrapper
出來。
當我們需要回傳給 Ruby 時,因為只需要轉換過的 name
內容,因此在 IntoValue
時可以直接使用 into_value_with
觸發 String
的 IntoValue
特性將 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 套件的好處