ハイサイ!オースティンやいびーん。
概要
前回の記事で、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フレームワーク関連のコードをコンパイルしないように指定しないといけません。
[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の設定
資料を追ってセットアップすると、クレートの設定も案内してくれますが、筆者がそれを追加した時に、インテグレーションテストが全てコンパイルされなくなりました
ドキュメントではcdylib
に設定するように書いていますが、rlib
も追加しておくとテストを壊さずに両方ともビルドできます。
[lib]
crate-type = ["cdylib", "rlib"]
簡単なPHP関数をRustでビルドしてみる
WP_Queryの逆輸入版に入る前に、簡単にext-php-rs
の設定が合っているかを試したいものです。
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で試してみます。
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に入っているIntoZval
とFromZval
のトレートを実装すると、明示的にRustとPHP間の情報伝達のロゼッタ石を作れるのです。
今回の実装では、RustのMySQLクエリで取得したstruct WP_Post
を一方的にPHPのZendの値に変換したいので、FromZval
を実装しなくていい、と言いたいところですが、コンパイル時に文句を言われるので、簡単に片付けてしまう筆者の悪い本性が以下のようにコードで具現化されました。
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
}
}
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にはちょうどそれができるツールがあるので使いましょう。
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
$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)
できているなぁ
パラメーターをPHPのArrayから汲み取る
これが最大の難関で筆者はどこまで真面目に実装するか悩んでいます。
WordPressのWP_Queryのドキュメンテーションを見てわかるように、PHPは型を意識しないから、さまざまなパターンで$args
を指定できて、その可能性の全てをRustで規制正しく組んであげるのは実にややこしい作業なんです。
ただ、その一部をとりあえず紹介します。
wp_query_rs
のクレートに戻ってParamsにFromZval
のトレートを実装しましょう。
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']];
型が自由な、ソフトな言語でまかり通る使い方が、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)
やったね
これをあと、何項目も、何パターンもカバーしていけばできる...よね?WP_Queryのドキュメントをみると身震いがします。
気力はもたなさそうなので、記事は「できるよね?」ということでここまでにしましょう。
まとめ
Rustで実装した WP_QueryをまたPHPに逆輸入してみましたが、いかがでしたでしょうか?筆者の時間の無駄遣いと趣味の悪さに唖然としましたでしょうか?
理解していただきたいのですが、あくまでもこれはPOCです!RustをこのようにPHPと仲良くさせられるから、PHPのアプリケーションでも高いパフォーマンスが要求される場面があれば、Rustを部分的に使えるのだということを(自分に)証明したかったのです。
最近はPHPをバッシングするかのようにJavaScriptフレームワークが勃発していますが、Next.jsとSSRの煩雑さを手懐けられる開発チームは日本にどれほどいるのでしょうか?いたとしても、あれほどのエンジニアの資源を投資してSPAを実現したところで、最悪なパフォーマンスでユーザー離れに苛まれるというのがオチです。
本当にいいUXを安定的に低コストで届けてくれるのはPHPです。PHPは、最高のSSRなのです。こんなにいいツールが実はあったのです、ずっとね
RustとPHPを組み合わせて使う開発モデルができたら最高に楽しいと思います。
ぜひそういうのが流行ったらいいな、Rubyでもいいからバックエンドエンジニアの派遣復活を祈ります。