Creating a logging system

Aurélien Vallée

This article will discuss the creation of a logging system in a C++ program. It focuses on modularity (multiple atomic loggers) and ease of use (automatic lexical casts and argument chaining).

- Titre : Creating a logging system

- 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. Overview of the logging system

- II. Atomic loggers

- II-A. The common interface

- II-B. Atomic logger examples

- III. The logger holder

- III-A. Helper classes

- III-B. The LoggerHolder

- IV. Debug mode

- V. Criticisms

I - Overview of the logging system

The logger will be split in three parts
First of all, we will find the atomic loggers. These are small specialized classes which aims at catching a message and writing it somewhere else (terminal, file, network...)
Next, we will find a common interface for every atomic logger.
Last, but not least, we will create a holder for all atomic loggers that will have to catch every logging message, and dispatch it to each atomic logger.

Here is a quick look at what it should look like:

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

For convenience, we will provide a specific preprocessor constant that will enable or disable messages logging (release builds). It will be called NLOG (no log).

If you plan to use Make/GCC, the only thing you will have to do is add a single line on top of your makefile, and pass it to GCC using the -D flag (i.e. defining a preprocessor constant) :

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

all: hello

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

clean:
rm -rf *o hello

If you are using Visual Studio, head to your project properties, then C++/Préprocesseur and add NLOG:

Adding a preprocessor constant using Visual Studio 2005

II - Atomic loggers

The major purpose of creating a logging system based on modularity, is that the atomic loggers should be very easy to implement.
We have to keep this in mind when designing the logger interface.

II-A - The common interface

We are going to design the common logger interface.
Basically, the only thing it has to provide, is a method taking a string (the message to log), and write it somewhere. That is what write() does.

#ifndef LOGGER_HH
#define LOGGER_HH

#include <string>

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

#endif // LOGGER_HH

This is really all we have to do for the logger interface. The real work will we achieved by the atomic loggers themthelves.

II-B - Atomic logger examples

We are going to illustrate the creation of atomic loggers, by implementing two small of them for terminal and file output.

The most obvious is the terminal. All it have to do is redirect the message to the standard output stream.

#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

Now we are going to create a little more advanced logger, aiming at writing messages on files.
The following implementation is Windows-dependent, but the use of preprocessor constants helps a lot for an hypothetical port on Linux or MacOSX.

Start by creating a file named platform.hh containing:

#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

Now we can create some other helpers such as a class for handling time and date:

#ifndef TIME_HH
#define TIME_HH

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

#ifdef PLATFORM_WIN32
#  include <atltime.h>
#elif  PLATFORM_LINUX
//TODO: handling linux
#elif  PLATFORM_MACOSX
//TODO: handling 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

We should now have all the necessary tools for implementing our file logger.

#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

Nothing uncommon about this class, the source is self explanatory. We can now foxus on the core of the logging system: the logger holder.

III - The logger holder

Now that we defined a logger interface, and that we have implemented somme dummy atomic loggers, the next step is to create the class that will contain all those loggers, and provide a centralized way to log messages and dispatch them to loggers.
Before heading deeply in the core of LoggerHolder, we wille have to create some helpers. (1) .

III-A - Helper classes

The first thing we will need is a way to store lists of type. So first of all we are going to define a TypeList class, along with some userful macros for declaring typelists.

Fonctionnal languages litterate readers will find this section rather obvious. A typelist is just a structure containing 2 classes, which might be wrapped by another, and so on. The list ends with the arbitrary defined class NullType.

class NullType {};

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

We are now able to define some helpful macros:

#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)>

Following is a short example of defining a new typelist of four type: char, int, float, double.

TYPELIST_4( char, int, float, double )

III-B - The LoggerHolder

At last, we can start the creation of the LoggerHolder. It will take a typelist as template parameter: the atomic loggers to use.
Here is a first draft:

#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

The major issue remains "how to iterate over our logger list and call the proper write method on each of them, from LoggerHolder's operator << ?

Ideally, we would have a container (such as a std::list) that would store our loggers. Then we would only have to iterate over it and call write.
We will call this hypothetical class LoggerList.

We don't have to create LoggerList immediatly. In fact we are able to implement LoggerHolder's operator << since we know LoggerList will inherit from std::list.

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;
}
};

Now, it's time to focus on LoggerList.
As we said earlier, metaprogramming is close to functionnal programming. This is mostly due to the heavy use of recursivity, along with simple structures such as lists.

Imagine the same problem in Scheme (LISP dialect), here is what would be a typical solution:

(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)))) )))

We will proceed the same manner.
When metaprogramming, the stop case of our recursion will be a partial template specialization. If we want to do a particuliar action for the last element, we will have to create a specialization based on NullType.

So here is the global LoggerList definition:

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

And this will be our stop case:

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

All we have to do now is the general case. The constructor will have to push the head type in the list, and merge it with the resulting list created with the tail of the typelist.
The destructor will ... destruct the logger.

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;
}
};

That's all for the LoggerList, now all you have to do is test it.

IV - Debug mode

Since we defined a preprocessor constant earlier, we can wrap each call to LoggerHolder's operator<< in a macro, that would be defined to nothing if log is disabled.

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

V - Criticisms

Whereas the overall concept of a static logger is quite attractive, the practical benefits are insignificant compared to a dynamic one. The major benifit resides in the capability to define pool of different loggers that one wouldn't have to dynamically plug.
This article should in fact be considered as an introduction to metaprogramming using C++.

(1) These classes belongs to the book Modern C++ Design by Andrei Alexandrescu.