kubell Creator's Note

ビジネスチャット「Chatwork」のエンジニアのブログです。

ビジネスチャット「Chatwork」のエンジニアのブログです。

読者になる

リリースして11年経過したPHPアプリケーションにPHPStanを導入した

はじめに

はじめまして。PHP部の山下(@task2021)です。

この度、リリースして11年経過したPHPアプリケーションのCIにPHPStanを導入しました。

歴史の長いPHPで実装されたプロダクトコードにPHPStanを導入するにあたり、「どのように導入していったか」というプロセスに焦点を当てて紹介したいと思います。

話さない事

  • PHPStanについての詳細
  • 技術的な話・CIへの導入方法

想定読者

  • 静的解析ツールを導入しようと考えているが、チームに受け入れられるか不安がある
  • 静的解析ツールを可能な限りスムーズに導入したい
  • 静的解析ツールを導入して、実際にどんなメリットがあったか聞きたい

目次

なぜPHPStanを導入したのか

2022年5月現在、ChatworkのPHPコードはバージョン8.0で動いています。どんどん型付が強くなっていき、非常に嬉しい限りです。

一方で、型を意識したコーディングができるようになったと言ってもPHPは動的型付け言語なので、仮に型の不一致などの問題が起きているコードがあった場合に、問題が発覚するのは実行時です。 もしこれが静的型付け言語であれば、コンパイル時に気づく事が可能です。

Chatworkではcommit毎に約6000ファイルのテストケースを実行しています。しかし、型エラーなどが発生していたとしてもテストでカバーしきれないところは、最悪の場合そのまま本番環境へリリースされてしまうという問題がありました。

それによって過去実際にユーザー影響のある障害が発生してしまったこともあり、Chatworkを安定稼働させ続ける上でも大きな問題点でした。

「これは辛い!」と思っていたそんな折に、PHPの現場にて、「PHP と型と静的解析ツール」について語られる会がありました。 それを聞いて「最高やん」と思い、社内にPHPStanを導入してはどうかと提案することにしました。

PHPStanとは

PHPコードを実行する前に静的に解析し、実行時エラーになるような問題のあるコードを検出・警告してくれるツールです。これをCIに組み込む事で「バグをリリースする前に気づくことができるようになる」だけでなく、「レビューコストの削減」も期待できます。

phpstan.org

具体的な導入方法に関しては以下のようなリンクが参考になります。

tdomy.com blog.shin1x1.com

静的解析ツールを使用していなかったリポジトリに導入していく道のり

解析レベルを決定する

まずはじめに、どのような問題を解決したいのかを洗い出しました。ツールの導入にあたり、Mustで発見したかった問題は以下になります。

  • 未定義メソッド・未定義変数・未定義クラスへの参照
  • 型エラーの検知

上記の問題は、ライブラリのメジャーバージョンアップや、各featureブランチの実装がリリースブランチにマージされたタイミングで実際に発生した事がある問題です。

PHPStanでは0~9までの解析レベルの設定が可能です。各レベルでどんなチェックがなされるかは以下のドキュメントに記載があります。

phpstan.org

また、自分達がチェックしたい問題はどのレベルで検知できるのかはPlayground | PHPStanを使って一つづつ見ていくと確実だと思います。

適用するルールを細かく設定したい!という事であれば、個別にon/offを切り替える事も可能です。

phpstan.org

先述したようなMustで検知したい問題に関してはLevel 5でチェックできる事がわかったので、Level 5からスタートする事にしました。

CIへ導入する

PHPStanの解析ジョブの追加とreviewdogの導入

ChatworkではCircleCIを利用しているのですが、コードをpushする度に約6000ファイルのテストコードを実行しています。 PHPStanもコードのpush毎に実行したかったので、同じタイミングでPHPStanを実行するようにしました。

解析の結果は、CIの結果から確認する事ができますし、必要であれば「どこで問題が起きたのか」をコードにコメントしてくれるようreviewdogを利用する事も有効です。

github.com

導入初期の./circleci/config.ymlの一部を紹介します。

