🍎

Rust で callback function を設定する callback_fn の紹介

2024/05/07に公開
6

Rust で callback function を設定するやさしい方法

はじめに

Rust で関数に callback function を追加するマクロ callback_fn 作成しました。
この記事ではマクロの魅力をお届けしようと思います。

https://github.com/poi2/callback_fn

callback_fn

callback_fn は Rust の関数を拡張するマクロです。
attribute 内で指定した関数を対象の関数の前後に追加します。

インストール

Cargo.tomlcallback_fn を追加してください。

[dependencies]
callback_fn = "0.1.0"

使い方

よくある Hello world をする関数があったとします。
この関数の前後に timestamp を出力し、Hello world の実行時間を計測したいとします。
素朴にコーディングすると以下のようなコードになります。

fn hello(str: &str) {
    println!("{}", chrono::Local::now());
    println!("Hello {}", str);
    println!("{}", chrono::Local::now());
}

もし callback_fn を使っていたら、以下のように書くことができます。

// 関数の前に追加するには before_callback を使います
#[before_callback(my_logger())]
// 関数の後ろに追加するには after_callback を使います
#[after_callback(my_logger())]
fn hello(str: &str) {
    println!("Hello {}", str);
}

fn my_logger() {
    println!("{}", chrono::Local::now());
}

このように書くと、hello の前後に my_logger が追加され、実行すると以下のような出力が得られます。

hello("world");

// 以下のような出力が得られる
// 2024-04-01T00:00:000.000000+09:00
// Hello world
// 2024-04-01T00:00:000.000100+09:00

もし前後に同じ関数を追加したい場合は、around_callback を使うと便利です。

use callback_fn::around_callback;

// 関数の前後に追加するには around_callback を使います
#[around_callback(my_logger())]
fn hello(str: &str) {
    println!("Hello {}", str);
}

ただまあ、このような単純な例だとありがたみはないかもしれませんね。

認証

より具体的な例として、Web サービスでの認証の例で考えてみましょう。
Rust で作られた Web サービスのユースケースにおいて、認可の仕組みを追加したいとします。
今回の例では記事の取得には Read 権限が、記事の作成には Create 権限がそれぞれ必要だとしましょう。
before_callbackhas_permission を指定し、引数に current_user と必要な Permission を指定します。

use callback_fn::before_callback;

// 記事の取得にはユーザーは Read 権限が必要
#[before_callback(has_permission(current_user, Permission::ReadArticle).map_err(UseCaseError::from)?)]
async  fn get_article_by_id(current_user: &User, id: usize) -> Result<Article, UseCaseError> {
    let article = get_article(id).await?;
    Ok(article)
}

// 記事の作成にはユーザーは Create 権限が必要
#[before_callback(has_permission(current_user, Permission::CreateArticle).map_err(UseCaseError::from)?)]
async fn create_article(current_user: &User, title: String, body: String) -> Result<Article, UseCaseError> {
    let article = Article { title, body };
    create_article(article.clone()).await?;
    Ok(article)
}

// ユーザーの権限をチェックする関数
fn has_permission(user: &User, permission: Permission) -> Result<(), PermissionError> {
    if user.permissions.contains(&permission) {
        Ok(())
    } else {
        Err(PermissionError::PermissionDenied(permission))
    }
}

このように書くことで、記事の取得や作成のメインロジックが呼び出される前に認可のチェックが行われます。
認可に失敗した場合 has_permissionPermissionError を返すのですが、これを呼び出すユースケースの戻り値のエラー型は UseCaseError になっているため、ないもしないと型のミスマッチが発生してます。
callback_fn の attribute は Rust のコードを書くことができるので、map_err を使って UseCaseError に変換することができます。
実際の Web サービスで考えると、各ユースケースは戻り値のエラー型は同じとは限りませんから、このように柔軟に対応できると実際に利用する際はとても便利です。

契約プログラミング

続いて契約プログラミングの例で考えてみましょう。
契約プログラミングとはソフトウェアの設計・実装方法のひとつで、関数やメソッドの不変条件(invariants)や事前条件(precondition)、事後条件(postcondition)を定義し、それを満たすことを保証するプログラミング手法です。
詳細な内容は Wikipedia の契約プログラミングの記事に譲るとして、これを callback_fn で実現しましょう。

