Harmonogramy i cykliczne operacje

Narzędzia takie jak cron mają jedną wadę: nie są elastyczne. Możemy zaprogramować tylko niektóre cykliczności. Dziś poradzimy sobie z tym problemem projektując system harmonogramów w pełni obiektowo.

Klient czasem żąda, abyśmy cyklicznie wykonywali jakieś operacje. Kiedyś miałem z tym spory problem. Żądania bywały różne:

  • Chcę móc włączać banery od-do dla wybranych dni, miesięcy i lat
  • Chcę pokazywać reklamę co X dni ale nie w święta
  • Zadanie powinno być wykonywane w każdy ostatni piątek miesiąca
  • Muszę dostać powiadomienie na 2 dni przed urodzinami ludzi z mojej rodziny
  • Wiadomość o awarii ma przyjść na godzinę przed jej wystąpieniem

Popatrzmy na każde z tych wymagań. Jak to zapisać? Jak zaprogramować?

Normalny programista chce mieć jeden system do harmonogramów. Chcemy zaprogramować to tak, aby kolejnym razem po prostu użyć naszej wcześniejszej pracy.

Zaczniemy od wymyślenia struktury bazy danych. Opiszemy nasz zakres czasowy. Będą pewnie dni, tygodnie, miesiące, lata, zakresy, oznaczenia świąt itp. Wszystko, co wymyślimy co ma nam pozwolić elastycznie zapisać datę.

A potem klient powie: Chcę dostawać powiadomienie w moje urodziny.

Tą drogą poszedł cron. Zaprojektowano dobry i elastyczny (tak się wydawało) opis czasu. Ale spróbujmy w cronie zapisać regułę „ostatni piątek miesiąca” – nie da się.

Projektowanie obiektowe

Spróbujmy więc obiektowo zaprojektować tak elastyczny system, żeby można było go użyć zawsze.

Zamiast wymyślać uniwersalną strukturę na opis daty zacznijmy od interfejsu. Nazwijmy go wyrażeniem datowym. Jakie operacje musimy wykonać? Zobaczmy:

  • Sprawdzanie, czy dziś (lub inna data) spełnia wyrażenie – tak
  • Pobranie tekstu opisującego wyrażenie (np. dla admina) – tak
  • Pokazanie następnej pasującej daty – być może
  • Pokazanie wszystkich pasujących dat – czy na pewno tego potrzebujemy?

Nie ma tu ani słowa o dniach, tygodniach, miesiącach itp. Koncentrujemy się na tym co chcemy zrobić a nie jak.

Napiszmy nasz interfejs w PHP:

interface DateExpression {
   function isMatching(\DateTime $date);
   function __toString();
}

Bardzo ogólne, ale też bardzo przydatne. Na tym etapie nie obchodzi nas co jest w środku. Możemy już napisać testy jednostkowe dla tego interfejsu (nie piszę ich tu bo są proste).

Spróbujmy stworzyć kilka obiektów. Na początek chcemy wykonywać jakąś akcję codziennie:

class EveryDay implements DateExpression {

   public function isMatching(\DateTime $date) {
      return true;
   }

   public function __toString() {
      return 'codziennie';
   }
}

Po prostu dla każdego dnia zwracamy true. Podobnie możemy napisać regułę dla każdego piątku:

class EveryFriday implements DateExpression {

   public function isMatching(\DateTime $date) {
         return ($date->format('w') == 5);
   }

   public function __toString() {
      return 'piątek, weekendu początek :)';
   }
}

Idąc dalej tą drogą możemy stworzyć dowolne obiekty. Nasze wyrażenie datowe może połączyć się z bazą danych i sprawdzać urodziny znajomych, terminy wyborów czy inne wydarzenia. Możliwości są nieograniczone.

Złożone wyrażenia

Na razie mamy proste wyrażenia. Ale co jeśli chcemy napisać „pierwszy piątek miesiąca„? Czy dla każdego takiego przypadku musimy tworzyć nową klasę?

Nasze pierwsze rozwiązanie – stworzymy klasy sparametryzowane. Możemy mieć klasę, np DayInMonth:

class DayInMonth implements DateExpression {

   /**
    * @param $weekDay - day of the week (0 - Sunday etc.)
    * @param $weekInMonth - week in month - (1 - first, -1 - last etc.)
    */
   public function __construct($weekDay, $weekInMonth) { ... }

   public function isMatching(\DateTime $date) { ... }
   public function __toString() { ... }
}

Taka klasa pozwala nam tworzyć np. drugi wtorek miesiąca itp. Jest lepiej ale nadal tragicznie: co, gdy chcemy pierwszy i ostatni piątek? Szybko zagrzebiemy się w tworzenie klas-potworków z wieloma parametrami.

Skorzystajmy ze wzorca filtr. Możemy utworzyć proste klasy dla dnia w tygodniu oraz tygodnia w miesiącu:

class DayOfWeek implements DateExpression {
   public function __construct($dayOfWeek) { ... }
}

class WeekOfMonth implements DateExpression {
   public function __construct($weekOfMonth) { ... }
}

Teraz wystarczy jedna klasa łącząca dwie inne, np operacją AND:

class AndExpression implements DateExpression {
   public function __construct(
       \DateExpression $e1,
       \DateExpression $e2
   )
   { ... }

   /**
    * Sprawdzamy, czy obie daty pasują
    */
   public function isMatching(\DateTime $date) {
       return (
          $this->e1->isMatching($date)
          AND
          $this->e2->isMatching($date)
       );
   }

   public function __tostring() {
      return sprintf("%s i %s",
         $this->e1->__tostring(),
         $this->e2->__tostring()
      );
   }
}

Podobnie napiszemy klasę dla alternatywy (OR zamiast AND). Teraz możemy łatwo tworzyć złożone wyrażenia:

$firstWeek = new WeekOfMonth(1); // pierwszy tydzień w miesiącu
$lastWeek = new WeekOfMonth(-1); // i ostatni
$friday = new DayOfWeek(5); // piątek

// pierwszy lub ostatni tydzień
$firstOrLastWeek = new OrExpression($firstWeek, $lastWeek);

// j/w ale musi być piątek
$firstAndLastFriday = new AndExpression($friday, $firstOrLastWeek);

Ostatni obiekt wciąż jest pełnoprawnym wyrażeniem datowym. Więcej o tym wzorcu w moim artykule o wzorcu Filtr (Specyfikacja).

Jak użyć wyrażeń datowych

Samo dopasowanie daty nie daje nam gotowego systemu. Powinniśmy je jakoś połączyć ze zdarzeniami i harmonogramem. Ta część na szczęście jest prosta. Zdarzenie może przyjmować taką formę:

interface Event {
   function execute()
}

Teraz połączymy zdarzenia z wyrażeniami datowymi. Nowy obiekt, np. element harmonogramu będzie wyglądał tak:

class ScheduleElement {

    public function __construct(\Event $event, \DateExpression $date) {
        $this->event = $event;
        $this->date = $date;
    }

    public function executeIfOnTime() {
        if ($this->date->isMatching(new \DateTime('now')) {
            $this->event->execute();
        }
    }
}

Teraz jest już łatwo. Możemy mieć listę obiektów ScheduleElement i okresowo (np. raz dziennie) wywoływać na każdym metodę executeIfOnTime(). Same zdarzenia mogą być czymkolwiek – przypomnieniem, wysłaniem maila, wykonaniem programu – muszą tylko mieć metodę execute().

Co dalej?

Zdarzenia możemy uruchamiać na dwa sposoby:

  1. Harmonogram zwróci listę zdarzeń, które trzeba teraz uruchomić. W takim przypadku ScheduleElement miałby metody np. isOnTime() i getEvent(). Lista zdarzeń byłaby przygotowana a potem uruchomiona gdzieś na zewnątrz. Dla naszego harmonogramu zdarzenia są „jakimiś” obiektami. Nie muszą mieć nawet metody execute().
  2. Sposób pokazany wcześniej. Harmonogram sam uruchamia swoje zdarzenia.

Zdarzenia mogą się powtarzać. Ten problem też należy rozwiązać. Mamy co najmniej trzy sposoby:

  1. Nie pozwalać na powtórne użycie tego samego zdarzenia w innym ScheduleElement.
  2. Rejestrować zdarzenia, które już zostały dziś wyonane (gdy harmonogram sam uruchamia zdarzenia) i przy wywołaniu kolejnego sprawdzać, czy było już dziś wywołane.
  3. Zwracać listę unikalnych zdarzeń, np. dodać metodę getId() w każdym zdarzeniu.

W artykule nie uwzględniłem czasu (godzin itp) oraz zakresów dat. Dodanie tych funkcjonalności nie zmienia logiki samego rozwiązania.

Warto też poczytać artykuł Martina Fowlera o harmonogramach.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *