CakePHP2 から CakePHP4 への移行について (1)


フレームワークのアップグレードは、しばしば開発者にとって頭の痛い問題となっています。今回は恐らく多くの組織で課題となっていると思われる CakePHP2 から CakePHP4 へ移行について取り上げてみたいと思います。

小規模かつCakePHP2版のソースのメンテナンスの継続を前提としたソフトウェアを移行対象としており、他のシステムの移行に対してはあまり参考にならない可能性があります。またビヘイビア、コンポーネント、ヘルパー、プラグイン、シェル、テストは移行対象としていません。記載しているコードは事前調査のために独自に作成したもので、動作を保証するものではありません。

背景

CakePHP は Ruby on Rails のMVCアーキテクチャの影響を強く受けたWebアプリケーションフレームワークで、コミュニティも活発で国内ではとても人気があります。近年、PHPフレームワークとしては Laravel が最も人気となっていますが、国内の運用中のWebアプリケーションの採用数では現在もCakePHP が上回っていると言われています。ただ CakePHP は 3.0 で別のフレームワークと呼べるぐらい大きな仕様変更が行われ、スムーズに移行が進まず、まだ多くのサイトで CakePHP2 が使用されていると思われます。

2015年頃にMVCフレームワークの勉強も兼ねて、CakePHP2 をベースに一人でシンプルなeラーニングシステムを開発し、オープンソース化しました。リリース後、大きな反響があり、既に多くの組織内のeラーニングで活用され、現在も頻繁にバージョンアップを行っています。CakePHP2 はコンパクトでとてもよくできたフレームワークで、可能な限りこのまま利用していきたいと考えていましたが、CakePHP コミュニティよるサポートの終了が発表されていることもあり、CakePHP4 への移行の検討を始めることとしました。(あくまでも事前調査や実験的実装の段階で、移行を決定したわけでありません。フレームワークにこれ以上の機能を求めていないため、CakePHP2 からフォークし、独自にメンテナンスする道もあると考えています。)

ちなみに2015年にちょうど CakePHP 3.0 がリリースされ、また Laravel の人気も高まっていた時期でしたが、以下の理由で CakePHP2 を採用しました。

  1. CakePHP 3.0 の情報が少なく、CakePHP 2.x が主流であった。また 3.x は今後、仕様が大きく変わる可能性があり、安定性を重視した。
  2. CakePHP 3.0 はPHP 5.4 以上を必須としていた。開発したソフトウェアは安価なレンタルサーバにインストールすることを前提としていたが、多くのレンタルサーバがまだPHP 5.3 をデフォルトのバージョンとしていた。
  3. Laravel は PHP 5.5 以上を必須としており、またコンパクトなソフトウェアにとって Laravel はオーバースペックだった。また CakePHP と比べて進化のスピードが速く、サポート期間もリリース後、数年程度と短い。(同様の理由で、今後も乗り換えを予定していない。)

既に CakePHP 4.1 で試験的に実装を開始しており、一通りの仕様は把握できています。CakePHP2 と CakePHP4 (CakePHP3)の主な違いは以下の通りです。

できることにそれほど大きな違いはありませんが、モデル周りの破壊的な仕様変更が移行の際、一番の障害となると考えています。

移行方針

移行は「事前のリファクタリング」と「コンバータの作成」を二つの柱とします。

まず移行前に、CakePHP2 側のソースをリファクタリング によってCakePHP4 の仕様に近づけます。今後、CakePHP4 版をリリースしても CakePHP2 版 をベースとしたプロジェクトを CakePHP4 版に移行するまでには相当な期間が必要と予想しています。そのためメンテナンス性も考えて、CakePHP2 版のソースと CakePHP4 版のソースはできる限り共通化したいという思惑があります。

次に CakePHP2 のディレクトリ構成やファイル形式、文字列や構文を、 CakePHP4 の仕様に合わせて一括変換を行うコンバータを作成します。現在、移行対象のソフトウェアからフォークしたプロジェクトがいくつも存在しており、それらのプロジェクトの移行も円滑に行いたいという思惑があります。ただしモデル周りのソースは構造が大きく異なるため、bake にてコードを再作成し、個別に移植することとします。コンバータはあくまでの手作業を減らすためのもので、全てのコードを変換するわけではありません。全体のコードの6割を自動変換することを目標とします。

