はじめに
お問い合わせフォームの作成依頼が来た!
ということで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
に型を付ける。
不正な入力値ならvalue
はnull
のままになる。
メールアドレスに対応する型も欲しいので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;
}
}
おっと、null
チェックが抜けていたようだ。
完成。
プロパティをインターフェースとメソッドに置き換える
ここまでのコードで、プロパティを直接参照しているのがちょっと気になる。
直接参照せずに、メソッドを通してアクセスするように変更しよう。
<?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
プロパティに直接アクセスしている箇所を探す。
あとは機械的に変換して…
おや?
nullチェックしているつもりが効いていない。
それもそのはずで、一回目の$this->email->getValue()
がnullじゃなかったからと言って、二回目もnullでない保証はない。
getValue
の中で実はnullをセットしている、なんてこともあるかもしれない。
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
のデータを持っているクラスがあった場合はインターフェースにしないといけない。
プロパティが多すぎるから妥協して継承にしたりなんかすると、余計にコードが把握困難になったりした。
楽に書けて、機能を分離できて、型チェックできる、そんな書き方はないものか…。