Spring auf den XML-Zug! (Linux-Magazin, Februar 2000)

XML eignet sich nicht nur zum Auszeichnen von Webdokumenten sondern ganz allgemein zum Strukturieren beliebiger Datensammlungen. Wie wär's mit einem automatischen Formularausfüller, der seine Weisheit aus XML-Daten bezieht?

Neulich, während ich eine auf Formulardaten reagierende Web-Applikation schrieb und zum Testen zum zehnten Mal die Felder ausfüllte, rief ich aus: Es reicht! Es müsste doch möglich sein, einem Perl-Skript vorzuschreiben, welche Daten ins Formular einzutragen wären, bevor es die Antwort des Servers vom Netz holt.

Nun hat der gute Herr Schwartz ja in [1] einen raffinierten Formulargrabscher vorgestellt, der aus den Feldern eines Online-Zettels ein Perl-Skript generiert, welches man editieren und zum Testen verwenden kann. Ich wollte allerdings etwas konservativeres: Ausgehend von einer formalen Beschreibung eines Requests soll mein serviler Agent ein Formular auf einem Webserver ausfüllen. In einer Datei lege ich fest, welchen URL, welche Methode (GET/POST), welche Parameter einfließen (d.h. welche Werte der Agent in welche Felder des Formulars eingibt), welche sonstigen Statusinformationen vorliegen (z.B. Cookies) und welche Sonderdinge zu beachten sind (Proxy-Einstellungen, passwortgeschützte Bereiche).

Das sieht sehr nach einem Programm mit unzähligen Kommandozeilenoptionen aus -- oder nach einer Beschreibungssprache. Um nicht schon wieder ein neues Format zu erfinden, sprang ich kurzentschlossen auf den letztens unüberhörbar bimmelnden XML-Zug.

Kleiner XML-Schnellkurs

Mit XML, der eXtensible Markup Language, lassen sich Teile eines Dokuments ``auszeichnen'', also mit einer Markierung versehen:

    Eine <wichtig>flexible</wichtig> Sprache

Anders als HTML mit seinem fest vordefinierten Satz von Markierungen (<h1>, <table>, etc.) läßt XML den Anwender beliebig viele neue Tags definieren. Es gibt keine ``richtigen'' Tags in XML, jeder kreiert nach Lust und Laune seinen eigenen Markup. So eignet sich XML nicht nur zum Strukturieren von Web-Dokumenten sondern auch für beliebige Datensammlungen.

Syntaktisch ist XML strenger als HTML und so müssen alle öffnenden Tags auch wieder geschlossen werden:

    <tag> ... </tag>

Eine Ausnahme bilden lediglich ``leere'' Tags, die keinen Inhalt aufweisen -- für sie ist wahlweise die Kurzschreibweise

    <tag/>

gültig. Schachtelungen treten nur in der richtigen Reihenfolge auf, d.h.

    <aussen> <innen> </aussen> </innen>

ist falsch, denn was zuerst geöffnet wurde, muß auch zuerst wieder geschlossen werden, wie in folgendem gültigen Konstrukt:

    <aussen> <innen> </innen> </aussen>

Attribute, die man in HTML schlampig als

    <a href=http://...>;

hinpfefferte, müssen in XML in Anführungszeichen:

    <anker url="http://...">

Groß- und Kleinschreibung spielen eine Rolle, <TAG> und <tag> sind unterschiedliche Tags, und um Verwirrungen zu vermeiden schreiben wir heute mal alles klein.

XML für Web-Requests

Listing simple.xml zeigt den einfachsten Fall eines Requests: Einfach ein URL eines Dokuments, das vom Netz geholt wird. Der <request>-Tag ist in diesem Fall leer, es reicht, einfach als Attribut den URL anzugeben.

Listing advanced.xml zeigt die XML-Definition eines Requests mit zwei zusätzlich definierten Parametern und zwei HTTP-Headern. Weiter steht das method-Attribut auf POST, worauf der Request die Parameter, anders als als bei der standardmäßig eingestellten GET-Methode, nach der POST-Methode an den Webserver übergibt.

Es handelt sich um einen HTTP-Request auf das in [2] vorgestellte Skript dump.cgi, das testhalber einfach die hereinkommenden CGI-Parameter ausgibt.

Abbildung 2 zeigt, was dump.cgi auf den Request hin ausspuckt: Nicht nur die zwei Parameter, die nach der POST-Methode übergeben wurden, kommen an, sondern auch die beiden Spezial-Header X-Header1 und X-Header2.

Listing password.xml zeigt die Definition eines Requests, der auf einen passwortgeschützten Bereich des Webservers zugreift. Auch soll ein Proxy aushelfen, im vorgestellten Fall mein Squid, der auf dem lokalen Rechner auf Port 3128 läuft.

Damit wäre unsere Markup-Sprache auch schon erklärt: Ein request führt als vorgeschriebenes Attribut url, zusätzlich dürfen optional method und proxy definiert werden. Ein request kann entweder leer sein oder ein oder mehrere parameter, header oder credentials-Elemente enthalten. parameter- und header-Elemente führen als Pflichtattribute name und value, ein credentials-Element hat username und password.

Diese Beschreibung der Sprache lässt sich computerfreundlich in eine DTD (Document Type Definition) verpacken, die ein validierender Parser benutzt, um die Gültigkeit eines XML-Texts zu überprüfen. Listing request.dtd zeigt die Definition für unsere Request-Sprache.

Von XML zum Web-Request

Aber wie holen wir, ausgehend von der Beschreibung mit dem XML-Markup das Dokument vom Netz? Für Aufgaben im WWW gibt's glücklicherweise schon ein Modul auf dem CPAN, den LWP::UserAgent, nur versteht der leider kein XML, sondern besteht darauf, dass wir die Parameter über Methoden eingeben. Kompliziert wird die Lage dadurch, dass eigentlich zwei verschiedene Objekte im Spiel sind, der LWP::UserAgent, dem man Proxy- und Passwortinformationen mitgibt und das HTTP::Request-Objekt, das man dem LWP::UserAgent überreicht und das den URL, die GET oder die POST-Methode, die Parameter und die Header festlegt.

Was tun? Wie immer in solchen Fällen, in denen wir nur Teile eines Moduls ändern müssen, um die neue Funktionalität zu erhalten, kommt Vererbung zum Einsatz. So zeigt Listing XmlUserAgent.pm eine von LWP::UserAgent abgeleitete Klasse XmlUserAgent, die die Methoden get_basic_credentials und request redefiniert und einen Konstruktor für die abgeleitete Klasse bereitstellt.

UserAgent mit Sonderauftrag

Verschieben wir die Implementierung von XmlUserAgent.pm mal auf später -- die Anwendung geht wie von LWP::UserAgent gewohnt:

    use XmlUserAgent;
    $ua = XmlUserAgent->new();
    $response = $ua->request($xmltext);
    print $response->content();

$xmltext enthält den Request in XML, die Fehlerbehandlung wurde einmal ausgespart.

Das Skript fetch.pl, das in Listing 5 vorgestellt ist, nimmt Namen von Requestdateien auf der Kommandozeile entgegen:

    fetch.pl simple.xml advanced.xml

führt die Requests aus und gibt für jeden einzelnen das Ergebnis aus, welches aus den vom Server gesandten Headern und dem Inhalt des zurückgegebenen Dokuments besteht. Cookies werden von Request zu Request automatisch durchgeschleift. Hierzu kommt in Zeile 4 von fetch.pl das Modul HTTP::Cookies herein, das eine komfortable Schnittstelle zur Cookie-Kontrolle mit dem LWP::UserAgent bietet: Einmal, wie in Zeile 9, die cookie_jar-Methode mit einem neu erzeugten HTTP::Cookies-Objekt aufgerufen, und schon verhält sich der UserAgent wie ein handelsüblicher Browser mit aktivierten Cookies.

