Posted at

PHPStanの静的解析で型安全な書き方をする場合の構造体的クラスとインターフェースのジレンマ


はじめに

お問い合わせフォームの作成依頼が来た!

ということでPHPStanを使って静的解析ができるフォームを作ろう。


入力を受け取るクラスを作る

まずは入力を受け取るクラスが必要だ。

型チェックをするつもりなので、stringの入力値と変換後の何らかの型をコードに書きたい。

基本となるInput\Valueクラスを作り、継承して各々のフォーム入力欄に対応したクラスを作る。

<?php

namespace MyProject\Input;
abstract class Value {
/** @var string */
public $name;
/** @var ?string */
public $input;
public $value;

public function __construct(string $name)
{
$this->name = $name;
$this->input = $_REQUEST[$name] ?: null;

if ($this->input !== null)
$this->convert($this->input);
}

abstract protected function convert(string $input);
}

inputは入力された文字列、valueはエラーが無い場合に利用するフォームの値である。

<?php

namespace MyProject\Input;

class StringValue extends Value {
/** @var ?string */
public $value;

/** @var int */
public $min;
/** @var int */
public $max;

public function __construct(string $name, int $min = 0, int $max = 10000)
{
$this->min = $min;
$this->max = $max;
parent::__construct($name);
}

protected function convert(string $input){
$len = strlen(trim($input));
if ($this->min <= $len && $len <= $this->max)
$this->value = $input;
}
}

<?php

namespace MyProject\Input;

class IntValue extends Value {
/** @var ?int */
public $value;

protected function convert(string $input){
if (preg_match('/[0-9]+$/', $input))
$this->value = (int)$input;
}
}

こんな感じでサブクラスのvalueに型を付ける。

不正な入力値ならvaluenullのままになる。

メールアドレスに対応する型も欲しいのでstringをラップして作る。

<?php

namespace MyProject\Type;

class Email {
/** @var string */
public $value;

public function __construct(string $value){
$this->value = $value;
}
}

<?php

namespace MyProject\Input;

use MyProject\Model;
use MyProject\Type;

class EmailValue extends Value {
/** @var ?Type\Email */
public $value;

public static function valid(string $input): bool {
switch (true){
case false === filter_var($input, FILTER_VALIDATE_EMAIL):
case !preg_match('/@([^@\[]++)\z/', $input, $m):
return false;

case !Model\Env::getInstance()->needsEmailCheckDns():
case checkdnsrr($m[1], 'MX'):
case checkdnsrr($m[1], 'A'):
case checkdnsrr($m[1], 'AAAA'):
return true;

default:
return false;
}
}
protected function convert(string $input)
{
if (self::valid($input))
$this->value = new Type\Email($input);
}
}

Emailを扱うときは、必ずメールアドレスとして正しい状態を保つようにできた。


メールを送信するためのクラスを作る

お問い合わせメールを送信するためには、メール送信関数にデータを受け渡す必要がある。レガシーなPHPなら配列を引数にするところだが、型チェックをするのでクラスを使う。

<?php

namespace MyProject\Dto;

use MyProject\Type\Email;

class InquiryDto {
/** @var Email */
public $email;
/** @var int */
public $inquiryType;
/** @var string */
public $subject;
/** @var string */
public $body;

public function __construct(Email $email, int $inquiryType, string $subject, string $body) {
$this->email = $email;
$this->inquiryType = $inquiryType;
$this->subject = $subject;
$this->body = $body;
}
}

これでInquiryDtoが存在するときは、各必要なデータが全て揃っている状態が保証された。

メールアドレスもEmailValueから取得したものに限り必ず正しいはずである。(パッケージプライベートで他のクラスからnewできないようにしたいところだ)

メール送信処理の本体は以下のようになる。

<?php

namespace MyProject\Model;
use MyProject\Dto;

class Mailer {
private $funcs;
public function __construct($funcs) {
$this->funcs = $funcs;
}
public function send(Dto\InquiryDto $data){
$this->funcs->mail($data->email->value, $data->subject, $data->body);
}
}

メールヘッダーを追加したい場合はType\Emailと同じように安全なType\EmailHeaderを作りたいところだが、ここでは割愛。


