コネヒト開発者ブログ

コネヒト開発者ブログ

PHPコードの解析をPhanからPHPStanに移行しようか検討しています

こんにちは、サーバーサイドのお仕事してます金城(@o0h_)です。
7月は「波よ聞いてくれ」と「ベアゲルター」が共に出るという、嬉しい事態でした。沙村作品は魅力的なキャラクターがいつも多いですが、その中でも女性キャラの強さ・芯の太さに圧倒されます。

[まとめ買い] ベアゲルター

さて、今回はタイトルの通り「(静的)解析どうするの」という問題について少し考えている事をまとめてみます。
「Phan」か「PHPStan」か、どっちが良いでしょうか?

https://cdn.mamari.jp/authorized/5b7e38f3-6834-4369-bea6-0018ac120002.jpg

現在コネヒトではPhanを導入してコードをチェックしています。
「道具で安心感を得られる」のは最高なので、Phanもまた最高なのです。しかしながら、実際に日常的に回していく中で、いくつか気になっていることもあります。

  1. 実行に時間がかかる
  2. (プロダクトでは利用しない)php-astエクステンションが必要になる
  3. エラーが発生した時の原因箇所の特定が大変

こうした中で、コネヒトのメインフレームワークであるCakePHPがPHPStanを利用しているのが目に付きました。*1
Phanと比較してみて、どのように違いがあるのでしょう?
実際に「乗り換えるのもアリかも」というのを視野に入れつつ比較検討してみたので、その内容をまとめてみたいと思います。

前提: コネヒトでの事情

それぞれ特徴やpros/consがあるツールなので、「目的に適っているか?」という観点で選定する必要があります。
まず最初に「コネヒトではどんな感じにコード解析入れているんだっけ」を整理したいと思います。

  1. PRごとにCI上で解析を入れている
    • 引っかかるようならレビューしない、通ってからコードを見る
  2. 最近、テストその他を含むCIの実行時間が気になってきている
  3. サーバーサイドエンジニアはPHPStormを利用している
    • 殆どのケースは書いている最中に検出されて、コミット前に対処される
  4. PHP 7.0 / 7.1がメイン
  5. 全てのメソッドにphpdoc必須
  6. 開発環境はDocker/docker-composeで容易に共有可能

大体この辺りの項目を洗い出すと「1番おすすめの解析」が浮かび上がってくるでしょうか。

これを抑えつつ、「ツールとしての方向性」「検査内容」「導入の簡易度」「実行速度」「拡張性・柔軟性」の5観点から調べてみたいと思います。

比較0: 人気度、注目度、話題性

具体的な比較に入る前に、まずはざっくりと両者を取り巻く環境や全体感についてです。

Phan PHPStan
レポジトリ phan/phan: Phan is a static analyzer for PHP. Phan prefers to avoid false-positives and attempts to prove incorrectness rather than correctness. phpstan/phpstan: PHP Static Analysis Tool - discover bugs in your code without running it!
公式による導入記事 Getting Started · phan/phan Wiki PHPStan: Find Bugs In Your Code Without Writing Tests!
国内での関連発表等 PhanでPHPコード静的解析2018 #phpstudy / 黒點 さん - ニコナレ PHPStanで始める継続的静的解析 #phperkaigi /php-static-analysis - Speaker Deck

人気度、開発状況

最初に「人の流れ」を見てみると、どちらも「人気度」「注目度」ともに、同程度のパワーを持っています。 phan vs PHPStan | LibHunt

また、GithubでInsightsを見てみると、双方とも直近まで活発な開発が継続していることが見て取れます。
そのため、どちらとも「選んで問題ない」といえるOSSとなるでしょう。

国内での盛り上がり

国内のPHP界隈でPHPStanが盛り上がったのは、やはり #phperkaigi でのこのスライドによるところは大きいのではないでしょうか。

Phanについては、その後の5月に発表された記事が楽しいです。

こちらは「PHPStanと比較されがちだよね」という観点も取り込まれていて、非常に分かりやすかったです。実際、私はこちらの発表内容を根拠として「PHPStanやってみない?」をチームに提案してみた次第です。*2

比較1: ツールとしての方向性

これは先に紹介したスライドにて説明があるので、そちらを参照してください。
最も大きいのは「完全に静的な解析か否か」という設計思想の違いだと思います。

  • Phan
    • 静的解析を行う = PHPを「ドキュメント」的に読んで、内容の矛盾や不備をレポートする
    • 定義されているクラスやメソッドを「先に読み込んで」から、対象の解析を行う
    • 実際の環境と関係ない仕組みで解析の実行が可能
      • 例えばPHP5.6のコードもPhanに掛けられる
    • デーモンモードとやらが・・*3
  • PHPStan
    • 一部動的に解析を行う = PHPを「コード」として理解した上で、内容の矛盾や不備をレポートする
    • 名前空間ベースの遅延読み込みが可能、PHPで設定ファイルを記述可能
      • 例えば定数定義等プロダクトコードのbootstrapを流用できる
      • この場合、実行環境への依存もあり得る

といったところでしょうか。

比較2: 検査内容

どんなルールがあるか

Phan

Phanについては、公式のwikiが充実していました。
それでも「どうやったら解消するんや・・」と悩むIssueも時たまありましたが、一覧があるのはとても親切だなーと感じます。

Issue Types Caught by Phan · phan/phan Wiki · GitHub

PHPStan

PHPStanの方は、公式の管理している検査内容の一覧は見つけることができませんでした・・・(どなたかご存知でしたら教えてください!) src/Rulesを眺めると、実装ベースで定義内容を見ることは出来そうです。

ドキュメントを読みながら「特徴的だな」と思ったことの1つに「処理の中断を起こすメソッドを考慮できる」といったものがありました。
これはどういうことか、READMEに記述されている例を見てみましょう。

<?php

if (somethingIsTrue()) {
    $foo = true;
} elseif (orSomethingElseIsTrue()) {
    $foo = false;
} else {
    $this->redirect('homepage');
}

doFoo($foo);

こちらのコードでは、else句の中に入った場合に 変数$fooが未宣言 となります。
しかし、実際には redirect() 以降のコードは処理されない(よって $foo が未定義として処理されるのはありえない)・・・という状況です。
PHPStanだと、この SomeClass::redirect()メソッド を「処理の中断を伴う(returnする、例外を投げるなど)」ものとして設定ファイル内で明示指定することで、続くコードの解析の扱いを変更することが可能です。

レベルについて

Phan、PHPStanとも「レベル」という概念を持っています。これによって、「よくある感じの」ルールセットを 導入することができます。
既存プロジェクトへの導入に際しては、最初は低いレベルを使って緩く適用しながら徐々に厳しくなるようにレベルを上げていく〜というのがおすすめです。*4

Phan曰くSetting it to only critical issues is a good place to start on a big sloppy mature code base.*5 であり、PHPStanのREADMEによれば You can start using PHPStan with a lower rule level and increase it when you feel like it. *6とのことです。

比較3: 導入の簡易度

実行環境はどうする?

どちらも、composerで導入して実行コマンドを叩いて完了!!という事には変わりありません。
ただし、Phanではext-astが必要です。一方で、PHPStanは「部分的にPHPを実行する」ので、少なくともPHPのバージョンや導入されている拡張についてはプロダクトコードを稼働させるのと同等の環境を用意する方が無難なのかな?と思いました。

PHP7.1以上を利用しているプロダクトであれば、「プロダクトコード自体に composer require --dev で混ぜてしまう」というのが最も手軽ではあると思います。ただし、ネガティブな面もあるのでは・・というのが先の 「PhanでPHPコード静的解析2018」 でも指摘されている通りです。

個人的には、「それ専用のDockerイメージ用意してこちょこちょするのが楽なんじゃねCLIからコマンド1発で叩けるし」という気持ちに落ち着いています。

Phanについては、Cloudflareが上げているイメージがDockerhubにあります。この辺りは、以前に書いた記事も御覧ください。

PHPStanについてはオフィシャルのものがあります。これに必要な拡張を入れたりして利用していくことになります。

GitHub - phpstan/docker-image: Docker container to check your application with PHPStan without require via composer.

試しに環境設定を書いてみたら、こんな感じになりました。

(phpstan + phpcs + reviewdog) on Docker · GitHub

設定ファイルについて

Phanの config.php

Phanはarrayを返す .phan/config.php をセットします。
Phan本体のsrc/.phan/config.phpに全て?の設定項目が列挙されているので、この中から必要なものだけを抜き出してPJに組み込むイメージでしょうか。
とはいえ、「必要なもの」は限られると思います。
コネヒトで実際に利用しているのは、読み込み対象・検査対象ファイルの指定 + 7オプションの指定があるくらいでした。

↓設定イメージ(.phan/config.php) gist.github.com

これだけで、十分にパワーを発揮してくれます。
だいぶシンプルな記述で、結構手軽に動く!というのが実際のところではないでしょうか。

PHPStanの phpstan.neon

PHPStanは、phpstan.neon という形式での設定ファイルを設置することになります。
「ファイルのインクルードを実際にPHPスクリプトを実行しながら行える」という特徴があるので、Phanでいうところの directory_list / file_list に対して、この「読み込むファイルの指定」がシンプルに済みます。
アプリケーション(やテストコード)が実際に使っている設定注入ファイルを、そのまま利用できるのです。

CakePHPのphpstan.neonを見ると、「test用のbootstrap.phpを読み込む」という風になっています。

f:id:o0h:20180822203921p:plain

bootstrap.phpは「フレームワークが利用する定数の用意」「autoload.phpの読み込み」「環境設定(ini_setなど)」を行うものですが、確かにコレだけあれば「必要なものが揃いそう」と言えそうです。

解析対象のアプリケーション(プロダクトコードやTestSuit)の知識をそのまま利用できる、という所で個人的にはPHPstanの方式に魅力を感じました。

比較4: 実行速度

個人的に1番の気になりポイントが実行速度です。
結論から言えば、PHPStanの方が速度が出ると思います。

これは両者の特性の違いが如実に出た形です。
Phanが解析実行前の parse フェーズで(例えばvendorディレクトリ以下も含めて)すべて読み込もうとするのに対して、PHPStanは解析を実行しながら名前空間の解決やautoloadを実行して必要に応じたファイルの読み出して・・という挙動になります。
公式のMediumによれば、 PHPStan is able to check our codebase (6000 files, 600k LOCs) in around a minute. And it checks itself under a second. らしいので、凄く効率的にやってくれそうだなぁと思いました。

実際にコネヒトが利用しているCakePHPベースプロダクトのコード( /src 以下)を対象に、Travis CI上でそれぞれを走らせてみたときの結果を見ると5〜8倍程度の差が出ています。 f:id:o0h:20180805225642p:plain

また、この例では「CI上での動作」を測るために /src 全体を対象にしていますが、特定ディレクトリや特定ファイルだけを対象とした解析も可能です。
Phanだと「一部のファイルだけを解析する」ケースでも「必要な依存ファイル *7 を全部読んでから解析」という動きになるので、数ファイル対象の解析はPHPStanの得意分野となりそうです。

比較5: 拡張性・柔軟性

(静的)コード解析は「いかに開発をストレスなく進めるか」を支援するツールです。そのため、「解析ツールの警告に対処するのにめちゃくちゃなコストがかかる」というのでは、本末転倒になる可能性すらあります。
そこで意識したいのは、導入や運用において「自分たちにそぐわないルールを緩和・排除できるか」「自分たちの欲しいルールや挙動を拡張できるか」という柔軟性や拡張性です。

Phan

ただし、個人的にはPhanのプラグインはAST等の挙動の理解をした上で作成するように思われ、ハードルが高く感じました。

PHPStan

  • 「この警告は無視する」というのを、設定ファイル上に「無視するエラー内容の正規表現」を記述することで実現可能
    • 設定例(CakePHP): f:id:o0h:20180822205153p:plain
  • Pluginの作成を作成可能
    • 「既存判定の上書き」を、実行可能なPHPコードで記述ができるため簡潔に実装可能

例えば、PHP Enumでは「クラス内のprivate定数と同名のメソッドをコールしてインスタンスを取得する」という使い方があります。

<?php

use MyCLabs\Enum\Enum;

/**
 * Action enum
 */
class Action extends Enum
{
    private const VIEW = 'view';
    private const EDIT = 'edit';
}

$action = Action::VIEW(); 

しかし、実際には VIEW() といったメソッドは、コード上「定義されていない」わけです。
真っ当に対応するなら、Articleのclass docとして @method を書いてあげれば対応が可能です。しかしながら、折角Enumライブラリを利用していることを考えれば冗長にも感じます(すべてのクラスに@methodを振るか?。

これに対して、対応プラグインtimeweb/phpstan-enumを導入することでhasMethod()`の挙動を拡張することができるのです。

// https://github.com/timeweb/phpstan-enum/blob/master/src/Reflection/EnumMethodsClassReflectionExtension.php#L14
<?php

    public function hasMethod(ClassReflection $classReflection, string $methodName): bool
    {
        if ($classReflection->isSubclassOf(Enum::class)) {
            $array = $classReflection->getNativeReflection()->getMethod('toArray')->invoke(null);
            return array_key_exists($methodName, $array);
        }
        return false;
    }

処理を追ってみると、

  1. 検査対象がEnumのサブクラスだった場合
  2. Enum::toArray() をリフレクションクラス経由でコールし
    • これは「列挙されている値(const key)を返す」という挙動
  3. その中に対象method名(=enumの定義)が含まれていれば「実行可能なメソッド」として解釈させる

というものです。よって、PHPStanの解析上 「Article::VIEW() は実行可能である」と見なされます。
これは非常に読み下しやすいのではないでしょうか・・

まとめ

ここまで調べていて、「ドキュメントが割と丁寧で、完全に静的な解析ができるPhan」「静的解析を諦めることで、柔軟さと軽量さを手に入れたPHPStan」というような特徴の違いを感じます。
最終的には「チームの欲するルールや分析項目が揃っているか・最悪自力で用意できるか」「どこまで解析ツールにやらせたいか」で結論が変わるのかな?と思いました。解析ツールの導入はコードの品質を磨いて保つことで、何れにせよ「導入初期から徐々に育んでいくことが必要」という事に違いはありません。

どちらのツールも、DockerやComposerを用いることで導入するだけなら意外とサクッと終わる!!と思います。もしコード解析ツールを未導入な方がいらしたら、まずはぜひ試して見てください。

コネヒトでも、どちらにするか結論が出たらまたレポートしてみたいと思います。

*1:一寸も関係ない話題ですが、PHPUnitの作者であるsebastianbergmann氏もPHPStanのコミッターなんですね

*2:https://twitter.com/o0h_/status/1002141763966976000

*3:使ったことないのですが、面白そう。。

*4:PHPStan https://github.com/phpstan/phpstan/tree/master/conf 、 Phanについては https://github.com/phan/phan/blob/master/src/Phan/Issue.php#L532 でIssueごとにコンストラクタの第3引数に渡されているの内容を確認

*5:https://github.com/phan/phan/wiki/Incrementally-Strengthening-Analysis

*6:https://github.com/phpstan/phpstan/blob/dc62f78c9aa6e9f7c44e8d6518f1123cd1e1b1c0/README.md#rule-levels

*7:vendorやWAF本体も、実際には利用されないファイルも含めて配置されているファイルを全て読み込みます