Woche 2

# Tag 14

#
Ein paar längere Beispiele

Widmen wir den letzten Tag der Woche ein paar weiteren Beispielen. Sie werden in diesem Kapitel nicht viel Neues lernen, auch Übungen und Quiz gibt es heute nicht. Betrachten Sie es als kurze Verschnaufpause, in der Sie sich vollständige Perl-Skripts ganz in Ruhe ansehen können. Insgesamt hat das Buch drei dieser Beispiellektionen (immer am Ende der Wochen), die Ihr neues Wissen festigen.

Heute analysieren wir zwei längere Perl-Skripts:

- Ein Adreßbuch-Skript, das die Namen und Adressen in dem Format einer einfachen Textdatenbank speichert. Mit unserem Perl-Skript können Sie in der Datenbank mit Hilfe einfacher logischer UND- und ODER-Tests nach Adressen suchen.
- Einen Analysierer für die Log-Dateien von Websites. Dieses Skript verarbeitet das Standardformat für Webserver-Log-Dateien (auch common log format genannt) und erzeugt Statistiken aller Art über die Nutzung der Website.

## Ein Adreßbuch zum Durchsuchen (adressen.pl)

Unser erstes Skript heute besteht aus zwei Teilen:

- Einer einfachen Adreßbuch-Datei, die die Namen, Adressen und Telefonnummern enthält
- Einem Perl-Skript, das Sie auffordert, Suchbegriffe einzugeben, und dann alle gefundenen Adressen ausgibt

Dieses Skript nutzt so ziemlich alles, was Sie diese Woche gelernt haben: Skalar- und Hash-Daten, Bedingungen, Schleifen, Ein- und Ausgabe, Subroutinen, lokale Variablen und Mustervergleich. Es gibt sogar ab und zu einen Funktionsaufruf, um die Sache interessanter zu machen. Aber jetzt lassen Sie uns ohne weitere Umschweife beginnen.

### Wie es funktioniert

Das Skript adressen.pl wird mit einem einzigen Argument aufgerufen: der Adreßdatei namens adressen.txt. Geben Sie den Aufruf in der Befehlszeile ein, wie Sie es bereits bei anderen Perl-Skripten gemacht haben:

    % adressen.pl adressen.txt

Wenn Sie MacPerl verwenden, speichern Sie das Skript adressen.pl als Droplet und ziehen (Drag) Sie dann die Datei adressen.txt auf das Symbol adressen.pl, wo Sie diese ablegen (Drop).

Als erstes fragt das Adreßbuch-Skript Sie nach dem gesuchten Begriff:

    Wonach soll gesucht werden? Johnson

Das Suchmuster, das Sie an `adressen.pl` übergeben, kann verschiedene Formen haben:

- einzelne Wörter, wie zum Beispiel `Johnson` im obigen Beispiel
- mehrere Wörter (`John Maggie Alice`). Alle Adressen, die einem dieser Wörter entsprechen, werden ausgegeben (gleicht einer ODER-Suche).
- mehrere Wörter getrennt durch `AND` oder `OR` (in Groß- oder Kleinbuchstaben). Boolesche Suchläufe verhalten sich wie logische Operatoren in Perl und werden von links nach rechts getestet. (Denken Sie daran, dass ein AND-Suchlauf nur sinnvoll ist, wenn der Vergleich innerhalb einer Adresse erfolgt; es erfolgt kein Vergleich über mehrere Adressen, wie das bei OR-Suchläufen der Fall ist).
- mehrere Wörter in Anführungszeichen (»`dies das`«) werden als ein einziges Suchmuster betrachtet. In diesem Fall sind Leerzeichen von Bedeutung.
- Pattern-Matching-Zeichen werden akzeptiert und als reguläre Ausdrücke verarbeitet (lassen Sie die `//` um die Muster fort).

So liefert zum Beispiel die Suche nach dem Namen `Johnson` in meiner Beispieldatei adressen.txt folgende Ausgabe zurück:

    *********************
    Paul Johnson
    212 345 9492
    234 33rd St Apt 12C, NY, NY 10023
    http://www.foo.org/users/don/paul.html
    *********************
    Alice Johnson
    (502) 348 2387
    (502) 348 2341
    *********************
    Mary Johnson
    (408) 342 0999
    (408) 323 2342
    mj@asd.net
    http://www.mjproductions.com
    *********************

> Alle Namen, Adressen, Telefonnummern und Webseiten dieser Beispiel-Adreßdatei sind natürlich frei erfunden. Irgendwelche Übereinstimmungen dieser Daten mit lebenden oder toten Personen sind reiner Zufall.

### Die Adreßdatei

Das Herzstück eines Adreßbuches (das Sie selbst erstellen müssen, wenn Sie dieses Skript nutzen wollen) ist eine Datei von Adressen in einem speziellen Format, das von Perl verstanden wird. Sie können es auch als einfache textbasierte Datenbank betrachten und dann Perl-Skripts schreiben, die diese Datenbank um Datensätze (Adressen) ergänzen oder auch Datensätze löschen.

Das Format der Adreßbuch-Datei in seiner generischen Form sieht folgendermaßen aus:

    Name: Name
    Telefon: Nummer
    Fax: Nummer
    Adresse: Adresse
    EMail: email-Adresse
    URL: Web URL
    ---

zum Beispiel:

    Name: Paul Johnson
    Telefon: 212 345 9492
    Adresse: 234 33rd St Apt 12C, NY, NY 10023
    URL: http://www.foo.org/users/don/paul.html
    ---

Jeder Datensatz besteht aus einer Reihe von Feldern (Name, Telefon, Fax, Adresse, E-Mail und URL, die jedoch nicht alle erforderlich sind) und endet mit drei Gedankenstrichen. Die Feldnamen (`Name`, `Telefon`, `URL` usw.) sind von ihren Werten durch einen Doppelpunkt und ein Leerzeichen getrennt. Die Werte müssen kein spezielles Format aufweisen. Sie können weitere Feldnamen in die Datenbank aufnehmen, und die Suchschlüssel werden diese zusätzlichen Felder auch durchsuchen, aber in der Ausgabe werden diese Felder übergangen. (Wenn Sie wirklich ein zusätzliches Feld benötigen, zum Beispiel für eine Handy-Nummer, können Sie das Skript jederzeit ändern. Perl macht es Ihnen in dieser Hinsicht leicht.)

Sie können beliebig viele Adressen in die Datei adressen.txt aufnehmen. Doch je größer das Adreßbuch, um so länger die Zeitdauer, bis übereinstimmende Datensätze gefunden werden, da für jede Adresse die Datei von vorn bis hinten durchgegangen wird. Solange Sie jedoch keine vier bis fünf Millionen Freunde haben, werden Sie von Perls Anstrengungen nichts mitbekommen.

### Das Skript

Das Skript adressen.pl liest die Datei adressen.txt Adresse für Adresse ein und gleicht dann das Suchmuster mit jeder Adresse ab. Der oberste Teil des Skripts ist eine `while`-Schleife, die diese Aufgabe übernimmt, wobei wiederum fünf weitere Subroutinen abgearbeitet werden, um die komplexeren Teile des Skripts zu bewältigen.

Lassen Sie uns ganz oben im Skript beginnen. Auf oberster Ebene definieren wir drei globale Variablen:

- `%rec` - enthält den aktuellen Adreßdatensatz, der nach Feldnamen indiziert ist.
- `$search` - nimmt das Suchmuster auf, das Sie bei der Eingabeaufforderung eingeben.
- `$bigmatch` - gibt an, ob in der Adreßdatei ein Datensatz gefunden wurde, der dem Suchmuster entspricht (es gibt auch lokale Variablen für den Fall, dass der aktuelle Datensatz übereinstimmt, doch dazu gleich mehr).

Der erste Schritt in dem äußeren Teil des Skripts besteht darin, zur Eingabe des Suchbegriffs aufzufordern und diesen dann in `$search` abzulegen:

    $search = &getpattern();        # fragt nach dem Suchmuster

Die Subroutine `&getpattern()` besteht aus den grundlegenden Schritten »Eingabe lesen / zurechtschneiden / Ergebnis zurückliefern«, die Ihnen schon viel zu oft in diesem Buch begegnet sind:

    sub getpattern {
        my $in = '';  # Eingabe
        print 'Wonach soll gesucht werden? ';
        chomp($in = <STDIN>);
        return $in;
    }

Schritt zwei im äußeren Teil des Skripts ist eine endlose `while`-Schleife, die einen Datensatz einliest, das Suchmuster verarbeitet und bei Übereinstimmung den Datensatz ausgibt:

    while () {              # durchsucht die Adressdatei
        %rec = &read_addr();
        if (%rec) {         # Datensatz gefunden
            &perform_search($search, %rec);
        } else {            # Ende der Adressdatei, Aufräumarbeiten
            if (!$bigmatch) {
                print "Nichts gefunden.\n";
            } else { print "*********************\n"; }
            last;           # verlassen, wir sind fertig
        }
    }

Innerhalb der `while`-Schleife rufen wir `&read_addr()` auf, um einen Datensatz einzulesen, und wenn ein Datensatz gefunden wurde, durchsuchen wir ihn mit Hilfe der Subroutine `&perform_search()`. Sind wir am Ende der Adreßdatei angekommen und die Variable `$bigmatch` ist gleich `0`, heißt das, dass keine Übereinstimmungen gefunden wurden, und wir informieren den Anwender darüber. Am Ende der Adreßdatei rufen wir jedoch auf alle Fälle `last` auf, um aus der Schleife auszusteigen und das Skript zu beenden.

#### Adressen einlesen

Mit der Subroutine `&read_addr()` wird ein Adreßdatensatz eingelesen. In Listing 14.1 sehen Sie den Inhalt von `&read_addr()`.

Listing 14.1: Die Subroutine &read_addr()

    1:  sub read_addr {
    2:      my %curr = ();              # aktueller Datensatz
    3:      my $key = '';               # temp. Schlüssel
    4:      my $val = '';               # temp. Wert
    5:
    6:      while (<>) {
    7:          chomp;
    8:          if ($_ ne '---') {      # Datensatz-Trennzeichen
    9:              ($key, $val) = split(/: /,$_,2);
    10:             $curr{$key} = $val;
    11:         }
    12:         else { last; }
    13:     }
    14:     return %curr;
    15: }

In früheren Beispielen mit `while`-Schleife, die `<>` verwendeten, haben wir die gesamte Datei auf einmal eingelesen und verarbeitet. Bei dieser `while`-Schleife ist das etwas anders; sie liest nur Teile der Datei ein und hört auf, wenn Sie auf ein Datensatz- Trennzeichen stößt (in diesem Fall der String '`---`'). Das nächste Mal, wenn die Subroutine `&read_addr()` aufgerufen wird, fährt die `while`-Schleife dort fort, wo sie in der Adreßdatei gestoppt hat. Perl hat keine Schwierigkeiten mit diesem Stop-and-Go der Eingabe und ist damit besonders geeignet zum Einlesen und Verarbeiten von Teilabschnitten einer Datei, wie sie hier vorliegen.

Konkret ausgedrückt, liest diese Subroutine eine Zeile ein. Lautet die Zeile nicht '`---`', so befindet sich die Zeile mitten in einem Datensatz und besteht aus dem Feldnamen (`Name:`, `Telefon:`, usw.) und dem Wert. Der Aufruf der `split`-Funktion erfolgt in Zeile 9. Beachten Sie das zweite Argument am Ende von `split`; damit wird angezeigt, dass es in jeder Datensatzzeile nur zwei Teile gibt. Mit dem Feldnamen `($key`) und dem Wert (`$val`) können Sie beginnen, den Hash für diese Adresse einzurichten.

Handelt es sich bei der eingelesenen Zeile um die Datensatzende-Marke, springt die `if`-Anweisung in Zeile 8 direkt zum `else`-Teil in Zeile 12, wo der `last`-Befehl die Schleife verläßt. Das Ergebnis dieser Subroutine ist ein Hash, der alle Zeilen der Adresse nach Feldnamen indiziert enthält.

#### Die Suche durchführen

Inzwischen sind Sie in der Ausführung des Skripts soweit fortgeschritten, dass Sie einen Suchbegriff in der Variablen `$search` und eine Adresse in der Variablen `$rec` gespeichert haben. Der nächste Schritt besteht jetzt darin, zum nächsten Teil unser großen `while`-Schleife zu Beginn des Skripts überzugehen. Das heißt, wenn `%rec` definiert ist (also eine Adresse existiert), rufen wir die Subroutine `&perform_search()` auf, um konkret festzustellen, ob der Suchbegriff in `$search` auch mit der Adresse in `&rec` übereinstimmt.

Die Subroutine `&perform_search()` wird in Listing 14.2 gegeben:

Listing 14.2: Die Subroutine &perform_search()

    1:  sub perform_search {
    2:      my ($str, %rec) = @_;
    3:      my $matched = 0;            # Übereinstimmung
    4:      my $i = 0;                  # Position innerhalb des Suchmusters
    5:      my $thing = '';             # temporäres Wort
    6:
    7:      my @things = $str =~ /("[^"]+"|\S+)/g;  # in Suchelemente aufspalten
    8:
    9:      while ($i <= $#things) {
    10:         $thing = $things[$i];   # Suchelement, UND oder ODER
    11:         if ($thing eq 'ODER' || $thing eq 'oder') { # OR Fall
    12:             if (!$matched) {    # noch keine Übereinstimmung,
                                        # nächstes Element
    13:                 $matched = &isitthere($things[$i+1], %rec);
    14:             }
    15:             $i += 2;            # ODER überspringen und nächstes Element
    16:         }
    17:         elsif ($thing eq 'UND' || $thing eq 'und') { # UND-Fall
    18:             if ($matched) {
                    # Übereinstimmung gefunden, andere Seite prüfen
    19:                 $matched = &isitthere($things[$i+1], %rec);
    20:             }
    21:             $i += 2;            # UND überspringen und nächstes Element
    22:         }
    23:         elsif (!$matched) {     # noch keine Übereinstimmung
    24:             $matched = &isitthere($thing, %rec);
    25:             $i++;               # weiter!
    26:         }
    27:         else { $i++; }          # $match wurde gefunden, weiter zum
                                        # nächsten Element
    28:     }
    29:
    30:     if ($matched) {             # alle Schlüssel durchgearbeitet, gab es
                                        # eine Übereinstimmung?
    31:         $bigmatch = 1;          # Ja, wir haben etwas gefunden
    32:         print_addr(%rec);       # Datensatz ausgeben
    33:     }
    34: }

Diese Subroutine ist sehr lang und etwas komplexer. Aber keine Bange, sie ist nicht so kompliziert, wie es den Anschein hat. Fangen wir oben an; dort übernimmt die Subroutine zwei Argumente: den Suchbegriff und den Adressen-Hash: Beachten Sie, dass diese Werte in globalen Variablen gespeichert sind. Deshalb gibt es an sich auch keinen Grund, sie als Argumente an die Subroutine zu übergeben. Wir hätten auf diese globalen Variablen einfach im Rumpf der Subroutine zugreifen können. Unsere Strategie, die Daten als Argumente zu übergeben, führt allerdings dazu, dass die Subroutine in sich abgeschlossener ist, da nur die Daten bearbeitet werden, die explizit übergeben werden. Sie könnten zum Beispiel diese Subroutine kopieren und in ein anderes Suchskript einfügen, ohne einen Gedanken an die Umbenennung von Variablen verschwenden zu müssen.

Die erste richtige Operation in dieser Subroutine erfolgt in Zeile 7, in der wir den Suchbegriff in seine Bestandteile aufsplitten. Denken Sie daran, dass der Suchbegriff in vielen Formen auftreten kann, einschließlich verschachtelter Strings in Anführungszeichen, UNDs und ODERs oder als eine Liste von Schlüsselwörtern. In Zeile 7 werden die einzelnen Elemente aus dem Suchbegriff extrahiert, und anschließend werden diese »Suchelemente« gemeinsam in dem Array `@things` gespeichert. Dabei sollten Sie beachten, dass der reguläre Ausdruck mit der Option `g` endet und in einem Listenkontext ausgewertet wird - das soll heißen, dass die `@things`-Liste alle möglichen von den Klammern eingefangenen Übereinstimmungen enthält. Womit stimmt dieses besondere Muster überein? Es gibt zwei Gruppen von Mustern, getrennt durch das Alternationszeichen (`|`). Die erste Gruppe lautet:

    "[^"]+"

Dabei handelt es sich, wenn Sie an Ihre Muster zurückdenken, um ein doppeltes Anführungszeichen gefolgt von einem oder mehreren Zeichen, die kein doppeltes Anführungszeichen sind, und einem abschließenden Anführungszeichen. Dieses Muster vergleicht mehrteilige Strings im Suchbegriff (in Anführungszeichen), wie zum Beispiel »`John Smith`« oder »`San Francisco`«, und behandelt sie als einen Suchbegriff.

Der zweite Teil des Musters besteht lediglich aus einem oder mehreren Zeichen, die keine Whitespace-Zeichen sind (`\S`). Dieser Teil des Musters vergleicht alle einfachen Wörter, wie zum Beispiel UND oder ODER, oder einzelne Schlüsselwörter. Von diesen beiden Mustern wird ein langer, komplexer Suchbegriff wie »`San Jose`« `ODER` »`San Francisco`« `UND` `John` in folgende Liste aufgesplittet (»`San Jose`«, `ODER`, »`San Francisco`«, `UND`, `John`).

Nachdem nun alle unsere Suchteile in einer Liste stehen, besteht die eigentliche Arbeit darin, diese Liste durchzugehen, wenn nötig, nach der Adresse zu suchen und die logischen Ausdrücke abzuarbeiten. All dies wird in der großen `while`-Schleife ausgeführt, die in Zeile 9 beginnt. Die Schleife verwendet eine Platzhaltervariable `$i` zum Festhalten der aktuellen Position in dem Muster und geht das Muster bis zum Ende durch. Innerhalb der `while`-Schleife prüft die Variable `$matched` ständig, ob ein bestimmter Teil des Musters mit dem Datensatz übereinstimmt. Begonnen wird mit einer `0`, falsch, für keine Übereinstimmung.

Innerhalb der `while`-Schleife starten wir in Zeile 10, wo wir der Variablen `$things` auf den aktuellen Teil des zu untersuchenden Musters zuweisen, einfach um uns bei weiteren Zugriffen Tipparbeit zu ersparen. Anschließend folgen vier größere Tests:

- Ist der aktuelle Teil ein ODER, befinden wir uns mitten zwischen zwei Tests, von denen einer bereits abgeschlossen wurde und entweder wahr oder falsch (je nach Wert von `$matched`) zurückgeliefert hat. Ist `$matched` wahr, wurde für die linke Seite eine Übereinstimmung gefunden, und es gibt keinen Grund, die Untersuchung auf die rechte Seite auszudehnen (genau, es handelt sich um ein Ausschluß-ODER). Gibt es für die linke Seite keine Übereinstimmung, ist der Wert der `$matched`-Variablen 0, und wir müssen die rechte Seite ebenfalls überprüfen. Dieser Schritt erfolgt in Zeile 13. Dort wird die Subroutine `&isitthere()` aufgerufen, die die eigentliche Suche nach dem Suchmuster durchführt und der als Argumente die rechte Seite des ODER (der nächste Teil in dem `@things`-Array) und der Datensatz selbst (`%rec`) übergeben werden.
  Unabhängig davon, ob es eine Übereinstimmung gegeben hat oder nicht, verarbeitet dieser Test das ODER und das Muster rechts davon. Deshalb können wir in dem `@things`-Array zwei Elemente vorrücken. Diese Aufgabe übernimmt Zeile 15, die den Zähler `$i` um zwei inkrementiert.
- Ist der aktuelle Teil ein UND, begeben wir uns zum Test in Zeile 17. Dieser Abschnitt ähnelt bis auf eine Ausnahme dem Abschnitt mit ODER: Der Ausschluß erfolgt auf anderem Wege. Erinnern wir uns: Gegeben sei ein Test für `x` `UND` `y`. Wenn `x` falsch ist, ist der gesamte Ausdruck falsch. Wenn aber `x` wahr ist, muss immer noch `y` überprüft werden, um zu sehen, ob diese Variable ebenfalls wahr ist. Und so funktioniert auch dieser Test. Wenn die Variable `$matched` wahr ergibt, dann ist damit die linke Seite des UND-Ausdrucks wahr, und wir rufen `&isitthere()` auf, um die rechte Seite zu testen. Andernfalls machen wir einfach gar nichts und überspringen die nächsten zwei Elemente, sowohl das UND als auch die rechte Seite des UND (`$i+=2`, Zeile 21), um danach fortzufahren.
- In Zeile 23 haben wir UND und ODER bereits abgehandelt, deshalb muss das gesuchte Teil ein konkreter Suchbegriff sein. Dabei kann es sich um einen einzelnen Suchbegriff oder um mehrere Suchbegriffe vereint zu einem String handeln. Was es ist, spielt letztlich keine Rolle, da beide Fälle einzeln untersucht werden können. Wir müssen jedoch nur dann nach dem Suchbegriff suchen, wenn wir noch keine Übereinstimmung gefunden haben. (Zur Erinnerung: Mehrere Suchläufe werden als ODER-Tests behandelt. Wenn also eine Übereinstimmung gefunden wurde, war es das schon.) In Zeile 23 rufen wir die Suchroutine `&isitthere()` daher nur dann auf, wenn wir bisher keine Übereinstimmung gefunden haben.
- Der letzte Fall tritt ein, wenn in `$thing` ein richtiger Suchschlüssel steht und `$matched` wahr ist. Wir müssen hier nichts machen, da bereits eine Übereinstimmung gefunden wurde. Deshalb muss nur die Position in dem Muster um eins inkrementiert und die Schleife erneut gestartet werden.

Wenn Sie mir soweit folgen konnten, haben Sie den schwierigsten Teil des Skripts bereits hinter sich. Sollten Sie noch Schwierigkeiten haben, versuchen Sie es einfach mal selbst mit verschiedenen Suchmustern: mit einzelnen Suchelementen, Elementen, die durch UND oder ODER getrennt sind, und Mustern mit mehreren Suchschlüsseln. Kontrollieren Sie die Werte von `$i` und `$matched` beim Schleifendurchlauf (wenn Sie mit dem Perl-Debugger bereits umgehen können, ist dies recht einfach, aber Sie können es auch auf Papier von Hand machen).

Was also passiert in der mysteriösen `&isitthere()`-Subroutine, die in der großen `while`-Schleife immer wieder aufgerufen wird? Nachdem Suchbegriff und Datensatz gegeben sind, findet hier die eigentliche Suche statt. Ich werde darauf verzichten, Ihnen hier den Inhalt von `&isitthere()` vorzustellen, Sie finden diese Subroutine in voller Länge als Teil des Gesamtcodes in Listing 14.3. Ich möchte Sie jedoch darauf hinweisen, dass die Subroutine lediglich den Inhalt des Adressen-Hash durchläuft und mit Hilfe eines regulären Ausdrucks den Suchbegriff mit jeder Zeile vergleicht. Bei einer Übereinstimmung liefert die Subroutine 1 zurück, bei keiner Übereinstimmung 0.

Im letzten Teil der Subroutine sind alle Teile des Suchbegriffs verarbeitet, einige Suchläufe wurden durchgeführt, und wir wissen, ob der Suchbegriff mit dem Datensatz übereinstimmt oder nicht. Die Zeilen 30 bis 33 testen, ob eine Übereinstimmung gefunden wurde. Wenn ja, setzen wir die Variable `$bigmatch` (mindestens eine Adresse wurde als Übereinstimmung gefunden) und rufen `&print_addr()` auf, um diese Adresse auszudrucken.

#### Den Datensatz ausdrucken

Von hier an wird es einfach. Die letzte Subroutine in der Datei wird nur aufgerufen, wenn eine Übereinstimmung gefunden wurde. Die Subroutine `&print_addr()` durchläuft einfach den Datensatz-Hash und gibt die Werte aus, um den Adreßdatensatz anzuzeigen.

    sub print_addr {
        my %record = @_;
        print "*********************\n";
        foreach my $key (qw(Name Telefon Fax Adresse EMail URL)) {
            if (defined($record{$key})) {
                print "$record{$key}\n";
            }
        }
    }

Interessant an dieser Subroutine ist allein die Liste der Schlüssel in der `foreach`- Schleife. Ich habe die Schlüssel in dieser Reihenfolge aufgeführt (und mit der Funktion `qw` in Anführungszeichen gesetzt), so dass die Ausgabe in einer bestimmten Reihenfolge erfolgt. Da Hashes in einer intern festgelegten Reihenfolge gespeichert werden, können wir nur so dafür sorgen, dass die Daten in der korrekten Reihenfolge ausgegeben werden. Nebenbei wird dadurch auch erreicht, dass nur die Zeilen ausgegeben werden, die tatsächlich verfügbar waren - der Aufruf von `defined` innerhalb der `foreach`-Schleife stellt sicher, dass nur die Felder ausgegeben werden, die auch tatsächlich im Datensatz existieren.

### Der Code

Alles OK? Nein? Manchmal ist es eine Hilfe, wenn man den ganzen Code auf einmal sieht. Listing 14.3 enthält den vollständigen Code für adressen.pl. Wenn Sie den Quelltext von der Website zu diesem Buch (unter `http://www.typerl.com`) heruntergeladen haben, werden Sie feststellen, dass der Code wesentlich mehr Kommentare enthält, um deutlich zu machen, was gerade passiert.

> Wie ich bereits gestern in dem Abschnitt zu `my`-Variablen angedeutet habe, können einige Perl-Versionen Schwierigkeiten mit der Verwendung der `my`-Variablen im Skript und den `foreach`-Schleifen haben. Sie können das Problem umgehen, indem Sie die `foreach`-Variable einfach wie folgt vor ihrem Gebrauch deklarieren:

> `my $key = 0;`

> `foreach $key (qw(Name Telefon Fax Adresse EMail URL)) { ...`

Listing 14.3: Der Code für adressen.pl

    1:  #!/usr/bin/perl -w
    2:  use strict;
    3:
    4:  my $bigmatch = 0;               # wurde etwas gefunden?
    5:  my %rec = ();                   # zu durchsuchender Datensatz
    6:  my $search = '';                # Suchmuster
    7:
    8:  $search = &getpattern();        # Eingabeaufforderung für das Muster
    9:
    10: while () {                      # durchsucht die Adressdatei
    11:     %rec = &read_addr();
    12:     if (%rec) {         # Datensatz gefunden
    13:         &perform_search($search, %rec);
    14:     } else {            # Ende der Adressdatei, Aufräumarbeiten
    15:         if (!$bigmatch) {
    16:             print "Nichts gefunden.\n";
    17:         } else { print "*********************\n"; }
    18:         last;           # Verlassen, wir sind fertig
    19:     }
    20: }
    21:
    22: sub getpattern {
    23:     my $in = '';  # Eingabe
    24:     print 'Wonach soll gesucht werden? ';
    25:     chomp($in = <STDIN>);
    26:     return $in;
    27: }
    28:
    29: sub read_addr {
    30:     my %curr = ();              # aktueller Datensatz
    31:     my $key = '';               # temp. Schlüssel
    32:     my $val = '';               # temp. Wert
    33:
    34:     while (<>) {                # bricht bei EOF ab
    35:         chomp;
    36:         if ($_ ne '---') {      # Datensatz-Trennzeichen
    37:             ($key, $val) = split(/: /,$_,2);
    38:             $curr{$key} = $val;
    39:         }
    40:         else { last; }
    41:     }
    42:     return %curr;
    43: }
    44:
    45: sub perform_search {
    46:     my ($str, %rec) = @_;
    47:     my $matched = 0;            # gesamte Übereinstimmung
    48:     my $i = 0;                  # Position innerhalb des Suchmusters
    49:     my $thing = '';             # temporäres Wort
    50:
    51:     my @things = $str =~ /("[^"]+"|\S+)/g;  # in Suchelemente aufspalten
    52:
    53:     while ($i <= $#things) {
    54:         $thing = $things[$i];   # Suchelement, UND oder ODER
    55:         if ($thing eq 'ODER' || $thing eq 'oder') { # ODER-Fall
    56:             if (!$matched) {    # noch keine Übereinstimmung, nächstes
                                        # Element
    57:                 $matched = &isitthere($things[$i+1], %rec);
    58:             }
    59:             $i += 2;            # ODER überspringen und nächstes Element
    60:         }
    61:         elsif ($thing eq 'UND' || $thing eq 'und') { # UND-Fall
    62:             if ($matched) {     # Übereinstimmung gefunden, andere Seite
                                        # prüfen
    63:                 $matched = &isitthere($things[$i+1], %rec);
    64:             }
    65:             $i += 2;            # UND überspringen und nächstes Element
    66:         }
    67:         elsif (!$matched) {     # noch keine Übereinstimmung
    68:             $matched = &isitthere($thing, %rec);
    69:             $i++;               # weiter!
    70:         }
    71:         else { $i++; }          # $match wurde gefunden, weiter zum
                                        # nächsten Element
    72:     }
    73:
    74:     if ($matched) {             # alle Schlüssel durchgearbeitet, gab es
                                        # eine Übereinstimmung?
    75:         $bigmatch = 1;  # Ja, wir haben etwas gefunden
    76:         print_addr(%rec);       # Datensatz ausgeben
    77:     }
    78: }
    79:
    80: sub isitthere {                 # einfacher Test
    81:     my ($pat, %rec) = @_;
    82:     foreach my $line (values %rec) {
    83:         if ($line =~ /$pat/) {
    84:             return 1;
    85:         }
    86:     }
    87:     return 0;
    88: }
    89:
    90: sub print_addr {
    91:     my %record = @_;
    92:     print "*********************\n";
    93:     foreach my $key (qw(Name Telefon Fax Adresse EMail URL)) {
    94:         if (defined($record{$key})) {
    95:             print "$record{$key}\n";
    96:         }
    97:     }
    98: }

## Ein Prozessor für Log-Dateien von Websites (weblog.pl)

Das zweite Beispielskript übernimmt eine Protokolldatei, wie Sie von Webservern erzeugt wird, und erstellt aus den darin enthaltenen Daten eine Statistik. Die meisten Webserver legen Dateien dieser Art an, mit denen sie unter anderem verfolgen, wie viele Besucher (»Hits«) es für eine Website gab, welche Dateien angefordert wurden und von welchen Sites die Anfragen kamen.

Im Web gibt es bereits viele Programme zur Analyse von Protokolldateien (einschließlich der Programme, die üblicherweise für Ihren Webserver zur Verfügung stehen). Deshalb ist dieses Beispiel auch nicht gerade neu. Die Statistiken, die damit erstellt werden, sind relativ einfach. Sie können dieses Skript jedoch ohne weiteres umschreiben, so dass es jede beliebige von Ihnen gewünschte Information mit aufnimmt. Es ist eine gute Ausgangsbasis zum Verarbeiten von Webprotokollen beziehungsweise ein gutes Muster, wenn Protokolldateien anderer Programme zu verarbeiten sind.

### Funktionsweise

Das Skript weblog.pl wird mit einem Argument, der Protokolldatei, aufgerufen. Auf vielen Webservern werden diese Dateien `access_log` genannt und folgen dem sogenannten common log-Format. Das Skript arbeitet erst einmal eine Weile vor sich hin. Dabei gibt es die Datumsstempel der bearbeiteten Protokolle aus, damit Sie erkennen, dass das Skript noch arbeitet. Anschließend werden einige Ergebnisse ausgegeben. Sehen Sie im folgenden, wie eine Ausgabe aussehen kann (das untenstehende Beispiel ist von den Protokollen meines eigenen Webservers `www.lne.com`):

    % weblog.pl access_log
    Log-Dateien verarbeiten....
    Verarbeite 09/Apr/1998
    Verarbeite 10/Apr/1998
    Verarbeite 11/Apr/1998
    Verarbeite 12/Apr/1998
    Auswertung der Log-Datei:
    Gesamtzahl der Treffer: 55789
    Gesamtzahl der fehlgeschlagenen Treffer: 1803 (3.23%)
    (erfolgreiche) HTML-Dateien: 18264 (33.83%)
    Anzahl der Hosts: 5911
    Anzahl der Domänen: 2121
    Die beliebtesten Dateien:
      /Web/index.html (2456 Treffer)
      /lemay/index.html (1711 Treffer)
      /Web/Title.gif (1685 Treffer)
      /Web/HTML3.2/3.2thm.gif (1669 Treffer)
      /Web/JavaProf/javaprof_thm.gif (1662 Treffer)
    Die beliebtesten Hosts:
      202.185.174.4 (487 Treffer)
      vader.integrinautics.com (440 Treffer)
      linea15.secsa.podernet.com.mx (437 Treffer)
      lobby.itmin.com (284 Treffer)
      pyx.net (256 Treffer)
    Die beliebtesten Domänen:
      mindspring.com (3160 Treffer)
      aol.com (1808 Treffer)
      uu.net (792 Treffer)
      grid.net (684 Treffer)
      compuserve.com (565 Treffer)

Die Ausgabe hier zeigt, um Platz zu sparen, nur die ersten fünf Dateien, Hosts und Domänen. Sie können das Skript allerdings so konfigurieren, dass es eine beliebige Anzahl dieser Statistiken ausgibt.

Der Unterschied zwischen einem Host und einer Domäne mag vielleicht nicht direkt ersichtlich sein. Ein Host ist der vollständige Hostname des Systems, das auf den Webserver zugegriffen hat, und kann dynamisch zugewiesene Adressen und Proxy- Server mit umfassen. So unterscheidet sich der Host `dialup124.servers.foo.com` vom Host `dialup567.servers.foo.com`. Die Domäne andererseits bezeichnet eine größere Gruppe von mehreren Hosts und besteht in der Regel aus zwei oder drei Teilen. So ist `foo.com` ein Domäne, ebenso wie `aol.com` oder `demon.co.uk`. Die Domänen-Listings neigen dazu, einzelne Host-Einträge in größere Gruppen zusammenzufassen - alle Hosts im Wirkungsbereich von `aol.com` erscheinen in der Domänenliste als Hit von `aol.com`.

Beachten Sie, dass es sich bei einem einfachen Hit um eine HTML-Seite, ein Bild, ein eingereichtes Formular oder eine andere beliebige Datei handeln kann. Es gibt normalerweise wesentlich mehr Hits als tatsächliche Zugriffe auf eine Seite (page views). Dieses Skript macht den Unterschied deutlich, indem HTML-Anfragen getrennt von der Gesamtzahl der Hits gezählt werden.

### Wie sieht ein Webprotokoll aus

Da das Skript weblog.pl Webprotokolle verarbeitet, ist es von Vorteil, wenn man weiß, wie diese Protokolldateien aussehen. Webprotokolle speichern einen Treffer pro Zeile und jede Zeile in dem sogenannten allgemeinen Protokollformat (allgemein, da das Format mehreren Webservern gemeinsam ist). Die meisten Webserver erzeugen ihre Protokolldateien in diesem Format beziehungsweise können entsprechend konfiguriert werden (viele Server verwenden eine erweiterte Form des allgemeinen Protokollformats, das mehr Informationen enthält). Eine Zeile einer Protokolldatei im allgemeinen Protokollformat kann folgendermaßen aussehen (diese Zeile steht aus drucktechnischen Gründen in zwei Zeilen, in Wirklichkeit steht sie jedoch nur in einer Zeile):

    proxy2bh.powerup.com.au - - [03/Apr/1998:00:09:02 -0800]
    "GET /lemay/ HTTP/1.0" 200 4621

Die einzelnen Elemente jeder Zeile der Protokolldatei sind:

- Der Hostname, der auf den Server zugreift (hier `proxy2bh.powerup.com.au`).
- Der Benutzername der Person, die auf die Seite zugreift, ermittelt durch `ident` (ein Unix-Programm zum Identifizieren von Benutzern) oder durch den Benutzer Ihrer Site selbst bekanntgemacht. Diese zwei Teile erscheinen normalerweise als zwei Bindestriche (`- -`), wenn der Benutzername nicht ermittelt werden kann.
- Datum und Zeit des Abrufs in eckigen Klammern.
- Innerhalb von Anführungszeichen die Aktion, die den Hit ausgelöst hat (in der Regel eine Webserver-Aktion): `GET` steht für das Anfordern einer Datei oder das Abrufen eines Formulars, `POST` für das Einreichen eines Formulars auf anderem Wege und `HEAD` für das Auslesen von Header-Informationen zu einer Datei.
- Nach der Aktion folgt der angeforderte Dateiname (oder das Verzeichnis), hier `/lemay/`.
- Die Versionsnummer des Protokolls, hier `HTTP/1.0`.
- Der Rückgabecode für den Treffer: 200 steht für erfolgreich, 404 für »nicht gefunden« und so weiter.
- Die Anzahl der übertragenen Bytes.

Natürlich sind nicht all diese Elemente einer Protokolldatei von Interesse für ein Skript zum Aufbau einer Statistik, und viele werden Sie erst verstehen, wenn Sie wissen, wie Webserver funktionieren. Ein paar jedoch, wie der Host, das Datum, der Dateiname und der Rückgabecode können extrahiert und für jede Zeile der Datei verarbeitet werden.

### Das Skript erstellen

Der logische Ablauf dieses Skripts ist leichter zu verfolgen als für das Skript adressen.pl. Es gibt eigentlich nur zwei größere Schritte - die Verarbeitung des Protokolls und die Erzeugung der Statistik. Dabei werden wir von einer Reihe von Subroutinen unterstützt.

Um genau zu sein, der gesamte Code für dieses Skript ist in Subroutinen untergebracht. Der Rumpf des Codes besteht aus einer Reihe von globalen Variablen und dem Aufruf von zwei Subroutinen: `&process_log()` und `&print_results()`.

Die globalen Variablen dienen dazu, die verschiedenen statistischen Daten und sonstigen Informationen über Teile der Protokolldatei zu speichern. Da viele dieser statistischen Daten Hashes sind, wäre die Verwendung von lokalen Variablen und das Weiterreichen der Daten zu kompliziert. In diesem Fall ist die Verwaltung der Variablen einfacher, wenn sie globaler Art sind. Zu den globalen Daten, die wir verfolgen, gehören:

- Die Anzahl der Treffer, Anzahl der mißlungenen Treffer und Anzahl der Treffer für HTML-Seiten.
- Ein Hash, um die verschiedenen Hostnamen und die Häufigkeit der Hosts in dem Protokoll zu speichern.
- Ein Hash, der dasselbe für die verschiedenen Dateien in dem Protokoll macht.

Darüber hinaus gibt es noch zwei weitere globale Variablen:

- Die Variable `$topthings` speichert eine Zahl, die angibt, wie viele Einträge Sie für die »beliebtesten« Teile der Statistik ausgeben wollen. In meinem Beispiel habe ich den Wert für `$topthings` auf 5 gesetzt und damit eine nette kurze Ausgabe erzeugt. Wenn Sie den Wert auf 20 setzen, werden die ersten 20 Dateien, Hosts und Domänen ausgegeben.
- Die Variable `$default` sollte auf die Standard-HTML-Seite Ihres Webservers gesetzt werden. Meistens heißt diese `index.html` oder `Home.html`. Dies ist die Datei, die als Hauptdatei für ein Verzeichnis dient, wenn der Benutzer keine bestimmte Datei anfordert. Normalerweise lautet der Name `index.html`.

Diese zwei Variablen legen fest, wie sich das Skript selbst verhält. Wir hätten diese Variablen auch tief im Innern des Programms verbergen können. Doch dadurch, dass wir sie hier direkt am Anfang aufgeführt haben, können Sie oder alle anderen, die das Skript nutzen, das Gesamtverhalten des Skripts ändern, ohne nach den zu ändernden Variablen lange suchen zu müssen. Diese Verfahrensweise gehört zum »guten Programmierstil«, unabhängig davon, welche Programmiersprache Sie verwenden.

#### Das Protokoll verarbeiten

Der erste Teil des weblog.pl-Skripts besteht aus der Subroutine `&process_log()`, die die einzelnen Zeilen der Protokolldatei durchläuft und die statistischen Daten aus der Zeile speichert. Ich werde Ihnen nicht jede Zeile dieser Subroutine erläutern, aber ich zeige Ihnen die wichtigsten Teile. Den vollständigen Code können Sie in Listing 14.7 am Ende dieses Kapitels einsehen.

Das Kernstück der Subroutine `&process_log()` ist eine weitere `while (<>)`-Schleife, um die Zeilen der Eingabe einzeln einzulesen. Im Gegensatz zu adressen.pl liest dieses Skript die Datei von Anfang bis Ende ein.

Für die Verarbeitung der Zeile splitten wir sie zuerst in ihre Bestandteile und speichern diese Teile in einem Hash, dessen Schlüssel der Teilename ist (`'site'`, `'file'` und so weiter). Für das Aufsplitten gibt es eine separate Subroutine namens `&splitline()`, die in Listing 14.4 zu sehen ist.

