7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Rustで作ったWP_QueryをまたPHPに持ってくる - RustでPHP拡張を作る

Posted at

ハイサイ!オースティンやいびーん。

概要

前回の記事で、RustでWP_Queryを実装しましたが、今回、それをPHPの拡張してコンパイルしてPHPのコードで使えるようにします。

目的

筆者は、Rustを幅広く活用したくて学習を始めました。最初はWebAssemblyを使いたくて、WebAssemblyにコンパイル言語として優れているから選定しましたが、PHPの拡張にもコンパイルできると知って、Rustの可能性が広がりました。

Webに強いPHPに、パフォーマンスと安全性を担保するRustを組み合わせれば、どのようなアプリケーションでも作れるのではないでしょうか?

前回の記事も今回のも、RustとPHPの距離を縮めて、RustでPHPをより強いツールにしていけるのではないかという仮説の裏付けがしたいのです。

ext-php-rs

RustのソースをPHPの拡張にコンパイルするのに役立つのは、ext-php-rsというクレートです。

他にもphperも存在しますが、ドキュメンテーションの充実さでext-php-rsを採用しました。

とても丁寧に資料を作ってくれているので、ほぼほぼ難なく使えました。

コンパイル設定

上記のクレートのドキュメントでも説明してくれますが、自分が躓いたところを踏まえて少し説明します。

ローカルにPHP Devをインストールする必要がある

クレートをインストールするだけでコンパイルできません。php-devがインストールされている前提でビルドされるらしいので、あらかじめインストールしてください。

Mac Homebrewだと簡単です。

brew install php

.cargo/config.tomlを忘れないように

とても重要で筆者にわかりにくかったセットアップの段階でしたら、.cargo/config.tomlを当クレートで作っておいて、PHPのZendフレームワーク関連のコードをコンパイルしないように指定しないといけません。

.cargo/config.toml
[target.'cfg(not(target_os = "windows"))']
rustflags = ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"]

これが入っていないと以下のようなエラーがビルド時に発生します。

Undefined symbols for architecture arm64:
            "___zend_malloc", referenced from:
                _ext_php_rs_zend_string_init in libext_php_rs-74f0decce9f75fdf.rlib(wrapper.o)

PHPの拡張はZendフレームワークを独自に持つのではなく、PHPから借りることになっているらしいので、含める必要はないそうです。

crate-typeの設定

資料を追ってセットアップすると、クレートの設定も案内してくれますが、筆者がそれを追加した時に、インテグレーションテストが全てコンパイルされなくなりました:joy:

ドキュメントではcdylibに設定するように書いていますが、rlibも追加しておくとテストを壊さずに両方ともビルドできます。

[lib]
crate-type = ["cdylib", "rlib"]

簡単なPHP関数をRustでビルドしてみる

WP_Queryの逆輸入版に入る前に、簡単にext-php-rsの設定が合っているかを試したいものです。

lib.rsにドキュメントで紹介しているシンプルなメソッドを入れてビルドしてみましょう。

src/lib.rs
use ext_php_rs::prelude::*;

#[php_function]
pub fn hello_world(name: &str) -> String {
    format!("Hello, {}!", name)
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
}

php_moduleはPHPに登録するために必要な関数だそうです。

上記のコードをPHPで試してみます。

php/test.php
var_dump(hello_world("Austin"));

以下のコマンドでビルドして実行しましょう。

cargo build
php -d extension=target/debug/libwp_query_rs_php_ext.dylib php/test.php

すると以下の出力があります。

string(14) "Hello, Austin!"

なんだかすごい歓迎されている気がしますね。

WP_QueryをPHPに持ってくる

本題に入りますがRustで再現したWP_QueryをまたPHPに逆輸入すると、なんとも言えない作業、とんでもなく無駄な作業にかかりましょう。

MySQLのコネクションプールを実装しておきます。

元々のqp_query_rsのパッケージのコードもあれこれ変更する必要がありますが、最初に手を付けるのはMySQLのコネクションの取り方。

