続きがあります。
PHPStanによる静的解析をLaravelアプリケーションに導入するためにやったこと(レベル1編)
これはWHITEPLUS Advent Calendar 2018の24日目の記事です。
はじめに
先日、無事弊社サービス「リネット1」のアプリケーションをPHP 5.6 + Laravel 4.2というレガシーな環境から、PHP 7.2 + Laravel 5.5というモダンな環境にアップグレードすることができました。めでたい
この際、一緒にPHPStanを導入し、静的解析を行うようにしました。
レベル0(最も緩いレベル)ですが、すべてのエラーを修正しています。
今日は、リネットのLaravelアプリケーションにPHPStanによる静的解析を導入するためにやったことについて書きます。
パッケージのバージョン
本記事で使用している各パッケージのバージョンは下記の通りです。
パッケージ | バージョン |
---|---|
PHP | 7.2.12 |
Laravel | 5.5.44 |
PHPStan | 0.10.5 |
Laravel 5 IDE Helper Generator | 2.5.1 |
PHPStanとは?
PHPStanとはバグ検出を目的とした静的解析ツールす。
テストとは違い、実際にプログラムを実行することなくバグを検出することができます2。
PHP 7.0以降で利用可能です。
同じような目的を持ったツールにPhanがあります。
リネットではCircleCIで静的解析を実行したかったので、実行速度が速いPHPStanの方を採用しました3。
インストール方法
Composerでインストール可能です。
composer require --dev phpstan/phpstan
使い方
analyse
コマンド使って静的解析を実行できます。
下記のコマンドでは、src
ディレクトリに対して静的解析を実行しています。
vendor/bin/phpstan analyse src
-l
オプションで静的解析のレベルを指定できます。指定可能なレベルは0〜7の8段階で、0が最も緩く、7が最も厳しいです。
vendor/bin/phpstan analyse -l 0 src
また、-c
オプションで設定ファイル(phpstan.neon
)を指定して静的解析を実行できます。PHPUnitにおけるphpunit.xml
のようなものです。
vendor/bin/phpstan analyse -c phpstan.neon
parameters:
level: 0
paths:
- src
Laravelに導入するためにやったこと
Laravelは内部でマジックメソッドを使って色々やっているため、単純にPHPStanを導入しただけでは大量にエラーが出てしまうため、泥臭く対応していく必要がありました4。(特にファサードとEloquentまわり)
ここでは、LaravelにPHPStanのレベル0を導入するためにやったことについて書きます。レベルを上げるとまた別の問題が出てくるかもしれません。
ファサードのエラーの対応
何もせずPHPStanを実行すると、ファサードを使用している箇所で下記のようなエラーが出ます。
Call to static method view() on an unknown class Response.
ファサードは実在するクラスではないため、PHPStanが理解できないためです。
この類のエラーは、Laravel 5 IDE Helper Generatorを利用することで解決できます。
このパッケージはLaravelのIDE補完を可能にするためのもので、IntelliJ IDEAやPhpStormを使って開発しているプロジェクトであれば、すでにインストールしてあるかもしれません。
まだの場合はComposerを使ってインストールできます。
composer require --dev barryvdh/laravel-ide-helper
インストール後、下記のコマンドを実行して、IDE補完用のヘルパーファイルを生成します。
artisan ide-helper:generate
生成されたヘルパーファイル(_ide_helper.php
)の中を見るとわかりますが、Laravel本体のクラスがずらっと定義されており、またファサードも実在のクラスとして定義されています。
このヘルパーファイル(_ide_helper.php
)の本来の用途はLaravelのIDE補完を可能にするためのものですが、これを利用することで、PHPStanを実行した際のファサードのエラーに対応できます。
_ide_helper.php
をphpstan.neon
のautoload_files
オプションに指定して読み込むようにすると、PHPStanがファサードを理解するようになり、エラーが出なくなります。
parameters:
autoload_files:
- _ide_helper.php
Eloquentのエラーの対応
where()
やfind()
などのEloquentのメソッドもファサードと同様エラーになります。
これらのメソッドもマジックメソッドだからです。
先ほど生成した_ide_helper.php
の中にEloquent
クラスが定義されていて、そのクラスのメソッドとしてwhere()
やfind()
などが実在のメソッドとして定義されているので、それを利用するのが一番手取り早いです。
普通はIlluminate\Database\Eloquent\Model
を継承してEloquentモデルを作っていると思いますが、代わりにEloquent
を継承するように変更します。
実コードを変更するのは気持ち悪いですが、Eloquent
はIlluminate\Database\Eloquent\Model
のエイリアスなので、プログラムの実行に差は出ないはずです。
<?php
abstract class BaseModel extends \Eloquent
{
}
これで最初に出てきたEloquentのメソッドに関するエラーは出なくなります。
ただ、代わりにwhere()
メソッドやorWhere()
メソッドで下記のようなエラーが出るようになります。
Parameter #2 $operator of method Eloquent::where() expects string|null, int given.
これは、Laravel 5.5のIlluminate\Database\Eloquent\Builder::where()
とIlluminate\Database\Eloquent\Builder::orWhere()
のDocコメントの第2引数の型が、mixed
が正しいのにstring|null
と間違って記述されているためです5。そのせいで_ide_helper.php
が間違った型情報で生成されてしまっています。
_ide_helper.php
を編集してもいいんですが、自動生成ファイルを編集するのは気持ち悪かったので、phpstan.neon
のignoreErrors
オプションでまるっと無視するようにしました。(もっといい方法ないかな…。)
parameters:
ignoreErrors:
- '#Parameter \#2 \$operator of method Eloquent::where\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
- '#Parameter \#2 \$operator of method Eloquent::orWhere\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
- '#Parameter \#2 \$operator of static method Eloquent::where\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
- '#Parameter \#2 \$operator of static method Eloquent::orWhere\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
reportUnmatchedIgnoredErrors: false
ignoreErrors
オプションには無視したいエラーメッセージを正規表現で指定することができます。
reportUnmatchedIgnoredErrors
オプションをfalse
にしておかないと、ignoreErrors
オプションに指定した正規表現にマッチするエラーが発生しなかった場合に解析失敗になってしまうので注意が必要です。
Eloquentのマジックwhereメソッドのエラーの対応
マジックwhereメソッドとは下記のようなものです。
// 通常のwhereメソッド
User::where('email', 'example@example.com');
// マジックwhereメソッド
User::whereEmail('example@example.com');
マジックwhereメソッドもマジックメソッドなので、PHPStanは理解できません。
これを解決するために、またもやLaravel 5 IDE Helper Generatorを利用します。
まず、Laravel 5 IDE Helper Generatorの設定ファイルを編集し、write_model_magic_where
オプションにtrue
を指定して、model_locations
オプションにEloquentモデルが置いてあるディレクトリを指定します。
'write_model_magic_where' => true,
'model_locations' => array(
'path/to/models',
),
その後、下記のコマンドを実行します。
artisan ide-helper:models -W
実行すると、各Eloquentモデルのクラスに、マジックwhereメソッドの定義情報が記載されたDocコメントが付与されます。
PHPStanがこのコメントを理解して、マジックwhereメソッドが存在するものとして静的解析を実行してくれるようになり、エラーが出なくなります。
ファサードクラスに直接定義されたメソッドのエラーの対応
ここで言うファサードクラスとはIlluminate\Support\Facades\Facade
を継承したクラスのことです。
ファサードクラスにはgetFacadeAccessor()
メソッドしか定義されていないかと思いきやそうではなく、それ以外にもメソッドが定義されている場合があります。
例えばCookie::get()
メソッドがそうです。
Cookie::get()
メソッドはIlluminate\Support\Facades\Cookie
に直接定義されています。
こういった類のメソッドは_ide_helper.php
に含まれないので、PHPStanが理解してくれません。
これを解決するために、Laravel 5 IDE Helper Generatorの設定ファイルのextra
オプションに設定を追加し、Cookie
に関してはIlluminate\Support\Facades\Cookie
も見て_ide_helper.php
を生成するようにします。
'extra' => array(
'Eloquent' => array('Illuminate\Database\Eloquent\Builder', 'Illuminate\Database\Query\Builder'),
'Session' => array('Illuminate\Session\Store'),
// 追加
'Cookie' => array('Illuminate\Support\Facades\Cookie'),
),
これで再度_ide_helper.php
を生成すると、Illuminate\Support\Facades\Cookie
に直接定義されたメソッドもCookie
クラスのメソッドとして定義されて、PHPStanのエラーが出なくなります。
この方法がベストなのかはわからないですが、現状これで対処しています。
カスタムファサードのエラーの対応
また、カスタムファサードを定義している場合、そのカスタムファサードクラスを解析すると下記のエラーが出てしまいます。
Method getFacadeAccessor() was not found in reflection of class
これはphpstan.neon
のexcludes_analyse
オプションで解析対象外とすることで対応しています。
parameters:
excludes_analyse:
- path/to/CustomFacade.php
その他、どうしようもない系のエラーの対応
どうしようもない系のエラーは無視するようにphpstan.neon
で設定できます。
リネットではRedisを使用するためにpredis/predis
パッケージを利用していますが、このパッケージを利用した場合Redis::get()
などのメソッドは実在せず静的解析できません。
Laravel本体のファイルではないので_ide_helper.php
を利用することもできません。
仕方がないのでエラーが出ても無視するようにしてます。
parameters:
ignoreErrors:
- '#Call to an undefined static method Redis::[a-zA-Z0-9\\_]+\(\)#'
reportUnmatchedIgnoredErrors: false
require(require_once)への対応
すべてのファイルがオートロードされている世界が理想ですが、現実は厳しいです。
解析対象ファイルの中でrequire
やrequire_once
が使われていても、PHPStanはそのファイルを読み込みません。
例えば、関数が定義されたファイルがあり、それをrequire
して使用している場合、PHPStanはその関数を見るけることができずエラーとして検出します。
これを回避するために、設定ファイルのautoload_files
オプションを使用して、静的解析の実行前に読み込んでおきたいファイルを指定できます。
parameters:
autoload_files:
- functions.php
ブートストラップ処理
PHPStanの実行前にブートストラップ処理をしたいこともあります。
設定ファイルのbootstrap
オプションを使用してブートストラップファイルを指定することができます。
リネットの場合、phpstan.neon
と同じ階層にphpstan-bootstrap.php
という名前でブートストラップファイルを作成しています。
リネットのプログラムに必要な$_SERVER
変数や定数を定義したり、Laravelのbootstrap/app.php
を読み込んだりしています。
parameters:
bootstrap: phpstan-bootstrap.php
<?php
// 必要な$_SERVER変数や定数の定義
// Laravelのブートストラップファイルの読み込み
require_once __DIR__ . '/bootstrap/app.php';
解析可能な範囲
(当然と言えば当然ですが)PHPStanはビューの解析には使えません。
Laravelの場合で言うと、ビューを解析しようとしても、コントローラから渡される変数がことごとく未定義変数扱いになってしまいます。
これに関しては、GitHubで/** @var */
コメントを使った回避方法が提案されていましたが6、今のところ対応していないので諦めるしかないです。
リネットでは、PHPStanの対象を「ビジネスロジック」「コントローラ」「ORM」「Laravelのapp
ディレクトリ」に限定しています。
ラッパーコマンド
簡単にPHPStanを実行できるように、Composerのscripts
を使ってラッパーコマンドを定義しています。
{
"scripts": {
"phpstan": [
"@php vendor/bin/phpstan analyse -c phpstan.neon"
]
}
}
composer phpstan
composer phpstan SomeClass.php
実行速度
参考として、リネットの場合のPHPStanの実行時間を記載します。
リネットには大きく分けて「リネット」「リネット保管」「ふとんリネット」「くつリネット」「CMS(内部向け管理画面)」「共通ライブラリ」という6つのプロジェクトがあり、各プロジェクト毎にPHPStanを実行していますが、CircleCI(2CPU、4GBメモリ)での実行時間は次の通りです。
プロジェクト | 解析対象ファイル数 | 実行時間 |
---|---|---|
リネット | 65 | 6秒 |
リネット保管 | 28 | 1秒 |
ふとんリネット | 17 | 1秒 |
くつリネット | 11 | 3秒 |
CMS | 268 | 21秒 |
共通ライブラリ | 1,395 | 34秒 |
CIで実行しても問題ないくらい速いと思います。
既存プロジェクトへの導入ポイント
既存のプロジェクトにPHPStanを導入する上で守ったほうがいいと思うポイントは下記の2点です。
- 最も緩いレベル0から始める
- すべてのエラーを修正してからCIで回す
最初から厳しいレベルで始めると絶対に心が折れます(笑)。また、すべてのエラーを修正する前にCIで回し始めると、割れ窓理論が発動して、誰もエラーを気にしなくなる可能性大です。
最も緩いレベルから始めて、エラーがない状態をしばらく維持できるようであれば、段階的に1ずつレベルを厳しくしていくのがいいと思っています。
新規プロジェクトの場合は、逆に最初から最も厳しいレベルにしておいた方があとあと楽になるのでいいと思います。
設定ファイル例
リネットのphpstan.neon
は下記のような雰囲気になっています。
# vim: set ft=yaml:
parameters:
level: 0
paths:
- %rootDir%/../../../app
- %rootDir%/../../../lib
bootstrap: %rootDir%/../../../phpstan-bootstrap.php
autoload_files:
- %rootDir%/../../../vendor/autoload.php
- %rootDir%/../../../_ide_helper.php
fileExtensions:
- php
ignoreErrors:
- '#Call to an undefined static method Redis::[a-zA-Z0-9\\_]+\(\)#'
- '#Parameter \#2 \$operator of method Eloquent::where\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
- '#Parameter \#2 \$operator of method Eloquent::orWhere\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
- '#Parameter \#2 \$operator of static method Eloquent::where\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
- '#Parameter \#2 \$operator of static method Eloquent::orWhere\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
reportUnmatchedIgnoredErrors: false
まとめ
PHPStanによる静的解析をLaravelアプリケーションに導入するためにやったことについて書きました。
Laravelはマジックメソッドを多用しているため、PHPStanを導入するのはかなり大変でした。おかげでマジックメソッドが嫌いになりました(笑)。
静的解析を導入したことで、テストコードがない部分に関してもある程度安心してリファクタリングできるようになったと感じています。
静的解析を活用して、安全かつ大胆にPHPアプリケーションを開発・運用していきたいと思います。
明日の担当
明日は弊社CTO @exmeat の担当です。よろしくお願いします。
参考サイト
- PHPコードの解析をPhanからPHPStanに移行しようか検討しています
- PHPStanで始めるPHPのための静的解析
-
「宅配クリーニングの『リネット』」「クリーニング×保管の『リネット保管」「布団クリーニングの『ふとんリネット』」「靴クリーニングの『くつリネット』」の4サービス。 ↩
-
仕様を満たしていないバグとか仕様自体のバグは当然ですが検出できませんので、然るべきテストをしましょう。 ↩
-
PHPStanとPhanの比較はコネヒトさんの記事がとてもわかりやすいです。 http://tech.connehito.com/entry/phan-or-phpstan ↩
-
Laravel 5.6以上であればLarastanというパッケージを利用できます。このパッケージを利用すれば、この辺りの泥臭い対応を自分でやらずに済むかもしれません。しかし、このパッケージはLaravel 5.5(LTS)には未対応です https://github.com/nunomaduro/larastan ↩
-
Laravel 5.6では修正されているようでした。 https://github.com/laravel/framework/pull/24173 ↩
-
実装される見込みは薄いと思います。 https://github.com/phpstan/phpstan/issues/351 ↩