pixiv insideは移転しました! ≫ http://inside.pixiv.blog/

Phan静的解析がもたらす大PHP型検査時代

こんにちは、pixivでPHPをやってるうさみです。健全なコードベースは黙っても降ってこないので、チーム全体で開発効率を高めるような改善をするのがお仕事です。

テキストエディタはmicro推しです ヾ(〃><)ノ゙☆

さる11月3日に大田区産業プラザ PiOで開催されたPHPカンファレンス 2016にて大怪獣に蹂躙されながらPhanについて30分のセッション発表をいたしましたので、その内容を紹介します!

Phanとは

PhanはPHPの静的解析ツールです。開発元はハンドメイドのマーケットサービスを運営し、現在PHP作者のRasmus Lerdorf氏するEtsy社です。もちろんRasmus Lerdorf氏も開発に参加してます

Phanは以下のような項目を検出できます。

  • 関数・クラス・定数・変数などがすべて定義済か、アクセスできるか
  • 関数の型と引数の数が合ってるか
  • PHP5とPHP7の後方互換性
  • 値が配列アクセス可能か
  • 安全な二項演算か ($a + $bが不正な型の計算ではないか)
  • 関数の返り値の型
  • 無用な配列・クロージャ・定数・変数など
  • デッドコード
  • クラス・関数などが再定義されてないか
  • 未定義クラスでキャッチしようとしてないか

……この特徴だけを並べたところで、PHPの静的解析(phpcs, phpmd, etc...) — Algo13で紹介されるような既存のツールに似たようなのが一個増えたんだな、と感じるかもしれません。

実際PHP Mess Detector(PHPMD)と重複する機能も持ちます。しかし変数や函数の「型」をきちんとした精度で扱ってくれる、つまり型推論してくれるのが既存ツールとは一線を画するところです。

また、PHPの型チェックの際にPHPの型宣言(引数および返り値)とphpDocumentor形式の型注釈(拙訳ですがないよりはましな日本語訳)の両方をサポートしてくれるのも大きな特徴です。

動的言語の静的型解析をするアプローチとしては、Pythonのmypy、JavaScriptのFlowなどが知られます。また、PhpStorm IDEもPHPにおいて型検査をしてくれる開発環境として知られます。

Phanを導入する

Phanは本稿執筆時点ではまだ安定版はリリースされて居らず、最新版の0.6Releases · etsy/phanからPhar形式でダウンロードできます。(まだ開発版ですが1.0 release candidate.とのことなので今年中に出るかはわかりませんが遠からず正式版になるのではないでしょうか)

Phanの動作にはPHP 7.0が必要でphp-astも要求されます。また、検査対象のアプリケーションが拡張モジュールに依存する場合は、それもインストールしておいた方が無難です。

ところでpixivの開発環境は現時点ではPHP 7への全面移行は完了して居りません。そのような環境では常用バージョンのPHPとは別のディレクトリにPHP7をインストールして、以下のようなラッパースクリプト経由で実行することでカバーできます。

UNIX系OSであれば、以下のようなスクリプトをphanとして保存し、chmod +x phanで実行権限を付与します。

#!/bin/sh
/path/to/php7/bin/php ${PHAN_BIN:-/path/to/bin/phan.phar} "$@"

/path/to/php7/bin/php/path/to/bin/phan.pharは、それぞれ実際のpathに置換してください。弊社環境ではメンバー各自のマシンではなく共用の開発機で動作させることが前提のため、以下のようなスクリプトが置いてあります。(-jオプションでプロセスの並列数設定)

#!/bin/sh
/home/hoge/opt/php7/bin/php ${PHAN_BIN:-/home/hoge/phan/vendor/bin/phan} -j6 "$@"

せっかくなので、弊社の開発マシンの写真を置いておきますね ヾ(〃><)ノ゙

Phanの設定

Phanの設定ファイルは.phan/config.phpに配置します。詳細な設定などはGetting Started · etsy/phan Wikiを参照してください。

