Kalenderrechnung (Linux-Magazin, September 1998)

Ging es um Datumsberechnungen, war ich bislang ein unbekehrbarer Date::Manip-Jünger, so schön einfach zu bedienen schien mir Sullivan Becks Modul. Doch als ich neulich Steffen Beyers Erzeugnis Date::Calc in die Hände bekam, und feststellte, daß es ähnliche Funktionalität bietet und dabei viel, viel schneller läuft, ließ ich ab vom Gewohnten und gab mich voll diesem neuen Teufelszeug hin!

Installation

Date::Calc wird nach dem üblichen Verfahren installiert. Die aktuelle Version liegt auf dem CPAN unter CPAN/modules/by-module/Date/Date-Calc-4.1.tar.gz kostenlos zur Abholung bereit.

Die Date::Calc-Tour

Datumsangaben liegen in Date::Calc üblicherweise als Dreier-Array ($year, $month, $day) vor. Das heutige Datum liefert die Funktion Today() folgendermaßen:

    use Date::Calc qw(Today);
    ($year, $month, $day) = Today();

Hier zeigt sich, daß Date::Calc von sich aus noch keine Funktionen exportiert. So steht Today() erst zur Verfügung, nachdem es der Export-Liste, die der use-Anweisung anhängt, beiliegt. Das Jahr liegt heuer als ``1998'' vor, Monat und Tag entsprechen dem gesundem Menschenverstand, starten also beide bei 1.

... bald ist wieder Weihnachtszeit!

Um nun zum Beispiel die Tage von heute bis Weihnachten zu zählen, muß man keine kalendertechnischen Klimmzüge absolvieren und mit Schaltjahren und ähnlichem Unbill herumjonglieren, sondern bemüht einfach Delta_Days(), wie in Listing xmas.pl vorgestellt: Delta_Days() nimmt zwei Datumsangaben entgegen, also insgesamt sechs Skalare. Nachdem das heutige Datum mit Today() ermittelt ist, steckt xmas.pl den 24.12. des gleichen Jahres in den Array @xmas, übergibt ihn Delta_Days() und -- schwupps! -- schon liefert es die Anzahl der Tage zwischen den zwei Datumsangaben zurück.

Listing xmas.pl

    01 #!/usr/bin/perl -w
    02 ##################################################
    03 # Michael Schilli, 1998 (mschilli@perlmeister.com)
    04 ##################################################
    05 
    06 use Date::Calc qw(Delta_Days Today);
    07 
    08 ($year, $month, $day) = Today();
    09 @xmas = ($year, 12, 24);
    10 
    11 $days = Delta_Days($year, $month, $day, @xmas);
    12 print "Noch ", $days, " Tage bis Weihnachten.\n";

Wochentage und Datumsarithmetik

Zeigt man (zum Beispiel mit dem in dieser Reihe vorgestellten Paket Chart) aktuelle Graphiken an, stellt sich zuweilen das Problem, daß man die Wochentage der hinter einem liegenden Woche auf die X-Achse zaubern muß -- doch wo beginnen? Listing week.pl löst das Problem, indem es einen Array @days aufbaut, in dem es die Wochentags-Kürzel hintereinanderreiht. Da Date::Calc aus deutschen Landen stammt, bietet es eine vorbildliche Vielsprachen-Steuerung an, so daß week.pl, falls heute Donnerstag wäre, folgende Reihe ausgäbe:

    Fr Sa So Mo Di Mi Do

