Further than SAM

NOTE: this is the second part of this article. Please read it if haven’t yet.


The interface

Let’s start from scratch. You know all the things you read in previous article, but you have no code at all.

So we said that SAM interfaces are technically equivalent to functions (to be precise: Func or Action depending on return type). So, let’s see where it can get us.

Knowing the interfaces were declared like this:

1
2
3
4
5
6
7
public interface ILogger {
void Log(Severity severity, Func<string> builder);
}
public interface ILoggerFactory {
ILogger GetLogger(string name);
}

we can redefine them as:

1
2
3
// pseudo-code
type ILogger = Action<Severity, Func<string>>;
type ILoggerFactory = Func<string, ILogger>;

Now, we can substitute ILogger actual type (Action<...>) as ILoggerFactory output type, receiving:

1
2
// Pseudo-code
type ILoggerFactory = Func<string, Action<Severity, Func<string>>>;

So, the interface to logging framework is just Func<string, Action<Severity, Func<string>>>, and next time someone ask you on the tube, “Hey man, what’s your Func<string, Action<Severity, Func<string>>>?” you can just tell him: “I’m using log4net, matey”.

In my opinion, using descriptive names depends on scope. I would actually encourage you to use just Func<T> for locally scoped or internal factory, or i as an index in 3-line for statement but for objects which are known by entire application I would prefer longer, more distinguished names.
In case of “logging framework interface” I would suggest quite descriptive names. Yes, it is just a function, but it would be much more readable if we call it LoggerFactory.

Let’s do it then:

1
2
3
public enum Severity { Trace, Debug, Info, Warn, Error, Fatal }
public delegate Logger LoggerFactory(string name);
public delegate void Logger(Severity severity, Func<string> builder);

That’s our interface.

You can almost go home now

So, the NLog implementation we did in previous article will look now a little bit different:

1
2
3
4
5
6
7
8
9
10
11
public static LoggerFactory NLogLoggerFactory()
{
return name => { // factory
var logger = LogManager.GetLogger(name);
return (severity, builder) => { // logger
var level = ToLogLevel(severity);
if (logger.IsEnabled(level))
logger.Log(level, builder());
};
};
}

You can see that we declared a factory method which can be called LoggerFactory constructor. A LoggerFactory will construct a Logger every time you call it with logger name (name => { ... }). A Logger will log the message when called with severity and (message) builder ((severity, builder) => { ... }).

If you need simplest possible implementations of LoggerFactory I would say it would NullLoggerFactory followed by ConsoleLoggerFactory:

1
2
3
4
5
6
7
8
9
10
public static LoggerFactory NullLoggerFactory()
{
return name => (severity, builder) => { };
}
public static LoggerFactory ConsoleLoggerFactory()
{
return name => (severity, builder) =>
Console.WriteLine($"[{severity}] {name}: {builder()}");
}

Let’s look at specific usage. You have to create a logger factory first (you need one of those):

1
var loggerFactory = NLogLoggerFactory(); // or ConsoleLoggerFactory() ?

Then, you can create loggers when you need them:

1
var logger = loggerFactory("default");

and log messages like you always did:

1
logger(Severity.Warn, () => "Something is rotten in the state of Denmark");

You can also just sprint through all the layers:

1
loggerFactory("root")(Severity.Trace, () => "Adhoc message");

Job’s done. That’s what we wanted.

It is not exactly the same, right?

It is not. There is only one way to use it but wasn’t it a good thing?. Joking aside, we want our extension methods back. Good news is, all you need is to ask:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static class LoggerFactoryExtensions
{
public static Logger Logger(this LoggerFactory factory, string name) =>
factory(name); // the call actually happens here
public static Logger Logger(this LoggerFactory factory, Type type) =>
factory.Logger(type.FullName);
public static Logger Logger<T>(this LoggerFactory factory) =>
factory.Logger(typeof(T));
[MethodImpl(MethodImplOptions.NoInlining)]
public static Logger Logger(this LoggerFactory factory) =>
factory.Logger(new StackTrace().GetFrame(1).GetMethod().DeclaringType);
}

