この記事は、CakePHP Advent Calendar 2017 の13日目の記事です。
昨日は @cakephper さんの CakePHPではDBカラムのSQLインジェクションに注意! でした。
今日は、意外と知らない人が多そうな「モデルのないフォーム」(Modelless Forms)というのを紹介したいと思います。
「モデルのないフォーム」とは?
CakePHP3 の Cookbook に大項目としてきちんと存在してるのですが、この章を読んだことはありますか?
この「モデルのないフォーム」ですが、使うとものすごい便利なんですが、 Cookbook の内容があっさりしすぎていて、いまいちよさが伝わってこない機能の一つだと思います。
そもそも、「モデルのないフォーム」という名付けがわかりづらい・・・
なんでこういう名前になっているかというと、
- FormHelper の create メソッド(
$this->Form->create
)の第一引数には通常、エンティティを渡すのだが、それに変わって、\Cake\Form\Form
を継承したクラスを渡して使う
FormHelperは、create メソッドの第一引数にエンティティを渡すと、スキーマを参照し、$this->Form->control
とかで、カラム名を指定したら、それが単なるテキストなのか、パスワードやメールアドレスといった特殊なカラムなのかを判断してよしなにレンダリングしてくれるのですが、そこにエンティティを渡さずにフォームを作ることになるので、モデル(エンティティ)がないフォームということで、こういう名付けになっているようです。
どういうクラスを作る必要があるのか?
モデルのないフォームを作るには以下の規約に従う必要があります。
-
\Cake\Form\Form
を継承する -
_buildSchema
メソッドを実装して、そのフォームで入力させたいカラムのスキーマを定義する -
_buildValidator
メソッドを実装して、入力される値に対するバリデーションを定義する -
_execute
メソッドを実装して、execute
メソッドが呼ばれた際のふるまいを定義する
これだけ見てると、FormHelperに普通にエンティティを渡すようにすれば、自分でスキーマを定義しなくても自動でやってくれるのに、こんなのわざわざ作るのって、手間が増えてるだけやん・・・と思ってしまいますよね。
実際、cookbook の例はそういう風に見えてしまいます。なので、おそらく誰も便利さに気が付かないという残念さになってしまっているのです。
では、どういうときにうれしいのか?
フォームで入力するものと、DBのテーブルのカラムが全く一緒だったら、手間を増やしているだけに見えますが、こういう場合はどうでしょうか?
- 会員登録フォームをつくろうとしている
- 登録フォーム上は以下の項目がある
- メールアドレス(ログイン用) (必須)
- パスワード(ログイン用) (必須)
- 名前 (必須)
- ニックネーム (必須)
- 趣味1 (必須)
- 趣味2
- 趣味3
- データベーステーブル的には以下のような構成になっている
- members
- id
- password
- member_profiles (members hasOne)
- member_id
- name
- nickname
- member_hobbies (members hasMany)
- member_id
- hobby_id
- members
上記のような場合、エンティティを直接 FormHelper に渡してフォームを作ることもできますが、ちょっとめんどくさくなっているのは「趣味1は必須」というもの。member_hobbies は hasMany なテーブルとしてつくるが、一つだけは必須にするという形になっています。
※ これは先日行われた、 第7回CakePHP MeetUp の中で議論したものをすこしアレンジして使わせてもらいました。
このような、リクエスト内容と実際のテーブルの関係が一致させずらい、もしくは、一致させないほうが簡単に実装できるという場合に威力を発揮するのが、「モデルのないフォーム」になります。
実際のコード
サンプルコードを github にあげておきました。内容をガッツリ見たい方は、実際に動かしてみたい方は以下のものを使ってください。
(注) 「モデルのないフォーム」の説明用なので、パスワードを暗号化してないとか適当なことをしてますが、説明したいのはそこじゃないので、ご容赦の程を・・・
肝心の「モデルのないフォーム」で使用する form クラスの内容を見ていきましょう。
_buildSchema
エンティティの代わりに FormHelper に渡す form クラスの _buildSchema
メソッドは以下のような形になります。フォームの入力内容に関して、スキーマ定義をしています。特に難しくはないですね。
<?php
namespace App\Form\Members;
class AddForm extends AppForm
{
:
(中略)
:
/**
* Schemaを組み立てて返却
*
* @param Schema $schema
* @return Schema
*/
protected function _buildSchema(Schema $schema) : Schema
{
$schema
->addField('email', ['type' => 'string'])
->addField('password', ['type' => 'string'])
->addField('name', ['type' => 'string'])
->addField('nickname', ['type' => 'string'])
->addField('hobby1', ['type' => 'integer'])
->addField('hobby2', ['type' => 'integer'])
->addField('hobby3', ['type' => 'integer']);
return $schema;
}
:
(中略)
:
}
_buildValidator
続いて、 _buildValidator
では、 _buildSchema
で定義された各入力項目に対するバリデーションルールを定義します。趣味1/趣味2/趣味3のあたりのルールがちょっとエグいことになってますが、変な値がきてないかというのと、3つの項目でダブりがないかをチェックしてるだけです。
(注) 引数で渡された $validator
を素直につかってないのには理由があるんですが、それはまた別の記事を書こうと思います
<?php
namespace App\Form\Members;
class AddForm extends AppForm
{
:
(中略)
:
/**
* バリデーションルールを組み立てて返却
*
* @param Validator $validator
* @return Validator
*/
protected function _buildValidator(Validator $validator) : Validator
{
$appValidator = new \App\Validation\Validator();
$appValidator
->notEmpty('email', __('この項目は必須です。'))
->maxLength('email', 255)
->email('email');
$appValidator
->notEmpty('password', __('この項目は必須です。'))
->minLength('password', 6)
->maxLength('password', 255);
$appValidator
->notEmpty('name', __('この項目は必須です。'))
->maxLength('name', 64);
$appValidator
->notEmpty('nickname', __('この項目は必須です。'))
->maxLength('nickname', 64);
$appValidator
->notEmpty('hobby1', __('この項目は必須です。'))
->integer('hobby1')
->add('hobby1', 'isValidHobby', [
'rule' => [$this, 'isValidHobby'],
'message' => __('選択された趣味が不正です。'),
])
->add('hobby1', 'isUniqueHobby', [
'rule' => function ($value, $context) {
if (empty($value)) {
return true;
}
$hobby2 = Hash::get($context, 'data.hobby2');
$hobby3 = Hash::get($context, 'data.hobby3');
return ((empty($hobby2) || ($value !== $hobby2)) &&
(empty($hobby3) || ($value !== $hobby3)));
},
'message' => __('趣味2/趣味3と異なったものを選択してください。'),
]);
$appValidator
->allowEmpty('hobby2')
->integer('hobby2')
->add('hobby2', 'isValidHobby', [
'rule' => [$this, 'isValidHobby'],
'message' => __('選択された趣味が不正です。'),
])
->add('hobby2', 'isUniqueHobby', [
'rule' => function ($value, $context) {
if (empty($value)) {
return true;
}
$hobby1 = Hash::get($context, 'data.hobby1');
$hobby3 = Hash::get($context, 'data.hobby3');
return ((empty($hobby1) || ($value !== $hobby1)) &&
(empty($hobby3) || ($value !== $hobby3)));
},
'message' => __('趣味1/趣味3と異なったものを選択してください。'),
]);
$appValidator
->allowEmpty('hobby3')
->integer('hobby3')
->add('hobby3', 'isValidHobby', [
'rule' => [$this, 'isValidHobby'],
'message' => __('選択された趣味が不正です。'),
])
->add('hobby3', 'isUniqueHobby', [
'rule' => function ($value, $context) {
if (empty($value)) {
return true;
}
$hobby1 = Hash::get($context, 'data.hobby1');
$hobby2 = Hash::get($context, 'data.hobby2');
return ((empty($hobby1) || ($value !== $hobby1)) &&
(empty($hobby2) || ($value !== $hobby2)));
},
'message' => __('趣味1/趣味2となったものを選択してください。'),
]);
return $appValidator;
}
/**
* 準備された選択肢の中に含まれているか?
*
* @param mixed $value
* @return bool
*/
public function isValidHobby($value) : bool
{
if (empty($value)) {
return true;
}
return array_key_exists($value, Configure::read('hobbies'));
}
:
(中略)
:
}
_execute
最後に、 _execute
ですが、バリデーション済みの値が引数で渡ってきますので、それに対する保存処理をします。
ちょっとした工夫としては、保存時に適用されるルールの結果を取得($errors = $member->getErrors()
)し、それを form にセット($this->setErrors($errors)
) することにより、バリデーションの結果とルールの結果を合わせているところです。
<?php
namespace App\Form\Members;
class AddForm extends AppForm
{
:
(中略)
:
/**
* ロジックを実行
*
* @param array $data
* @return bool
*/
protected function _execute(array $data) : bool
{
$result = true;
$this->loadModel('Members');
try {
$entity = [
'email' => Hash::get($data, 'email'),
'password' => Hash::get($data, 'password'),
'member_profile' => [
'name' => Hash::get($data, 'name'),
'nickname' => Hash::get($data, 'nickname'),
],
'member_hobbies' => $this->buildMemberHobbies($data),
];
$member = $this->Members->newEntity($entity, [
'associated' => [
'MemberProfiles',
'MemberHobbies',
]
]);
$this->Members->save($member);
$errors = $member->getErrors();
} catch (\Exception $e) {
$this->log($e->getMessage(), 'debug');
$errors = [
'exception' => $e->getMessage(),
];
}
if (!empty($errors)) {
$this->setErrors($errors);
$result = false;
}
return $result;
}
/**
* @param array $data
* @return array
*/
protected function buildMemberHobbies(array $data) : array
{
$hobbies = [
[
'hobby_id' => Hash::get($data, 'hobby1'),
],
];
$hobby2 = Hash::get($data, 'hobby2');
if ($hobby2) {
$hobbies[] = [
'hobby_id' => Hash::get($data, 'hobby2'),
];
}
$hobby3 = Hash::get($data, 'hobby3');
if ($hobby3) {
$hobbies[] = [
'hobby_id' => Hash::get($data, 'hobby3'),
];
}
return $hobbies;
}
:
(中略)
:
}
Controller はどうなるのか?
この form クラスをつくることにより、Controller は以下のようにものすごい簡単なものになります。
<?php
namespace App\Controller;
use App\Form\Members\AddForm;
use Cake\Core\Configure;
/**
* Members Controller
*/
class MembersController extends AppController
{
/**
* Add method
*
* @return void
*/
public function add() : void
{
$form = new AddForm();
if ($this->request->is('post')) {
if ($form->execute($this->request->getData())) {
$this->Flash->success('メンバーの登録に成功しました。');
$this->render('thanks');
return;
}
$this->Flash->error('入力に問題があります。');
}
$hobbies = Configure::read('hobbies');
$this->set(compact('form', 'hobbies'));
}
}
CakePHP3 では、バリデーションとルールが分割されたため、それを Controller に直接記述すると、条件判定等が増えることになりますが、「モデルのないフォーム」をつかえば、それらを execute
メソッドに閉じ込めることができます。
execute
メソッドを実行すると以下のことが行われます。
- バリデーションが実行される
- もちろん内部的に _buildValidator で設定したバリデーションルールが使われる
- バリデーション失敗した項目に関しては、 $form->errors() に設定され、FormHelper のエラー表示に使用される
-
_execute
メソッドが実行される-
_execute
メソッドの返却値がそのまま、execute
メソッドの返却値になります
-
テンプレートはどうなるのか?
テンプレートは以下のような感じです。(最低限のものだけを記述してます)
FormHelper の第一引数に、Controller で設定した \App\Form\Members\AddForm
が使われてるのが見て取れると思います。
<?= $this->Form->create($form); ?>
<?= $this->Form->control('email', ['required' => false]); ?>
<?= $this->Form->control('password', ['required' => false]); ?>
<?= $this->Form->control('name', ['required' => false]); ?>
<?= $this->Form->control('nickname', ['required' => false]); ?>
<?= $this->Form->control('hobby1', ['required' => false, 'empty' => '--', 'options' => $hobbies]); ?>
<?= $this->Form->control('hobby2', ['required' => false, 'empty' => '--', 'options' => $hobbies]); ?>
<?= $this->Form->control('hobby3', ['required' => false, 'empty' => '--', 'options' => $hobbies]); ?>
<?= $this->Form->submit(); ?>
<?= $this->Form->end(); ?>
Service 層としての役割
今回は、フォームの入力項目とテーブルの構成が違った場合ということで、この「モデルのないフォーム」を活用しましたが、別の意味合いで使えないかと考えています。
CakePHPは、Fat Controller になりやすく、それを避けるために、色んな人が Controller と Model との間にService層の役割を持たせるためのクラスを独自に作ってきたと思います。
@nojimageさんが、既に 2016 年の Fukuoka.php で指摘されてるのですが、この「モデルのないフォーム」はサービス層としてもっと活用するのがいいのではないかと思っています。
今回は入力フォームの処理部分だけに、「モデルのないフォーム」を使いましたが、私の方では、一覧や詳細画面等でも積極的に使うのもありだなと思ってそれを試してみたりしています。
以下のものでは、_buildSchema
でなにもしていないという、もうそれ FormHelper の第一引数に渡せないやんという歪んだ使い方をしていてたりします。興味のある方は内部をみていただければ、私がどれだけ「モデルのないフォーム」を気に入っているかが伝わると思います。
まとめ
今まで色んな人が、CakePHP の規約の外で、なんとかサービス層的なものをつくってきたと思いますが、「モデルのないフォーム」の機構は CakePHP3 標準の機能であり、これを使うことにより、Controller がすっきりさせることができます。
Form クラスに対するユニットテストは、Controllerと違って、関連する前処理や大量の関連クラスのようなものはなく、とてもテストしやすいです。
cookbook があっさりしすぎて、その破壊力がなかなか伝わらない「モデルのないフォーム」ですが、うまく活用していく方法を見つけてみてはいかがでしょうか?
明日の担当は、@oppara さんです。