Créer un système de log

Aurélien Vallée

Dans cet article nous allons réaliser un système de log pour une application C++. L'objectif est d'obtenir quelquechose de modulaire (pouvoir ajouter simplement des sorties différentes) et de simple à utiliser (chainâge des messages et transtypages lexicaux).

- Titre : Créer un système de log

- Auteur : Aurélien Vallée

- Parution : 8 juin 2008

- Licence : Copyright ® 2008 Aurélien Vallée. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Aperçu général du logger

- II. Les loggers atomiques

- II-A. Une interface commune

- II-B. Exemples de loggers atomiques

- III. Le conteneur de loggers

- III-A. Classes utilitaires

- III-B. Le LoggerHolder

- IV. Le mode debug

- V. Critiques

I - Aperçu général du logger

Notre logger va être découpé en 3 parties distinces.
Premièrement nous allons trouver les loggers atomiques. Ce sont de petites classes très spécialisées qui ont pour unique but de récupérer un message, et de l'inscrire quelque part (fichier, console, réseau, ...).
Ensuite, nous trouverons une interface, commune à chacun des petites modules de log.
Dernièrement, nous aurons un conteneur pour tous ces loggers différents, qui centralisera l'arrivée des messages, et fera un dispatch vers les loggers atomiques.

Voici un aperçu de ce que nous souhaitons pouvoir faire:

LOG( "Hello from " << 'C' << '+' << '+' << " logger release" << 1 << '.' << 0 )

