🔗

php-node で PHP スクリプトを実行する

に公開

序文

php-node は PHP スクリプトを Node.js アプリの一部として実行できるようにするツールです。サーバーサイドの Node.js アプリを新規開発しながら既存の PHP スクリプトを流用することができます。PHP アプリを開発するためだけの HTTP サーバーを作ることもできます。php-node の開発には Rust の napi-rs と ext-php-rs が使われています。

この記事から学べること

リクエストをもとにレスポンスを生成するハンドラーの使い方、PHP スクリプトを実行する CLI ツールや HTTP サーバー、グローバルコマンドの作成方法を学びます。既存のツールとの比較を通して php-node の位置づけや評価方法を模索します。アーキテクチャの解説において DeepWiki の使い方や Rust における非同期処理を学びます。

開発元

php-node を開発する Platformatic は Node.js を対象とした OSS のサポートとマネージドクラウドサービスを展開してます。メンバーのなかには Node.js TSC(技術諮問委員会)のメンバーや Fastify のリードメンテナーがいます。

PHP の制約

PHP スクリプトは Node.js の一部として動くのでシングルスレッドであることが要求されます。PHP ビルトインサーバーや php-fpm で動く PHP スクリプトでもシングルスレッドが要求されるので、多くのフレームワークや CMS は利用できます。

環境

Debian 12 で動作を確認しました。php-node パッケージには libphp.so が付属するため、システムに PHP をインストールする必要はありませんが、PHP および拡張モジュールが依存するライブラリをインストールする必要があります。Debian の場合、次のモジュールをインストールする必要がありました。

sudo apt-get install -y libssl-dev libcurl4-openssl-dev libxml2-dev \
  libsqlite3-dev libonig-dev re2c

プロジェクトの作成

php-node で実際にコードが動くか試します。次のコマンドでプロジェクトを作成します。

npm init -y
npm install @platformatic/php-node

ES モジュール方式で利用するために package.jsontype の値を commonjs から module に変更します。

大雑把な理解

最小限覚えておく必要があることはハンドラーがユーザーからの HTTP リクエストをもとに HTTP レスポンスを生成すること、PHP スクリプトの実行には HTTP サーバーの起動は必要ないことです。

C 言語も Rust も知らない場合、大雑把なコードの理解としては Node.js がユーザーからの HTTP リクエストをもとに URL で指定された PHP スクリプトを include もしくは require しつつ、スーパーグローバル変数を定義する PHP スクリプトを自動生成し、そのスクリプトを外部コマンドで実行して出力結果の文字列をキャプチャして解析し HTTP レスポンスしたものと考えても差支えはないです。後で詳しいアーキテクチャを説明します。

CLI からの実行

コマンドライン引数で指定した PHP スクリプトを実行する JS スクリプトを作成します。

cli.js
import { Php, Request } from '@platformatic/php-node';
import path from 'path';
import fs from 'fs';

const phpScript = process.argv[2];

if (!phpScript) {
  console.error('Usage: node cli.js <php-script>');
  process.exit(1);
}

// ファイル存在チェック(カレントディレクトリ以外も可)
const absPath = path.isAbsolute(phpScript) ? phpScript : path.resolve(process.cwd(), phpScript);
if (!fs.existsSync(absPath)) {
  console.error(`File not found: ${absPath}`);
  process.exit(1);
}

const php = new Php({
  argv: process.argv,
  docroot: path.dirname(absPath)  // スクリプトのあるディレクトリをdocrootに
});

// URL の path 部分のみを指定
const url = 'http://localhost/' + path.basename(absPath);

const request = new Request({
  url
});

const response = await php.handleRequest(request);

console.log(response.body.toString());

Node.js でリクエストとレスポンスオブジェクトを作成していますが、PHP スクリプトの実行には HTTP サーバーは不要です。URL の localhostexample.com など別の文字列でもコードは動きます。

test.php
<?php

echo "Hello World!";

次のコマンドを入力すれば Hello World! が表示されます。

node cli.js test.php

スーパーグローバル変数が定義されているか調べてみます。

test.php
<?php

echo $_SERVER['REQUEST_URI'], PHP_EOL;

node cli.js test.php の実行結果は /test.php になります。

組み込みの PHP を調べる

test.php を修正します。

test.php
<?php