Die Zeilen 12 bis 14 lesen den Inhalt der aktuellen XML-Datei aus und legen ihn in $data ab. Zeile 16 feuert den Request ab, das Ergebnis liegt anschließend als HTTP::Response-Objekt in $response, das mit den Methoden is_success, content, headers, code, message nach Erfolgsstatus, Dokumentinhalt, empfangenen HTTP-Headern, Fehlercode und Fehlermeldung befragt werden darf.

Die Zeilen 18 bis 25 ahmen einen Browser-Bug nach: Laut HTTP-Spezifikation soll der Browser, falls er auf einen POST-Request einen Redirect erhält, diesem nicht automatisch folgen, was LWP::UserAgent auch geflissentlich so implementiert -- nur machen's alle wichtigen Browser anders und folgen dem Redirect.

Nun zu XmlUserAgent.pm: Der @ISA-Array in Zeile 10 legt XmlUserAgent als einen Spezialfall von LWP::UserAgent fest, nur dass die request-Methode von XmlUserAgent nicht nur mit einem HTTP::Request-Objekt wie sein großer Bruder arbeitet, sondern als Argument auch wahlweise ein Stück XML-Text im oben definierten Format versteht.

Da perl beim Erzeugen einer abgeleiteten Klasse nicht automatisch den Konstruktor der Basisklasse aufruft, muss new selbst dafür sorgen: Die Instanzvariable ua zeigt auf das Objekt der Basisklasse, deren Konstruktor mit dem SUPER-Konstrukt aufgerufen wird. Das SUPER-Schlüsselwort läßt perl die Basisklasse der gegenwärtigen Klasse suchen, um dann dort die gewählte Methode auszuführen, $class->SUPER::new() ist also im Beispiel gleichbedeutend mit LWP::UserAgent->new().

Die weiter definierten Instanzvariablen nehmen alle im XML-Text definierten Parameter auf. Im Konstruktor werden sie auf auf unverfängliche Werte gesetzt, url und method auf undef, genau wie username und password, die beiden Variablen für den Fall, dass eine Webseite passwortgeschützt ist. Die Listenreferenzen headers und params zeigen auf anfangs leere Listen.

Der bless-Befehl in Zeile 26 schließlich macht aus dem anonymen Hash mit den Instanzvariablen ein Objekt der Klasse XmlUserAgent.

Die request-Methode ab Zeile 39 überprüft zunächst, ob als Request ein Objekt der Klasse HTTP::Request vorliegt. Ist dies der Fall, liefert ref $req den Klassennamen zurück, und Zeile 43 ruft statt unserer Fancy-Logik die ganz normale request-Methode des LWP::UserAgent auf. In diesem Fall imitiert XmlUserAgent also das Verhalten von LWP::UserAgent.

Anders im Fall eines übergebenen Strings: Hier gibt ref $req einen Leerstring zurück: Um den XML-Salat zu parsen, kommt das Modul XML::Parser zum Einsatz. Es handelt sich nicht um einen prüfenden Parser, der zunächst die Übereinstimmung mit einer gegebenen DTD (Syntaxbeschreibung des Markups) prüfen würde, sondern um ein einfaches Tool, das lediglich die ``Wohlgeformtheit'' des XML-Codes sicherstellt, also unter anderem nachprüft, ob alle geöffneten Tags wieder geschlossen wurden und keine unerlaubten Sonderzeichen vorkommen. Wer den Markup hingegen auf Syntaxfehler gegenüber der DTD abklopfen will, muss sich James Clarks Parser sp besorgen ([3]), der gibt dann Dinge wie

    nsgmls:test.xml:2:33:E: required attribute "url" not specified

aus, falls ein erforderliches Attribut fehlt und man den Dokumenttyp im XML-Markup mit

    <!DOCTYPE request SYSTEM "request.dtd">

