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とアノテーションだけじゃ何も起こらないので、アノテーションを読み取ってバリデーションを行う部分を用意します。かなり適当ですが、ざっくり説明すると、
- 定義されたアノテーションから『[アノテーション名] + "ValidateFilter"』というValidateFilterオブジェクトの配列を作る
- それを順次実行
- バリデーションに失敗したら、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クラスがない』というエラーになります。