PHP

индекс
206,80

Персональный cron для проекта

Думаю, все знакомы с таким замечательным инструментом как cron. С его помощью мы с достаточной гибкостью можем конфигурировать время запуска необходимых нам скриптов наших проектов. Однако с ростом числа и сложности проектов данный инструмент обнаруживает некоторые свои недостатки:
  1. crontab нарушает целостность проекта, вбирая часть его логики (время запуска скриптов) в себя;
  2. редактирование crontab’a не возможно без выделения соответствующих прав.
Проблема сама по себе не нова, и имеет как минимум два типа решений.

В обоих случаях используется локальный (по отношению к проекту) аналог crontab’a и скрипт, реализующий логику работы cron’а (т.е. разбор crontab’а и запуск соответствующих скриптов). Разница в основном заключается в способе запуска скрипта cron’а.

Запуск на основе пользовательских запросов


В данном методе подразумевается, что у Вас в принципе нет доступа к crontab’у на сервере. В этом случае запуск cron-скрипта инициализируется по средствам пользовательских запросов, т.е. происходит каждый раз при обращении пользователя к странице. Для того, что бы избежать дублирующих запусков к crontab’у добавляется время последнего запуска проекта. Реализацию этой (или схожей) технологии можно увидеть как в персональных проектах, так и в различных фреймворках. Этот метод безусловно хорош с точки зрения массового использования, так как лишён необходимости правки системного crontab’а. Однако, при его использовании возникает другая проблема: как запустить скрипт (возможно «тяжёлый») во время отсутствия посетителей на сайте? В ответ на данный вопрос мне на ум приходят лишь ректальные методики (типа имитации запроса пользователя с другой машины, где есть cron), что делает данный подход на мой взгляд не применимым.

Запуск на основе крона


Второй же метод подразумевает наличие доступа к crontab’у. В этом случае для избежания недостатков, описанных в начале статьи, нам достаточно выделить в каждом проекте на сервере «точку входа» (скрипт/контроллер, реализующий логику cron’a для проекта) и добавить его ежеминутный вызов в системный crontab.

К слову сказать «гибрид» этих двух методов должен мог быть включён в ZF в качестве класса Zend_Scheduler, но работы, к сожалению, прикрыли.

Пример реализации


Напоследок хочу привести пример реализации cron-скрипта. Я постарался сделать код достаточно самодокументированным, так что надеюсь, что вопросов не возникнет. Вкратце, суть работы скрипта сводится к трём шагам:
  1. разбор строки crontab’а согласно регулярному выражению;
  2. преобразование шаблона времени запуска в список возможных значений;
  3. проверка совпадения текущего времени с критериями запуска скрипта.
Сразу хочу заметить, что не все стандарты формирования crontab'а учтены, ввиду элементарной человеческой лени.
<?php
/**
* Реализация крона
* @see unixhelp.ed.ac.uk/CGI/main-cgi?crontab+5
*/
class Cron {

  /**
   * CronTab
   * @var array
   */
  private $aCronTab;

  /**
   * Конструктор
   * @param string $sCronTabFile
   */
  public function __construct( $sCronTabFile ) {
    $aCronTab = file( $sCronTabFile );
    $this->aCronTab = $this->_parse( $aCronTab );
  }

  // Минимальные/максимальные значения для временных критериев
  const MINUTE_MIN = 0;
  const MINUTE_MAX = 59;
  const HOUR_MIN  = 0;
  const HOUR_MAX  = 23;
  const DAY_MIN  = 1;
  const DAY_MAX  = 31;
  const MONTH_MIN = 1;
  const MONTH_MAX = 12;
  const DOW_MIN  = 0;
  const DOW_MAX  = 7;

