Protocol Buffer als Konfigurationsdateien?

Ich habe eine Weile lang nach einer möglichst bequemen Möglichkeit gesucht, Konfigurationsdateien zu lesen und zu schreiben, ohne mich selbst um das Parsen und Emittieren zu kümmern, außerdem sollten die Dateien nach Möglichkeit menschen-lesbar sein. Aus letztgenanntem Grund habe ich deshalb auch zuerst an XML, JSON oder YAML gedacht, die leider alle einen Nachteil haben: Die verschiedenen (mir bekannten) Parser liefern entweder eine DOM-Repräsentation oder sind nur über SAX oder etwas Artverwandtes (z.B. yaml-cpp) zu bedienen. Beides erfordert mehr Handarbeit als mir recht war, inklusive Typprüfungen die ich selber vornehmen müsste und vieles mehr.

So hab ich ein paar Wochen rumgeeiert und versucht, mit eine halbwegs brauchbare generische, typsichere und möglichst statisch initialisierbare Schnittstelle für einen YAML-Parser zu überlegen, bin aber immer wieder auf Punkte gestoßen, die mir meine verschiedenen Schnittstellenentwürfe unsympathisch gemacht haben. Also habe ich irgendwann die ganze Idee hingeworfen und nach einer schneller umzusetzenden Lösung gesucht.

Nach ein paar Stunden Grübeln bin ich dann auf die Idee gekommen, die Anforderung “menschenlesbar” etwas zu dehnen, immerhin ist XML auch nicht wirklich gut für Menschen zu lesen, geschweige denn zu schreiben. Wenn man das tut, ergibt sich eine schnelle und elegante Lösung: Google Protocol Buffer! Mit denen speichert man seine Daten zwar binär (und zwar im Allgemeinen sehr platzsparend, was aber nur ein netter Nebeneffekt ist), aber der mitgelieferte Compiler, der aus den Nachrichten-Beschreibungs-Dateien Code für verschiedene Programmiersprachen generiert, kann binär gespeicherte Daten auch dekodieren, in ein sehr angenehmes, lesbares Format konvertieren und wieder zurück. Und wenn man will, liefert Google auch gleich eine Schnittstelle mit, um direkt im Textformat zu arbeiten.

Und weil diese Lösung sich inzwischen als wirklich angenehm zu benutzen herausgestellt hat, dachte ich, teile ich meine Erfahrung mal anhand eines kleines Beispiels.

Tun wir mal so, als wollten wir einen IRC-Client schreiben, der sich verschiedene IRC-Netzwerke und -Server merken will. Der Einfachheit halber gehen wir davon aus, dass der Client eine Liste von Netzwerken kennt, zu jedem Netzwerk dessen Name, ein zu verwendender Nickname und eine Liste von Servern gehört und zu jedem Server die Adresse und der Port gespeichert werden.

Ich gehe hier nicht ins Detail bezüglich Protocol Buffern, die kann man auf der Projektseite viel besser nachlesen. Stattdessen kommen wir gleich zum Punkt und gucken uns an, wie man diese Struktur als Protocol Buffer Messages beschreiben könnte:

// irc_client_config.proto

message Server {
  required string address      = 1;
  optional uint32 port         = 2 [default = 194];
}

message Network {
  required string network_name = 1;
  optional string nickname     = 2;
  repeated Server server       = 3;
}

message IRCClientConfig {
  repeated Network network     = 1;
}

Übersetzen wir diese Datei jetzt mit protoc (was übrigens mit FindProtoBuf.cmake unter CMake wirklich bequem geht), erhalten wir eine Source- und eine Headerdatei, die wir dann in unserem Client benutzen können. In der Headerdatei finden wir zu jedem definierten Message-Typ eine Klasse, die uns direkte Zugriffe auf die open angegebenen Attribute liefert. Wir könnten jetzt mit folgendem Code eine Beispieldatei erzeugen:

#include "irc_client_config.pb.h"
#include <fstream>

int main() {
    IRCClientConfig config;
    Network* network1 = config.add_network();
    network1->set_network_name("superIRC");
    network1->set_nickname("liesschen_mueller");
    Server* server1 = network1->add_server();
    server1->set_address("irc.superirc.net");
    Server* server2 = network1->add_server();
    server2->set_address("irc.de.superirc.net");
    server2->set_port(6667);

    std::ofstream output_file("servers.config", std::ios::binary);
    config.SerializeToOstream(&output_file);
}

Wenn wir das Programm übersetzen und ausführen, bekommen wir unsere Konfigurationsdatei mit dem Inhalt

0a4b 0a08 7375 7065 7249 5243 1211 6c69  .K..superIRC..li
6573 7363 6865 6e5f 6d75 656c 6c65 721a  esschen_mueller.
120a 1069 7263 2e73 7570 6572 6972 632e  ...irc.superirc.
6e65 741a 180a 1369 7263 2e64 652e 7375  net....irc.de.su
7065 7269 7263 2e6e 6574 108b 34         perirc.net..4

der, weil binär, zunächst erstmal nicht besonders gut lesbar ist. Aber wie gesagt, protoc kann diese Binärdateien auch wieder in ein gut lesbares Format übersetzen:

$ protoc --decode=IRCClientConfig irc_client_config.proto <servers.config >servers.config.txt
$ cat servers.config.txt
network {
  network_name: "superIRC"
  nickname: "liesschen_mueller"
  server {
    address: "irc.superirc.net"
  }
  server {
    address: "irc.de.superirc.net"
    port: 6667
  }
}

Das ist doch mal eine hübsche Konfigurationsdatei! Wie weitere Server oder Netzwerke hinzuzufügen sind ist auch direkt ersichtlich. Zurückübersetzen lässt sich das ganze mit

$ protoc --encode=IRCClientConfig irc_client_config.proto <servers.config.txt >servers.config

Natürlich bringt eine Konfigurationsdatei nix, wenn man sie nicht auch wieder einlesen kann:

#include "irc_client_config.pb.h"
#include <fstream>
#include <iostream>

int main() {
    IRCClientConfig config;
    std::ifstream input_file("servers.config", std::ios::binary);
    config.ParseFromIstream(&input_file);

    for(auto network: config.networks()) {
        std::cout << "Netzwerk: " << network.network_name() << std::endl;
        std::cout << "   Nickname: " << network.nickname() << std::endl;
        for(auto server: network.servers()) {
            std::cout << "    Server " << server.address() << ":" << server.port() << std::endl;
        }   
    }   
}

Um die Dateien direkt im Textformat zu lesen, müssen nur die Header für das Text-Format (google/protobuf/text_format.h) und Input-Streams (google/protobuf/io/zero_copy_stream_impl.h) hinzugefügt werden und das Öffnen des Streams und die Zeile mit ParseFromIstream ersetzt werden, durch

    std::ifstream input_file("servers.config.txt");
    google::protobuf::io::IstreamInputStream input(&input_file);
    google::protobuf::TextFormat::Parse(&input, &config);

Das Schreiben direkt im Textformat funktioniert genauso (istreams durch ostreams ersetzen und statt Parse, die Funktion Print aufrufen), Details gibt es hier.

Für mich funktioniert diese Methode mit meinen Konfigurationsdateien umzugehen sehr gut und ich bin froh, den leichten Weg gegangen zu sein (das Ganze waren insgesamt vielleicht 30 Minuten Arbeit um eine erste Version für mein Projekt, das übrigens nichts mit IRC zu tun hat, zum Laufen zu bringen). Ich hoffe, das es vielleicht auch für andere nützlich ist. Lasst es mich wissen, wenn ihr das genauso macht und was ihr für Erfahrungen damit habt!

Tags: , , , ,

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: