PHPの型に向き合いDXを向上させる
ランサーズQAチームのいさな(@isanasan_)です。
これはランサーズ Advent Calendar 2021 14日目の記事です。
モチベーション
ランサーズの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!
つまり、コーディング支援機能を提供する言語サーバー(Language Server,LS)を用意し、標準化されたJSON-RPCを介してエディタと通信することで、LSP Clientの機能を有したエディタであれば、あらゆるツールで同様の機能が利用できるということです。
余談ですが、元々はOmniSharpというVimのプラグインがあり、それを汎用的に使えるようにMicrosoftが拡張したものがLSPになったそうです。
PHPの言語サーバーIntelephense
こちらが、おそらくPHP界で最もポピュラーな言語サーバーかと思います。OSSではありませんが、基本的な機能は無料で利用でき、課金することで全ての機能が開放される形となっています。
node製なので導入しやすく、筆者も愛用しています。本稿ではこちらのLSを利用して実例を紹介していきます。
VS Code拡張はこちら
補足として、IntelephenseをPhpStormと比較すると、補完の効かないケースがあったり、型パラメータが未サポートだったり、配列の中身までは検証してくれない等の弱点があります。より安全で便利な開発体験を実現するために、PHPStanのような静的解析ツールを組合せて使うのが重要です。
とは言うものの、12/06にv1.8.0がリリースされ、サポートされる記法が少しずつ充実しているので今後の進化に期待です。
その他の言語サーバー
PHPの言語サーバーは他にも実装がありますので簡単に紹介します。
Psalm
Psalmは静的解析ツールとして有名ですが、言語サーバーの機能も実装されており、LSクライアントから機能を利用することが出来ます。また、自動修正を行えるPsalterという機能を内包しているのが特徴です。尚、Psalmを利用する場合はPHP7.1以上が必要となります。
VS Code拡張はこちら
Phan
Phanも同じく静的解析ツールですが、言語サーバーとしての利用もサポートされています。
ちゃんと使ったことがないのですが、かなり厳密な型を求めるツールな印象です。こちらはPHP7.2以上が必要となります。
VS Code拡張はこちら
静的解析ツールがベースとなっている言語サーバーは当然、解析ツール側がサポートしている型の表現には対応しているので、その点がIntelephense、PhpStormと比較するとメリットかなと思います。
ハンズオン
CakePHPのコードを触りながら、言語サーバーの機能を活用していきましょう。
サンプルリポジトリはこちらです。
尚、本稿で扱うコード例はCakePHPで書いたものになっていますが、内容としてはFWの機能には関係ありませんのでその辺りは差し引いてご覧頂ければと思います。
環境
筆者は普段、メインのエディタとしてVimを愛用しているので、本稿ではVimを使って説明しますが、LSPの機能は他のエディタからも利用できるためVS Code,Emacsなどでも同様のことが出来ます。またPhpStormはLSPを利用していませんが、補完やコードジャンプについてはほぼ同等の動きをすると考えていただいて差し支えないかと思います。
key | value |
---|---|
エディタ | Vim |
LS | Intelephense |
PHPDocを使った簡単な型付け
まずはコントローラをbakeします。
<?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して簡単なメソッドを実装します。
<?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;
}
}
UsersController
でUtilComponent
を読み込みます。
/**
* 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の補完しか出ません。
@property
を記述して型を明記します。
/**
* 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
{
コンポーネントの補完が出るようになりました。
メソッド名も補完されメソッドのシグネチャも確認できるようになりました。
とりあえず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'));
}
すると引数が足りてないことを教えてくれます。
引数を与えるためにメソッドの情報を確認してみます。
シグネチャはmixed
になっています。
とりあえず何でも良いから渡して実行してみます。
public function index()
{
- $this->Util->doComplexOperation();
+ dd($this->Util->doComplexOperation('hoge', 'hage'));
$users = $this->paginate($this->Users);
$this->set(compact('users'));
}
warning
が出てしまいました。
doComplexOperation
にtypeを記述してみましょう。折角なので、ここでLSのGo to definition
という機能を使ってメソッドの定義場所までジャンプします。
同様の操作はVS CodeであればF14
、PhpStormであればF4
を押下することで実現できます。
あらためて、doComplexOperation()
にtypeを記述します。
class UtilComponent extends Component
{
- public function doComplexOperation($amount1, $amount2)
+ public function doComplexOperation(int $amount1, int $amount2): int
UsersController
に戻って確認すると実行するまでもなくエラーを確認できるようになりました。[1]
修正して実行してみます。
public function index()
{
- dd($this->Util->doComplexOperation('hoge', 'hage'));
+ dd($this->Util->doComplexOperation(1, 1));
もちろん問題なく動きます。
簡単な型付けその2
こんな感じのテーブル構成でカスタムファインダーを作ってみます。
予めUser
エンティティに仮想プロパティとしてfull_name
を定義しておきます。
+ protected function _getFullName()
+ {
+ return $this->user_public_profile->first_name . ' ' . $this->user_public_profile->last_name;
+ }
忘れずにプロパティのtypeも追加します。
* @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
にカスタムファインダー的なものを実装します。
+ public function findUsersForIndex(): array
+ {
+ return $this->fetchTable('Users')
+ ->find()
+ ->contain([
+ 'UserLoginCredentials',
+ 'UserPrivacies',
+ 'UserPublicProfiles',
+ ])
+ ->all()
+ ->toList();
+ }
UsersController
からメソッドを呼んでPHPStanで型を確認してみましょう。
LSの機能を使えばマウスオーバーするだけでも型を確認することは可能ですが、折角なのでPHPStanのdumpType
を使って型を確認してみましょう。
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なので当然補完が効きません。
ここでメソッドの戻り値のtypeを付けます。
+ /**
+ * @return \App\Model\Entity\User[]
+ */
public function findUsersForIndex(): array
補完が効くようになりました。
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を取得するメソッドを作ってみます。
+ /**
+ * @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がサポートしています。
詳しい解説はドキュメントを参照してください。
上記のコードを書く時、補完が効きません。
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.
コントローラの実装を加えて実行してみましょう。
public function view($id = null)
{
+ dd($this->Util->getFullNameById($id));
問題なく動きます。
意図的に存在しないユーザーへアクセスしてみるとWarning
が発生してしまいました。
PHPStanが実装の考慮漏れを教えてくれていたのです。
ちゃんとnullチェックを入れて実行してみます。
- 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;
}
問題なく実行出来ました。
しかし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]
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
勿論、補完も効くようになりました。
明日は、@manamin0521さんの「大学で学んだことで業務に活かせそうな話」です。お楽しみに!!
参考
Discussion