π οΈ Practicing Low-Level Design: Building a Logging Framework π
Looking to enhance your low-level design skills using design patterns? In this tutorial, we'll embark on a fascinating journey of building a logging framework from the ground up.
By leveraging the power of three key design patterns
Singleton, Chain of Responsibility, and Observer we'll not only create a functional logging system but also gain valuable insights into the art of low-level design. So, roll up your sleeves, fire up your favorite IDE, and let's dive into this hands-on coding adventure! π»
Let's start with gathering the requirements ...
1- π Multiple Sync: The framework should support logging in multiple places, such as console, log file, database, and distributed queue.
2- π Multiple Categories: The framework should support logging into multiple categories, such as info, debug, and error.
3- π Configurability: The category and logging location should be configurable.
Here is the result code we are going to implement today
Welcome to the Low-level Design Examples repository! Here, you'll find a collection of practical examples demonstrating various low-level design concepts and patterns. These examples are aimed at helping you understand and implement robust, efficient, and maintainable software solutions.
Contribute
We welcome contributions to this repository! If you have any low-level design examples or improvements to existing examples, feel free to open a pull request. Let's collaborate to create a valuable resource for the software development community.
If you find these examples helpful, please consider starring this repository to show your support. Thank you for visiting!
π Logger Class: The main class exposed to the application for writing logs.
π Categories: Info, Debug, and Error.
π Target: Console, File, and Database.
Logger class:
For this class, we will start with a creational design pattern to create the logger.
Here, we will choose Singleton design pattern to create just one instance from the logger all over our application
// Singleton pattern ensures that a class has only one instance and provides a global point of access to it.internalclassLogger{// Private static instance of the Logger class.privatestaticLogger_logger;// Private constructor to prevent instantiation of the Logger class from outside.privateLogger(){// Check if an instance of Logger already exists, throw an exception if so.if(_logger!=null)thrownewInvalidOperationException("Object already created");}// Public static method to provide access to the single instance of the Logger class.publicstaticLoggerGetLogger(){// Check if _logger is null (not yet initialized).if(_logger==null){// Use locking to ensure thread safety in multi-threaded environments.lock(typeof(Logger)){// Double-check if _logger is still null (another thread might have initialized it while waiting for the lock).if(_logger==null){// Create a new instance of Logger and assign it to _logger._logger=newLogger();}}}// Return the single instance of Logger.return_logger;}}
So this implementation:
π« Prevent Multiple Instances: The private constructor prevents the creation of multiple instances of the Logger class, so it's the class's responsibility to create the object and return the same instance when requested using the GetLogger function.
Now let's move to the second section, categorization of the logs ...
For that we will use Chain of responsibility design pattern
Chain of Responsibility is a behavioral design pattern that lets you pass requests along a chain of handlers. Upon receiving a request, each handler decides either to process the request or to pass it to the next handler in the chain.
Let's now create a simple Enum to carry the values that will identify the log level ...
internalenumLoggerLevel{Info=1,Error=2,Debug=3}
Now, let's implement the Chain of Responsibility pattern for categories (Info, Debug, and Error) using the AbstractLogger class and its concrete subclasses.
namespaceLoggingSystem{// AbstractLogger is an abstract base class for other logging classes.internalabstractclassAbstractLogger{// Levels is a protected field that holds the logging level.protectedLoggerLevelLevels;// _nextLevelLogger is a private field that holds the next logger in the chain of responsibility.privateAbstractLogger_nextLevelLogger;// SetNextLevelLogger is a public method that sets the next logger in the chain of responsibility.publicvoidSetNextLevelLogger(AbstractLoggernextLevelLogger){_nextLevelLogger=nextLevelLogger;}// LogMessage is a public method that logs a message.publicvoidLogMessage(LoggerLevellevel,stringmsg,LoggerTargetloggerTarget){// If the current logger's level matches the provided level, it displays the message.if(Levels==level){Display(msg,loggerTarget);}// Regardless of whether it displayed the message, it passes the message to the next logger in the chain (if there is one).if(_nextLevelLogger!=null){_nextLevelLogger.LogMessage(level,msg,loggerTarget);}}// Display is a protected abstract method that displays a message.// This method has no implementation in this class and must be implemented in any non-abstract subclass.protectedabstractvoidDisplay(stringmsg,LoggerTargetloggerTarget);}}
π Next Logger: It maintains a private field _nextLevelLogger to hold the reference to the next logger in the chain, enabling the passing of log messages to the next logger if needed.
π Levels Field: The Levels field holds the logging level for the current logger, determining which messages to handle.
π LogMessage Method: The LogMessage method checks if the provided logging level matches the logger's level. If it does, it displays the message; otherwise, it passes the message to the next logger in the chain.
π§ Display Method: The Display method is a protected abstract method that must be implemented by subclasses to handle the actual display of log messages.
π SetNextLevelLogger Method: This method allows setting the next logger in the chain, establishing the order in which loggers handle messages.
Now let's create the concrete classes for displaying the message...
For Info:
internalclassInfoLogger:AbstractLogger{publicInfoLogger(LoggerLevellevels){this.Levels=levels;}protectedoverridevoidDisplay(stringmsg,LoggerTargetloggerTarget){//temporary for now ...Console.WriteLine("INFO: "+msg);}}
For Error:
internalclassErrorLogger:AbstractLogger{publicErrorLogger(LoggerLevellevels){this.Levels=levels;}protectedoverridevoidDisplay(stringmsg,LoggerTargetloggerTarget){//temporary for now ...Console.WriteLine("ERROR: "+msg);}}
For Debug:
internalclassDebugLogger:AbstractLogger{publicDebugLogger(LoggerLevellevels){this.Levels=levels;}protectedoverridevoidDisplay(stringmsg,LoggerTargetloggerTarget){//temporary for now ...Console.WriteLine("DEBUG: "+msg);}}
Now, let's edit or logger class ...
we need to use the abstract logger to create loggers for various levels.
So we will instantiate a new instance of the abstract logger, let's name it _chainOfLogger in the GetLogger function.
internalclassLogger{privatestaticLogger_logger;privatestaticAbstractLogger_chainOfLogger;privateLogger(){if(_logger!=null)thrownewInvalidOperationException("Object already created");}publicstaticLoggerGetLogger(){if(_logger==null){lock(typeof(Logger)){if(_logger==null){_logger=newLogger();_chainOfLogger=CreateChainOfLogger();//IS THIS REALLY WHERE THE CreateChainOfLogger FUNCTION BELONGS??}}}return_logger;}}
- We should make a slight enhancement here ... CreateChainOfLogger doesn't actually belong to the logger class, it is not it's responsibility to build the chain...
So we will create another class called LogManager to handle it ...
internalclassLogManager{publicstaticAbstractLoggerDoChaining(){// Create an InfoLogger with the Info level.AbstractLoggerinfoLogger=newInfoLogger(LoggerLevel.Info);// Create an ErrorLogger with the Error level.AbstractLoggererrorLogger=newErrorLogger(LoggerLevel.Error);// Create a DebugLogger with the Debug level.AbstractLoggerdebugLogger=newDebugLogger(LoggerLevel.Debug);// Set the next logger for the InfoLogger to be the ErrorLogger.infoLogger.SetNextLevelLogger(errorLogger);// Set the next logger for the ErrorLogger to be the DebugLogger.errorLogger.SetNextLevelLogger(debugLogger);// Return the first logger in the chain (InfoLogger).returninfoLogger;}}
π Logger Chain Setup: The DoChaining method sets up a chain of responsibility for loggers, where each logger handles messages based on its logging level.
π Chain Order: It creates three loggers (InfoLogger, ErrorLogger, and DebugLogger) with increasing logging levels (Info < Error < Debug) and sets the next logger in the chain accordingly.
Then we will use this function to create the chain inside the Logger class.
After that, we will implement a function to create the log itself, it a general function accepting the message and the level, it will also be a private function, it will only be called from inside the logger class by three functions for each log level...
So the Logger class will be like below ...
internalclassLogger{privatestaticLogger_logger;privatestaticAbstractLogger_chainOfLogger;privateLogger(){if(_logger!=null)thrownewInvalidOperationException("Object already created");}publicstaticLoggerGetLogger(){if(_logger==null){lock(typeof(Logger)){if(_logger==null){_logger=newLogger();_chainOfLogger=LogManager.DoChaining();}}}return_logger;}publicvoidInfo(stringmessage){CreateLog(LoggerLevel.Info,message);}publicvoidError(stringmessage){CreateLog(LoggerLevel.Error,message);}publicvoidDebug(stringmessage){CreateLog(LoggerLevel.Debug,message);}// The CreateLog method is a private method that creates a log with the provided level and message.privatevoidCreateLog(LoggerLevellevel,stringmessage){// It calls the LogMessage method on the chain of loggers with the provided level, message, and logger target._chainOfLogger.LogMessage(level,message,_loggerTarget);}}
Let's test our application till now, let's create a simple console app to test it ...
usingLoggingSystem;Loggerlogger=Logger.GetLogger();logger.Info("This is info message");Console.WriteLine("___");logger.Error("This is Error message");Console.WriteLine("___");logger.Debug("This is Debug message");
and after running it, we will have the result as below
As you can see:
1- Info message prints only log
2- Error message prints info and error
3- Debug message prints info, error, and debug.
Now let's move to the last part of our implementation, which is the log target, here we will be using the Observer design pattern
_Observer _ is a behavioral design pattern that lets you define a subscription mechanism to notify multiple objects about any events that happen to the object theyβre observing.
So, we will have two parts here ...
1- The target, which will be the changing part, where the log target is changing.
2- The constant part, which is the observer.
So Lt's create the 2 classes, first we will write an interface for all Log observers (console, file, database, ..etc).
This class will have only the log function, to force all its implementations to implement it.
Same as what we did with the chain of logger, we will instantiate Log target in the logger class in the GetLogger function using the Logger manager again.
Also the LoggerTarget needs to be passed to the LogMessage and Display function.
And the LoggerManager will implement the new AddObservers function as below
internalclassLogManager{publicstaticAbstractLoggerDoChaining(){AbstractLoggerinfoLogger=newInfoLogger(LoggerLevel.Info);AbstractLoggererrorLogger=newErrorLogger(LoggerLevel.Error);AbstractLoggerdebugLogger=newDebugLogger(LoggerLevel.Debug);infoLogger.SetNextLevelLogger(errorLogger);errorLogger.SetNextLevelLogger(debugLogger);returninfoLogger;}// AddObservers is a static method that sets up observers for different logging levels.publicstaticLoggerTargetAddObservers(){// Create a new LoggerTarget. This is the target that the observers will be observing.LoggerTargetloggerTarget=newLoggerTarget();// Create a new ConsoleLogger. This logger will log messages to the console.ConsoleLoggerconsoleLogger=newConsoleLogger();// Add the ConsoleLogger as an observer for Info level logs.loggerTarget.AddObserver(LoggerLevel.Info,consoleLogger);// Add the ConsoleLogger as an observer for Error level logs.loggerTarget.AddObserver(LoggerLevel.Error,consoleLogger);// Add the ConsoleLogger as an observer for Debug level logs.loggerTarget.AddObserver(LoggerLevel.Debug,consoleLogger);// Create a new FileLogger. This logger will log messages to a file.FileLoggerfileLogger=newFileLogger();// Add the FileLogger as an observer for Error level logs.loggerTarget.AddObserver(LoggerLevel.Error,fileLogger);// Return the LoggerTarget with the observers added.returnloggerTarget;}}
ποΈ Observer Setup: The AddObservers method sets up observers for different logging levels using the Observer design pattern. Observers watch for changes in the target (the LoggerTarget) and react accordingly.
π LoggerTarget Creation: It creates a new LoggerTarget, which serves as the target that observers will be observing. The LoggerTarget manages the list of observers and notifies them of any changes.
π₯οΈ ConsoleLogger Setup: The method creates a ConsoleLogger, which logs messages to the console. It then adds this ConsoleLogger as an observer for Info, Error, and Debug level logs.
π FileLogger Setup: Additionally, the method creates a FileLogger, which logs messages to a file. It adds this FileLogger as an observer specifically for Error level logs.
π Observer Registration: Observers are registered with the LoggerTarget using the AddObserver method, specifying the logging level they are interested in and the corresponding logger to handle messages at that level.
π Return: Finally, the method returns the LoggerTarget with all the observers added, ready to observe and react to changes in logging levels.
Notice that in the Logger class, LoggerTarget is now sent to the LogMessage function, it will be passed to the display function for each type of logger (Debug, info, or error) to notify its observers.
So, let's edit the Abstract Logger too, to be like below
Now let's edit all implementations of the Display function..
Info Logger
internalclassInfoLogger:AbstractLogger{publicInfoLogger(LoggerLevellevels){this.Levels=levels;}protectedoverridevoidDisplay(stringmsg,LoggerTargetloggerTarget){//replace console log with notify observer loggerTarget.NotifyAllObservers(LoggerLevel.Info,"INFO: "+msg);}}
Error Logger
internalclassErrorLogger:AbstractLogger{publicErrorLogger(LoggerLevellevels){this.Levels=levels;}protectedoverridevoidDisplay(stringmsg,LoggerTargetloggerTarget){//replace console log with notify observerloggerTarget.NotifyAllObservers(LoggerLevel.Error,"ERROR: "+msg);}}
Debug Logger
internalclassDebugLogger:AbstractLogger{publicDebugLogger(LoggerLevellevel){this.Levels=level;}protectedoverridevoidDisplay(stringmsg,LoggerTargetloggerTarget){//replace console log with notify observerloggerTarget.NotifyAllObservers(LoggerLevel.Debug,"DEBUG: "+msg);}}
let's run the solution now and check the result ...
And it's working as expected, because according to our configuration, we are
printing info message to console
printing error message in console and file (and I will be printed in error and info levels).
printing debug message in console and file (and I will be printed in error,info and debug levels).
and we can easily change this behavior...
Let's assume we only need to print in the same level as instructed, not the level and the levels below it, then in the LogMessage function, we can easily edit it like below
publicvoidLogMessage(LoggerLevellevel,stringmsg,LoggerTargetloggerTarget){//old: if (Levels <= level)if(Levels==level){Display(msg,loggerTarget);}//The rest of the code of Abstract logger
Now the result will be
So as instructed in the LoggerManager class :
1- Console Logger will have info, debug and error.
2- File logger will have only errors.
Of course this LoggerManager in a real logger system will read from a config file to give you the flexibility to change logging levels and targets.
Wrapping Up: Embracing the Power of Design Patterns in Logging
In this tutorial, we've explored the intricate world of low-level design by building a logging framework from scratch. We've delved into the Singleton pattern, ensuring our logging class has only one instance, and the Chain of Responsibility pattern, allowing different loggers to handle log messages based on their levels. We've also embraced the Observer pattern, setting up observers to react to changes in logging levels.
By understanding and implementing these design patterns, we've not only created a functional logging system but also gained valuable insights into the art of designing flexible, extensible, and maintainable software solutions. We hope this tutorial has inspired you to apply these principles in your own projects and continue to explore the vast landscape of software design patterns. Happy coding!