weidner/archives/2012/06/

Ein Skript, ein Skript, ein Königreich für ein Skript

Für die Analyse eines Problems mit einem HTTP-Server wollte ich HTTP-Requests dosiert verzögert senden. Weil ich kein passendes Programm dafür fand, habe ich ein kleines Perl-Skript geschrieben. Das Skript selbst ist weniger wichtig. In diesem Artikel geht es darum, wie ich beim Schreiben eines Perl-Skripts meistens vorgehe.

Am Anfang ein Template

Da es auch mir so geht, dass ich mich nach ein paar Monaten nicht mehr erinnern kann, warum ich ein Skript so und nicht anders geschrieben habe, beginne ich ein Perl-Skript oft mit dem folgenden Template:

#!/usr/bin/perl
# vim: set ts=4 sw=4 tw=78 et si:
#
# %TITLE%
#
use 5.010;
use strict;
use warnings;

use Getopt::Long;
use Pod::Usage;

my %opt;

GetOptions( \%opt,
    'help|?', 'manual')
    or pod2usage(2);

pod2usage(-exitval => 0, -verbose => 1, -input => \*DATA) if ($opt{help});
pod2usage(-exitval => 0, -verbose => 2, -input => \*DATA) if ($opt{manual});

__END__

=head1 NAME

%TITLE% - do nothing.

=head1 SYNOPSIS

 %TITLE% [options]

=head1 OPTIONS

=over 8

=item B<< -help >>

Print a brief help message and exit.

=item B<< -manual >>

Print the manual page and exit.

=back

=head1 DESCRIPTION

This program will do nothing.

=head1 AUTHOR

Mathias Weidner

Das Template hat einige Besonderheiten, auf die ich hier kurz eingehen will.

In Zeile 6 schalte ich mit use 5.010 die neuen Feature von Perl 5.10 und neueren Versionen ein. Mehr zu diesen Features kann man aus dem Buch Modern Perl von chromatic erfahren.

In Zeile 10 importiere ich das Modul Getopt::Long, das ich bevorzuge um Kommandozeilenoptionen einzulesen. Damit kann ich lange "sprechende" und kurze Optionen definieren und muss nicht selbst den Code zum Einlesen derselben schreiben. Außerdem kann ich die Optionen beim Aufruf des Skripts beliebig abkürzen, solange die Abkürzung eindeutig ist.

Das Modul Pod::Usage, welches ich in Zeile 11 importiere, hilft mir bei der Programmdokumentation. Traditionell werden Perl-Programme und -Module mit POD (Plain Old Documentation) dokumentiert, aus dem die Handbuchseiten generiert werden. Oft steht die POD-Dokumentation unter dem Programmtext, manchmal auch dazwischen. Mit Pod::Usage habe ich nun diese Dokumentation, soweit sie direkt im Skript steht und den Konventionen von POD folgt, direkt im Programm zur Verfügung. Die Funktion pod2usage() liefert mir eine Hilfeausgabe, die entweder nur den Teil unter SYNOPSIS umfasst oder die komplette Dokumentation inklusive Pager. Das verwende ich in den Zeilen 19 und 20 mit den Kommandozeilenoptionen help beziehungsweise manual.

Das Template selbst ist bereits ausführbar, ich speichere es unter dem Namen http-injector.pl und kann mir schon die Hilfe ausgeben lassen:

$ perl http-injektor.pl -h
Usage:
     %TITLE% [options]

Options:
    -help   Print a brief help message and exit.

    -manual Print the manual page and exit.

Ich ersetze %TITLE% und kann mich dem eigentlichen Programm widmen.

Ein simpler TCP-Client

Zunächst fange ich mit einem einfachen TCP-Client an, der den Request von Standardeingabe komplett liest, an den HTTP-Server sendet und die Antwort zur Standardausgabe sendet. Den Code dafür finde ich im Perl Kochbuch von Tom Christiansen und Nathan Torkington oder bei PLEAC. Ich verwende das Modul IO::Socket um einen Socket zu erzeugen, in den ich mit print $socket $request schreiben kann und aus dem ich mit $reply = <$socket> lesen kann.

Um den Socket zu öffnen verwende ich eine Funktion, die ich erweitern kann, ohne in den restlichen Code einzugreifen:

use IO::Socket;

sub get_socket {
    my ($server,$port) = @_;

    my $socket = IO::Socket::INET->new(PeerAddr => $server,
                                       PeerPort => $port,
                                       Proto    => 'tcp',
                                       Type     => SOCK_STREAM);
    return $socket;
} # get_socket()

Auch für das Senden des Requests verwende ich eine eigene Funktion, die die Zeilenenden konvertiert und automatisch eine Leerzeile am Ende einfügt.

sub send_request {
    my ($socket, $input) = @_;

    while (<$input>) {
        s/[\r\n]$//;
        print $socket $_, "\r\n";
    }
    print $socket "\r\n";
}