  /**
   * Разбор crontab'а
   * @param array $aCronTab
   * @return array
   */
  private function _parse( array $aCronTab ) {

    // Шаблон для разбора строки crontab'а
    $sPattern = '~^(?<minutes>[-0-9,/*]+)\s+(?<hours>[-0-9,/*]+)\s+' .
      '(?<days>[-0-9,/*]+)\s+' .
      '(?<months>[-0-9,/*]+|(-|Jan|Feb|Mar|Apr|May|Jul|Jun|Aug|Sep|Oct|Nov|Dec)+)\s+' .
      '(?<dows>[-0-9,/*]+|(-|Sun|Mon|Tue|Wen|Thu|Fri|Sat)+)\s*' .
      '(?<command>[^#]+)$~i';

    // Разбор строк crontab'а
    $aParsedCronTab = array();
    foreach( $aCronTab as $sJob ) {
      if( '#' !== $sJob[0] && preg_match( $sPattern, $sJob, $aJob ) ) {

        // Замена имён месяцов их номерами
        $aJob['months'] = str_replace(
          array('Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep',
            'Oct','Nov','Dec'),
          array(1,2,3,4,5,6,7,8,9,10,11,12),
          $aJob['months']
        );

        // Замена названий дней недели их номерами
        $aJob['dows'] = str_replace(
          array('Sun','Mon','Tue','Wed','Thu','Fri','Sat'),
          array(0,1,2,3,4,5,6),
          $aJob['dows']
        );

        // Преобразование времени запуска заданий в диапозон возможных значений
        $aTime['minutes'] = $this->_convertElement( $aJob['minutes'],
          self::MINUTE_MIN, self::MINUTE_MAX );
        $aTime['hours'] = $this->_convertElement( $aJob['hours'],
          self::HOUR_MIN, self::HOUR_MAX );
        $aTime['days'] = $this->_convertElement( $aJob['days'],
          self::DAY_MIN, self::DAY_MAX );
        $aTime['months'] = $this->_convertElement( $aJob['months'],
          self::MONTH_MIN, self::MONTH_MAX );
        $aTime['dows'] = $this->_convertElement( $aJob['dows'],
          self::DOW_MIN, self::DOW_MAX );

        $aParsedCronTab[] = array(
          'time' => $aTime,
          'command' => trim( $aJob['command'] )
        );
      }
    }

    return $aParsedCronTab;
  }

  /**
   * Преобразование времени запуска задания из формата crontab'а
   * в список возможных значений
   *
   * @param string $sElement
   * @param int $iMinValue Минимальное значение для элемента
   * @param int $iMaxValue Максимальное значение для элемента
   * @return array Список возможных значений элемента
   */
  private function _convertElement( $sElement, $iMinValue, $iMaxValue ) {

    // Инициализация переменных
    $aAvailableValues = array();

    // Шаблон для разбора элемента
    $sPattern = '~^((?<asterisk>\*)|((?<number>[0-9]{1,2})+(-(?<range>[0-9]{1,2}))?))' .
      '(/(?<step>[0-9]{1,2}))?$~i';

    // Разделение списка элементов (1,2,3)
    $aElements = explode( ',', $sElement );

    // Получение возможных значений для каждого элемента
    foreach( $aElements as $sElements ) {
      if( preg_match( $sPattern, $sElements, $aElement ) ) {

        // Элемент равен "*"
        if( !empty( $aElement['asterisk'] ) ) {
          $aValues = range(
            $iMinValue,
            $iMaxValue,
            ( !empty( $aElement['step'] ) ) ? (int)$aElement['step'] : 1
          );
          $aAvailableValues = array_merge( $aAvailableValues, $aValues );
        }

        // Элемен - диапозон значений
        elseif( !empty( $aElement['range'] ) ) {
          $aValues = range(
            $aElement['number'],
            $aElement['range'],
            ( !empty( $aElement['step'] ) ) ? (int)$aElement['step'] : 1
          );
          $aAvailableValues = array_merge( $aAvailableValues, $aValues );
        }

        // Элемент - обычное число
        else {
          $aAvailableValues = array_merge( $aAvailableValues,
            array( (int)$aElement['number'] ) );
        }
      }
    }

    return $aAvailableValues;
  }

  /**
   * Запуск задач из crontab'а
   */
  public function run() {

    // Максимальное количество дней в месяце и дней недели
    $iMaxDaysCount = self::DAY_MAX - self::DAY_MIN + 1;
    $iMaxDOWsCount = self::DOW_MAX - self::DOW_MIN + 1;

    // Получение текущего времени
    $aCurrentDate = getdate();

    // Перебор задач из crontab'а
    foreach( $this->aCronTab as $aJob ) {

      // Проверка времени запуска задачи
      $bRun = false;
      $aTaskDate = $aJob['time'];
      if( in_array( $aCurrentDate['minutes'], $aTaskDate['minutes'] )
        && in_array( $aCurrentDate['hours'], $aTaskDate['hours'] )
        && in_array( $aCurrentDate['mon'], $aTaskDate['months'] )
      ) {
        // Дни месяца и дни недели не заданы как '*'
        if( ( $iMaxDOWsCount !== count( $aTaskDate['dows'] ) )
          && ( $iMaxDaysCount !== count( $aTaskDate['days'] ) )
        ) {
          if( in_array( $aCurrentDate['mday'], $aTaskDate['days'] )
            || in_array( $aCurrentDate['wday'], $aTaskDate['dows'] )
          )
            $bRun = true;

        // Дни месяца или дни недели заданы как '*'
        } else {
          if( in_array( $aCurrentDate['mday'], $aTaskDate['days'] )
            && in_array( $aCurrentDate['wday'], $aTaskDate['dows'] )
          )
            $bRun = true;
        }
      }

      if( $bRun )
        $this->_runJob( $aJob['command'] );
    }

  }

  /**
   * Запуск задачи
   * @param string $sCommand Задача
   */
  private function _runJob( $sCommand ) {
    exec( "{$sCommand} &" );
  }
}


* This source code was highlighted with Source Code Highlighter.

Для использования класса достаточно создать скрипт, который будет скармливать классу содержимое crontab-файла Вашего проекта и добавить ежеминутный запуск этого скрипта в системный crontab.
_________
Текст подготовлен в ХабраРедакторе
–5
19 декабря 2009, 11:51
26

комментарии (18)

+6
kAIST #
эээ… не понял, зачем такой велосипед? Сейчас практически на любом shared хостинге есть доступ к cron.
Тем более, есть туча бесплатных сервисов которые делают get по расписанию, типа cronjob.ru/
–1
magicstream #
ну как бы бывают ситуации когда программеру(ну не все программеры работают на себя) нужно добавить новый скрипт в кронтаб, но соответсвующие права на сервере только у админа, который благополучным образом может просто забыть добавить или что то перепутать.

Вот вариант «Запуск на основе крона» отлично решает эту проблему. Программер просто добавит соответствующую строку в конфиг.

+3
kAIST #
админа, который благополучным образом может просто забыть добавить или что то перепутать.

гнать надо таких админов :)
–1
magicstream #
да, я только за :) но есть всегда «но!!!», вот и приходится как то ухищряться :)
0
krestjaninoff #
Представьте ситуацию (вполне кстати стандартную), когда разработка проекта ведётся не на боевом, а на dev-сервере. В этом случае при классическом подходе получаем как минимум две копии crontab'a, которые нужно актуализировать, что не есть удобно.

Про тучу бесплатных сервисов… я бы не стал в какой-либо мере доверять работу своего проекта кому-то на стороне.
0
kAIST #
По мне, так на dev сервере можно вообще обойтись без cron. Все равно удобнее ручками скрипты на проверку запускать. Зачем плодить сущности?
0
MrGrenade #
Еще может быть staging сервер — там лучше все настроить максимально приближенно к боевой обстановке, чтобы потом было меньше сюрпризов при запуске в продакшен, в том числе и запуск скриптов по расписанию. А если в crontab много правил, то нужно еще и следить за их актуальностью на разных машинах.
0
Vyazovoi #
Ну так или иначе вы все-таки доверяете работу своего проекта кому-то на стороне — хостеру например :D
0
biotech #
Большое спасибо за ссылку!
+5
intellinside #
> const DOW_MIN = 0;
> const DOW_MAX = 7;
8 дней в неделе? =)
+3
krestjaninoff #
Вы не поверите — да :) 0 и 7 — воскресенье, так уж устроен крон.
0
intellinside #
и вправду, век живи, век учись =)
wiki: «day of week (0-6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat»
–3
magicstream #
Можно немного улучшить вариант «Запуск на основе крона»

Пусть крон так же запускает самодельный контроллер, но уже скажем каждые 30 минут
1. в контроллере добавим бесконечный цикл(с sleep(1), один оборот в секунду), который будет обрабатывать конфиг.
2. контроллер перед запуском цикла должен проверять не запущен ли он уже, если запущен то хальт.

плюсы:
нет нужды читать конфиг каждую минуту
точность запуска до секунд
не будет дублированных запусков контроллера
+1
jiexaspb #
Вот это интересно. Пригодилось бы) Никто не хочет написать? :))
0
andreypaa #
Обычно на выполнение php скрипта дается всего 30 секунд.
0
kenga #
Это если он выполняется в среде веб-сервера. Если же запускать его в режиме cli, то такого ограничение нету.
0
krestjaninoff #
Имхо это излишнее усложнение
0
kenga #
Ну тогда можно и своего демона написать (=
Вот только php очень плох своими утечками памяти. К сожалению, для production такая вещь не подойдет.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.