MySQLのコネクションを作ることが最大のパフォーマンスの打撃なので、一つのPHPのインスタンスにプールを張ってすぐにコネクションが取れるようにしたい。

RustでSingletonを実装するのには広く浸透しているonce_cellのクレートを使います。実はonce_cellは、Rustのスタンダードライブラリにもこのクレートの一部が入るほど、認められているようです。

Rustのスタンダードライブラリのバージョンだ、Syncのトレートを実装しないといけないのですが、筆者はまだRust知識が足りないのでより簡単な道を選びました。

use mysql::{OptsBuilder, Pool, PooledConn};
use once_cell::sync::OnceCell;

static POOL_INSTANCE: OnceCell<Pool> = OnceCell::new();

pub fn get_pool() -> &'static Pool {
    POOL_INSTANCE.get_or_init(|| {
        let env_vars = EnvVars::from_env();
        let opts = build_opts_from_env(env_vars);

        Pool::new(opts).expect("SqlConnectionError")
    })
}

これで環境変数からコネクションプールを張って、PHP側でも多少のパフォーマンス向上が図れるでしょう。

WP_PostのstructがZendのオブジェクトに変換されるようにする

PHPのZendフレームワークは独自の方法でPHPで使えるオブジェクト、配列等の実装をしていますが、php-ext-rsはその変換を手伝ってくれます。

php-ext-rsに入っているIntoZvalFromZvalのトレートを実装すると、明示的にRustとPHP間の情報伝達のロゼッタ石を作れるのです。

今回の実装では、RustのMySQLクエリで取得したstruct WP_Postを一方的にPHPのZendの値に変換したいので、FromZvalを実装しなくていい、と言いたいところですが、コンパイル時に文句を言われるので、簡単に片付けてしまう筆者の悪い本性が以下のようにコードで具現化されました。

wp_query_rs/src/wp_post.rs
impl<'a> FromZval<'a> for WP_Post {
    const TYPE: ext_php_rs::flags::DataType = DataType::Object(Some("RS_WP_Post"));

    // Do not implement as not used, must satisfy ext-php-rs traits
    fn from_zval(zval: &'a Zval) -> Option<Self> {
        None
    }
}

:sweat_smile:

IntoZvalはきちんとしないといけないので以下のように実装しました。

impl IntoZval for WP_Post {
    const TYPE: ext_php_rs::flags::DataType = DataType::Object(Some("RS_WP_Post"));

    fn into_zval(self, persistent: bool) -> ext_php_rs::error::Result<ext_php_rs::types::Zval> {
        let zobj = self.build_zobj()?;

        zobj.into_zval(persistent)
    }

    fn set_zval(self, zv: &mut Zval, persistent: bool) -> ext_php_rs::error::Result<()> {
        let mut zobj = self.build_zobj()?;

        zv.set_object(&mut zobj);

        Ok(())
    }
}

Zvalの中の値をZBox<_zend_object>という型にして返します。

このとレートで実装を要求されている二つの関数ですが、set_zvalが気になりますよね。into_zvalは無論その通りなのですが、なぜセットするのでしょうか?

思いつくのは以下のような状況のことかな?

$var_a = ['a' => 1];
$var_a = $var_a;

もしかしてZvalはスタックに入る参照で、中身は全てヒープに置かれているのでしょうか?PHPの知識が足りないので答えがまだ見えませんが、興味深く思いました。

ちなみに、set_objectで使っているbuild_zobjの関数は以下のような内容です。

