0
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?

WordPressプラグインでComposer依存関係を安全に同梱する

0
Posted at

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で公開しています。

プラグイン本体はこちらです。

0
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
0
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?