Zunächst setzt die Language()-Funktion die verwendete Sprache auf "Deutsch". Language() selbst verarbeitet aber keinen String als Parameter, sondern einen kleinen Integer, den kein Mensch kennt, außer der Funktion Decode_Language(), die den National-String in das interne Format umwandelt. Dann ermittelt week.pl das aktuelle Datum als Startwert und springt dann in eine Schleife über sieben Tage, in der es pro Durchgang um einen Tag zurückspringt. Mit Day_of_Week() ermittelt es jeweils die Nummer des aktuellen Wochentags, verwandelt diese mit Day_of_Week_to_Text() in einen Wochentags-String voller Länge (z.B. "Montag"), kürzt diesen auf zwei Buchstaben und fügt ihn schließlich am Anfang (!) des Arrays @days ein. Um pro Schleifendurchgang einen Tag zurückzusetzen, kommt die Funktion Add_Delta_YMD() zum Zug, die ein dreiteiliges Startdatum und ein ebenfalls dreiteiliges Delta aus Jahren, Monaten und Tagen erwartet. Im vorliegenden Fall führt der letzte Parameter den Wert -1, was einem Zeitsprung von einem Tag in die Vergangenheit entspricht.

Listing week.pl

    01 #!/usr/bin/perl -w
    02 ##################################################
    03 # Michael Schilli, 1998 (mschilli@perlmeister.com)
    04 ##################################################
    05 
    06 use Date::Calc qw(Today Decode_Language Language 
    07                   Day_of_Week Day_of_Week_to_Text   
    08                   Add_Delta_YMD);
    09 
    10 Language(Decode_Language("Deutsch"));
    11 
    12 @date = Today();
    13 
    14 foreach (1..7) {
    15     $day = Day_of_Week_to_Text(Day_of_Week(@date));
    16     unshift(@days, substr($day, 0, 2));
    17     @date = Add_Delta_YMD(@date, 0, 0, -1);
    18 }
    19 
    20 print "@days\n";

Tage der Woche im Monat

Die Funktion Day_of_Week() kalkuliert aus Jahr, Monat und Tag den Wochentag. Listing first.pl zeigt, wie sich die Monatsersten des Jahres 1998 berechnen lassen. Ohne explizit ausgewähltes Deutsch-Modul liefern Funktionen wie Month_to_Text(), die den Namen eines Monats, der als Zahl vorliegt, liefert, englische Wörter:

    Thursday, January 1st, 1998
    Sunday, February 1st, 1998
    ...
    Sunday, November 1st, 1998
    Tuesday, December 1st, 1998

Listing first.pl

    01 #!/usr/bin/perl -w
    02 ##################################################
    03 # Michael Schilli, 1998 (mschilli@perlmeister.com)
    04 ##################################################
    05 
    06 use Date::Calc qw(Today Day_of_Week 
    07                   Day_of_Week_to_Text Month_to_Text);
    08 $year = 1998;
    09 
    10 foreach $month (1..12) {
    11     $dow = Day_of_Week($year, $month, 1);
    12 
    13     printf "%s, %s 1st, %d\n",
    14            Day_of_Week_to_Text($dow),
    15            Month_to_Text($month), $year;
    16 }

``Laß uns regelmäßig jeden zweiten Freitag im Atzinger zusammensitzen und ein, zwei Augustiner trinken!'' -- wer hat diesen Satz nicht so oder so ähnlich schon ausgesprochen. Listing secfri.pl zeigt, wie sich das in die Tat umsetzen läßt: Ausgehend vom aktuellen Datum, spult das Skript zwölfmal jeweils einen Monat vor, um für den aktuellen Monat mittels der Funktion Nth_Weekday_of_Month_Year() das Datum des zweiten Freitags zu bestimmen. Den Freitag nimmt die Funktion dabei über den dritten Parameter als "5" entgegen, dies enspricht der internen Numerierung von Date::Calc die am Montag mit "1" startet. Der vierte Parameter gibt an der wievielte Freitag im Monat gemeint ist. Die Ausgabe ist verblüffend richtig:

    11.09.1998
    09.10.1998
    ...
    09.07.1999
    13.08.1999

Listing secfri.pl

    01 #!/usr/bin/perl -w
    02 ##################################################
    03 # Michael Schilli, 1998 (mschilli@perlmeister.com)
    04 ##################################################
    05 
    06 use Date::Calc qw(Today Nth_Weekday_of_Month_Year 
    07                   Add_Delta_YMD);
    08 
    09 my ($year, $month, $day) = Today();
    10     
    11 foreach (1..12) {    # Zwölf Monate lang
    12 
    13                      # Zum nächsten Monat vorspulen
    14     ($year, $month, $day) = 
    15          Add_Delta_YMD($year, $month, $day, 0, 1, 0);
    16 
    17                      # 5: Freitag 2: 2. Freitag   
    18     ($y, $m, $d) = Nth_Weekday_of_Month_Year($year, 
    19                                      $month, 5, 2);
    20     printf "%02d.%02d.%d\n", $d, $m, $y;
    21 }

