巷で話題のPhanをいろいろためしてみました。

Phanとは

https://github.com/etsy/phan
PHP7で動く静的解析ツール。実行にはphp-astが必要。

Phanについての解説は@tadsanさんの記事が詳しいです。
http://inside.pixiv.net/entry/2016/11/11/202656
READMEとあわせて一読されると理解が深まります。

検証環境

Phanを動かすための検証環境

環境構築

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
php.ini
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のサンプルを参考に作成します。

.phan/config.php
<?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をあげてエラーを回避します。

php.ini
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の中身は以下のようなイメージです。

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クラスが存在しない。

sample.php
<?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コメントの書き間違いなので修正すべきです。
後者はバグです。以下のようなコードでした。

sample.php
<?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が間違っているとこの指摘をされてしまいます。

sample1.php
<?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が間違っているケースがほとんどでした。

sample2.php
<?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

メソッドを呼び出すときの引数が多い。

sample.php
<?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以外を渡していました。

sample.php
<?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

http://php.net/manual/ja/migration70.incompatible.php#migration70.incompatible.variable-handling

$c->$m[0]();

こうする

$c->{$m[0]}();

PhanTypeComparisonFromArray

配列と配列以外(null)の比較をしている。

sample.php
<?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)

以下の方法はphan-0.9.2より前のバージョンで有効な方法です。

テストコードを解析対象にすると実行に時間がかかることがあります。(後述の実行速度の比較を参照)
実行が遅いときはまずテストコードを解析対象から除外したほうがよいかもしれません。

テストコードが1箇所にまとまっていればディレクトリの除外設定を追加するだけで設定できます。

.phan/config.php
<?php

return [
    'directory_list' => [
        'src',
        'vendor',
    ],
    "exclude_analysis_directory_list" => [
        'vendor',
        'Tests', // Tests配下にすべてのテストコードがある場合
    ],
];

テストコードがまとまっていないときは設定がやや面倒です。
.phan/config.phpでワイルドカード指定などができればよさそうですが、検証時点ではどうもそれができないようでした。
例えば各ディレクトリのTests以下にテストコードを置いている場合どうすればよいでしょうか。

.phan/config.php
<?php

return [
    'directory_list' => [
        'src',
        'vendor',
    ],
    "exclude_analysis_directory_list" => [
        'vendor',
        // こういう設定がしたいところですがこの設定は効きません
        '*Tests',
    ],
];

この場合は解析対象ファイルのリストを事前に集めておいて、Phan実行時にリストを指定する方法をとるしかないようです。
Tutorial-for-Analyzing-a-Large-Sloppy-Code-Baseにある手順に従って、各Tests配下のphpファイルを除外する設定をしてみます。

スクリプトの用意

解析対象のファイルを抽出するスクリプトを用意して実行、ファイル名のリストを出力します。

.phan/bin/mkfilelist
#!/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.phpdirectory_listの指定を外す必要があります。
これを指定したままにするとfilesのリストに加えてこちらの設定も効いてしまいます。

.phan/config.php
<?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だけで設定することができます。

.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の設定で実行してみます。

.phan/config.php
<?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の設定にすると最小限の指摘になる
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.