impl WP_Post {
    fn build_zobj(self) -> ext_php_rs::error::Result<ZBox<_zend_object>> {
        let mut zobj = ZendObject::new_stdclass();

        zobj.set_property("ID", self.ID)?;
        zobj.set_property("post_status", self.post_status)?;
        zobj.set_property("post_author", self.post_author)?;
        zobj.set_property("post_date", self.post_date.to_string())?;
        zobj.set_property("post_date_gmt", self.post_date_gmt.to_string())?;
        zobj.set_property("post_content", self.post_content)?;
        zobj.set_property("post_title", self.post_title)?;
        zobj.set_property("post_excerpt", self.post_excerpt)?;
        zobj.set_property("comment_status", self.comment_status)?;
        zobj.set_property("ping_status", self.ping_status)?;
        zobj.set_property("post_password", self.post_password)?;
        zobj.set_property("post_name", self.post_name)?;
        zobj.set_property("to_ping", self.to_ping)?;
        zobj.set_property("pinged", self.pinged)?;
        zobj.set_property("post_modified", self.post_modified.to_string())?;
        zobj.set_property("post_modified_gmt", self.post_modified_gmt.to_string())?;
        zobj.set_property("post_content_filtered", self.post_content_filtered)?;
        zobj.set_property("post_parent", self.post_parent)?;
        zobj.set_property("guid", self.guid)?;
        zobj.set_property("menu_order", self.menu_order)?;
        zobj.set_property("post_type", self.post_type)?;
        zobj.set_property("post_mime_type", self.post_mime_type)?;
        zobj.set_property("comment_count", self.comment_count)?;

        Ok(zobj)
    }
}

PHPのクラスとしてコンパイルされるようにmacroを使う

WordPressと同様に、グローバルのクラスとして定義したいです。

php-ext-rsにはちょうどそれができるツールがあるので使いましょう。

wp_query_rs_php_ext/src/lib.rs
use ext_php_rs::prelude::*;

#[derive(Debug, Clone)]
#[php_class]
#[allow(non_camel_case_types)]
pub struct RS_WP_Query {
    pub posts: Vec<WP_Post>,
}

#[php_impl]
impl RS_WP_Query {
    pub fn __construct(args: Params) -> Self {
        let q = WP_Query::new(args).unwrap();

        Self { posts: q.posts }
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
}

#[php_class]のダイレクティブを使うと、structをPHPのクラスに変換できます。

そのクラスにメソッドを実装する場合は、#[php_impl]imlの上につけてRustらしく実装します。

Paramsのことは後で説明しますが、とりあえず上記のコードをコンパイルして、PHPで使えるか確認しましょう。

php/test.php
<?php

$rs_wp_query = new RS_WP_Query([]);

var_dump($rs_wp_query);

実行してみると以下のような出力があります。

object(RS_WP_Query)#1 (0) {
}

あれ?postsは??

ちょっと間違いがありました。#[prop]というダイレクティブをstruct RS_WP_Queryの、PHPに使わせたいプロパティの上に追加する必要があります。

#[derive(Debug, Clone)]
#[php_class]
#[allow(non_camel_case_types)]
pub struct RS_WP_Query {
    #[prop]
    pub posts: Vec<WP_Post>,
}

こうして再度上記のコードを実行すると正しく表示されます。

<?php

$rs_wp_query = new RS_WP_Query([]);

var_dump(count($rs_wp_query->posts));

出力:

int(10)

できているなぁ:kissing_heart:

パラメーターをPHPのArrayから汲み取る

これが最大の難関で筆者はどこまで真面目に実装するか悩んでいます。

WordPressのWP_Queryのドキュメンテーションを見てわかるように、PHPは型を意識しないから、さまざまなパターンで$argsを指定できて、その可能性の全てをRustで規制正しく組んであげるのは実にややこしい作業なんです。

ただ、その一部をとりあえず紹介します。

wp_query_rsのクレートに戻ってParamsにFromZvalのトレートを実装しましょう。

wp_query_rs/src/query/params.rs
impl<'a> FromZval<'a> for Params {
    const TYPE: ext_php_rs::flags::DataType = ext_php_rs::flags::DataType::Array;

    fn from_zval(zval: &'a ext_php_rs::types::Zval) -> Option<Self> {
        if !zval.is_array() {
            return None;
        }

        let mut params = Self::new();

        Some(params)
    }
}