筆者は以下のように実行するのが好きです。Phanは実行に数十秒と待たされるので、手動実行すると「こいつ途中で止まってるんじゃないのか」と疑心暗鬼に駆られるのですが、--progress-barオプションを付けると固まってるわけじゃないんだなと(心理的に)ちょっぴり安心です。

~/pixiv/dev-script/phan --progress-bar -ophan.log

Phanの紹介

PHPカンファレンスで発表したスライドがあるので、こちらをご覧ください。

Phanで検出されがちなパターン

pixivで掘りだされまくったPhanの検出パターンと対処方法について紹介していきます。

PhanUndeclaredClass

hogehoge.php:19 PhanUndeclaredClass Reference to undeclared class \Google_Client

\Google_Clientを見付けられなかったと言ってます。Phanはオートローディングを使ってくれません。Composerので導入したライブラリであれば、.phan/config.php'directory_list'に明示的にvendor/google/apiclient/src/Google/を追加してやります。

PhanUndeclaredTypeParameter

これはPHPの型について把握してないとはまりがちなパターンです。

hogehoge.php:86 PhanUndeclaredTypeParameter Parameter of undeclared type \str

intintegerはエイリアスなのでどちらでも有効な型表記なのですが、stringの短縮形は定義されてないのでこのような事態に陥ります。@return str@param str $hogeは原則としてすべてstringに直さなければいけません。

PhanUndeclaredTypeParameter (その2)

hogehoge.php:58 PhanUndeclaredTypeParameter Parameter of undeclared type \timestamp

これは、以下のような関数の返り値が誤って型付けされたものです。

<?php

/**
 * @return timestamp
 */
function hoge()
{
    // なんかいろいろ処理があって
    return strtotime($data);
}

この処理はUnixタイムスタンプを返すので、@returnを書いた気持ちはわかります。しかしPHP的には単なるintなので、timestampと書いてしまうと不都合があります。

<?php

/**
 * @return int timestamp
 */

こうすると、型を表現しつつドキュメントに残すことができます。

PhanTypeMismatchReturn

上記と似たパターンです。

hogehoge.php:230 PhanTypeMismatchReturn Returning type bool but checkPass() is declared to return \パスワードが正しければtrue

このパターンは上記スライド中にも登場したのでみなさまご想像の通りですが、以下のようなコードです。

<?php

/**
 * @return パスワードが正しければtrue
 */
function checkPass()
{
}

この形式は(日本語の読める)人間が読めば意味はわかりますが、当然ながらPhanは解釈してくれないのでboolを明示してやります。

<?php

/**
 * @return bool パスワードが正しければtrue
 */
function checkPass()
{
}

PhanStaticCallToNonStatic

hogehoge.php:72 PhanStaticCallToNonStatic Static call to non-static method \Fuga::getPiyo() defined at path/to/Fuga.php:48

静的メソッドではないものを静的メソッドとして呼ぶパターンです。

<?php

class Fuga
{
    public function getPiyo()
    {
    }
}

これは「定義にstaticを付け忘れ」のパターンと「呼び出し方法が間違ってる」のどちらかパターンが想定されます。このパターンはPHP5ではE_STRICT、PHP7ではE_DEPRECATEDレベルの警告が出ますが、error_reporting()のレベルが低い場合には、何事もなかったかのように動作してしまうこともあるかもしれません。

PhanUndeclaredConstant

定数を見付けられないパターンです。

hogehoge.php:178 PhanUndeclaredConstant Reference to undeclared constant \FOO_ID
hogehoge.php:178 PhanUndeclaredConstant Reference to undeclared constant \HOGE_IS_ENABLE

スライド中でも指摘した通り、Phanはconst構文でコンパイル時に定義された定数を検出できますが、define()関数で実行時に定義された定数は検出しません。

この問題は意外と根が深く、注意が必要です。

<?php

define('FOO_ID', 12345);
define('HOGE_IS_ENABLE', date('YmdHis') > 20161111134500);

前者は簡単に置換可能です。

<?php

const FOO_ID = 12345;

しかし後者のようなパターンはconst定数にはならないので、PhanUndeclaredConstantを見て見るふりをするか、関数化するかの選択を迫られます。

<?php

function hoge_is_enable ()
{
    return date('YmdHis') > 20161111134500;
}

これが呼び出される回数にもよりますが、関数が数回増えたところでコスト増加はたかが知れてるので、特に問題はないでしょう。


余談ですが、PHP 7.1ではオブジェクト定数private constの機能が追加されます。この機能のおかげで、定数を濫用したオープン・クローズドの原則(OCP)に反した以下のようなコードを制限できるようになり、とてもべんりです。

<?php

if ($hoge->type === Hoge::TYPE_A) {
     // 処理
}

PhanUndeclaredProperty

これが出力される原因としては「プロパティの誤り(タイプミス)」「プロパティの定義漏れ」と「プロパティのオーバーロード」があります。

hogehoge.php:23 PhanUndeclaredProperty Reference to undeclared property \HogeModel->args

最初のパターンは、$obj->nameと記述すべきところを$obj->nemeとしてしまったり、あるいは$obj->getHoge()()を忘れて$obj->getHogeと書く、といった人間のミスです。

次のパターンは以下のようなクラス定義です。このクラスは($obj->nameがpublicに公開される以外は)問題なく動作します。

<?php

final class User
{
    /** @var int */
    private $id;

    public function __construct($id, $name)
    {
        $this->id = $id;
        $this->name = $name;
    }

    public function getName()
    {
        return $this->name;
    }
}

余談ですが、この類の問題は拙作のReadOnly.phpPrivateGetter.phpのような(ライブラリを導入するまでもない)シンプルなコードで対策ができます。

最後の「プロパティのオーバーロード」とは、シンプルに書くと以下のような実装です。

<?php

/**
 * @property int    $id
 * @property string $name
 */
final class User
{
    private $values = [
        'id' => 0,
        'name' => '',
    ];

    public function __construct($id, $name)
    {
        $this->id = $id;
        $this->name = $name;
    }

    public function __get($key)
    {
        if (!isset($this->values[$key])) {
            throw new \OutOfRangeException;
        }

        return $this->values[$key];
    }

    public function __set($key, $value)
    {
        if (!isset($this->values[$key])) {
            throw new \OutOfRangeException;
        }

        $this->values[$key] = $value;
    }
}

これは最低限に近いシンプルな実装ですが、プロパティの型定義と実行時型チェックのような活用方法があります(PerlではMooseのようなオブジェクトシステムでアトリビュート(プロパティ)の型定義が可能なようですが、PHPでは定番ではないようです)。

そして、この形式の@propertyは現状のPhanではサポートされないためPhanUndeclaredPropertyと検出されます。

最近の流れではIssue #386 Support class @property@propertyを入れる是非についての話が進んで居り、どうやらプラグインでサポートする流れになりそうですね。

PhanUndeclaredProperty (その2)

本稿執筆時点のPhanは\stdClassに対する態度が冷淡です。

hogehoge.php:85 PhanUndeclaredProperty Reference to undeclared property \stdClass->user_id

自前のコードならば特定のクラスにマッピングした方が良いかもしれませんが、ライブラリが\stdClassを返してくるのはどうしようもないので、これは見て見ぬふりをした方が良いかもしれません。

PhanUndeclaredMethod

もちろん「メソッドが未定義」といった意味なのですが、実際には動作するにも拘らず検出できないパターンがあります。

hogehoge.php:48 PhanUndeclaredMethod Call to undeclared method \DB_ExPDOBase::close

これは社内で利用されるProxy パターンを利用したPDOのラッパークラスなのですが、このパターンには現状では対応できません。

<?php

/**
 * @method int piyo(int $a, mixed $b)
 * @method string[] fizzbuzz(int $n)
 */
class Hoge
{
    private $fuga;

    public function __construct(Fuga $fuga)
    {
        $this->fuga = $fuga;
    }

    public function __call($name, $args)
    {
        // 実際にはほかの処理とか条件もある
        return call_user_func_array([$this->fuga, $name], $args);
    }
}

phpDocumentorでは@methodが定義されてるのですが、@propertyと同様に現状ではPhan未対応です。

RubyやPythonと違ってPHPはファイルのモンキーパッチングに対応しないので、このようなテクニックが利用されることがあります。

実運用の手引き

非推奨機能を洗い出す

この機能やめたいんだけど、いますぐ置換は難しい… そんなときには実装に@deprecatedタグを付けましょう。そうすれば、Phanは利用箇所をすべてリストアップしてくれます。また、PhpStormは$obj->oldFunc()のように取り消し線をつけて表示してくれます。

リファクタリングのおともに

多数の警告をいきなり撲滅するのは大変ですが、大規模なリファクタリングの前後の出力の差分をとって妙な行が増えないか、逆に不自然に減ったりしないかなど参考になります。

握り潰しのテクニック

Phanはライブラリを含めた全ファイルを一斉に検査する必要があるのですが、同時にライブラリに検出された良くないコードも出力されてくることがあります。それはライブラリ側でちゃんとしてくれよ、ってこともあるのですがとりあえず視界から外すために以下のようなフィルタスクリプトを書いて「見て見ぬふり」をすることにしました。

また、すぐには対処できないもの(define()定数のやつとか)もあるので、これも同様に一時的に見て見ぬふりをします。優先度の低いものを除外することで、すぐに対処すべき問題に集中することができます。

cat phan.log | ./dev-script/phan-filter > filtered.log
#!/usr/bin/php
<?php

$const = [
    'PhanDeprecatedFunction', // TODO
    'vendor/vlucas/valitron/',
    'vendor/apache/thrift/',
    'vendor/chrisboulton/php-resque',
    'vendor/ezyang/htmlpurifier/',
    'vendor/filp/whoops/',
    'vendor/guzzle/guzzle/',
    'vendor/guzzlehttp/',
    'vendor/pixiv/tateseta/lib/Tateseta/',
    'vendor/sendgrid/sendgrid/',
    'vendor/sendgrid/smtpapi/',
    'vendor/openid/php-openid/',
    'vendor/savvot/random/src/',
    'vendor/symfony/dependency-injection/',
    'vendor/symfony/event-dispatcher/',
    'vendor/respect/validation/library/',
    'undeclared class \Imagick', // TODO
    'undeclared class \Smarty',  // TODO
    'undeclared constant \GROUP_',
    'undeclared constant \IMF_',
    'undeclared constant \IMG_',
    'undeclared constant \XHPROF_',
    'undeclared function \thrift_',
];

while ($line = fgets(STDIN)) {
    foreach ($const as $c) {
        if (strpos($line, $c) !== false) {
            goto next;
        }
    }

    echo $line;
    next:
}

このスクリプトの話は @tamanobi がWEB+DB PRESS Vol .95の「PHP大規模開発入門」書いてくれたので、よろしければお読みください。

gihyo.jp

プルリクチャンス

「OSSに貢献してみたいけど、どうやったらいいのかわからない…」とお悩みのみなさん、あなたの使ってるライブラリをPhanで検査してみると、もしかしたらつっこみどころが見つかってコントリビューターデビューできるチャンスかもしれませんよ!

まとめ

  • Phanは型推論できるイカした静的解析ツールです
  • PHPDoc形式についてはphpDocumentor)のドキュメントが参考になります
  • 実コードで発見したパターンと対応策を紹介しました

実コードについては笑覧いただけたかと存じますが、率直な話PHPDocについては日本語情報が全然なくて、布教が大変です。何ページかは訳してあるので参考にしてください。

出版物としてはWEB+DB PRESS Vol. 87の「PHP大規模開発入門」にも「PHPDocでコードの品質を保つ」でPHPDoc形式の型注釈について詳しく書きました。

f:id:zonu_exe:20161111195637j:plain

次回予告

今年もアドベントカレンダーやります!

qiita.com