🐘

PHPの型に向き合いDXを向上させる

2021/12/14に公開

ランサーズQAチームのいさな(@isanasan_)です。
これはランサーズ Advent Calendar 2021 14日目の記事です。
https://qiita.com/advent-calendar/2021/lancers

モチベーション

ランサーズのCakePHPバージョンアッププロジェクトではCIにPHPStanを導入し、品質の向上を図っています。

コードを実行することなく考慮漏れなどのミスを指摘してくれて便利な静的解析ツールなのですが、型を普段から意識していないとコードをpushしたらいきなりCIで怒られて困惑し、エラーを黙らせるためだけにtypeの記述をしてしまいがちです。こういった体験は、実装者にとってあまり心象の良いものでは無いでしょう。

そこで本稿では、みなさんがPHPの型と気持ち良く向き合ってもらえるように、typeの記述によってエディタの支援が受けられるようになり、開発体験が向上する例を紹介したいと思います。

本稿で扱わないこと

  • LSPの導入手順
  • 各言語サーバーの比較
  • PHPStanの導入手順
  • CakePHPの環境構築手順
  • CakePHPの各種機能の解説
  • PHPのtypehintに関する解説

LSPの紹介

まず始めに本稿で扱うエディタのコーディング支援機能を実現している仕組みである、LSP(Language Server Protocol)について簡単に紹介します。

以下は公式からの引用です。

Adding features like auto complete, go to definition, or documentation on hover for a programming language takes significant effort. Traditionally this work had to be repeated for each development tool, as each tool provides different APIs for implementing the same feature.
A Language Server is meant to provide the language-specific smarts and communicate with development tools over a protocol that enables inter-process communication.
The idea behind the Language Server Protocol (LSP) is to standardize the protocol for how such servers and development tools communicate. This way, a single Language Server can be re-used in multiple development tools, which in turn can support multiple languages with minimal effort.
LSP is a win for both language providers and tooling vendors!

image

つまり、コーディング支援機能を提供する言語サーバー(Language Server,LS)を用意し、標準化されたJSON-RPCを介してエディタと通信することで、LSP Clientの機能を有したエディタであれば、あらゆるツールで同様の機能が利用できるということです。

余談ですが、元々はOmniSharpというVimのプラグインがあり、それを汎用的に使えるようにMicrosoftが拡張したものがLSPになったそうです。

https://twitter.com/mattn_jp/status/1207298699933544449

PHPの言語サーバーIntelephense

こちらが、おそらくPHP界で最もポピュラーな言語サーバーかと思います。OSSではありませんが、基本的な機能は無料で利用でき、課金することで全ての機能が開放される形となっています。
node製なので導入しやすく、筆者も愛用しています。本稿ではこちらのLSを利用して実例を紹介していきます。

https://intelephense.com/

VS Code拡張はこちら

https://marketplace.visualstudio.com/items?itemName=bmewburn.vscode-intelephense-client

補足として、IntelephenseをPhpStormと比較すると、補完の効かないケースがあったり、型パラメータが未サポートだったり、配列の中身までは検証してくれない等の弱点があります。より安全で便利な開発体験を実現するために、PHPStanのような静的解析ツールを組合せて使うのが重要です。
とは言うものの、12/06にv1.8.0がリリースされ、サポートされる記法が少しずつ充実しているので今後の進化に期待です。

その他の言語サーバー

PHPの言語サーバーは他にも実装がありますので簡単に紹介します。

Psalm

Psalmは静的解析ツールとして有名ですが、言語サーバーの機能も実装されており、LSクライアントから機能を利用することが出来ます。また、自動修正を行えるPsalterという機能を内包しているのが特徴です。尚、Psalmを利用する場合はPHP7.1以上が必要となります。

https://github.com/vimeo/psalm

VS Code拡張はこちら

https://marketplace.visualstudio.com/items?itemName=getpsalm.psalm-vscode-plugin

Phan

Phanも同じく静的解析ツールですが、言語サーバーとしての利用もサポートされています。
ちゃんと使ったことがないのですが、かなり厳密な型を求めるツールな印象です。こちらはPHP7.2以上が必要となります。

https://github.com/phan/phan

VS Code拡張はこちら

https://marketplace.visualstudio.com/items?itemName=TysonAndre.php-phan

静的解析ツールがベースとなっている言語サーバーは当然、解析ツール側がサポートしている型の表現には対応しているので、その点がIntelephense、PhpStormと比較するとメリットかなと思います。

ハンズオン

CakePHPのコードを触りながら、言語サーバーの機能を活用していきましょう。
サンプルリポジトリはこちらです。

https://github.com/isanasan/cake-tutorial

尚、本稿で扱うコード例はCakePHPで書いたものになっていますが、内容としてはFWの機能には関係ありませんのでその辺りは差し引いてご覧頂ければと思います。

環境

筆者は普段、メインのエディタとしてVimを愛用しているので、本稿ではVimを使って説明しますが、LSPの機能は他のエディタからも利用できるためVS Code,Emacsなどでも同様のことが出来ます。またPhpStormはLSPを利用していませんが、補完やコードジャンプについてはほぼ同等の動きをすると考えていただいて差し支えないかと思います。

key value
エディタ Vim
LS Intelephense

PHPDocを使った簡単な型付け

まずはコントローラをbakeします。

Controller/UsersController.php
<?php

declare(strict_types=1);

namespace App\Controller;

/**
 * Users Controller
 *
 * @property \App\Model\Table\UsersTable $Users
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
 */
class UsersController extends AppController
{
    /**
     * Index method
     *
     * @return \Cake\Http\Response|null|void Renders view
     */
    public function index()
    {
        $users = $this->paginate($this->Users);

        $this->set(compact('users'));
    }
}

Componentをbakeして簡単なメソッドを実装します。

Controller/Component/UtilComponent.php
<?php

declare(strict_types=1);

namespace App\Controller\Component;

use Cake\Controller\Component;

/**
 * Util component
 */
class UtilComponent extends Component
{
    public function doComplexOperation($amount1, $amount2)
    {
        return $amount1 + $amount2;
    }
}

UsersControllerUtilComponentを読み込みます。

Controller/UsersController.php
 /**
  * Users Controller
  *
  * @property \App\Model\Table\UsersTable $Users
  * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
  */
  class UsersController extends AppController
  {
+	public function initialize(): void
+	{
+		$this->loadComponent('Util');
+	}
  }

この時点ではUsersの補完しか出ません。

Pasted image 20211212222540

@propertyを記述して型を明記します。

Controller/UsersController.php
  /**
  * Users Controller
  *
  * @property \App\Model\Table\UsersTable $Users
+ * @property \App\Controller\Component\UtilComponent $Util
  * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
  */
  class UsersController extends AppController
  {

コンポーネントの補完が出るようになりました。

Pasted image 20211212223211

メソッド名も補完されメソッドのシグネチャも確認できるようになりました。

Pasted image 20211212223428

とりあえずUsersControllerからdoComplexOperation()を呼んみましょう。

    /**
     * Index method
     *
     * @return \Cake\Http\Response|null|void Renders view
     */
    public function index()
    {
+       $this->Util->doComplexOperation();
        $users = $this->paginate($this->Users);

        $this->set(compact('users'));
    }

すると引数が足りてないことを教えてくれます。

Pasted image 20211212224310

引数を与えるためにメソッドの情報を確認してみます。

Pasted image 20211212225416

シグネチャはmixedになっています。

とりあえず何でも良いから渡して実行してみます。

    public function index()
    {
-	$this->Util->doComplexOperation();
+       dd($this->Util->doComplexOperation('hoge', 'hage'));
        $users = $this->paginate($this->Users);

        $this->set(compact('users'));
    }

warningが出てしまいました。

Pasted image 20211212225812

doComplexOperationにtypeを記述してみましょう。折角なので、ここでLSのGo to definitionという機能を使ってメソッドの定義場所までジャンプします。

Animation 1

同様の操作はVS CodeであればF14、PhpStormであればF4を押下することで実現できます。

あらためて、doComplexOperation()にtypeを記述します。

Controller/Component/UtilComponent.php
class UtilComponent extends Component
{
-   public function doComplexOperation($amount1, $amount2)
+   public function doComplexOperation(int $amount1, int $amount2): int

UsersControllerに戻って確認すると実行するまでもなくエラーを確認できるようになりました。[1]

Pasted image 20211212231015

修正して実行してみます。

public function index()
    {
-	dd($this->Util->doComplexOperation('hoge', 'hage'));
+       dd($this->Util->doComplexOperation(1, 1));
 

もちろん問題なく動きます。

Pasted image 20211213093259

簡単な型付けその2

こんな感じのテーブル構成でカスタムファインダーを作ってみます。

userER

予めUserエンティティに仮想プロパティとしてfull_nameを定義しておきます。

App/Model/Entity/User.php
+    protected function _getFullName()
+   {
+       return $this->user_public_profile->first_name . ' ' . $this->user_public_profile->last_name;
+    }

忘れずにプロパティのtypeも追加します。

App/Model/Entity/User.php
     * @property \App\Model\Entity\Article[] $articles
+    * @property string $full_name
     */
    class User extends Entity
    {
        /**
         * Fields that can be mass assigned using newEntity() or patchEntity().
         *
         * Note that when '*' is set to true, this allows all unspecified fields to
         * be mass assigned. For security purposes, it is advised to set '*' to false
         * (or remove it), and explicitly make individual fields accessible as needed.
         *
         * @var array
         */
        protected $_accessible = [
            'user_login_credential_id' => true,
            'user_public_profile_id' => true,
            'user_privacy_id' => true,
            'created' => true,
            'modified' => true,
            'user_login_credentials' => true,
            'user_public_profiles' => true,
            'user_privacies' => true,
            'articles' => true,
+           'full_name' => true,
        ];

UtilComponentにカスタムファインダー的なものを実装します。

Controller/Component/UtilComponent.php
+    public function findUsersForIndex(): array
+    {
+        return $this->fetchTable('Users')
+            ->find()
+            ->contain([
+                'UserLoginCredentials',
+                'UserPrivacies',
+                'UserPublicProfiles',
+           ])
+           ->all()
+           ->toList();
+   }

UsersControllerからメソッドを呼んでPHPStanで型を確認してみましょう。
LSの機能を使えばマウスオーバーするだけでも型を確認することは可能ですが、折角なのでPHPStanのdumpTypeを使って型を確認してみましょう。

Controller/UsersController.php
    public function index()
    {
+        foreach ($this->Util->findUsersForIndex() as $user){
+            \PHPStan\dumpType($user); 
+        }

結果はmixedとなっています。

root@5465c172f0d1:/var/www/html# vendor/bin/phpstan analyse src/Controller/UsersController.php
Note: Using configuration file /var/www/html/phpstan.neon.

 1/1 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

 ------ ---------------------
  Line   UsersController.php
 ------ ---------------------
  29     Dumped type: mixed
 ------ ---------------------


 [ERROR] Found 1 error

型がmixedなので当然補完が効きません。

Pasted image 20211213112623

ここでメソッドの戻り値のtypeを付けます。

+   /**
+    * @return \App\Model\Entity\User[]
+    */
    public function findUsersForIndex(): array

補完が効くようになりました。

Pasted image 20211213113021

dumpTypeを使って型を確認してみると、foreachの中でも型が付いていることが確認できます。

root@5465c172f0d1:/var/www/html# vendor/bin/phpstan analyse src/Controller/UsersController.php
Note: Using configuration file /var/www/html/phpstan.neon.

 1/1 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

 ------ ------------------------------------
  Line   UsersController.php
 ------ ------------------------------------
  29     Dumped type: App\Model\Entity\User
 ------ ------------------------------------


 [ERROR] Found 1 error

まとめ

型定義を行うことで言語サーバーの機能を引き出すことが出来ました。今回の例は極端に簡単なものでしたが、もっと複雑で規模の大きいコードになった時や他人が書いたComponentを利用する立場になった時を考えると、日頃から型を意識することで生産性に違いが出てくることが想像できたのではないでしょうか?もしVS CodeやVimなどのエディタを利用されていて言語サーバーを使っていないのであれば、是非インストールしてみてください。

おまけ

本稿のスコープからは外れますが、CakePHPでクエリビルダを利用する際にありがちなことについて少し触れておきます。

user_idを指定してfull_nameを取得するメソッドを作ってみます。

Controller/Component/UtilComponent.php
+    /**
+     * @phpstan-param numeric-string $id
+     */
+    public function getFullNameById($id):string
+    {
+        $user = $this->fetchTable('Users')
+        ->find()
+        ->contain([
+            'UserPublicProfiles',
+        ])
+        ->where([
+            "Users.id = $id",
+        ])
+        ->first();
+
+        return $user->full_name;
+    }

引数の型指定に記述したのはis_numeric()がtrueになる文字列値を表わす記法です。
この記法は現在PHPStanとPsalmがサポートしています。
詳しい解説はドキュメントを参照してください。

https://phpstan.org/writing-php-code/phpdoc-types#other-advanced-string-types

https://psalm.dev/docs/annotating_code/type_syntax/scalar_types/#numeric-string

上記のコードを書く時、補完が効きません。

Pasted image 20211213134721

dumpType()を使って型を確認してみるとarray|Cake\Datasource\EntityInterface|nullとなっています。

root@5465c172f0d1:/var/www/html# vendor/bin/phpstan analyse src/Controller/Component/UtilComponent.php
Note: Using configuration file /var/www/html/phpstan.neon.

 1/1 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

 ------ -----------------------------------------------------------------------------
  Line   UtilComponent.php
 ------ -----------------------------------------------------------------------------
  52     Dumped type: array|Cake\Datasource\EntityInterface|null
  54     Cannot access property $full_name on array|Cake\Datasource\EntityInterface.
 ------ -----------------------------------------------------------------------------


 [ERROR] Found 2 errors

ここでさらに気になるのは2つ目54行目のエラーです。

Cannot access property $full_name on array|Cake\Datasource\EntityInterface.

コントローラの実装を加えて実行してみましょう。

Controller/UsesController.php
    public function view($id = null)
    {
+        dd($this->Util->getFullNameById($id));

問題なく動きます。

Pasted image 20211213140900

意図的に存在しないユーザーへアクセスしてみるとWarningが発生してしまいました。

Pasted image 20211213141016

PHPStanが実装の考慮漏れを教えてくれていたのです。
ちゃんとnullチェックを入れて実行してみます。

Controller/Component/UtilComponent.php
-   public function getFullNameById($id):string
+   public function getFullNameById($id): ?string
    {
        $user = $this->fetchTable('Users')
            ->find()
            ->contain([
                'UserPublicProfiles',
            ])
            ->where([
                "Users.id = $id",
            ])
            ->first();

+       if (is_null($user)) {
+           return null;
+       }
		return $user->full_name;
	}

問題なく実行出来ました。

Pasted image 20211213141813

しかしPHPStanは許してくれません。

root@5465c172f0d1:/var/www/html# vendor/bin/phpstan analyse src/Controller/Component/UtilComponent.php
Note: Using configuration file /var/www/html/phpstan.neon.

 1/1 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

 ------ -----------------------------------------------------------------------------
  Line   UtilComponent.php
 ------ -----------------------------------------------------------------------------
  56     Cannot access property $full_name on array|Cake\Datasource\EntityInterface.
 ------ -----------------------------------------------------------------------------


 [ERROR] Found 1 error

これはCake\ORM\Query::first()の戻りの型がarray|Cake\Datasource\EntityInterfaceとなっている為です。このようなケースでは独自にPHPStan拡張を作って対応したりするのですが[2]、本稿では特定の静的解析ツールやプラグインに依存しない方法としてassert()を用い、$userに適切な型を記憶することにします。[3]

Controller/Component/UtilComponent.php
        if (is_null($user)) {
            return null;
        }

+       assert($user instanceof \App\Model\Entity\User);

        return $user->full_name;

エラーを回避することが出来ました。

root@5465c172f0d1:/var/www/html# vendor/bin/phpstan analyse src/Controller/Component/UtilComponent.php
Note: Using configuration file /var/www/html/phpstan.neon.

 1/1 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%



 [OK] No errors

勿論、補完も効くようになりました。

Pasted image 20211213143017

明日は、@manamin0521さんの「大学で学んだことで業務に活かせそうな話」です。お楽しみに!!

参考

https://www.phper.ninja/entry/2021/04/05/062933

脚注
  1. これはLSのdiagnosticという機能ですが、全てのエラーを検知できる訳ではありません。このあたりが静的解析ツールと併用するのが望ましい所以です ↩︎

  2. 実際CakePHPには拡張が用意されているのですがこの話は別の機会に譲ります ↩︎

  3. @varで型を付けることも出来るのですが、非推奨なので筆者は極力避けるようにしています ↩︎

Discussion