注目の'aですが、RustにこのZvalのライフタイムがいつまでなのかを把握させるためにここで指定していますが、実際にはPHPはGCなので、Rustの判断でこいつのmallocとdropができないらしいです。余談ですが。

上記のようにとりあえずセットアップしておくと、空の配列をPHPで渡しておけばデフォルトの設定を投稿を取得して渡してくれるようになっています。

実際に、post_typeの引数を反映させてみましょう。

元々のWP_Queryの実装だと、以下の二つのパターンでpost_typeを渡される可能性があります。

$args = ['post_type' => 'page']; // OR
$args = ['post_type' => ['page']];

:sweat:

型が自由な、ソフトな言語でまかり通る使い方が、Rustのような言語の眼鏡でみると、メモリに対する冒涜に感じます。

ただ、なす術がないわけではありません。以下のようにif letで対応できます。

impl<'a> FromZval<'a> for Params {
    const TYPE: ext_php_rs::flags::DataType = ext_php_rs::flags::DataType::Array;

    fn from_zval(zval: &'a ext_php_rs::types::Zval) -> Option<Self> {
        let mut params = Self::new();

        if let Some(array) = zval.array() {
            /* Post Type */
            // PHP Version allows for array or string, accounts for both possibilies
            if let Some(post_types) = array.get("post_type").map(|r| r.array()).flatten() {
                let p_types: Vec<String> =
                    post_types.iter().filter_map(|p_type| p_type.2.string()).collect();
                params.post_type = Some(p_types)
            } else if let Some(post_type) = array.get("post_type").map(|v| v.string()).flatten() {
                params.post_type = Some(vec![post_type]);
            }
        }

        Some(params)
    }
}

上記のソースコードが醜いかもしれませんが、PHPからくる引数の可能性をカバーできています。

最初に、Stringの配列を渡される場合が適応されるべきかをチェックします。次に、Stringだけの場合。そもそも指定がなければ何もパラメーターを追加しない、デフォルトのままにする。

nested ifの居心地の悪さはさておき、試してみればうまくいくのです。

<?php

$rs_wp_query = new RS_WP_Query(['post_type' => ['guide', 'post']]);

$rs_wp_query2 = new RS_WP_Query(['post_type' => 'guide']);

var_dump(count($rs_wp_query->posts));
var_dump($rs_wp_query->posts[0]->post_type);
var_dump(count($rs_wp_query2->posts));

結果:

int(10)
string(5) "guide"
int(10)

やったね:yum:

これをあと、何項目も、何パターンもカバーしていけばできる...よね?WP_Queryのドキュメントをみると身震いがします。:joy:

気力はもたなさそうなので、記事は「できるよね?」ということでここまでにしましょう。

まとめ

Rustで実装した WP_QueryをまたPHPに逆輸入してみましたが、いかがでしたでしょうか?筆者の時間の無駄遣いと趣味の悪さに唖然としましたでしょうか?:joy:

理解していただきたいのですが、あくまでもこれはPOCです!RustをこのようにPHPと仲良くさせられるから、PHPのアプリケーションでも高いパフォーマンスが要求される場面があれば、Rustを部分的に使えるのだということを(自分に)証明したかったのです。

最近はPHPをバッシングするかのようにJavaScriptフレームワークが勃発していますが、Next.jsとSSRの煩雑さを手懐けられる開発チームは日本にどれほどいるのでしょうか?いたとしても、あれほどのエンジニアの資源を投資してSPAを実現したところで、最悪なパフォーマンスでユーザー離れに苛まれるというのがオチです。

本当にいいUXを安定的に低コストで届けてくれるのはPHPです。PHPは、最高のSSRなのです。こんなにいいツールが実はあったのです、ずっとね:sob:

RustとPHPを組み合わせて使う開発モデルができたら最高に楽しいと思います。

ぜひそういうのが流行ったらいいな、Rubyでもいいからバックエンドエンジニアの派遣復活を祈ります。

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?