Practical Symfony #23: ドメインの知識を使ったフォームバリデーション
フォームは、PHPメンターズの設計と実装の型で述べているように、アプリケーションレイヤーにて実装されます。今回はフォームのバリデーションの拡張についてとりあげます。
バリデーションの仕組みの基本
ユーザーが入力した値を受け取り、アプリケーションのフォームでその入力を表すオブジェクト(フォームのデータを格納する入れ物、フォームDTO: Data Transfer Objectと名づけます)が組み立てられます。このフォームDTOの持つデータが妥当かどうかをチェックするのがバリデーションの役割です。
バリデーションはフォームDTOに対して行われるため、SymfonyではフォームDTOクラスにバリデーションの定義を記述します。
class Author
{
/**
* @Assert\NotBlank()
* @Assert\Length(min = "3")
*/
private $firstName;
}
フォームDTOのデータを確認するための様々な制約がSymfonyに用意されています。基本的には、フォームDTOのフィールドについて検証を行うもので、処理はフォームDTO内に閉じている前提になっています。
しかし、フォームを使うアプリケーションでは単純に単一エントリのデータの妥当性検査を行うだけでなく、関連データも含めた整合性検査も行いたい場合があります。簡単な例は「登録するメールアドレスがシステム内でユニークであること」といった条件です。ユーザーが入力したデータだけでなく、すでに登録されているデータを検索してチェックしなくてはなりません。Symfonyにはこの目的に特化したUniqueEntityという制約が用意されていますが、このような処理を一般化して考えると、単なるユニーク制約ではなく、「ドメインのルールに基づくバリデーション」を行いたいということになります。
ドメインのルールはどこに表されている?
ドメイン駆動設計に「仕様(Specification)」というパターンがあります(Symfonyでの実装例)。この名前から想起されるとおり、バリデーションで使いたいようなルールは、ドメインの仕様として表すことができます。先ほど例に挙げたユニークであるかどうかという制約もドメインにおける仕様です。仕様オブジェクトとして独立させる他に、リポジトリのメソッドとして表現してもよいでしょう。
仕様として表現する場合、これは1つのオブジェクトで、状態を持たないサービスの一種です。この中では自由に他のサービスやリポジトリを組み合わせて使えます。ですから、アプリケーションのフォームにおけるバリデーションに、ドメインレイヤーのサービスを手軽に指定できればよさそうです。
バリデーションにサービスのメソッドを使う
Alert これ以降の「サービス」は、DDDのサービスのことではなくて、Symfonyのコンテナで扱うサービスを指します。
Symfonyはサービスコンテナをアーキテクチャの基盤に持っており、さまざまな場面でサービスに処理を分散させることができます。ドメインレイヤーのサービスでも、コンテナに登録してあればどこからでも呼び出せます。
問題は、サービスを使ったバリデーションのための制約が、Symfonyの組み込みでは用意されていないということです。また、制約を記述しているフォームDTOやエンティティは、サービスコンテナの参照を保持しません。どうやってサービスをバリデーション時に呼び出せばよいでしょうか?
筆者の使っているServiceCallbackという制約を紹介します。ServiceCallback制約を使うと、フォームDTOに対してサービスのメソッドをバリデーションに使えます。以下は、サービスコンテナに登録されたdomain.member.allow_upgrade_specというサービスのisSatisfiedByメソッドをバリデーション時に呼び出す記述例です。
/**
* @AssertServiceCallback(service="domain.member.allow_upgrade_spec",
method="isSatisfiedBy", message="you can't upgrade.")
*/
class Member
{
呼び出すサービスの方はとてもシンプルで、フォームDTOを引数で受け取るisSatisfiedByを定義し、サービスとして登録するのみです。必要であれば他のサービスやリポジトリなどをDIで注入して使うこともできます。検査結果をtrue/falseで返します。
use JMS\DiExtraBundle\Annotation As DI;
/**
* @DI\Service("domain.member.allow_upgrade_spec")
*/
class AllowUpgradeSpecification
{
/**
* inject services if you need
*/
private $memberRepository;
public function isSatisfiedBy($member)
{
// リポジトリなどを使った条件のチェック
...
return true;
}
}
ServiceCallbackバリデーターの実装
カスタムバリデーターの実装には、ServiceCallback制約とServiceCallbackValidatorを作り、バリデーターとして使えるようサービスコンテナに登録しています。
<?php
namespace PHPMentors\ValidatorBundle\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
*/
class ServiceCallback extends Constraint
{
public $method;
public $service;
public $message = 'Service callback returns an error.';
/**
* {@inheritdoc}
*/
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
/**
* {@inheritdoc}
*/
public function validatedBy()
{
return 'PHPMentorsServiceCallbackValidator';
}
}
<?php
namespace PHPMentors\ValidatorBundle\Validator\Constraints;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
class ServiceCallbackValidator extends ConstraintValidator implements ContainerAwareInterface
{
/**
* @var ContainerInterface
*/
protected $container;
/**
* {@inheritdoc}
*/
public function validate($object, Constraint $constraint)
{
if (null === $object) {
return;
}
if (!$this->container->has($constraint->service)) {
throw new ConstraintDefinitionException;
}
$service = $this->container->get($constraint->service);
if (!method_exists($service, $constraint->method)) {
throw new ConstraintDefinitionException(sprintf('Method "%s" targeted by ServiceCallback constraint does not exist', $constraint->method));
}
$result = call_user_func(array($service, $constraint->method), $object);
if (false == $result) {
$this->context->addViolation($constraint->message);
}
}
/**
* {@inheritdoc}
*/
public function setContainer(ContainerInterface $container = null)
{
$this->container = $container;
}
}
parameters:
php_mentors_validator.service_callback.class: PHPMentors\ValidatorBundle\Validator\Constraints\ServiceCallbackValidator
services:
php_mentors_validator.service_callback:
class: %php_mentors_validator.service_callback.class%
calls:
- [ setContainer, ["@service_container"] ]
tags:
- { name: validator.constraint_validator, alias: PHPMentorsServiceCallbackValidator }
上記コードはSymfony 2.4で動作確認しているものです。2.0向けでは多少書き換えないといけません。動作しているコード例をこちらにあげてあります。
まとめ
ServiceCallback制約を使うことで、バリデーションの記述の自由度が増し、バリデーションのためにフォームDTOやエンティティに余計な知識を埋め込んでしまうことを避けられます。同時に、ドメインの知識がアプリケーションに散らばってしまうことをも防げます。
この制約の実装自体が(厳密なエラー処理等をしていないことを除外しても)とてもシンプルなのは、サービスコンテナやバンドルを土台としているSymfonyのアーキテクチャの恩恵です。しかしその一方で、今回紹介したようにアプリケーションを開発する上でSymfonyに足りない要素があることも事実です。少しだけ手間をかけて仕組みを用意することで、アプリケーションレイヤーとドメインレイヤーの分離を維持することができます。こうしてSymfonyのようなOSSのフレームワークを自分用に育てていけば、頑強な基盤と同時に高い生産性を無理なく両立していけるでしょう。