CakePHP4のConsole/Commandでバッチを作る

## イントロダクション

ランサーズでは、現在CakePHP2 → CakePHP4へのバージョンアップを行っています。

CakePHP1.3 → 2へのバージョンアップと比べ、単純な手順で移行するわけにはいきません。
CakePHP → 3 or 4へのバージョンアップは、非常に変更が多く、ほぼ作り直しに等しい工数がかかります。

作り直しになるのであれば、このタイミングで今までの負債を解消してしまおうという方針で、バッチと管理画面を別リポジトリに分離して再構築中です。

https://speakerdeck.com/ykanazawa/ransazufalse-cakephp4yi-xing-nituite?slide=9
![スライド9.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38162/bd323944-3607-187f-7f09-b79b674ebff9.png)

また、この過程でCakePHP4以降のノウハウを貯めておき、後のlancers本体のCakePHP4移行で応用するという方針です。

## ShellからConsole/Commandへの移行

CakePHP2で現在運用中のShellクラスは、CakePHP5で廃止される予定です。
そのため、バッチをCakePHP4で作り直す過程で、Console/Commandへ移行しました。

CakePHP4.x：コンソールコマンド
https://book.cakephp.org/4/ja/console-and-shells/commands.html

## 多重起動防止の方式

CakePHP2で運用中のバッチは、そのほとんどがcronで定期実行しているので、多重起動防止の処理を独自に実装していました。

多重起動防止の処理については、主に以下の方法があると思います。

1. ロックファイルを作成する
2. psコマンド等で同じ名前のプロセスがあるか調べる
3. ロックファイルの中にプロセス番号を書き込む

CakePHP2で運用中のバッチが実装しているのは、主に1の方法です。
しかしバッチが異常終了して落ちると、ロックファイルが残ったままとなり、手動で消さない限り起動できなくなります。

2の方法で実装しているバッチも一部ありましたが、似た名前のプロセスがあった場合に誤判定することが多く、あまり良い方法ではなかったと思います。

3は、多くのサービルミドルウェアが採用している方法です。
中に書いてあるプロセス番号が一致した場合は起動せず、
一致しなかった場合は、プロセス番号を書き換えて起動します。

新しいバッチでは、3の方法を採用することにしました。

## 多重起動処理を共通クラスに実装

以下が多重防止処理の実装になります。
BaseCommandクラスを作成し、全バッチがこのクラスを継承することで共通に使えるようにしています。

```php
<?php
declare(strict_types=1);

namespace App\Command;

use Cake\Console\Command;
use ReflectionClass;

class BaseCommand extends Command
{
    protected $className;
    protected $lockFileName;
    protected $isLockfileExit = false;

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

        $this->className = (new ReflectionClass($this))->getShortName();
        $this->lockFileName = TMP . $this->className . '.lock';

        // ロックファイルのプロセスIDをチェック
        if ($this->checkLockFile()) {
            $this->isLockfileExit = true;
            exit;
        }

        // ロックファイルの作成
        file_put_contents($this->lockFileName, getmypid());
    }

    public function __destruct()
    {
        // プロセスがすでに存在して強制終了でなければ
        if (!$this->isLockfileExit) {
            // ロックファイルの削除
            unlink($this->lockFileName);
        }
    }
    /*
     * ロックファイルのプロセスIDをチェック
     */
    protected function checkLockFile(): bool
    {
        // ロックファイルの存在確認
        if (!file_exists($this->lockFileName)) {
            return false;
        }
        // ファイル内に記載されているプロセスIDを取得
        $pid = file_get_contents($this->lockFileName);
        $pid = str_replace(PHP_EOL, '', $pid);
        if (!is_numeric($pid)) {
            return false;
        }

        //取得したプロセスIDが動いていないかチェック
        $output = [];
        $cmd = "ps h " . $pid;
        exec($cmd, $output);
        if (isset($output[0])) {
            echo "Process already exists.\n";
            echo $output[0] . "\n";
            return true;
        }
        return false;
    }
}
```

## バッチ実行時のメモリ消費について

移行前のCakePHP2バッチは、元々CakePHP1.3時代に実装したものが多いです。

CakePHP1.3では、長時間かかるバッチになるとメモリリーク的に使用量が増加し続け、最終的にバッチが落ちてしまっていました。

これは、いわゆるModelキャッシュが原因で、PHP-FPM等のアプリケーションサーバーでは、（定期的にプロセスが入れ替わるため）あまり問題にならないことが多いですが、長時間かかるバッチではこの問題が表面化します。

※2010年頃まではRailsやZend Frameworkでも同様の事象に悩まされていた記憶があるので、当時のフレームワーク共通の問題だったのかも知れません。

そのため、長時間かかるバッチでは、例えばユーザーIDごとに処理を区切って何度も起動したりして対応していました。

※Modelからfind関数で呼び出した場合に発生していたので、素のPHPでPDOから直接SQLを実行するように実装して回避したりもしていました。

CakePHP4で作り直したバッチでは、起動後、ある程度まではメモリ消費が増加するものの、一定以上は増加しないことを確認しました。

以下は、新着仕事メールのバッチのメモリ使用量の変化です。
バッチ開始時は約18MBの消費で、その後増え続けますが、26MBあたりで落ち着きました。

```
$ bin/cake NewWorkEmail
送信数:22 メモリ使用量:18874368 user_id:380
送信数:39 メモリ使用量:18874368 user_id:666
送信数:59 メモリ使用量:18874368 user_id:2982
送信数:78 メモリ使用量:18874368 user_id:3695
送信数:99 メモリ使用量:20971520 user_id:2367310
送信数:112 メモリ使用量:20971520 user_id:2367353
送信数:126 メモリ使用量:20971520 user_id:2367380
...
送信数:6685 メモリ使用量:27262976 user_id:2377277
送信数:6702 メモリ使用量:27262976 user_id:2377294
送信数:6715 メモリ使用量:27262976 user_id:2377313
$
```

※2021/04/15追記：
12時間かかるバッチで検証した結果、
14MB→16MB→18MB→...→52MB
まで増え続けることを確認しました。
しかしながら、かなりペースは緩いため長いバッチでもある程度は耐えられそうです。

CakePHP1.3、CakePHP2時代では、延々とメモリが増え続けていたため、数回に分けて実行するなどして回避していましたが、CakePHP4で作り直したバッチは1回で実行できるようになり、処理時間の短縮につながりました。

※CakePHP2ではモデルキャッシュを切ることができたので、CakePHP2に移行した時点で解決できた問題だったかも知れませんが、少なくともCakePHP4で実装したバッチについては、メモリリーク的増加の心配はする必要がなさそうです。

参考にした記事：https://qiita.com/zaru/items/b3cf10c043d0304cdc34