Listing 14.4: Die Subroutine &splitline()

    1:  sub splitline {
    2:      my $in = $_[0];
    3:      my %line = ();
    4:      if ($in =~ /^([^\s]+)\s           # Site
    5:                 ([\w-]+\s[\w-]+)\s     # Benutzer
    6:                 \[([^\]]+)\]\s         # Datum
    7:                  \"(\w+)\s             # Protokoll
    8:                 (\/[^\s]*)\s           # Datei
    9:                 ([^"]+)\"\s            # HTTP-Version
    10:                (\d{3})\s              # Rückgabe-Code
    11:                ([\d-]+)               # übertragene Bytes
    12:     /x) {
    13:        $line{'site'} = $1;
    14:        $line{'date'} = $3;
    15:        $line{'file'} = $5;
    16:        $line{'code'} = $7;
    17:        return %line;
    18:     } else { return (); }
    19: }

Das erste, was Ihnen bei dieser Subroutine wahrscheinlich ins Auge fällt, ist der nicht enden wollende reguläre Ausdruck in der Mitte von Zeile 4 bis Zeile 11. Er ist so häßlich, dass er sechs Zeilen belegt! Und Kommentare erforderlich macht! Dieser reguläre Ausdruck hat die Form erweiterter regulärer Ausdrücke. Ich habe diese Ausdrücke bereits in dem Abschnitt »Vertiefung« in Kapitel 5, »Mit Hashes arbeiten«, eingehend beschrieben. Hier eine kurze Zusammenfassung: Angenommen Sie haben einen besonders ekligen regulären Ausdruck wie den in diesem Beispiel (aus drucktechnischen Gründen steht er auf zwei Zeilen, da er nicht in eine Zeile paßt!):

    if ($in =~ /^([^\s]+)\s([\w-]+\s[\w-]+)\s\[([^\]]+)\]\s\"(\w+)
    \(\/[^\s]*)\s([^"]+)\"\s(\d{3})\s([\d-]+)/)

Sehr wahrscheinlich brauchen Sie entweder eine unendliche Geduld oder sehr starke Beruhigungsmittel, um diesen Ausdruck aufzuschlüsseln und zu verstehen. Und das Debuggen dieses Ausdrucks ist auch nicht sehr lustig. Wenn Sie jedoch an das Ende des Ausdrucks die Option `/x` setzen (wie hier in Zeile 12), können Sie den regulären Ausdruck aufsplitten und auf verschiedene Zeilen verteilen, die Sie außerdem noch mit Kommentaren versehen können. Alle Leerzeichen darin werden ignoriert. Wenn Sie im Text eine Übereinstimmung auf Leerzeichen suchen wollen, müssen Sie `\s` verwenden. Die Option `/x` erleichtert lediglich das Lesen und Debuggen des regulären Ausdrucks.

Unser regulärer Ausdruck geht von dem allgemeinen Protokollformat (common log format) aus, das ich bereits oben beschrieben habe:

- Zeile 4 ermittelt den Namen der Site (Host). Die Site steht immer am Anfang der Zeile und besteht aus einer Reihe von Zeichen (keine Whitespace-Zeichen) gefolgt von einem Leerzeichen (in diesem Fall `\s`, damit sich das erweiterte Muster anwenden läßt).
- Zeile 5 prüft die Benutzerfelder (zwei). Die Benutzer bestehen aus einem oder mehreren alphanumerischen Zeichen oder einem Gedankenstrich, getrennt durch und gefolgt von einem Whitespace-Zeichen. Beachten Sie die Gedankenstriche innerhalb der Zeichenklassen; in der `\w`-Klasse sind sie nicht mit eingeschlossen.
- Zeile 6 ermittelt das Datum. Dabei handelt es sich um ein oder mehrere Zeichen oder Whitespaces zwischen eckigen Klammern (`[]`).
- Zeile 7 ermittelt das Protokoll (`GET`, `HEAD` usw.). Der String beginnt mit einem Anführungszeichen gefolgt von einem oder mehreren Zeichen (das schließende Anführungszeichen steht nach der HTTP-Version in Zeile 9).
- Zeile 8 ermittelt die Datei. Diese Information beginnt immer mit einem Slash (`/`) gefolgt von einer 0 oder mehreren anderen Zeichen und endet mit einem Whitespace-Zeichen.
- Zeile 9 ermittelt die HTTP-Version, die sich in den übriggebliebenen Zeichen vor dem abschließenden Anführungszeichen verbirgt.
- Zeile 10 ermittelt den Rückgabecode, der immer drei Ziffern umfaßt, gefolgt von einem Whitespace-Zeichen (es wäre weniger speziell, hier nur `\d+` zu verwenden, aber so habe ich eine Gelegenheit, Ihnen die Verwendung des Musters `{3}` zu zeigen).
- Zeile 11 beendet die Prüfung mit der Anzahl der übertragenen Bytes. Dabei kann es sich um eine beliebige Anzahl von Ziffern handeln. Wurden keine Bytes übertragen, weil zum Beispiel der Treffer ein Fehler war, besteht dieses Feld aus einem Gedankenstrich. Auch diesen findet das Muster.

Jedes Element dieses regulären Ausdrucks wird in einem geklammerten Ausdruck (und einer Übereinstimmungsvariablen) gespeichert, wobei die zusätzlichen Klammern oder Anführungszeichen entfernt werden. Sobald das Pattern Matching beendet ist, können wir die verschiedenen übereinstimmenden Teile in einem Hash ablegen. Beachten Sie, dass wir nur die Hälfte der Übereinstimmungen in dem Hash ablegen. Wir müssen nur das abspeichern, was wir am Ende auch nutzen wollen. Wenn Sie aber dieses Beispiel erweitern wollen, um Statistiken über weitere Teile der Treffer zu erstellen, müssen Sie lediglich Zeilen einfügen, die diese Übereinstimmungen dem Hash hinzufügen. Sie brauchen den regulären Ausdruck nicht zu verändern, um mehr Informationen zu erhalten.

Nachdem die Zeile jetzt in ihre einzelnen Elemente zerlegt ist, kehren wir von der Subroutine `&splitline()` zurück zu der Hauptroutine `&process_log()`. Diese Routine überprüft als nächstes alle fehlgeschlagenen Treffer. Wenn eine Zeile im Webprotokoll nicht dem Muster entspricht - was bei einigen der Fall ist -, liefert die Subroutine `&splitline()` Null zurück. Dieses Ergebnis wird als fehlgeschlagener Treffer interpretiert, der dann zu der Zahl der fehlgeschlagenen Treffer addiert wird. Anschließend wird der Rest der Schleife übersprungen, um mit der nächsten Zeile fortzufahren:

    if (!%hit) {  # mißgestaltete Zeile im Webprotokoll
       $failhits++;
        next;
    }

Der nächste Schritt im Skript ist ein Entgegenkommen an all diejenigen, die das Skript ausführen. Die Verarbeitung einer beliebig großen Protokolldatei kann lange dauern, und manchmal ist es schwer zu sagen, ob Perl noch die Protokolldatei bearbeitet oder ob sich das System aufgehängt hat und keinen Wert mehr liefern wird. Dieser Teil des Skripts gibt eine Nachricht mit dem Datum der Zeilen aus, die gerade verarbeitet werden. Jedesmal, wenn die Treffer eines Tages vollständig bearbeitet worden sind, erscheint eine neue Nachricht, die das Fortschreiten von Perl in der Datei anzeigt:

    $dateshort = &getday($hit{'date'});
    if ($currdate ne $dateshort) {
        print "Verarbeite $dateshort\n";
        $currdate = $dateshort;
    }

In diesem Fragment ist `&getday()` eine kurze Subroutine, die den Monat und den Tag aus dem Datumsfeld ausliest. Dabei wird ein Muster verwendet, so dass Monat und Tag mit dem gerade verarbeiteten Datum verglichen werden können (auf den Ausdruck des Codes für `&getday()` verzichte ich, da Sie ihn in dem vollständigen Listing am Ende des Kapitels finden). Sind sie unterschiedlich, wird eine Nachricht ausgegeben und die Variable `$currdate` aktualisiert.

Zusätzlich zu den Zeilen in der Protokolldatei, die nicht dem Protokollformat entsprechen, werden auch jene Zeilen als fehlgeschlagene Treffer bezeichnet, die zwar dem Muster entsprechen, aber nicht dazu führten, dass wirklich eine Datei zurückgeliefert wurde (falsche URL-Angaben oder Dateien, die verschoben wurden, lösen diese Art von Treffer aus). Diese Treffer werden in der Protokolldatei mit einem Fehlercode aufgezeichnet, der mit 4 beginnt (vielleicht ist Ihnen bereits der Error 404 im Web aufgefallen). Der Rückgabecode gehört mit zu den Elementen der Zeile, die wir gespeichert hatten. Deshalb ist die Überprüfung ein einfacher Musterabgleich:

    if ($hit{'code'} =~ /^4/) { # 404, 403, etc. (Fehler)
        $failhits++;

Der `else`-Teil dieser `if`-Anweisung betrifft alle anderen Treffer - gemeint sind damit alle erfolgreichen Treffer, die tatsächlich HTML-Dateien oder Grafiken zurückgeliefert haben. Diese Treffer haben einen Rückgabecode von 200 oder 304.

    } elsif ($hit{'code'} =~ /200|304/) {   # behandelt nur erfolgreiche Treffer

Webserver sind so eingerichtet, dass sie eine Standarddatei, in der Regel `index.html`, zurückliefern, wenn eine URL angefordert wird, die einem Verzeichnisnamen entspricht. Das bedeutet, dass eine Anfrage nach `/web/` und eine Anfrage nach `/web/ index.html` sich auf die gleiche Datei beziehen, jedoch in der Protokolldatei als unterschiedliche Einträge erscheinen. Um Verzeichnisse und Standarddateien als einen Eintrag zu behandeln, gibt es einige Zeilen, die prüfen, ob die angeforderte Datei mit einem Slash endet, und wenn ja, dafür sorgen, dass der Standarddateiname hinten angehängt wird. Die Standarddatei, wie ich bereits oben erwähnt habe, wird durch die Variable `$default` definiert:

    if ($hit{'file'} =~ /\/$/) { # slashes werden zu $default
        $hit{'file'} .= $default;
    }

Nachdem wir dies erledigt haben, können wir die Verarbeitung damit abschließen, dass wir die Variable `$htmlhits` inkrementieren, wenn es sich bei der Datei um eine HTML-Datei handelt, und die Hashes für die Site und für die Datei aktualisieren:

    if ($hit{'file'} =~ /\.html?$/) { # .htm oder .html
        $htmlhits++;
    }

    $hosts{ $hit{'site'} }++;
    $files{ $hit{'file'} }++;

Damit sind wir ans Ende der `while`-Schleife angelangt, die mit der nächsten Zeile in der Datei von vorne beginnt. Die Schleife wird so lange durchlaufen, bis alle Zeilen verarbeitet sind. Danach gehen wir zum Ausgeben der Ergebnisse dieses Skripts über.

#### Die Ergebnisse ausgeben

Die Subroutine `&process_log()` verarbeitet die Protokolldatei zeilenweise und ruft zu ihrer Unterstützung die Subroutinen `&splitline()` und `&getday()` auf. Der zweite Teil unseres weblog.pl-Skripts besteht aus der Subroutine `&print_results()`, die ebenfalls auf einige weitere Subroutinen zur Unterstützung zurückgreift. Der größte Teil der Subroutine besteht jedoch aus einer Reihe von `print`-Anweisungen, um die verschiedenen Statistiken auszugeben.

Die ersten Zeilen geben die Gesamtzahl der Treffer, Gesamtzahl der fehlgeschlagenen Treffer und Gesamtzahl der HTML-Treffer aus. Die letzteren werden auch als Prozent der gesamten Treffer aufgeschlüsselt, wobei die HTML-Treffer sich nur auf die Gesamtsumme der erfolgreichen Treffer beziehen. Wir erhalten diese Werte mit ein wenig Mathematik und der Anweisung `printf`:

    print "Auswertung der Log-Datei:\n";
    print "Gesamtzahl der Treffer: $totalhits\n";
    print "Gesamtzahl der fehlgeschlagenen Treffer: $failhits (";
    printf('%.2f', $failhits / $totalhits * 100);
    print "%)\n";
    print "(erfolgreiche) HTML-Dateien: $htmlhits (";
    printf('%.2f', $htmlhits / ($totalhits - $failhits) * 100);
    print "%)\n";

Als nächstes kommt die Gesamtzahl der Hosts. Diesen Wert erhalten wir, indem wir die Schlüssel aus dem Hash `%hosts` herausziehen und in einer Liste ablegen. Anschließend werten wir diese Liste in einem skalaren Kontext aus (mit Hilfe der Funktion `scalar`).

    print 'Anzahl der Hosts: ';
    print scalar(keys %hosts);
    print "\n";

Um die Anzahl der Domänen zu ermitteln, müssen wir den Hash `%hosts` verarbeiten, um die Hosts ihren Domänen zuzuordnen, und einen neuen Hash (`%domains`) einrichten, der die Anzahl der Treffer für die Domänen aufnimmt. Dazu verwenden wir eine Subroutine namens `&getdomains()`, die ich im nächsten Abschnitt besprechen werde. Gehen wir einfach davon aus, dass wir unseren Hash `%domains` bereits haben. Wir können auf die Schlüssel dieses Hash den gleichen Trick mit `scalar` anwenden, um die Anzahl der Domänen zu ermitteln:

    my %domains = &getdomains(keys %hosts);
    print 'Anzahl der Domänen: ';
    print scalar(keys %domains);
    print "\n";

Als letztes sind die beliebtesten Dateien, Hosts und Domänen auszudrucken. Für die Ermittlung dieser Werte gibt es die Subroutine `&gettop()`, die jeden Hash nach seinen Werten sortiert (die Häufigkeit, mit der jede Datei, Host oder Domäne in einem Treffer vorkam) und dann einen Array deskriptiver Strings einrichtet mit den Schlüsseln und Werten im Hash. Das Array enthält nur die 5 oder 10 (oder was auch immer Sie als Wert in `$topthings` ablegen) beliebtesten Dateien, Hosts oder Domänen. Doch gleich mehr zu der Subroutine `&gettops()`.

Jedes dieser Arrays wird zum Schluß ausgegeben. Hier sehen Sie den Code für die Ausgabe der Dateien:

    print "Die beliebtesten Dateien: \n";
        foreach my $file (&gettop(%files)) {
            print "  $file\n";
        }

#### Die Subroutine &getdomains()

Noch sind wir nicht fertig. Es fehlen uns noch die Hilfsroutinen zur Ausgabe der Statistiken: `&getdomains()`, um die Domänen aus dem `%hosts`-Hash zu extrahieren und die Statistik neu zu berechnen, und `&gettop()`, um einen Hash von Schlüsseln und Frequenzwerten zu übernehmen und die beliebtesten Elemente zurückzuliefern. Die Subroutine `&getdomains()` finden Sie in Listing 14.5.

Listing 14.5: Die Subroutine &getdomains()

    1:  sub getdomains {
    2:      my %domains = ();
    3:      my ($sd,$d,$tld);       # sekundäre Domäne, Domäne, oberste Domäne
    4:      foreach my $host (@_) {
    5:          my $dom = '';
    6:          if($host =~ /(([^.]+)\.)?([^.]+)\.([^.]+)$/ ) {
    7:              if (!defined($1)) { # nur zwei Domänen (i.e. aol.com)
    8:                  ($d,$tld) = ($3, $4);
    9:              } else {            # eine gewöhnliche Domäne x.y.com etc
    10:                 ($sd, $d, $tld) = ($2, $3, $4);
    11:             }
    12:             if ($tld =~ /\D+/) { # ignoriert reine IP-Zahlen
    13:                 if ($tld =~ /com|edu|net|gov|mil|org$/i) { # US TLDs
    14:                     $dom = "$d.$tld";
    15:                 } else { $dom = "$sd.$d.$tld"; }
    16:                 $domains{$dom} += $hosts{$host};
    17:             }
    18:         } else { print "Fehlerhaft: $host\n"; }
    19:     }
    20:     return %domains;
    21: }

Diese Subroutine ist nicht so kompliziert, wie sie aussieht. Ich gehe dabei von ein paar Grundvoraussetzungen für den Hostnamen aus: dass zum Beispiel jeder Hostname aus mehreren Teilen besteht, die durch Punkte getrennt sind, und dass die Domäne abhängig von ihrem Namen entweder aus den zwei oder drei ganz rechts stehenden Teilen besteht. In dieser Subroutine werden wir dann jeden Host auf seine eigentliche Domäne reduzieren und dann diesen Domänennamen als Index für einen neuen Hash nutzen, wobei wir alle ursprünglichen Treffer für den Hostnamen in dem neuen domänenbasierten Hash speichern.

Die Hauptarbeit dieser Subroutine wird in der `foreach`-Schleife, die in Zeile 4 startet, geleistet. Das Argument, das dieser Subroutine übergeben wird, ist ein Array mit den Hostnamen aus dem `%hosts`-Array. Dabei durchläuft die Schleife alle Hostnamen, um sicherzustellen, dass sie alle berücksichtigt werden.

Der erste Teil der `foreach`-Schleife ist der lange und abschreckende reguläre Ausdruck in Zeile 6. Dieser Ausdruck greift sich die letzten zwei Teile des Hostnamens und, wenn es kann, auch die letzten drei (einige Hostnamen bestehen nur aus zwei Teilen, die jedoch auch vom regulären Ausdruck erfaßt werden). Von Zeile 7 bis 11 wird geprüft, wie viele Teile wir haben (2 oder 3). Diese Teile werden den Variablen `$sd`, `$d` und `$tld` zugewiesen (`$sd` steht für sekundäre Domäne, `$d` für Domäne und `$tld` für Top-Level-Domäne, falls Sie sie auseinanderhalten wollen).

Der zweite Teil der Schleife legt fest, ob wir zwei oder drei Teile des Hostnamens als eigentliche Domäne verwenden wollen, und ignoriert in Zeile 12 alle Hosts, die aus IP-Nummern anstelle von eigentlichen Domänennamen bestehen. Die rein willkürliche Regel, nach der ich entschieden habe, ob eine Domäne aus zwei oder drei Teilen besteht, lautet: Handelt es sich bei der Top-Level-Domäne (den am weitesten rechts gelegenen Teil des Hostnamens) um eine US-Domäne wie `.com`, `.edu` etc. (die vollständige Liste sehen Sie in Zeile 13), dann hat die Domäne nur zwei Teile. Dazu gehören `aol.com`, `mit.edu`, `whitehouse.gov` etc. Lautet die Top-Level-Domäne anders, ist es mit großer Wahrscheinlichkeit eine landesspezifische Domäne wie `.us`, `.au`, `.mx` etc. Diese Domänen verwenden in der Regel drei Teile, um auf eine Site Bezug zu nehmen (zum Beispiel `citygate.co.uk` oder `monash.edu.au`). Zwei Teile wären in diesem Falle nicht genau genug (`edu.au` bezieht sich auf alle Universitäten in Australien und nicht auf eine spezielle namens `edu`).

Das ist also die Aufgabe der Zeilen 13 bis 15: einen Domänennamen aus zwei oder drei Teilen zusammenzusetzen und in dem String `$dom` zu speichern. Wenn wir den Domänennamen zusammengesetzt haben, können wir ihn als Schlüssel für den neuen Hash verwenden und die Treffer, die wir für den ursprünglichen Host ermittelt haben, übertragen (Zeile 16). Nachdem der Domänen-Hash eingerichtet ist, sollten alle Treffer in dem Host-Hash auch in dem Domänen-Hash berücksichtigt sein, so dass wir diesen Hash an die Subroutine `&print_results` zurückgeben können.

Noch eine Sache: In Zeile 18 prüft die Subroutine auf Fehler im Hostnamen. Wenn der Ausdruck des Mustervergleichs in Zeile 6 zu keiner Übereinstimmung führt, muss in der Tat ein sehr seltsamer Hostname vorliegen, und wir geben eine entsprechende Nachricht aus. Im allgemeinen sollte eine solche Nachricht allerdings nicht erscheinen, da ein abartiger Hostname in der Protokolldatei normalerweise bedeutet, dass ein abartiger Hostname auf dem Host selbst vorliegt, was eigentlich über das Internet nur schwer möglich sein dürfte.

#### Die Subroutine &gettop()

Noch eine Subroutine, und dann können wir den Lehrstoff dieser Woche zur Seite legen, ein Bierchen trinken und feiern, dass wir zwei Drittel dieses Buches bereits bewältigt haben. Die letzte Subroutine `&gettop()` übernimmt einen Hash, sortiert ihn nach Werten und schneidet dann die obersten X Elemente ab, wobei X durch die Variable `&topthings` gegeben ist. Die Subroutine liefert ein Array von Strings zurück, wobei jeder String den Schlüssel und den Wert für die obersten X Elemente in einer Form enthält, die leicht durch die Subroutine `&print_results()`, von der aus diese Subroutine aufgerufen wurde, ausgegeben werden kann. Sehen Sie dazu das Listing 14.6.

Listing 14.6: Die Subroutine &gettop()

    1:  sub gettop {
    2:      my %hash = @_;
    3:      my $i = 1;
    4:      my @topkeys = ();
    5:      foreach my $key (sort { $hash{$b} <=> $hash{$a} } keys %hash) {
    6:          if ($i <= $topthings) {
    7:              push @topkeys, "$key ($hash{$key} hits)";
    8:              $i++;
    9:          }
    10:     }
    11:     return @topkeys;
    12: }

### Der Code

Listing 14.7 enthält den vollständigen Code für das Skript weblog.pl.

> Je nach Perl-Version sollten Sie auch hier an die `my`-Variablen innerhalb der `foreach`-Schleifen denken. Details finden Sie in dem Hinweis direkt vor dem Listing 14.3.

Listing 14.7: Der Code für weblog.pl

    1:  #!/usr/bin/perl -w
    2:  use strict;
    3:
    4:  my $default = 'index.html';     # Angabe Ihrer Standard-HTML-Datei
    5:  my $topthings = 30;             # Anzahl der zu protokollierenden
                                        # Dateien, Sites etc.
    6:  my $totalhits = 0;
    7:  my $failhits = 0;
    8:  my $htmlhits = 0;
    9:  my %hosts= ();
    10: my %files = ();
    11:
    12: &process_log();
    13: &print_results();
    14:
    15: sub process_log {
    16:     my %hit = ();
    17:     my $currdate = '';
    18:     my $dateshort = '';
    19:     print "Log-Dateien verarbeiten....\n";
    20:     while (<>) {
    21:         chomp;
    22:         %hit = splitline($_);
    23:         $totalhits++;
    24:
    25:         # Prüfen auf fehlerhafte Zeilen
    26:         if (!%hit) {  # fehlerhafte Zeilen im Webprotokoll
    27:             $failhits++;
    28:             next;
    29:         }
    30:
    31:         $dateshort = &getday($hit{'date'});
    32:         if ($currdate ne $dateshort) {
    33:             print "Verarbeite $dateshort\n";
    34:             $currdate = $dateshort;
    35:         }
    36:
    37:         # nach 404ern suchen
    38:         if ($hit{'code'} =~ /^4/) { # 404, 403, etc. (Fehler)
    39:             $failhits++;
    40:         # andere Dateien
    41:         } elsif ($hit{'code'} =~ /200|304/) {
                # nur im Erfolgsfall bearbeiten
    42:             if ($hit{'file'} =~ /\/$/) { # slashes werden zu $default
    43:                 $hit{'file'} .= $default;
    44:             }
    45:
    46:             if ($hit{'file'} =~ /\.html?$/) { # .htm oder .html
    47:                 $htmlhits++;
    48:             }
    49:
    50:             $hosts{ $hit{'site'} }++;
    51:             $files{ $hit{'file'} }++;
    52:         }
    53:     }
    54: }
    55:
    56: sub splitline {
    57:     my $in = $_[0];
    58:     my %line = ();
    59:     if ($in =~ /^([^\s]+)\s           # Site
    60:                ([\w-]+\s[\w-]+)\s     # Benutzer
    61:                \[([^\]]+)\]\s         # Datum
    62:                 \"(\w+)\s             # Protokoll
    63:                (\/[^\s]*)\s           # Datei
    64:                ([^"]+)\"\s            # HTTP-Version
    65:                (\d{3})\s              # Rückgabe-Code
    66:                ([\d-]+)               # übertragene Bytes
    67:     /x) {
    68:        # wir sind nur an bestimmten Daten interessiert
    69:        # (zufällig jede 2. Information)
    70:        $line{'site'} = $1;
    71:        $line{'date'} = $3;
    72:        $line{'file'} = $5;
    73:        $line{'code'} = $7;
    74:        return %line;
    75:     } else { return (); }
    76: }
    77:
    78: sub getday {
    79:     my $date;
    80:     if ($_[0] =~ /([^:]+):/) {
    81:         $date = $1;
    82:         return $date;
    83:     } else {
    84:         return $_[0];
    85:     }
    86: }
    87:
    88: sub print_results {
    89:     print "Auswertung der Log-Datei:\n";
    90:     print "Gesamtzahl der Treffer: $totalhits\n";
    91:     print "Gesamtzahl der fehlgeschlagenen Treffer: $failhits (";
    92:     printf('%.2f', $failhits / $totalhits * 100);
    93:     print "%)\n";
    94:
    95:     print "(erfolgreiche) HTML-Dateien: $htmlhits (";
    96:     printf('%.2f', $htmlhits / ($totalhits - $failhits) * 100);
    97:     print "%)\n";
    98:
    99:     print 'Anzahl der Hosts: ';
    101:    print scalar(keys %hosts);
    102:    print "\n";
    103:
    104:    my %domains = &getdomains(keys %hosts);
    105:    print 'Anzahl der Domänen: ';
    106:    print scalar(keys %domains);
    107:    print "\n";
    108:
    109:    print "Die beliebtesten Dateien: \n";
    110:    foreach my $file (&gettop(%files)) {
    111:       print "  $file\n";
    112:    }
    113:    print "Die beliebtesten Hosts: \n";
    114:    foreach my $host (&gettop(%hosts)) {
    115:       print "  $host\n";
    116:    }
    117:
    118:    print "Die beliebtesten Domänen: \n";
    119:    foreach my $dom (&gettop(%domains)) {
    120:       print "  $dom\n";
    121:    }
    122: }
    123:
    124: sub getdomains {
    125:     my %domains = ();
    126:     my ($sd,$d,$tld);       # sekundäre Domäne, Domäne, oberste Domäne
    127:     foreach my $host (@_) {
    128:         my $dom = '';
    129:         if($host =~ /(([^.]+)\.)?([^.]+)\.([^.]+)$/ ) {
    130:             if (!defined($1)) { # nur zwei Domänen (i.e. aol.com)
    131:                 ($d,$tld) = ($3, $4);
    132:             } else {            # eine normale Domäne x.y.com etc
    133:                 ($sd, $d, $tld) = ($2, $3, $4);
    134:             }
    135:             if ($tld =~ /\D+/) { # ignoriert reine IP-Zahlen
    136:                 if ($tld =~ /com|edu|net|gov|mil|org$/i) { # US TLDs
    137:                     $dom = "$d.$tld";
    138:                 } else { $dom = "$sd.$d.$tld"; }
    139:                 $domains{$dom} += $hosts{$host};
    140:             }
    141:         } else { print "Fehlerhaft: $host\n"; }
    142:     }
    143:     return %domains;
    144: }
    145:
    146: sub gettop {
    147:     my %hash = @_;
    148:     my $i = 1;
    149:     my @topkeys = ();
    150:     foreach my $key (sort { $hash{$b} <=> $hash{$a} } keys %hash) {
    151:         if ($i <= $topthings) {
    152:             push @topkeys, "$key ($hash{$key} hits)";
    153:             $i++;
    154:         }
    155:     }
    156:     return @topkeys;
    157: }

## Zusammenfassung

Meistens wird Ihnen das Wissen in Programmierbüchern mit vielen Worten, aber zu wenigen praktischen Codebeispielen vermittelt, so dass Sie oft Schwierigkeiten haben, das Erlernte umzusetzen. Ich möchte zwar nicht behaupten, dass dieses Buch zu den wortkargen gehört, mit diesen Beispielkapiteln möchte ich Ihnen jedoch etwas längere Programme präsentieren, die richtige Probleme lösen und demonstrieren, wie ein reales Skript zusammengesetzt wird.

In der heutigen Lektion haben wir zwei längere Skripts unter die Lupe genommen: eine einfache Adreßdatei mit Suchfunktionen, die eine textbasierte Datenbank mit Namen und Adressen verwendet. Das Skript zur Verarbeitung dieser Datei ermöglicht es Ihnen, ein relativ komplexes Suchmuster zu verarbeiten, einschließlich dem Verschachteln logischer Ausdrücke, und das Zusammenstellen von Wörtern und Phrasen mit Hilfe von Anführungszeichen. Sie könnten dieses Beispiel so erweitern, dass Sie damit so ziemlich jede Situation meistern, in der eine komplexe Suche über Teile einer Datendatei ausgeführt werden soll: zum Beispiel um Mail-Nachrichten anhand von bestimmten Kriterien aus einem Mail-Ordner zu filtern oder nach besonderen Comic-Büchern aus einer Sammlung von Comics zu suchen. Jede Textdatei kann als einfache Datenbank dienen, und dieses Skript kann sie durchsuchen, solange es dahingehend modifiziert wurde, die Daten dieser Datenbank zu verarbeiten.

Das zweite Beispiel war ein Skript zur Auswertung von Log-Dateien, das Protokolle von Web-Servern verarbeitet und Statistiken ausgibt. Reine Protokolle sind häufig vom Äußeren ziemlich abschreckend. Dieses Skript liefert Ihnen einige grundlegende Informationen über das, was auf einer Website so alles abläuft. Dabei bediente es sich einiger komplexer regulärer Ausdrücke und einer Menge von Hashes, um die Rohdaten zu speichern. Sie könnten dieses Beispiel dahingehend erweitern, dass es auch andere Statistiken erstellt (zum Beispiel um Histogramme über die Anzahl der Treffer pro Tag oder pro Stunde anzulegen oder außer HTML-Dateien auch Bild- oder andere Dateien zu verfolgen). Oder Sie könnten Änderungen vornehmen, so dass andere Protokollarten (Mail-Protokolle, FTP-Protokolle oder was gerade anfällt) statistisch erschlossen werden.

Meinen Glückwunsch zum erfolgreichen Abschluß der zweiten Woche dieses dreiwöchigen Exkurses. Nach dieser Woche haben Sie bereits ein Großteil der Skriptsprache aufgenommen, so dass Sie jetzt in der Lage sein sollten, bereits einige Aufgaben in Perl zu lösen. Ab jetzt werden wir auf das Erlernte aufbauen. Also auf zu Woche 3!

© Markt&Technik Verlag, ein Imprint der Pearson Education Deutschland GmbH