Friction River Software

  • お問い合わせ

CakePHP5入門【CakePHP5応用編⑫】コマンドクラス

A子

インスタグラムの「ストーリーズ」って、投稿から24時間つと自動的に消えるじゃん

この掲示板でも同じようなことができないかな?

B美

何でよ?

A子

いや、この掲示板ってプログラム学習のためのサンプルだから、気軽に投稿テストできるようにしときたいんだよね

今のままじゃ、投稿した文面や画像データがいつまでも残っちゃうから、投稿自体に二の足を踏む人が多いと思うのよ

C菜

たしかにそうですね~

ゴミのような投稿をいつまでも残しているのは気が引けますし、かと言ってここで真面目な発言を行うのも変ですしね~

B美

なるほど…

まぁ、分からなくもないわね

A子

シェルスクリプトとクーロンでなんとかなる?

C菜

無理だと思いますよ~

だって、個別の投稿ごとに削除処理するには、データベースアクセスが必要だと思うです~
(さすがにシェルスクリプトからデータベースアクセスするのは難しいんじゃないかと~)

B美

C菜の言う通りね

定期的な実行についてはcronクーロンを使うしかないんだけど、そこから呼び出すのはシェルスクリプトではないってことよ
(まぁ、やろうと思えば「シェルスクリプトからデータベースアクセス」もできるんだけど…(苦笑))

A子

「CakePHP」のメソッドをブラウザ経由で呼び出すの?

B美

いいえ

「CakePHP」のクラスを直接cronクーロンから呼び出すのよ
(正確には「bin/cake」コマンド経由で…)

C菜

そんなこともできるんですね~

なんかすごいです~

B美

これも「CakePHP」学習の一環になるわね

それじゃ手順を説明するわよ
まずは「MATE端末」を開いてから、こう打ち込んでね

cd html/bbsapp[Enter]
bin/cake bake command stories[Enter]

これにより、「bbsapp/src」の中に「Command」ディレクトリが作られて、その中に「StoriesCommand.php」が自動生成されるわ
(もちろん「stories」の箇所は、他の言葉でもOKよ)


B美

そのファイル(StoriesCommand.php)を開くと、中に「execute」メソッドがあるわ
(中身は空っぽだけど…)

cronクーロンから呼び出したい処理は、この中(「execute」メソッド内)に書けば良いってわけ


A子

えっと、つまりこのクラス一つで単一の処理しかできないってこと?

executeを複数書くことはできないんだよね?

B美

そういうこと
なぜなら実行手順はこうなるからね

bin/cake stories[Enter]

要するに、「bin/cake」の引数として指定するのは、クラス名の前半部分(Commandの前)になるのよ

C菜

データベースへのアクセスは、いつも通り「$this->fetchTable()」ですか~?

B美

その通り!
まぁ、ほかには

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

という方法もあるけど、

$this->Posts = $this->fetchTable('Posts');

のほうが簡単でしょうね

A子

んじゃ、さっそく「StoriesCommand.php」を書き換えよう

private $Posts;

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

    $this->Posts = $this->fetchTable('Posts');
}

…で良いんだよね?

B美

ええ
(わざわざinitializeメソッドを作らず、executeメソッドの先頭に書いても良いんだけど…(苦笑))

あとはPostsテーブルにアクセスできるようになったかを検証するために、「execute」メソッドに以下の二行を書き加えてね
(とりあえずの実験よ)

public function execute(Arguments $args, ConsoleIo $io)
{
    $post = $this->Posts->get(1);
    $io->out($post->title);

}

あ、「$io->out()」というのは画面へのメッセージ出力のことだからね

C菜

できました~
それじゃ「MATE端末」から実行してみますね~

bin/cake stories[Enter]

なんだかいっぱいエラー(警告?)みたいなやつが出ました~
一応、正しい結果(投稿番号1のレコードのタイトル)も表示されましたけど…

B美

ここに出たのは警告(Warning)

キャッシュファイルの所有者オーナーってWebサーバ(www-data)なんだけど、ターミナル上でコマンドを実行したのは現在のログインユーザだから、そのキャッシュファイルに対して書き込みを行う権限がない

C菜

警告が出ないようにしたいです~

どうしたら良いですか~?

B美

一番簡単なのは、キャッシュファイルのパーミッションを「666」にすることね
(セキュリティ的にはあまりお勧めできないけど…)

まぁ、「bbsapp/tmp/cache/models」の中にある「myapp_cake_model_default_posts」だけを変更するのであれば大丈夫でしょう(苦笑)

ほかにも

・この一般ユーザを「www-data」グループに追加して、キャッシュファイルのパーミッションを「664」にする
sudoを使って一時的に「www-data」ユーザになる
・一般ユーザ用のキャッシュディレクトリを別に作成する

など、色々な方法があるんだけどね

A子

わかった
とりあえず、一番簡単なやつをやってみよう

cd[Enter]
cd html/bbsapp/tmp/cache/models[Enter]
chmod 666 myapp_cake_model_default_posts[Enter]

で、どうよ

…って、あれ?
エラーだ…

C菜

パーミッションを変更(chmod)する権限がないですよ~

su[Enter]
chmod 666 myapp_cake_model_default_posts[Enter]
exit[Enter]

でOKのはずです~
(「su -」ではなくて「su」)

A子

んじゃ、あらためて「MATE端末」を開き直して…っと

cd html/bbsapp[Enter]
bin/cake stories[Enter]

おぉ!
完璧じゃん

B美

あとは試行錯誤しながらやってみなさい
(ChatGPTを活用しても良いから…)

A子

OK、OK
とりあえずこんな感じで組もうと思う

1.投稿番号1のレコードを除外して、投稿から24時間以上経過しているレコードのidを配列で取得
2.そのidを順に検索(配列をforeachで回す)して、画像投稿済み(filepathが空文字列ではない)であれば、その画像ファイルを完全に削除する
3.同時にそのidのレコードを削除(delete)する

…って感じのアルゴリズムでどうかな?

C菜

削除フラグを立てたり、画像ファイルのリネームではないんですね~?

A子

うん、そのほうがユーザにとって安心感があると思うんだよね
(この世にかけらも残さない…ってことが分かってるほうが…)

C菜

あと、画像ファイルを削除するためには、1番の手順がどうしても必要なんですよね~?
(それがなければ一発で対象レコードを全て削除できそうなんですけど~)

A子

そうなんだよね
この手順以外、思いつかなかった…とも言う(苦笑)

ちなみに、ChatGPTには以下の文面で問い合わせてみた。

CakePHP5のStoriesCommandクラスのexecuteメソッドに以下の条件でコードを書きたい。
1.クラスフィールド$PostsにはモデルPostsをfetchTable済み。
2.idが1のレコードを除外して、投稿から24時間以上経過しているレコードのidを配列で取得する。
3.その配列をforeachで回して、各idのレコードを順に検索する。
4.レコードのfilepathフィールドに「./files/(idの値).拡張子」という形式で画像ファイルへの相対パス(webrootが基点)が格納されている(画像が無ければ空文字列)。
5.画像ファイルが存在すれば、その画像ファイルを完全に削除する。
6.Postsテーブルの当該レコードを削除(delete)する。

A子

んで、ChatGPTの回答がこれ

public function execute(Arguments $args, ConsoleIo $io)
{
    $threshold = FrozenTime::now()->subDay(); // 現在から24時間前の日時

    // idが1を除外し、24時間以上経過したレコードのidを取得
    $ids = $this->Posts->find('list', [
            'keyField' => 'id',
            'valueField' => 'id'
        ])
        ->where(['created <' => $threshold, 'id !=' => 1])
        ->toArray();

    // webrootのパスを取得
    $webrootPath = WWW_ROOT; // CakePHPの定数。例: /var/www/html/webroot/

    // 各IDごとに画像ファイルを削除し、レコードを削除
    foreach ($ids as $id) {
        $post = $this->Posts->get($id); // レコード取得

        // filepathが空でなければ画像ファイルを削除
        if (!empty($post->filepath)) {
            $filePath = $webrootPath . ltrim($post->filepath, './'); // './'を除去して絶対パスに変換
            if (file_exists($filePath)) {
                if (unlink($filePath)) {
                    $io->success("Deleted file: {$filePath}");
                } else {
                    $io->error("Failed to delete file: {$filePath}");
                }
            } else {
                $io->warning("File not found: {$filePath}");
            }
        }

        // レコードを削除
        if ($this->Posts->delete($post)) {
            $io->success("Deleted record with ID: {$id}");
        } else {
            $io->error("Failed to delete record with ID: {$id}");
        }
    }
}

