Do You PHP はてブロ

Do You PHPはてなからはてブロに移動しました

Class Metadataを使ってPOPOのバリデーションやってみた

1つ前のエントリの続き。
一通り訳が終わったということで、http://wiki.php.net/rfc/annotationsに添付されたパッチをsnapshot版PHPに組み込んで、とりあえずPOPO(Plain Old PHP Object)にアノテーションを付けてバリデーションをおこなう、というサンプルを作ってみました。

使用したPHP

2010/08/26 04:30付けのsnapshotにhttp://wiki.php.net/rfc/annotationsに添付されたパッチを当てたPHPです。build自体は結構前にやったんですが、しばらく放置プレーしてました。。。

$ php -v
PHP 5.3.99-dev (cli) (built: Aug 26 2010 15:13:07)
Copyright (c) 1997-2010 The PHP Group
Zend Engine v2.4.0, Copyright (c) 1998-2010 Zend Technologies
$ 

なお、このパッチを当てるとintl拡張モジュールのbuildで失敗するようです。

POPOのコード

対象のPOPOコードは次のような感じ。

<?php
namespace Sample;
use \Annotation;

class User {
    [Annotation\Required(msg=名前を入力してください)]
    [Annotation\Validate(mask="^[\x20-\x7e]+$", msg=すべてASCII文字で入力してください)]
    public $name;

    [Annotation\Validate(enum={'famale', 'male'}, msg='"famele"もしくは"male"を選択してください')]
    public $sex;

    [Annotation\Validate(date='Ymd', msg=誕生日の書式が正しくありません)]
    public $birthday;
}

ここで使っている

  • \Annotation\Required
  • \Annotation\Validate

が、独自に定義したアノテーションです。

アノテーションのコード

アノテーションのコードは次のような感じ。ReflectionAnnotationクラスのサブクラスとして定義します。

<?php
namespace Annotation;

abstract class ValidatorAnnotation extends \ReflectionAnnotation {
}
class Required extends ValidatorAnnotation {
    public $required = true;
    public $msg = null;
}
class Validate extends ValidatorAnnotation {
    public $mask = null;
    public $enum = null;
    public $date = null;
    public $msg = null;
}

ReflectionAnnotationクラスのサブクラスで定義されたプロパティ名(maskとかmsgなど)は、アノテーションを記述する際に利用でき、定義した値が自動的に代入されます。

アノテーションを処理するコード

当然、POPOとアノテーションだけじゃ何も起こらないので、アノテーションを読み取ってバリデーションを行う部分を用意します。かなり適当ですが、ざっくり説明すると、

  1. 定義されたアノテーションから『[アノテーション名] + "ValidateFilter"』というValidateFilterオブジェクトの配列を作る
  2. それを順次実行
  3. バリデーションに失敗したら、InvalidArgumentExceptionをthrow

という感じです。

<?php
namespace Annotation;

class Validator {
    public static function validate($obj) {
        $obj_class = new \ReflectionClass($obj);
        foreach ($obj_class->getProperties() as $property) {
            $prop = new \ReflectionProperty($obj, $property->name);
            $annotations = $prop->getAnnotations();
            $chain = new ValidatorChain();
            foreach ($annotations as $key => $annotation_obj) {
                $validate_class = new \ReflectionClass($key);
                foreach ($validate_class->getProperties() as $filter) {
                    if ($filter->name === 'msg') {
                        continue;
                    }
                    if (!is_null($annotation_obj->{$filter->name})) {
                        $class_name = '\\Annotation\\' . ucfirst($filter->name) . 'ValidateFilter';
                        $chain->add(new $class_name($annotation_obj));
                    }
                }
            }
            $chain->execute($obj->{$property->name});
        }
    }
}

class ValidatorChain {
    private $validators = array();
    public function add(ValidateFilter $validator) {
        $this->validators[] = $validator;
    }
    public function execute($str) {
        foreach ($this->validators as $validator) {
            $validator->validate($str);
        }
    }
}

interface ValidateFilter {
    public function validate($str);
}
class RequiredValidateFilter implements ValidateFilter {
    private $validate;
    public function __construct(ValidatorAnnotation $validate) {
        $this->validate = $validate;
    }
    public function validate($str) {
        $msg = $this->validate->msg;
        if (is_null($msg) || $msg === '') {
            $msg = "入力してください";
        }
        if (is_null($str) || $str === '') {
            throw new \InvalidArgumentException($msg);
        }
    }
}
class MaskValidateFilter implements ValidateFilter {
    private $validate;
    public function __construct(ValidatorAnnotation $validate) {
        $this->validate = $validate;
    }
    public function validate($str) {
        if (is_null($this->validate->mask)) {
            throw new \InvalidArgumentException('mask not set');
        }
        $msg = $this->validate->msg;
        if (is_null($msg) || $msg === '') {
            $msg = "'{$validate->mask}'にマッチしません";
        }
        if (!preg_match("/{$this->validate->mask}/u", $str)) {
            throw new \InvalidArgumentException($msg);
        }
    }
}
class EnumValidateFilter implements ValidateFilter {
    private $validate;
    public function __construct(ValidatorAnnotation $validate) {
        $this->validate = $validate;
    }
    public function validate($str) {
        if (is_null($this->validate->enum) || !is_array($this->validate->enum) || $this->validate->enum === array()) {
            throw new \InvalidArgumentException('enum not set');
        }
        $msg = $this->validate->msg;
        if (is_null($msg) || $msg === '') {
            $msg = "'予期しない値です";
        }
        if (!in_array($str, $this->validate->enum)) {
            throw new \InvalidArgumentException($msg);
        }
    }
}
class DateValidateFilter implements ValidateFilter {
    private $validate;
    public function __construct(ValidatorAnnotation $validate) {
        $this->validate = $validate;
    }
    public function validate($str) {
        if (is_null($this->validate->date)) {
            throw new \InvalidArgumentException('date not set');
        }
        $msg = $this->validate->msg;
        if (is_null($msg) || $msg === '') {
            $msg = "日付が正しくありません";
        }
        $date = new \DateTime($str);
        if ($str !== $date->format($this->validate->date)) {
            throw new \InvalidArgumentException($msg);
        }
    }
}

で、実行してみる

動作確認用のコードは次の通り。

<?php
namespace Sample;
use \Annotation;

$user = new User();
try {
    Annotation\Validator::validate($user);
    var_dump('OK');
} catch (\Exception $e) {
    var_dump($e->getMessage());
}

$user = new User();
$user->name = 'ほげほげ';
try {
    Annotation\Validator::validate($user);
    var_dump('OK');
} catch (\Exception $e) {
    var_dump($e->getMessage());
}

$user = new User();
$user->name = 'foobar';
$user->sex = 'abc';
try {
    Annotation\Validator::validate($user);
    var_dump('OK');
} catch (\Exception $e) {
    var_dump($e->getMessage());
}

$user = new User();
$user->name = 'foobar';
$user->sex = 'male';
$user->birthday = '20100229';
try {
    Annotation\Validator::validate($user);
    var_dump('OK');
} catch (\Exception $e) {
    var_dump($e->getMessage());
}

これを実行してみると、次のように。

$ php -ddate.timezone="Asia/Tokyo" User.class.php
string(33) "名前を入力してください"
string(47) "すべてASCII文字で入力してください"
string(53) ""famele"もしくは"male"を選択してください"
string(45) "誕生日の書式が正しくありません"
$ 

まとめ(っぽいもの)

実際に独自のアノテーションを作ってみましたが、作るのは結構簡単です。サンプルが結構ありきたりなので、何だか「XML/YAMLに定義して(ry」と変わらない気もしましたが、コードそのものに『どういうアノテーションが付いているか(どういう処理をさせるか)』が付いているので、XML/YAMLよりは分かりやすくなるんじゃないかと。
ただし、「アノテーションの設計」をうまくしないと、使いにくいとか、アノテーションを処理する側が大変になりそうな感じがします。PHP5.3以降で利用できる名前空間と組み合わせると、もっと気を使うことになると思います。
なお、現状のパッチは名前空間をあまり考慮していないっぽくて、深い名前空間(\Validator\Annotation\...とか)に対してuseを使っていると、アノテーション名前空間を正しく取得できないようでした。たとえば、

<?php
namespace Validation\Annotation;
class Required extends \ReflectionAnnotation {}

namespace Sample;
use \Validation\Annotation\Required;

class User {
    [Required]
    public $name;
}
$obj = new User();
$obj_class = new \ReflectionClass($obj);
$prop = new \ReflectionProperty($obj, 'name');
$annotations = $prop->getAnnotations();

というコードを実行した場合、getAnnotationsメソッドで『Requiredクラスがない』というエラーになります。