So now you can use extension methods on LoggerFactory or use LoggerFactory as a function (yes, it may look confusing):

1
2
3
4
5
// just call *it*
var logger = loggerFactory("RootLogger");
// or use extension
var logger = loggerFactory.Logger(typeof(GatewayController));
var logger = loggerFactory.Logger();

Normalizing Logger interface

In previous article we’ve introduced message Severity to reduce number of overloads, but when we added convenience methods there was 24 of them again (still better than 24 to be implemented on interface though). Let’s try to apply same tactics and normalize convenience layer as well. We can create new “interface” (it’s not technically an interface but it is still an interface). Let’s call it Channel (like Trace, Debug or Error channel).

1
public delegate void Channel(Func<string> factory);

As you can see, Channel is like a Logger, but without Severity, as Severity has been already applied. We can add some convenience methods as well:

1
2
3
4
5
6
7
8
9
10
11
12
public static partial class LoggerExtensions
{
public static Channel Channel(this Logger logger, Severity severity) =>
builder => logger(severity, builder); // partially apply severity
public static Channel Debug(this Logger logger) => logger.Channel(Severity.Debug);
public static Channel Trace(this Logger logger) => logger.Channel(Severity.Trace);
public static Channel Info(this Logger logger) => logger.Channel(Severity.Info);
public static Channel Warn(this Logger logger) => logger.Channel(Severity.Warn);
public static Channel Error(this Logger logger) => logger.Channel(Severity.Error);
public static Channel Fatal(this Logger logger) => logger.Channel(Severity.Fatal);
}

Now you can use like this:

1
2
var channel = logger.Trace();
channel(() => "This is trace message");

There is still only one way to format a message (using Func<string>). Although, we have a type (Channel) we can add extensions methods to it:

1
2
3
4
5
6
7
8
9
10
11
12
public static class ChannelExtensions
{
public static void Log(this Channel channel, Func<string> builder) =>
channel(builder); // the call actually happens here
public static void Log(this Channel channel, string message) =>
channel.Log(() => message);
public static void Log(this Channel channel, string pattern, params object[] args) =>
channel.Log(() => string.Format(pattern, args));
public static void Log(this Channel channel, Exception error) =>
channel.Log(error.ToString);
}

So, now we have all convenience methods back:

1
2
3
var logger = loggerFactory.Logger(); // for this class
logger.Warn().Log("This is a message with some args, like '{0}' for example", 7);
logger.Error().Log(new Exception("Failed to do something"));

Syntactically, I don’t like the parenthesis after channel (Trace, Debug, etc.). Unfortunately, we don’t have extension properties yet (C# 8, maybe?). As soon as they are available, they could be used to strip parenthesis from channel selection and produce prettier code:

1
2
var logger = loggerFactory.Logger(); // for this class
logger.Warn.Log("This is a message with some args, like '{0}' for example", 7);

Nice, isn’t it?

Full implementation

So, complete implementation of the interface with extension methods is:

While adapter for NLog could be implemented like:

What can be added?

Definitely, message expansion (builder()) should be wrapped in try...catch so badly formatted message cannot blow up the application. It would suggest doing it in Logger.Channel(this Logger logger, Severity severity) as everything ultimately goes through this function (yes, it is by design - there is only one place where it needs to be done).

I would also invest in better error.ToString to log exception. Some Func<Exception, string> (of course) which would explain the exception, processing inner exceptions and stack traces.

Convenience methods can be still added to Logger: like Log(Severity, ...), and all 24 overloads for Trace(...), Debug(...), etc. I didn’t do it here, as they are not really needed anymore, but if you really like them there is no problem to do so (see GitHub project for T4 implementation).

Sources

This project is available on GitHub. Please note, this is just a toy project to present some ideas rather than production ready solution.