CakePHP で Table の Validator を差し替える #cakephp

以前 Validation をカスタムしやすくする方法を書いた。

su-kun1899.hatenablog.com

Validation は bootstrap.php で差し替えればいいんだけれど、じゃあ自前で呼ばない Table の Validator を差し替えるにはどうしたらいいのか。

Table での Validation

Table クラスでの既定のバリデーションは、 validationDefault() で行われる。

インターフェースとしては Validator を引数で受け取る形になっており、ここを差し替えたい。

<?php
public function validationDefault(Validator $validator)
{
    $validator
        ->requirePresence('title', 'create')
        ->notEmpty('title');

    ...

    return $validator;
}

initialize で書き換えればよい

Table クラスの initialize で、 _validatorClass を差し替えてあげればよさそう。

<?php
public function initialize(array $config): void
{
    $this->_validatorClass = MyValidator::class;;
}

ドキュメントにもちゃんと書いてあった。

https://book.cakephp.org/4/en/orm/validation.html#default-validator-class

横断的に差し替えたいとなったら、基底クラス用意するのがいいのかなー。

PHP7 で CakePHP4 の FrozenDate を使うと、月差分が正しく判定されない場合がある #php #cakephp

概要

PHP8.1 未満の環境における CakePHP の FrozenDate は、 UTC でない Timezone を使った場合に、日付差分が正しく取れないケースがある。

FrozenTime を使うようにするか、 PHP8.1 以降にバージョンを上げると解決する。

※タイトルでは PHP7 としているけど、 8.0 でもだめなはず。

PHP8.1未満で比較する場合

環境は PHP7.4 , CakePHP は 4.2.8 で試す。

たとえば 2021年1月1日から3月1日までの差分を求めるとする。

期待値は当然 2 だ。

$ php -v
PHP 7.4.24 (cli) (built: Sep 23 2021 22:49:50) ( NTS )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies
    with Zend OPcache v7.4.24, Copyright (c), by Zend Technologies
    with Xdebug v2.9.8, Copyright (c) 2002-2020, by Derick Rethans
$ bin/cake version
4.2.8

FrozenTime を使った場合

以前からあった diffInMonths は TimeZone の問題を抱えていたが、diffInMonthsIgnoreTimezone が提供されるようになったことで、期待通りの結果が得られるようになった。

github.com

<?php
use Cake\I18n\FrozenTime;

// UTC 以外にする
date_default_timezone_set('Asia/Tokyo');

$from = FrozenTime::create(2021, 1, 1);
$to = FrozenTime::create(2021, 3, 1);

// UTC 以外だと 1 が返ってきてしまうのでだめ
$result1 = $from->diffInMonths($to);

// 期待通り!
$result2 = $from->diffInMonthsIgnoreTimezone($to);

FrozenDate を使った場合

ところが FrozenDate を使うと、どうにも 1 が返ってきてしまう。

<?php
use Cake\I18n\FrozenDate;

// UTC 以外にする
date_default_timezone_set('Asia/Tokyo');

$from = FrozenDate::create(2021, 1, 1);
$to = FrozenDate::create(2021, 3, 1);

// IgnoreTimezone しても 1 が返ってきてしまう!
$result = $from->diffInMonthsIgnoreTimezone($to);

原因

前述の Issue にコメントされているのだが、どうやらそもそも DateTime::diff に問題があったようで、バグ報告されている。

このバグは既に対応済で、 PHP8.1 のリリースに含まれている。

PHP8.1で比較する場合

環境は PHP8.1 , CakePHP は 4.3.2 で試す。

$ php -v  
PHP 8.1.0 (cli) (built: Nov 30 2021 06:10:58) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.1.0, Copyright (c) Zend Technologies
$ bin/cake version
4.3.2

この環境で試すと、期待通りの結果が得られる。

(コードは同じもの)

<?php
use Cake\I18n\FrozenDate;

// UTC 以外にする
date_default_timezone_set('Asia/Tokyo');

