Регулярные фоновые работы — загрузка прайсов поставщиков, выгрузка товаров в торговые площадки, пересчёт статистики, рассылка напоминаний, очистка кеша — в Melbis выполняются не консольными скриптами, а обычными модулями платформы, вызываемыми по HTTP.
Консольный запуск PHP-скрипта живёт в другом окружении, чем витрина:
свои настройки интерпретатора, свой пользователь, нет ни
$_SERVER, ни сессии, ни привычного окружения запроса. Из-за
этого возникает классическая ловушка: в браузере скрипт отрабатывает, а
по расписанию падает — или наоборот.
Планировщик Melbis устроен так, что фоновая задача — это тот же самый модульный скрипт, который разработчик пишет и отлаживает в IDE, открывая его в браузере. Он выполняется тем же кодом, с тем же кешем, с теми же подключёнными библиотеками. Открыли модуль в браузере, добились нужного результата — ровно это же произойдёт и ночью по расписанию.
Системный crontab (хост) Контейнер melbis_shop
| |
|── раз в минуту ───────────────────> |
| docker exec ... php cron.php |── cron.php
| | ├─ CronAdd(...) — объявление задач
| | └─ CronRun() — что запускать сейчас
| | |
| | └── curl → http://localhost/?mod=...
| | |
| | └─ Apache того же контейнера
| | └─ index.php → модуль задачи
Ключевая деталь: curl бьёт в localhost
внутри того же контейнера, минуя внешний прокси. Из
этого следуют два важных свойства:
REMOTE_ADDR равен
127.0.0.1, тогда как у настоящего посетителя там будет
адрес прокси-контейнера. На этом различии строится защита cron-модулей
(см. ниже).Требование к развёртыванию. Различие адресов работает, пока веб-сервер проекта и прокси живут в разных сетевых пространствах. Если прокси когда-нибудь окажется в том же контейнере или будет запущен с
network_mode: host, защитаCronLocalOnlyперестанет отличать посетителя от планировщика — молча.
При установке стандартным инсталлятором всё уже настроено: в системный crontab добавлена строка
* * * * * /usr/bin/docker exec melbis_shop php /var/www/html/cron.php > /dev/null 2>&1
а в корне проекта лежит cron.php. Прямой доступ к нему
из браузера закрыт правилом .htaccess
(RewriteRule ^cron.php$ - [F]).
Системный crontab вызывает скрипт каждую минуту и
больше не меняется никогда: всё расписание проекта живёт внутри
cron.php, а конкретная работа — в модулях. Лезть в crontab
на сервере не нужно.
<?php
// cron.php
require 'units/melbis.php';
// Tasks
MELBIS()->CronAdd('*/5 * * * *', 'http://localhost/?mod=melbis_cron_price', 240, 'price');
MELBIS()->CronAdd('0 3 * * *', 'http://localhost/?mod=melbis_cron_clearing', 600);
// Run
MELBIS()->CronRun();
?>CronAdd($mTiming, $mUrl, $mTimeout = 300, $mLogName = false)
$mTiming — расписание в формате cron (см. следующий
раздел).$mUrl — адрес, который надо вызвать. Как правило
http://localhost/?mod=имя_модуля.$mTimeout — сколько секунд ждать ответа, прежде чем
оборвать запрос.$mLogName — имя журнала без пути и расширения или
false, если журнал не нужен.CronRun() сверяет расписания с текущей
минутой и запускает подошедшие задачи. Если задач несколько, они уходят
параллельно и CronRun ждёт завершения всех
— то есть медленная задача не задерживает старт остальных, но
задерживает завершение самого cron.php.
Время сверяется в часовом поясе проекта
(MELBIS_TIME_ZONE), а не в поясе сервера.
Пять полей, разделённых пробелами:
┌───────────── минута 0-59
│ ┌─────────── час 0-23
│ │ ┌───────── день месяца 1-31
│ │ │ ┌─────── месяц 1-12
│ │ │ │ ┌───── день недели 0-7 (0 и 7 — воскресенье)
│ │ │ │ │
* * * * *
Каждое поле принимает:
| Запись | Значение |
|---|---|
* |
любое значение |
5 |
ровно 5 |
1,15,30 |
список значений |
9-18 |
диапазон |
*/10 |
шаг по всему диапазону поля |
1-20/3 |
шаг внутри диапазона |
Шаг отсчитывается от начала диапазона поля, а не от
нуля. Это то же правило, что и в системном cron, но оно часто
удивляет: */2 в поле дня месяца означает 1, 3, 5 … 31-е
число, а не чётные числа. Причина в том, что * — это просто
сокращение для полного диапазона поля, а диапазон дня начинается с
единицы. Для минут и часов, которые начинаются с нуля, разницы не
видно.
День месяца и день недели объединяются через ИЛИ, если
ограничены оба. Расписание 0 0 13 * 5 сработает
13-го числа или в пятницу — а не только в пятницу
13-го. Если одно из двух полей равно *, всё работает
обычным И. Правило досталось от системного cron и существует потому, что
живые расписания обычно звучат как «1-го числа и по понедельникам».
Примеры:
* * * * * каждую минуту
*/15 * * * * каждые 15 минут
0 * * * * в начале каждого часа
30 3 * * * ежедневно в 03:30
0 9-18 * * 1-5 каждый час с 9 до 18 по будням
0 4 1 * * 1-го числа каждого месяца в 04:00
0 2 */7 * * каждое 1, 8, 15, 22 и 29-е число в 02:00
MON, JAN и подобные надо писать числами.22-2 в
поле часа означает «с 22:00 до 02:00 через полночь». Системный cron
такое выражение отвергает — это расширение Melbis.5/10 в минутах означает «начиная с 5-й минуты, каждые 10» —
то есть 5, 15, 25 и так далее. Системный cron такое выражение тоже
отвергает.*/0 просто никогда не совпадёт, а не остановит
планировщик.Cron-модуль — обычный модульный скрипт с одним отличием: в параметрах модуля в IDE должен быть включён флаг «Модуль входной точки», иначе парсер откажется запускать его по прямому обращению.
function MELBIS_CRON_PRICE($mVars)
{
// Вывод в журнал, а не в браузер
header('Content-Type: text/plain; charset=utf-8');
// Только локальный вызов
MELBIS()->CronLocalOnly();
// ... работа ...
echo "Обработано товаров: $count\r\n";
return '';
}CronLocalOnly() пропускает запрос
только с 127.0.0.1 или ::1, а любой другой
обрывает с кодом 403 и текстом Only local allowed. Перед
выходом метод снимает блокировку компиляции кеша — если оборвать
выполнение обычным die, блокировка останется, и следующий
запрос к этому модулю будет впустую ждать несуществующую компиляцию.
Ставьте вызов первой строкой после заголовка, до любых запросов к базе: задача, доступная снаружи, — это открытая возможность запускать тяжёлую работу на вашем сервере сколько угодно раз.
На время отладки строку удобно закомментировать, чтобы открывать модуль из браузера, и вернуть перед выкаткой.
Всё, что модуль печатает через echo, попадает в журнал
задачи — поэтому осмысленный текстовый отчёт полезнее молчания.
Планировщик не мешает задаче запуститься второй раз,
пока не завершилась первая. Если задача с расписанием
*/5 * * * * работает семь минут, через пять минут она будет
запущена повторно, параллельно с ещё не закончившейся.
Это сделано намеренно: принудительная блокировка означала бы, что упавшая задача оставляет замок, который надо как-то снимать по таймауту, — а это источник сюрпризов худших, чем сама проблема. Ответственность за поведение задачи лежит на её авторе.
Рабочий приём — самоограничение по времени: задача обрабатывает данные порциями и выходит, не дожидаясь конца работы, если исчерпала отведённое время. Недоделанное подхватит следующий запуск.
$start = MELBIS()->RunTime();
foreach ( $rows as $row )
{
// ... обработка одной записи ...
$time = MELBIS()->RunTime($start);
echo 'ID: '.$row['id'].' Time: '.$time."\r\n";
if ( $time > 100 )
{
echo "Abort, time limit\r\n";
break;
}
}Порог выбирается заведомо меньшим, чем период расписания, — тогда
очередной запуск гарантированно застанет предыдущий завершённым. Заодно
это защищает от $mTimeout: задача сама решает, где
остановиться, вместо того чтобы быть оборванной на середине.
Имя журнала передаётся четвёртым аргументом CronAdd —
без пути и без расширения:
MELBIS()->CronAdd('*/5 * * * *', 'http://localhost/?mod=melbis_cron_price', 240, 'price');Записи уйдут в core/log/cron/price.log. Всё лишнее —
каталоги, расширение — отбрасывается: аргумент задаёт только имя файла
внутри core/log/cron/. Задачи могут писать в общий журнал
или каждая в свой — достаточно передать одно и то же имя или разные.
В журнал пишется строка с временем запуска, адресом, кодом ответа и временем выполнения, а следом — весь вывод модуля.
Платформа журналы не чистит. Размер не ограничен,
старые записи не удаляются: кто включил журнал, тот за ним и следит. В
стандартной установке этим занимается системный logrotate,
которому отдана вся папка core/log — он режет файлы
посуточно, сжимает и хранит неделю. На нестандартном развёртывании об
этом надо позаботиться самостоятельно.
Если задача не ответила или вернула код не из диапазона
2xx, планировщик сообщает об этом через штатный механизм
ошибок проекта с типом Cron Error — то
есть запись попадает в core/log/melbis/front.log рядом с
остальными ошибками платформы. Это происходит
независимо от того, задан журнал задачи или нет, так
что молча падающая задача не останется незамеченной.
Напомним, что запись в этот журнал включается наличием файла
error.save в корне проекта (см. «Журналы, ошибки и
метрики»). Без него сообщение никуда не попадёт.