CakePHP2 側のリファクタリング

CakePHP4 へ移行する前に、事前に CakePHP2 側でリファクタリングを行います。主な内容は以下の通りです。

  1. array() を [] 形式に変換
  2. .ctp ファイルの <?php echo を <?= 形式に変換
  3. CakePHP 標準メソッドのラッピング
  4. 非互換メソッドの置き換え
  5. その他(SQLのべた書き等)

1. array() を [] 形式に変換

CakePHP2の時代は、長らく PHP5.3 をサポートしていたこともあり、配列の記述に array() を使われてきました。しかし PHP5.4 以降は [] (短縮構文)が使用でき、現在は [] 形式が一般的なため、事前に array() を [] 形式に変換するとしました。(移行対象のソフトウェアも既にPHP5.4を最小バージョンとしているため変換することに問題はありません。)

array() 形式

$users = $this->User->find('all', array(
    'conditions' => array('User.role' => 'user'),
    'order' => array('User.created' => 'desc')
));

[] (短縮構文)形式

$users = $this->User->find('all', [
    'conditions' => ['User.role' => 'user'],
    'order' => ['User.created' => 'desc']
]);

変換は手作業ではなく、以下のライブラリを使用して一括変換を行います。

PHP 5.4 Short Array Syntax Converter
https://github.com/thomasbachem/php-short-array-syntax-converter

コマンドプロンプトで以下のような convert.php を実行します。(Windows の場合)

FOR /f "tokens=*" %a in ('dir Config\*.php /S/B') DO php convert.php %a -w
FOR /f "tokens=*" %a in ('dir Controller\*.php /S/B') DO php convert.php %a -w
FOR /f "tokens=*" %a in ('dir Model\*.php /S/B') DO php convert.php %a -w
FOR /f "tokens=*" %a in ('dir Vendor\*.php /S/B') DO php convert.php %a -w
FOR /f "tokens=*" %a in ('dir View\*.php /S/B') DO php convert.php %a -w
FOR /f "tokens=*" %a in ('dir View\*.ctp /S/B') DO php convert.php %a -w
FOR /f "tokens=*" %a in ('dir webroot\*.php /S/B') DO php convert.php %a -w

これで見た目もかなりすっきりします。

2. .ctp ファイルの <?php echo を <?= 形式に変換

CakePHP2では、View の .ctp ファイルで文字列を出力する際、<?php echo “xxx” ?> が使われてきました。現在は <?= “xxx” ?>と略するのが一般的となっています。そこで gerp 置換ツールなどを使い、以下のように <?php echo を <?= 形式に一括置換を行います。

置換前

<div class="panel panel-success">
  <div class="panel-heading"><?php echo __('お知らせ一覧'); ?></div>
  <div class="panel-body">
    <table cellpadding="0" cellspacing="0">
    <thead>
    <tr>
      <th><?php echo $this->Paginator->sort('created',   __('日付')); ?></th>
      <th><?php echo $this->Paginator->sort('title',   __('タイトル')); ?></th>
    </tr>
    </thead>
    <tbody>
    <?php foreach ($infos as $info): ?>
    <tr>
      <td"><?php echo $info['Info']['created']; ?></td>
      <td><?php echo $this->Html->link($info['Info']['title'], ['action' => 'view', $info['Info']['id']]); ?>&nbsp;</td>
    </tr>
    <?php endforeach; ?>
    </tbody>
    </table>
    <?php echo $this->element('paging');?>
  </div>
</div>

置換後

<div class="panel panel-success">
  <div class="panel-heading"><?= __('お知らせ一覧'); ?></div>
  <div class="panel-body">
    <table cellpadding="0" cellspacing="0">
    <thead>
    <tr>
      <th><?= $this->Paginator->sort('created',   __('日付')); ?></th>
      <th><?= $this->Paginator->sort('title',   __('タイトル')); ?></th>
    </tr>
    </thead>
    <tbody>
    <?php foreach ($infos as $info): ?>
    <tr>
      <td><?= $info['Info']['created']; ?></td>
      <td><?= $this->Html->link($info['Info']['title'], ['action' => 'view', $info['Info']['id']]); ?>&nbsp;</td>
    </tr>
    <?php endforeach; ?>
    </tbody>
    </table>
    <?= $this->element('paging');?>
  </div>
</div>

3. CakePHP 標準メソッド、プロパティのラッピング

CakePHP2 と CakePHP4 ではセッションやリクエストパラメータのアクセス用のクラス構成が異なります。そこで CakePHP のメソッド、プロパティに直接アクセスするのではなく、ラッピングすることとしました。

CakePHP2 と CakePHP4 は以下のように記述方法が異なります。

// CakePHP2 セッションの参照、書き込み、削除
$this->Session->read($key);
$this->Session->write($key, $value);
$this->Session->delete($key);

// CakePHP4 セッションの参照、書き込み、削除
$this->getRequest()->getSession()->read($key);
$this->getRequest()->getSession()->write($key, $value);
$this->getRequest()->getSession()->delete($key);

// CakePHP2 ログインユーザ情報の取得
$this->Auth->user($key);
// CakePHP4 ログインユーザ情報の取得
$this->getRequest()->getAttribute('identity')->get($key);

// CakePHP2 クエリストリングの取得
$this->request->query[$key]);
// CakePHP4 クエリストリングの取得
$this->getRequest()->getQuery($key);

// CakePHP2 POSTデータの取得
$this->Auth->user($key);
// CakePHP4 POSTデータの取得
$this->getRequest()->getData();

このようなコードを毎回ソースファイルごとに書き換えるのはとても手間がかかり、メンテナンス性もよくありません。そこで CakePHP 特有のメソッド、プロパティはできる限りラッピングすることとしました。こうすることによって CakePHP4 へ移行後も大半のコードは書き換えなくてすむようになります。

CakePHP2 の AppController.php でこのようにラッピングします。

protected function readSession($key)
{
    return $this->Session->read($key);
}

protected function deleteSession($key)
{
    $this->Session->delete($key);
}

protected function writeSession($key)
{
    $this->Session->write($key, $value);
}

protected function readAuthUser($key)
{
    return $this->Auth->user($key);
}

protected function getQuery($key)
{
    return $this->request->query[$key];
}

protected function getData($key = null)
{
    return $this->request->data[$key];
}

CakePHP4 側の AppController.php ではこのようにラッピングします。

protected function readSession($key)
{
    return $this->getRequest()->getSession()->read($key);
}

protected function deleteSession($key)
{
    $this->getRequest()->getSession()->delete($key);
}

protected function writeSession($key, $value)
{
    $this->getRequest()->getSession()->write($key, $value);
}

protected function readAuthUser($key)
{
    return $this->getRequest()->getAttribute('identity')->get($key);
}

protected function getQuery($key)
{
    return $this->getRequest()->getQuery($key);
}

protected function getData($key)
{
    return $this->getRequest()->getData($key);
}

これでコントローラからは以下の通り、CakePHP2 と CakePHP4 で同じコードでメソッドにアクセスできます。

$this->readSession($key);
$this->deleteSession($key);
$this->writeSession($key, $value);
$this->readAuthUser($key);
$this->getQuery($key);
$this->getData($key);

4. 非互換メソッドの置き換え

CakePHP4 では多くのメソッドの仕様が変更されています。例えばモデルからデータを取得する際、CakePHP2 ではこのように記述していました。

$user = $this->User->find('first', [
    'conditions' => ['User.id' => $user_id]
]);

$users = $this->User->find('all', [
    'conditions' => ['User.role' => 'admin'],
    'order' => ['User.created' => 'desc']
]);

CakePHP4 では以下のように記述するようになっています。特にメソッドチェーンを使用してクエリの条件を指定することが特徴となっています。

$user = $this->User->get($user_id);

