Ein Perlskript mit flexibler Plugin-Architektur zeigt auf Kommando die aktuellen Vermögensverhältnisse an.
Wer wie Dagobert Duck gerne sein Geld zählt, muss dafür nicht mehr kopfüber in den Geldspeicher springen: Programme wie GnuCash unterstützen bei der Buchführung und geben Kontostände sauber formatiert oder sogar grafisch gestaltet aus.
Allerdings verlangen die in der Tradition von Quicken und Microsoft Money stehenden freien Ableger eiserne Disziplin bei der Buchführung. Doch welcher Freizeitbuchhalter hat schon die Zeit, minutiös alle Ausgaben einzutragen? Und mal ganz abgesehen von der Installationsorgie, die GnuCash verlangt, wenn es nicht gerade schon einer Distro beiliegt, birgt es einen weitere Hürde: Es ist nicht einfach und beliebig erweiterbar.
Das heute vorgestellte Perlskript ist für kleine Dagoberts, die nur einmal im Monat 10 Minuten Arbeit dafür erübrigen, Kontostände aufzufrischen, dann aber täglich mit Online verfügbaren Börsenkursen ruckzuck ihr Hab und Gut in bare Münze umrechnen können.
Außerdem lässt sich das System durch eine flexible Plugin-Architektur beliebig erweitern. Währungsumrechnungen oder für den Einzelfall anwendbare Steuergesetze lassen sich so schnell einarbeiten und das System an spezielle private Bedürfnisse anpassen, ohne gleich Bloatware zu erzeugen.
Freilich kann man's auch übertreiben: Für Aktien-Splits, die sich alle paar Jahre mal ereignen, braucht man kein extra Modul, die kann man auch von Hand korrigieren. Also Vorsicht: ``Wer jedermanns Liebling sein will, ist irgendwann jedermanns Depp''.
Das heute vorgestellte Skript dagobert
sucht den Mittelweg. Es bietet Basisfunktionen, um den Inhalt
mehrerer Konten und Aktiendepots zusammenzurechnen, überlässt es aber dem
Anwender, Plugins für spezielle Anforderungen einzuhängen.
Abbildung 1: Definition der Kontodaten in einer Konfigurationsdatei, die gleichzeitig ein ausführbares Skript ist. |
Abbildung 2: Geldzähler dagobert in Aktion: Von der Kommandozeile aus aufgerufen, zeigt er Einzelkonten und Gesamtvermögen farblich aufgebessert an. |
Kontendaten definiert der Anwender in einer Datei money
nach Abbildung 1. Ein neues Konto definiert das Schlüsselwort
account
, eine Aktienposition startet mit stock
und Bargeldbestand
mit cash
.
Der Interpreter dieser Finanzdaten ist das Skript
dagobert
aus Listing 1, das die Kontodefinitionen einliest, aktuelle
Börsenkurse einholt und die Gewinne, Verluste und Gesamtstand ausrechnet.
Das Finanz-Skript könnte man nun mit
dagobert money
von der Kommandozeile aus
aufrufen, aber es geht noch einfacher: Die Konfigurationsdatei
money
wird ausführbar
gemacht und der dagobert
-Interpreter in die She-Bang-Zeile
eingehängt. So läuft money
als Skript ab, nicht mit
perl
als Interpreter, sondern mit dagobert
.
Wenn man aber nicht gerade unter der hypermodernen zsh
-Shell
arbeitet, sondern mit der guten alten bash
, die
Skripts und Programme ohne Magie gleich vom Kernel ausführen lässt, sind in
der She-Bang-Zeile keine Skripts zugelassen. Statt dessen
wird einfach schnell ein C-Wrapper in einem C-Programm
dago.c
herumgewickelt:
main(int argc, char **argv) { execv("/usr/bin/dagobert", argv); }
Compiliert man dago.c
anschließend einfach mit
cc -o dago dago.c
dann lässt sich das Executable dago
im She-Bang als
Interpreter der Finanzdaten verwenden:
#!/usr/bin/dago account DeutscheBank stock SIEGn.DE 10 62.38 # ...
Und falls nun die diesen 'Code' enthaltende Datei money
ausführbar ist,
genügt der Aufruf money
, um den Geldzähler zu starten. Was eigentlich
aussieht wie eine Konfigurationsdatei, ist in Wirklichkeit ein
ausführbares Skript. Abbildung 2 zeigt die Ausgabe. Praktisch!
01 #!/usr/local/bin/perl -w 02 ########################################### 03 # dagobert - Money Counting Interpreter 04 # Mike Schilli, 2004 (m@perlmeister.com) 05 ########################################### 06 use strict; 07 08 use lib '/home/mschilli/perl-modules'; 09 use Log::Log4perl qw(:easy); 10 Log::Log4perl->easy_init($ERROR); 11 use Plugger; 12 my $string = join '', <>; 13 14 my $plugger = Plugger->new(); 15 $plugger->init(); 16 $plugger->parse($string);
Der Interpreter in Listing dagobert
ist sehr kurz gehalten: Er erzeugt
lediglich eine neue Instanz eines Objektes vom Typ Plugger
, initialisiert
die dahinterliegende Plugin-Architektur mit init()
und übergibt die vorher von der Standardeingabe (mittels <>
)
eingelesenen Konfigurationsdaten der parse()
-Methode des Plugin-Systems.
Das in Listing Plugger.pm
gezeigte Framework interpretiert das
jeweils erste Wort einer Zeile als Kommando. Ohne Plugins ist das Framework
jedoch nicht in der Lage, irgendein Kommando zu interpretieren. Lediglich
Kommentarzeilen, die mit einem '#' beginnen, verwirft es vorsorglich.
Abbildung 3: Die Plugin-Architektur: Der Plugin-Verwalter Plugger.pm liest mit Module::Pluggable alle Plugins unter C |
Jedes unter dem Verzeichnis Plugger/
hinzugefügte Modul liest Plugger.pm
während der Compile-Phase automatisch ein. Dies erledigt das in Zeile
6 eingebundene CPAN-Modul Module::Pluggable, denn das require
-Flag
ist gesetzt und der Suchpfad Plugger
, relativ zum aktuellen Verzeichnis
oder einem @INC
-Pfad.
Diese Plugins haben keinen
new()
-Konstruktor, wie in der Objektorientierung üblich, sondern eine
init()
-Funktion, die Plugger.pm
als Herr aller Plugins für jedes
gefundene Plugin-Modul nacheinander aufruft. Module::Pluggable
fügt in seinen Wirt (Plugger
in diesem Fall)
automatisch eine Methode plugins()
ein, die die Namen aller gefundenen Plugins als Liste zurückgibt.
Zeile 28 in Plugger.pm
nutzt dies, um durch die init()
-Funktionen
aller Plugins zu orgeln.
01 ########################################### 02 package Plugger; 03 ########################################### 04 use strict; use warnings; 05 06 use Module::Pluggable 07 require => 1, 08 search_path => [qw(Plugger)]; 09 10 our %DISPATCH = (); 11 our %MEM = (); 12 13 ########################################### 14 sub new { 15 ########################################### 16 my($class) = @_; 17 18 bless my $self = {}, $class; 19 20 return $self; 21 } 22 23 ########################################### 24 sub init { 25 ########################################### 26 my($self) = @_; 27 28 print "plugins=", join('-', $self->plugins()), "\n"; 29 $_->init($self) for $self->plugins(); 30 } 31 32 sub mem { return \%MEM; } 33 34 ########################################### 35 sub parse { 36 ########################################### 37 my($self, $string) = @_; 38 39 for(sort keys %DISPATCH) { 40 $DISPATCH{$_}->{start}->($self) if 41 $DISPATCH{$_}->{start}; 42 } 43 44 for(split /\n/, $string) { 45 46 s/#.*//; 47 next if /^\s*$/; 48 last if /^__END__/; 49 chomp; 50 51 my($cmd, @args) = split ' ', $_; 52 53 die "Unknown command: $cmd" unless 54 exists $DISPATCH{$cmd}; 55 56 $DISPATCH{$cmd}->{process}->($self, 57 $cmd, @args); 58 } 59 60 for(sort keys %DISPATCH) { 61 $DISPATCH{$_}->{finish}->($self) if 62 $DISPATCH{$_}->{finish}; 63 } 64 } 65 66 ########################################### 67 sub register_cmd { 68 ########################################### 69 my($self, $cmd, $start, 70 $process, $finish) = @_; 71 72 $DISPATCH{$cmd} = { 73 start => $start, 74 process => $process, 75 finish => $finish, 76 }; 77 } 78 79 1;
Damit der Plugin auch weiss, wer der Aufrufer ist und gegebenenfalls
dessen Methoden aufrufen kann, übergibt Plugger.pm
der init()
-Methode
des Plugins jeweils eine Referenz $ctx
(von Context).
Dahinter steckt nichts anderes
als eine Referenz auf das einzige existierende Plugger
-Objekt, dem
Plugin-Verwalter.
Damit kann Plugin nun seinerseits Anweisungen an den Verwalter Plugger
schicken. Da Plugger
die
Kommandos in einer Konfigurationsdatei interpretiert,
ruft der Plugin die Verwalter-Methode register_cmd()
auf, um neue Kommandos
zu registrieren.
In Listing Account.pm
, das einen im Verzeichnis Plugger/
hängenden
Plugin zeigt, nutzt der Plugin dies, um dem Pluginverwalter das Kommando
account
beizubringen:
$ctx->register_cmd("account", \&start, \&process, \&finish);
Gemäß den Regeln des Plugger
-Frameworks definiert dies, dass Plugger
nach der Interpretation des Schlüsselwortes account
in der
Konfigurationsdatei die Funktion process()
in Plugger/Account.pm
aufruft
und ihr die aufgesplitteten Teile der Konfigurationszeile als
Argumente überreicht.
Außerdem wird Plugger.pm
die Funktion start()
aus Plugger/Account.pm
aufrufen, bevor die Interpretation der Konfigurationsdatei beginnt und
die finish()
-Funktion des Plugins, nachdem die Arbeit erledigt ist.
001 ########################################### 002 package Plugger::Account; 003 # 2004, Mike Schilli <m@perlmeister.com> 004 ########################################### 005 006 use strict; 007 use warnings; 008 use Term::ANSIColor qw(:constants); 009 010 ########################################### 011 sub init { 012 ########################################### 013 my($class, $ctx) = @_; 014 015 $ctx->register_cmd("account", 016 \&start, \&process, \&finish); 017 } 018 019 ########################################### 020 sub start { 021 ########################################### 022 my($ctx) = @_; 023 024 $ctx->mem()->{account_total} = 0; 025 } 026 027 ########################################### 028 sub account_start { 029 ########################################### 030 my($ctx, $name) = @_; 031 032 print BOLD, BLUE, 033 "Account: $name\n", 034 RESET; 035 036 $ctx->mem()->{account_subtotal} = 0; 037 $ctx->mem()->{account_current} = $name; 038 } 039 040 ########################################### 041 sub account_end { 042 ########################################### 043 my($ctx, $name) = @_; 044 045 print BOLD, BLUE; 046 printf "%-47s %9.2f\n\n", "Subtotal:", 047 $ctx->mem()->{account_subtotal}; 048 print RESET; 049 } 050 051 ########################################### 052 sub account_end_all { 053 ########################################### 054 my($ctx) = @_; 055 056 print BOLD, BLUE; 057 printf "%-47s %9.2f\n\n", "Total:", 058 $ctx->mem()->{account_total}; 059 print RESET; 060 } 061 062 ########################################### 063 sub process { 064 ########################################### 065 my($ctx, @args) = @_; 066 067 my $c = $ctx->mem()->{account_current}; 068 account_end($ctx, $c) if $c; 069 account_start($ctx, $args[1]); 070 } 071 072 ########################################### 073 sub finish { 074 ########################################### 075 my($ctx) = @_; 076 077 my $c = $ctx->mem()->{account_current}; 078 account_end($ctx, $c) if $c; 079 account_end_all($ctx); 080 } 081 082 ########################################### 083 sub position { 084 ########################################### 085 my($type, $ticker, $n, $at, $price, 086 $value, $gain) = @_; 087 088 unless(defined $ticker) { 089 printf "%-47s %9.2f\n", 090 $type, $value; 091 return; 092 } 093 094 my $clr = $gain > 0 ? GREEN : RED; 095 096 printf "%-8s %-10s %9.3f %9.3f %7.2f" . 097 " %9.2f %s(%+9.2f)%s\n", 098 $type, $ticker, $n, $at, 099 $price, 100 $value, $clr, $gain, RESET; 101 } 102 103 1;
Der Plugin Account.pm
nutzt dies, um vor Beginn des Parse-Reigens
den in der globalen Variablen account_total
gespeicherten Gesamtwert
aller definierten Konten auf 0 zu setzen.
Doch wo definiert man einen
derartigen Zähler, auf den womöglich nicht nur Account.pm
, sondern
auch andere Plugins Zugriff haben sollen?
Hierfür definiert das Modul Plugger.pm
einen Hash %MEM
, und
es wird jedem, der mit dem Accessor mem()
danach fragt, eine Referenz
darauf zuspielen. So kann ein Plugin wie Account.pm
mit
$ctx->mem()->{account_total} = 0;
eine Variable setzen, die jeder andere Plugin ebenso zugreifen
kann, der ebenfalls mit $ctx
eine Referenz auf den
Plugin-Verwalter Plugger
besitzt.
Auf diese Weise kommunizieren die zwei Plugins Account.pm
und
Position.pm
miteinander: Account.pm
setzt account_total
am Anfang auf 0, und Position.pm
, das mit jeder stock
- oder
cash
-Definition
drankommt, rechnet deren Wert aus und addiert ihn zu account_total
hinzu.
01 ########################################### 02 package Plugger::Position; 03 # 2004, Mike Schilli <m@perlmeister.com> 04 ########################################### 05 use strict; use warnings; 06 use Log::Log4perl qw(:easy); 07 use Finance::YahooQuote; 08 use Term::ANSIColor; 09 10 ########################################### 11 sub init { 12 ########################################### 13 my($class, $ctx) = @_; 14 15 DEBUG "Registering @_"; 16 17 $ctx->register_cmd("stock", 18 undef, \&process, undef); 19 $ctx->register_cmd("cash", 20 undef, \&process_cash, undef); 21 $ctx->register_cmd("txcash", 22 undef, \&process_tx_cash, undef); 23 } 24 25 ########################################### 26 sub process { 27 ########################################### 28 my($ctx, $cmd, @args) = @_; 29 30 my $value = price($args[0]) * $args[1]; 31 my $gain = $value - 32 $args[2] * $args[1]; 33 34 Plugger::Account::position( 35 ucfirst($cmd), 36 @args[0..2], price($args[0]), 37 $value, $gain); 38 39 my $mem = $ctx->mem(); 40 $mem->{account_subtotal} += $value; 41 $mem->{account_total} += $value; 42 } 43 44 ########################################### 45 sub process_cash { 46 ########################################### 47 my($ctx, $cmd, @args) = @_; 48 49 my $mem = $ctx->mem(); 50 $mem->{account_subtotal} += $args[0]; 51 $mem->{account_total} += $args[0]; 52 53 Plugger::Account::position( 54 ucfirst($cmd), 55 (undef) x 4, $args[0], undef); 56 } 57 58 ########################################### 59 sub process_tx_cash { 60 ########################################### 61 my($ctx, $cmd, @args) = @_; 62 63 $args[0] /= 2.0; 64 process_cash($ctx, $cmd, @args); 65 } 66 67 use Cache::FileCache; 68 69 my $cache = Cache::FileCache->new( 70 { namespace => 'Dagobert', 71 default_expires_in => 600, 72 }); 73 74 ########################################### 75 sub price { 76 ########################################### 77 my($stock) = @_; 78 79 DEBUG "Fetching quote for $stock"; 80 81 my $cached = $cache->get($stock); 82 83 if(defined $cached) { 84 DEBUG "Using cached value: $cached"; 85 return $cached; 86 } 87 88 my @quote = getonequote $stock; 89 die "Cannot get quote for $stock" 90 unless @quote; 91 $cache->set($stock, $quote[2]); 92 93 return $quote[2]; 94 } 95 96 1;
Damit Account.pm
die Kopfzeilen eines Kontos und dessen Stand schön
fett und in blau anzeigt, nutzt es das CPAN-Modul Term::ANSIColor
.
Dessen :constants
-Tag am Ende der use
-Anweisung veranlasst es,
'Konstanten' wie BLUE
(blauer Text), BOLD
(Fettdruck) und
RESET
(zurück zur Normalschrift) in den Namensraum des aufrufenden
Skripts zu exportieren. Anschließend geben print
-Anweisungen
wie
print BLUE, BOLD, "In blau und fett!", RESET;
spezielle ANSI-Sonderzeichen aus, die den angegebenen Text
im aktuellen Terminal fett und blau markieren, bevor RESET
für die nächste print
-Anweisung wieder in den Normalmodus
zurückschaltetet.
Aktuelle (gut, 20 Minuten verzögerte) Börsenkurse holt Position
einfach mit Finance::YahooQuote von Yahoos Finanzseite. Die exportierte
Funktion getonequote()
nimmt ein Tickersymbol wie
BMWG.DE
für die deutsche Notierung der BMW-Aktie oder
EBAY
für die Ebay-Notierung an amerikanischen Börsen.
Eine ausführliche Liste deutscher Tickersymbole findet sich auf [3].
Da dagobert
aber unter Umständen denselben Kurs mehrmals braucht, puffert
Position
die Kurse einfach in einem 10 Minuten lang bestehenden
Cache. Das Modul Cache::Cache
vom CPAN bietet eine denkbar einfache
Schnittstelle: set()
um einen Cache-Eintrag zu setzen und get()
um ihn wieder zu holen. An Implementierungen bietet es unter anderem
einen In-Memory-Cache namens Cache::MemoryCache
und mit
Cache::FileCache
einen Datei-gestützten persistenten Cache.
Position.pm
legt mit
my $cache = Cache::FileCache->new( { namespace => 'Dagobert', default_expires_in => 600, });
ein neues Cache-Objekt an und kümmert sich um alle Details, wie
die effiziente Speicherung in temporären Dateien ohne mit
anderen Applikationen zu kollidieren. Der Anwender braucht nur noch
$cache->set()
und $cache->get()
aufzurufen.
Für eine einfache Kontoanzeige ist dagobert
natürlich vollkommen
Over-Engineered, offensichtlich das Werk eines
Architektur-Astronauten wie Joel Spolsky es in [4] so treffend formuliert!
Das Plugger
-Framework läuft allerdings erst zu seiner
vollen Spielstärke auf, wenn es darum geht, benutzerspezifische
Funktionalität einzuhängen, ohne den ursprünglichen Code
zu verändern.
Der Plugin Plugger/TaxedPosition.pm
zeigt ein solches Beispiel: Er
verbucht bei mit txstock
definierten Aktien eventuelle
Gewinne nicht 1:1, sondern zieht vorher 50% Abgaben ab.
Im diesem ``Ab auf die Insel''-Modus lässt sich
ausrechnen, wieviel Geld man bei einer sofortigen Realisierung von
eventuellen Aktiengewinnen und
einem virtuellen Steuersatz von 50% übrig behielte.
Bei Aktienverlusten zieht TaxedPosition
nichts ab, sondern zeigt den
Wert der Depotposition an, die nach dem Verkauf der Versageraktie
übrigbliebe.
Je nach persönlicher Situation kann nun der Anwender Plugins für weitere Schlüsselworte schreiben, ins Framework einhängen und das System anpassen.
Da TaxedPosition.pm
auf die in Position.pm
definierte Funktion
price()
zurückgreift, liegt es nahe, eine Art Vererbung oder
Interface-Mechanismus zwischen
TaxedPosition.pm
und Position.pm
einzusetzen.
Da im Plugger
-Framework jedoch keine Klassen im Spiel sind, definiert
TaxedPosition.pm
in Zeile 9 einfach einen AUTOLOAD
-Handler,
der Aufrufe von unbekannten Funktionen nach Position.pm
kanalisiert.
So übernimmt der Plugin Position.pm
aus Gründen der Übersichtlich-
und Wartbarkeit alle sichtbaren Bildschirmausgaben. Die Funktion
position()
nimmt die Daten einer Position mit Typ, Tickersymbol,
Anzahl, Kaufkurs, aktuellem Kurs, aktuellem Gesamtwert und Gewinn/Verlust
entgegen und gibt sie schön formatiert aus. Für Bargeldposten
entfällt alles außer der linken und der rechten Spalte.
Weitere eingefügte Plugins sollten, wie TaxedPosition.pm
, ebenfalls
auf die print
-Funktionen von Position.pm
zurückgreifen. Auch die
Funktion price()
aus Position.pm
dürfte für den ein oder
anderen Plugin von Nutzen sein.
01 ########################################### 02 package Plugger::TaxedPosition; 03 # 2004, Mike Schilli <m@perlmeister.com> 04 ########################################### 05 use strict; use warnings; 06 use Log::Log4perl qw(:easy); 07 08 ########################################### 09 sub AUTOLOAD { 10 ########################################### 11 no strict qw(vars refs); 12 13 (my $func = $AUTOLOAD) =~ 14 s/.*::/Plugger::Position::/; 15 $func->(@_); 16 } 17 18 ########################################### 19 sub init { 20 ########################################### 21 my($class, $ctx) = @_; 22 23 $ctx->register_cmd("txstock", 24 undef, \&process, undef); 25 } 26 27 ########################################### 28 sub process { 29 ########################################### 30 my($ctx, $cmd, @args) = @_; 31 32 my $value = price($args[0]) * $args[1]; 33 my $gain = $value - 34 $args[2] * $args[1]; 35 36 my $tax = $gain / 2; 37 38 $value -= $tax if $gain > 0; 39 $gain -= $tax if $gain > 0; 40 41 Plugger::Account::position( 42 ucfirst($cmd), 43 @args[0..2], price($args[0]), 44 $value, $gain); 45 46 my $mem = $ctx->mem(); 47 $mem->{account_subtotal} += $value; 48 $mem->{account_total} += $value; 49 } 50 51 1;
Sowohl das Skript dagobert
als auch der kompilierte C-Wrapper dago
sollen ins Verzeichnis /usr/bin
und dort ausführbar sein. Das Modul
Plugger.pm
und alle Plugins unter Plugger/
sollten irgendwo in
einen der @INC
-Pfade der Perl-Installation. Alternativ kann die Zeile
use lib '/home/mschilli/perl-modules';
im Perlskript dagobert
den Pfad bekanntmachen, falls Plugger & Co. unter
dem angegebenen Verzeichnis installiert sind. Die Module Module::Pluggable
,
Finance::YahooQuote
und Term::ANSIColor
stehen auf dem CPAN
bereit und lassen sich am einfachsten mit einer CPAN-Shell installieren.
Auf geht's, zählt euren Zaster!
TODO: * Finance::Rounding * SWISH::API auf laeppi
Michael Schilliarbeitet 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. |