Kaufrausch (Linux-Magazin, Juni 2009)

Eine auf dem verteilten Versionskontrollsystem git basierende Datenbank merkt sich, ob Internet-Bestellungen auch wirklich beim Empfänger eintreffen.

Wer wie ich gerne günstige Produkte im Internet bestellt, fragt sich manchmal vielleicht, ob das, was da bestellt wurde, drei Tage später auch wirklich ankommt. Im Kaufrausch verliert so mancher Schnäppchenjäger gern den Überblick über das, was da in einem stetigen Bestellstrom bei verschiedenen Anbietern geordert wurde.

Abbildung 1: Kartons von Internetbestellungen stapeln sich im Cubicle des Autors

Was liegt also näher, als abgeschickte Bestellungen in eine Datenbank aufzunehmen, und diese dann bei Wareneingang wiederum aufzufrischen? Die Datenbank sollte freilich auch dort verfügbar sein, wo der Kaufwütige seinem Bestellwahn nachgeht.

Ob im Büro, zu Hause, oder vielleicht auch auf einem Laptop in einem billigen Motelzimmer. Dort kann es allerdings sein, dass kein Internet zur Verfügung steht und die Datenbank zunächst lokal auf dem Laptop gepflegt wird, um sie später, wieder zurück in heimatlichen Gefielden, über die dort vorhandene Internetverbindung auf den neuesten Stand zu bringen.

Als Speichermedium bietet sich hierzu das verteilte Versionskontrollsystem git ([2]) an. Von Kernel-Zampano Linus Torvalds ursprünglich eigenhändig in einer Woche zusammengeklopft, um das proprietäre Produkt Bitkeeper abzulösen, verwaltet Git heutzutage den Linux-Kernel und kann blitzschnell tausende von Dateien patchen und mergen. Bei der heute anstehenden Aufgabe ist die Geschwindigkeit jedoch eher irrelevant, praktisch ist nur, dass git mit seinem eingebauten Replikationsmechanismus ohne große Klimmzüge verschiedene verteilte Dateibäume synchronisieren kann.

Immer alles am Mann

Abbildung 2: Das zentrale Git-Repository auf dem Hosting-Service dient als Austauschpunkt zwischen den einzelnen lokalen Repositories zuhause, in der Arbeit und unterwegs.

Informationen über getätigte Internetkäufe und deren geschätzte Lieferungsdaten liegen in einem Cache auf der lokalen Festplatte. Das hierzu verwendete Modul legt für jeden Zugriffsschlüssel eine eigene Datei in einem Dateibaum an. Diesen versioniert git in einem lokalen Repository. Dies ist typisch für ein verteiltes Versionierungssystem wie git. Entwickler haben auch ohne Internetzugang und ohne zentrale Repositories zu stören vollen Zugriff auf sämtliche Funktionen. Sie können neue Versionen einchecken, alte hervorholen, parallele Entwicklungszweige (Branches) erzeugen, oder mit anderen mischen und vieles mehr.

Um das lokale Repo mit anderen Rechnern zu synchronisieren, führt der lokale Benutzer einen ``push'' zu einem ``zentralen'' Repo aus, das auf einem Hostingservice liegt. Letztendlich ist jedoch kein git-Repo wirklich zentral, es bleibt jedem selbst überlassen, mit welcher Instanz man Kontakt aufnimmt und Patches einfließen lässt oder für den Download von neuen Features auf die lokale Instanz anzapft.

Sitzt der Kaufwütige dann am Laptop, kann er sich aus der zentralen Instanz mit ``git clone'' erstmal einen Klon bauen, den er später nicht nur internetlos abfragen und mit neuen Daten füttern kann, sondern bei wieder bereitstehender Verbindung einfach mit der zentralen Instanz synchronisiert.

An den Haaren aus dem Sumpf

