search
LoginSignup
6

More than 1 year has passed since last update.

posted at

updated at

CakePHP4 で bakeable な開発環境の実現 [Model編]

この記事は Lancers(ランサーズ) Advent Calendar 2020 2日目のエントリーです。

バックエンドエンジニア&DBRE の まみー です。

ランサーズでは CakePHP4 へのバージョンアッププロジェクトが進行しています。
その中で、より bakeable な開発環境実現のためにやったことを話します。

前置きはいいから何をやるんだよ!という方は実際のコードを コチラ からご覧ください。

bake の機能と問題点

CakePHP には bake コマンドというのがあって、原則的に全てのクラスは bake コマンドで生成するべきです。
例えば users テーブルに対応する Model を生成するために以下のコマンドを実行すると、関連する各種クラスを自動生成してくれます。

Modelクラス作成
$ bin/cake bake model users
One moment while associations are detected.

Baking table class for Users...

Creating file /var/www/app/src/Model/Table/UsersTable.php
Wrote `/var/www/app/src/Model/Table/UsersTable.php`

Baking entity class for Menu...

Creating file /var/www/app/src/Model/Entity/User.php
Wrote `/var/www/app/src/Model/Entity/User.php`

Baking test fixture for Users...

Creating file /var/www/app/tests/Fixture/UsersFixture.php
Wrote `/var/www/app/tests/Fixture/UsersFixture.php`

Baking test case for App\Model\Table\UsersTable ...

Creating file /var/www/app/tests/TestCase/Model/Table/UsersTableTest.php
Wrote `/var/www/app/tests/TestCase/Model/Table/UsersTableTest.php`
Done

このあと、例えばバリデーションを追加実装する場合は

  • src/Model/Table/UsersTable.php

に追加していきます。
カスタムバリデーションやテーブルの関連づけなどを追記していくことは割と多いです。

取得レコードに対する仮想プロパティ 1 を書く場合は

  • src/Model/Entity/User.php

に関数を追加していくことになります。

bake コマンドは既存のテーブル定義を読み込んで、様々な定義を自動生成してくれるので非常に便利で、僕たちは baked なクラスに機能追加しがちです。

しかしテーブル定義は後日必ず変化します。
よって都度 bake で最新に追従するのが一番なのですが、bake コマンドで各クラスを上書きしちゃうと実装してきたコードが消えてしまいます。
これは不便ッ!

bake する都度、差分をみて既存実装を反映とか、またはその逆とか考えたくもありません。

テーブル定義変更に常に追従したい。
でもフレームワークが生成してくれるファイルに手をつけたくない。
…では 継承すればいいのでは?

と考え実施した内容を紹介します。

Generation Gapパターン

継承して解決する方法ってないのかな?と思って調べたら、結城浩 先生のエントリーに行き当たりました。
http://www.hyuki.com/dp/dpinfo.html#GenerationGap

内容を一部抜粋します。

Generation Gapパターンでは、 継承を使ってその問題を解決します。 すなわち、自動生成ツールが作るのはスーパークラスのみとする。 そしてそれには人間は手を加えない。 人間はそのクラスのサブクラスを作る。自動生成ツールはそのサブクラスはいじらない。 …これがGeneration Gapパターンのあらすじです。

まさにやりたかったことだ…!
というわけで実践していきます。

構成

以下で作りました。
各種簡単に解説します。
(exa コマンド 2 地味に便利です。)

ディレクトリ・ファイル構成
$ exa --tree app/
app
├── src
│  ├── Command
│  │  └── ExtendedModelCommand.php
│  └── Model
│     ├── Baked
│     │  ├── Entity
│     │  │  └── User.php
│     │  └── Table
│     │     └── UsersTable.php
│     ├── Entity
│     │  └── User.php
│     └── Table
│        └── UsersTable.php
└── templates
   └── plugin
      └── Bake
         └── Model
            ├── entity.twig
            ├── extended_entity.twig
            ├── extended_table.twig
            └── table.twig

Command

bake コマンドで実行する Command です。
bin/cake bake extended_model users で実行する extended_model の部分です。

bake model に代わり、 bake extended_model を今後使っていく想定です。

│  ├── Command
│  │  └── ExtendedModelCommand.php

Model クラス出力

Baked/Table|Entity ディレクトリを新設します。
以下は実際に bin/cake bake extended_model users を実行した結果です。
bakeable なクラスと、開発していくクラスが分かれています。
Baked 配下のクラスを継承して使いますので、継承するクラスも自動生成に含みます。

│  └── Model
│     ├── Baked
│     │  ├── Entity
│     │  │  └── User.php
│     │  └── Table
│     │     └── UsersTable.php
│     ├── Entity
│     │  └── User.php
│     └── Table
│        └── UsersTable.php

templates

bake コマンド実行時に読み込む Model の template を配置します。

└── templates
   └── plugin
      └── Bake
         └── Model
            ├── entity.twig
            ├── extended_entity.twig
            ├── extended_table.twig
            └── table.twig

(余談) bake はどうやってクラス生成してるのか

以下のコマンドを実行すると

Modelクラス作成
$ bin/cake bake model users

以下の Command クラスが実行されます。

vendor/cakephp/bake/src/Command/ModelCommand.php

bake 関数がクラス出力していることがわかります。

bake 関数

    public function bake(string $name, Arguments $args, ConsoleIo $io): void
    {
        $table = $this->getTable($name, $args);
        $tableObject = $this->getTableObject($name, $table);
        $data = $this->getTableContext($tableObject, $table, $name, $args, $io);
        $this->bakeTable($tableObject, $data, $args, $io);
        $this->bakeEntity($tableObject, $data, $args, $io);
        $this->bakeFixture($tableObject->getAlias(), $tableObject->getTable(), $args, $io);
        $this->bakeTest($tableObject->getAlias(), $args, $io);
    }

以下2行で、Table と Entity クラスを生成してることがわかります。

        $this->bakeTable($tableObject, $data, $args, $io);
        $this->bakeEntity($tableObject, $data, $args, $io);

変更ポイント1:templatesファイルパス

Model クラスの template を以下で指定しています。
https://github.com/cakephp/bake/blob/master/src/Command/ModelCommand.php#L1028

        $out = $renderer->generate('Bake.Model/table');

内容を変更してクラス生成する場合は、ここを変更する必要があります。

変更ポイント2:出力ファイルパスとファイル名

出力ファイルパスとファイル名を以下で指定しています。
https://github.com/cakephp/bake/blob/master/src/Command/ModelCommand.php#L1030-L1031

        $path = $this->getPath($args);
        $filename = $path . 'Entity' . DS . $name . '.php';

getPath関数で、ModelCommand クラスのメンバ変数 $this->pathFragment が使われています。
https://github.com/cakephp/bake/blob/master/src/Command/BakeCommand.php#L108-L120

    public function getPath(Arguments $args): string
    {
        $path = APP . $this->pathFragment;
        if ($this->plugin) {
            $path = $this->_pluginPath($this->plugin) . 'src/' . $this->pathFragment;
        }
        $prefix = $this->getPrefix($args);
        if ($prefix) {
            $path .= $prefix . DIRECTORY_SEPARATOR;
        }

        return str_replace('/', DIRECTORY_SEPARATOR, $path);
    }

bakeTable を実行する前に、 $this->pathFragment にパスを指定すれば良さそうです。

bakeEntity にも同じことを実施すれば、Table|Entity 両方に対応できます。

なんと先人が…ッ!

ここまで考えて、同じようなことやってる人いないのかな?と思ったら…
なんと先人がおられました。
https://wp.tech-style.info/archives/1519
https://qiita.com/learn_tech1/items/9556eea585dcac6eab6d
https://qiita.com/ymm1x/items/12ced37212636b09de19

しかし、最新の CakePHP4 に対応されていない。
じゃあ対応させて公開するしかないな!
ということで実際のソースコードです。

ソースコード

templates/plugin/Bake/Model/table.twig

従来の bake で生成される Table クラスの template です。
vendor 配下の template に対し修正を行っています。

  • vendor 配下の元 template
    • vendor/cakephp/bake/templates/bake/Model/table.twig
  • 変更点
    • namespace を Baked 配下に変更
      • namespace {{ namespace }}\Model\Baked\Table;
    • cakephp-master-replica plugin 3 を利用するための BaseTable クラス継承 4
      • 継承が Table クラスで問題ない場合は変更不要です
      • uses に use App\\Model\\Table\\BaseTable; を追加
      • extends Table -> extends BaseTable に変更
templates/plugin/Bake/Model/table.twig
{#
/**
 * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 * @link          http://cakephp.org CakePHP(tm) Project
 * @since         2.0.0
 * @license       http://www.opensource.org/licenses/mit-license.php MIT License
 */
#}
{% set annotations = DocBlock.buildTableAnnotations(associations, associationInfo, behaviors, entity, namespace) %}
<?php
declare(strict_types=1);

namespace {{ namespace }}\Model\Baked\Table;

{% set uses = [
        'use Cake\\ORM\\Query;',
        'use Cake\\ORM\\RulesChecker;',
        'use Cake\\ORM\\Table;',
        'use Cake\\Validation\\Validator;',
        'use App\\Model\\Table\\BaseTable;',
    ] %}
{{ uses|join('\n')|raw }}

{{ DocBlock.classDescription(name, 'Model', annotations)|raw }}
class {{ name }}Table extends BaseTable
{
    /**
     * Initialize method
     *
     * @param array $config The configuration for the Table.
     * @return void
     */
    public function initialize(array $config): void
    {
        parent::initialize($config);

{% if table %}
        $this->setTable('{{ table }}');
{% endif %}

{%- if displayField %}
        $this->setDisplayField('{{ displayField }}');
{% endif %}

{%- if primaryKey %}
    {%- if primaryKey is iterable and primaryKey|length > 1 %}
        $this->setPrimaryKey([{{ Bake.stringifyList(primaryKey, {'indent': false})|raw }}]);
        {{- "\n" }}
    {%- else %}
        $this->setPrimaryKey('{{ primaryKey|as_array|first }}');
        {{- "\n" }}
    {%- endif %}
{% endif %}

{%- if behaviors %}

{% endif %}

{%- for behavior, behaviorData in behaviors %}
        $this->addBehavior('{{ behavior }}'{{ (behaviorData ? (", [" ~ Bake.stringifyList(behaviorData, {'indent': 3, 'quotes': false})|raw ~ ']') : '')|raw }});
{% endfor %}

{%- if associations.belongsTo or associations.hasMany or associations.belongsToMany %}

{% endif %}

{%- for type, assocs in associations %}
    {%- for assoc in assocs %}
        {%- set assocData = [] %}
        {%- for key, val in assoc %}
            {%- if key is not same as('alias') %}
                {%- set assocData = assocData|merge({(key): val}) %}
            {%- endif %}
        {%- endfor %}
        $this->{{ type }}('{{ assoc.alias }}', [{{ Bake.stringifyList(assocData, {'indent': 3})|raw }}]);
        {{- "\n" }}
    {%- endfor %}
{% endfor %}
    }
{{- "\n" }}

{%- if validation %}

    /**
     * Default validation rules.
     *
     * @param \Cake\Validation\Validator $validator Validator instance.
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator): Validator
    {
{% for field, rules in validation %}
{% set validationMethods = Bake.getValidationMethods(field, rules) %}
{% if validationMethods %}
        $validator
{% for validationMethod in validationMethods %}
{% if loop.last %}
{% set validationMethod = validationMethod ~ ';' %}
{% endif %}
            {{ validationMethod|raw }}
{% endfor %}

{% endif %}
{% endfor %}
        return $validator;
    }
{% endif %}

{%- if rulesChecker %}

    /**
     * Returns a rules checker object that will be used for validating
     * application integrity.
     *
     * @param \Cake\ORM\RulesChecker $rules The rules object to be modified.
     * @return \Cake\ORM\RulesChecker
     */
    public function buildRules(RulesChecker $rules): RulesChecker
    {
{% for field, rule in rulesChecker %}
        $rules->add($rules->{{ rule.name }}(['{{ field }}']{{ (rule.extra is defined and rule.extra ? (", '#{rule.extra}'") : '')|raw }}), ['errorField' => '{{ field }}']);
{% endfor %}

        return $rules;
    }
{% endif %}

{%- if connection is not same as('default') %}

    /**
     * Returns the database connection name to use by default.
     *
     * @return string
     */
    public static function defaultConnectionName(): string
    {
        return '{{ connection }}';
    }
{% endif %}
}

templates/plugin/Bake/Model/entity.twig

従来の bake で生成される Entity クラスの template です。
vendor 配下の template に対し修正を行っています。

  • vendor 配下の元 template
    • vendor/cakephp/bake/templates/bake/Model/entity.twig
  • 変更点
    • namespace を Baked 配下に変更
      • namespace {{ namespace }}\Model\Baked\Entity;
templates/plugin/Bake/Model/entity.twig
{#
/**
 * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 * @link          http://cakephp.org CakePHP(tm) Project
 * @since         2.0.0
 * @license       http://www.opensource.org/licenses/mit-license.php MIT License
 */
#}
{% set propertyHintMap = DocBlock.buildEntityPropertyHintTypeMap(propertySchema ?: []) %}
{% set associationHintMap = DocBlock.buildEntityAssociationHintTypeMap(propertySchema ?: []) %}
{% set annotations = DocBlock.propertyHints(propertyHintMap) %}

{%- if associationHintMap %}
    {%- set annotations = annotations|merge(['']) %}
    {%- set annotations = annotations|merge(DocBlock.propertyHints(associationHintMap)) %}
{% endif %}

{%- set accessible = Bake.getFieldAccessibility(fields, primaryKey) %}
<?php
declare(strict_types=1);

namespace {{ namespace }}\Model\Baked\Entity;

use Cake\ORM\Entity;

{{ DocBlock.classDescription(name, 'Entity', annotations)|raw }}
class {{ name }} extends Entity
{
{% if accessible %}
    /**
     * Fields that can be mass assigned using newEntity() or patchEntity().
     *
     * Note that when '*' is set to true, this allows all unspecified fields to
     * be mass assigned. For security purposes, it is advised to set '*' to false
     * (or remove it), and explicitly make individual fields accessible as needed.
     *
     * @var array
     */
    protected $_accessible = [{{ Bake.stringifyList(accessible, {'quotes': false})|raw }}];
{% endif %}
{% if accessible and hidden %}

{% endif %}
{%- if hidden %}
    /**
     * Fields that are excluded from JSON versions of the entity.
     *
     * @var array
     */
    protected $_hidden = [{{ Bake.stringifyList(hidden)|raw }}];
{% endif %}
}

templates/plugin/Bake/Model/extended_table.twig

bake で生成される Table クラスを継承した開発用クラスの template です。
基本的に親クラスを利用するだけの記述にとどめ、クラス生成後に必要に応じてバリデーションやユニークキー・リレーションの追加・修正などをします。
先人の知恵と同様に、use する Baked なクラスの Alias を切っています。

templates/plugin/Bake/Model/extended_table.twig
<?php
declare(strict_types=1);

namespace {{ namespace }}\Model\Table;

use {{ namespace }}\Model\Baked\Table\{{ name }}Table as BakedTable;

{% set uses = ['use Cake\\ORM\\Query;', 'use Cake\\ORM\\RulesChecker;', 'use Cake\\ORM\\Table;', 'use Cake\\Validation\\Validator;'] %}
{{ uses|join('\n')|raw }}

/**
 * {@inheritDoc}
 */
class {{ name }}Table extends BakedTable
{
    /**
     * Initialize method
     *
     * @param array $config The configuration for the Table.
     * @return void
     */
    public function initialize(array $config): void
    {
        parent::initialize($config);
    }
{{- "\n" }}
{%- if validation %}
    /**
     * Default validation rules.
     *
     * @param \Cake\Validation\Validator $validator Validator instance.
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator): Validator
    {
        parent::validationDefault($validator);
        return $validator;
    }
{% endif %}
{%- if rulesChecker %}
    /**
     * Returns a rules checker object that will be used for validating
     * application integrity.
     *
     * @param \Cake\ORM\RulesChecker $rules The rules object to be modified.
     * @return \Cake\ORM\RulesChecker
     */
    public function buildRules(RulesChecker $rules): RulesChecker
    {
        parent::buildRules($rules);
        return $rules;
    }
{% endif %}
}

templates/plugin/Bake/Model/extended_entity.twig

bake で生成される Entity クラスを継承した開発用クラスの template です。
こちらも親クラスを利用するだけの記述にとどめ、クラス生成後に必要に応じて仮想プロパティなどの実装を追加します。
Alias は Table と同様です。

templates/plugin/Bake/Model/extended_entity.twig
<?php
declare(strict_types=1);

namespace {{ namespace }}\Model\Entity;

use {{ namespace }}\Model\Baked\Entity\{{ name }} as BakedEntity;

/**
 * {@inheritDoc}
 */
class {{ name }} extends BakedEntity
{
}

src/Command/ExtendedModelCommand.php

bake に追加する Command です。
これにより以下のコマンドでクラス生成が可能になります。

Modelクラスの生成
$ bin/cake bake extended_model table_name
  • vendor 配下の元クラス
    • vendor/cakephp/bake/src/Command/ModelCommand.php
    • bakeTable、bakeEntity関数をコピーし、bakeExtendedTable、bakeExtendedEntity関数を作成
      • 継承用クラスを作成している
      • 変更点は生成先のパスと template ファイル名だけ
      • 何か他に良い方法があれば良いのだが…
  • ポイント
src/Command/ExtendedModelCommand.php
<?php
declare(strict_types=1);

namespace App\Command;

use Bake\Command\ModelCommand;
use Bake\Utility\TemplateRenderer;
use Cake\Console\Arguments;
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
use Cake\Core\Configure;
use Cake\ORM\Table;

/**
 * ExtendedModel command.
 *
 * bin/cake bake extended_model [テーブル名]
 * これにより、Model/Baked/Table|Entity に bake でメンテ対象のクラスを出力
 * Model/Table|Entity に bake されたクラスを継承した、今まで通り機能実装するクラスを出力
 */
class ExtendedModelCommand extends ModelCommand
{
    /**
     * {@inheritDoc}
     */
    public function bake(string $name, Arguments $args, ConsoleIo $io): void
    {
        $table = $this->getTable($name, $args);
        $tableObject = $this->getTableObject($name, $table);
        $data = $this->getTableContext($tableObject, $table, $name, $args, $io);

        // bake で生成される Table と Entity
        $this->pathFragment = 'Model/Baked/';
        $this->bakeTable($tableObject, $data, $args, $io);
        $this->bakeEntity($tableObject, $data, $args, $io);

        // Baked を継承した、機能を追加する Table と Entity
        $this->pathFragment = 'Model/';
        $this->bakeExtendedTable($tableObject, $data, $args, $io);
        $this->bakeExtendedEntity($tableObject, $data, $args, $io);
    }

    /**
     * Bake a extended table class.
     *
     * @param \Cake\ORM\Table $model Model name or object
     * @param array $data An array to use to generate the Table
     */
    public function bakeExtendedTable(Table $model, array $data, Arguments $args, ConsoleIo $io): void
    {
        if ($args->getOption('no-table')) {
            return;
        }

        $namespace = Configure::read('App.namespace');
        $pluginPath = '';
        if ($this->plugin) {
            $namespace = $this->_pluginNamespace($this->plugin);
        }

        $name = $model->getAlias();
        $entity = $this->_entityName($model->getAlias());
        $data += [
            'plugin' => $this->plugin,
            'pluginPath' => $pluginPath,
            'namespace' => $namespace,
            'name' => $name,
            'entity' => $entity,
            'associations' => [],
            'primaryKey' => 'id',
            'displayField' => null,
            'table' => null,
            'validation' => [],
            'rulesChecker' => [],
            'behaviors' => [],
            'connection' => $this->connection,
        ];

        $renderer = new TemplateRenderer($this->theme);
        $renderer->set($data);
        $out = $renderer->generate('Bake.Model/extended_table');

        $path = $this->getPath($args);
        $filename = $path . 'Table' . DS . $name . 'Table.php';
        $io->out("\n" . sprintf('Baking table class for %s...', $name), 1, ConsoleIo::QUIET);

        // 継承されたファイルは上書きを防ぐため、存在する場合は自動でスキップさせる
        if ($this->_isFile($filename, $io)) {
            return;
        }
        $io->createFile($filename, $out, false);

        // Work around composer caching that classes/files do not exist.
        // Check for the file as it might not exist in tests.
        if (file_exists($filename)) {
            require_once $filename;
        }
        $this->getTableLocator()->clear();

        $emptyFile = $path . 'Table' . DS . '.gitkeep';
        $this->deleteEmptyFile($emptyFile, $io);
    }

    /**
     * Bake a extended entity class.
     *
     * @param \Cake\ORM\Table $model Model name or object
     * @param array $data An array to use to generate the Table
     */
    public function bakeExtendedEntity(Table $model, array $data, Arguments $args, ConsoleIo $io): void
    {
        if ($args->getOption('no-entity')) {
            return;
        }
        $name = $this->_entityName($model->getAlias());

        $namespace = Configure::read('App.namespace');
        $pluginPath = '';
        if ($this->plugin) {
            $namespace = $this->_pluginNamespace($this->plugin);
            $pluginPath = $this->plugin . '.';
        }

        $data += [
            'name' => $name,
            'namespace' => $namespace,
            'plugin' => $this->plugin,
            'pluginPath' => $pluginPath,
            'primaryKey' => [],
        ];

        $renderer = new TemplateRenderer($this->theme);
        $renderer->set($data);
        $out = $renderer->generate('Bake.Model/extended_entity');

        $path = $this->getPath($args);
        $filename = $path . 'Entity' . DS . $name . '.php';
        $io->out("\n" . sprintf('Baking entity class for %s...', $name), 1, ConsoleIo::QUIET);

        // 継承されたファイルは上書きを防ぐため、存在する場合は自動でスキップさせる
        if ($this->_isFile($filename, $io)) {
            return;
        }
        $io->createFile($filename, $out, $args->getOption('force'));

        $emptyFile = $path . 'Entity' . DS . '.gitkeep';
        $this->deleteEmptyFile($emptyFile, $io);
    }

    /**
     * Check file exists
     * @param string $path filepath
     * @return bool
     */
    protected function _isFile(string $path, ConsoleIo $io): bool
    {
        $path = str_replace(DS . DS, DS, $path);

        $fileExists = is_file($path);
        if ($fileExists) {
            $io->out("\n" . sprintf('<warning>File exists, skipping</warning> for %s', $path), 1, ConsoleIo::QUIET);
            return true;
        }

        return false;
    }
}

コマンドの実行と生成された各クラス

実際にテーブルに対しコマンドを実行した結果と、生成されたクラスです。

テーブル用意

テスト用に以下のテーブルを用意しました。
余談ですが、COLLATE, CHARSET は my.cnf で設定済みですので、テーブル作成時は指定していません。
カラム・テーブル個別の設定は可能な限りやめましょう。
show create table を実行すると COLLATE, CHARSET は表示されちゃいますが)

usersテーブル
mysql> show create table users\G
*************************** 1. row ***************************
       Table: users
Create Table: CREATE TABLE `users` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `username` varchar(255) COLLATE utf8mb4_bin NOT NULL,
  `email` varchar(255) COLLATE utf8mb4_bin NOT NULL,
  `password` varchar(255) COLLATE utf8mb4_bin NOT NULL,
  `created` datetime NOT NULL,
  `modified` datetime NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
1 row in set (0.00 sec)

コマンド実行

それぞれ 4つのクラスが生成されます。

extended_modelコマンド実行
$ bin/cake bake extended_model users
One moment while associations are detected.

Baking table class for Users...

Creating file /var/www/lancers_batch/src/Model/Baked/Table/UsersTable.php
Wrote `/var/www/lancers_batch/src/Model/Baked/Table/UsersTable.php`

Baking entity class for User...

Creating file /var/www/lancers_batch/src/Model/Baked/Entity/User.php
Wrote `/var/www/lancers_batch/src/Model/Baked/Entity/User.php`

Baking entended table class for Users...

Creating file /var/www/lancers_batch/src/Model/Table/UsersTable.php
Wrote `/var/www/lancers_batch/src/Model/Table/UsersTable.php`

Baking entended entity class for User...

Creating file /var/www/lancers_batch/src/Model/Entity/User.php
Wrote `/var/www/lancers_batch/src/Model/Entity/User.php`

生成ファイル

src/Model/Baked/Table/UsersTable.php

src/Model/Baked/Table/UsersTable.php
<?php
declare(strict_types=1);

namespace App\Model\Baked\Table;

use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;

/**
 * Users Model
 *
 * @method \App\Model\Entity\User newEmptyEntity()
 * @method \App\Model\Entity\User newEntity(array $data, array $options = [])
 * @method \App\Model\Entity\User[] newEntities(array $data, array $options = [])
 * @method \App\Model\Entity\User get($primaryKey, $options = [])
 * @method \App\Model\Entity\User findOrCreate($search, ?callable $callback = null, $options = [])
 * @method \App\Model\Entity\User patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
 * @method \App\Model\Entity\User[] patchEntities(iterable $entities, array $data, array $options = [])
 * @method \App\Model\Entity\User|false save(\Cake\Datasource\EntityInterface $entity, $options = [])
 * @method \App\Model\Entity\User saveOrFail(\Cake\Datasource\EntityInterface $entity, $options = [])
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface|false saveMany(iterable $entities, $options = [])
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface saveManyOrFail(iterable $entities, $options = [])
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface|false deleteMany(iterable $entities, $options = [])
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface deleteManyOrFail(iterable $entities, $options = [])
 *
 * @mixin \Cake\ORM\Behavior\TimestampBehavior
 */
class UsersTable extends BaseTable
{
    /**
     * Initialize method
     *
     * @param array $config The configuration for the Table.
     * @return void
     */
    public function initialize(array $config): void
    {
        parent::initialize($config);

        $this->setTable('users');
        $this->setDisplayField('id');
        $this->setPrimaryKey('id');

        $this->addBehavior('Timestamp');
    }

    /**
     * Default validation rules.
     *
     * @param \Cake\Validation\Validator $validator Validator instance.
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator): Validator
    {
        $validator
            ->nonNegativeInteger('id')
            ->allowEmptyString('id', null, 'create');

        $validator
            ->scalar('username')
            ->maxLength('username', 255)
            ->requirePresence('username', 'create')
            ->notEmptyString('username');

        $validator
            ->email('email')
            ->requirePresence('email', 'create')
            ->notEmptyString('email');

        $validator
            ->scalar('password')
            ->maxLength('password', 255)
            ->requirePresence('password', 'create')
            ->notEmptyString('password');

        return $validator;
    }

    /**
     * Returns a rules checker object that will be used for validating
     * application integrity.
     *
     * @param \Cake\ORM\RulesChecker $rules The rules object to be modified.
     * @return \Cake\ORM\RulesChecker
     */
    public function buildRules(RulesChecker $rules): RulesChecker
    {
        $rules->add($rules->isUnique(['username']), ['errorField' => 'username']);
        $rules->add($rules->isUnique(['email']), ['errorField' => 'email']);

        return $rules;
    }
}

src/Model/Baked/Entity/User.php

src/Model/Baked/Entity/User.php
<?php
declare(strict_types=1);

namespace App\Model\Baked\Entity;

use Cake\ORM\Entity;

/**
 * User Entity
 *
 * @property int $id
 * @property string $username
 * @property string $email
 * @property string $password
 * @property \Cake\I18n\FrozenTime $created
 * @property \Cake\I18n\FrozenTime $modified
 */
class User extends Entity
{
    /**
     * Fields that can be mass assigned using newEntity() or patchEntity().
     *
     * Note that when '*' is set to true, this allows all unspecified fields to
     * be mass assigned. For security purposes, it is advised to set '*' to false
     * (or remove it), and explicitly make individual fields accessible as needed.
     *
     * @var array
     */
    protected $_accessible = [
        'username' => true,
        'email' => true,
        'password' => true,
        'created' => true,
        'modified' => true,
    ];

    /**
     * Fields that are excluded from JSON versions of the entity.
     *
     * @var array
     */
    protected $_hidden = [
        'password',
    ];
}

src/Model/Table/UsersTable.php

src/Model/Table/UsersTable.php
<?php
declare(strict_types=1);

namespace App\Model\Table;

use App\Model\Baked\Table\UsersTable as BakedTable;

use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;

/**
 * {@inheritDoc}
 */
class UsersTable extends BakedTable
{
    /**
     * Initialize method
     *
     * @param array $config The configuration for the Table.
     * @return void
     */
    public function initialize(array $config): void
    {
        parent::initialize($config);
    }
    /**
     * Default validation rules.
     *
     * @param \Cake\Validation\Validator $validator Validator instance.
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator): Validator
    {
        parent::validationDefault($validator);
        return $validator;
    }
    /**
     * Returns a rules checker object that will be used for validating
     * application integrity.
     *
     * @param \Cake\ORM\RulesChecker $rules The rules object to be modified.
     * @return \Cake\ORM\RulesChecker
     */
    public function buildRules(RulesChecker $rules): RulesChecker
    {
        parent::buildRules($rules);
        return $rules;
    }
}

backend/src/Model/Entity/User.php

backend/src/Model/Entity/User.php
<?php
declare(strict_types=1);

namespace App\Model\Entity;

use App\Model\Baked\Entity\User as BakedEntity;

/**
 * {@inheritDoc}
 */
class User extends BakedEntity
{
}

課題

今後の課題は大小いくつかあります。

コピペ問題

ModelCommand からコピペしているコードである、bakeExtendedTable・bakeExtendedEntity関数、および ExtendedModelCommand クラスは、今後 ModelCommand が更新される都度、追従する必要があります。

template についても同じことが言えるので、今後の大きな課題の1つです。

しばらくはこのまま運用してみます。

何か良い方法をご存知でしたら教えてください!

plugin 化

ランサーズでは管理画面、ランサーズ本体、バッチ処理、など、リポジトリを今後分けていく対応をしています。
それ以外のサービスや機能単位で今後リポジトリが分かれる可能性を考えると、plugin 化しないとな、と考えています。

社内でもう少し議論してからになるかと思いますが、plugin 化はおそらく近いうちに実施するので、完成したら更新します。

既存コードへの対応

既に開発中の機能は、この Command の Model 構成ではありません。
リリースされたら対応していく必要がありますので、順次実施していきます。

テストコードの追加

現段階ではテストコードを生成していませんので、親クラスの bakeFixture、bakeTest の実行を追加する想定です。

やってみて

CakePHP をメインに開発するのはランサーズに入社してからが初でしたが、bake コマンドを使って bakeable に開発を進めていくうちに、今回の課題にぶつかりました。
自動生成されるクラスは自動生成でメンテナンスし続けるべきである、ではどうするか…

そこで bake コマンドがどう動いているのか追いかけ、どこを変更すれば要件を満たせるか考え実装しているうちに、同じ課題を解決している方がおられることも知りました。

今回実装した Command を使っているのはまだ僕だけなので総合的な使い勝手などは時間を置いて判断したいと思いますが、個人的には bakeable で非常に良好です。

自動化したほうがいいところは、今後もどんどん実施していきたいですね。

所感

最後までお読みいただきありがとうございました。

最新の CakePHP4 を用いての開発は、CakePHP を知ることもさることながら、アンチパターンや、成功事例であるデザインパターン、ORM・クエリビルダと生成されるクエリチューニング、テーブル・データ設計など、多岐にわたる技術を網羅的に学び成長を続けていることが実感できていて最高です。

もちろんそれらの多くは CakePHP に限らずではありますが、そんな環境なんだよってことを伝えて、次の @ota-yuki にバトンを渡します。

  1. https://book.cakephp.org/4/ja/orm/entities.html#entities-virtual-properties

  2. https://github.com/ogham/exa

  3. https://github.com/Connehito/cakephp-master-replica

  4. ランサーズでは SELECT の際は Read系、INSERT/UPDATE/DELETE などは Write系に接続を切り替える処理が BaseTable に書かれています

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
What you can do with signing up
6