angegeben hat.

Ein Parser von Herrn Wall persönlich

Nein, heute soll's bescheiden zugehen. Zeile 48 erzeugt ein neues XML::Parser-Objekt, welches als Callback für sich öffnende Tags die Routine start_handler im gleichen Modul festlegt. Normalerweise arbeitet XML::Parser gleich mit drei Callbacks: Start, End und Char definieren Handler, die aufgerufen werden, falls sich öffnende oder schließende Tags oder dazwischenliegender Text zeigen. Die erste Version des Moduls wurde übrigens von Larry Wall entwickelt.

Da unser XML-Markup eigentlich nur leere Elemente mit Attributen definiert, haben die Char und End-Handler keinen Einfluß auf das Ergebnis und können weggelassen werden. Außerdem verzichtet XmlUserAgent auf jegliche Konsistenzprüfungen des Markups, um das Modul einfach zu halten.

Zeile 50 startet den Parser, der sich durch das übergebene XML-Dokument frisst, für jeden Start-Tag die Funktion handle_start aufruft und ihr eine Referenz auf das XmlUserAgent-Objekt ($a), sowie (das erledigt XML::Parser intern) eine Parserreferenz ($p), den Namen des Elements ($el) und einen Hash mit Attributwerten (%atts) übergibt.

handle_start analysiert dann, welcher Art das Element ist, fieselt die entsprechenden Attribute hervor und speichert sie als Instanzvariablen des XmlUserAgent-Objekts ab. Im Falle von parameter oder header-Konstrukten werden die gefundenen Key/Value-Paare an die bereitgestellten Listen angehängt.

Sinn und Zweck der Übung: Ist der Text geparst, liegen alle notwendigen Informationen über den bevorstehenden Request in Instanzvariablen des XmlUserAgent-Objekts und können bei Bedarf hervorgezogen werden. So prüft Zeile 52 ob ein POST-Request verlangt wurde, und, falls ja, ruft sie die von HTTP::Request::Common praktischerweise exportierte POST-Funktion auf, die aus einem URL, einer Referenz auf eine Parameterliste und einer Headerliste ein HTTP::Request-Objekt zimmert. Für den Fall, dass nach einem GET-Request verlangt wird, muß in Zeile 57 zunächst die query_form-Methode der URI::URL-Klasse die Parameter an den URL anhängen, bevor in Zeile 58 die ebenfalls von HTTP::Request::Common exportierte GET-Funktion daraus ein HTTP::Request-Objekt baut. Zeile 62 gibt das aus dem XML-String erzeugte HTTP::Request-Objekt dann lediglich an die request-Methode der Basisklasse weiter, die dann den HTTP-Request tatsächlich durchführt.

get_basic_credentials ab Zeile 30 ist die Funktion, die der LWP::UserAgent (oder abgeleitete Klassen wie unser XmlUserAgent) aufrufen, wenn sie auf ein Webdokument stoßen, das Benutzernamen und Passwort zur Authorisierung verlangt. get_basic_credentials soll Benutzernamen und Passwort als Liste zurückgeben, falls sie weiß, wie man sich einloggt, und undef, falls nicht, was in den Zeilen 34 und 35 passiert. Ist die Instanzvariable username definiert, spezifizierte das XML-Dokument höchstwahrscheinlich eine credential-Sektion mit der UserID und dem Passwort für den Request.

Installation

Die Module LWP::UserAgent, HTTP::Cookies und XML::Parser gibt's wie immer auf dem CPAN (LWP::UserAgent ist in Bundle::LWP enthalten), die praktische CPAN-Shell spart wie immer Zeit beim Installieren. Damit auch SSL-Requests nach dem https-Protokoll verarbeitet werden können, muß zusätzlich Crypt::SSLeay installiert sein, das dem Modul beiliegende Installationsdokument verrät auch, wie man den kostenlosen SSL-Code bekommt.

Test

Als kleines Testbeispiel zeigt Listing amazon.xml die Definition eines Requests, der in das Suchfeld auf amazon.com das Wort "Schilli" eintippt und die ``Go''-Taste drückt. Der Aufruf

    fetch.pl amazon.xml

gibt das von Amazon auf die Anfrage zurückgesandte HTML über die Standardausgabe aus. Die Felder für die Definition in amazon.xml kann man entweder von Hand aus der Amazon-Seite extrahieren oder das schon erwähnte Tool von Randal Schwartz ([1]) verwenden. index ist die Auswahlbox, die die Suche auf einen der verschiedenen Amazon-Geschäftsbereiche beschränkt, im vorliegenden Fall werden mit books Bücher ausgewählt, und field-keywords ist der Name des Suchfeldes, in das der eingegebene Text kommt. Hidden Fields hat das Formular keine, falls doch, kämen diese auch in eine <parameter>-Sektion der XML-Beschreibung. Der zusätzlich definierte Referer-Header imitiert den Browser, der auch die Formularseite dort ablegen würde.

Die Suite, bitte

In der nächsten Folge zaubern wir eine kleine Test-Suite, die unsere Web-Formulare abklappert und überprüft, ob noch alles funktioniert -- gerne auch mit Cookies und allem Schnickschnack!

Wer übrigens denkt, ich hätte zuviel objektorientierte Medizin geschluckt, hat ganz recht: Ich las [4] und war begeistert. Ein klasse Buch! Sofort lesen! Bis nächsten Monat!

Figure

Abb.1: Die Antwort des Dump-Skripts auf den Request

Listing simple.xml

    <request url="http://localhost"; />

Listing advanced.xml

    <request url="http://localhost/cgi-bin/dump.cgi"; 
             method="POST">
    
      <parameter name="first_name" value="Michael" />
      <parameter name="last_name" value="Schilli"  />
    
      <header name="X-Header1" value="header1" />
      <header name="X-Header2" value="header2" />
    
    </request>

Listing password.xml

    <request url="http://localhost/geheim/index.html";
             method="GET" 
             proxy="http://localhost:3128">
    
      <credentials username = "michael"
                   password = "nixgibts!" />
    </request>

Listing request.dtd

    <!ELEMENT request (parameter|header|credentials)*>
    
    <!ELEMENT parameter EMPTY>
    <!ELEMENT header EMPTY>
    <!ELEMENT credentials EMPTY>
    
    <!ATTLIST request
              url      CDATA #REQUIRED
              method   CDATA #IMPLIED
              proxy    CDATA #IMPLIED> 
    <!ATTLIST parameter
              name     CDATA #REQUIRED
              value    CDATA #REQUIRED>
    <!ATTLIST header
              name     CDATA #REQUIRED
              value    CDATA #REQUIRED>
    <!ATTLIST credentials
              username CDATA #REQUIRED
              password CDATA #REQUIRED>

Listing fetch.pl

    01 #!/usr/bin/perl -w
    02 
    03 use XmlUserAgent;
    04 use HTTP::Cookies;
    05 
    06 $ua  = XmlUserAgent->new();
    07 $jar = HTTP::Cookies->new();
    08 
    09 $ua->cookie_jar($jar);
    10 
    11 foreach $xml (@ARGV) {
    12     open FILE, "<$xml" or die "Cannot open $xml";
    13     my $data = join '', <FILE>;
    14     close FILE;
    15 
    16     $response = $ua->request($data);
    17 
    18     {  # Handle POST redirects
    19        if($response->code == 302) {
    20            $request = HTTP::Request->new(GET => 
    21                    $response->header('Location'));    
    22            $response = $ua->request($request);
    23            redo;
    24        }
    25     }
    26 }
    27 
    28 if ($response->is_success) {
    29     print $response->headers_as_string(), "\n",
    30           $response->content(), "\n\n";
    31 } else {
    32     print "Error! Code=", $response->code, "\n";
    33     print "Message=", $response->message, "\n\n";
    34 }