echo phpversion(), PHP_EOL;
echo php_sapi_name();

実行結果は次のようになります。

8.4.10-dev
php_lang_handler

利用可能な拡張モジュールも調べてみましょう。

<?php

print_r(get_loaded_extensions());

実行結果は次のようになります。

Array
(
    [0] => Core
    [1] => date
    [2] => libxml
    [3] => openssl
    [4] => pcre
    [5] => sqlite3
    [6] => zlib
    [7] => ctype
    [8] => curl
    [9] => dom
    [10] => json
    [11] => fileinfo
    [12] => filter
    [13] => gd
    [14] => hash
    [15] => SPL
    [16] => mbstring
    [17] => session
    [18] => standard
    [19] => exif
    [20] => mysqlnd
    [21] => PDO
    [22] => pdo_mysql
    [23] => pdo_sqlite
    [24] => Phar
    [25] => posix
    [26] => random
    [27] => readline
    [28] => Reflection
    [29] => mysqli
    [30] => SimpleXML
    [31] => tokenizer
    [32] => xml
    [33] => xmlreader
    [34] => xmlwriter
    [35] => zip
    [36] => php
)

HTTP サーバー経由で PHP を実行する

HTTP/1 サーバーの JS スクリプトおよび PHP スクリプトを用意します。

server.js
import { Php, Request } from '@platformatic/php-node';
import http from 'node:http';
import fs from 'fs';
import path from 'path';
import process from 'process';

const args = process.argv.slice(2);
const port = Number(args[0]) || 8080;
const docroot = args[1] ? path.resolve(args[1]) : process.cwd();

if (!fs.existsSync(docroot)) {
  console.error('指定されたドキュメントルートが存在しません:', docroot);
  process.exit(1);
}

const php = new Php({ docroot });

const server = http.createServer((req, res) => {
  let bodyData = Buffer.alloc(0);
  req.on('data', chunk => {
    bodyData = Buffer.concat([bodyData, chunk]);
  });

  req.on('end', async () => {
    const request = new Request({
      url: `http://localhost${req.url}`,
      method: req.method,
      headers: req.headers,
      body: bodyData,
    });

    try {
      const response = await php.handleRequest(request);

      // ヘッダー設定
      Object.entries(response.headers || {}).forEach(([k, v]) => {
        res.setHeader(k, v);
      });
      // ステータスコード未定義時は200
      res.statusCode = response.statusCode || 200;
      res.end(response.body);
    } catch (err) {
      res.statusCode = 500;
      res.end('Internal Server Error');
      console.error(err);
    }
  });
});

server.listen(port, () => {
  console.log(`php-node HTTP/1.1 サーバー起動: http://localhost:${port}/`);
  console.log('ドキュメントルート:', docroot);
});

// グレースフルシャットダウン
process.on('SIGINT', async () => {
  console.log('\nサーバー停止中...');
  server.close(async () => {
    try {
      await php.close();
      console.log('グレースフルシャットダウン完了。');
      process.exit(0);
    } catch (err) {
      console.error('php.close()失敗:', err);
      process.exit(1);
    }
  });
});

通常の Node.js との違いは php.close() を実行して PHP のワーカースレッド(MainThread)を停止させる必要があることです。ワーカースレッドが残っている場合、再度 HTTP サーバーを起動させようとしてもポートが占拠されていて起動できなくなります。ps コマンドを実行すれば MainThread の存在を確認できます。

PID TTY          TIME CMD
10865 pts/1    00:00:00 bash
15253 pts/1    00:00:00 MainThread
15296 pts/1    00:00:00 ps 

停止には kill コマンドを使います。

SIGTERM

kill 15253

SIGKILL

kill -9 15253

public フォルダーに index.php と json.php を設置します。

index.php
<?php

echo "Hello";
json.php
<?php

echo json_encode($_POST);

サーバーを起動させます。

node server.js 8080 ./public

curl で正常に動くか確認します。

curl -v http://localhost:8080/
curl -v --data-urlencode 'emoji=🐘' http://localhost:8080/json.php

HTTP サーバーコマンドを作成する

汎用の HTTP サーバーを起動させるコマンドツールを作成することができます。次のように bin フォルダーにコマンドツールとして使いたいスクリプトを設置します。

package.json
bin/phpnode-server.js

スクリプトの冒頭には #!/usr/bin/env node を記載します。インストールするためには次のコマンドを実行します。

npm install -g .

詳細なコードは Gist に公開しておきます。

独自ビルド

php-node で自分たちが必要な PHP モジュールが利用したい場合、php-src をビルドして libphp.so を用意しておく必要があります。配布されている npm パッケージの PHP ビルドオプションは CI.yml で調べることができます。

php-node をビルドするにはプロジェクトのルートディレクトリで npm run build (napi build --cargo-name php_node --platform --js false --release) を実行します (package.json) 。npm run build を実行する前に libphp.so を参照できるように環境変数を設定する必要があります。Intel CPU の Debian 12 の場合、php.linux-x64-gnu.node が生成されます。npm/linux-x64-gnu に php.linux-x64-gnu.node を binding.node としてコピーし、またあらかじめビルドしておいた libphp.so も binding.node と同じディレクトリにコピーしておく必要があります。

php-node

  • index.js
  • index.d.ts
  • npm/linux-x64-gnu
    • binding.node
    • libphp.so

比較

アーキテクチャの話に入る前に既存のツールとの比較を示します。Node.js モジュールの比較対象として外部コマンド(child_process モジュールの exec または spawnexecFile)やFastCGI プロトコルで php-fpm と通信して PHP スクリプトを実行させるモジュール (fastcgi-client) が挙げられます。外部コマンドは CGI と同じく起動コストの問題があり比較的遅く、FastCGI プロトコルと比べると通信量が少なくてすみます。

別の比較対象として Rust の axum などのフレームワークで開発した PHP 拡張による HTTP サーバーやフレームワークを挙げることができます。Rust と比べると Node.js のフレームワークやミドルウェアは充実しています。

PHP の比較としてはビルトインサーバーが挙げられます。TLS 限定で利用可能なクライアントサイド JavaScript の機能として次のものが挙げられます。

  • Service Worker/PWA
  • Geolocation API
  • Web Bluetooth / Web USB / Web Serial / Web MIDI などのデバイスAPI
  • Clipboard API
  • Notification API
  • Web Share API
  • Payment Request API
  • Storage Access API
  • HTTP/2, HTTP/3

アーキテクチャ

php-node の開発には Rust の napi-rs と ext-php-rs が使われています。napi-rs は Rust で Node.js モジュールを開発するためのツールです。ext-php-rs は Rust で PHP 拡張機能を開発するためのツールです。アーキテクチャの学習には DeepWiki を利用することをおすすめします。

大まかな処理の流れは Node.js のコードと同じくリクエストオブジェクトを生成し、ハンドラーオブジェクトがリクエストオブジェクトをもとにレスポンスオブジェクトを生成するということです。

napi-rs は独自の非同期処理機能である AsyncTask を実装しています。AsyncTask は PHP リクエストの非同期処理に使われています。PHP スクリプトを非同期処理で扱うために Embed 構造体が定義されます。Embed 構造体は PHP VM の管理やリクエスト・レスポンス処理、ワーカープール連携のためのラッパーです。Embed 構造体は Send と Sync トレイトを実装しています。「質問:マルチスレッドの使われ方」

Embed 構造体を定義する理由として ext-php-rs の Zval/ZendCallable が Send トレイトを実装していない制約を挙げることができます。「質問: Zval/ZendCallable は Send トレイトを実装していない」。ext-php-rs のソースコードには参照カウント型(reference counted types)が原子的な参照カウンターを持たないため、複数のスレッドが同じオブジェクトを参照して同時に参照カウンターを変更しようとする可能性があるという問題があるそうです。

PHP スクリプトの実行には php-src/main/main.c で定義される php_execute_script が使われます「質問: php_execute_script 実行後の PHP 出力のキャプチャ」。実行された PHP スクリプトの出力は3つの関数でキャプチャされます (crates/php/src/sapi.rs)。

  • sapi_module_ub_write    標準出力
  • sapi_module_send_header HTTPヘッダー
  • sapi_module_log_message ログメッセージ

実行フローにおいて PHP の出力を蓄積するのは RequestContext::current から取得される ResponseBuilder です。PHP スクリプト実行前にスーパーグローバル変数の生成が sapi_module_register_server_variables によって生成されます「質問: スーパーグローバル変数の生成」。

GitHubで編集を提案

Discussion