Zunächst wird, wie Abbildung 3 zeigt, auf dem Hostingserver mit ssh-Zugang mit git init ein leeres git-Repo angelegt. Hierzu erzeugt man schlicht ein neues Verzeichnis an, springt hinein und führt git init aus. Nun sollte man meinen, dass man dieses auf dem Client einfach klonen könnte, aber aus unerfindlichen Gründen muss der Client durch einige brennende Reifen springen, um das zentrale Repo mit den ersten Daten zu füllen.

Abbildung 3: Per Hand wird serverseitig ein leeres Repo namens "buy.git" angelegt.

Wie in Abbildung 4 gezeigt, legt der Client mit git init ebenfalls ein leeres Repo an. Zu Testzwecken packt er eine Datei testfile hinein und fügt sie erst mit git add ins Repo ein und besiegelt dann den Vorgang mit einem commit. Der anschließend mit remote add definierte Remote-Branch zeigt unter dem Alias origin auf das ``zentrale'' Repo auf dem Hosting-server, das der Synchronisation verschiedener Clients dient. Der Befehl push origin master synchronisiert dann den master-Branch des Clients mit dem gleichnamigen Branch des Servers. Bequem ist dabei, wenn der Server über den public key des Clients in der Datei ~/.ssh/authorized_keys verfügt, sonst muss der Anwender bei jedem Zugriff übers Netz sein Passwort tippen.

Abbildung 4: Ein mit 'git init' auf dem Client angelegtes leeres Repo erhält einen Remote-Branch, der auf das Server-Repo zeigt. Mittels "git push" füttert es dann das Remote-Repo mit den lokalen Änderungen.

Möchte nun ein weiterer Client die Daten aus dem Server-Repo ziehen, klont er es lediglich, wie Abbildung 5 zeigt. Einmal auf dem lokalen Rechner, ist es eine vollständige Kopie des Server-Repo samt der Möglichkeit, lokal eingecheckte Änderungen mit git push wieder zum Server hochzuspielen.

Abbildung 5: Weitere Clients können nun das Remote-Repo klonen und anschließend mit "git push" ihrerseits Änderungen auf der Serverseite einspielen.

In Perl verpackt

Listing shop zeigt das Perlskript, das die im Kasten ``Befehle für shop'' aufgelisteten Befehle entgegen nimmt und die entsprechenden git-Kommandos absetzt. Es nutzt Sysadm::Install vom CPAN, um schnell zwischen verschiedenen Verzeichnissen hin- und herzuspringen (cd und cdback) und um verschiedene git-Aufrufe von der Kommandozeile aus auszuführen.

Die Bestelldaten landen in einem Cache der Marke Cache::FileCache und die in Zeile 25 eingestellte Tiefe Null bestimmt, dass jeder Eintrag in einer Datei direkt im Verzeichnis ~/data/shop landet. Zeile 21 erzeugt mit mkd aus Sysadm::Install das Verzeichnis, falls es noch nicht existiert, im Vergleich zur Perl-Funktion mkdir() führt es auch noch die Fehlerprüfung durch und loggt, was es treibt, falls Log4perl aktiviert ist.

Kasten (zweispaltige Tabelle): ``Befehle für shop''

    shop init: Auf dem ersten Client das Repository erzeugen
    shop clone: Auf weiteren Clients lokalen Klon des Server-Repo initialisieren
    shop push: Lokale Änderungen zum Server hochspielen
    shop pull: Lokales Repo auf neuesten Server-Stand bringen
    shop list: Aktuelle Bestellungen auflisten
    shop buy [ITEM] [DAYS]: Bestellung für ITEM, erwartet in DAYS Tagen
    shop got [ITEM]: Löschen der Bestellung für ITEM nach Eintreffen

Auf Cache-Einträge greifen die Methoden set() und get() zu, die als Schlüssel den Produktnamen (z.B. "iPod") entgegennehmen und Einträge im in der Funktion record_new() (Zeile 89) definierten Format abspeichern und abrufen. Neben dem Produktnamen besteht ein Datensatz auch noch aus zwei Datumsfeldern des Typs DateTime. Das erste, das den Namen bought trägt, enthält das Kaufdatum, das der Einfachheit halber in Zeile 99 mit der Methode today() auf das aktuelle Datum gesetzt wird.

Wann der User einen Artikel per Post erwartet, gibt er bei einem buy-Befehl mit an, so legt zum Beispiel

    shop buy 'dell netbook' 30

fest, dass das bei Dell bestellte Netbook 30 Tage Lieferzeit hat. Zeile 101 in Listing shop wandelt diese Tageszahl in ein Objekt der Klasse DateTime::Duration um, die man anschließend durch etwas schwarze Operator-Magie einfach zu einem DateTime-Objekt addieren kann, um das voraussichtliche Lieferdatum zu errechnen.

Beide DateTime-Objekte erhalten noch einen Formatierer vom Typ DateTime::Format::Strptime, der mit ``%F'' festlegt, dass sich das Objekt in einem String-Kontext als Datum im Format ``JJJJ-MM-DD'' darstellt.

Die so entstandene tief verschachtelte Datenstruktur legt Cache::FileCache ohne Murren in einer Datei ab, indem es sie vorher intern ausflacht und beim Einlesen später wieder in Perl-Objekte zurückverwandelt. Ist die Cache-Datei im Workspace des lokalen Repos gelandet, schreibt ein folgendes git commit sie im lokalen Repo fest.

Vorhang zerrissen

Zum Löschen eines Eintrags nach Erhalt der Ware muss das Skript hinter die Kulissen des Caches blicken, denn um eine Datei aus einem Git-Repo zu tilgen, muss deren Name bekannt sein. Der Cache generiert aber für jeden Schlüssel aus einem 40 Byte langen Hashwert bestehenden Dateinamen (z.B. ``d549f860476c...''). Verlangt der User also mit dem Befehl shop got iPod, dass der Eintrag unter dem Schlüssel ``iPod'' aus dem Cache zu löschen ist, linst die ab Zeile 79 definierte Funktion path_to_key hinter die Kulissen der Cache-Abstraktion und holt über den Backend der Implementierung den zugehörigen Pfadnamen hervor. Zeile 47 setzt darauf ein git rm an, das nicht nur die Datei im lokalen Workspace löscht, sondern auch den Eintrag im lokalen Repo. Der folgende commit zementiert die Aktion.

Auf den Befehl 'list' hin ruft die Funktion record_list() ab Zeile 115 die Methode get_keys() der Cache-Implementierung auf und bekommt alle im Cache existierenden Schlüssel in einer Liste zurück. Diese wiederum übergibt sie der Methode get(), die einen Cache-Eintrag zu einem Schlüssel von der Platte holt.

Die git-Kommandos laufen allesamt über die ab Zeile 148 definierte Funktion cmd_run, die intern tap aus dem Modul Sysadm::Install aufruft, das wiederum Programmzeilen ausführt, STDERR und STDOUT abfängt und dem Aufrufer sauber als Rückgabeparameter aushändigt.

Alles mitprotokolliert

Das lokale Repository speichert die Vorgänge minutiös ab und könnte alle vergangenen Zustände umstandslos wieder herstellen. Wer sich zum Beispiel dafür interessiert, welche Aktionen im Repo ausgeführt wurden, kann, wie in Abbildung 6 gezeigt, im Verzeichnis ~/data/shop einfach mit dem Kommando git log das Log des Repos abfragen.

Abbildung 6: Das Kommando 'git log' im Verzeichnis ~/data/shop zeigt die letzten Transaktionen im lokalen Repo.

Konflikte passieren

Speichern zwei Clients unabhängig voneinander das gleiche Produkt in ihre lokalen Repos ein, kommt es zum Konflikt, sobald der zweite versucht, seine Änderungen mit shop push dem zentralen Server mitzuteilen.

Abbildung 7 zeigt gits unschöne Fehlermeldung, wenn der zweite Client shop push ausführt. Eigentlich sollte git bei einem darauffolgenden shop pull feststellen, dass die Änderung auf dem Remote-Branch und die lokale Datei identisch sind, aber da es sich um eine von Cache::FileCache erzeugte Binärdatei handelt, traut es sich nicht und beschwert sich lautstark. git befindet sich nun im Merge-Zustand und wartet beharrlich darauf, dass der User das Problem löst. Im vorliegenden Fall löscht dieser das Produkt erst lokal (shop got) und legt es anschließend neu an (shop buy). Ein anschließender push geht durch und damit ist auch das Server-Repo wieder happy. Hätte der Client das Produkt übrigens nur gelöscht und nicht wieder angelegt, und sofort den push ausgeführt, wäre die bestellte Kettensäge erst aus dem Server-Repo verschwunden, und nach einem shop pull des ersten Clients auch aus dessen lokalem Repo.

Abbildung 7: Zwei Clients geben unabhängig voneinander das gleiche Produkt ein und der zentrale Server meldet einen Konflikt.

Listing 1: shop

    001 #!/usr/local/bin/perl -w
    002 use strict;
    003 use Sysadm::Install qw(:all);
    004 use Cache::FileCache;
    005 use DateTime;
    006 use DateTime::Format::Strptime;
    007 use File::Basename;
    008 
    009 my ($H)       = glob "~";
    010 my $data_dir  = "data";
    011 my $repo_name = "shop";
    012 my $repo_dir  = "$H/$data_dir/$repo_name";
    013 
    014 my $repo_url = 
    015 'mschilli@box.goofhost.com:repos/shop.git';
    016 
    017 my($action) = shift;
    018 die "usage: $0 buy|got|list ..." unless 
    019                            defined $action;
    020 
    021 mkd $repo_dir unless -d $repo_dir;
    022 
    023 my $CACHE = Cache::FileCache->new({ 
    024   cache_root  => "$H/$data_dir",
    025   cache_depth => 0,
    026   namespace   => $repo_name,
    027 });
    028 
    029 if($action eq "buy") {
    030   my($item, $days) = @ARGV;
    031   die "usage: $0 buy item days" if 
    032                          !defined $days or
    033                          $days =~ /\D/;
    034 
    035   my $rec = record_new($item, $days);
    036   if($CACHE->get($item) ) {
    037       die "$item already exists.";
    038   }
    039   $CACHE->set($item, $rec);
    040   git_commit("Added item $item");
    041 
    042 } elsif( $action eq "got" ) {
    043   my($key) = @ARGV;
    044   die "usage: $0 got item" unless 
    045                      defined $key;
    046   my $path = path_to_key( $key );
    047   git_cmd("git", "rm", "-f",
    048           basename($path));
    049   git_cmd("git", "commit", "-a", 
    050           "-m$key deleted");
    051 
    052 
    053 } elsif( $action eq "list" ) {
    054   record_list();
    055 
    056 } elsif( $action eq "push" ) {
    057   git_cmd("git", "push", 
    058           "origin", "master");
    059 
    060 } elsif( $action eq "pull" ) {
    061   git_cmd("git", "pull", 
    062           "origin", "master");
    063 
    064 } elsif( $action eq "clone" ) {
    065   cd "$H/$data_dir";
    066   rmdir $repo_name;
    067   cmd_run("git", "clone", $repo_url);
    068   cdback;
    069 
    070 } elsif( $action eq "init" ) {
    071   git_cmd("git", "init");
    072   git_cmd("git", "remote", "add",
    073             "origin", $repo_url);
    074 } else {
    075     die "Unknown action '$action";
    076 }
    077 
    078 ###########################################
    079 sub path_to_key {
    080 ###########################################
    081     my($key) = @_;
    082 
    083     return
    084       $CACHE->_get_backend()->_path_to_key(
    085                         $repo_name, $key );
    086 }
    087 
    088 ###########################################
    089 sub record_new {
    090 ###########################################
    091   my($item, $days) = @_;
    092 
    093   my $df = 
    094    DateTime::Format::Strptime->new(
    095      pattern   => "%F",
    096      time_zone => "local",
    097    );
    098 
    099   my $now = DateTime->today();
    100   my $exp = $now + 
    101       DateTime::Duration->new(
    102                           days => $days);
    103 
    104   $now->set_formatter($df);
    105   $exp->set_formatter($df);
    106 
    107   return {
    108       item     => $item,
    109       bought   => $now,
    110       expected => $exp,
    111   };
    112 }
    113 
    114 ###########################################
    115 sub record_list {
    116 ###########################################
    117 
    118   for my $key ( $CACHE->get_keys() ) {
    119     my $r = $CACHE->get( $key );
    120     print "$r->{item} ",
    121           "bought:$r->{bought} ",
    122           "exp:$r->{expected} ",
    123           "\n";
    124   }
    125 }
    126 
    127 ###########################################
    128 sub git_commit {
    129 ###########################################
    130   my($msg) = @_;
    131 
    132   cd $repo_dir;
    133   cmd_run("git", "add", ".");
    134   cmd_run("git", "commit", "-a", 
    135          "-m$msg");
    136   cdback;
    137 }
    138 
    139 ###########################################
    140 sub git_cmd {
    141 ###########################################
    142   cd $repo_dir;
    143   cmd_run(@_);
    144   cdback;
    145 }
    146 
    147 ###########################################
    148 sub cmd_run {
    149 ###########################################
    150     my($stdout, $stderr, $rc) = tap @_;
    151     if($rc != 0) {
    152         die $stderr;
    153     }
    154 }

Installation

Das Skript benötigt die CPAN-Module Sysadm::Install, Cache::FileCache, DateTime und DateTime::Format::Strptime, die am besten mit einer CPAN-Shell installiert werden.

Bevor es losgeht, wird dann erst das server-seitige leere Repository von Hand mit git installiert. Auf dem Hosting-Server sind demnach keine Perl-Module erforderlich, nur das auf Linux-Systemen normalerweise vorhandene git-Programm muss installiert sein.

Abbildung 8 zeigt, wie der erste Client sein lokales Repository initialisiert, ein paar Käufe mit shop buy ... einträgt, mit shop list die lokale Datenbank abfragt und dann mit shop push die Daten zum Server überträgt. Alle shop-Kommandos geben in bester Unix-Tradition nichts aus, falls sie erfolgreich ablaufen.

Abbildung 8: Der erste Client legt das lokale Repository an, füllt es mit Daten und frischt dann das bis dato leere Server-Repository auf.

Abbildung 9: Der zweite Client holt die Daten vom Server-Repository, fügt neue hinzu und spielt das Ergebnis wieder zum Server zurück.

Der zweite Client legt dann nach Abbildung 9 seinen lokalen Klon des Server-Repositories mit shop clone an. Auch er tätigt Käufe und markiert die Lieferung des bei Amazon bestellten iPod Nano als erfolgreich abgeschlossen. Und mit shop push spielt auch er anschließend die lokalen Änderungen wieder zum Server hoch. Ähnliches gilt für alle weiteren Clients, auch sie klonen zuerst das Server-Repository, fuhrwerken dann zunächst lokal im Repo herum, spielen neue Daten mit push zum Server zurück und erhalten die neuesten Updates der anderen Clients über einen pull vom Server. Der Bestellreigen setzt sich ungestört fort, und dank git fällt es jetzt sofort auf, falls mal eine Bestellung nicht am Cubicle eintrifft, weil die Lieferung verloren ging. Keine schlaflosen Nächte mehr, alles unter Kontrolle!

Hat sich übrigens jemand gewundert, was die falschen Schnurrbärte rechts oben im Cubicle in Abbildung 1 sollen? Nun, das ist die Ausrüstung für ``Fake Mustache Friday'' ([3]). Zugegeben, verschrobener Humor!

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2009/06/Perl

[2]

``Pragmatic Version Control Using Git'', Travis Swicegood, The Pragmatic Bookshelf``, 2008

[3]

``Fake Mustache Friday'', http://www.flickr.com/photos/sesen/2093159891/

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.