Ein RFC822 Parser mit boost::spirit bauen

2011-03-05 20:30

Habe dieses Wochenende einen Parser für RFC822 in boost::spirit geschrieben. Hintergrund ist, dass in RSS das Datenformat eben jenes RFC ist, und Qt es nicht sofort parsen kann, zumindest ergab dies eine erste Recherche. Ich fand aber im Netz einen alten boost::spirit Parser, im spirit Application Repository. Dieser ist in spirit 1.x implementiert, welches aber nicht zur aktuellen spirit Version kompatibel mehr ist. Und da ich sowieso gerne mal wieder mit spirit rumspielen wollte, dachte ich: "super Gelegenheit!"
Ich plane nämlich einen RSS Client zu schreiben, weil dass ja sehr einfach ist als Protokoll. Alle Feedformate sind XML, und vom Aufbau her trivial. Den Client würde ich dann auch versuchen auf mobile Plattformen zu portieren, bzw. halt direkt mit Qt zu schreiben, um damit Symbian, MeeGo, Windows, Linux und Android abdecken zu können. Weil ich finde Feeds auf dem Handy sehr praktisch.

Parserbau


Wie gesagt, hat Peter Simons einen entsprechenden RFC822 Parser im spirit Application Repository veröffentlicht, welchen ich nun auf eine neuere Version von spirit portierte. Sein Parser schreibt die Ergebnisse in ein von tm abgeleitetes struct, ich hatte es erst mit boost::date_time versucht. Aber mich später dann auch für seine Version mit tm entschieden, auch weil sie portabler ist:

struct timestamp : public tm
{
    timestamp() { memset(this, 0, sizeof(*this)); }
    int tzoffset;
};

Fast alle Datenklassen wie boost::date_time oder QDateTime lassen sich ja aus einem struct tm erstellen.
Dann gibt es noch die Besonderheit, dass Wochentage, Monate und Zeitzonen abgekürtzt im Datenstring als Strings stehen können. In boost::spirit kann man dies mit einem symbol table entsprechend auf zahlen mappen, da wir für tm z.b. bei den Wochentagen 0 - 6 benötigen statt "Fri" oder "Mon":

struct weekday_parser : boost::spirit::qi::symbols<char,int>
{
    weekday_parser()
    {
        add ("sun", 0)("mon", 1)("tue", 2)("wed", 3)
        ("thu", 4)("fri", 5)("sat", 6);
    }
};

Mit add wird die Symboltabelle für den späteren Gebrauch gefüllt. Man könnte auch direkt ein symbols<char,int> instanziieren und entsprechend füllen, ich habe hier erstmal die Klassen aus dem vorherigen Parser kopiert und angepasst. Bleibt noch eine spannende Frage, nämlich die der Lokalisierung, was wenn hier nicht englische Wochentagsbezeichner sondern deutsche genutzt werden? Der Parser könnte natürlich um ("son",0),("die",2),... ergänzt werden, aber eine generelle Lokalisierung fehlt hier im Code noch. Für RSS und viele Vorkommen vom RFC822 ist dies aber nicht relevant. Die Parser für die Monate und Zeitzonen sehen ähnlich aus.

Hauptgrammatik

Der eigentliche Parser ist dann eine Templateklasse welche von boost::spirit::qi::grammar abgeleietet ist:

template<class Iterator>
class rfc822 : public boost::spirit::qi::grammar<Iterator,void(),skipper<Iterator> >

Die Hauptgrammatik im Detail:

start = lexeme[ no_case[weekday[boost::phoenix::ref(dt.tm_wday) = _1] ] ] >> ','// wochentag auslesen und mit ref in die variable schreiben
            >> date
            >> time
            >> zone;
date = uint_  // day
    >> lexeme[no_case[ month ] ] //month
    >> (limit(0u,99u) //year
         |min_limit(1900u) ); // year
time = uint_ >> ':' //hour
    >> uint_//minute
    >> -( char_(':') >> uint_ );// second (optinal)
zone = char_('+') >> uint_ //timezone
    | char_('-') >> uint_ //timezone
    | lexeme[ no_case[ timezone  ] ];//timezone

