Laravel&Larastan(PHPStan)で静的コード解析を行う
- 公開:
- カテゴリ: PHP Laravel
- タグ: PHP,Laravel,PHPStan,Parsing,Larastan
PHPでWEBアプリケーション開発を行う際の静的コード解析ツールと言えばPHPStanが有名ですが、それをLaravel用に最適化したLarastanというパッケージがあります。
今回は、Larastanを使ってLaravelアプリケーションの実装コードの静的解析を行っていきます。
- アジェンダ
開発環境
今回の開発環境は以下の通りです。
- PHP 7.3
- Laravel 5.8
- Composer 1.8
Laravelのルートディレクトリを「laravel/」としています。
静的コード解析
静的コード解析 (Static Code Analysis)は、実行ファイルを動作させる事なく解析を行い、定義に対しての振る舞いに一貫性があるかどうかを検証する事を指します。「静的解析」「静的プログラム解析」とも呼ばれています。
前回の記事(LaravelにPHP_CodeSnifferを導入しコーディング規約(PSR)に沿った記述を行う)で紹介した構文チェックも、広義にはこれに含まれます。
Larastan
Larastanは、Laravel用のPHPStanラッパーです。
https://github.com/nunomaduro/larastan
2018年11月にリリースされた比較的新しいパッケージになります。
利用するには以下のシステム要件を満たしている必要があります。
- PHP
- 7.1.3 以上
- Laravel
- 5.6 以上
LaravelにPHPStanを導入する場合、Laravelの仕様上インストールしただけではスムーズに使えず、マジックメソッドなどフレームワークの仕様部分を一部フォローしてあげる(PHPStanに適切に理解させる)必要があります(2019年8月現在)。
LarastanはPHPStanをラップし、この部分を解決させたパッケージになります。
FW仕様のフォローに時間をかけない事、フォローの為のパッケージや設定ファイルを増やさない事を考えると選択肢の1つとしておすすめです。
インストール
composerにて導入します。以下のコマンドを叩いてインストールします。
# プロジェクトルートへ移動
cd /path/to/laravel
# Larastanインストール
composer require --dev nunomaduro/larastan
静的コード解析実行
まだ何も実装を行ってはいませんが、動作確認を兼ねて静的コード解析を実行してみます。
appとtestsディレクトリを対象に、レベルはMAX(最高値=7=最も厳しい)で解析を行います。実行には以下のartisanコマンドを叩きます。
# 静的コード解析実行
php artisan code:analyse --paths="app,tests" --level=max
# 実行結果
30/30 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
[OK] No errors
エラーなく実行できる事が確認できました。
ちなみにLarastanは、デフォルト(オプション未指定)で実行した場合は以下の動作をします。
- 解析対象(ファイルやディレクトリ)未指定の場合
- appディレクトリのみを見に行く
- レベル未指定の場合
- レベル5で実行
レベルは0~7まであり、7が最大値(厳しい)となっています。
phpstan.neon
artisanコマンドにオプションである解析対象のパスとレベルを付けて実行しましたが、LarastanもPHPStanと同じように、phpstan.neonへオプションを書き出し、コマンドを簡単にすることができます。
- laravel/phpstan.neon
-
includes:
- ./vendor/nunomaduro/larastan/extension.neon
parameters:
level: max
paths:
- app
- tests
ポイントは larastan の extension.neon をincludeする点です。phpstan.neonを作成する場合はこれを行わないとLarastanの設定が読み込まれないので恩恵に預かる事ができません。
そして1点問題があります。
Larastanのextension.neonでは「paths」が効きません。
つまり上記で設定した「app」と「tests」は無視されます。なので結局artisanコマンドでpathsオプションは付けてあげないとappディレクトリのみを解析してしまいます。
これはバグとして認識されているのですが、2019年8月現在、まだ修正されていません。
phpstan.neon paths not working · Issue #202 · nunomaduro/larastan
修正されるまではcomposer.jsonのscriptsに登録して使うのが良いかもしれません。
- laravel/composer.json
-
"scripts": {
#
# 省略
#
"larastan": [
"@php artisan code:analyse --paths=\"app,tests\" --level=max"
]
}
実装コードを解析する1
ここからは実際にコーディングを行い、静的コード解析を行っていきます。
- laravel/app/Http/Controllers/SampleControllers.php
-
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class SampleController extends Controller
{
public function index()
{
$a = 1280;
$b = 2500;
return $this->addition($a, $b);
}
/**
* 加算
* @param int $a
* @param int $b
* @return int
*/
private function addition(int $a, int $b): int
{
return $a + $b;
}
}
上記は足し算を行う処理ですが、このコードは問題無いのでテストに通ります。
ここから、一方の値を文字列型にしてみます。
public function index()
{
$a = 1280;
// $b = 2500;
$b = '2500';
return $this->addition($a, $b);
}
この状態で静的解析を実行すると、エラーとなります。
------ --------------------------------------------------------------------------------------------------------
Line app/Http/Controllers/SampleController.php
------ --------------------------------------------------------------------------------------------------------
16 Parameter #2 $b of method App\Http\Controllers\SampleController::addition() expects int, string given.
------ --------------------------------------------------------------------------------------------------------
[ERROR] Found 1 error
足し算を行うaddition()メソッドの引数には数値型を指定しているので、静的コード解析で文字列型が渡ってくる事が判明した為、エラーとなりました。
この例は実装もエラー箇所も単純なのでなかなか解析の有り難みを受けにくいかもしれませんが、最大の恩恵は処理を実行せずにこうした不整合を発見できた事にあります。
例えば処理がもっともっと入り組んで複雑な機能などの場合では知らず知らずのうちに意図しない形式のデータを渡してしまう事もありますし、チームで開発していて自分以外のメンバーが実装した処理の仕様に気づかない場合もあります。
これらは間々ある事ですが、それを、コードを実行せずに判定出来るという事がこの静的コード解析の最大の利点です。
実装コードを解析する2
もう一つ例を見てみます。今度はEloquentを絡めてデータを取得します。
- laravel/app/Http/Controllers/SampleControllers.php
-
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\User;
class SampleController extends Controller
{
public function get()
{
$user = User::where('id', 1)->first();
return $this->userOperation($user);
}
/**
* ユーザーデータを処理する(つもり)
* @param User $user
* @return User
*/
private function userOperation(User $user)
{
/*
* 色々な処理...
*/
return $user;
}
}
上記はユーザーのデータをデータソースから取得し、それを何かしらの処理を行ってから返却するまでの一連の処理になっています。
この実装の場合、静的解析を行うとエラー無く通ります。
では以下の場合ではどうでしょうか
public function get()
{
// $user = User::where('id', 1)->first();
$user = User::where('id', 1)->get();
return $this->userOperation($user);
}
この場合はエラーになります。
前者はfirst()で取得しているので単数での取得となりUserオブジェクトが返却されるのに対して、後者はget()で取得しているのでCollectionオブジェクトで返却されます。
よって、次のuserOperation()に渡す際の引数のタイプヒンティングで不整合が起きる為にエラーとなってしまいます。
取得出来るデータはどちらでも目的通りのものになりますが、少しの違いでこうして意図しないものを次へ渡してしまう事が結構カジュアルに起こるものです。
もちろんこれは実行すればエラーとなりスタックトレースが表示されますが、わざわざブラウザを叩かなくても静的解析を走らせれば処理を実行しなくてもミスに気づく事ができます。
実装コードを解析する3
ちなみに、データソースから取得するデータは必ずしも期待した結果(同一の型)ではない事もあります。
// レコードは id < 10 しか存在しないのに
$user = User::where('id', 100)->first();
Eloquentのfirst()メソッドでの取得の場合は、データが存在する場合はUserオブジェクトが返りますが、データが存在しない場合はnullが返ってきます。
ちなみにこのメソッドのPHPDocには、返り値が以下の様に記述されています。
/**
* Execute the query and get the first result.
*
* @param array $columns
* @return \Illuminate\Database\Eloquent\Model|object|static|null
*/
public function first($columns = ['*'])
{
return $this->take(1)->get($columns)->first();
}
これでは次の、引数をUserオブジェクトとしてタイプヒンティングしているuserOperation()メソッドには値を渡す事ができません。
PHPStanでは実行ファイルを動作させるわけではないのでメソッドの返り値などをPHPDocから読み取ったりしながら解析を行います。
つまりnullが返ってくる可能性があると判断するので、静的解析を走らせるとエラーとなります。
------ --------------------------------------------------------------------------------------------------------------------
Line app/Http/Controllers/SampleController.php
------ --------------------------------------------------------------------------------------------------------------------
51 Parameter #1 $user of method App\Http\Controllers\SampleController::userOperation() expects App\User, mixed given.
------ --------------------------------------------------------------------------------------------------------------------
「Userオブジェクトの型指定してるけど、これ実際にはmixedな型で入ってきますよ。」
しかしながらこれは正常な判定です。
この実装の場合、必ずユーザーデータが返ってくる(し、返ってくるであろう検索しか行わない)と暗黙的に了解し実装してしまっているので、それに伴って次のuserOperation()メソッドでも、Userオブジェクトの型指定を行ってしまっている事に気付かされます。
でも実際は、nullである場合の状況もハンドリングしてあげるべきなのです。
という事で、しっかり処理を挟みます。
public function get()
{
// レコードは id < 10 しか存在しないのに
$user = User::where('id', 100)->first();
if(!$user) {
throw new \Exception('User not found.');
}
return $this->userOperation($user);
}
「必ずユーザーデータが返ってくる」という前提なので、今回はnullなら例外を投げています。
どういう処理を行えばスムーズ且つクリーンなのかは状況によりますが、こうして然るべき状態をハンドリングしてあげることで次の処理に想定外の値(型)が渡される事が無くなるので、堅牢な実装を継続する事ができます。
今回の場合、もし型指定を外してもnull値が渡ったらエラーになるでしょう。しかも、「必ずユーザーデータが返ってくる」前提で実装されているので、そうでなかった時の処理はおそらく書かずにリリースされる可能性もあります。
気づく事が出来たからこそ予めハンドリングを行えて、異常終了しない処理を実装できそうです。
そして何よりこの実装なら、静的コード解析を無事に通過します。
[OK] No errors
エラーを無視する
にっちもさっちもいかなくなったら、エラーを無視するという手段も取れます。
その場合は設定ファイルにignoreErrorsを定義します。
- laravel/phpstan.neon
-
includes:
- ./vendor/nunomaduro/larastan/extension.neon
parameters:
level: max
paths:
- app
- tests
ignoreErrors:
- '#Parameter \#1 \$user of method App\\Http\\Controllers\\SampleController::userOperation\(\) expects App\\User, mixed given#'
# 正規表現も使用可能
# - '#Parameter \#1 \$[a-z]{1}[a-zA-Z0-9_]+ of method App\\Http\\Controllers\\SampleController::userOperation\(\) expects App\\User, mixed given#'
正規表現も使えます。上記のコメントアウトされている記述は、変数名を正規表現で置き換えた場合です。これでも適用されます。
ただこれは、FWの性格上どうしても回避せざるを得ない場合とかに限り使用するのが良いと思います。きちんと動作を理解した上で無視しないと、本来エラー検知しなくてはならないところを通過させてしまう可能性があるからです。
そしてなんでもかんでも除外していると、気がついたら結構な量のエラー無視になり、管理しきれなくなります。これでは、静的コード解析を行う意味がありません。
まとめ
以上で作業は終了です。静的コード解析を行う事で実装中の気づきは沢山得られると思います。
開発はトライ&エラーの繰り返しですが、大切な事は走らせたアプリケーションを予想外の振る舞いで止めない事。未知の脅威の排除はできるだけしておきたいところです。その為にこういった解析ツールも前向きに導入していきたいですね。