...省略
jobs:
  build:  # PhpUnitの実行
    ...省略
  phpstan:  # PHPStanを実行するjobを追加する
    docker:
      - image: cimg/php:8.0.11
    working_directory: ~/repo
    steps:
      - checkout
      - composer_install
      - run:  # PHPStanの実行
          name: Analyse.
          command: |
            vendor/bin/phpstan analyse --memory-limit=3
      - run:  # 解析に失敗時、reviewdogを使ってPRにコメントする。
          name: Review the analysis results.
          command: |  # reviewdogをinstall & PHPStanを実行し、結果をreviewdogに渡す
            curl -sfL https://raw.githubusercontent.com/reviewdog/reviewdog/master/install.sh | sh -s
            cd ~/repo && \
            vendor/bin/phpstan analyse --memory-limit=3G --error-format=raw --no-progress | \
            REVIEWDOG_GITHUB_API_TOKEN=${GITHUB_ACCESS_TOKEN} ./bin/reviewdog -f=phpstan -reporter=github-pr-review || \
            exit 0
          when: on_fail  # 失敗時のみ実行する

workflows:
  version: 2
  build:
    jobs:
      - build  # phpunitを実行
      - phpstan  # PHPStanを実行する

上記を見ると、Analyse.ステップとReview the analysis results.ステップの両方でPHPStanを実行している事がわかります。両者の違いは出力形式です。

# CLIで確認しやすいフォーマットで出力される
vendor/bin/phpstan analyse
# reviewdogが処理できる形で出力される
vendor/bin/phpstan analyse --error-format=raw --no-progress

「二回も実行するのは二倍時間がかかってしまうのでは?」と思われるかもしれませんが、PHPStanは実行時にキャッシュを生成するため、reviewdogの為の二回目の実行では数秒で解析が完了します。

二回目の実行は数秒で完了する

ただし、reviewdogに関しては、「問題が起こってる場所をわかりやすく教えてくれるけど、Githubの差分が見づらくなって辛い」「PHPStanこけてたらマージできないから普通にCIのエラー見に行くし、恩恵感じた事少ないかも」という意見も出てきたため、現在は停止しています。

こればかりは開発チーム次第だと思うので、一旦導入してみて不評だったらやめる、というスタンスがあっているかもしれません。

baselineを作成し、既存のエラーは検知対象外にする

また、導入当初は大量のエラーが発生する事が予想されます。ChatworkではPHPStanの導入を優先する為にそれらのエラーをbaselineという機能を使い、一旦無視する事にしました。

baseline機能とは、特定のエラーをあかかじめ指定しておく事で、PHPStanの解析時に無視してくれるようになる機能です。

この機能を利用すれば、「PHPStanを導入したいけど、実行してみたら2万件くらいエラーが発生した...無理だ...」という状況でも、一旦警告除外対象にして導入を進める事が可能です。

phpstan.org

baselineを利用する場合は、除外対象にしていたエラーを解消した際の運用も一緒に考えておく必要があります。「倒した警告はどんどんbaseline(警告除外対象)から削っていく」のが理想なのですが、一方で頻繁にbaselineファイルを更新するのは面倒に感じます。

baselineファイルを更新するタイミングとしては2パターンあります。

(1) 除外対象にしていた警告の解消と共に、baselineファイルを更新する

(2) 除外対象にしていた警告の解消時は何もせず、定期的にまとめてbaselineファイルを更新する

執筆時点で、Chatworkでは(2)の方法をとっていますが、将来的には(1)の方法をCIで自動化する事を考えています。

ちなみに、解析時に発生していないエラーがbaselineに除外対象として残っていた場合は、PHPStanは警告を発生させるようになっています。

この振る舞いは設定により変更する事が可能です。(2)の方法をとる場合は、reportUnmatchedIgnoredErrorsの設定をOFFにする事がおすすめです。

phpstan.org

誤検知が発生しないよう、設定ファイルを見直す

「既存のエラーはbaselineに入れて、一旦黙らしてしまう」時には、「設定漏れによる誤検知エラー」もそのままbaselineに入れてしまわないように注意が必要です。

たとえば、フレームワークの初期化処理などで初期化されるグローバル定数を使用しているコードがある場合、PHPStanの実行時に定数にダミーの値を入れておかなければ、参照時にエラーになってしまいます。

<?php

class グローバル定数に依存しているクラス
{
    public function echo(): void
    {
        echo GLOBAL_CONSTANT;
    }
}
$ vendor/bin/phpstan analyze グローバル定数に依存しているクラス.php
Note: Using configuration file /path/to/project/phpstan.neon.
 1/1 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

 ------ ---------------------------------------------------------------------
  Line   グローバル定数に依存しているクラス.php
 ------ ---------------------------------------------------------------------
  8      Constant GLOBAL_CONSTANT not found.
         💡 Learn more at https://phpstan.org/user-guide/discovering-symbols
 ------ ---------------------------------------------------------------------

 [ERROR] Found 1 error

これを回避する為に、グローバル定数にダミー値を入れるスクリプトとしてbootstap.phpを作成し、PHPStanの初期化処理として実行するようにしました。

