Professionelles Loggen in C++
Bei den meisten neuen C++ Projekten ist das Logging ein wichtiges Thema in der Konzeptionsphase. Professionelles Loggen in C++ will gut durchdacht sein. Das Logging durchzieht meist den kompletten Source Code. Ein Fehler in der Architektur bezahlt man später durch enormen Aufwand. Deshalb empfiehlt es sich vor der ersten Zeile Code sich Gedanken zu einem effizienten und sicheren Logger zu machen.
Professionelles Loggen in C++
Bei fast jedem neuen Projekt stehen Überlegungen zum Loggen an erster Stelle. Durch ein kluges und ausführliches Logging findet man schneller Fehler und kann im Echtbetrieb auch Daten vom Kunden bekommen. Doch welchen Ansatz sollte man in seiner Architektur verfolgen?
Singleton Anti Pattern
Das Singleton Pattern ist einfach und scheinbar für eine Logging Klasse perfekt. Es gibt maximal eine Instanz, man kann von überall darauf zugreifen. Doch Achtung! Eine Singleton Klasse ist durch die Verwendung in fast jeder anderen Klasse tief in der Struktur des Programms verwoben. Jede Änderung führt zu einer Änderung überall, wir müssen den gesamten Code jedesmal neu bauen. Viel schöner wäre ein Ansatz, der Vorteile der Objektorientierung verwendet. Wie wäre es mit Dependency Injection?
Dependency Injection
Anstatt jeder Klasse die genaue Information über die Logging Klasse zu geben wäre es besser diese kennt lediglich ein Interface. Wie dieses in der Instanz implementiert ist kann der Klasse in der geloggt wird egal sein. Das bedeutet je nachdem welche Voraussetzungen wir haben können wir auf die Standardausgabe oder in einem Logfile loggen und das in allen denkbaren Formaten. Mit Dependency Injection trennen wir das Interface vom Logger von der eigentlichen Implementierung und der Logger einer beliebigen Klasse ist auch zur Laufzeit austauschbar. Wie setzt man das um? Ich halte mich beim Logger an die Tipps vom Buch Clean C++von Stephan Roth in der genau so ein Logger vorgestellt wird.
Wir erstellen ein Interface eines Loggers:
#pragma once #include <memory> #include <string_view> class LoggingFacility { public: virtual ~LoggingFacility() = default; virtual void writeInfoEntry(std::string_view entry) = 0; virtual void writeWarnEntry(std::string_view entry) = 0; virtual void writeErrorEntry(std::string_view entry) = 0; }; using Logger = std::shared_ptr<LoggingFacility>;
In der einfachsten Form kann der Logger unterschiedliche Meldungen aufzeichnen beziehungsweise ausgeben. In der IT haben sich Logstufen etabliert wie beispielsweise die recht ausführlichen von syslog. Im Gegensatz zum Betriebssystem benötigt eine Anwendung weniger Abstufungen, 3-4 haben sich bewährt. Info, Warnung und Error sind für mich ausreichend. Das Interface ist im C++17 Standard geschrieben und verwendet beispielsweise string_view und den Smartpointer shared_prt.
Basierend auf dem Interface kann man nun beliebige Logger Implementierungen erstellen. Im Buch wird ein Logger für die Standardausgabe erstellt:
#include "LoggingFacility.h" #include <iostream> class StandardOutputLogger : public LoggingFacility { public: virtual void writeInfoEntry(std::string_view entry) override { std::cout << "[INFO] " << entry << std::endl; } virtual void writeWarnEntry(std::string_view entry) override { std::cout << "[WARNING] " << entry << std::endl; } virtual void writeErrorEntry(std::string_view entry) override { std::cout << "[ERROR] " << entry << std::endl; } };
Man kann aber auch einen eigenen Logger für das Dateisystem erstellen oder wie ich eine vorhandene Logging Bibliothek einbinden.
Bibliotheken einbinden
Bei der modernen C++ Programmierung kann man auf zahlreiche Bibliothek zurückgreifen. Der Logger sollte threadsafe und typesafe sein und sich möglichst einfach ins Projekt integrieren. Der leichtgewichtige Single Header Ansatz von easylogging++ hat mir deshalb besonders gefallen. Die Einbindung in das Programm war dank Interface auch problemlos möglich. Die Klasse sieht dann so aus:
#include "LoggingFacility.h" #include "easylogging++.h" INITIALIZE_EASYLOGGINGPP class FileLogger : public LoggingFacility { public: virtual void writeInfoEntry(std::string_view entry) override { LOG(INFO) << entry; } virtual void writeWarnEntry(std::string_view entry) override { LOG(WARNING) << entry; } virtual void writeErrorEntry(std::string_view entry) override { LOG(ERROR) << entry; } };
In der Standardkonfiguration loggt der jetzt zwar auf die Konsole, einmal korrekt konfiguriert dann auch in *.log Dateien.
Logger verwenden
Dank Dependency Injection Entwurfsmuster ist man bei der Verwendung des Loggers sehr flexibel. Jede Klasse in der geloggt wird hat eine Referenz auf das allgemeine Logger Interface, welches über Dependency Injection mit einer Instanz verknüpft wird. Das geht über einen Konstruktor oder über eine Setter Methode.
//#include "StandardOutputLogger.h" #include "FileLogger.h" int main() { //Logger logger = std::make_shared<StandardOutputLogger>(); Logger logger = std::make_shared<FileLogger>(); PlayerFactory playerFactory(logger); ... }
Ich habe im Hauptprogramm den spezialisierten Logger einmal per Smartpointer instanziert. An die Klassen kann man diesen dank dem Interface über den Konstruktor injecten. An den auskommentierten Zeilen könnt ihr sehen, dass man beliebige Logger ohne große Änderungen im Code durch das komplette Programm schleusen kann. Große Umstellung mit minimalen Aufwand. Der Konstruktor der Klasse sieht so aus:
#include "LoggingFacility.h" class PlayerFactory { public: PlayerFactory(const Logger& logger) : logger(logger) {} ... private: Logger logger; ... };
Der Smartpointer zählt bei der Übergabe der Referenzen brav mit. Wird der Logger so durch die Klassenhierarchie gereicht entstehen zahlreiche Referenzen auf ein Objekt. Da dieses über den Smartpointer verwaltet wird braucht man sich wegen Freigabe von Speicher keine Gedanken machen. Man kann das sehr einfach prüfen. An geeigneten Stellen in Methoden die in einer Klasse sind die einen Logger haben gibt folgender Code
logger->writeInfoEntry("pointer auf logger: " + std::to_string(logger.use_count()));
Infos über den Smartpointer aus.
Fazit
Bei der Entwicklung meines Programms war ein effizientes und intelligentes Logging System ein wichtiger Bestandteil. Im Zuge der Recherche und Implementierung habe ich gelernt, dass der Dependency Injection Ansatz besser ist als ein Singleton. Ich kann nun einfach (auch zur Laufzeit) über das Interface einen beliebigen Logger verwenden und recht einfach fertige Logger Bibliotheken anbinden. In Summe ein recht gut durchdachtes System wie ich finde.
Das macht allerdings das Interface sämtlicher Klassen nicht gerade übersichtlicher.
Damit gewinnt quasi jede Klasse einen weiteres Konstruktorargument.
Dem könnte man durch eine Factory entgegenwirken.. Aber möchte man das wirklich überall haben ?
Ich finde den Singleton-Ansatz in dem Fall gar nicht so blöd, allerdings nimmt dieser Ansatz einem die Möglichkeit (bzw. macht es schwerer) das Logging pro Klasse/Modul zu switchen.
Also sowas wie
ILogger& globalLogger()
{
// static oder woanders die Instanz aufbewahren
static MySpecialLogger logger(…);
return logger;
}
Wenn sich der dynamische Typ vom Logger ändert, müssen die Cleints des Loggers nicht neukompilieren.