Der Code ist für spirit Kenner recht selbsterklärend, die Bereiche wo wie in Zeile 1 zu sehen ist mit phoenix::ref in die timestamp Instanz geschrieben wird habe ich mal entfernt. Der Übersichtlichkeit halber. An der ersten Regel start lässt sich erkennen, dass ein Datum nach RFC822 aus Wochentag, Datum Zeit (und) Zeitzone zusammensetzt.
Mit lexeme wird erreicht, das nur zusammenhängender Text geparst wird, also Fri aber nicht F r i. no_case bewirkt wiederum das der Parser den Input nicht mehr case sensitiv behandelt. Dies ist besonders wichtig für unsere Symboltabellen.

Bis hier hin ist die Portierung sehr einfach gewesen, die Regeln und Klassen mussten nur Trivial angepasst werden. Und ich konnte mit spirit 2.x in den letzten Jahren schon viel Erfahrung sammeln und habe einige Parser damit geschrieben, es blieb bei der Portierung aber noch ein letztes Problem. Im original Code wurde bei der Jahresregel limit_d und min_limit_d benutzt, welche aber keine direkten "Nachfahren" in spirit 2.x besitzen. Erst eine suche nach limit_d im Archiv der spirit Mailingliste brachte den entscheidenden Hinweis, dies lässt sich mit eps in spirit 2.x lösen:

limit %= uint_[_a = _1] >> eps( _a <=_r2 && _a >= _r1);
min_limit %=   uint_[_a = _1] >> eps( _a >= _r1);

Mit eps lässt sich über ein local<int> überprüfen, ob die vorher eingelesene Zahl der in eps angegebenen Regel entspricht. Für RFC822 benötigen wir dies für das Jahr, da wir hier entweder 0 - 99 oder ein 4 stelliges Datum > 1900 erwarten bzw. benötigen. Letzteres hat mit struct tm zu tun, da dort alle Jahre -1900 gespeichert werden, 2011 ist dort also entsprechend 111.

Damit ist die Hauptgrammatik geschrieben, die Klasse rfc822 bekommt im Konstruktor eine Referenz auf ein timestamp Objekt, und liest in diese das Datum ein. Schlägt das Parsing fehl, hat dies natürlich den Vorteil/Nachteil das dieses Objekt unter Umständen schon die vorher eingelesenen Elemente enthält.

Ergänzungen


Eigentlich ist damit der Parser fertig, es fehlen nur noch 2 Dinge für die eigentliche Anwendung, eine Templatefunktion welche den Parser aufruft, damit dies nicht im eigentlichen Produktionscode stehen muss:

template <class Iterator>
bool parse_date(Iterator first, Iterator last,DateTimeType & dt)
{
    using boost::spirit::qi::_1;
    skipper<Iterator> space;
    rfc822< Iterator> RFC822(dt);
    bool r = boost::spirit::qi::phrase_parse(first, last, RFC822,space);
    if (first != last) // fail if we did not get a full match
        return false;
    return r;
}

DateTimeType ist ein Typedef auf unsere timestamp Klasse, die Funktion ruft dann phrase_parse aus spirit auf, und übergibt ihr die entsprechenden Parameter und das Parserobjekt. Die Klasse skipper ist eine entsprechende Grammatik für das überlesen von Whitespaces und Kommentaren.
RFC822 erlaubt nämlich Kommentare in der Form von (Hier steht ein Kommentar), dies wollen wir natürlich überlesen. Denn standardmässig überliest spirit ja nur Whitespace-Zeichen (" \t\r\n"):

template<class Iterator>
class skipper : public boost::spirit::qi::grammar<Iterator>
{
    boost::spirit::qi::rule<Iterator> start;
public:
    skipper():skipper::base_type(start)
    {
        using namespace boost::spirit::qi;
        start = space | char_('(') >> *(char_('\\') >> char_ | char_ -char_(')'))>> char_(')');
    }
};

Damit ist nun der Parser für das RFC822 fertig, in einer ersten Version.
Testen lässt sich das dann einfach mit einem Aufruf, wie diesem hier:

std::string date = "Fri, (my test comment)\n04 Mar 2011 19:25:02 +0000";
DateTimeType dt;
if(parse_date(date.begin(),date.end(),dt))
...

Den Code gibts auch hier zum herunterladen.


Zurück