$users = $this->User->find()
    ->where(['role' => 'user'])
    ->order(['User.created' => 'desc'])
    ->all();

これを CakePHP2 でも同様に記述できるように、app\Model\AppModel.php に以下のようなコードを追加します。

class AppModel extends Model
{
    private $options = [];
    
    public function get($id)
    {
        return $this->findById($id);
    }

    public function find($type = null, $options = [])
    {
        if($type == null) {
            $this->options = [];
            return $this;
        }
        
        return parent::find($type, $options);
    }

    public function select($value)
    {
        $this->options['fields'] = $value;
        return $this;
    }

    public function where($value)
    {
        $this->options['conditions'] = $value;
        return $this;
    }

    public function order($value)
    {
        $this->options['order'] = $value;
        return $this;
    }

    public function limit($value)
    {
        $this->options['limit'] = $value;
        return $this;
    }

    public function page($value)
    {
        $this->options['page'] = $value;
        return $this;
    }

    public function all()
    {
        return parent::find('all', $this->options);
    }

    public function first()
    {
        return parent::find('first', $this->options);
    }

    public function count()
    {
        return parent::find('count', $this->options);
    }
}

これで CakePHP2 と CakePHP4 で同じコードが動作するようになります。

また実験的な実装ですが、以下のように変更することよって、all() や first() の結果を連想配列ではなく、オブジェクトに変換することも可能です。

class AppModel extends Model
{
    private $options = [];
    private $is_object = false; // 連想配列をオブジェクトに変換するかどうか(実験的実装)
    
    public function get($id)
    {
        return $this->findById($id);
    }

    public function find($type = null, $options = [])
    {
        if($type == null)
        {
            $this->options = [];
            return $this;
        }
        
        return parent::find($type, $options);
    }

    public function select($value)
    {
        $this->options['fields'] = $value;
        return $this;
    }

    public function where($value)
    {
        $this->options['conditions'] = $value;
        return $this;
    }

    public function order($value)
    {
        $this->options['order'] = $value;
        return $this;
    }

    public function limit($value)
    {
        $this->options['limit'] = $value;
        return $this;
    }

    public function page($value)
    {
        $this->options['page'] = $value;
        return $this;
    }

    public function all()
    {
        if($this->is_object) {
            $data = parent::find('all', $this->options);
            
            // 連想配列[Model][field] を [field] に変更
            foreach($data as &$row) {
                $row = array_merge($row, $row[$this->name]);
                unset($row[$this->name]);
            }
            
            // [Model][field] を 削除
            $object= new stdClass();
            $data = $this->_array_to_object($data, $object);
            
            return $data;
        }
        return parent::find('all', $this->options);
    }

    public function first()
    {
        if($this->is_object) {
            $data = parent::find('first', $this->options);
            
            // 連想配列[Model][field] を [field] に変更
            $data = array_merge($data, $data[$this->name]);
            
            // [Model][field] を 削除
            unset($data[$this->name]);
            
            $object= new stdClass();
            $data = $this->_array_to_object($data, $object);
            
            return $data;
        }
        return parent::find('first', $this->options);
    }

    public function count()
    {
        return parent::find('count', $this->options);
    }

    /**
     * 取得形式をオブジェクトと設定
     */
    public function convert()
    {
        $this->is_object = true;
        return $this;
    }
    
    /**
     * 配列をオブジェクトに変換
     */
    private function _array_to_object($array, &$obj)
    {
        foreach($array as $key => $value) {
            if(is_array($value)) {
                // データが複数かつ連想配列の場合、オブジェクト名の最後に sをつける
                if(is_string($key))
                    $key .= 's';
                
                $obj->{strtolower($key)} = new stdClass();
                $this->_array_to_object($value, $obj->{strtolower($key)});
            } else {
                $obj->{strtolower($key)} = $value;
            }
        }
        return $obj;
    }
}

オブジェクトへの変換方法

$course = $this->Course->find()
     ->where(['Course.id' => $course_id])
     ->convert() // 取得結果をオブジェクトと指定
     ->first();

出力結果

object(stdClass) {
    contents => object(stdClass) {
        0 => object(stdClass) {
            id => '463'
            title => 'test20210113'
            created => '2020-12-23 18:36:56'
        }
        1 => object(stdClass) {
            id => '465'
            title => 'pdf'
            created => '2021-01-12 18:27:20'
            modified => '2021-01-13 12:08:27'
        }
    }
    id => '98'
    title => 'コースのタイトル'
    created => '2016-01-22 07:00:55'
    modified => '2018-02-07 18:57:16'
}

5. その他

5-1. SQLのべた書き

O/Rマッピングはとても便利で大半の場合にはメリットのほうが大きいのですが、少し複雑な集計を行おうとすると、とたんに学習コストが高くなる傾向があります。また複雑なクエリほどフレームワークをアップグレードする際、障害になることが多々あります。そこで複雑なデータの集計を行う場合は、SQL のべた書きを基本とすることしました。SQL はフレームワークに依存しないため、移行にも適していると言えます。必要に応じてビューやストアドプロシージャなども活用します。このような対応を行うのは、移行対象のパッケージが MySQL のみをサポートしているため、ほかのDBを考慮することがないことも理由の一つと言えます。

複雑なクエリの例

public function getCourseRecord($user_id)
{
  $sql = <<<EOF
SELECT Course.*, Course.id, Course.title, first_date, last_date,
   (ifnull(content_cnt, 0) - ifnull(study_cnt, 0) ) as left_cnt
FROM ib_courses Course
LEFT OUTER JOIN
   (SELECT h.course_id, h.user_id,
           MAX(DATE_FORMAT(created, '%Y/%m/%d')) as last_date,
           MIN(DATE_FORMAT(created, '%Y/%m/%d')) as first_date
      FROM ib_records h
     WHERE h.user_id =:user_id
     GROUP BY h.course_id, h.user_id) Record
 ON Record.course_id   = Course.id
AND Record.user_id     =:user_id
LEFT OUTER JOIN
    (SELECT course_id, COUNT(*) as study_cnt
       FROM
        (SELECT r.course_id, r.content_id, COUNT(*)
           FROM ib_records r
          INNER JOIN ib_contents c ON r.content_id = c.id AND r.course_id = c.course_id
          WHERE r.user_id = :user_id
            AND status = 1
          GROUP BY r.course_id, r.content_id) as c
     GROUP BY course_id) StudyCount
 ON StudyCount.course_id   = Course.id
LEFT OUTER JOIN
    (SELECT course_id, COUNT(*) as content_cnt
       FROM ib_contents
      WHERE kind NOT IN ('label', 'file')
        AND status = 1
      GROUP BY course_id) ContentCount
 ON ContentCount.course_id   = Course.id
WHERE id IN (SELECT course_id FROM ib_users_groups ug INNER JOIN ib_groups_courses gc ON ug.group_id = gc.group_id WHERE user_id = :user_id)
 OR id IN (SELECT course_id FROM ib_users_courses WHERE user_id = :user_id)
ORDER BY Course.sort_no asc
EOF;

$params = [
    'user_id' => $user_id
];

  $data = $this->query($sql, $params);
  return $data;
}

5-2. 削除されたプロパティの追加

CakePHP4 では CakePHP2 でよく使用していた以下のようなプロパティが削除されています。

$this->action;
$this->webroot;

前述のようにラッピングすることも考えましたが、あまりにも修正箇所が多くなるため、CakePHP4 側の以下の2ファイルにプロパティを追加することといたしました。これで CakePHP2 と同じコードが動作するようになります。

src\Controller\AppController.php
src\View\AppView.php

public $action; //CakePHP2の仕様の引継ぎ
public $webroot; //CakePHP2の仕様の引継ぎ

public function initialize(): void
{
    parent::initialize();
    $this->action = $this->request->getParam('action');
    $this->webroot = Router::url('/', true);
}

次回は CakePHP2 から CakePHP4 へのコンバータの作成と認証周りの移行について書く予定です。

関連記事:

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA


このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください