PHP-ScoperでWordPressプラグインのコンフリクトを解決する

PHPにはcomposerという高度なパッケージ管理ツールが存在しており、WordPressでもcomposerを利用した開発がさかんに行われている。しかし、ついにコンフリクトが発生するようになったため、その解決方法と知見を共有したい。本記事の対象読者は以下の通り。

  • プラグインを作成している。
  • テーマを開発しているがプラグインで問題が起きた。Uncaught Error: Call to undefined method GuzzleHttp\Utils::chooseHandler() と言われた。

開発者ではないユーザーにとっては、「そういうプラグインを使わない」という解決策しかないので、ご了承いただきたい。では、本題に入ろう。

なぜコンフリクトが発生するか

composerは基本的に「プロジェクト単位で1つ」という暗黙の前提が存在するツールである。たとえばPHPのフレームワークLaravelなどはそもそもcomposerでインストールを行うので、プロジェクト全体の依存関係がcomposerによって解決しうる。たとえば、Laravelのインストールは次のように指示されている。

# プロジェクトを作成
composer create-project laravel/laravel example-app
# Laravelをインストール
composer global require laravel/installer
laravel new example-app
# AWS-SDKを追加
composer require aws/aws-sdk-php 

ところがWordPressはプラグインシステムを採用しており、composerにも対応していないので、それぞれのプラグインが勝手にcomposerを導入している。vendorディレクトリはcomposerのライブラリが収められるディレクトリだ。

wp-content/plugins
    - plugin-a
        - vendor
            - guzzlehttp
            - google
    - plugin-b
        - vendor
            - guzzlehttp

クラスの読み込み自体はオートロードの仕組みがあるので、オートロードが登録された順にクラスファイルの利用順が決まり、同じクラスファイルが存在していても問題が起こらないことも多かった。また、各プラグインで利用しているライブラリが被らないことも問題が露見しない原因でもあっただろう。

しかしながら、guzzle(HTTPリクエストライブラリ)やsymfony/finder(ファイル探査)のようなライブラリは他のライブラリで必要とされることも多く、特にAPIを利用するようなライブラリはすべてといってよいほどguzzleに依存している。そんなわけで、guzzleに破壊的な変更が入ると、コンフリクトが起きてしまうのだ。上記の例でいえば、plugin-bはguzzle 7.x系を必要としているが、オートロードによってplugin-a同梱のguzzle 6.x系が優先されてしまい、plugin-bの実行時点でエラーが発生してしまう。

他のプラグインはどのように解決しているか?

composerは便利だが、他のプラグインがどのcomposerライブラリを利用するかを決めることはできない。では、どうするか? この問題に直面しつつ解決したプラグインとしてYoastとWP Migrateが挙げられる。

WooCommerceはまだこの問題が残っているようだ。

とにかく、PHP-Scoperというツールを使うことで解決できるようだ。PHP-Scoperは指定されたディレクトリのすべてのクラスと関数にプレフィックスをつける(例・CapitalP)。いわば、GuzzleHttp\GuzzleCapitalP\GuzzleHttp\Guzzleに変更するということだ。これにより、そのライブラリはプラグイン専用となり、他のプラグインがGuzzleを利用していようが関係なくなる。

具体例・PHP-ScoperでWordPressプラグインを保護する

それでは、筆者が作成したga-communicatorというプラグインで実際にその例を見てみよう。このプラグインはGoogle AnalyticsのAPIを利用し、データを取得することができる。もちろん、GA4対応済み。しかしながら、Guzzleが入ってしまっているので、他のプラグインとコンフリクトが起きた。

まず、php-scoperをグローバルにインストールする。なぜプロジェクトに含めないのかというと、後述するように、PHPUnitなどの開発用ツールはデプロイせず、保護する必要がないからだ。

 composer global require humbug/php-scoper 

続いて、設定ファイルを作成する。php-scoper init コマンドで設定ファイル scoper-inc.phpが生成される。これをカスタマイズしていく。基本的な原則としては、次のとおり。

  1. 自分のライブラリはプレフィックスをつける必要がない。PSR-4に対応していないし、外部の関数から利用しているため。ただし、その中に書いてある GuzzleHttp\GuzzleCapitalP\GuzzleHttp\Guzzle になってほしい。
  2. WordPressの標準クラス WP_Query や関数 the_permalink にプレフィックスをつけられてしまうと、そもそも動作しないので、除外したい。
  3. プレフィックスをつけたファイルは vendor-prefixed というディレクトリに保存する。この名前はお好み。

こうしてできた設定ファイルがこちらである。

続いて、この設定ファイルを利用して名前空間が保護されたライブラリを書き出してみよう。次のようなコマンドである。

# パスが通っていれば、php-scoperだけでも大丈夫
~/.composer/vendor/bin/php-scoper add-prefix --output-dir=vendor-prefixed --force
# vendor-prefixed用のautoloaderを生成
composer dump-autoload --working-dir vendor-prefixed --classmap-authoritative

こうしてプレフィックス付きのファイルが書き出されたのだが、ここで2つの問題が生じる。

  1. ライブライがアップデートされた場合は、毎回この作業を行わなくてはならない。
  2. 開発の時に少しめんどくさい。

また、ga-communicator固有の問題として、プラグイン形式とcomposer形式の両方で配布しているので、composerで読み込まれる場合はPHP-Scoperは必要ないという問題がある。そこで、次のような解決策を導入した。

  • 読み込むオートローダー(vendor/autoload.phpかvendor-prefixed/scoper-autoload.php)のどちらがあるかで読み込み先を変更。
  • GitHub Actionsでプラグインに固めるときにPHP-Scoperを実行

まず、composerのライブラリ読み込みを次のように書き換えて、読みこみ先を変更。なお、ga-communicatorはPSR-0の命名規則に従っていたが、PHP-ScoperはPSR-4にしか対応していなかったので、そこだけオートローダーを自前実装することになった。

# 以前はこの1行
require_once __DIR__ . '/vendor/autoload.php';
# 新しいオートローダー読みこみ
if ( file_exists( __DIR__ . '/vendor-prefixed/vendor/scoper-autoload.php' ) ) {
	// PHP-Scoperで固められていたらそれを読み込み
	require_once __DIR__ . '/vendor-prefixed/vendor/scoper-autoload.php';
	// 自前のオートローダー
	spl_autoload_register( function( $class_name ) {
		$class_name = ltrim( $class_name, '\\' );
		$prefix     = 'Kunoichi\\GaCommunicator';
		if ( 0 !== strpos( $class_name, $prefix ) ) {
			return;
		}
		$file = __DIR__ . '/vendor-prefixed/src/' . str_replace( '\\', '/', $class_name ) . '.php';
		if ( file_exists( $file ) ) {
			require_once $file;
		}
	} );
} elseif ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) {
	// 以前のオートローダー
	require_once __DIR__ . '/vendor/autoload.php';
} else {
	trigger_error( __( 'Composer file is missing. Please run composer install.', 'ga-communicator' ), E_USER_ERROR );
}

以降の処理はほとんど同じである。実際のファイルはこちら

続いて、GitHub Actionsで「デプロイの時にスコープを固める」という作業を追加する。通常の配布用プラグインを作成している方も、この作業はデプロイ直前にやるだけの方がありがたいだろう。

# GitHub Actionsのジョブ
- name: Do PHP scoper
        run: |
          composer global require humbug/php-scoper
            ~/.composer/vendor/bin/php-scoper add-prefix --output-dir=vendor-prefixed --force
          composer dump-autoload --working-dir vendor-prefixed --classmap-authoritative

これで配布時だけスコープを区切られたプラグインが完成した。この後はvendorディレクトリを消したり色々としている。実際のファイルはこちら

自作のテーマを作っている方は基本的に同じ方針で対処可能である。

PHP-Scoperは安全なのか?

デプロイ時にスコープに固めるだけという処理だと、安全確認(そのプラグインは本当に動くのか)がしづらい。では、PHP-Scoperは完璧なのだろうか? この点についてはPHP-Scoperの制限についてのドキュメントで説明されている。

  1. 動的なクラス呼び出しはダメ。クラス名が正規表現だったり、文字列連結で生成されていたり。
  2. 日付文字列がクラスと間違えられることがある。Ymd\THis\Z とか。これは設定ファイルにpatcherというものを追加することで除外。
  3. ヒアドキュメントはダメ。

なんにせよ、「安全というわけではない」という結論になりそうだ。PHP-Scoper自体がまだ0.xバージョンなので今後のアップデートに期待したい。WordPressのサポートも課題になっているようだ。

まとめ

WordPressプラグインのエコシステムが発展し、ライブラリなどで依存関係が増えてきた結果、思わぬ事故も発生するようになってきた。まだソリューションとしては新しい仕組みではあるが、いざというときの緊急避難方法としてPHP-Scoperの存在を知っておいてほしい。今後はソリューションも洗練されて、composer.jsonに1行足すだけという時代もくることだろう。

“PHP-ScoperでWordPressプラグインのコンフリクトを解決する”への2件の反応

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください