WHITEPLUSDay 24

PHPStanによる静的解析をLaravelアプリケーションに導入するためにやったこと

これはWHITEPLUS Advent Calendar 2018の24日目の記事です。



はじめに

先日、無事弊社サービス「リネット1」のアプリケーションをPHP 5.6 + Laravel 4.2というレガシーな環境から、PHP 7.2 + Laravel 5.5というモダンな環境にアップグレードすることができました。めでたい:confetti_ball::confetti_ball:

この際、一緒に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


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.phpphpstan.neonautoload_filesオプションに指定して読み込むようにすると、PHPStanがファサードを理解するようになり、エラーが出なくなります。


phpstan.neon

parameters:

autoload_files:
- _ide_helper.php


Eloquentのエラーの対応

where()find()などのEloquentのメソッドもファサードと同様エラーになります。

これらのメソッドもマジックメソッドだからです。

先ほど生成した_ide_helper.phpの中にEloquentクラスが定義されていて、そのクラスのメソッドとしてwhere()find()などが実在のメソッドとして定義されているので、それを利用するのが一番手取り早いです。

普通はIlluminate\Database\Eloquent\Modelを継承してEloquentモデルを作っていると思いますが、代わりにEloquentを継承するように変更します。

実コードを変更するのは気持ち悪いですが、EloquentIlluminate\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.neonignoreErrorsオプションでまるっと無視するようにしました。(もっといい方法ないかな…。)


phpstan.neon

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モデルが置いてあるディレクトリを指定します。


config/ide-helper.php

'write_model_magic_where' => true,



config/ide-helper.php

'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を生成するようにします。


config/ide-helpers.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.neonexcludes_analyseオプションで解析対象外とすることで対応しています。


phpstan.neon

parameters:

excludes_analyse:
- path/to/CustomFacade.php


その他、どうしようもない系のエラーの対応

どうしようもない系のエラーは無視するようにphpstan.neonで設定できます。

リネットではRedisを使用するためにpredis/predisパッケージを利用していますが、このパッケージを利用した場合Redis::get()などのメソッドは実在せず静的解析できません。

Laravel本体のファイルではないので_ide_helper.phpを利用することもできません。

仕方がないのでエラーが出ても無視するようにしてます。


phpstan.neon

parameters:

ignoreErrors:
- '#Call to an undefined static method Redis::[a-zA-Z0-9\\_]+\(\)#'
reportUnmatchedIgnoredErrors: false


require(require_once)への対応

すべてのファイルがオートロードされている世界が理想ですが、現実は厳しいです。

解析対象ファイルの中でrequirerequire_onceが使われていても、PHPStanはそのファイルを読み込みません。

例えば、関数が定義されたファイルがあり、それをrequireして使用している場合、PHPStanはその関数を見るけることができずエラーとして検出します。

これを回避するために、設定ファイルのautoload_filesオプションを使用して、静的解析の実行前に読み込んでおきたいファイルを指定できます。


phpstan.neon

parameters:

autoload_files:
- functions.php


ブートストラップ処理

PHPStanの実行前にブートストラップ処理をしたいこともあります。

設定ファイルのbootstrapオプションを使用してブートストラップファイルを指定することができます。

リネットの場合、phpstan.neonと同じ階層にphpstan-bootstrap.phpという名前でブートストラップファイルを作成しています。

リネットのプログラムに必要な$_SERVER変数や定数を定義したり、Laravelのbootstrap/app.phpを読み込んだりしています。


phpstan.neon

parameters:

bootstrap: phpstan-bootstrap.php


phpstan-bootstrap.php

<?php

// 必要な$_SERVER変数や定数の定義

// Laravelのブートストラップファイルの読み込み
require_once __DIR__ . '/bootstrap/app.php';



解析可能な範囲

(当然と言えば当然ですが)PHPStanはビューの解析には使えません。

Laravelの場合で言うと、ビューを解析しようとしても、コントローラから渡される変数がことごとく未定義変数扱いになってしまいます。

これに関しては、GitHubで/** @var */コメントを使った回避方法が提案されていましたが6、今のところ対応していないので諦めるしかないです。

リネットでは、PHPStanの対象を「ビジネスロジック」「コントローラ」「ORM」「Laravelのappディレクトリ」に限定しています。


ラッパーコマンド

簡単にPHPStanを実行できるように、Composerのscriptsを使ってラッパーコマンドを定義しています。


composer.json

{

"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点です。


  1. 最も緩いレベル0から始める

  2. すべてのエラーを修正してからCIで回す

最初から厳しいレベルで始めると絶対に心が折れます(笑)。また、すべてのエラーを修正する前にCIで回し始めると、割れ窓理論が発動して、誰もエラーを気にしなくなる可能性大です。

最も緩いレベルから始めて、エラーがない状態をしばらく維持できるようであれば、段階的に1ずつレベルを厳しくしていくのがいいと思っています。

新規プロジェクトの場合は、逆に最初から最も厳しいレベルにしておいた方があとあと楽になるのでいいと思います。


設定ファイル例

リネットのphpstan.neonは下記のような雰囲気になっています。


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 の担当です。よろしくお願いします。


参考サイト





  1. 宅配クリーニングの『リネット』」「クリーニング×保管の『リネット保管」「布団クリーニングの『ふとんリネット』」「靴クリーニングの『くつリネット』」の4サービス。 



  2. 仕様を満たしていないバグとか仕様自体のバグは当然ですが検出できませんので、然るべきテストをしましょう。 



  3. PHPStanとPhanの比較はコネヒトさんの記事がとてもわかりやすいです。 http://tech.connehito.com/entry/phan-or-phpstan 



  4. Laravel 5.6以上であればLarastanというパッケージを利用できます。このパッケージを利用すれば、この辺りの泥臭い対応を自分でやらずに済むかもしれません。しかし、このパッケージはLaravel 5.5(LTS)には未対応です:cry: https://github.com/nunomaduro/larastan 



  5. Laravel 5.6では修正されているようでした。 https://github.com/laravel/framework/pull/24173 



  6. 実装される見込みは薄いと思います。 https://github.com/phpstan/phpstan/issues/351