<?php

const GLOBAL_CONSTANT = 'dummy';

初期化処理は、PHPStanの設定ファイルであるphpstan.neonに以下のように指定する事が可能です。

parameters:
    bootstrapFiles:
        - bootstrap.php

上記は一例ですが、その他にも「メソッド内部でexit()しているコードなのに、解析時にはそのまま処理が続いているように解釈されてしまう」などの状況はよく発生する事例だと思います。

プロジェクトによって適切な設定内容は変わってくるので、最初の方は以下のページと睨めっこしながら調整していく必要がありそうです。

phpstan.org

各メンバーがPHPStanを実行しやすくする

CIに導入した事で、より安心して変更をpushできるようになりました。 しかし、「CIがこける→結果を確認する→修正」ではどうしてもフィードバックが遅くなってしまうので、以下の環境を整備する事に取り組みました。

  • 各メンバーがPHPStanをローカル環境で実行しやすくする
  • IDEにPHPStanを組み込み、リアルタイムでPHPStanの警告に気づき・対応できるようにする

特にIDEへの組み込みは重要だと思っています。IDEに組み込まれていれば、リアルタイムに解析を実行する事が可能なので、常にPHPStan先生に指摘を受けながらコードを書くことができます。

PhpStormにPHPStanでリアルタイム解析をかけた様子

PHPStanでは「どんなルール・レベルで解析をかけるか」を設定ファイルで管理します。この設定ファイルはgit管理されているので、開発メンバー全員で同じルールの静的解析を実行しつつ、開発する事が可能になります。

また、PHPStormでは先述したbaselineに登録して除外対象にしている警告も表示されるので、継続的にbaselineにダメージを与えていける事も期待しています。

強いてデメリットをあげると、バックグランドでPHPStanが実行される事になるので、コードを書いてから警告が表示されるまで5~10秒ほどのラグがある事には注意が必要です。

PHPStanの警告への対応について相談できる場を作る

Chatworkでは、「トピックに応じてグループチャットを新規に作成する」という文化があります。PHPStanに関しても、導入初期には「このエラーはどう対処すれば良いのか」という疑問が出てくる事が予想されました。

また、仮にメソッドの内部でexit()しているようなコードがあった場合は、「このメソッドは内部的にexit()するので、処理が終了する」という事をPHPStanに教えてあげる必要があります。適切に設定していない場合は、こういった設定漏れがPHPStanの「誤検知」をうみます。

適切に設定しないと、PHPStanが正しく解析できない

導入した時点で既に2万件くらいのエラーが発生していたので、誤検知かどうかを一件一件みていく事は現実的じゃありませんでした。

相談部屋を用意する事で、メンバーからの警告について共有 → 誤検知であれば設定の見直し、という流れをスムーズに作ることができました。

「困ったらここで聞く」という流れを作れたのもよかったと思います。

「PHPStanをエンジョイする部屋」開設

今から導入するなら意識したい事

運用が安定してきたら早めにマージブロックする

導入初期は、いきなりPHPStanの実行結果で警告が出ていても、PRをマージできるようにしていました。特に導入初期は設定漏れによる誤検知も多く、「本当は問題ないのにマージできない状態」が多々ありました。「緊急対応時にPHPStanがブロックになりリリースできない」とう状況を避けたかったのもあります。

予想外のエラーが起きた時は、都度PHPStan相談チャットで共有いただいていたので、誤検知を無くしていく流れはうまく回っていたと思います。

ただ、運用が安定してくるともっと早めにマージブロックした方がよかったなと感じてます。というのも、PHPStanの警告が出ている状態で一度mainブランチにmergeされてしまうと、そこからブランチを切った瞬間からCIが落ちる状態なので、みんな「こんなん知らんやん」という状態になってました。

緊急対応時でも権限を持ってる方ならそのままマージできるはずなので、そこまで気にする必要もなかったかな、というのが今の感想です。

解析レベルを上げるときは新しく出るエラーをメンバーへ事前に周知する

PHPStanは最初はlevel 5で導入しました。そこから「そろそろlevel 6にしたいなー」とう事でレベルをあげちゃったのですが、どういう内容で怒られる事になるかを通知してからあげればよかったと思ってます。

先述したように、せっかく相談グルチャを作成しているので、以下のような事を事前に周知しておくと、開発メンバーもすんなりと受け入れやすくなるのではと思います。

  • PHPStanのルールが変更される事
  • ルール変更後、新たにどのようなコードが警告の対象になるのか
  • 警告への対処方法