Dernière chose tant que nous y sommes, il serait pratique que les messages de log puissent être desactivés (lors d'une génération release par exemple).
Nous utiliserons pour celà une constante préprocesseur NLOG (messages desactivés).

Si vous utilisez Make/GCC, il vous suffit pour celà de rajouter une ligne à votre makefile, par exemple :

CC=g++
CFLAGS=-c -Wall
LOG=NLOG

all: hello

hello: hello.o
$(CC) hello.o -o hello -D$(LOG)

clean:
rm -rf *o hello

Si vous utilisez Visual Studio, vous n'avez qu'à vous rendre dans les propriétés de votre projet sous l'onglet C++/Préprocesseur et d'ajouter NLOG ou LOG

Ajouter une constante préprocesseur prédéfinie sous Visual Studio 2005

II - Les loggers atomiques

L'intéret d'un système de log basé sur de multiples loggers atomiques, est que ces derniers soient particulièrement simples à implémenter. Si ce n'était le cas, alors autant créer un logger monolithique.
Nous tacherons donc de minimiser au maximum les dépendances dans nos loggers atomiques.

II-A - Une interface commune

Commençons par définir une interface qui sera commune à tous nos loggers atomiques.
Typiquement, tout ce qu'elle a à faire, c'est de fournir une méthode write pour écrire du texte.

#ifndef LOGGER_HH
#define LOGGER_HH

#include <string>

class Logger
{
public:
virtual ~Logger   ( void ) {}
virtual void write( const std::string& ) = 0;
};

#endif // LOGGER_HH

C'est vraiment tout ce que nous avons à faire pour notre interface, tout le reste du travail, sera d'implémenter write pour ses classes filles.

II-B - Exemples de loggers atomiques

Nous allons tenter d'illustrer un peu la création de loggers atomiques, en implémentant deux petits modules de sortie pour la console et pour un fichier.

Le plus trivial est bien entendu la console, voici ce que celà donne:

#ifndef LOGGERCONSOLE_HH
#define LOGGERCONSOLE_HH

#include <string>
#include <iostream>
#include "logger.hh"

class LoggerConsole : public Logger
{
public:
virtual void write( const std::string& msg )
{
std::cout << msg << std::endl;
}
};

#endif // LOGGERCONSOLE_HH

Essayons maintenant de fournir un logger atomique un peu plus évolué prenant en charge l'écriture des messages dans un fichier de log.
L'implémentation donnée ici est spécifique à Windows, mais la place est laissée pour une éventuelle version Linux et MacOSX.

Créez un fichier platform.hh contenant par exemple:

#if defined linux || defined __linux__ || defined __linux
#  define PLATFORM_LINUX
#  define PLATFORM_NAME "Linux"
#endif

// Plateforme Windows
#if defined _WIN32 || defined WIN32 || defined __NT__ || defined __WIN32__
#  define PLATFORM_WIN32
#  define PLATFORM_NAME "Windows"
#endif

// Plateforme MacOS X
#if ( defined __MWERKS__ && defined __powerc && !defined macintosh ) || defined __APPLE_CC__ || defined macosx
#  define PLATFORM_MACOSX
#  define PLATFORM_NAME "MacOS X"
#endif

Tant que nous sommes dans les classes utilitaires, nous pouvons donc ajouter un fichier time.hh contenant:

#ifndef TIME_HH
#define TIME_HH

#include <string>
#include "platform.hh"

#ifdef PLATFORM_WIN32
#  include <atltime.h>
#elif  PLATFORM_LINUX
//TODO: gérer linux
#elif  PLATFORM_MACOSX
//TODO: gérer macosx
#endif

class Time
{
public:
static const std::string getTime()
{
std::ostringstream oss;

#ifdef PLATFORM_WIN32
CTime time = CTime::GetCurrentTime();
oss << time.GetHour()
<< ":"
<< time.GetMinute()
<< ":"
<< time.GetSecond();
#else
//TODO: Linux and MacOS X
#endif

return oss.str();
}

static const std::string getDate()
{
std::ostringstream oss;

#ifdef YASHMUP_PLATFORM_WIN32
CTime time = CTime::GetCurrentTime();
oss << time.GetDay()
<< "/"
<< time.GetMonth()
<< "/"
<< time.GetYear();
#else
//TODO: Linux and MacOS X
#endif

return oss.str();
}
};

#endif // TIME_HH

Nous avons donc maintenant tous les outils pour pouvoir créer un logger atomique chargé d'enregistrer les messages dans un fichier!

#ifndef LOGGERFILE_HH
#define LOGGERFILE_HH

#include <string>
#include <fstream>
#include "time.hh"
#include "logger.hh"

class LoggerFile : public Logger
{
public:
LoggerFile()
{
file_.open( "output.log", std::ios::app );
file_.seekp( std::ios::beg );

if (!file_.good()) return;
file_ << "  ===============================================\n"
<< "    Begin Output log ( "
<< Time::getDate()
<< " at "
<< Time::getTime()
<< " ): "
<< PLATFORM_NAME
<< "\n  ===============================================\n\n";
file_.flush();
}

virtual ~LoggerFile()
{
if (!file_.good()) return;
file_ << "\n  ===============================================\n"
<< "    End   Output log ( "
<< Time::getDate()
<< " at "
<< Time::getTime()
<< " ): "
<< PLATFORM_NAME
<< "\n  ===============================================\n\n";
file_.flush();
file_.close();
}

virtual void write( const std::string& msg )
{
file_ << msg;
file_.flush();
}

private:
std::ofstream file_;;
};

#endif // LOGGERFILE_HH

Encore une fois, rien d'exceptionnel dans ce logger, le but étant de démontrer leur facilité de création. Il va maintenant falloir s'attaquer à la grosse partie: le conteneur de loggers atomiques.

III - Le conteneur de loggers

Maintenant que nous avons nos loggers, il va nous falloir une classe pour tous les contenir, et pour dispatcher les messages. Nous la sommerons LoggerHolder. Mais avant de rentrer directement dans le code, nous allons devoir définir quelques classes utilitaires (1) .

III-A - Classes utilitaires

La première chose dont nous aurons besoin, c'est d'un moyen de stocker des listes de types. Nous allons donc définir un type TypeList, ainsi que divers macros nous permettant de les construire simplement.

Pour les habitués des langages fonctionnels (LISP en particulier), il n'y aura rien de sorcier ici. Une liste est en fait une classe prenant deux paramètres templates, enveloppée dans une récursion s'arrêtant sur un type arbitrairement nommé NullType.

class NullType {};

template <class H, class T>
class TypeList
{
typedef H Head;
typedef T Tail;
};

Quelques macros pour en faciliter la création ne seront pas de trop:

#define TYPELIST_1(T1)\
TypeList<T1, NullType>

#define TYPELIST_2(T1, T2)\
TypeList<T1, TYPELIST_1(T2)>

#define TYPELIST_3(T1, T2, T3)\
TypeList<T1, TYPELIST_2(T2, T3)>

#define TYPELIST_4(T1, T2, T3, T4)\
TypeList<T1, TYPELIST_3(T2, T3, T4)>

Et voilà, nous pouvons maintenant très simplement créer des listes de types. Voici un simple exemple d'utilisation:

TYPELIST_4( char, int, float, double )

III-B - Le LoggerHolder

Nous pouvons (enfin) débuter la création du conteneur de loggers. Ce dernier prendra en paramètres une liste de type: les loggers atomiques à utiliser.
Voici un premier jet:

#ifndef LOGGERHOLDER_HH
#define LOGGERHOLDER_HH

#include <list>
#include <string>
#include <sstream>
#include "logger.h"

template <class T>
class LoggerHolder
{
private:
public:
LoggerHolder( void ) {}
~LoggerHolder( void ) {}

template <class U>
LoggerHolder<T>& operator<<( const U& message )
{
//TODO: Dispatcher les messages
return *this;
}
};

#endif // LOGGERHOLDER_HH

Reste la question principale: Comment itérer à travers nos loggers atomiques, et appeler leur méthode write sur le paramètre message de l'operateur << ?

Pour celà, l'idéal serait de disposer d'une classe de liste (ressemblant à std::list) qui stockerait nos loggers. Il ne resterait plus qu'à la parcourir et appelant write sur chaque élément.
Lançons-nous donc dans sa création ! Pour plus de propreté, nous avons choisi de déclarer LoggerList comme une classe interne à LoggerHolder.

Avant de nous attaquer à LoggerList, nous pouvons déjà implémenter l'opérateur << du LoggerHolder, maintenant que nous savons comment seront stockés nos loggers atomiques:

template <class T>
class LoggerHolder
{
private:
LoggerList<T> loggers_;

public:
LoggerHolder( void ) {}
~LoggerHolder( void ) {}

template <class U>
LoggerHolder<T>& operator<<( const U& message )
{
std::ostringstream oss;
oss << message;
for ( LoggerList<T>::iterator it=loggers_.begin();
it!=loggers_.end();
++it )
{
(**it).write( oss.str() );
}
return *this;
}
};

Maintenant, attaquons LoggerList.
Nous l'avons déjà évoqué, la métaprogrammation se rapproche souvent de la programmation fonctionnelle. Ceci est en parti du aux structures de données qu'on y retrouve (essentiellement des listes), et à l'usage intensif de la récursivité.

Imaginez que vous vouliez parcourir une liste en Scheme ( dialecte de LISP), vous procéderiez comme suit:

(define NullType      ()             )
(define LoggerConsole "LoggerConsole")
(define LoggerFile    "LoggerFile"   )

(define TypeList
(lambda (h d)
(cons h d) ))

(define TYPELIST_2 (TypeList LoggerConsole (TypeList LoggerFile NullType)))

(define head
(lambda (x)
(car x) ))

(define tail
(lambda (x)
(cdr x) ))

(define list.merge
(lambda (list1 list2)
(cond
((equal? NullType list2) list1)
(else                    (list.merge (TypeList (head list2) list1) (tail list2))) )))

(define logger->write
(lambda (logger)
(begin (display "Writing to ") (display logger) (newline)) ))

(define loggerholder->write
(lambda (loggers)
(cond
((equal? NullType (tail loggers)) (logger->write (head loggers)))
(else                             (begin (logger->write (head loggers)) (loggerholder->write (tail loggers)))) )))

Nous allons donc procéder de manière analogue. Dans une récursion en métaprogrammation, les cas d'arrêts sont des spécialisation partielles. Si l'on veut créer un traitement spécifique pour la fin d'une liste de types, il suffit donc de spécialiser partiellement la classe avec NullType.

Voici donc la déclaration générale de notre liste de loggers:

template <class T>
struct LoggerList : public std::list<Logger*> {};

Le cas d'arrêt de notre récursion, le voici:

template <>
struct LoggerList<NullType> : public std::list<Logger*>{};

Il ne reste plus qu'à faire le cas général. A savoir que dans le constructeur, nous créerons un nouveau logger, et nous fusionnerons avec une LoggerList templatée avec les arguments template de celle courante, sauf le premier.
Le destructeur quand à lui se contentera bien sur de détruire le contenu de notre liste.

template <class H, class T>
struct LoggerList<TypeList<typename H, typename T> >
: public std::list<Logger*>
{
typedef TypeList<typename H, typename T> List_t;
typedef typename H Head_t;
typedef typename T Tail_t;

LoggerList()
{
push_back( new Head_t );
LoggerList<Tail_t> tmp;
merge( tmp );
}

~LoggerList()
{
LoggerList<List_t>::iterator it;
for ( it=begin(); it!=end(); ++it )
delete *it;
}
};

C'est terminé. Il ne reste plus qu'à utiliser.

IV - Le mode debug

Grace à notre constante préprocesseur définie plus haut, nous pouvons englober les appels au LoggerHolder dans une macro, et ne rien faire lorsque le mode sans-messages est activé.

#ifdef NLOG
#  define LOG(msg)
#else
#  define LOG(msg) (*LoggerHolder<TYPELIST_2( LoggerConsole, LoggerFile )>::getInstance()) << msg;
#endif

V - Critiques

L'intéret pratique d'un logger presque entièrement statique comme celui-ci n'est au final pas indispensable. C'est en revanche un bon exercice d'introduction à la métaprogrammation, et celà permet de définir une batterie de types de loggers prets à l'emploi, sans avoir à s'embeter en les plugant dynamiquement sur un LoggerHolder.

(1) Ces classes sont issues du livre Modern C++ Design par Andrei Alexandrescu.