Listing XmlUserAgent.pm

    01 ##################################################
    02 package XmlUserAgent;
    03 ##################################################
    04 
    05 use LWP::UserAgent;
    06 use URI::URL;
    07 use XML::Parser;
    08 use HTTP::Request::Common;
    09 
    10 @ISA = qw(LWP::UserAgent);
    11 
    12 ##################################################
    13 sub new {          # Constructor
    14 ##################################################
    15     my ($class) = @_;
    16 
    17     my $self  = { ua       => LWP::UserAgent->new(),
    18                   url      => undef, 
    19                   method   => undef,
    20                   headers  => [], 
    21                   params   => [],
    22                   username => undef,
    23                   password => undef,
    24                 };
    25 
    26     bless $self, $class;
    27 }
    28 
    29 ##################################################
    30 sub get_basic_credentials {  
    31 ##################################################
    32     my $self = shift;
    33 
    34     return undef unless exists $self->{username};
    35     return($self->{username}, $self->{password}); 
    36 }
    37 
    38 ##################################################
    39 sub request {
    40 ##################################################
    41     my ($self, $req) = @_;
    42 
    43     return shift->{ua}->request(@_) if ref $req;
    44 
    45     my $start_handler = sub { 
    46                      handle_start($self, @_) };
    47 
    48     my $p = XML::Parser->new( 
    49         Handlers => { Start => $start_handler });
    50     $p->parse($req);
    51 
    52     if($self->{method} eq "POST") {
    53         $req = POST($self->{url}, $self->{params}, 
    54                     @{$self->{headers}});
    55     } else {
    56         # GET request -- assemble parameters
    57         $self->{url}->query_form(@{$self->{params}});
    58         $req = GET($self->{url}, 
    59                    @{$self->{headers}});
    60    }
    61 
    62    return $self->{ua}->request($req);
    63 }
    64 
    65 ##################################################
    66 sub handle_start {
    67 ##################################################
    68     my ($a, $p, $el, %atts) = @_;
    69 
    70     if($el eq "request") {
    71         $a->{method} = $atts{method} || "GET";
    72         $a->{url}    = URI::URL->new($atts{url});
    73         $a->{ua}->proxy($a->{url}->scheme, $atts{proxy}) if 
    74                             exists $atts{proxy};
    75     }
    76 
    77     if($el eq "credentials") {
    78         $a->{username} = $atts{username};
    79         $a->{password} = $atts{password};
    80     }
    81 
    82     if($el eq "parameter") {
    83         push(@{$a->{params}}, 
    84              $atts{name}, $atts{value});
    85     }
    86 
    87     if($el eq "header") {
    88         push(@{$a->{headers}}, 
    89              $atts{name}, $atts{value});
    90     }
    91 }
    92 
    93 1;

Listing amazon.xml

    01 <request url="http://www.amazon.com/exec/obidos/search-handle-form"; 
    02          method="POST">
    03 
    04   <parameter name="index" value="books"             />
    05   <parameter name="field-keywords" value="schilli"  />
    06   <parameter name="Go" value="Go"                   />
    07 
    08   <header name="Referer" 
    09      value="http://www.amazon.com/exec/obidos/subst/home/home.html"; />
    10 
    11 </request>

Referenzen

[1]
Randal Schwartz, WebTechniques 11/99: ``Automatically Testing a Form'', http://web.stonehenge.com/merlyn/WebTechniques/col43.html

[2]
Michael Schilli, Linux-Magazin 03/98: ``Aus der CGI-Trickkiste I'', http://www.linux-magazin.de/ausgabe/1998/03/CGI/cgi1.html

[3]
James Clarks XML parser sp: http://www.jclark.com/sp/xml.htm

[4]
Damian Conway, ``Object Oriented Perl'', ISBN=1884777791, Manning 1999, http://www.amazon.de/exec/obidos/ASIN/1884777791/perlmeistercom04

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.