フォームの入力値を受け取ってデータを作り出す

ここでフォームを作るわけだが、エラーがある場合のエラーメッセージ表示はとりあえず後回しにする。プログラム的に正しい動作をまず作る。

<?php

namespace MyProject\Form;
use MyProject\Input;
use MyProject\Dto;

class InquiryForm {
/** @var Input\EmailValue */
public $email;
/** @var Input\IntValue */
public $type;
/** @var Input\StringValue */
public $subject;
/** @var Input\StringValue */
public $body;

public function __construct()
{
$this->email = new Input\EmailValue('email');
$this->type = new Input\IntValue('type');
$this->subject = new Input\StringValue('subject', 5);
$this->body = new Input\StringValue('body', 10);
}

public function getValue(): ?Dto\InquiryDto
{
if ($this->email->value
&& $this->type->value
&& $this->subject->value)
return new Dto\InquiryDto(
$this->email->value,
$this->type->value,
$this->subject->value,
$this->body->value);
return null;
}
}

form.png

おっと、nullチェックが抜けていたようだ。

form2.png

完成。


プロパティをインターフェースとメソッドに置き換える

ここまでのコードで、プロパティを直接参照しているのがちょっと気になる。

直接参照せずに、メソッドを通してアクセスするように変更しよう。

<?php

namespace MyProject\Type;

interface HasValue {
public function getValue();
}

<?php

namespace MyProject\Input;
use MyProject\Type;

abstract class Value implements Type\HasValue {

// ...

protected $value;
public function getValue(){ return $this->value; }
}

<?php

namespace MyProject\Input;

use MyProject\Model;
use MyProject\Type;

class EmailValue extends Value {
/** @var ?Type\Email */
protected $value;

// ...

public function getValue(): ?Type\Email { return $this->value; }
}

共変戻り値のルールにより、サブクラスの戻り値に型を指定できる。

PHPStanのコメントでの型指定からPHPの型指定になり、より安全になった。

アクセス権はprotectedに変更して、valueプロパティに直接アクセスしている箇所を探す。

phpstan.png

あとは機械的に変換して…

form3.png

おや?

nullチェックしているつもりが効いていない。

それもそのはずで、一回目の$this->email->getValue() がnullじゃなかったからと言って、二回目もnullでない保証はない。

getValueの中で実はnullをセットしている、なんてこともあるかもしれない。

form4.png

getValue()のメソッド呼び出しの結果は一度変数に代入しないといけない。

エラーメッセージを表示する機能はまだ無いものの、これでひとまず正常フローは完成。


考察

それから、この方針で次々クラスを作っていったとき、ふと思った。

代入がめんどくさい…。一瞬しか使わない変数が邪魔…。

        $email = $this->email->getValue();

$type = $this->type->getValue();
$subject = $this->subject->getValue();
$body = $this->body->getValue();

こういったコードがあちこちに出てくる、そしてここで使った変数はすぐに不要になる。

DBからデータを取ってきた場合なんかは、プロパティが大量にある。それを全部メソッドに置き換えるのは正気の沙汰ではない。

でもインターフェースを使いたい部分もあるんだよな…。

DBからデータを取得する場合に、テーブルの一部のカラムだけ取得したり、joinをしたりしなかったり、left outer joinしたり、nullが入る余地が多い。

その場合は

function run(Entity $entity){

if ($entity->col1 && $entity->col2)
Model::optionalFeature($entity->col1, $entity->col2);
// ...
}

のように書けると楽である。

function run(FeatureColsInterface $entity){

$col1 = $entity->getCol1();
$col2 = $entity->getCol2();
if ($col1 && $col2)
Model::optionalFeature($col1, $col2);
// ...
}

いちいち変数に代入するのはちょっとしんどい。

ただしEntityクラス以外にもFeatureColsInterfaceのデータを持っているクラスがあった場合はインターフェースにしないといけない。

プロパティが多すぎるから妥協して継承にしたりなんかすると、余計にコードが把握困難になったりした。

楽に書けて、機能を分離できて、型チェックできる、そんな書き方はないものか…。