Logfile-Analyse des kleinen Mannes

Zum Abschluß noch ein Beispiel, das aufzeigt, wie einfach sich mit dem Kalendermodul die Log-Dateien von Webservern analysieren lassen: Eine Zeile des Access-Logs sieht üblicherweise in etwa so aus:

    kunde.com - - [30/Jul/1998:02:31:06 -0700] "GET /index.html HTTP/1.0" 200 7304

Um festzustellen, wieviele Hits letzte Woche auf die einzelnen Wochentage verteilt eingegangen sind, iteriert man über die Zeilen der Datei, stellt fest, ob das angegebene Datum (in eckigen Klammern) in die letzte Woche fiel, und wenn, erhöht man einen Zähler für den entsprechenden Wochentag.

Ob ein angegebes Datum in einem festgelegten Zeitrahmen liegt, läßt sich dadurch bestimmen, daß man sämtliche Angaben in Tage umrechnet, die seit einem Zeitpunkt weit in der Vergangenheit vergangen sind. Die Funktion Date_to_Days() macht Nägel mit Köpfen und liefert zu einem vorgegeben Datum (Tag-Monat-Jahr) die Anzahl der Tage, die seit dem Urknall des Universums, nein, Spaß beiseite, seit dem 1.1. des Jahres 1 anno Domini vergangen sind. So ergibt sich für den 01.08.1998 etwa ein Wert von 729602 Tagen. Liegen Datumsangaben in solchen Zahlen vor, kann man Vergleiche einfach numerisch mit < und > durchführen.

In Listing log.pl drehen wir ein bißchen an der Schwierigkeitsschraube, bitte anschnallen und die Arme im Fahrzeug lassen!

Die zentrale Datenstruktur dort ist der Array @result, der als Elemente Referenzen auf kleine 2er-Listen enthält, die als erstes Element die Abkürzung des Wochentags und als zweites einen Zähler führen, der anzeigt, wieviele Hits am entsprechenden Wochentag schon eingegangen sind. @result führt genau 7 Einträge. Falls heute Donnerstag ist, steht dort für jeden Tag von Freitag letzter Woche bis heute jeweils ein Element, das den Wochentag mitsamt dem zugehörigen Zähler enthält.

Um diese Struktur anfangs aufzubauen, könnten wir wieder auf die Logik nach Listing week.pl zurückgreifen, aber nach dem Motto Öfter mal was Neues! legen wir diesmal in @days eine Liste mit Mo bis So ab, bestimmen das heutige Datum (@date) mitsamt der zugehörigen Wochentagszahl ($dow) und springen (Zeile 11) in eine Schleife mit 7 Durchgängen, die jeweils den richtigen Wochentag aus @days holt und mitsamt eines auf 0 vorbesetzten Zählers in @result einfügt. Die Modulo-Logik $_ % 7 setzt dabei den Index des aktuell in @days gelesenen Elements wieder an den Anfang zurück, falls der Zähler über das Ende des Arrays hinausschießt.

Die Zeilen 15-17 legen ein Zeitfenster für die vergangene Woche fest, $window_from und $window_to sind jeweils die zwischen Anno Tobak und dem Anfang bzw. Ende des Zeitfensters vergangenen Tage. Kommt also ein ebenfalls in diesem Format dargestelltes Datum daher, läßt sich leicht feststellen, ob es innerhalb oder außerhalb des Fensters liegt.

Zeile 20 öffnet die Log-Datei zum Lesen, der reguläre Ausdruck in Zeile 25 fördert für jede durchlaufene Zeile das Zugriffsdatum zutage. Nun hat Date::Calc zwar die Funktion Parse_Date(), die Datumsstrings im Format

    Thu Jul  9 19:18:28 PDT 1998

importiert, doch für eine Funktion, die das in der Log-Datei verwendete Format 18/Aug/1997:20:58:42 -0700 beherrscht, suchte ich vergebens -- egal, log.pl macht das zu Fuß. Da der Monat in dem oben einsehbaren dreibuchstabigen Kürzel vorliegt, muß er mit der Funktion Decode_Month() aus Date::Calc in eine Zahl von 1-12 umgewandelt werden, bevor Date_to_Days() zum Einsatz kommt und den Tageswert berechnet. Die Zeilen 33 und 34 stellen dann fest, ob das gefundene Datum im eingestellten Bereich liegt. Ist dies der Fall, greift Zeile 36 auf das @result-Element am Index $days - $windows_from zu (entspricht dem richtigen Wochentag in @result) und erhöht den Zähler (zweites Element der Unter-Liste) um Eins.

Bleibt nur noch, in den Zeilen 43-46 den Array @result zu durchlaufen und das Ergebnis auszugeben -- man stelle sich vor, was man daraus mit David Bonners Chart-Paket zaubern könnte!

Listing log.pl

    01 #!/usr/bin/perl
    02 ##################################################
    03 # Michael Schilli, 1998 (mschilli@perlmeister.com)
    04 ##################################################
    05 
    06 use Date::Calc qw(Today Day_of_Week Decode_Month
    07                   Add_Delta_YMD Date_to_Days);
    08 
    09 @days = qw/Mo Di Mi Do Fr Sa So/;
    10 
    11 @today = Today();              # Heutiges Datum
    12 $dow   = Day_of_Week(@today);  # Wochentag (Nummer)
    13 
    14 foreach ($dow..$dow+6) {
    15     push(@result, [$days[$_ % 7], 0]);
    16 }
    17                      # 'Fenster' für Woche
    18 @six_days_back = Add_Delta_YMD(@today, 0, 0, -6);
    19 $window_from   = Date_to_Days(@six_days_back);
    20 $window_to     = Date_to_Days(@today);
    21 
    22                      # Access-Log-Datei bearbeiten
    23 open(LOGFILE, "<access.log") || 
    24     die "Cannot open file access.log";
    25 
    26 while(<LOGFILE>) {   # Datum steht in [...] im
    27                      # Format dd/Mon/yyyy:hh:mm:ss
    28     if(m#\[(\d+)/(\w+)/(\d+)#) {
    29         ($day, $monthname, $year) = ($1, $2, $3);
    30         $month = Decode_Month($monthname);
    31         @date = ($year, $month, $day);
    32           
    33                      # Prüfen, ob Datum in 
    34                      # letzter Woche liegt
    35         $days = Date_to_Days(@date);
    36         if($days >= $window_from && 
    37            $days <= $window_to) {
    38                      # Wochentag-Zähler erhöhen
    39             $result[$days-$window_from]->[1]++; 
    40         }
    41     }
    42 }
    43 close(LOGFILE);
    44 
    45                      # Wochen-Report ausgeben
    46 foreach $result (@result) {
    47     my ($weekday, $count) = @$result;
    48     printf "$weekday: %d\n", $count;
    49 }

Wie jedem guten Modul liegt auch Date::Calc ausführliche Dokumentation bei, die nach der Installation mit perldoc Date::Calc zum Vorschein kommt. Viel Spaß damit!

Hurra, die Zwölf!

Ein ganzes Jahr Perl-Snapshot ist um, meine lieben Leser: Zeit, ein bißchen in sich zu gehen und über die Zukunft nachzudenken. Hat's Euch gefallen? Was gibt's zu verbessern? Was interessiert Euch besonders? Rafft's Euch auf, schreibt's, und sagt's mir, ob Ihr noch weitere G'schichterln aus Perl-Land lesen wollt. Wenn ich genug Fanpost und Themenvorschläge kriege, häng' ich glatt noch ein Jahr dran, soviel Spaß hat's gemacht! Laßt was hören!

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.