PHP-ScoperでAWS SDKを隔離した話
はじめに
前回は、WordPress.orgへプラグインを申請する際、Plugin Checkでエラー0件になっていても、それだけでは公開審査が終わらなかったことを書きました。
今回は少し時間を戻して、プラグインを配布可能な形へ仕上げる過程で遭遇した別の問題について書きます。
今回作成した Ozeki Database Backup for S3 では、Amazon S3へ接続するためにAWS SDK for PHPを利用しています。
AWS SDKはComposerから簡単に導入できます。
composer require aws/aws-sdk-php
普通のPHPアプリケーションなら、これで依存関係管理についてはかなり楽になります。
ところがWordPressプラグインとして配布することを考えると、
Composerで依存関係を管理しているから大丈夫
とは言い切れません。
理由は、WordPressでは複数のプラグインが同じPHPプロセス上で動くからです。
今回は、その問題に対してPHP-Scoperを使い、AWS SDKなどの依存ライブラリをプラグイン内部へ隔離した過程を整理します。
1. Composerを使えば依存関係の問題は解決するのでは?
AWS SDK for PHPを使うコード自体は単純です。
use Aws\S3\S3Client;
$client = new S3Client([
'version' => 'latest',
'region' => $region,
]);
Composerを使えば、
aws/aws-sdk-php
guzzlehttp/guzzle
guzzlehttp/psr7
psr/http-message
...
といった依存パッケージもまとめて管理できます。
composer.lock があれば、どのバージョンを使うかも固定できます。
ここまでを見ると、
composer.json
↓
composer.lock
↓
composer install
↓
vendor/
で問題は解決したように見えます。
しかし、これは「一つのアプリケーションの中でComposerを使う」という前提では非常にうまく機能します。
WordPressプラグインでは事情が少し違います。
2. WordPressでは複数のComposer環境が同居する
例えば、自分のプラグインがAWS SDKを使っているとします。
Plugin A
└── vendor/
└── aws/aws-sdk-php
別のプラグインもAWS SDKを使っているかもしれません。
Plugin B
└── vendor/
└── aws/aws-sdk-php
ここで両方が同じバージョンを使っていれば問題が起きないかもしれません。
しかし、
Plugin A
AWS SDK version X
Plugin B
AWS SDK version Y
だったらどうなるでしょうか。
PHPでクラス名は、
Aws\S3\S3Client
です。
Plugin AのAWS SDKでも、
Aws\S3\S3Client
Plugin BのAWS SDKでも、
Aws\S3\S3Client
です。
PHPから見れば同じクラス名です。
さらに、それぞれのプラグインがComposer autoloaderを登録します。
すると、
どちらのプラグインが期待していたAWS SDKが実際にロードされるのか
という問題が出てきます。
AWS SDKだけではありません。
AWS SDKが内部で利用するGuzzleなども同じです。
WordPress
│
├── Plugin A
│ └── AWS SDK
│ └── Guzzle
│
└── Plugin B
└── AWS SDK
└── Guzzle
つまりWordPressでは、
一つのPHP実行環境の中に、複数のComposer依存関係が持ち込まれる
可能性があります。
Composerはそれぞれのプロジェクト内の依存関係を解決してくれますが、
別々に作られたWordPressプラグイン同士の依存関係競合
まで自動的に解決してくれるわけではありません。
3. namespaceを変えてしまえばよいのではないか
そこで考えたのが、依存ライブラリのnamespace自体をプラグイン固有のものへ変更する方法です。
例えば、
Aws\S3\S3Client
を、
SecureS3StorageForWordpressVendor\Aws\S3\S3Client
へ変換します。
すると、
他のプラグイン
Aws\S3\S3Client
自分のプラグイン
SecureS3StorageForWordpressVendor\Aws\S3\S3Client
となります。
PHPから見ても別のクラスです。
Guzzleなどについても同様にprefixを付けます。
GuzzleHttp\Client
から、
SecureS3StorageForWordpressVendor\GuzzleHttp\Client
へ変換する。
こうすれば、別のプラグインが異なるAWS SDKやGuzzleを読み込んでも、自分のプラグインが使用するライブラリとは名前空間が分離されます。
この処理に使ったのが PHP-Scoper です。
4. PHP-Scoperをrelease buildの中で使う
重要なのは、開発中のソースコードそのものを書き換えないことです。
開発時には通常通り、
use Aws\S3\S3Client;
として開発します。
そしてリリースZIPを生成するときだけ、
source
↓
Composer dependenciesをinstall
↓
PHP-Scoper
↓
prefix済みrelease tree
↓
ZIP
と変換します。
今回のプラグインでは、この一連の処理を build-release.sh にまとめました。
大まかな処理は次のようになっています。
1. 必要コマンド確認
2. composer.json / composer.lock確認
3. PHP syntax check
4. 一時staging directory作成
5. composer install --no-dev
6. PHP-Scoper実行
7. autoload再生成
8. 変換結果を検証
9. transformed PHP syntax check
10. 配布に必要なファイルを確認
11. release ZIP生成
手動で、
composer install
↓
php-scoper
↓
ファイルをコピー
↓
不要ファイルを削除
↓
zip
としても作れます。
しかし、毎回手で行えば、いずれ何かを忘れます。
そのため、
配布物は必ずbuild scriptから作る
ことにしました。
5. PHP-Scoperの設定
現在の設定では、dependency側に、
SecureS3StorageForWordpressVendor
というprefixを付けています。
概念的には、
return [
'prefix' => 'SecureS3StorageForWordpressVendor',
];
です。
一方、自分自身のプラグインnamespaceまで変換してしまうと困ります。
今回の本体namespaceは、
SecureS3StorageForWordpress
なので、これはscoping対象から除外しています。
'exclude-namespaces' => [
'SecureS3StorageForWordpress',
],
また、WordPress側から提供される WP_CLI まで独自namespaceへ変換してはいけないので、これも除外しています。
'exclude-classes' => [
'WP_CLI',
],
つまり、
自分のコード
SecureS3StorageForWordpress\...
↓
そのまま
外部dependency
Aws\...
GuzzleHttp\...
↓
SecureS3StorageForWordpressVendor\...
という境界にしています。
6. 実際には「prefixを付ければ終わり」でもなかった
ここで少し厄介なことが起きました。
PHP-ScoperはPHPコードを解析してnamespaceやclass referenceを変換してくれます。
しかし、すべてのクラス参照がPHPの構文として書かれているとは限りません。
AWS SDK内部には、クラス名を文字列として動的に組み立てる処理があります。
例えば概念的には、
"Aws\\{$service}\\Exception\\{$service}Exception"
のようなものです。
これは通常の、
new Aws\Something\Example();
とは違います。
文字列として生成されるクラス名なので、単純なnamespace変換では正しく変更できないケースがあります。
そこでPHP-Scoperのpatcherを利用し、scoping後に必要な変換を追加しました。
同様に、AWS Signature Version 4で使われるISO 8601の日付フォーマット文字列についても、PHP-Scoperがクラス文字列のように解釈してprefixを付けてしまうケースがありました。
本来、
Ymd\THis\Z
でなければならないものが変換されてしまうと、AWS署名そのものが壊れます。
そのため、この部分もpatcherで元のliteralへ戻しています。
ここで、
namespace prefixは単純な検索・置換ではない
ことをかなり実感しました。
特にSDKのような大きなライブラリでは、
- dynamic class name
- reflection
- string based class reference
- generated configuration
などがあるため、変換後の実動作確認が必要になります。
7. 「PHP-Scoperが成功した」だけでは信用しない
そこでrelease buildでは、PHP-Scoperの終了コードだけを見て終わりにはしていません。
例えば、変換後に、
class_exists(
'SecureS3StorageForWordpressVendor\\Aws\\S3\\S3Client'
)
を確認します。
これが存在しなければ失敗です。
逆に、
class_exists('Aws\\S3\\S3Client')
がtrueになっても失敗としています。
欲しい状態は、
Scoped AWS SDK
SecureS3StorageForWordpressVendor\Aws\S3\S3Client
→ 存在する
Unscoped AWS SDK
Aws\S3\S3Client
→ 存在しない
です。
つまり、
「prefixされたクラスが動く」
だけではなく、
「元のグローバルなAWS SDKがrelease packageからロードできない」
ところまで確認しています。
8. WordPress側のクラスまで変換されていないかも確認する
逆方向の確認も必要です。
例えば WP_CLI はWordPress環境側から提供されます。
これが誤って、
SecureS3StorageForWordpressVendor\WP_CLI
となってしまったら動きません。
そこでrelease buildでは、
SecureS3StorageForWordpressVendor\WP_CLI
が生成されていないことも確認しています。
WordPressプラグインのentry pointについても同じです。
プラグイン本体のentry pointまでvendor namespaceへ入ってしまうと、WordPressから正しくロードできません。
そのため、WordPressのentry pointと uninstall.php は、reviewした元ソースと同じものを最終release側へ戻しています。
考え方としては、
WordPressとの境界
→ 変えない
自分のplugin namespace
→ 変えない
bundled third-party dependencies
→ prefixする
です。
9. PHPCS suppressionまで消えていないか確認した
今回もう一つ面白かったのが、Plugin Check対応との関係です。
以前の記事でも書いたように、このプラグインではdatabase dumpのために proc_open() を使用しています。
これは意図的な設計なので、理由をコメントした上で、局所的なPHPCS suppressionを入れています。
ところがrelease buildではソースコード自体をPHP-Scoperが変換します。
ならば、
変換の結果、そのsuppression commentが失われる可能性はないか
も確認した方が安全です。
そこでbuild scriptでは、変換後のファイルに、
phpcs:ignore Generic.PHP.ForbiddenFunctions.Found
が残っていることまで確認しています。
少し細かすぎるようにも見えます。
しかし、
開発sourceではPlugin Check 0件
↓
release buildで変換
↓
release ZIPでは警告再発
では意味がありません。
最終的に利用者へ配布するのはrelease ZIPだからです。
10. 変換後にもう一度syntax checkする
当然ですが、scoping前にPHP syntax checkが通っていても、
PHP-Scoper
↓
patcher
↓
autoload再生成
を行った後で壊れていない保証にはなりません。
そこで、
original source
↓
php -l
PHP-Scoper
transformed source
↓
もう一度 php -l
としています。
つまりsyntax checkを2回行います。
最初のチェックは、
元ソースが壊れていないか
後のチェックは、
build processがコードを壊していないか
を見るものです。
同じ php -l でも意味が違います。
11. composer.lock はbuildには使うがreleaseには入れない
依存関係を再現可能にするため、build時には composer.lock を使います。
composer.json
composer.lock
↓
composer install
です。
一方、最終releaseでは composer.lock は削除しています。
今回の考え方では、
composer.lock
→ releaseを再現するためのbuild input
vendor/
→ 実際にWordPressで動かすruntime dependency
と役割を分けています。
つまり開発repositoryと配布ZIPは同じものではありません。
Git repository
↓ build
Release package
という関係です。
この考え方は、Plugin CheckをGitHub Actionsへ組み込むときにも重要になりました。
12. 最終的にチェックするのはrelease ZIP
release buildが完成した後は、
ozeki-database-backup-for-s3-x.y.z.zip
を生成します。
そしてGitHub Actionsでは、このZIPをもう一度展開してPlugin Checkへ渡しています。
つまり、
Source
↓
Composer
↓
PHP-Scoper
↓
release build
↓
ZIP
↓
展開
↓
Plugin Check
です。
ソースコードをPlugin Checkするだけではありません。
PHP-Scoperで変換され、実際に配布される状態になったコードをチェックしています。
最終的にGitHub Actionsでは、
Success: Checks complete. No errors found.
まで確認できるようになりました。
13. 依存関係の隔離はWordPressだけの問題ではない
今回この仕組みを作ってみて、これはAWS SDKだけの話ではないと感じました。
WordPressプラグインが、
AWS SDK
Google API Client
Guzzle
Symfony components
PSR packages
など大きなComposer dependencyを同梱する場合、同じ問題が起こる可能性があります。
特に、
自分のプラグイン単体では正常
でも、
別プラグインと同時に有効化したら壊れる
という問題は厄介です。
開発環境では再現しにくいからです。
その意味で、
third-party dependencyをプラグイン固有のnamespaceへ隔離する
という方法には大きな意味があります。
もちろん、すべてのWordPressプラグインでPHP-Scoperが必要とは思いません。
依存関係が小さい場合や、WordPress自身が提供しているAPIだけで実装できる場合には、むしろ使わない方が単純です。
しかし、大きな外部SDKを配布物へ同梱する場合には、
他のプラグインと同じPHPプロセスで動く
というWordPress特有の条件を考慮する必要があります。
14. 「依存関係を管理する」と「依存関係を隔離する」は違う
今回一番整理されたのは、この違いです。
Composerは、
自分のプロジェクトに必要な依存関係を管理する
ための仕組みです。
PHP-Scoperは今回、
その依存関係を他のプラグインから隔離する
ために使いました。
整理すると、
Composer
↓
どのdependencyを使うか管理
composer.lock
↓
どのversionを使うか固定
PHP-Scoper
↓
他pluginとのnamespace衝突を避ける
build-release.sh
↓
毎回同じ手順でreleaseを作る
GitHub Actions
↓
完成したreleaseを検査する
という役割分担になります。
一つのツールですべて解決しようとしない方が、結果として分かりやすくなりました。
まとめ
最初は、
composer require aws/aws-sdk-php
でAWS SDKを入れれば、それで終わりだと思っていました。
実際、プラグイン単体で動かすだけなら、それでも動きます。
しかし公開するWordPressプラグインとして考えると、
他のプラグインが何をロードしているか分からない
という条件が加わります。
そこで、
Composer
↓
PHP-Scoper
↓
namespace isolation
↓
verification
↓
release ZIP
↓
Plugin Check
というrelease pipelineを作ることになりました。
ここで重要だったのは、
PHP-Scoperを使ったこと
そのものよりも、
変換した結果が本当に意図した状態になっているかをbuild時に検証する
ことだったと思います。
外部ライブラリを同梱する場合、
installできた
autoloadできた
だけではなく、
期待したclassが存在する
期待しないclassは存在しない
WordPress側のclassは壊していない
変換後もsyntaxが正しい
最終ZIPもPlugin Checkを通る
ところまで確認する。
今回のプラグイン開発を通して、
依存関係は、入れるところまでではなく、他と共存できるところまで考える
必要があることを学びました。
今回使用しているrelease buildとPHP-Scoperの設定はGitHubで公開しています。
プラグイン本体はこちらです。