master
ブランチ master
LittleBeast / src / Std / Lib / CronTab.php
9.6 KB、284行 生 Blame 履歴
1<?php
2namespace Std\Lib;
3
4/**
5 * Cronのジョブ1件(スケジュールとタスク名)
6 *
7 * スケジュールは普通のcronと同じ5項目:分 時 日 月 曜日
8 * * 全部
9 * 5 その値だけ
10 * 1-5 範囲
11 * 1,3,5 リスト
12 * */15 間隔(0, 15, 30, 45。「*」は半角の*)
13 * 10-50/20 範囲の中の間隔(10, 30, 50)
14 * jan-dec, sun-sat 月と曜日の名前(曜日の7も日曜日)
15 * 又は @yearly @annually @monthly @weekly @daily @midnight @hourly
16 *
17 * 日と曜日の両方を指定した場合は、どちらかに合えば実行する(普通のcronと同じ)。
18 * 時刻はPHPの既定のタイムゾーン(date_default_timezone_get())で判断する。
19 */
20class CronTab {
21 private int $id = 0;
22 public bool $isActive;
23 public string $minute;
24 public string $hour;
25 public string $dayOfMonth;
26 public string $month;
27 public string $dayOfWeek;
28 public string $command;
29 /** 最後に実行した時刻(UNIX時間)、未実行ならnull */
30 public ?int $lastRun = null;
31 /** 最後の実行のエラー、成功ならnull */
32 public ?string $lastError = null;
33
34 private const MACROS = [
35 '@yearly' => '0 0 1 1 *',
36 '@annually' => '0 0 1 1 *',
37 '@monthly' => '0 0 1 * *',
38 '@weekly' => '0 0 * * 0',
39 '@daily' => '0 0 * * *',
40 '@midnight' => '0 0 * * *',
41 '@hourly' => '0 * * * *',
42 ];
43
44 private const MONTHS = ['jan' => 1, 'feb' => 2, 'mar' => 3, 'apr' => 4, 'may' => 5, 'jun' => 6,
45 'jul' => 7, 'aug' => 8, 'sep' => 9, 'oct' => 10, 'nov' => 11, 'dec' => 12];
46 private const DAYS = ['sun' => 0, 'mon' => 1, 'tue' => 2, 'wed' => 3, 'thu' => 4, 'fri' => 5, 'sat' => 6];
47
48 /** @var array|null 解析済みの各項目(値 => true)のキャッシュ */
49 private ?array $parsed = null;
50
51 public function __construct(
52 bool $isActive = true,
53 string $minute = '*',
54 string $hour = '*',
55 string $dayOfMonth = '*',
56 string $month = '*',
57 string $dayOfWeek = '*',
58 string $command = ''
59 ) {
60 $this->isActive = $isActive;
61 $this->minute = trim($minute);
62 $this->hour = trim($hour);
63 $this->dayOfMonth = trim($dayOfMonth);
64 $this->month = trim($month);
65 $this->dayOfWeek = trim($dayOfWeek);
66 $this->command = trim($command);
67 }
68
69 /**
70 * 「0 2 * * * apfederate」又は「@daily apfederate」から作る
71 *
72 * @param string $line スケジュールとタスク名
73 * @param bool $isActive 有効にするか
74 * @return self
75 * @throws \InvalidArgumentException 形式が不正な場合
76 */
77 public static function fromString(string $line, bool $isActive = true): self {
78 $parts = preg_split('/\s+/', trim($line));
79 if (isset($parts[0]) && str_starts_with($parts[0], '@')) {
80 $macro = strtolower($parts[0]);
81 if (!isset(self::MACROS[$macro])) throw new \InvalidArgumentException("未対応のマクロ: {$parts[0]}");
82 $parts = array_merge(explode(' ', self::MACROS[$macro]), array_slice($parts, 1));
83 }
84 if (count($parts) !== 6) {
85 throw new \InvalidArgumentException('形式は「分 時 日 月 曜日 タスク名」又は「@daily タスク名」です。');
86 }
87 return new self($isActive, ...$parts);
88 }
89
90 public static function fromArray(array $data): self {
91 $ct = new self(
92 (bool)($data['isActive'] ?? true),
93 (string)($data['minute'] ?? '*'),
94 (string)($data['hour'] ?? '*'),
95 (string)($data['dayOfMonth'] ?? '*'),
96 (string)($data['month'] ?? '*'),
97 (string)($data['dayOfWeek'] ?? '*'),
98 (string)($data['command'] ?? ''),
99 );
100 $ct->setId((int)($data['id'] ?? 0));
101 $ct->lastRun = isset($data['lastRun']) ? (int)$data['lastRun'] : null;
102 $ct->lastError = isset($data['lastError']) ? (string)$data['lastError'] : null;
103 return $ct;
104 }
105
106 public function getId(): int {
107 return $this->id;
108 }
109
110 public function setId(int $id): void {
111 $this->id = $id;
112 }
113
114 public function toArray(): array {
115 return [
116 'id' => $this->id,
117 'isActive' => $this->isActive,
118 'minute' => $this->minute,
119 'hour' => $this->hour,
120 'dayOfMonth' => $this->dayOfMonth,
121 'month' => $this->month,
122 'dayOfWeek' => $this->dayOfWeek,
123 'command' => $this->command,
124 'lastRun' => $this->lastRun,
125 'lastError' => $this->lastError,
126 ];
127 }
128
129 /**
130 * スケジュールの部分(「0 2 * * *」)
131 */
132 public function expression(): string {
133 return "{$this->minute} {$this->hour} {$this->dayOfMonth} {$this->month} {$this->dayOfWeek}";
134 }
135
136 /**
137 * スケジュールとタスク名を確認する
138 *
139 * @return string|null エラーメッセージ、正しければnull
140 */
141 public function validate(): ?string {
142 if (!preg_match('/^[A-Za-z0-9_.:-]{1,64}$/', $this->command)) {
143 return 'タスク名は英数字と「_ . : -」の64文字以内で入力して下さい。';
144 }
145 try {
146 $this->fields();
147 } catch (\InvalidArgumentException $e) {
148 return $e->getMessage();
149 }
150 return null;
151 }
152
153 /**
154 * 指定した時刻(分単位)に実行するか
155 *
156 * @param \DateTimeInterface $t 時刻
157 * @return bool
158 */
159 public function isDue(\DateTimeInterface $t): bool {
160 $f = $this->fields();
161 return isset($f['minute'][(int)$t->format('i')])
162 && isset($f['hour'][(int)$t->format('G')])
163 && isset($f['month'][(int)$t->format('n')])
164 && $this->dayMatches($t, $f);
165 }
166
167 /**
168 * 指定した時刻より後の、次の実行時刻
169 *
170 * @param \DateTimeInterface $after この時刻より後(同じ分は含まない)
171 * @return \DateTimeImmutable|null 5年以内に無ければnull(2月30日等)
172 */
173 public function nextRun(\DateTimeInterface $after): ?\DateTimeImmutable {
174 $f = $this->fields();
175 $t = \DateTimeImmutable::createFromInterface($after);
176 $t = $t->setTime((int)$t->format('G'), (int)$t->format('i'))->modify('+1 minute');
177 $limit = $t->modify('+5 years');
178
179 // 合わない項目は、その単位ごと飛ばす(1分ずつ確認しない)
180 while ($t <= $limit) {
181 if (!isset($f['month'][(int)$t->format('n')])) {
182 $t = $t->setDate((int)$t->format('Y'), (int)$t->format('n'), 1)->setTime(0, 0)->modify('+1 month');
183 continue;
184 }
185 if (!$this->dayMatches($t, $f)) {
186 $t = $t->setTime(0, 0)->modify('+1 day');
187 continue;
188 }
189 if (!isset($f['hour'][(int)$t->format('G')])) {
190 $t = $t->setTime((int)$t->format('G'), 0)->modify('+1 hour');
191 continue;
192 }
193 if (!isset($f['minute'][(int)$t->format('i')])) {
194 $t = $t->modify('+1 minute');
195 continue;
196 }
197 return $t;
198 }
199
200 return null;
201 }
202
203 ///////////////
204
205 /**
206 * 日と曜日の判定
207 * どちらかが「*」で始まる場合は両方に合う事、両方とも指定された場合はどちらかに合う事(普通のcronと同じ)
208 */
209 private function dayMatches(\DateTimeInterface $t, array $f): bool {
210 $dom = isset($f['dayOfMonth'][(int)$t->format('j')]);
211 $dow = isset($f['dayOfWeek'][(int)$t->format('w')]);
212 if (str_starts_with($this->dayOfMonth, '*') || str_starts_with($this->dayOfWeek, '*')) return $dom && $dow;
213 return $dom || $dow;
214 }
215
216 /**
217 * 5項目を解析する
218 *
219 * @return array 項目名 => [値 => true]
220 * @throws \InvalidArgumentException
221 */
222 private function fields(): array {
223 if ($this->parsed !== null) return $this->parsed;
224
225 $dow = self::parseField($this->dayOfWeek, 0, 7, self::DAYS, '曜日');
226 if (isset($dow[7])) {
227 $dow[0] = true; // 7も日曜日
228 unset($dow[7]);
229 }
230
231 return $this->parsed = [
232 'minute' => self::parseField($this->minute, 0, 59, [], '分'),
233 'hour' => self::parseField($this->hour, 0, 23, [], '時'),
234 'dayOfMonth' => self::parseField($this->dayOfMonth, 1, 31, [], '日'),
235 'month' => self::parseField($this->month, 1, 12, self::MONTHS, '月'),
236 'dayOfWeek' => $dow,
237 ];
238 }
239
240 /**
241 * 1項目を解析する(*, 5, 1-5, 1,3,5, 間隔, 10-50/20, 名前)
242 *
243 * @return array 値 => true
244 * @throws \InvalidArgumentException
245 */
246 private static function parseField(string $field, int $min, int $max, array $names, string $label): array {
247 $out = [];
248 if ($field === '') throw new \InvalidArgumentException("{$label}が空です。");
249
250 foreach (explode(',', strtolower($field)) as $part) {
251 $step = 1;
252 if (str_contains($part, '/')) {
253 [$part, $s] = explode('/', $part, 2);
254 if (!ctype_digit($s) || (int)$s < 1) throw new \InvalidArgumentException("{$label}の間隔「{$s}」が不正です。");
255 $step = (int)$s;
256 }
257
258 if ($part === '*') {
259 [$from, $to] = [$min, $max];
260 } else if (str_contains($part, '-')) {
261 [$a, $b] = explode('-', $part, 2);
262 $from = self::value($a, $min, $max, $names, $label);
263 $to = self::value($b, $min, $max, $names, $label);
264 if ($from > $to) throw new \InvalidArgumentException("{$label}の範囲「{$part}」が逆です。");
265 } else {
266 $from = self::value($part, $min, $max, $names, $label);
267 // 「5/15」は5から最後まで15毎
268 $to = $step > 1 ? $max : $from;
269 }
270
271 for ($v = $from; $v <= $to; $v += $step) $out[$v] = true;
272 }
273
274 return $out;
275 }
276
277 private static function value(string $v, int $min, int $max, array $names, string $label): int {
278 if (isset($names[$v])) return $names[$v];
279 if (!ctype_digit($v)) throw new \InvalidArgumentException("{$label}の値「{$v}」が不正です。");
280 $n = (int)$v;
281 if ($n < $min || $n > $max) throw new \InvalidArgumentException("{$label}の値「{$v}」は{$min}〜{$max}の範囲外です。");
282 return $n;
283 }
284}