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

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

B美

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

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

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

A子

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

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

A子

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

C菜
なんかすごいです~

B美
それじゃ手順を説明するわよ
まずは「MATE端末」を開いてから、こう打ち込んでね
cd html/bbsapp[Enter]
bin/cake bake command stories[Enter] |
これにより、「bbsapp/src」の中に「Command」ディレクトリが作られて、その中に「StoriesCommand.php」が自動生成されるわ
(もちろん「stories」の箇所は、他の言葉でもOKよ)



B美
(中身は空っぽだけど…)
cronから呼び出したい処理は、この中(「execute」メソッド内)に書けば良いってわけ



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

B美
なぜなら実行手順はこうなるからね
bin/cake stories[Enter] |
要するに、「bin/cake」の引数として指定するのは、クラス名の前半部分(Commandの前)になるのよ

C菜

B美
まぁ、ほかには
$this->Posts = Cake\ORM\TableRegistry::getTableLocator()->get('Posts'); |
という方法もあるけど、
$this->Posts = $this->fetchTable('Posts'); |
のほうが簡単でしょうね

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

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

B美
(セキュリティ的にはあまりお勧めできないけど…)
まぁ、「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菜
su[Enter]
chmod 666 myapp_cake_model_default_posts[Enter] exit[Enter] |
でOKのはずです~
(「su -」ではなくて「su」)


A子
cd html/bbsapp[Enter]
bin/cake stories[Enter] |
おぉ!
完璧じゃん


B美
(ChatGPTを活用しても良いから…)

A子
とりあえずこんな感じで組もうと思う
1.投稿番号1のレコードを除外して、投稿から24時間以上経過しているレコードのidを配列で取得
2.そのidを順に検索(配列をforeachで回す)して、画像投稿済み(filepathが空文字列ではない)であれば、その画像ファイルを完全に削除する 3.同時にそのidのレコードを削除(delete)する |
…って感じのアルゴリズムでどうかな?

C菜

A子
(この世にかけらも残さない…ってことが分かってるほうが…)

C菜
(それがなければ一発で対象レコードを全て削除できそうなんですけど~)

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子
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 images_backup_stories_test.zip html/bbsapp/webroot/files/*[Enter] |
で、良いはずです~


A子
それじゃテストしてみよう
cd html/bbsapp[Enter]
bin/cake stories[Enter] |


C菜

A子
(なんじゃそりゃ(呆れ))
$threshold = FrozenTime::now()->modify('-1 day'); // 24時間前の日時 |
に変更しろ…って言われたよ(苦笑)


C菜
それでは気を取り直して、もう一度実行してみます~
bin/cake stories[Enter] |




A子
いや、ちょっと待って…「11.png」が見つからない?
あー、これって投稿を手動削除した際、リネームしたやつだ
(削除した投稿に付随する画像ファイルへのアクセスを防ぐ仕組み)


C菜
$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時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美
なので、クラスの先頭部分にこう書いて…
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美
(「推奨されない」ってだけだし…)
まぁ、警告は出ちゃうんだけどね(苦笑)