45
44

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

[CakePHP3] 意外としられていない「モデルのないフォーム」を紹介する

Last updated at Posted at 2017-12-13

この記事は、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
      • email
      • password
    • member_profiles (members hasOne)
      • member_id
      • name
      • nickname
    • member_hobbies (members hasMany)
      • member_id
      • hobby_id

alt

上記のような場合、エンティティを直接 FormHelper に渡してフォームを作ることもできますが、ちょっとめんどくさくなっているのは「趣味1は必須」というもの。member_hobbies は hasMany なテーブルとしてつくるが、一つだけは必須にするという形になっています。

※ これは先日行われた、 第7回CakePHP MeetUp の中で議論したものをすこしアレンジして使わせてもらいました。

このような、リクエスト内容と実際のテーブルの関係が一致させずらい、もしくは、一致させないほうが簡単に実装できるという場合に威力を発揮するのが、「モデルのないフォーム」になります。

実際のコード

サンプルコードを github にあげておきました。内容をガッツリ見たい方は、実際に動かしてみたい方は以下のものを使ってください。

(注) 「モデルのないフォーム」の説明用なので、パスワードを暗号化してないとか適当なことをしてますが、説明したいのはそこじゃないので、ご容赦の程を・・・

肝心の「モデルのないフォーム」で使用する form クラスの内容を見ていきましょう。

_buildSchema

エンティティの代わりに FormHelper に渡す form クラスの _buildSchema メソッドは以下のような形になります。フォームの入力内容に関して、スキーマ定義をしています。特に難しくはないですね。

src/Form/Members/AddForm.php
<?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 を素直につかってないのには理由があるんですが、それはまた別の記事を書こうと思います

src/Form/Members/AddForm.php
<?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)) することにより、バリデーションの結果とルールの結果を合わせているところです。

src/Form/Members/AddForm.php
<?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 は以下のようにものすごい簡単なものになります。

src/Controller/MembersController.php
<?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 が使われてるのが見て取れると思います。

src/Template/Members/add.ctp
<?= $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 さんです。

45
44
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
45
44

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?