以下の例では EC サイトにおいて、カートに含まれるアイテムの合計金額が正しいことを保証する仕組みを callback_fn で実現しています。

use callback_fn::around_callback;

struct Cart {
    total_price: usize,
    items: Vec<Item>,
}
struct Item {
    price: usize,
}

impl Cart {
    fn new() -> Self {
        Self {
            total_price: 0,
            items: vec![],
        }
    }

    // add_item の前後で合計金額が正しいことを保証する
    #[around_callback(self.ensure_total_price()?)]
    fn add_item(&mut self, item: Item) -> Result<(), String> {
        self.items.push(item);
        self.update_total_price();
        Ok(())
    }

    fn update_total_price(&mut self) {
        self.total_price = self.items.iter().map(|item| item.price).sum()
    }

    fn ensure_total_price(&self) -> Result<(), String> {
        if self.total_price == self.items.iter().map(|item| item.price).sum() {
            Ok(())
        } else {
            Err("Total price is not correct".to_string())
        }
    }
}

add_item はアイテムをカートに追加するメソッドですが、このメソッドの前後に ensure_total_price を追加しています。
add_item の中で update_total_price が呼ばれているため、カートに含まれるアイテムの合計金額とカートの合計金額の整合性は取れています。
もし add_item を呼び出す前や呼び出した後に整合性が取れていなければエラーが発生し、処理を失敗させます。

この状態でリリースをすると、毎回 ensure_total_price が呼ばれるため、パフォーマンスが低下する可能性があります。
場合によってはテスト時のみで有効にし、本番では無効にしたいという要望もあるでしょう。
その要件は cfg_attr(ref: The cfg_attr attribute)を使うことでシンプルに実現できます。

impl Cart {
    // ...
    #[cfg_attr(test, around_callback(self.ensure_total_price()?))]
    fn add_item(&mut self, item: Item) -> Result<(), String> {
        self.items.push(item);
        self.update_total_price();
        Ok(())
    }
    // ...
}

まとめ

Rust の関数に callback function を設定するマクロ callback_fn の紹介をしました。
これを使うとシステムの要件をよりシンプルに表現でき、そして柔軟に対応できるようになります。
ぜひお試しください。

バグや要望があればコメントか issue に書いていただけると幸いです。

Discussion

kanaruskanarus
typo
  • インストール

    `callback_fn` = "0.1.0"

    callback_fn = "0.1.0"


  • 契約プログラミング > 以下の例では EC サイトにおいて...

    保障

    → 保証

TaqqnTaqqn

実装を拝見し、proc_macroの勉強になりました!
macro_rules! で作ることは難しいから、proc_macroを利用されたのでしょうか。
もしそうなら、macro_rules! でやるとどこが詰まるか教えていただけたら嬉しいです。

kanaruskanarus

( macro_rules! では attribute は作れないですよね…?)

TaqqnTaqqn

それもそうでした。ありがとうございます!

daisuke itodaisuke ito

興味深い質問ありがとうございます。
たしかに declarative macro でも同様の機能のマクロを作れるかもしれませんね。
ただしユースケースや解決したい課題によって取るべき選択肢は変わってくると思います。

私は関数やメソッドの定義を拡張する機能が欲しく、呼び出し時に拡張されたコードが拡張されていることを意識せずに実行されて欲しいと考えました。
それを実現するには procedural macro を使うのが適していました。

関数やメソッドを呼び出す時に拡張されたコードとオリジナルのコードを使い分けたい時があり、呼び出し時にアドホックに使い分けたいこともあるかもしれません。
そのようなユースケースにおいては declarative macro を使って、呼び出すタイミングでコードを拡張できるようにするのが適しているかもしれません。

というわけで質問に対するぴったりな回答は持ち合わせていないのですが、procedural macro でも declarative macro でも自分にとって難易度が高いので、いたるところで悩んでいます。
The Rust Reference の Macro の章を読み、似た機能を持つマクロのコードを参考にしたり、デバッグしたりを繰り返してコードを書いています。