$from = FrozenDate::create(2021, 1, 1);
$to = FrozenDate::create(2021, 3, 1);

// 無事 2 が返ってくるようになった
$result = $from->diffInMonthsIgnoreTimezone($to);

宣伝

今回の検証にあたって、以前作った Cake 用の Docker Image に PHP8.1 版を追加しておいたので、よかったら使ってみてください。

参考

dateInMonths の罠については↓らへんの記事が参考になった。

CakePHP の Command のテストで Mock を使うのは、 DI を利用するのがよさそう #cakephp

概要

Mock はできるだけ使いたくない派なんだけれど、外部API利用時なんかはどうしても必要になる時がある。

Controller の場合はやりようがあるが、 Command はちょっと迷った。

CakePHP の Command で使うようなときに Mock をするには DI を使うのがよさそう。

Controller の場合はどうするか

Controller の場合は \Cake\TestSuite\IntegrationTestTrait::controllerSpy という用途にあったメソッドが提供されている。

https://api.cakephp.org/4.2/trait-Cake.TestSuite.IntegrationTestTrait.html#controllerSpy()

また、 Controller.initialize のように、初期化時のフックイベントが元から用意されているので、そこに差し込むようなことも可能。

https://book.cakephp.org/4/en/controllers.html#event-list

DI がよさそうな理由

Command 用には \Cake\TestSuite\ConsoleIntegrationTestTrait が用意されているものの、 controllerSpy に相当するようなメソッドは用意されていないようだ。

Command 関連のイベントには Console.buildCommands が用意されているが、これはどちらかというとコマンドの名前変更等が目的なようで、このイベント内でどうにかするにはかなり無理がありそう(イベントが発火されるのも、Command のインスタンス生成前)。

自前で差し込み用のイベントを作成することも考えたが、テストのためだけに新しいイベントを作成するのは躊躇われる。

そういった理由から DI がよさそうと判断した。DI 自体は CakePHP の中ではまだ実験的な位置づけらしいので、大きな変更や使えなくなったりする可能性はあると思う。

DI でやってみる

まず Command のコンストラクタで、 Mock したいクラスを受け取るようにする。 受け取った引数をメンバ変数に格納する

<?php

class HelloCommand extends Command
{
    public $externalApiService;

    public function __construct(ExternalApiService $externalApiService)
    {
        parent::__construct();
        $this->externalApiService = $externalApiService;
    }

    public function execute(Arguments $args, ConsoleIo $io)
    {
        // モックしたい処理
        $this->externalApiService->doSomething();
    }
}

\App\Application::services で、 DI コンテナに登録しておく。

Command の場合は次のようになる。

<?php

public function services(ContainerInterface $container): void
{
    parent::services($container);
    $container
        ->add(HelloCommand::class)
        ->addArgument(ExternalApiService::class);
}

ここまでで DI されるようになったので、テストで Mock に差し替えてみる。

差し替えには \Cake\TestSuite\ContainerStubTrait::mockService を使う。

ConsoleIntegrationTestTrait は ContainerStubTrait を実装しているので、別途 use する必要はない。

<?php

class HelloCommandTest extends TestCase
{
  public function testExecute(): void
    {
        // モックに差し替える
        $this->mockService(ExternalApiService::class, function () {
            $mock = $this->createPartialMock(ExternalApiService::class, ['doSomething']);
            $mock->expects($this->atLeastOnce())->method('doSomething');

            return $mock;
        });

        $this->exec('hello');
        $this->assertExitCode(CommandInterface::CODE_SUCCESS);
    }
}

参考

CakePHP4 で、 Controller 単位で認証をスキップする #cakephp

概要

CakePHP3 の AuthComponent は非推奨になり、 CakePHP4 では AuthenticationComponent を使うことになる。

認証スキップのやり方が変わったのでメモしておく。

CakePHP3 の場合

\Cake\Controller\Component\AuthComponent::allow を使えば良い。

Controller の initialize で、認証不要のアクションを指定してあげればいい。

<?php

public function initialize(): void
{
    parent::initialize();

    $this->loadComponent('Auth');

    // index アクションは認証不要
    $this->Auth->allow('index');
}

CakePHP4 の場合

\Authentication\Controller\Component\AuthenticationComponent::allowUnauthenticated を使う。

AuthComponent では複数アクション指定の場合は配列、単独の場合は文字列で指定できたが、 AuthenticationComponent の場合は必ず配列で指定する必要がある。

<?php

public function initialize(): void
{
    parent::initialize();

    $this->loadComponent('Authentication.Authentication');

    // 必ず配列で指定する
    $this->Authentication->allowUnauthenticated(['index']);
}

Controller 単位で認証不要にするには?

Cake3 (AuthComponent) の場合は、 allow の引数を指定しないことで全アクションを対象にできた。

<?php

public function initialize(): void
{
    parent::initialize();

    $this->loadComponent('Auth');

    // Controller 内すべてのアクションで認証不要になる
    $this->Auth->allow();
}

ただし Cake4 (AuthenticationComponent) では設定でチェックを無効化する形になる。

<?php

public function initialize(): void
{
    parent::initialize();

    $this->loadComponent('Authentication.Authentication', [
        // requireIdentity で無効化する
        'requireIdentity' => false,
    ]);
}

実際に AuthenticationComponent を利用する際は AppController など基底クラスで load することになると思うので、各コントローラで設定を切り替えてやればよい。

<?php

public function initialize(): void
{
    parent::initialize();
    // 認証不要
    $this->Authentication->setConfig('requireIdentity', false);
}

参考

PHP でメモリが足りなくなったら #php

メモリが足りなくて死んじゃう

↓みたいなメッセージが出て死んじゃうことがある。

PHP Fatal error: Out of memory (allocated 39845888) (tried to allocate 512000 bytes)...
Fatal error : Allowed memory size of 2147483648 bytes exhausted (tried to allocate 80 bytes) in ・・・

メモリがどれくらい使われてるのか調べる

memory_get_usage や memory_get_peak_usage を使うと、使用しているメモリを調べられる。

real_usage を引数で指定することにより、確保しているメモリか実際に使っているメモリか判別できる。

echo "memory_get_usage: " . memory_get_usage() / (1024 * 1024) . "MB";
echo "memory_get_peak_usage: " . memory_get_peak_usage() / (1024 * 1024) . "MB";

getrusage というのもあるらしい

メモリ上限の設定を確認する、変更する

php のメモリ上限は設定ファイルで定義されているので、値を変更することで上限を引き上げられる。

-1 にすると上限がなくなる(必要なら使えるだけ使う)。

$ php -i | grep memory_limit
memory_limit => 128M => 128M

php.ini を修正してあげればOK

; Maximum amount of memory a script may consume  
; http://php.net/memory-limit      
; memory_limit = 128M 
memory_limit = 256M

メモリの上限を動的に書き換える

ini_set で memory_limit を変更することによって、プログラムからメモリ上限を引き上げることができる。

// 取得
$memory_limit = ini_get('memory_limit');

// 設定
ini_set('memory_limit', '1G');

GCを強制的に実行する

メモリリークをしているような処理はないのに、まるでメモリリークをしているような現象に遭遇することがある。

そういう場合は gc_collect_cycles で解決するかもしれない。

gc_collect_cycles();

PHPの循環参照の検出はルートが満杯になると動作するが、その件数が1万件に固定されている。

そのため、「件数は少ないがメモリ消費量が大きい」状態になると、GC が動くより先にメモリ上限に達してしまうことがある(と理解している)。

なので、手動で実行すると問題が解消することがあった。

GCの呼び出しを完全に手動制御する gc_enable や gc_disable といった API もあるが、パフォーマンス上の止むにやまない問題がある場合を除き、あえて使うリスクを犯す必要はないと思う( gc_enable でも gc_collect_cycles は使える)。

おまけ: ECSのメモリ制限でOOM

AWS の ECS タスクで PHP プログラムを実行したところ、 ECS 側のメモリ制限に引っかかって OOM になる事象が発生した。

memory_get_peak_usage で取得したメモリ使用量上は問題はなさそうに見えたのだが、プロセスが実際に使用するメモリはまた別のようだ。

なので、 memory_get_peak_usage だけを元にプロセス自体のメモリ制限をカツカツにしないほうがよい。

stackoverflow.com

参考

チーム横断での定例的なやつについて思うこと

なぜ定例が生まれるのか

組織が一定の規模以上になってチームが分割されてきたりすると、どうしても横の繋がりが弱くなってくる傾向がある。

リモートワークしてたりすると、よりその力学は強くなることが多い。

そうすると「定期的に連携しましょう、コミュニケーションとりましょう」ということになり、定例が生まれる。

あるあるな課題

じゃあ横断的な定例を始めようとするとどうなるかというと、普段密にコミュニケーション取っているわけじゃないので、「共有のための共有」になりがち。

前提のコンテキストが足りないので、どうしても説明がほとんどになってしまう。

そうするとまぁ、「資料を共有してくればいい」「後で読んでおく」「全員が集まる必要はない」といった声が上がってくる。

定例をやめると

じゃあ定例をやめて資料を読んでおくことにするとどうなるかというと、読む人と読まない人が出てくるし、当然読むタイミングにもズレが生じる。

そもそも横の繋がりが弱くなっている現状があるので、優先順位が落とされる構造にある。

自分はできるだけ読もうとするタイプだと思うが、忙しかったら後回しにすると思う。

読まないやつが悪いといえばその通りなのだが、そういっても課題は解決されない。

仮に全員が目を通したとしても、そこで感じた疑問・感想・その他フィードバックが可視化されることは稀に思う。

明確な課題ならそもそも定例を待たずに顕在化してくるものだし。

どうするといいのかなぁ

個人的には共有のための共有の場はあってもいいと思う。

ただ中身の設計は工夫する必要があって、参加人数・組織構造・事業フェーズに左右されるので、開催しない方がよい場合もあるだろう。

そういった場合に、資料共有などで同期コストを最小にするなら、たとえば感想や疑問を気軽にフィードバックできるような、ネットワークが相互にリンクするような仕組みが必要なんだろうなと思う。

それは別に定例を開く場合でも同じなんだけど、同期コミュニケーションは自然と行いやすいので(質疑応答のタイミングがあったり)。

CakePHP3 から CakePHP4 に移行したら Middleware が動かなくなった #cakephp

現象

CakePHP3 から CakePHP4 に移行した際に、CakePHP がデフォルトで提供しているミドルウェアを拡張して利用していた Middleware が動かなくなった。(正確には意図しない挙動になった)

原因

CakePHP3 ではミドルウェア__invoke($request, $response, $next) を実装すればよかった。

しかし PSR 15 対応が行われたことにより、 __invoke メソッドは呼ばれなくなってしまった。

github.com

// Cake の提供している Middleware を拡張
class MyMiddleware extends ErrorHandlerMiddleware
{
    // 呼ばれなくなってしまった。。
    public function __invoke($request, $response, $next)
    {
        // do something
    }
}

対応

CakePHP4 では、 ミドルウェアに MiddlewareInterface を実装する必要がある。

ただし今回の場合は拡張なので、 ( __invoke ではなく) process($request, $handler) をオーバーライドすればよい。

// Cake の提供している Middleware を拡張
class MyMiddleware extends ErrorHandlerMiddleware
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        // do something
    }
}

github.com

感想

拡張していることにより拡張元のメソッドが呼ばれることによって、パッと見動いているようになっていた。

それゆえに少し原因特定に時間がかかってしまった。

移行ガイドでも互換性があるように読み取れるし、拡張であったがゆえに見逃しやすい気がする。

参考