Implémentation d'un Système de Journalisation Thread-Safe en C++ avec le Design Pattern Strategy

Cet article présente la conception et l'implémentation d'un module de journalisation robuste en C++, conçu pour être thread-safe et flexible grâce au pattern Strategy. Cet outil servira de base à la création d'un pool de threads, en facilitant le débogage grâce à des fonctionnalités de journalisation avancées.

Objectifs de Conception

Le système de journalisation doit répondre aux exigences suivantes :

  1. Sécurité des Threads : Les opérations de journalisation doivent être intrinsèquement thread-safe. L'utilisation directe de std::cout ou printf est problématique car ils accèdent à des ressources partagées (le terminal) sans mécanisme de synchronisation adéquat, pouvant mener à des écritures concurrentes corruptrices.
  2. Stratégies de Sortie Multiples : Le système doit supporter différentes stratégies de sortie pour les journaux, notamment la sortie vers la console ou vers un fichier.
  3. Interface C++ : La journalisation doit offrir une API intuitive de style C++, permettant la construction aisée des messages. La classe Logger interne générera les éléments constants du journal (comme le timestamp et le niveau), tandis que l'utilisateur fournira les informations spécifiques via une interface d'insertion de flux.

L'utilisation prévue est la suivante :

Logger logger;
logger(LogLevel::INFO, __FILE__, __LINE__) << "Message d'erreur spécifique 1" << "Message d'erreur spécifique 2";

Structure du Pattern Strategy

Le pattern Strategy sera utilisé pour découpler la logique de génération du message de journal de la manière dont il est effectivement écrit. Une classe abstraite de base définira l'interface de stratégie, et des classes dérivées implémenteront des stratégies spécifiques.

1. Classe de Base : SyncStrategy

Cette classe virtuelle pure définit l'interface commune pour toutes les stratégies de synchronisation. La méthode Sync est responsable de l'écriture du message.

// Interface abstraite pour les stratégies de synchronisation des journaux
class SyncStrategy
{
public:
   SyncStrategy() = default;
   virtual ~SyncStrategy() = default; // Destructeur virtuel
   virtual void Sync(const std::string &message) = 0; // Méthode pure virtuelle
};

2. Stratégie de Sortie Console : ConsoleSyncStrategy

Cette classe dérivée implémente la stratégie pour écrire les journaux sur la sortie standard (console). Pour garantir la thread-safety, un mutex est utilisé pour protéger l'accès à std::cout.

// Stratégie pour écrire les journaux sur la console
class ConsoleSyncStrategy : public SyncStrategy
{
public:
   void Sync(const std::string &message) override
   {
       // Utilisation d'un LockGuard pour une gestion RAII du mutex
       std::lock_guard<:mutex> lock(_mutex);
       std::cout << message << std::endl; // Utilisation de std::endl pour un saut de ligne et un flush
   }

private:
   std::mutex _mutex; // Mutex pour la protection thread-safe
};</:mutex>

3. Stratégie de Sortie Fichier : FileSyncStrategy

Cette stratégie gère l'écriture des journaux dans un fichier. Elle inclut la création automatique du répertoire de log si nécessaire et utilise le mode d'ajout (append) pour écrire dans le fichier. La thread-safety est assurée par un mutex.

// Stratégie pour écrire les journaux dans un fichier
class FileSyncStrategy : public SyncStrategy
{
public:
   // Constructeur prenant le chemin du répertoire et le nom du fichier
   FileSyncStrategy(const std::string& dirPath = "./logs", const std::string& fileName = "application.log")
       : _dirPath(dirPath), _fileName(fileName)
   {
       std::lock_guard<:mutex> lock(_mutex);
       // Vérification et création du répertoire si inexistant
       if (!std::filesystem::exists(_dirPath))
       {
           try
           {
               std::filesystem::create_directories(_dirPath);
           }
           catch (const std::filesystem::filesystem_error& e)
           {
               // Gestion d'erreur : affichage sur stderr car le fichier de log n'est pas encore fiable
               std::cerr << "Erreur lors de la création du répertoire de logs: " << e.what() << std::endl;
               // On pourrait choisir de lancer une exception ici
           }
       }
   }

   void Sync(const std::string &message) override
   {
       std::lock_guard<:mutex> lock(_mutex);
       // Construction du chemin complet du fichier
       std::string fullPath = _dirPath + "/" + _fileName;
       // Ouverture du fichier en mode ajout (append)
       std::ofstream logFile(fullPath, std::ios::app);
       if (logFile.is_open())
       {
           logFile << message << std::endl; // Ajout d'un saut de ligne
       }
       else
       {
           // Gestion d'erreur : échec d'ouverture du fichier
           std::cerr << "Erreur: Impossible d'ouvrir le fichier de log: " << fullPath << std::endl;
       }
   }

private:
   std::string _dirPath;
   std::string _fileName;
   std::mutex _mutex; // Mutex pour la protection thread-safe
};</:mutex></:mutex>

Gestion du Contenu du Journal

Le contenu d'un journal est composé d'éléments fixes et variables.

Éléments Fixes du Journal :

  • Niveau de Journalisation (Log Level) : Définit la sévérité du message (DEBUG, INFO, WARNING, ERROR, FATAL).
  • Timestamp : L'heure et la date de l'événement.
  • Identifiant du Processus (PID) : L'ID du processus courant.
  • Nom du Fichier Source : Le nom du fichier où le log est généré (via __FILE__).
  • Numéro de Ligne : La ligne du fichier source où le log est généré (via __LINE__).

Éléments Variables du Journal :

Ce sont les messages spécifiques fournis par l'utilisateur.

Classe Logger et Classe Interne LogMessage

La classe Logger agit comme l'interface utilisateur principale. Elle détient un pointeur vers une SyncStrategy et permet de choisir la stratégie de sortie.

#include <memory> // Pour std::unique_ptr
#include <sstream> // Pour std::stringstream
#include <iomanip> // Pour std::put_time (si nécessaire, mais std::snprintf est utilisé ici)
#include <filesystem> // Pour la gestion des fichiers et répertoires
#include <fstream> // Pour std::ofstream
#include <ctime> // Pour time, localtime_r, struct tm
#include <unistd.h> // Pour getpid()
#include <mutex> // Pour std::mutex et std::lock_guard

// Définition du namespace pour organiser le code
namespace LogModule
{
   // ... (définitions de SyncStrategy, ConsoleSyncStrategy, FileSyncStrategy ici) ...

   // Enumération pour les niveaux de journalisation
   enum class LogLevel
   {
       DEBUG,
       INFO,
       WARNING,
       ERROR,
       FATAL
   };

   // Fonction utilitaire pour convertir LogLevel en chaîne de caractères
   std::string LevelToString(LogLevel level)
   {
       switch (level)
       {
           case LogLevel::DEBUG:   return "DEBUG";
           case LogLevel::INFO:    return "INFO";
           case LogLevel::WARNING: return "WARNING";
           case LogLevel::ERROR:   return "ERROR";
           case LogLevel::FATAL:   return "FATAL";
           default:                return "UNKNOWN";
       }
   }

   // Fonction utilitaire pour obtenir le timestamp formaté
   std::string GetTimestamp()
   {
       std::time_t now = std::time(nullptr);
       struct std::tm timeinfo;
       // Utilisation de localtime_r pour la thread-safety
       #ifdef _WIN32
           localtime_s(&timeinfo, &now); // Version Windows
       #else
           localtime_r(&now, &timeinfo); // Version POSIX
       #endif

       char buffer[80];
       // Format: YYYY-MM-DD HH:MM:SS
       std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &timeinfo);
       return buffer;
   }

   // Classe principale Logger
   class Logger
   {
   public:
       Logger() : _syncStrategy(std::make_unique<ConsoleSyncStrategy>()) {} // Stratégie par défaut : console

       // Méthodes pour activer différentes stratégies
       void EnableConsoleOutput()
       {
           _syncStrategy = std::make_unique<ConsoleSyncStrategy>();
       }

       void EnableFileOutput(const std::string& dirPath = "./logs", const std::string& fileName = "application.log")
       {
           _syncStrategy = std::make_unique<FileSyncStrategy>(dirPath, fileName);
       }

       // Classe interne LogMessage pour construire le message de journalisation
       class LogMessage
       {
       public:
           // Constructeur : initialise les éléments fixes et prépare le message
           LogMessage(LogLevel level, const char* file, int line, Logger& logger)
               : _level(LevelToString(level)),
                 _file(file),
                 _line(line),
                 _loggerRef(logger)
           {
               std::stringstream ss;
               ss << "[" << GetTimestamp() << "] "
                  << "[" << _level << "] "
                  << "[" << getpid() << "] "
                  << "[" << _file << ":" << _line << "] - ";
               _messageContent = ss.str();
           }

           // Surcharge de l'opérateur << pour ajouter des chaînes au message
           LogMessage& operator<<(const std::string& str)
           {
               _messageContent += str;
               _messageContent += " "; // Ajoute un espace entre les éléments
               return *this;
           }

           // Surcharge de l'opérateur << pour ajouter des types streamables
           template<typename T>
           LogMessage& operator<<(const T& value)
           {
               _messageContent += std::to_string(value); // Conversion basique
               _messageContent += " ";
               return *this;
           }

           // Destructeur : déclenche la synchronisation du message lorsque l'objet LogMessage temporaire est détruit
           ~LogMessage()
           {
               // Assure que la stratégie est valide avant de l'appeler
               if (_loggerRef._syncStrategy)
               {
                   _loggerRef._syncStrategy->Sync(_messageContent);
               }
           }

       private:
           std::string _level;
           const char* _file;
           int _line;
           std::string _messageContent;
           Logger& _loggerRef; // Référence à l'objet Logger parent
       };

       // Surcharge de l'opérateur d'appel de fonction "()" pour créer un objet LogMessage
       // Cette méthode retourne un objet LogMessage qui sera ensuite utilisé avec l'opérateur <<
       LogMessage operator()(LogLevel level, const char* file, int line)
       {
           // Crée et retourne un objet LogMessage temporaire
           return LogMessage(level, file, line, *this);
       }

   private:
       std::unique_ptr<SyncStrategy> _syncStrategy; // Pointeur vers la stratégie de synchronisation active
   };

   // Instance globale du Logger pour faciliter l'utilisation via des macros
   Logger g_logger;

   // Macros pour simplifier l'utilisation
   #define LOG_INFO(...) g_logger(LogLevel::INFO, __FILE__, __LINE__) << __VA_ARGS__
   #define LOG_DEBUG(...) g_logger(LogLevel::DEBUG, __FILE__, __LINE__) << __VA_ARGS__
   #define LOG_WARNING(...) g_logger(LogLevel::WARNING, __FILE__, __LINE__) << __VA_ARGS__
   #define LOG_ERROR(...) g_logger(LogLevel::ERROR, __FILE__, __LINE__) << __VA_ARGS__
   #define LOG_FATAL(...) g_logger(LogLevel::FATAL, __FILE__, __LINE__) << __VA_ARGS__

   // Macros pour activer les stratégies
   #define ENABLE_CONSOLE_LOG() g_logger.EnableConsoleOutput()
   #define ENABLE_FILE_LOG(dir, file) g_logger.EnableFileOutput(dir, file)

} // Fin du namespace LogModule

Optimisation de l'Expérience Utilisateur via les Macros

Pour simplifier davnatage l'utilisation, des macros sont définies. Elles encapsulent l'appel à la méthode operator() de Logger avec les macros prédéfinies __FILE__ et __LINE__.

// Utilisation simplifiée avec les macros
LOG_INFO("Le système démarre.");
LOG_ERROR("Une erreur critique s'est produite.") << "Détail de l'erreur: " << errorCode;

// Activation des stratégies via macros
ENABLE_CONSOLE_LOG(); // Utiliser la console par défaut
// OU
ENABLE_FILE_LOG("./app_logs", "service.log"); // Utiliser un fichier spécifique

Code Complet du Module de Journalisation

Voici le code source complet du module, incluant les stratégies et la classe Logger.

#pragma once

#include <iostream>
#include <string>
#include <memory>
#include <sstream>
#include <iomanip>
#include <filesystem>
#include <fstream>
#include <ctime>
#include <unistd.h>
#include <mutex>
#include <stdexcept> // Pour std::runtime_error

namespace LogModule
{
   // Forward declaration de Logger pour LogMessage
   class Logger;

   // Interface abstraite pour les stratégies de synchronisation des journaux
   class SyncStrategy
   {
   public:
       SyncStrategy() = default;
       virtual ~SyncStrategy() = default;
       virtual void Sync(const std::string &message) = 0;
   };

   // Stratégie pour écrire les journaux sur la console
   class ConsoleSyncStrategy : public SyncStrategy
   {
   public:
       void Sync(const std::string &message) override
       {
           std::lock_guard<std::mutex> lock(_mutex);
           std::cout << message << std::endl;
       }

   private:
       std::mutex _mutex;
   };

   // Stratégie pour écrire les journaux dans un fichier
   class FileSyncStrategy : public SyncStrategy
   {
   public:
       FileSyncStrategy(const std::string& dirPath = "./logs", const std::string& fileName = "application.log")
           : _dirPath(dirPath), _fileName(fileName)
       {
           std::lock_guard<std::mutex> lock(_mutex);
           if (!std::filesystem::exists(_dirPath))
           {
               try
               {
                   std::filesystem::create_directories(_dirPath);
               }
               catch (const std::filesystem::filesystem_error& e)
               {
                   // Ici, on pourrait logger vers stderr si la création échoue
                   std::cerr << "Erreur de création du répertoire '" << _dirPath << "': " << e.what() << std::endl;
                   // Lancer une exception pour signaler l'échec critique
                   throw std::runtime_error("Échec de la création du répertoire de logs.");
               }
           }
       }

       void Sync(const std::string &message) override
       {
           std::lock_guard<std::mutex> lock(_mutex);
           std::string fullPath = _dirPath + "/" + _fileName;
           std::ofstream logFile(fullPath, std::ios::app);
           if (logFile.is_open())
           {
               logFile << message << std::endl;
           }
           else
           {
               // Log vers stderr si le fichier ne peut être ouvert
               std::cerr << "Erreur: Impossible d'ouvrir le fichier de log '" << fullPath << "'" << std::endl;
           }
       }

   private:
       std::string _dirPath;
       std::string _fileName;
       std::mutex _mutex;
   };

   enum class LogLevel
   {
       DEBUG,
       INFO,
       WARNING,
       ERROR,
       FATAL
   };

   std::string LevelToString(LogLevel level)
   {
       switch (level)
       {
           case LogLevel::DEBUG:   return "DEBUG";
           case LogLevel::INFO:    return "INFO";
           case LogLevel::WARNING: return "WARNING";
           case LogLevel::ERROR:   return "ERROR";
           case LogLevel::FATAL:   return "FATAL";
           default:                return "UNKNOWN";
       }
   }

   std::string GetTimestamp()
   {
       std::time_t now = std::time(nullptr);
       struct std::tm timeinfo;
       #ifdef _WIN32
           localtime_s(&timeinfo, &now);
       #else
           localtime_r(&now, &timeinfo);
       #endif
       char buffer[80];
       std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &timeinfo);
       return buffer;
   }

   class Logger
   {
   public:
       // Classe interne LogMessage
       class LogMessage
       {
       public:
           LogMessage(LogLevel level, const char* file, int line, Logger& logger)
               : _level(LevelToString(level)),
                 _file(file),
                 _line(line),
                 _loggerRef(logger)
           {
               std::stringstream ss;
               ss << "[" << GetTimestamp() << "] "
                  << "[" << _level << "] "
                  << "[" << getpid() << "] "
                  << "[" << _file << ":" << _line << "] - ";
               _messageContent = ss.str();
           }

           LogMessage& operator<<(const std::string& str)
           {
               _messageContent += str;
               _messageContent += " ";
               return *this;
           }

           // Surcharge pour différents types
           template<typename T>
           LogMessage& operator<<(const T& value)
           {
               // Utiliser ostringstream pour une conversion plus générale
               std::ostringstream oss;
               oss << value;
               _messageContent += oss.str();
               _messageContent += " ";
               return *this;
           }

           ~LogMessage()
           {
               if (_loggerRef._syncStrategy)
               {
                   _loggerRef._syncStrategy->Sync(_messageContent);
               }
           }

       private:
           std::string _level;
           const char* _file;
           int _line;
           std::string _messageContent;
           Logger& _loggerRef;
       };

       // Constructeur de Logger initialisant la stratégie par défaut
       Logger() : _syncStrategy(std::make_unique<ConsoleSyncStrategy>()) {}

       // Activation de la sortie console
       void EnableConsoleOutput()
       {
           _syncStrategy = std::make_unique<ConsoleSyncStrategy>();
       }

       // Activation de la sortie fichier
       void EnableFileOutput(const std::string& dirPath = "./logs", const std::string& fileName = "application.log")
       {
           _syncStrategy = std::make_unique<FileSyncStrategy>(dirPath, fileName);
       }

       // Opérateur d'appel de fonction pour créer un objet LogMessage
       LogMessage operator()(LogLevel level, const char* file, int line)
       {
           return LogMessage(level, file, line, *this);
       }

   private:
       std::unique_ptr<SyncStrategy> _syncStrategy;
   };

   // Instance globale du logger
   Logger g_logger;

   // Macros pour simplifier l'usage
   #define LOG_DEBUG(...) LogModule::g_logger(LogModule::LogLevel::DEBUG, __FILE__, __LINE__) << __VA_ARGS__
   #define LOG_INFO(...) LogModule::g_logger(LogModule::LogLevel::INFO, __FILE__, __LINE__) << __VA_ARGS__
   #define LOG_WARNING(...) LogModule::g_logger(LogModule::LogLevel::WARNING, __FILE__, __LINE__) << __VA_ARGS__
   #define LOG_ERROR(...) LogModule::g_logger(LogModule::LogLevel::ERROR, __FILE__, __LINE__) << __VA_ARGS__
   #define LOG_FATAL(...) LogModule::g_logger(LogModule::LogLevel::FATAL, __FILE__, __LINE__) << __VA_ARGS__

   #define ENABLE_CONSOLE_LOG() LogModule::g_logger.EnableConsoleOutput()
   #define ENABLE_FILE_LOG(dir, file) LogModule::g_logger.EnableFileOutput(dir, file)

} // Fin du namespace LogModule

Étiquettes: C++ journalisation thread-safe Design Patterns Strategy Pattern

Publié le 21 juillet à 20h21