Nun brauche ich mein Programm nur noch um diese Zeilen ergänzen und kann HTTP-Requests von Standardeingabe an den Server schicken, dessen Antwort ich in die Standardausgabe bekomme.

my $server = shift;
my $port   = shift || 80;

pod2usage(-exitval => 0, -verbose => 1, -input => \*DATA) unless ($server);

my $socket = get_socket($server, $port)
    or die "Can't get socket for $server:$port: $!";

send_request($socket, *STDIN);

while (my $line = <$socket>) {
    print $line;
}

close($socket);

Natürlich passe ich auch die POD am Ende des Skripts entsprechend an, bevor ich es vergesse.

Damit kann ich nun bereits via HTTP Dateien abholen. Ich gebe das Folgende ein, gefolgt von CTRL-D für Dateiende:

$ ./http-injektor.pl localhost
GET / HTTP/1.0
Host: localhost

Daraufhin bekomme ich folgende Antwort vom Server:

HTTP/1.0 200 OK
Vary: Accept-Encoding
Content-Type: text/html
...

Den Reply in eine Datei schreiben

Als nächstes möchte ich die Antwort des Servers in eine Datei schreiben können, um sie später auszuwerten. Ich ergänze den Aufruf von GetOptions() um eine Option für den Dateinamen, um diesen in der Kommandozeile bestimmen zu können:

GetOptions( \%opt,
    'help|?', 'manual',
    'output=s')
    or pod2usage(2);

Der Code für die Ausgabe der Antwort vom Server ändert sich nur geringfügig zu:

my $output = get_output(\%opt);

while (my $line = <$socket>) {
    print $output $line;
}

Die Funktion get_output() ist ebenfalls trivial:

sub get_output {
    my ($opt) = @_;

    my $out;
    if ($opt->{output}
        && open($out, '>', $opt->{output})) {
        return $out;
    }
    return *STDOUT;
} # get_output()

Auch hier passe ich wieder die POD-Dokumentation am Ende des Skripts an und füge folgendes bei den Optionen ein:

=item B<< -output filename >>

Write output from server to file I<< filename >> instead to STDOUT.

Den Request aus Datei lesen

Da ich längere Anfragen an den Server nicht immer von Hand eingeben oder via Pipe in die Standardeingabe schicken will, erweitere ich das Skript um die Möglichkeit, die Anfrage aus einer Datei zu lesen. Ich verwende eine Funktion get_input(), um die Quelle für die Anfrage zu bestimmen:

my $input  = get_input(\%opt);

sub get_input {
    my ($opt) = @_;

    my $in;
    if ($opt->{input}
        && open($in, '<', $opt->{input})) {
        return $in;
    }
    return *STDIN;
} # get_input()

send_request($socket, $input);

Auch hier passe ich wieder die POD-Dokumentation am Ende an.

Verbindung via HTTP-Proxy

Mein nächstes Ziel ist es, mit dem Skript Verbindungen zu Servern via HTTP-Proxies wie zum Beispiel Squid oder dem Perl Modul HTTP::Proxy aufzunehmen. Die einfachste Möglichkeit wäre hier, einfach den Proxy die HTTP-Anfrage erledigen zu lassen. Da ich aber die Anfrage selbst kontrollieren will, lasse ich mir mit CONNECT eine TCP-Verbindung zum gewünschten Server herstellen. Das kann ich sehr gut in der Funktion get_socket() kapseln, so dass ich den Rest des Programmes kaum ändern muss. Ich erweitere diese Funktion um eine Referenz auf %opt als drittes Argument, damit sie selbst entscheiden kann, ob eine direkte Verbindung oder eine Verbindung via Proxy hergestellt werden soll:

