Portable Logging Architecture for Multi-Platform Applications

20
May

Portable Logging Architecture for Multi-Platform Applications

When I began writing portable libraries, I quickly realized that my usual go-to logging library (NLog) was not compatible with portable class libraries. Not using a logging system was unacceptable, and departing from my favorite logging library felt silly. To solve this problem, I began to write a portable logging architecture that no longer required developers to make this choice. Enter, the Portable Log Adapter.

A Logging Facade 

As a developer, implementing a logging component is a critical task; it provides a historical record of what has happened and insight into why anything might have gone wrong. Logging is almost exclusively the first component I work on when I begin a new project (or take over an existing project). This is why the Portable Log Adapter is the first component in my portable components blog series. When I began to design this library I wanted to ensure that the choice of logging system was insignificant. Most logging systems that I have encountered have a very large intersection of common functionality and a small subset of complimentary functionality (if any). This means that there is no reason that a generic logging facade cannot be made to work with 95% of all logging use-cases. There will always be some corner cases where a facade is simply not good enough, but these cases should be encountered fairly seldom, and are therefore not relevant to this discussion (we want to solve the 95% cases).

When you break it down, logging hasn't truly changed much in the last 20 years. Typically we write messages to the system, then expect those messages to appear in some (or many) output(s). The complexities of logging and the advancement of the systems available today come in the form of configuration. The interesting thing is that configuration (typically) has no effect on the actual logging calls performed in the software. This means that we can very much ignore this aspect when abstracting a logging system into a facade. As long as the configuration is performed one way or another, the calls into the logging system will remain the same. It is therefore imperative that our facade's API is solid, full featured, and simple to use when creating loggers and performing logging calls.

Designing the Logging Contracts

NLog was my go-to logging system for many years (for a multitude of reasons, but primarily because I appreciated the simplicity and extensibility of their API and configuration). When I began this project I decided I would first adapt the facade to the NLog system. This development decision gives the facade an NLog-like feel to it. The main similarity that most NLog users will notice right away is the LogManager and Logger relationship. However, in the case of this facade, we want to instead represent this as an ILogManager and ILogger, since the corresponding logging systems would provide an implementation to these contracts (the Design By Contract approach has me calling all interfaces contracts these days).

The ILogManager contract has a very simple role, produce ILogger instances given a logger name. Most logging systems use a Type (sometimes automatically determined), but ultimately this boils down to a name (produced from a Type). By eliminating clutter in the contract, we leave all the configuration details up to the implementing system and only expose the necessary functionality. The ILogger instances must be named, configured with a logging level, and support logging messages. Sounds pretty boring, but again the trick is to keep the contract simple to only expose the most basic necessary functionality. Ultimately, an ILogger contract only requires a very simple Log(level, message) function. A slight deviation from this concept is found in ILogger as the contract exposes discrete functions for logging both with and without an exception, as well as both with and without a message creator delegate. This design decision was made to improve overall consistency with the majority of logging systems without breaking compatibility. That is, most logging systems provide special handling of logging messages with exceptions, as well as logging messages where the message is generated dynamically through a delegate (so you don't waste resources on computing a complex logging message if the message is never materialized to any output). Exceptions can easily be interpolated into a logging message, and the message creator delegate can easily be made backwards compatible with systems that do not support it. Finally, the ILogger additionally provides automated message formatting since most systems also support this, and if not there is a handy built-in extension called FormatWith(this string format, params object[] args) that will handle the formatting (and intelligently bypass formatting when no args are supplied). All that remains is making this facade easy to use, and that's where the extensions come in.

Adding Functionality With Extensions

This project is built heavily upon the power of extension methods. By providing the bare necessities of a logging contract (and a log manager contract), the extension methods can layer on top of those core functions the simple function calls that make logging a breeze (by eliminating redundant function parameters). This is where the real power of this logging facade comes into play, the majority of the API is codeless and only adapts the input parameters to core contract parameters. This design allows an extremely simple logging system implementation that immediately gains the full API advantage of the facade. This project also makes heavy use of T4 templates to automate the production of the API based on a core set of logging levels. This allows the rich API of this facade to be (re)generated easily and deterministically. These T4 templates generate the log levels available and all the level-named extension methods appended to the ILogger contract. The result is that we have a rich API produced from a microscopic amount of source code. Less code means less code to test, and less code to test means an easier library to maintain.

Logging Adapters

There are a few main components to an adapter implementation. We obviously need to implement the manager and the logger functionality, but beyond that basic implementation we need to perform some mapping between log levels (since this facade uses a common level enumeration). The manager implementation is dead simple and typically involves only a very small amount of code. For the NLog adapter, we simply wrap the call to create a logger.

public ILogger GetLogger(string name)
{
    return new NLogLogger(NLog.LogManager.GetLogger(name));
}

The logger is a bit more involved since we perform the level conversions. These are made relatively simple by using static mapping tables to go in either direction, and conversion helper functions to perform those mappings.

private static readonly Dictionary<NLog.LogLevel, LogLevel> NLogToPortableMap = 
    new Dictionary<NLog.LogLevel, LogLevel>() { /*...*/ };
private static readonly Dictionary<LogLevel, NLog.LogLevel> PortableToNLogMap = 
    new Dictionary<LogLevel, NLog.LogLevel>() { /*...*/ };

private static NLog.LogLevel Convert(LogLevel level) { /*...*/ };
private static LogLevel Convert(NLog.LogLevel level) { /*...*/ };

With these in place, the contract implementation becomes very straight forward, just a simple wrapping around the NLog system.

public void Log(LogLevel level, string format, params object[] args)
{
    Logger.Log(Convert(level), format, args);
}

Along with the NLog adapter that I have used as an example above, I am also releasing a log4net adapter for those that are more comfortable with their logging system. Additionally, there will be a beta release of an Android logger (which simply adapts to logcat). In the works is an iOS adapter, once I spend a little more time learning the best logging practices for iOS software. I haven't really used any other logging software so leave a comment if you think you know a strong candidate for another log adapter (or better yet, just send me a pull request with the implementation).

Getting Started with the Facade

Using this facade in your projects is beyond incredibly simple. After adding a reference to the package from NuGet, all that is left is to create the ILogManager and start requesting new ILogger instances. Below is a simple example of how to get started with the built-in DelegateLogManager using Debug.WriteLine as the output. There is also a screen cast available where I go through a simple demo explaining the benefits of this package.

// this would be placed somewhere central, App.xaml.cs for example
// we use DelegateLogManager here for simplicity, but this could be replaced with any other adapter
// at any time to seamlessly transition to a full logging system
public static readonly ILogManager LogManager =
  new DelegateLogManager((logger, message) => System.Diagnostics.Debug.WriteLine("[{0}]{1}", logger.Name, message), LogLevel.Info);

// use any of the GetLogger functions
ILogger logger1 = LogManager.GetLogger("name");
ILogger logger2 = LogManager.GetLogger(this);
ILogger logger3 = LogManager.GetLogger(GetType());
ILogger logger4 = LogManager.GetLogger<MyClass>();

// now just log using the rich API
logger1.Debug("this is a {0} log message", "Test");
logger2.Info(() => FetchMessageFromExpensiveFunction());
logger3.WarnException(new Exception(), "A wild exception has appeared");
logger4.Log(LogLevel.Trace, "this is a test");

Header image sourced from Getty Images