---
title: "深入 magnus 以 Rust 為基底的 Ruby Gem 開發"
date: 2024-08-21T00:00:00+08:00
publishDate: 2024-08-21T00:00:00+08:00
lastmod: 2024-08-17T21:56:50+08:00
tags: ["Ruby","Rust","經驗"]
toc: true
permalink: "https://blog.aotoki.me/posts/2024/08/21/deep-into-magnus-to-write-rust-extension-for-ruby/"
language: "zh-tw"
---


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

<!--more-->

## Magnus

[magnus](https://github.com/matsadler/magnus) 基本上可以視為目前撰寫 Ruby 的 Rust Extension 首選，在使用 Bundler 的 Gem 樣板時，預設也會使用 magnus 來做為基底。

```bash
bundle gem --ext=rust demo
```

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

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

```rust
use magnus::{function, prelude::*, Error, Ruby};

fn hello(subject: String) -> String {
    format!("Hello from Rust, {subject}!")
}

#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
    let module = ruby.define_module("Demo")?;
    module.define_singleton_method("hello", function!(hello, 1))?;
    Ok(())
}
```

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

## 物件{#object}

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

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

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

```rust
#[magnus::wrap(class = "Demo::User")]
pub struct RUser {
	name: String;
};

// ...
#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
    let module = ruby.define_module("Demo")?;
    module.define_class("User", ruby.class_object())?;
    Ok(())
}
```

首先我們利用 `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` 的時候進行定義。

```rust
// ...
impl RUser {
    fn new(name: String) -> Self {
        Self { name }
    }
}

// ...
#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
    let module = ruby.define_module("Demo")?;
    module.define_class("User", ruby.class_object())?;
    class.define_singleton_method("new", function!(RUser::new, 1))?;
    Ok(())
}
```

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

> 這是比較簡單的方法，在 Ruby 初始化物件是兩階段的，第一階段在 `.new` 的時候會呼叫 [`.allocate` ](https://ruby-doc.org/3.0.7/Class.html#method-i-allocate) 這個方法分配記憶體，然後再去呼叫物件定義的 `#initialize` 初始化，因為我們的物件實際上是在 Rust 上設計的記憶體，因此會像這樣覆蓋掉原有呼叫 `.allocate` 的行為

## 方法{#method}

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

```rust
// ...
impl RUser {
    fn new(name: String) -> Self {
        Self { name }
    }

    fn name(&self) -> String {
        self.name.clone()
    }
}

#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
    let module = ruby.define_module("Demo")?;
    let class = module.define_class("User", ruby.class_object())?;
    class.define_singleton_method("new", function!(RUser::new, 1))?;
    class.define_method("name", method!(RUser::name, 0))?;
    Ok(())
}
```

跟實作 `.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 特性。

```rust
// TryConvert for String
impl TryConvert for String {
    #[inline]
    fn try_convert(val: Value) -> Result<Self, Error> {
        debug_assert_value!(val);
        RString::try_convert(val)?.to_string()
    }
}

// TryConvert for RString
impl TryConvert for RString {
    fn try_convert(val: Value) -> Result<Self, Error> {
        match Self::from_value(val) {
            Some(i) => Ok(i),
            None => protect(|| {
                debug_assert_value!(val);
                unsafe { Self::from_rb_value_unchecked(rb_str_to_str(val.as_rb_value())) }
            }),
        }
    }
}
```

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

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

## 非 Ruby 物件轉換{#non-ruby-object-convert}

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

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

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

```rust
struct User {
    name: String,
}

impl User {
    fn new(name: String) -> Self {
        Self { name }
    }

    fn to_upper(&self) -> User {
        User {
            name: self.name.to_uppercase(),
        }
    }

    fn name(&self) -> String {
        self.name.clone()
    }
}
```

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

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

```rust
pub struct UserWrapper(User);

impl TryConvert for UserWrapper {
    fn try_convert(value: Value) -> Result<Self, Error> {
        let name = TryConvert::try_convert(value)?;
        Ok(Self(User::new(name)))
    }
}

impl IntoValue for UserWrapper {
    fn into_value_with(self, ruby: &Ruby) -> Value {
        self.0.name().into_value_with(ruby)
    }
}
```

因為我們並不需要 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。

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

```rust
fn transform(user: UserWrapper) -> UserWrapper {
    UserWrapper(user.0.to_upper())
}

#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
    let module = ruby.define_module("Demo")?;
    module.define_singleton_method("transform", function!(transform, 1))?;

    Ok(())
}
```

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

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

透過這樣的機制，我們就不一定需要實作完整的 Ruby Object 也可以快速的跟 Ruby 溝通，這個特性被我應用在 [cedar-policy-rb](https://github.com/elct9620/cedar-policy-rb) 上，讓他可以支援這樣的寫法。

```ruby
entities = CedarPolicy::Entities.new([
    CedarPolicy::Entity.new(
        CedarPolicy::EntityUid.new("User", "1"),
        { role: "admin" },
        [] # Parents' EntityUid
    ),
    {
        uid: { type: "Image", id: "1" },
        attrs: {},
        parents: []
    }
])
```

進入到 Rust 前會統一用 `#to_hash` 轉成普通的 Ruby Hash 後，利用 [magnus_serde](https://github.com/OneSignal/serde-magnus) 序列化成 Rust 的結構，直接被 Cedar 的底層使用。

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

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

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


