はじめに
BEAR.Sundayが標準で提供するフォームライブラリはAura.Inputですが、フルスタックスタイルで提供される様々なフォームライブラリの恩恵を受けたいケースも多々あると思います。
今回はZendFramework2のZend\FormをBEAR.Sundayで利用する一例を紹介したいと思います。
BEAR.Sundayのフォーム実装を踏襲して、今回もフォームはインターセプターとして実装します。
フォームインターセプターの作成
インターセプターを作成します。
traitの実装
まず、フォーム共通で利用する処理をtraitとして実装します。
<?php
namespace My\App\Interceptor\ZendForm;
use BEAR\Resource\NamedParameter;
use Ray\Aop\MethodInvocation;
use Ray\Di\Di\Inject;
/**
* Class ZendFormInject
* @package My\App\Interceptor\ZendForm
*/
trait ZendFormInject
{
/**
* @var array
*/
private $defaultValue = [];
/**
* @var NamedParameter
*/
private $params;
/**
* @var bool
*/
public $hasSubmit = false;
/**
* @param NamedParameter $params
* @Inject
*/
public function setNamedParameter(NamedParameter $params)
{
$this->params = $params;
}
/**
* @param MethodInvocation $invocation
* @return mixed|object
*/
public function invoke(MethodInvocation $invocation)
{
$this->hasSubmit = $invocation->getMethod()->name === 'onPost';
if ($this->hasSubmit) {
$this->defaultValue = $_POST;
}
/** @noinspection PhpUndefinedMethodInspection */
$this->setForm();
/** @var \BEAR\Resource\ResourceObject $page */
$page = $invocation->getThis();
$method = $invocation->getMethod()->name;
$page->body['form'] = $this;
if ($this->hasSubmit && $this->isValid()) {
$data = $this->getData();
$args = $this->params->getArgs([$page, $method], $data);
return call_user_func_array([$page, 'onPost'], $args);
}
return call_user_func_array([$page, 'onGet'], (array) $invocation->getArguments());
}
}
ここでは以下の処理を行っています。
- フォーム作成処理の実行
- フォームのバリデーション
- PageResourceObjectのメソッド実行
余談ですが、こちらのコードには少し問題があります。
本来、Ray.AOPでは本質の処理を実行する際には以下のメソッドを呼び出しますが、
$invocation->proceed();
今回はResourceObjectのどのメソッド(onGet or onPost)を呼び出すべきかという点が、フォームのバリデーション結果によって左右されますので、代わりに以下の方法でメソッドを呼び出しています。
call_user_func_array([$page, 'onGet'], (array) $invocation->getArguments());
この方法では本アスペクト以降にバインドされた処理が実行されません。
現在のバージョンのRay(2014/12/12時点)では、実行される順番はバインドされる順番に依存しています。
そこだけ注意が必要です。
Formの実装
次にFormを実装します。今回はユーザ登録フォームを作成します。
<?php
namespace My\App\Interceptor\Form;
use BEAR\Sunday\Inject\ResourceInject;
use Ray\Aop\MethodInterceptor;
use Ray\Di\Di\Inject;
use Ray\Di\Di\Named;
use My\App\Interceptor\ZendForm\ZendFormInject;
use Zend\Form\Form;
use Zend\InputFilter\InputFilterAwareInterface;
/**
* Class Join
* @package My\App\Interceptor\Form
*/
class Join extends Form implements MethodInterceptor
{
use ResourceInject;
use ZendFormInject;
/**
* @var InputFilterAwareInterface
*/
private $validator;
/**
* @param InputFilterAwareInterface $validator
* @Inject
* @Named("join")
*/
public function setValidator(InputFilterAwareInterface $validator)
{
$this->validator = $validator;
}
/**
* (non-PHPDoc)
*/
private function setForm()
{
$this->setName('joinForm');
$this->setAttribute('method', 'post');
// ユーザ名
$this->add(
[
'name' => 'name',
'type' => 'Zend\Form\Element\Text',
'attributes' => [
'placeholder' => 'ユーザーネーム',
'required' => 'required',
'maxlength' => 32
]
]
);
// パスワード
$this->add(
[
'name' => 'password',
'type' => 'Zend\Form\Element\Password',
'attributes' => [
'placeholder' => 'パスワード',
'required' => 'required',
'maxlength' => 32
]
]
);
// メールアドレス
$this->add(
[
'name' => 'mail',
'type' => 'Zend\Form\Element\Email',
'attributes' => [
'placeholder' => 'メールアドレス',
'required' => 'required',
'maxlength' => 128
]
]
);
// 性別
$this->add(
[
'name' => 'gender',
'type' => 'Zend\Form\Element\Radio',
'attributes' => [
'required' => 'required',
'value' => 'male',
],
'options' => [
'value_options' => [
'male' => '男性',
'female' => '女性',
]
]
]
);
// 年齢
$this->add(
[
'name' => 'generation',
'type' => 'Zend\Form\Element\Select',
'attributes' => [
'required' => 'required',
],
'options' => [
'value_options' => [
'0' => '〜10代',
'1' => '20代',
'2' => '30代',
'3' => '40代',
'4' => '50代',
'5' => '60代〜',
]
]
]
);
// 興味のあるカテゴリ
/** @noinspection PhpUndefinedFieldInspection */
$categories = $this->resource->get->uri('app://self/category')->eager->request()->body;
$valueOptions = [];
foreach ($categories as $category) {
$valueOptions[$category['slug']] = $category['name'];
}
$this->add(
[
'name' => 'category',
'type' => 'Zend\Form\Element\MultiCheckbox',
'required' => true,
'options' => [
'value_options' => $valueOptions
]
]
);
// CSRF
$this->add(
[
'name' => 'csrf',
'type' => 'Zend\Form\Element\Csrf'
]
);
// submit
$this->add(
[
'name' => 'submit',
'type' => 'Zend\Form\Element\Button',
'attributes' => [
'class' => 'button',
'type' => 'submit'
],
'options' => [
'label' => 'アカウント登録'
]
]
);
$this->setInputFilter($this->validator->getInputFilter());
$this->setData($this->defaultValue);
$this->prepare();
}
}
Interceptor自体がZend\Form\FormInterfaceを実装しています。
InputFilterAwareInterfaceを実装した $validator インスタンスを注入しているセッターインジェクションに注目して下さい。
Zend\FormではInputFilterとFormの実装は分離されていますので、このNamedマーカー付きのセッターインジェクションで依存を注入しています。
注入されたValidatorはフォームオブジェクト自身に対してFilterとして登録されます。
フォームフィルター(Validator)の実装
フォームフィルターを作成します。詳しい実装方法は本家ドキュメントを参照して下さい。
<?php
namespace My\App\Filter;
use BEAR\Sunday\Inject\ResourceInject;
use Ray\Di\Di\Inject;
use Ray\Di\Di\Named;
use My\App\Module\App\Zend\Validator\Jp\Translator;
use Zend\InputFilter\Factory as InputFactory;
use Zend\InputFilter\InputFilterAwareInterface;
use Zend\InputFilter\InputFilterInterface;
use Zend\Validator;
/**
* Class Join
* @package My\App\Mail\Filter
*/
class Join implements InputFilterAwareInterface
{
use ResourceInject;
/**
* @var InputFilterInterface
*/
private $inputFilter;
/**
* @var InputFactory
*/
private $inputFactory;
/**
* @param InputFilterInterface $inputFilter
* @return InputFilterInterface
* @Inject
*/
public function setInputFilter(InputFilterInterface $inputFilter)
{
$this->inputFilter = $inputFilter;
}
/**
* @param InputFactory $inputFactory
* @Inject
*/
public function setInputFactory(InputFactory $inputFactory)
{
$this->inputFactory = $inputFactory;
}
/**
* @param Translator $translator
* @Inject
*/
public function setTranslator(Translator $translator)
{
Validator\AbstractValidator::setDefaultTranslator($translator);
}
/**
* {@inheritdoc}
*/
public function getInputFilter()
{
$resource = $this->resource;
$callback = new Validator\Callback(
function ($value) use ($resource) {
/** @noinspection PhpUndefinedFieldInspection */
$user = $resource->get
->uri('app://self/user')
->withQuery(['name' => $value])
->eager->request();
return empty($user->body);
}
);
$this->inputFilter->add(
$this->inputFactory->createInput(
[
'name' => 'name',
'required' => true,
'filters' => [
['name' => 'Zend\Filter\StripTags'],
['name' => 'Zend\Filter\StringTrim']
],
'validators' => [
[
'name' => 'Zend\Validator\Callback',
'options' => [
'callback' => $callback,
'messages' => [
Validator\Callback::INVALID_VALUE
=> 'このユーザーネームは既に登録されています。他のユーザーネームを指定して下さい。'
]
]
]
]
]
)
);
$callback = new Validator\Callback(
function ($value) use ($resource) {
/** @noinspection PhpUndefinedFieldInspection */
$user = $resource->get
->uri('app://self/user')
->withQuery(['mail' => $value])
->eager->request();
return empty($user->body);
}
);
$this->inputFilter->add(
$this->inputFactory->createInput(
[
'name' => 'mail',
'required' => true,
'filters' => [
['name' => 'Zend\Filter\StripTags'],
['name' => 'Zend\Filter\StringTrim']
],
'validators' =>[
[
'name' => 'Zend\Validator\EmailAddress',
],
[
'name' => 'Zend\Validator\Callback',
'options' => [
'callback' => $callback,
'messages' => [
Validator\Callback::INVALID_VALUE
=> 'このユーザーネームは既に登録されています。他のユーザーネームを指定して下さい。'
]
]
]
],
]
)
);
$this->inputFilter->add(
$this->inputFactory->createInput(
[
'name' => 'gender',
'required' => true,
'filters' => [
['name' => 'Zend\Filter\StripTags'],
['name' => 'Zend\Filter\StringTrim']
],
'validators' => [
[
'name' => 'Zend\Validator\InArray',
'options' => [
'haystack' => ['male', 'female']
]
]
]
]
)
);
$this->inputFilter->add(
$this->inputFactory->createInput(
[
'name' => 'generation',
'required' => true,
'filters' => [
['name' => 'Zend\Filter\StripTags'],
['name' => 'Zend\Filter\StringTrim']
],
'validators' => [
[
'name' => 'Zend\Validator\InArray',
'options' => [
'haystack' => ['0', '1', '2', '3', '4', '5']
]
]
]
]
)
);
$this->inputFilter->add(
$this->inputFactory->createInput(
[
'name' => 'category',
'required' => true,
'filters' => [
['name' => 'Zend\Filter\StripTags'],
['name' => 'Zend\Filter\StringTrim']
]
]
)
);
return $this->inputFilter;
}
}
今回コールバックフィルターを利用して、既存ユーザの検索を行うことでユニーク属性を持ったフィールドをフィルタリングしています。
また、Translatorを注入しているセッターインジェクション(setTranslator)にも注目して下さい。
こちらのメソッドでtranslatorを取得すると同時に、Validatorのデフォルトトランスレーターに登録しています。
Zend\Formは様々な言語を、Translatorとして登録することでサポートすることが出来ます。
Translatorを外部から注入することにより、コンテキストに応じた翻訳を自在に行うことが出来ます。これは紛れもないDIの恩恵です。*1
フィルターメッセージの日本語化
サービス内容に応じて適宜メッセージの翻訳を行います。
今回は日本語化を行います。
translatorの登録
zf2には標準で各種日本語リソースが含まれています。
この日本語translatorをRay.DIのProviderとして作成します。
<?php
namespace My\App\Module\App\Zend\i18N\Jp;
use Ray\Di\ProviderInterface;
use Zend\I18n\Translator\Translator;
/**
* Class TranslatorProvider
* @package My\App\Module\App\Zend\i18N
*/
class TranslatorProvider implements ProviderInterface
{
/**
* @return \Zend\I18n\Translator\TranslatorInterface
*/
public function get()
{
global $appDir;
$translator = new Translator;
$file = $appDir . '/vendor/zendframework/zendframework/resources/languages/ja/Zend_Validate.php';
$translator->addTranslationFile('phparray', $file, 'default', 'ja_JP');
return $translator;
}
}
Validator用Translatorの作成
上記Translatorは様々なサービスで汎用的に利用されるtranslatorです。
Validatorに適用できる専用の日本語Translatorも作成する必要があります。
<?php
namespace My\App\Module\App\Zend\Validator\Jp;
use Ray\Di\Di\Inject;
use Ray\Di\Di\Named;
use Zend\Validator\Translator\TranslatorInterface;
class Translator implements TranslatorInterface
{
/**
* @var Translator
*/
private $translator;
/**
* @param \Zend\I18n\Translator\TranslatorInterface $translator
* @Inject
* @Named("ja_JP")
*/
public function __construct(\Zend\I18n\Translator\TranslatorInterface $translator)
{
$this->translator = $translator;
}
/**
* @param string $message
* @param string $textDomain
* @param string $locale
* @return string
*/
public function translate($message, $textDomain = 'default', $locale = 'ja_JP')
{
return $this->translator->translate($message, $textDomain, $locale);
}
}
Moduleへの登録
いよいよBindを行います。
DI
DI関連のBindです。
<?php
namespace My\App\Module\App;
use Ray\Di\AbstractModule;
use Ray\Di\Scope;
/**
* Class ZendFormModule
* @package My\App\Module\App
*/
class ZendFormModule extends AbstractModule
{
/**
* {@inheritdoc}
*/
protected function configure()
{
$this->bind('Zend\InputFilter\InputFilterInterface')
->to('Zend\InputFilter\InputFilter');
$this->bind('Zend\InputFilter\InputFilterAwareInterface')
->annotatedWith('join')
->to('My\App\Filter\Join');
$this->bind('Zend\I18n\Translator\TranslatorInterface')
->annotatedWith('ja_JP')
->toProvider('My\App\Module\App\Zend\i18N\Jp\TranslatorProvider')
->in(Scope::SINGLETON);
$this->bind('Zend\Validator\Translator\TranslatorInterface')
->to('My\App\Module\App\Zend\Validator\Jp\Translator')
->in(Scope::SINGLETON);
$this->bind('Zend\ServiceManager\ServiceManager')
->annotatedWith('formHelper')
->toProvider('My\App\Module\App\Zend\ServiceManager\FormHelperProvider')
->in(Scope::SINGLETON);
}
}
処理の上から順にバインドの内容を説明します。
- InputFilterInterfaceに対して実装をBindしています。
- 登録フォームのValidatorをBindしています。Namdパラメータを設定して対象を限定しています。
- 日本語TranslatorをBindしています。こちらもNamedパラメータで言語の対象を限定しています。
- ValidatorのTranslatorInterfaceに対して日本語TranslatorをBindしています。日本語しか期待しないのでシンプルにBindしています。
- Zend\ServiceManagerをBindしています。後ほど説明しますがFormHelperとして限定的に利用します。
AOP
AOP関連のBindです。
<?php
namespace My\App\Module\App;
use BEAR\Package;
use Ray\Di\AbstractModule;
class Aspect extends AbstractModule
{
/**
* {@inheritdoc}
*/
protected function configure()
{
$this->bindInterceptor(
$this->matcher->subclassesOf('My\App\Resource\Page\Join'),
$this->matcher->annotatedWith('BEAR\Sunday\Annotation\Form'),
[$this->requestInjection('My\App\Interceptor\Form\Join')]
);
}
}
今回もBEAR.Sundayが標準で用意されている@Formアノテーションを利用します。
対象となるページにFormをバインドしています。
フォームヘルパーの実装
テンプレートで利用する際に利用するヘルパーを作成します。
テンプレートエンジンとしてTwigを利用することを想定しています。
サービスマネージャの作成
Zf2が標準で用意するヘルパー機能を利用するために、Zend\ServiceManagerを提供するProviderを作成します。
ServiceManagerのConstructorにHelperConfigを渡すことで、必要なサービスをサービスロケータに登録しています。
<?php
namespace My\App\Module\App\Zend\ServiceManager;
use Ray\Di\ProviderInterface as Provide;
use Zend\Form\View\HelperConfig;
use Zend\ServiceManager\ServiceManager;
/**
* ServiceManager for FormHelper
*/
class FormHelperProvider implements Provide
{
/**
* @return mixed|ServiceManager
*/
public function get()
{
return new ServiceManager(new HelperConfig);
}
}
こちらのProviderは前述のZendFormModule.phpで利用されます。
Twig_Extensionの実装
上記ヘルパーをテンプレートエンジン上で利用できるようにTwig_Extensionとして登録します。
Extensionの実装
今回は細かい処理の内容は割愛しますが、FormのViewへのサービス登録とTwigExtensionとしての登録、大まかに分けて2つの登録処理を行っています。
<?php
namespace My\App\Interceptor\TemplateEngine\Twig\Extension;
use Ray\Di\Di\Inject;
use Ray\Di\Di\Named;
use Zend\Form\View\HelperConfig;
use Zend\ServiceManager\ServiceManager;
use Zend\View\HelperPluginManager;
use Zend\View\Renderer\PhpRenderer;
/**
* Class ZendForm_Twig_Extension
* @package My\App\Interceptor\TemplateEngine\Twig
*/
class ZendForm_Twig_Extension extends \Twig_Extension
{
/**
* @var ServiceManager
*/
private $serviceManager;
/**
* @param \Twig_Environment $environment
*/
public function initRuntime(\Twig_Environment $environment)
{
$renderer = new PhpRenderer();
$helperPluginManager = new HelperPluginManager(new HelperConfig);
$registeredService = $this->serviceManager->getRegisteredServices();
foreach ($registeredService['invokableClasses'] as $invokable) {
/** @var \Zend\Form\View\Helper\FormElement $formHelper */
$formHelper = $this->serviceManager->get($invokable);
$formHelper->setView($renderer);
$renderer->setHelperPluginManager($helperPluginManager);
$environment->registerUndefinedFunctionCallback(
function ($name) use ($helperPluginManager, $renderer) {
if (!$helperPluginManager->has($name)) {
return '';
}
$callable = [$renderer->plugin($name), '__invoke'];
$options = ['is_safe' => ['html']];
return new \Twig_SimpleFunction($name, $callable, $options);
}
);
}
}
/**
* @param ServiceManager $serviceManager
* @Inject
* @Named("formHelper")
*/
public function __construct(ServiceManager $serviceManager)
{
$this->serviceManager = $serviceManager;
}
/**
* @return string
*/
public function getName()
{
return 'ZendForm';
}
/**
* @return array
*/
public function getFunctions()
{
$registeredService = $this->serviceManager->getRegisteredServices();
$functions = [];
foreach ($registeredService['invokableClasses'] as $invokable) {
$functions[] = new \Twig_SimpleFunction(
$invokable,
[$this, $invokable],
['is_safe' => ['html']]
);
}
return $functions;
}
/**
* @param $method
* @param $arguments
* @return string
*/
public function __call($method, $arguments)
{
if (!$this->serviceManager->has($method)) {
return '';
}
/** @var Callable $callable */
$callable = $this->serviceManager->get($method);
return isset($arguments[0]) ? $callable($arguments[0]) : $callable();
}
}
コンストラクタインジェクションで注入されているServiceManagerは、予めForm関連のベルパーサービスが注入されているサービスロケータオブジェクトです。(前項参照)
それらのサービスを参照し、Twigの関数として動作するように設定を行っています。
Extensionの追加方法
BEAR.Sundayでは標準でTwig_Environment作成時にAura.Input関連のtwig拡張がインストールされます。
その作成処理というポイントカットに対して「Extension登録」というアスペクトを登録してみます。
<?php
namespace My\App\Interceptor\TemplateEngine\Twig;
use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;
use Ray\Di\Di\Inject;
/**
* Class ExtensionInjector
* @package My\App\Interceptor\TemplateEngine\Twig
*/
class ExtensionInjector implements MethodInterceptor
{
/**
* @var Extension\ZendForm_Twig_Extension
*/
private $zendFormExtension;
/**
* @param Extension\ZendForm_Twig_Extension $zendFormExtension
* @Inject
*/
public function __construct(
Extension\ZendForm_Twig_Extension $zendFormExtension)
{
$this->zendFormExtension = $zendFormExtension;
}
/**
* {@inheritdoc}
*/
public function invoke(MethodInvocation $invocation)
{
/** @var \Twig_Environment $twig */
$twig = $invocation->proceed();
$twig->addExtension($this->zendFormExtension);
return $twig;
}
}
先ほど作成したExtensionをコンストラクタインジェクションで注入、After adviceとしてExtensionを登録します。
こちらも当然以下の通りModuleに登録する必要があります。
<?php
namespace My\App\Module\App;
use BEAR\Package;
use Ray\Di\AbstractModule;
class Aspect extends AbstractModule
{
/**
* {@inheritdoc}
*/
protected function configure()
{
$this->bindInterceptor(
$this->matcher->startsWith('BEAR\Package\Provide\TemplateEngine\Twig\TwigProvider'),
$this->matcher->startsWith('get'),
[$this->requestInjection('My\App\Interceptor\TemplateEngine\Twig\ExtensionInjector')]
);
}
}
テンプレートの記述
テンプレートを記述します。
フォームオブジェクト自体はPageリソースのBodyの中に登録されていますので、そちらを操作してFormをレンダリングします。
詳しい関数の動作等、こちらも公式ドキュメントを参照して下さい。
{% extends "layout/default.twig" %}
{% block page %}
{% if not mail %}
{% if form.hasSubmit and not form.isValid() %}
<ul class="form-errors">
{% for messages in form.getMessages() %}
{% for message in messages %}
<li>*{{ message }}</li>
{% endfor %}
{% endfor %}
</ul>
{% endif %}
{{ form().openTag(form) }}
{% for element in form %}
{{ formelement(element) }}
{% endfor %}
{{ form().closeTag(form) }}
{% else %}
<h2 class="main-title">アカウントの登録を受け付けました。</h2>
<p class="annotations">ご登録いただいた{{ mail|e }}宛てに登録確認用のご案内をお送りしましたので、メールの内容を確認して登録確認を完了してください。</p>
{% endif %}
{% endblock %}
最後に
今回はBEAR.Sundayのフォームライブラリの変更を行いましたが、予想以上にやることが多かったと思います。
ただ、実装していく中で「どうしようもない大きな壁」にぶつかることはあまりなく、順を追って問題を解決していけば必ず実装できるという確信がありました。
そう思わせるのも、BEAR.Sundayが提供する機能あっての事であると同時に、紛れも無い「接続フレームワーク」としての魅力の一つだと思います。
*1 今回の例ではコールバックフィルタにカスタムメッセージを日本語でハードコードしてしまっていますが、多言語サポートを期待するのであれば当然別の箇所で適切に定義すべきです。