CakePHP2 から CakePHP3 ORM を使ってみる

これは [CakePHP Advent Calendar 2018](https://qiita.com/advent-calendar/2018/cakephp) の第2日目の記事です。

2日目にしてかなり無理矢理なハックになりますが、よければお付き合いください。

## なぜ、しようとしたのか？

まずは、大前提として「なんでそんなことをしようとしたの？」という話なんですが、今年のPHPCon関西で、以下のような話をした時に出たものでした。

> CakePHP2 で作られているシステムを CakePHP3 や今後の CakePHP4 へ移行する方法はどういう流れにするのがいいんだろうか？

おそらく、上記のような状況にある方々が結構いらっしゃると思っていて、CakePHP2 から CakePHP3 への移行の最大の問題点はモデル周りの大変更じゃないかという話になり、

> なら、最初にそれやっちゃえばいいんじゃない？

という話が出て、一番違いが大きいところだからこそ最初にやっつけてしまえれば後が楽じゃないのかと。

ただし、その話をしたときには、実際に CakePHP2 から CakePHP3 ORM を使うというのを試した人はいなくて、たぶんできるんじゃないの？くらいの感じで可能なのかはその時にはわからないままでした。

その後、実際にやってみたらできたので、この記事になったということになります。

## ソース差分

以降で手順を書いていきますが、そんなまどろっこしいことはいいからどうやったのだけ見せろ！という方のためにソース差分を最初に示して置きますね。

[ソース差分](https://github.com/kunit/cakephp2-with-cakephp3-orm/compare/feature/blog_tutorial...feature/use_cakephp3_orm)

## どうやればいいのか？

実際にやってみたら、CakePHP2 のプロジェクトから CakePHP3 ORM を使うのは、思った以上に簡単でした。(もちろん、途中で試行錯誤したことはありますが）

流れとしては以下のような感じになります｡

### 0. まずは、CakePHP2 でできているシステムがある

今回サンプルとして、CakePHP2 の Cookbook にある、[ブログチュートリアル](https://book.cakephp.org/2.0/ja/tutorials-and-examples/blog/blog.html) をベースに進めていこうと思います。

ブログチュートリアルを普通に実装したのが以下のものです。

- https://github.com/kunit/cakephp2-with-cakephp3-orm/tree/feature/blog_tutorial

PostsController から Post モデルを使って、記事の一覧/詳細表示/追加/編集/削除ができるというものになります｡

### 1. CakePHP3 をインストールする

ここに対して、CakePHP3 のアプリケーションを無理やり app の横にインストールしてしまいます。

```sh
composer create-project --prefer-dist cakephp/app cakephp3
```

### 2. composer.json を編集する

CakePHP2 側の `app/composer.json` を以下のように編集します。

```json:編集前
{
    "name": "app",
    "require": {
        "php": ">=7.1.0",
        "cakephp/cakephp": "2.10.*"
    },
    "config": {
        "vendor-dir": "Vendor/"
    },
```

```json:編集後
{
    "name": "app",
    "require": {
        "php": ">=7.1.0",
        "cakephp/cakephp": "2.10.*",
        "cakephp/orm": "3.6.*",
        "cakephp/i18n": "3.6.*",
        "josegonzalez/dotenv": "3.*"
    },
    "config": {
        "vendor-dir": "Vendor/"
    },
    "autoload": {
        "psr-4": {
            "App\\": "../cakephp3/src"
        }
    }
}

```

### 3. composer update する

CakePHP2 側で composer update します。

```sh
composer update -d app
```

### 4. CakePHP3 の初期設定の流れを追加する

CakePHP2 から CakePHP3 のクラス群を利用するために、 `bootstrap.php` に以下のコードを追加します。

```php:app/Config/bootstrap.php(追加部分のみ)
require APP . 'Vendor/autoload.php';

// CakePHP3 の ORM を使うための設定を行う
define('CONFIG_CAKEPHP3', __DIR__ . '/../../cakephp3/config/');

$dotenv = new \josegonzalez\Dotenv\Loader([CONFIG_CAKEPHP3 . '.env']);
$dotenv->parse()
    ->putenv()
    ->toEnv()
    ->toServer();

try {
    \Cake\Core\Configure::config(
        'default',
        new \Cake\Core\Configure\Engine\PhpConfig(CONFIG_CAKEPHP3)
    );
    \Cake\Core\Configure::load('app', 'default', false);
} catch (\Exception $e) {
    exit($e->getMessage() . "\n");
}

\Cake\Datasource\ConnectionManager::setConfig(
    'default',
    \Cake\Core\Configure::read('Datasources.default')
);

\Cake\Cache\Cache::setConfig(
    '_cake_model_',
    \Cake\Core\Configure::read('Cache._cake_model_')
);

\Cake\I18n\I18n::setLocale(\Cake\Core\Configure::read('App.defaultLocale'));

```

### 5. CakePHP3 側のディレクトリにモデル関連ファイルを追加

CakePHP3 側のディレクトリ配下にモデル関連の2つのファイルを作成します。

```php:cakephp3/src/Model/Entity/Post.php
<?php
namespace App\Model\Entity;

use Cake\ORM\Entity;

class Post extends Entity
{
}
```

```php:cakephp3/src/Model/Table/PostsTable.php
<?php
namespace App\Model\Table;

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

class PostsTable extends Table
{
    public function initialize(array $config)
    {
        parent::initialize($config);

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

    /**
     * デフォルトのバリデーションルール
     *
     * @param Validator $validator
     * @return Validator
     */
    public function validationDefault(Validator $validator)
    {
        $validator
            ->requirePresence('title')
            ->notEmpty('title');

        $validator
            ->requirePresence('body')
            ->notEmpty('body');

        return $validator;
    }
}
```

### 6. CakePHP3 のエンティティを変換するコンポーネントを追加

View を変更したくないので、CakePHP3 のエンティティを変換するコンポーネントを作りました。

```php:app/Controller/Component/EntityConverterComponent.php
<?php

App::uses('Component', 'Controller');

class EntityConverterComponent extends Component
{
    /**
     * CakePHP3のクエリ結果をCakePHP2のfindの結果形式に変換する
     *
     * @param \Cake\ORM\Entity|\Cake\ORM\Query $entity
     * @param string $alias
     * @return array
     */
    public function toArray($entity, string $alias = null) : array
    {
        $result = [];

        if ($entity instanceof \Cake\Orm\Query) {
            foreach ($entity as $row) {
                $result[] = $this->toArray($row);
            }
        } else {
            if ($alias === null) {
                // TODO: もっとスマートな方法がないのか？
                $alias = str_replace('App\\Model\\Entity\\', '', get_class($entity));
            }
            $result[$alias] = $entity->toArray();
        }

        return $result;
    }

    /**
     * リクエストデータからCakePHP3のエンティティに変換
     *
     * @param \Cake\ORM\Table $model
     * @param \Cake\ORM\Entity|null $entity
     * @param array $data
     * @return \Cake\ORM\Entity
     */
    public function toEntity(\Cake\ORM\Table $model, ?\Cake\ORM\Entity $entity, array $data) : \Cake\ORM\Entity
    {
        $data = Hash::get($data, Inflector::classify($model->getAlias()));

        if ($entity === null) {
            $entity = $model->newEntity($data);
        } else {
            $entity = $model->patchEntity($entity, $data);
        }

        return $entity;
    }
}
```

### 7. CakePHP2 のコントローラーを修正

5 で追加したCakePHP3 のモデルを CakePHP2 のコントローラーで使うように修正します。

```php:app/Controller/PostsController.php
<?php

App::uses('AppController', 'Controller');

/**
 * 記事を扱うコントローラー
 *
 * CakePHP2 のブログチュートリアルをベースにしている
 *
 * @see https://book.cakephp.org/2.0/ja/tutorials-and-examples/blog/blog.html
 *
 * @property Post $Post
 * @property EntityConverterComponent $EntityConverter
 */
class PostsController extends AppController
{
    public $components = [
        'EntityConverter',
    ];

    /** @var \App\Model\Table\PostsTable */
    private $PostsTable;

    public function beforeFilter()
    {
        parent::beforeFilter();

        $this->PostsTable = \Cake\ORM\TableRegistry::getTableLocator()->get('posts');
    }

    /**
     * 記事一覧
     *
     * @return void
     */
    public function index() : void
    {
        $this->set('posts', $this->EntityConverter->toArray($this->PostsTable->find('all')));
    }

    /**
     * 記事詳細
     *
     * @param int $id
     * @return void
     * @throws NotFoundException
     */
    public function view($id = null) : void
    {
        if (!$id) {
            throw new NotFoundException(__('Invalid post'));
        }

        /** @var \Cake\ORM\Entity $post */
        $post = $this->PostsTable->get($id);
        if (!$post) {
            throw new NotFoundException(__('Invalid post'));
        }
        $this->set('post', $this->EntityConverter->toArray($post));
    }

    /**
     * 記事追加
     *
     * @return void
     */
    public function add() : void
    {
        if ($this->request->is('post')) {
            $post = $this->EntityConverter->toEntity($this->PostsTable, null, $this->request->data);
            if ($this->PostsTable->save($post)) {
                $this->Flash->success(__('Your post has been saved.'));
                $this->redirect(['action' => 'index']);
                return;
            }
            $this->Post->validationErrors = $post->getErrors();
            $this->Flash->error(__('Unable to add your post.'));
        }
    }

    /**
     * 記事編集
     *
     * @param int $id
     * @return void
     * @throws NotFoundException
     */
    public function edit($id = null) : void
    {
        if (!$id) {
            throw new NotFoundException(__('Invalid post'));
        }

        /** @var \Cake\ORM\Entity $post */
        $post = $this->PostsTable->get($id);
        if (!$post) {
            throw new NotFoundException(__('Invalid post'));
        }

        if ($this->request->is(['post', 'put'])) {
            $post = $this->EntityConverter->toEntity($this->PostsTable, $post, $this->request->data);
            if ($this->PostsTable->save($post)) {
                $this->Flash->success(__('Your post has been updated.'));
                $this->redirect(['action' => 'index']);
                return;
            }
            $this->Post->validationErrors = $post->getErrors();
            $this->Flash->error(__('Unable to update your post.'));
        }

        if (!$this->request->data) {
            $this->request->data = $this->EntityConverter->toArray($post);
        }
    }

    /**
     * @param int $id
     * @return void
     * @throws MethodNotAllowedException
     */
    public function delete($id = null) : void
    {
        if ($this->request->is('get')) {
            throw new MethodNotAllowedException();
        }

        /** @var \Cake\ORM\Entity $post */
        $post = $this->PostsTable->get($id);
        if (!$post) {
            throw new NotFoundException(__('Invalid post'));
        }

         if ($this->PostsTable->delete($post)) {
            $this->Flash->success(
                __('The post with id: %s has been deleted.', h($id))
            );
        } else {
            $this->Flash->error(
                __('The post with id: %s could not be deleted.', h($id))
            );
        }

        $this->redirect(['action' => 'index']);
    }
}
```

## どうでしたか？

意外と簡単では？と思っていただけたでしょうか？

モデルの移行が終われば、次は Form を移行し、最後に Controller を移行するという形ができるのではないだろうかという感じがすこしはしたでしょうか？

もちろん、こんなシンプルな例どころではない複雑なコードを普段はメンテしていると思うので、こんなにすんなりはいかないとは思いますが、CakePHP2 -> CakePHP3/CakePHP4 への道があるかもしれないということを示してみました。

## 最後に

昨年の [CakePHP Advent Calendar 2017](https://qiita.com/advent-calendar/2017/cakephp) の 1日目の記事として、[CakePHPの過去、現在、そして未来](https://qiita.com/kunit/items/b4b187429ccba0d07c7e) というのを書きました。

その後スケジュールの変更があり、CakePHP 3.6 が CakePHP 3.x の最後ではなく、CakePHP 3.7 が出ることになったりしていて、CakePHP4 が出るのはもうしばらくかかるようです。

その結果、CakePHP4 が出てからしばらくメンテされることがアナウンスされている CakePHP2 はしばらく安泰かというと、最近では、PHP のバージョンアップに追従するのがどんどん大変になってきており、そちらのほうが場合によっては問題になってくるかもしれません。(PHP 7.2/7.3 ともテストを通すためにかなり苦労していたようです)

手元にある CakePHP2 でできたシステムをどうしていくのか？というを具体的に考えないといけない時期だと思いますので、この記事で書いたような無理やりな方法ではなく、もっとスムーズな方法を思いついたら、どんどん公開して、みんなで知恵を出し合うというのがいいのではないかと思っています。

明日の担当は、[@tenkoma](https://twitter.com/tenkoma) さんです。