register('apfederate', fn() => …); * $cron->add(CronTab::fromString('0 * * * * apfederate')); * $cron->run(); // システムのcrontabから毎分呼ぶ(cron.php) * * run()は前回の実行から今までに予定されていたジョブを1回ずつ実行する。 * 同じ分に2回呼ばれても2重に実行せず、前の実行がまだ終わっていなければ何もしない。 * サーバーが止まっていた等で遅れた場合は、最大CATCH_UPの分だけ遡る。 */ class Cron { /** 遅れた時に遡る最大時間(秒) */ public const CATCH_UP = 3600; private string $dataDir; private string $dataFile; private string $stateFile; private string $lockFile; /** @var array タスク名 => callable */ private array $tasks = []; /** * @param string|null $dataDir 保存先(テスト用。既定はdata/cron/) */ public function __construct(?string $dataDir = null) { $this->dataDir = rtrim($dataDir ?? ROOT.'/data/cron', '/').'/'; $this->dataFile = $this->dataDir.'cron.json'; $this->stateFile = $this->dataDir.'state.json'; $this->lockFile = $this->dataDir.'run.lock'; } /** * タスクを登録する * タスクはジョブ(CronTab)を引数に受け取り、Result・bool・nullを返す。falseかResult::Errorは失敗とする。 * * @param string $name タスク名(ジョブのcommand) * @param callable $task 実行する処理 * @return void */ public function register(string $name, callable $task): void { $this->tasks[$name] = $task; } /** * 登録されたタスク名の一覧 * * @return array */ public function tasks(): array { return array_keys($this->tasks); } /** * run()が最後に実行された時刻(システムのcrontabから呼ばれているかの確認用) * * @return int|null まだ実行されていなければnull */ public function lastTick(): ?int { $state = $this->readState(); return isset($state['lastTick']) ? (int)$state['lastTick'] : null; } public function getLastId(): int { $res = $this->list(); if (!$res->isSuccess || empty($res->data)) return 0; return (int)max(array_column($res->data, 'id')); } /** * 全てのジョブ * * @return Result dataはジョブの配列 */ public function list(): Result { if (!CRON_ENABLED) return Result::Error('エラー:Cronシステムは無効です。'); if (!$this->ensureStorage()) return Result::Error('Cronディレクトリの作成に失敗'); $fp = fopen($this->dataFile, 'r'); if (!$fp) return Result::Error('Cronファイルが存在しません。'); flock($fp, LOCK_SH); $content = stream_get_contents($fp); flock($fp, LOCK_UN); fclose($fp); $data = $this->decode($content); if ($data === null) return Result::Error('Cronの復号化に失敗'); return Result::Success('', $data); } /** * ジョブ1件 * * @param int $id ジョブのID * @return Result dataはジョブ */ public function show(int $id): Result { $res = $this->list(); if (!$res->isSuccess) return $res; foreach ($res->data as $d) { if (($d['id'] ?? 0) === $id) return Result::Success('', $d); } return Result::Error("ID {$id} のCronエントリが見つかりません。"); } public function enable(int $id): Result { return $this->setActive($id, fn(bool $current): bool => true); } public function disable(int $id): Result { return $this->setActive($id, fn(bool $current): bool => false); } public function toggle(int $id): Result { return $this->setActive($id, fn(bool $current): bool => !$current); } /** * ジョブを追加する * * @param CronTab $ct ジョブ(IDは自動で付ける) * @return Result dataは追加したジョブ */ public function add(CronTab $ct): Result { if (($err = $ct->validate()) !== null) return Result::Error($err); return $this->modify(function(array &$data) use ($ct): Result { $ids = array_column($data, 'id'); $ct->setId(empty($ids) ? 1 : (int)max($ids) + 1); $data[] = $ct->toArray(); return Result::Success("Cronエントリ #{$ct->getId()} を追加しました。", $ct->toArray()); }); } /** * ジョブを変更する(実行履歴は残す) * * @param int $id ジョブのID * @param CronTab $ct 新しい内容 * @return Result dataは変更後のジョブ */ public function edit(int $id, CronTab $ct): Result { if (($err = $ct->validate()) !== null) return Result::Error($err); return $this->modify(function(array &$data) use ($id, $ct): Result { foreach ($data as $i => $d) { if (($d['id'] ?? 0) !== $id) continue; $ct->setId($id); $ct->lastRun = $d['lastRun'] ?? null; $ct->lastError = $d['lastError'] ?? null; $data[$i] = $ct->toArray(); return Result::Success("ID {$id} のCronエントリを更新しました。", $data[$i]); } return Result::Error("ID {$id} のCronエントリが見つかりません。"); }); } /** * ジョブを削除する * * @param int $id ジョブのID * @return Result dataは削除したジョブ */ public function delete(int $id): Result { return $this->modify(function(array &$data) use ($id): Result { foreach ($data as $i => $d) { if (($d['id'] ?? 0) !== $id) continue; array_splice($data, $i, 1); return Result::Success("ID {$id} のCronエントリを削除しました。", $d); } return Result::Error("ID {$id} のCronエントリが見つかりません。"); }); } /** * 予定の来たジョブを実行する(システムのcrontabから毎分呼ぶ) * * @param \DateTimeInterface|null $now 現在時刻(テスト用) * @return Result dataは ['ran' => [ID => 結果のメッセージ], 'failed' => [ID => エラー]] */ public function run(?\DateTimeInterface $now = null): Result { if (!CRON_ENABLED) return Result::Error('エラー:Cronシステムは無効です。'); if (!$this->ensureStorage()) return Result::Error('Cronディレクトリの作成に失敗'); // 前の実行がまだ終わっていなければ何もしない // (エラーにすると、長いタスクの間に毎分cronからメールが届くので成功扱い) $lock = @fopen($this->lockFile, 'c'); if (!$lock) return Result::Error('ロックファイルを開けません。'); if (!flock($lock, LOCK_EX | LOCK_NB)) { fclose($lock); return Result::Success('前の実行がまだ終わっていません。', ['ran' => [], 'failed' => [], 'busy' => true]); } try { $now = \DateTimeImmutable::createFromInterface($now ?? new \DateTimeImmutable()); $now = $now->setTime((int)$now->format('G'), (int)$now->format('i')); // 前回の実行の次の分から今までが対象(最大CATCH_UPまで遡る) $state = $this->readState(); $last = isset($state['lastTick']) ? (new \DateTimeImmutable())->setTimestamp((int)$state['lastTick']) ->setTimezone($now->getTimezone()) : null; $earliest = $now->modify('-'.self::CATCH_UP.' seconds'); $from = $last === null ? $now->modify('-1 minute') : max($last, $earliest); if ($from >= $now) return Result::Success('この分は実行済みです。', ['ran' => [], 'failed' => []]); $list = $this->list(); if (!$list->isSuccess) return $list; $ran = []; $failed = []; $updates = []; foreach ($list->data as $d) { $ct = CronTab::fromArray($d); if (!$ct->isActive || $ct->validate() !== null) continue; // $fromより後、$now以前に予定があったか $due = $ct->nextRun($from); if ($due === null || $due > $now) continue; [$ok, $msg] = $this->execute($ct); $updates[$ct->getId()] = ['lastRun' => $now->getTimestamp(), 'lastError' => $ok ? null : $msg]; if ($ok) $ran[$ct->getId()] = $msg; else $failed[$ct->getId()] = $msg; } // 実行結果を保存(実行中に変更されたジョブを上書きしない様に、IDで合わせる) if (!empty($updates)) { $this->modify(function(array &$data) use ($updates): Result { foreach ($data as $i => $d) { if (isset($updates[$d['id'] ?? 0])) $data[$i] = array_merge($d, $updates[$d['id']]); } return Result::Success(); }); } $this->writeState(['lastTick' => $now->getTimestamp()]); $total = count($ran) + count($failed); return Result::Success("{$total}件を実行しました(失敗:".count($failed).'件)', ['ran' => $ran, 'failed' => $failed]); } finally { flock($lock, LOCK_UN); fclose($lock); } } /** * ジョブを予定に関係なく今すぐ実行する(手動実行・テスト用) * * @param int $id ジョブのID * @return Result */ public function runNow(int $id): Result { $res = $this->show($id); if (!$res->isSuccess) return $res; $ct = CronTab::fromArray($res->data); [$ok, $msg] = $this->execute($ct); $this->modify(function(array &$data) use ($id, $ok, $msg): Result { foreach ($data as $i => $d) { if (($d['id'] ?? 0) === $id) $data[$i] = array_merge($d, ['lastRun' => time(), 'lastError' => $ok ? null : $msg]); } return Result::Success(); }); return $ok ? Result::Success($msg) : Result::Error($msg); } /////////////// /** * タスクを実行する(例外も失敗として扱う) * * @return array [成功か, メッセージ] */ private function execute(CronTab $ct): array { $task = $this->tasks[$ct->command] ?? null; $label = "Cron #{$ct->getId()}({$ct->command})"; if ($task === null) { $msg = "タスク「{$ct->command}」が登録されていません。"; logger(LogType::Cron, "{$label}:{$msg}"); return [false, $msg]; } $start = microtime(true); try { $res = $task($ct); $ok = !($res === false || ($res instanceof Result && !$res->isSuccess)); $msg = $res instanceof Result ? (string)$res->message : ($ok ? '完了' : '失敗'); } catch (\Throwable $e) { $ok = false; $msg = get_class($e).':'.$e->getMessage(); } logger(LogType::Cron, sprintf('%s %s:%s(%.2f秒)%s', date('Y-m-d H:i:s'), $label, $ok ? '成功' : '失敗', microtime(true) - $start, $msg !== '' ? " {$msg}" : '')); return [$ok, $msg === '' ? ($ok ? '完了' : '失敗') : $msg]; } private function setActive(int $id, callable $value): Result { return $this->modify(function(array &$data) use ($id, $value): Result { foreach ($data as $i => $d) { if (($d['id'] ?? 0) !== $id) continue; $data[$i]['isActive'] = $value((bool)($d['isActive'] ?? true)); return Result::Success("ID {$id} を".($data[$i]['isActive'] ? '有効' : '無効').'にしました。', $data[$i]); } return Result::Error("ID {$id} のCronエントリが見つかりません。"); }); } /** * ファイルをロックして読み、変更して書き戻す(同時に実行されても壊れない様に) * $fnがResult::Errorを返した場合は保存しない * * @param callable $fn function(array &$data): Result * @return Result $fnの結果 */ private function modify(callable $fn): Result { if (!CRON_ENABLED) return Result::Error('エラー:Cronシステムは無効です。'); if (!$this->ensureStorage()) return Result::Error('Cronディレクトリの作成に失敗'); $fp = fopen($this->dataFile, 'c+'); if (!$fp) return Result::Error('Cronファイルを開けません。'); flock($fp, LOCK_EX); try { $data = $this->decode(stream_get_contents($fp)); if ($data === null) return Result::Error('Cronの復号化に失敗'); $res = $fn($data); if (!$res->isSuccess) return $res; $json = json_encode(array_values($data), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ftruncate($fp, 0); rewind($fp); if (fwrite($fp, $json) === false) return Result::Error('Cronデータの保存に失敗'); fflush($fp); return $res; } finally { flock($fp, LOCK_UN); fclose($fp); } } private function decode(string|false $content): ?array { if ($content === false) return null; if (trim($content) === '') return []; $data = json_decode($content, true); return is_array($data) ? array_values($data) : null; } private function ensureStorage(): bool { if (!is_dir($this->dataDir) && !@mkdir($this->dataDir, 0755, true) && !is_dir($this->dataDir)) return false; if (!file_exists($this->dataFile)) return file_put_contents($this->dataFile, '[]') !== false; return true; } private function readState(): array { if (!is_file($this->stateFile)) return []; $data = json_decode((string)file_get_contents($this->stateFile), true); return is_array($data) ? $data : []; } private function writeState(array $state): void { file_put_contents($this->stateFile, json_encode($state), LOCK_EX); } }