Статьи

Тестирование Кварцевых Cron Выражений

Объявление сложных выражений Cron все еще доставляет мне некоторые головные боли, особенно когда используются более сложные конструкции. В конце концов, вы можете сказать, когда следующий триггер сработает «0 0 17 L-3W 6-9? *»? Поскольку триггеры часто предназначены для запуска далеко в будущем, желательно предварительно их протестировать и убедиться, что они действительно сработают, когда мы думаем, что они сработают.

Кварцевый планировщик (я тестирую версию 2.1.6) не предоставляет прямой поддержки для этого, но легко создать некоторую простую функцию на основе существующих API, а именно метод CronExpression.getNextValidTimeAfter () . Наша цель — определить метод, который будет возвращать следующие N запланированных выполнений для данного выражения Cron. Мы не можем запросить всепоскольку некоторые триггеры (в том числе вышеописанные) не имеют конечной даты, повторяющейся бесконечно. Мы можем зависеть только от вышеупомянутого getNextValidTimeAfter (), который принимает дату в качестве аргумента и возвращает ближайшее время срабатывания T 1 после этой даты. Поэтому, если мы хотим найти второе запланированное выполнение, мы должны спросить о следующем выполнении после первого (T 1 ). И так далее. Давайте поместим это в код:

def findTriggerTimesIterative(expr: CronExpression, from: Date = new Date, max: Int = 100): Seq[Date] = {
    val times = mutable.Buffer[Date]()
    var next = expr getNextValidTimeAfter from
    while (next != null && times.size < max) {
        times += next
        next = expr getNextValidTimeAfter next
    }
    times
}

Если нет следующего времени срабатывания (например, триггер предполагается запустить только в 2012 году, и мы спрашиваем о времени срабатывания после 1 января 2013 года), возвращается ноль. Немного краш-тестирования:

findTriggerTimesRecursive(new CronExpression("0 0 17 L-3W 6-9 ? *")) foreach println

выходы:

Thu Jun 27 17:00:00 CEST 2013
Mon Jul 29 17:00:00 CEST 2013
Wed Aug 28 17:00:00 CEST 2013
Fri Sep 27 17:00:00 CEST 2013
Fri Jun 27 17:00:00 CEST 2014
Mon Jul 28 17:00:00 CEST 2014
Thu Aug 28 17:00:00 CEST 2014
Fri Sep 26 17:00:00 CEST 2014
Fri Jun 26 17:00:00 CEST 2015
Tue Jul 28 17:00:00 CEST 2015
Fri Aug 28 17:00:00 CEST 2015
Mon Sep 28 17:00:00 CEST 2015
Mon Jun 27 17:00:00 CEST 2016
...

Надеюсь, что значение нашего сложного выражения Cron теперь стало более понятным: ближайший день недели (W) за три дня до конца месяца (L-3) в период с июня по сентябрь (6-9) в 17:00:00 (0 0 17) , Теперь я начал немного экспериментировать с различными реализациями, чтобы найти наиболее элегантную и подходящую для этой довольно простой задачи. Сначала я заметил, что проблема не итеративная, а рекурсивная: поиск следующих 100 времен выполнения эквивалентен нахождению первого выполнения и нахождению 99 оставшихся выполнений после первого:

def findTriggerTimesRecursive(expr: CronExpression, from: Date = new Date, max: Int = 100): List[Date] =
    expr getNextValidTimeAfter from match {
        case null => Nil
        case next =>
            if (max > 0)
                next :: findTriggerTimesRecursive(expr, next, max - 1)
            else
                Nil
    }

Похоже, что реализация намного проще: нет совпадений — вернуть пустой список (Nil). Матч найден — верните его в начале следующего матча, если мы не собрали достаточно дат. Есть одна проблема с этой реализацией, хотя она не хвостовая рекурсия . Очень часто это можно изменить введением второй функции и накоплением промежуточных результатов в аргументах:

def findTriggerTimesTailRecursive(expr: CronExpression, from: Date = new Date, max: Int = 100) = {
 
    @tailrec def accum(curFrom: Date, curMax: Int, acc: List[Date]): List[Date] = {
        expr getNextValidTimeAfter curFrom match {
            case null => acc
            case next =>
                if (curMax > 0)
                    accum(next, curMax - 1, next :: acc)
                else
                    acc
        }
    }
 
    accum(from, max, Nil)
}

Немного сложнее, но, по крайней мере, StackOverflowError не разбудит нас среди ночи. Кстати, я только что заметил, что IntelliJ IDEA не только показывает значки, идентифицирующие рекурсию (см. Рядом с номером строки), но также использует различные значки, когда используется оптимизация хвостового вызова (!):

Поэтому я подумал, что это лучшее, что я могу получить, когда пришла другая идея. Прежде всего, искусственный максимальный предел (по умолчанию равный 100) казался неловким. Кроме того, зачем накапливать все результаты, если мы можем вычислять их на лету, один за другим? Это когда я понял, что мне не нужны Seq или List, мне нужен итератор [Date]!

class TimeIterator(expr: CronExpression, from: Date = new Date) extends Iterator[Date] {
    private var cur = expr getNextValidTimeAfter from
 
    def hasNext = cur != null
 
    def next() = if (hasNext) {
        val toReturn = cur
        cur = expr getNextValidTimeAfter cur
        toReturn
    } else {
        throw new NoSuchElementException
    }
}

Я потратил некоторое время на то, чтобы уменьшить значение if-true в одну строку и избежать промежуточной переменной toReturn. Это возможно, но для ясности (и пощадить глаза) Я не буду раскрывать его * . Но почему итератор, как известно, менее гибкий и приятный в использовании? Ну, во-первых, это позволяет нам лениво генерировать время следующего запуска, поэтому мы не платим за то, что не используем. Также промежуточные результаты нигде не хранятся, поэтому мы можем сэкономить память. И поскольку все, что работает для последовательностей, работает и для итераторов, мы можем легко работать с итераторами в Scala, например, печатать ( брать ) первые 10 дат:

new TimeIterator(expr) take 10 foreach println

Заманчиво сделать небольшой тест, сравнивающий различные реализации (здесь, используя суппорт ):

object FindTriggerTimesBenchmark extends App {
    Runner.main(classOf[FindTriggerTimesBenchmark], Array("--trials", "1"))
}
 
class FindTriggerTimesBenchmark extends SimpleBenchmark {
 
    val expr = new CronExpression("0 0 17 L-3W 6-9 ? *")
 
    def timeIterative(reps: Int) {
        for (i <- 1 to reps) {
            findTriggerTimesIterative(expr)
        }
    }
 
    def timeRecursive(reps: Int) {
        for (i <- 1 to reps) {
            findTriggerTimesRecursive(expr)
        }
    }
 
    def timeTailRecursive(reps: Int) {
        for (i <- 1 to reps) {
            findTriggerTimesTailRecursive(expr)
        }
    }
 
    def timeUsedIterator(reps: Int) {
        for (i <- 1 to reps) {
            (new TimeIterator(expr) take 100).toList
        }
    }
 
    def timeNotUsedIterator(reps: Int) {
        for (i <- 1 to reps) {
            new TimeIterator(expr)
        }
    }
}

Похоже, что изменения реализации оказывают незначительное влияние на время, так как большая часть ЦП предположительно сгорает внутри getNextValidTimeAfter ().

Что мы узнали сегодня?

  • не думайте слишком много о производительности, если у вас действительно нет проблем. Стремитесь к лучшему дизайну и простейшей реализации.
  • Подумайте много о структурах данных, которые вы хотите использовать для представления вашей проблемы и решения. В этой (тривиальной на первый взгляд) проблеме Iterator (лениво оцениваемый, возможно, бесконечный поток элементов) оказался лучшим подходом

* ОК, вот как. Подсказка: назначение имеет тип Единицы и кортеж (Дата, Единица) здесь:

def next() = if (hasNext)
    (cur, cur = expr getNextValidTimeAfter cur)._1
else
    throw new NoSuchElementException