あと、クラスの先頭に以下の記述が必要だからね

use Cake\I18n\FrozenTime;



C菜

ぱっと見、問題は無いように見えますね~
(メッセージが英語であること以外は~(苦笑))

A子

それじゃさっそくテスト実行してみよう

C菜

あ、ちょっと待ってくださいです~

現時点の開発環境に存在するテストデータをバックアップして、削除前に戻せるようにしておきたいんですけど~
(繰返しテストできるように…)

A子

だったらこうだね

mysqldump -u root -p bbsdb > bbsdb_backup_stories_test.sql[Enter]

ファイル名は分かりやすいようにちょっと変えてみた

C菜

画像ファイルについては、ZIP圧縮のやり方をググってみました~

zip images_backup_stories_test.zip html/bbsapp/webroot/files/*[Enter]

で、良いはずです~

A子

よし!
それじゃテストしてみよう

cd html/bbsapp[Enter]
bin/cake stories[Enter]

C菜

エラーですぅ~

A子

このエラーのことをChatGPTに聞いてみたら「CakePHP5では、FrozenTimeクラスはsubDay()メソッドを持ちません。」だってさ
(なんじゃそりゃ(呆れ))

$threshold = FrozenTime::now()->modify('-1 day'); // 24時間前の日時

に変更しろ…って言われたよ(苦笑)

C菜

これこそが、B美部長が以前言っていた「ChatGPTのコードが一発で動いたことはほとんどない」ってやつですね~(笑)

それでは気を取り直して、もう一度実行してみます~

bin/cake stories[Enter]



A子

うまくいったみたいだね…ん?
いや、ちょっと待って…「11.png」が見つからない?

あー、これって投稿を手動削除した際、リネームしたやつだ
(削除した投稿に付随する画像ファイルへのアクセスを防ぐ仕組み)

C菜

ChatGPTに対処方法を聞いてみるです~

$pattern = $webrootPath . "files/" . str_repeat('?', 20) . "_____" . basename($filePath);
foreach (glob($pattern) as $file) {
    if (unlink($file)) {
        $io->success("Deleted file: {$file}");
    } else {
        $io->error("Failed to delete file: {$file}");
    }
}

で良いみたいですよ~

A子

もう一度テストするために、さっきの削除処理を全部無かったことにしよう
(要するに、バックアップファイルからリストアする…ってこと)

mysql -u root -p bbsdb < bbsdb_backup_stories_test.sql[Enter]
unzip images_backup_stories_test.zip[Enter]

C菜

もう一回テストです~

cd html/bbsapp[Enter]
bin/cake stories[Enter]

今度はバッチリ削除されました~

A子

うん、良い感じ

あとはメッセージ関係は削除しちゃおう
(cronで実行するから意味が無い…)

public function execute(Arguments $args, ConsoleIo $io)
{
    $threshold = FrozenTime::now()->modify('-1 day'); //現在から24時間前の日時

    //idが1のレコードを除外し、24時間以上経過したレコードのidを取得
    $ids = $this->Posts->find('list', [
            'keyField' => 'id',
            'valueField' => 'id'
        ])
        ->where(['created <' => $threshold, 'id !=' => 1])
        ->toArray();

    //webrootのパスを取得
    $webrootPath = WWW_ROOT; //CakePHPの定数

    //各IDごとに画像ファイル及びレコードを削除
    foreach ($ids as $id) {
        $post = $this->Posts->get($id); //レコード取得

        //filepathが空でなければ画像ファイルを削除
        if (!empty($post->filepath)) {
            $filePath = $webrootPath.ltrim($post->filepath, './'); //'./'を除去して絶対パスに変換
            if (file_exists($filePath)) {
                unlink($filePath);
            } else {
                $pattern = $webrootPath."files/".str_repeat('?', 20)."_____".basename($filePath);
                foreach (glob($pattern) as $file) {
                    unlink($file);
                }
            }
        }

        //レコードを削除
        $this->Posts->delete($post);
    }
}

executeメソッドの最終版がこれね
(ちなみにWebアプリとしてのバージョンは「1.5.0」にしたよ)

B美

うわっ!

いつの間にか、なかなか大したものを作り上げたわね
(ちょっとびっくり…)

それで、この削除処理をどのタイミングで実行するつもりなの?

A子

シェルスクリプトによるバックアップを毎日午前2時ちょうどに取ってるからさ

削除処理も一日一回で良いんじゃないかな?
(バックアップ直後の午前2時5分とかで…)

C菜

バックアップ処理は、もう要らないんじゃないですか~?

あと、午前2時6分に書き込まれた投稿が、約48時間も残ることになりますよ~

A子

ん?
えーっと、あぁそうなるのか…

まぁ良いんじゃない?
(1時間に一回の削除処理なんかにしちゃうと、Webサーバに負荷をかけそうだし…)

B美

それほどの負荷じゃないけど、(サーバ管理者としては)その配慮はありがたいわね
あ、掲示板のトップページには(投稿が自動削除される件について)注意書きを表示しておきなさいよ

あと、削除処理を行う時間は午前2時ジャストにしておきなさい
(バックアップ処理の代わりとしてcron登録すれば良いと思うわ)

A子

あー、そうするかー
それじゃ「bbsapp/templates/Posts」の中にある「index.php」に、以下の文面を追記しよう

『CakePHP5入門』というコンテンツの中で作成したサンプルWebアプリケーションです。
誰でも自由に投稿可能で、画像ファイル(GIF,JPEG,PNG)のアップロードもできます。

ただし、投稿から24時間以上経過したものについては、自動的に削除されます。
(削除処理のタイミングは毎日午前2時に設定しているため、最長で48時間ほど残るかもしれません)

なお、削除フラグを立てたり、画像ファイルのリネームではなく、完全に跡形もなく削除します。
なので、皆様がテスト投稿を行うにあたって、心理的なハードルは低いと思われます。
どうぞご活用ください。
(1件目の投稿が残っているのは、不具合ではなく仕様です)

※消えるからと言って、犯罪行為の連絡手段として用いないように(笑)

実際は、tableタグの中に入れたり、フォントサイズや色を変えたりもしてるけどね



C菜

良いと思います~

B美

最後の一文が不穏だけどね(笑)

あと、実は「FrozenTime」クラスって、非推奨(deprecated)になってるのよね(CakePHP5以降)
さらに言えば、findメソッドの第二引数に連想配列を与えるのも…

C菜

え?
それじゃ推奨されているのは何なんですか~?

B美

「FrozenTime」に替わるのは「DateTime」クラスよ
なので、クラスの先頭部分にこう書いて…

use Cake\I18n\DateTime;

execute」メソッドはこう書くべきね

public function execute(Arguments $args, ConsoleIo $io)
{
    $threshold = DateTime::now()->modify('-1 day'); //現在から24時間前の日時

    //idが1のレコードを除外し、24時間以上経過したレコードのidを取得
    $ids = $this->Posts->find()
        ->select(['id'])
        ->where(['created <' => $threshold, 'id !=' => 1])
        ->all()
        ->map(fn($row) => $row->id)
        ->toList();


    //webrootのパスを取得
    $webrootPath = WWW_ROOT; //CakePHPの定数

    //各IDごとに画像ファイル及びレコードを削除
    foreach ($ids as $id) {
        $post = $this->Posts->get($id); //レコード取得

        //filepathが空でなければ画像ファイルを削除
        if (!empty($post->filepath)) {
            $filePath = $webrootPath.ltrim($post->filepath, './'); //'./'を除去して絶対パスに変換
            if (file_exists($filePath)) {
                unlink($filePath);
            } else {
                $pattern = $webrootPath."files/".str_repeat('?', 20)."_____".basename($filePath);
                foreach (glob($pattern) as $file) {
                    unlink($file);
                }
            }
        }

        //レコードを削除
        $this->Posts->delete($post);
    }
}

要するに「FrozenTime」を「DateTime」に変更して、検索結果から配列を作るやり方は赤字の箇所のようにすればOKってわけ


A子

な、なるほど…

でも「FrozenTime」でも大丈夫だったし、データベース検索のほうも別に問題なく動いたよ?

B美

もちろん、動くわよ
(「推奨されない」ってだけだし…)

まぁ、警告は出ちゃうんだけどね(苦笑)