'0 0 1 1 *', '@annually' => '0 0 1 1 *', '@monthly' => '0 0 1 * *', '@weekly' => '0 0 * * 0', '@daily' => '0 0 * * *', '@midnight' => '0 0 * * *', '@hourly' => '0 * * * *', ]; private const MONTHS = ['jan' => 1, 'feb' => 2, 'mar' => 3, 'apr' => 4, 'may' => 5, 'jun' => 6, 'jul' => 7, 'aug' => 8, 'sep' => 9, 'oct' => 10, 'nov' => 11, 'dec' => 12]; private const DAYS = ['sun' => 0, 'mon' => 1, 'tue' => 2, 'wed' => 3, 'thu' => 4, 'fri' => 5, 'sat' => 6]; /** @var array|null 解析済みの各項目(値 => true)のキャッシュ */ private ?array $parsed = null; public function __construct( bool $isActive = true, string $minute = '*', string $hour = '*', string $dayOfMonth = '*', string $month = '*', string $dayOfWeek = '*', string $command = '' ) { $this->isActive = $isActive; $this->minute = trim($minute); $this->hour = trim($hour); $this->dayOfMonth = trim($dayOfMonth); $this->month = trim($month); $this->dayOfWeek = trim($dayOfWeek); $this->command = trim($command); } /** * 「0 2 * * * apfederate」又は「@daily apfederate」から作る * * @param string $line スケジュールとタスク名 * @param bool $isActive 有効にするか * @return self * @throws \InvalidArgumentException 形式が不正な場合 */ public static function fromString(string $line, bool $isActive = true): self { $parts = preg_split('/\s+/', trim($line)); if (isset($parts[0]) && str_starts_with($parts[0], '@')) { $macro = strtolower($parts[0]); if (!isset(self::MACROS[$macro])) throw new \InvalidArgumentException("未対応のマクロ: {$parts[0]}"); $parts = array_merge(explode(' ', self::MACROS[$macro]), array_slice($parts, 1)); } if (count($parts) !== 6) { throw new \InvalidArgumentException('形式は「分 時 日 月 曜日 タスク名」又は「@daily タスク名」です。'); } return new self($isActive, ...$parts); } public static function fromArray(array $data): self { $ct = new self( (bool)($data['isActive'] ?? true), (string)($data['minute'] ?? '*'), (string)($data['hour'] ?? '*'), (string)($data['dayOfMonth'] ?? '*'), (string)($data['month'] ?? '*'), (string)($data['dayOfWeek'] ?? '*'), (string)($data['command'] ?? ''), ); $ct->setId((int)($data['id'] ?? 0)); $ct->lastRun = isset($data['lastRun']) ? (int)$data['lastRun'] : null; $ct->lastError = isset($data['lastError']) ? (string)$data['lastError'] : null; return $ct; } public function getId(): int { return $this->id; } public function setId(int $id): void { $this->id = $id; } public function toArray(): array { return [ 'id' => $this->id, 'isActive' => $this->isActive, 'minute' => $this->minute, 'hour' => $this->hour, 'dayOfMonth' => $this->dayOfMonth, 'month' => $this->month, 'dayOfWeek' => $this->dayOfWeek, 'command' => $this->command, 'lastRun' => $this->lastRun, 'lastError' => $this->lastError, ]; } /** * スケジュールの部分(「0 2 * * *」) */ public function expression(): string { return "{$this->minute} {$this->hour} {$this->dayOfMonth} {$this->month} {$this->dayOfWeek}"; } /** * スケジュールとタスク名を確認する * * @return string|null エラーメッセージ、正しければnull */ public function validate(): ?string { if (!preg_match('/^[A-Za-z0-9_.:-]{1,64}$/', $this->command)) { return 'タスク名は英数字と「_ . : -」の64文字以内で入力して下さい。'; } try { $this->fields(); } catch (\InvalidArgumentException $e) { return $e->getMessage(); } return null; } /** * 指定した時刻(分単位)に実行するか * * @param \DateTimeInterface $t 時刻 * @return bool */ public function isDue(\DateTimeInterface $t): bool { $f = $this->fields(); return isset($f['minute'][(int)$t->format('i')]) && isset($f['hour'][(int)$t->format('G')]) && isset($f['month'][(int)$t->format('n')]) && $this->dayMatches($t, $f); } /** * 指定した時刻より後の、次の実行時刻 * * @param \DateTimeInterface $after この時刻より後(同じ分は含まない) * @return \DateTimeImmutable|null 5年以内に無ければnull(2月30日等) */ public function nextRun(\DateTimeInterface $after): ?\DateTimeImmutable { $f = $this->fields(); $t = \DateTimeImmutable::createFromInterface($after); $t = $t->setTime((int)$t->format('G'), (int)$t->format('i'))->modify('+1 minute'); $limit = $t->modify('+5 years'); // 合わない項目は、その単位ごと飛ばす(1分ずつ確認しない) while ($t <= $limit) { if (!isset($f['month'][(int)$t->format('n')])) { $t = $t->setDate((int)$t->format('Y'), (int)$t->format('n'), 1)->setTime(0, 0)->modify('+1 month'); continue; } if (!$this->dayMatches($t, $f)) { $t = $t->setTime(0, 0)->modify('+1 day'); continue; } if (!isset($f['hour'][(int)$t->format('G')])) { $t = $t->setTime((int)$t->format('G'), 0)->modify('+1 hour'); continue; } if (!isset($f['minute'][(int)$t->format('i')])) { $t = $t->modify('+1 minute'); continue; } return $t; } return null; } /////////////// /** * 日と曜日の判定 * どちらかが「*」で始まる場合は両方に合う事、両方とも指定された場合はどちらかに合う事(普通のcronと同じ) */ private function dayMatches(\DateTimeInterface $t, array $f): bool { $dom = isset($f['dayOfMonth'][(int)$t->format('j')]); $dow = isset($f['dayOfWeek'][(int)$t->format('w')]); if (str_starts_with($this->dayOfMonth, '*') || str_starts_with($this->dayOfWeek, '*')) return $dom && $dow; return $dom || $dow; } /** * 5項目を解析する * * @return array 項目名 => [値 => true] * @throws \InvalidArgumentException */ private function fields(): array { if ($this->parsed !== null) return $this->parsed; $dow = self::parseField($this->dayOfWeek, 0, 7, self::DAYS, '曜日'); if (isset($dow[7])) { $dow[0] = true; // 7も日曜日 unset($dow[7]); } return $this->parsed = [ 'minute' => self::parseField($this->minute, 0, 59, [], '分'), 'hour' => self::parseField($this->hour, 0, 23, [], '時'), 'dayOfMonth' => self::parseField($this->dayOfMonth, 1, 31, [], '日'), 'month' => self::parseField($this->month, 1, 12, self::MONTHS, '月'), 'dayOfWeek' => $dow, ]; } /** * 1項目を解析する(*, 5, 1-5, 1,3,5, 間隔, 10-50/20, 名前) * * @return array 値 => true * @throws \InvalidArgumentException */ private static function parseField(string $field, int $min, int $max, array $names, string $label): array { $out = []; if ($field === '') throw new \InvalidArgumentException("{$label}が空です。"); foreach (explode(',', strtolower($field)) as $part) { $step = 1; if (str_contains($part, '/')) { [$part, $s] = explode('/', $part, 2); if (!ctype_digit($s) || (int)$s < 1) throw new \InvalidArgumentException("{$label}の間隔「{$s}」が不正です。"); $step = (int)$s; } if ($part === '*') { [$from, $to] = [$min, $max]; } else if (str_contains($part, '-')) { [$a, $b] = explode('-', $part, 2); $from = self::value($a, $min, $max, $names, $label); $to = self::value($b, $min, $max, $names, $label); if ($from > $to) throw new \InvalidArgumentException("{$label}の範囲「{$part}」が逆です。"); } else { $from = self::value($part, $min, $max, $names, $label); // 「5/15」は5から最後まで15毎 $to = $step > 1 ? $max : $from; } for ($v = $from; $v <= $to; $v += $step) $out[$v] = true; } return $out; } private static function value(string $v, int $min, int $max, array $names, string $label): int { if (isset($names[$v])) return $names[$v]; if (!ctype_digit($v)) throw new \InvalidArgumentException("{$label}の値「{$v}」が不正です。"); $n = (int)$v; if ($n < $min || $n > $max) throw new \InvalidArgumentException("{$label}の値「{$v}」は{$min}〜{$max}の範囲外です。"); return $n; } }