CakePHP3のbakeで常に最新のTable/Entityを出力できるようにする

CakePHPでの開発で欠かせないbakeによる自動コード生成。DBのテーブルから自動でソースコードを生成してくれるため、非常に便利です。ですが、実際に運用すると実行するたびにソースコードが上書かれてしまうという欠点があります。今回はそんなbakeと上手に付き合っていく方法をご紹介します。

スポンサーリンク

bakeの欠点

実際の開発の現場で発生するbakeの欠点は、実装済みのコードが上書きされて消えてしまう。という点です。特にDB周りは開発を進めていく段階で少なからず変更が入ります。
その度にbakeを叩いてしまうと既存コードが消えてしまうため、手動でかつ寸分の狂いもなく以下の実装をする必要があるのです。

  • validatorの追加
  • buildRulesの追加
  • アソシエーション定義の追加
  • Entityのaccesibleへの追加
  • phpdocの更新

Generation Gapパターンによる解決

DB変更が入る度に手動で実装が非常に面倒で、カラム追加/テーブル追加の度に億劫な気分になっていましたが、その中で以下の記事を発見しました。

bakeで生成したクラスは継承して使おう
CakePHP3のbakeによるコード自動生成のプラクティス

CakePHPの某案件が終わり、ちょうどまた別のCakePHP新規開発を予定していた私にとっては目からウロコな内容でした。要はbakeで生成されたTable/Entityを継承した、Extのプレフィックス付きのクラスを用意し、追加ロジックはそこで実装する。というものです。これによりbakeを繰り返しても追加した実装は消えることがなくなり、継承元で定義が更新されるようになります。
この仕組みはGeneration Gapパターンと呼ばれるデザインパターンに基づいているそうです。

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

bakeをカスタマイズしてGeneration Gapパターンに対応させる

本記事ではbakeをカスタマイズし以下の仕様を実現します。

  • Generation Gapに対応したcake bake generation_gap_modelコマンドを作成
  • Table/Entityディレクトリ配下にBakedディレクトリを作成し、継承元のクラスを出力
  • 通常のTable/Entity直下は継承したクラスを配置。開発では基本的にここで実装を行う。

ディレクトリ階層は以下のイメージです。

Model
├ Table
│ ├ Baked
│ │ ├ AdministratorsTable.php
│ │ └ UsersTable.php
│ ├ AdministratorsTable.php
│ └ UsersTable.php
└ Entity
  ├ Baked
  │ ├ Administrator.php
  │ └ User.php
  ├ Administrator.php
  └ User.php

bakeのカスタマイズ

プラグイン化したかったのですが、いったん使用したコードをgistに上げました。
gist以下コマンドで実行できます。

$ cake bake generation_gap_model

追加したファイルは以下です。

src
├ Shell
│ └ Task
│   └ GenerationGapTask.php
└ Template
  └ Bake
    └ Model
      ├ entity.twig
      ├ extended_entity.twig
      ├ table.twig
      └ extended_table.twig

GenerationGapModel.php

bake modelのコードを継承しつつ実装をしています。まずはcreateFileを継承し本来bakeされるファイルをBakedに移動させます。

    /**
     * {@inheritDoc}
     */
    public function createFile($path, $contents)
    {
        // BakeしたものはBakedのディレクトリに移動する
        $paths = [
            'Table' . DS => 'Table' . DS . 'Baked' . DS,
            'Entity' . DS => 'Entity' . DS . 'Baked' . DS
        ];
        $path = str_replace(array_keys($paths), array_values($paths), $path);
        return parent::createFile($path, $contents);
    }

次にbakeメソッドを継承し、元のTable/Entityを継承したファイルを出力させるようにします。


    /**
     * {@inheritDoc}
     */
    public function bake($name)
    {
        parent::bake($name);
        $table = $this->getTable($name);
        $tableObject = $this->getTableObject($name, $table);
        $data = $this->getTableContext($tableObject, $table, $name);
        $this->bakeExtendedEntity($tableObject, $data);
        $this->bakeExtendedTable($tableObject, $data);
    }

bakeExtendedEntityとbakeExtendedTableは独自に実装していますが、内容としてはbakeTable/bakeEntityと変わりません。
使用するテンプレートを後述のテンプレートに変更しています。以下抜粋。


        $filename = $path . 'Table' . DS . $name . 'Table.php';
        $this->out("\n" . sprintf('Baking table class for %s...', $name), 1, Shell::QUIET);
        // 継承されたファイルは上書きを防ぐため、存在する場合は自動でスキップさせる。
        if ($this->_isFile($filename)) {
            return null;
        }
        // 同クラスで実装しているcreateFileはBakedに出力されるため、親を使用
        parent::createFile($filename, $out);

Table/Entityのテンプレートは上書きを行い、Bakedのnamespaceへ変更しただけです。

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

継承先のテンプレートは基本的に空になります。
混在させないためBakedから継承しBakedEntityというエイリアスで継承させました。


namespace {{ namespace }}\Model\Entity;
use {{ namespace }}\Model\Entity\Baked\{{ name }} as BakedEntity;
/**
 * {@inheritDoc}
 */
class {{ name }} extends BakedEntity
{
}

Tableも同様にBakedから継承しています。メソッドはすぐ実装を始められるように親を継承しつつ親メソッドを呼び出す処理を記載しています。


namespace {{ namespace }}\Model\Table;
use {{ namespace }}\Model\Table\Baked\{{ 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)
    {
        parent::initialize($config);
    }
{{- "\n" }}
{%- if validation %}
    /**
     * Default validation rules.
     *
     * @param Validator $validator Validator instance.
     * @return Validator
     */
    public function validationDefault(Validator $validator)
    {
        parent::validationDefault($validator);
        return $validator;
    }
{% endif %}
{%- if rulesChecker %}
    /**
     * Returns a rules checker object that will be used for validating
     * application integrity.
     *
     * @param RulesChecker $rules The rules object to be modified.
     * @return RulesChecker
     */
    public function buildRules(RulesChecker $rules)
    {
        parent::buildRules($rules);
        return $rules;
    }
{% endif %}
}

使用してみて

実案件で運用し始めましたが、特に問題なく案件終盤までこれました。
親クラスは触らない。DB定義を変更した時にgeneration_gap_modelを走らせる運用にし、適宜最新のbakeされたファイルを使用することができました。
稀にbakeで出力されたバリデーションやアソシエーションを消したいといったことがありましたが、最悪parentを呼び出さず書き換えるという手段もあるので詰むようなことはありませんでした。
※外部連携用のIDとしてother_system_idなど持つとbakeがOtherSystemsとしてアソシエーションを作成され、気持ち悪いということが。。
余力があったらプラグイン化します。

未分類
スポンサーリンク
この記事が気に入ったら
いいね!しよう
最新情報をお届けします。
この記事を書いた人

スパイスファクトリー株式会社 Webエンジニア。フロントエンドやWebサイトの高速化が得意です。インフラ・バックエンドも一通りやってます。
個人的なお仕事のご依頼や情報交換などはお問い合わせまたはTwitterにメンションをお願いします。

ShoheiTaiをフォローする
このエントリーが役に立ったらシェアをお願いします!
イケてないコード – Webエンジニアのブログ

コメント

タイトルとURLをコピーしました