巷で話題のPhanをいろいろためしてみました。
Phanとは
https://github.com/etsy/phan
PHP7で動く静的解析ツール。実行にはphp-astが必要。
Phanについての解説は@tadsanさんの記事が詳しいです。
http://inside.pixiv.net/entry/2016/11/11/202656
READMEとあわせて一読されると理解が深まります。
検証環境
Phanを動かすための検証環境
- Mac OS X
- PHP 7.0.12
- nikic/php-ast v0.1.2
- etsy/phan dev-master#935c3ef
環境構築
PHPはphpenvで7.0.12を、php-astはソースコードからコンパイルしました。
$ git clone https://github.com/nikic/php-ast
$ cd php-ast
$ phpize
$ ./configure --with-php-config=$HOME/.phpenv/versions/7.0.12/bin/php-config
$ make
$ make install
extension = ast.so
Phanのインストール
https://github.com/etsy/phan/wiki/Getting-Started
いくつか方法があります。今回はcomposer経由でインストールしました。
$ composer require --dev "etsy/phan:dev-master"
$ composer install
※php-astがないとcomposer requireで失敗します
$ vendor/bin/phan -h
Usage: vendor/bin/phan [options] [files...]
-f, --file-list <filename>
A file containing a list of PHP files to be analyzed
...
OK
Phanの設定ファイル
Getting-Startedのサンプルを参考に作成します。
<?php
/**
* This configuration will be read and overlaid on top of the
* default configuration. Command line arguments will be applied
* after this file is read.
*/
return [
// A list of directories that should be parsed for class and
// method information. After excluding the directories
// defined in exclude_analysis_directory_list, the remaining
// files will be statically analyzed for errors.
//
// Thus, both first-party and third-party code being used by
// your application should be included in this list.
'directory_list' => [
'src',
'vendor',
],
// A directory list that defines files that will be excluded
// from static analysis, but whose class and method
// information should be included.
//
// Generally, you'll want to include the directories for
// third-party code (such as "vendor/") in this list.
//
// n.b.: If you'd like to parse but not analyze 3rd
// party code, directories containing that code
// should be added to the `directory_list` as
// to `excluce_analysis_directory_list`.
"exclude_analysis_directory_list" => [
'vendor'
],
];
directory_list
directory_list
にはPhanがパースする対象のディレクトリを指定します。
ソースコードとそれに依存するコード(vendor)があるディレクトリを指定すればOKです。
ここに不足分があるとPhanでは未定義として扱われ解析に失敗してしまいます。
exclude_analysis_directory_list
exclude_analysis_directory_list
には解析対象から除外するディレクトリを指定します。
依存するコード(vendor)は解析しなくてよいのでここに指定します。
Phanを実行
解析対象のコードはPHP5.6で動いているコードです。
PHP7上でphpunitが動く程度の修正を事前にしておきます。
$ vendor/bin/phan
PHP Fatal error: Maximum function nesting level of '256' reached, aborting! in /path/to/vendor/etsy/phan/src/Phan/Language/Context.php on line 187
おもむろに実行してみましたが失敗しました。どこかでループしている。
調べてみると似たようなissueがありました。
https://github.com/etsy/phan/issues/116
SymfonyのConfiguration
クラスを使っているとこのエラーに遭遇するかもしれません。
xdebug.max_nesting_level
をあげてエラーを回避します。
xdebug.max_nesting_level = 50000
この設定を入れるとひとまず動くようになりました。
実行結果
集計コマンドはTutorial-for-Analyzing-a-Large-Sloppy-Code-Baseを参考に
$ vendor/bin/phan -p -o analysis.txt
$ cat analysis.txt | cut -d ' ' -f2 | sort | uniq -c | sort -n -r
3244 PhanTypeMismatchArgument
315 PhanUndeclaredMethod
303 PhanParamSignatureMismatch
115 PhanUndeclaredClassMethod
109 PhanUndeclaredProperty
103 PhanTypeMismatchReturn
46 PhanUndeclaredTypeParameter
30 PhanParamTooMany
26 PhanStaticCallToNonStatic
19 PhanTypeMismatchProperty
13 PhanUndeclaredTypeProperty
8 PhanTypeMissingReturn
7 PhanDeprecatedClass
6 PhanTypeMismatchArgumentInternal
3 PhanUndeclaredClassCatch
3 PhanCompatiblePHP7
2 PhanTypeMismatchForeach
2 PhanTypeComparisonFromArray
2 PhanParamTooFew
1 PhanNoopProperty
1 PhanNonClassMethodCall
1 PhanAccessMethodProtected
analysis.txtの中身は以下のようなイメージです。
path/to/ClassA.php:11 PhanUndeclaredMethod Call to undeclared method \Path\To\ClassB::method
path/to/ClassC.php:22 PhanUndeclaredClassMethod Call to method foo from undeclared class \Path\To\ClassD
...
解析内容
出力された解析内容(Phanではissue typesとして名前がついている)をいくつかピックアップしてみます。
バグのissue
PhanUndeclaredClassCatch
catchしようとしているException
クラスが存在しない。
<?php
namespace Foo;
class Sample
{
public function foo()
{
try {
// ...
} catch (InvalidArgumentException $e) {
// ...
}
}
}
sample.php:11 PhanUndeclaredClassCatch Catching undeclared class \Foo\InvalidArgumentException
独自の例外クラスInvalidArgumentException
をcatchしようとしたつもりでしたがuseがありませんでした。
テストコードもなかったためバグに気づかず。
バグかもしれないissue
PhanUndeclaredClassMethod
発生するパターン
- Docコメントの
@return
の型が間違っている - 存在しないクラスをnewしようとしている
前者はバグではないですがDocコメントの書き間違いなので修正すべきです。
後者はバグです。以下のようなコードでした。
<?php
namespace Foo;
class Sample
{
public function foo()
{
try {
// ...
} catch (InvalidArgumentException $e) {
return new JsonResponse();
}
}
}
sample.php:12 PhanUndeclaredClassMethod Call to method __construct from undeclared class \Foo\JsonResponse
(PhanUndeclaredClassCatchと同じ箇所)
これもuseの書き漏れ+テストコードがありませんでした。
前者のパターンとissueタイプは同じなので見分けるためには"Call to method __construct"のキーワードが手がかりになりそうです。
その他issue
PhanTypeMismatchArgument
メソッドの引数に渡そうとしている値の型がタイプヒンティングと異なる。
一見バグのように見えますがDocコメントの@return
が間違っているとこの指摘をされてしまいます。
<?php
class SampleA
{
/**
* Docコメントが間違っている
*
* @return SampleB
*/
public static function create()
{
return new self();
}
public function foo() {}
}
class SampleB
{
public function bar(SampleA $sampleA) {}
}
$sampleB = new SampleB();
// PhanTypeMismatchArgument
$sampleB->foo(SampleA::create());
実際のコードは動きますがPhanの解析結果はNGです。
@return
が間違っているのでPhanTypeMismatchReturnとセットで指摘されます。
PhanUndeclaredMethod
未定義メソッドのコール。
これもPhanTypeMismatchArgumentと同様に@return
が間違っているケースがほとんどでした。
<?php
// SampleA, SampleBはsample1.phpと同じ
class SampleA
{
/**
* Docコメントが間違っている
*
* @return SampleB
*/
public static function create()
{
return new self();
}
public function foo() {}
}
class SampleB
{
public function bar(SampleA $sampleA) {}
}
$sampleA = SampleA::create();
// PhanUndeclaredMethod
$sampleA->foo();
SampleB::foo()
はないのでNG。(実際はSampleA::foo()
)
PhanParamSignatureMismatch
親/子クラスのメソッドの引数の型が異なる。
PHP7からはPHP Warningになるので修正が必要です。
PhanUndeclaredProperty
プロパティを宣言していないのにメソッド内などで使ってしまっているケース。
宣言は忘れずに。
PhanParamTooMany
メソッドを呼び出すときの引数が多い。
<?php
class Sample
{
public function foo() {}
}
$sample = new Sample();
$sample->foo('不要な引数');
sample.php:9 PhanParamTooMany Call with 1 arg(s) to \Sample::foo() which only takes 0 arg(s) defined at sample.php:5
これでも動きますが記述的には間違いなので要修正。
PhanDeprecatedClass
Docコメントに@deprecated
があると指摘されます。
ソースコードに@deprecated
を書いている場合以外でも、vendorのコードで@deprecated
が書いてあるクラスを使った場合でもつられて指摘されてしまいます。
Symfony2系では\Symfony\Component\Validator\Constraints\GroupSequenceなどがこれに該当しました。
PhanTypeMismatchArgumentInternal
PHPネイティブ関数に渡している引数の型が異なる。
array_key_exists()
の第2引数にarray以外を渡していました。
<?php
array_key_exists('key', new \ArrayObject(['key' => 'value']));
sample.php:3 PhanTypeMismatchArgumentInternal Argument 2 (search) is \ArrayAccess|\ArrayObject|\Countable|\Serializable|\Traversable|\iteratoraggregate but \array_key_exists takes array
動くけど要修正。
PhanCompatiblePHP7
$c->$m[0]();
こうする
$c->{$m[0]}();
PhanTypeComparisonFromArray
配列と配列以外(null)の比較をしている。
<?php
class Sample
{
public function foo($items)
{
if ($items === null) {
// ...
}
}
}
$sample = new Sample();
$sample->foo(['key' => 'value']);
sample.php:7 PhanTypeComparisonFromArray array to null comparison
このissueはいくつか条件があるようで
- メソッドの引数に限定してチェックされる
- 引数が空配列だと指摘されない
- arrayのタイプヒンティングがあれば指摘されない
いずれにしてもタイプヒンティングを適切に書いておけば問題ないところでしょう。
その他まだまだありますがここまで。
Phanのissue一覧は以下で確認できます。
https://github.com/etsy/phan/wiki/Issue-Types-Caught-by-Phan
指摘された内容のまとめ
とにかく多かったのがDocコメントが間違っているパターン。特に@return
の間違い。
適当に書いてしまいがちですがDocコメントが間違っているとPhanでは大量に指摘されてしまいます。
またバグを指摘するissueもいくつかありました(PhanUndeclaredClassCatch, PhanUndeclaredClassMethod)
これらが出てきたときは優先的に確認したほうがよいでしょう。
テストコードを解析対象から除外する方法(< phan-0.9.2)
テストコードを解析対象にすると実行に時間がかかることがあります。(後述の実行速度の比較を参照)
実行が遅いときはまずテストコードを解析対象から除外したほうがよいかもしれません。
テストコードが1箇所にまとまっていればディレクトリの除外設定を追加するだけで設定できます。
<?php
return [
'directory_list' => [
'src',
'vendor',
],
"exclude_analysis_directory_list" => [
'vendor',
'Tests', // Tests配下にすべてのテストコードがある場合
],
];
テストコードがまとまっていないときは設定がやや面倒です。
.phan/config.php
でワイルドカード指定などができればよさそうですが、検証時点ではどうもそれができないようでした。
例えば各ディレクトリのTests以下にテストコードを置いている場合どうすればよいでしょうか。
<?php
return [
'directory_list' => [
'src',
'vendor',
],
"exclude_analysis_directory_list" => [
'vendor',
// こういう設定がしたいところですがこの設定は効きません
'*Tests',
],
];
この場合は解析対象ファイルのリストを事前に集めておいて、Phan実行時にリストを指定する方法をとるしかないようです。
Tutorial-for-Analyzing-a-Large-Sloppy-Code-Baseにある手順に従って、各Tests配下のphpファイルを除外する設定をしてみます。
スクリプトの用意
解析対象のファイルを抽出するスクリプトを用意して実行、ファイル名のリストを出力します。
#!/bin/bash
if [[ -z $WORKSPACE ]]
then
export WORKSPACE=.
fi
cd $WORKSPACE
for dir in \
src \
vendor
do
if [ -d "$dir" ]; then
find $dir -name '*.php' -not -regex '.*Tests/.*' -type f
fi
done
$ .phan/bin/mkfilelist > files
.phan/config.php
.phan/config.php
のdirectory_list
の指定を外す必要があります。
これを指定したままにするとfilesのリストに加えてこちらの設定も効いてしまいます。
<?php
return [
// 外す
//'directory_list' => [
// 'src',
// 'vendor',
//],
"exclude_analysis_directory_list" => [
'vendor',
],
];
exclude_analysis_directory_list
は残します。
Phanを実行
-fオプションでfilesを指定して実行します。
$ vendor/bin/phan -p -f files -o analysis.txt
analysis.txtの結果を見てみると/Testsのコードは対象になっていないのが確認できるはずです。
テストコードを解析対象から除外する方法(phan-0.9.2)
最新版のphan-0.9.2であれば.phan/config.phpだけで設定することができます。
<?php
return [
'directory_list' => [
'src',
'vendor',
],
'exclude_analysis_directory_list' => [
'vendor',
],
'exclude_file_regex' => '@Tests/.*\.php$@',
];
Weak Analysis
Tutorial-for-Analyzing-a-Large-Sloppy-Code-BaseにあるWeak Analysisの設定で実行してみます。
<?php
return [
//'directory_list' => [
// 'src',
// 'vendor',
//],
"exclude_analysis_directory_list" => [
'vendor'
],
// ここまで同じ
// 以下追加
// If true, missing properties will be created when
// they are first seen. If false, we'll report an
// error message.
"allow_missing_properties" => true,
// Allow null to be cast as any type and for any
// type to be cast to null.
"null_casts_as_any_type" => true,
// Backwards Compatibility Checking
'backward_compatibility_checks' => false,
// Run a quick version of checks that takes less
// time
"quick_mode" => true,
// Only emit critical issues
"minimum_severity" => 10,
// A set of fully qualified class-names for which
// a call to parent::__construct() is required
'parent_constructor_required' => [
],
];
$ vendor/bin/phan -p -f files -o analysis.txt
$ cat analysis.txt | cut -d ' ' -f2 | sort | uniq -c | sort -n -r
105 PhanUndeclaredClassMethod
2 PhanUndeclaredClassCatch
Weak Analysisの設定にすると最小限のissueだけが残るようになりました。
指摘された内容のまとめにもあるようにバグの可能性が高いissueだけが指摘されるようです。
実行速度の比較
テストコードを解析対象にするかしないか、またWeak Analysisの設定にするかしないかで実行速度に差が出ました。
テストコード | Weak Analysis | 実行時間(sec) |
---|---|---|
あり | 設定しない | 637 |
あり | 設定する | 414 |
なし | 設定しない | 197 |
なし | 設定する | 177 |
並列実行
-jオプションで解析フェーズを並列実行することができます。
$ vendor/bin/phan -j 10 -p -f files -o analysis.txt
並列実行速度の比較
テストコードを除外、Weak Analysisの設定で並列数を動かして実行速度の差を見てみます。
-j(process) | 実行速度(sec) |
---|---|
1 | 177 |
2 | 144 |
3 | 136 |
4 | 138 |
5 | 143 |
10 | 166 |
20 | 220 |
30 | 300 over |
2~10並列程度であればやや速くなり、並列数をあげすぎると逆に遅くなりました。
マシンスペックをあげればもう少し並列数をあげて速くすることができそうです。
まとめ
- PHP5.6のコードに対してPhanを実行した
- 解析内容はDocコメント間違いの指摘が多い
@return
は正しく書こう - バグも検出された
- テストコードを除外するとPhanの実行速度が速くなる
テストコードの除外設定はちょっと面倒 - デフォルトの設定で指摘点が多いときはWeak Analysisの設定にすると最小限の指摘になる