Friday, February 6, 2015

Logging Continued

In my last blog post you saw how you can define a simple logging facility.  Now I need to show you how to implement it :-)  Let's start by implementing the prototypical instance I mentioned in the previous blog post.  Let's define the static configuration method as the following.  If the prototype has not yet been created, we'll do that here first.

+ (MDBLog *)configuration {

    static MDBLog *prototypeLog = nil;
    @synchronized(self) {
        
        //Initial defaults, can be overridden.
        if (prototypeLog == nil) {
            
            prototypeLog = [[self alloc] init];
        
            //Set the defaults
            prototypeLog.logLevelMDBLogLevelError;
            prototypeLog.logContext = [NSString stringWithFormat:@"%@", [MDBLog class]];
            prototypeLog.logFormat = @"[%@] (%@:%@): %@";
            prototypeLog.logWriter = [MDBLog defaultLogWriter];
        }
    }
    
    return prototypeLog;
}

First, we create the static Log prototype.  Then we set the defaults for that prototype.  Lastly, we return that static instance that can be configured.

You'll notice that I set the log level to Error (the highest, least detailed), use the default log writer which writes to the console, and give a message string format that will show something like the following.  This of course can be configured.

"[LOGLEVEL] (CLASS:METHOD): LOG MESSAGE PARAMETERIZED WITH VALUES"

When we create a new Log instance, we'll use one of the following initialization methods which uses the prototypical instance to set the defaults:

- (id)initWithClass:(Class)logClass {
    
    return [self initWithContext:[NSString stringWithFormat:@"%@", logClass]];
}

- (id)initWithContext:(NSString *)logContext {
    
    if (self = [super init]) {
        
        _logContext = logContext;
        _logFormat = [MDBLog configuration].logFormat;
        _logLevel = [MDBLog configuration].logLevel;
        _logWriter = [MDBLog configuration].logWriter;
    }
    
    return self;
}

The logging methods are just as you'd expect them to be.  Here they are for "Error" log level, the others are similar:

- (BOOL)errorEnabled {
    
    return [self levelEnabled:MDBLogLevelError];
}

- (void)error:(SEL)method format:(NSString *)format, ... {
    
    va_list args;
    va_start(args, format);
    
    [self logAt:MDBLogLevelError method:method format:format args:args];
}

The generic methods for logging are defined as the following:

- (BOOL)levelEnabled:(enum MDBLogLevel)intentLevel {

    return self.logLevel <= intentLevel;
}

- (void)logAt:(int)intentLevel method:(SEL)method format:(NSString *)format args:(va_list)args {
    
    [self.logWriter log:self logLevel:intentLevel method:method format:format args:args];
}

In this case, all the information is sent to the configured Log Writer, including the variable parameter list and associated log message format string.  In order to write out to the console, we will implement the Log Writer like so:

@implementation MDBLogWriterDefault : NSObject

- (void)log:(MDBLog *)log logLevel:(enum MDBLogLevel)logLevel method:(SEL)method format:(NSString *)format args:(va_list)args {
    
    if ([log levelEnabled:logLevel]) {

        NSString *str = [[NSString alloc] initWithFormat:format arguments:args];
        NSLog(log.logFormat, [MDBLog nameOfLevel:logLevel], log.logContext, NSStringFromSelector(method), str);
    }
}

Of course, you can provide your own Log Writer that could pipe the log message to any other output or another logging facility.

An example use of this Logging facility is something like the following:

// Unnecessary, but done to show how can set custom writer.
[[MDBLog configuration] setLogWriter:[MDBLog defaultLogWriter]];

#ifdef DEBUG
[[MDBLog configuration] setLogLevel:MDBLogLevelDebug];
#else
[[MDBLog configuration] setLogLevel:MDBLogLevelWarn];
#endif

_log = [MDBLog logWithClass:[self class]];

...

[self.log debug:_cmd format:@"Formatted log message with key/value pair: %@=%@", key, value];

And that's it!  Simple and powerful.  Read more to get the full implementation here...

Logging

My first development blog post, and what other topic would I pick other than the boring topic of Logging!  It might be boring, but it's fundamental in all we devs do.  I don't know about you, but I hate bringing in big frameworks or complex code, especially just to do logging.  I prefer to have my own simple set of classes, and if I want to later, I can simply pipe the results to an existing framework depending on the project I'm working on.

So here we go, how do we create a simple logging capability that is flexible enough to plug in else where?  It's simple!  I'll show you just what I did, in this case in Objective-C, but the concepts can easily convert to any other syntax.

So let's start with the basics, logging levels.  These are pretty standard across the board, and start from most amount of detail, to least.  So for example, selecting logging level "info" will include info message, as well as debug and fine messages.  So let's define an enum that represents this (note, using MDB as the prefix which stands for my blog name "My Dev Bits"):

enum MDBLogLevel {
    MDBLogLevelFine = 1,
    MDBLogLevelDebug,
    MDBLogLevelInfo,
    MDBLogLevelWarn,
    MDBLogLevelError
};

Next, let's define the how we get a Log we can use in our code.  We'll create two static methods:

+ (MDBLog *)logWithClass:(Class)logClass;
+ (MDBLog *)logWithContext:(NSString *)logContext;

The first will create a "Context" (a simple string) using the name of the Class.  If you want to use something else, you can specify that in the second method.

Now, we will want to be able to configure the Log globally, so we will add a static method for that.

+ (MDBLog *)configuration;

And a static helper method that we can use later to format our Log strings.

+ (NSString *)nameOfLevel:(int)logLevel;

Now we need to define a set of properties and getters/setters for a Log instance.  In this case, a Log instance will have a 1) logging level (the level at which it will either output or suppress the log output), 2) the format of the logging string, and 3) a string log context. 

@property (readwrite, nonatomic) enum MDBLogLevel logLevel;
@property (readonly, nonatomic, copy) NSString *logFormat;
@property (readonlynonatomiccopyNSString *logContext;

The log Level and log Format could actually just be statically defined, but instead I made them instance variables to give the most flexibility in case there is a reason to change it for a particular logging instance.  But we don't want to do this all the time, so in order to have default values, we will create a "prototype" Log instance that we will create internally and will be used to set the defaults for any new Log instances created.  More on that later.

Now we need to define the methods a client can use to instruct the Log to output some information, let's define them this way to match the possible logging levels:

// Determines if the Log is outputting at the specified intent level.
- (BOOL)levelEnabled:(enum MDBLogLevel)intentLevel;

// Determines if the particular Log level is enabled.
- (BOOL)fineEnabled;
- (BOOL)debugEnabled;
- (BOOL)infoEnabled;
- (BOOL)warnEnabled;
- (BOOL)errorEnabled;

- (void)log:(enum MDBLogLevel)logLevel method:(SEL)method format:(NSString *)format, ...;

- (void)fine:(SEL)method format:(NSString *)format, ...;
- (void)debug:(SEL)method format:(NSString *)format, ...;
- (void)info:(SEL)method format:(NSString *)format, ...;
- (void)warn:(SEL)method format:(NSString *)format, ...;
- (void)error:(SEL)method format:(NSString *)format, ...;

The main logging method here is the "log" method and it takes a logging level, a method that is calling the log, and the string that defines the format of this particular logging message.

Also note that the last parameter is "..." and the refers to a dynamic list of parameters that an be provided.  This will allow the client to call the logging methods and pass a string that will have substitute values, and then provide the list of those values to substitute.

The other methods are for convenience and correspond to logging at a particular logging level and will ultimately just call the more generic "log" method specifying the log level.

By default, we will just write the logging output to the console.  But we want the ability to "pipe" the logging output to some other output (e.g. another logging facility perhaps).   We can accomplish this simply by defining an interface for a "log writer" and then let clients of this Log facility create their own and plug them in.  So let's define that, first the interface:

@protocol MDBLogWriter <NSObject>

- (void)log:(MDBLog *)log logLevel:(enum MDBLogLevel)logLevel method:(SEL)method 
format:(NSString *)format args:(va_list)args;

@end

Implementors of this interface will get the calling Log class, the log level to output at, the calling method, a format for the string, and the variable list of argument parameters to replace in the format string.

We will also create a default Log Writer that will just output to the console, and we can define that with a property for the Log Writer in a particular Log instance.

@property (readwrite, nonatomic) id<MDBLogWriter> logWriter;

And a static method to get the default log writer:

+ (id<MDBLogWriter>)defaultLogWriter;

And then define the default Log Writer instance:

@interface MDBLogWriterDefault : NSObject <MDBLogWriter>
@end 

So that's it, this defines our Log class and related facilities.  In my next blog post I'll describe what these properties and methods do, and how to implement them.  In the mean time, read more here to get the complete header file...