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