PHPStanの公式サイトに気軽にPHPStanを試す事ができるので、こちらの実行結果を共有するとより伝わりやすいと思います。

phpstan.org

何を得られたか

思い切ったリファクタが気軽にできるようになった

直近では、別々のリポジトリとして管理していたコードを一つのリポジトリに統合するという大規模なリファクタがあったのですが、その際にも「何かミスがあってもPHPStanが見つけてくれる」という安心感がありました。

また、静的型付け言語のような恩恵を受けられるというのも大きなポイントだと思います。

レガシーコード改善ガイドには「コンパイラ任せ」という以下のようなリファクタリングテクニックが紹介されています。

  • 宣言を変更してコンパイルエラーを起こさせる
  • エラーが発生した部分に対して変更を行う

PHPは動的型付け言語なので上記のような方法は使えないのですが、「PHPStanまかせ」で同様の効果を得る事が可能になりました。

www.shoeisha.co.jp

レビューコストの削減

また、コードレビューの面でも恩恵があります。以前までは、クラスの削除・メソッドの削除・インターフェイスの変更・namespaceの変更等は、レビュー対象のPRをローカルにpullして、IDEで本当に問題がないかを確認していました。

今では、「PHPStan通ってるし、問題ないでしょう」という判断ができるので 非常に楽です。

実装時も、commit事にPHPStan先生がレビューしてくれるので、何か不備があってもレビューを出す前に気づけるの、PRでやりとりするというコミュニケーションコストの削減も期待できます。

CircleCiのPHPStanジョブの結果

ライブラリのメジャーバージョンアップがより安心して実施できるようになった

Chatworkでは定期的なライブラリのバージョンアップの仕組みを作るためにdependabotを取り入れています。

dependabotの詳細については以下の記事が参考になります。

dev.classmethod.jp

ライブラリがセマンティックバージョニングに沿っている場合、メジャーバージョンアップの際には、破壊的なインターフェイスの変更がある事が予想されます。

「メソッドの削除された」「引数に型宣言がついていなかったメソッドに型宣言が追加された」などの変更があれば、実際に動かしたり、利用箇所を精査しない限り問題に気づくことが困難でした。

今ではdependabotが作成したPRに対して即時にCIが走りPHPStanの解析が実行されるため、問題の発見が非常に楽になりました。

開発メンバーの感想

導入してしばらく経ったのちにメンバーに感想を聞いてみました。

以下、いただいた感想を(ちょっとだけ文脈を補正して)そのまま紹介します。

  • 既存のコードが悪いのもあるけどarrayの中身の型を書くのが面倒だった(最近慣れてきた)
  • 一箇所警告を治すと芋づる式に修正箇所が増えることがあってどこまで頑張るべきかの判断が難しい
  • IDEで動かす時に、PHPStanの処理が重め
  • メリットがでかすぎて、細かい感想は忘れた
  • CIで指摘があがってきてどういう意味?が最初あったけど、すぐ慣れた。

自分も同じような感想を持っているので、どこの組織に導入しても似たような感想が出てくるのではないかと思います。

まとめ

PHPStanは非常に大きな恩恵があります。導入するとより厳密なコーディングが求められるため、面倒に思えるような場面もあるかもしれません。

ただしそれを差し引いても得られる恩恵のほうが大きいと感じています。実際に導入してみて、以下のようなメリットを感じました。

  • レビューコスト・コミュニケーションコストの削減した
  • 実装コストの削減・リファクタリングの推進された
  • バグをリリースする前に気づけるようになった

特にCIと組み合わせる事によって、「コードを書く→静的解析→コードを修正」というフィードバックループを高速に回すことが可能になります。より実装に集中しやすい状況を作るという意味でも、大きな味方になってくれます。

また、「PHPStanを入れたいけど古いコードがあって入れられる気がしない...」とう方も安心してください。Chatworkでもそうしたように「一旦はbaseline(警告除外対象)に入れてしまい新規のコードに対してルールを適用していく」事が可能です。baselineを利用するなら、導入コストもぐっと下がります。

何より、PHPStanの導入自体は低コストでできるので、非常にコストパフォーマンスが高いと感じました。

PHP部では一緒に働く仲間を募集しています!

PHPStanの導入は、「PHPStan導入したい!」という個人の思いから「やっていこう!」と話が進みました。社内であまり前例がないことでも、良さそうなら積極的に採用していこうという文化があり、やりたいと思ったことがやらせてもらえる環境です。

PHPという枠にとらわれず、さまざまな技術に触れたい!挑戦したい! など少しでも興味を持っていただければ、下記リンクをご覧ください!

hrmos.co