sub get_socket {
    my ($server,$port,$opt) = @_;
    my $socket;

    if ($opt->{proxy}) {
        if ($opt->{proxy} =~ m|^(http://)?([^:]+):([0-9]+)?|) {
            my $pserver = $2;
            my $pport   = $3;
            $socket = IO::Socket::INET->new(PeerAddr => $pserver,
                                            PeerPort => $pport,
                                            Proto    => 'tcp',
                                            Type     => SOCK_STREAM)
                or die "Can't get socket for proxy $pserver:$pport: $!";
            print $socket "CONNECT $server:$port\r\n\r\n";
            while (<$socket>) {
                last if /^[\r\n]?$/;
            }
        }
    }
    else {
        $socket = IO::Socket::INET->new(PeerAddr => $server,
                                        PeerPort => $port,
                                        Proto    => 'tcp',
                                        Type     => SOCK_STREAM);
    }
    return $socket;
} # get_socket()

Den Proxy-Server kann ich mit der Option -proxy in der Form http://proxyserver:proxyport/ angeben. Das führende http:// und den folgenden / kann ich auch weglassen. Zunächst baue ich eine TCP-Verbindung zum Proxy-Server auf und sende diesem den Befehl CONNECT mit der Angabe des eigentlichen Servers und TCP-Ports. Da ich diesen Befehl selbst mit HTTP Version 1.0 sende, fange ich die unmittelbare Antwort des Proxy ab, so dass im aufrufenden Programm nur noch die Antwort des eigentlichen Servers ankommt. Gibt es keine Option -proxy, dann wird der Socket wie bisher direkt mit dem Server verbunden. Der Aufruf von get_socket() ändert sich zu:

my $socket = get_socket($server, $port, \%opt)
    or die "Can't get socket for $server:$port: $!";

Beim Aufruf von GetOptions() füge ich den Parameter "proxy=s" ein, damit diese Funktion den entsprechenden Kommandozeilenparameter akzeptiert.

Natürlich ergänze ich auch hier die POD-Dokumentation am Ende des Skripts.

Die Zeit für Request und Reply protokollieren

Um die Zeit für den Request und die Antwort zu protokollieren, verwende ich das Perl-Modul Time::HiRes, wie im Perl Kochbuch beziehungsweise bei PLEAC beschrieben. Damit sieht dieser Teil des Codes wie folgt aus:

use Time::HiRes qw(gettimeofday sleep);

my $t0 = gettimeofday;

send_request($socket, $input);

my $t1 = gettimeofday;

while (my $line = <$socket>) {
    print $output $line;
}

my $t2 = gettimeofday;

my $treq = $t1 - $t0;
my $trep = $t2 - $t1;

print "Request took $treq seconds.\nReply took $trep seconds.\n";

Ich habe aus dem Modul Time::HiRes außer der Funktion gettimeofday() gleich noch die Funktion sleep() importiert, die gegenüber der normalen Funktion mit Bruchteilen von Sekunden arbeiten kann, was ich für die nächste Erweiterung benötige.

Die Zeit für den Request manipulieren

Bleibt zum Schluss nur noch, die Zeit für den Request zu manipulieren. Bei dem Problem, für das ich das Skript entwickelt habe, arbeite ich mit mehrzeiligen Anfragen. Ich mache ich es mir darum einfach und verzögere jede einzelne Zeile um einen Bruchteil der Gesamtverzögerung, so dass sich am Ende alles zur gewünschten Verzögerung summiert.

Das kann ich in der Funktion send_request() unabhängig vom Rest des Programmes erledigen:

sub send_request {
    my ($socket, $input, $opt) = @_;

    if (my $delay = $opt->{delay}) {
        my @in = <$input>;
        my $del = $delay / ( 1.0 + scalar @in );
        foreach (@in) {
            s/[\r\n]+$//;
            sleep $del;
            print $socket $_, "\r\n";
        }
        sleep $del;
    }
    else {
        while (<$input>) {
            s/[\r\n]+$//;
            print $socket $_, "\r\n";
        }
    }
    print $socket "\r\n";
}

Dazu gebe ich ihr beim Aufruf die Kommandozeilenoptionen als zusätzliches Argument mit:

send_request($socket, $input, \%opt);

Damit die Option -delay vom Skript akzeptiert wird, passe ich den Aufruf von GetOptions() entsprechend an:

GetOptions( \%opt,
    'help|?', 'manual',
    'delay=i',
    'input=s', 'output=s',
    'proxy=s')
    or pod2usage(2);

Schließlich ergänze ich die POD-Dokumentation am Ende. Damit ist mein Skript fertig und ich kann es wie gewünscht einsetzen:

$ ./http-injektor.pl -h
Usage:
     http-injektor [options] [server [port]]

    Send a HTTP request from stdin to port "port" (default 80) on server
    "server" (default *localhost*) and print the reply to stdout.

Options:
    -help   Print a brief help message and exit.

    -manual Print the manual page and exit.

    -delay time
            Delay the request for *time* seconds (only full seconds).

    -input filename
            Read request for server form file *filename* instead from STDIN.

    -output filename
            Write output from server to file *filename* instead to STDOUT.

    -proxy proxyserver:proxyport
            Use HTTP proxy at *proxyserver* with TCP port *proxyport* to
            connect to the server.

$ ./http-injektor.pl -in abc.request -out abc.html localhost
Request took 0.000128984451293945 seconds.
Reply took 0.00029301643371582 seconds.
$ ./http-injektor.pl -in abc.request -out abc.html -del 2 localhost
Request took 2.00089406967163 seconds.
Reply took 0.000682830810546875 seconds.
$ ./http-injektor.pl -in abc.request -out abc.html \
                     -proxy localhost:3128 -delay 1 localhost
Request took 1.00074911117554 seconds.
Reply took 0.00131392478942871 seconds.
Posted 2012-06-13
Tags: