Adding ILogger<T> support to Umbraco v8

8 min read

As part of my recent works on getting Vendr .NET Core ready, and because we are using a multi-targeted approach, one of the bigger changes between Umbraco v8 and v9 is that of logging where we have moved from an Umbraco logger interface of

public interface ILogger {
    Info<T>(...);
    Debug<T>(...);
    Error<T>(...);
}

To the .NET Core logger interface of

public interface ILogger<T> {
    Info(...);
    Debug(...);
    Error(...);
}

Functionally it's not a very big change, but when it comes to a multi-targeted approach one of the biggest aims you have is to try and maintain a standard API across both implementations in order to minimize the number of differences you have to manage.

And because logging is a cross cutting concern, it get's used in a LOT of places so we really don't want to have to be using conditional directives all over the place.

#if NET
    private readonly ILogger<MyClass> _logger;

    public MyClass(ILogger<MyClass> logger) 
    {
        _logger = logger;
    }
#else
    private readonly ILogger _logger;

    public MyClass(ILogger logger) 
    {
        _logger = logger;
    }
#endif

    public void MyMethod() 
    {
        // Do some common logic and log a result
        var result = "Some value";
#if NET
        _logger.Debug(result);
#else
        _logger.Debug<MyClass>(result);
#endif
    }

YUCK! 🤮

Introducing your own logger abstraction

The first part of the puzzle is that we will want to introduce our own logger abstraction and give it 2 implementations for the different frameworks. This means most of our code can simply depend on our interface and then the 2 implementations handle hiding away the differences.

A simplified example might look something like this.

// Our custom ILogger interface
public interface ILogger<T>
{
    Info(string message);
    Debug(string message);
    Error(Exception exception, string message);
}

#if NET

// The Umbravo v9 logger implementation
public class MicrosoftLogger<T> : ILogger<T>
{
    private global::Microsoft.Extensions.Logging.ILogger<T> _logger;

    public MicrosoftLogger(global::Microsoft.Extensions.Logging.ILogger<T> logger)
        => _logger = logger;

    public void Info(string message)
        => _logger.LogInfo(message);

    public void Debug(string message)
        => _logger.LogDebug(message);

    public void Error(Exception exception, , string message)
        => _logger.LogDebug(exception, message);
}

#else

// The Umbravo v8 logger implementation
public class UmbracoLogger<T> : ILogger<T>
{
    private global::Umbraco.Core.Logging.ILogger _logger;

    public UmbracoLogger(global::Umbraco.Core.Logging.ILogger logger)
        => _logger = logger;

    public void Info(string message)
        => _logger.Info(typeof(T), message);

    public void Debug(string message)
        => _logger.Debug(typeof(T), message);

    public void Error(Exception exception, , string message)
        => _logger.Error(typeof(T), exception, message);
}

#endif

By introducing the abstraction, our code now only needs to depend on our own interface and doesn't need to be concerned about how it has been implemented.

private readonly ILogger<MyClass> _logger;

public MyClass(ILogger<MyClass> logger) 
{
    _logger = logger;
}

public void MyMethod() 
{
    // Do some common logic and log a result
    var result = "Some value";

    _logger.Debug(result);
}

Much better 😻

Registering your implementations with the DI container

The last thing we need to do to be able to use our custom logger is to register our implementations with the DI container.

For Umbraco v9 and .NET Core this is pretty easy as MSDI supports registering generic interfaces and so we'd register it by calling the following code in our IUmbracoBuilder extension for adding our package.

public static class MyPackageExtensions
{
    public static IUmbracoBuilder AddMyPackage(this IUmbracoBuilder builder)
    {
        builder.Services.AddSingleton(typeof(ILogger<>), typeof(MicrosoftLogger<>));
        // Register your other services...
    }
}

For Umbraco v8 however, we have a bit a problem. As you can see in the v9 snippet above, we don't register a typed logger like ILogger<MyClass> as this would require us to register an instance for every class we want to be able to log in, which would be a lot. Instead we need to be able to register the generic interface with a generic implementation and have the DI container automatically resolve the generic type based up the injected dependency type.

Unfortunately, out of the box Umbraco doesn't provide the functionally for this and so we'll need to add it ourselves. But thankfully they do have something pretty close that we can modify.

What we need to do is define a custom extension method for the Umbraco Composer with the following code.

public static class ComposerExtensions
{
    internal static void RegisterAuto(this Composer composer, Type serviceBaseType, Type implementingBaseType)
    {
        var container = composer.Concrete as ServiceContainer;
        if (container != null)
        {
            container.RegisterFallback((serviceType, serviceName) =>
            {
                if (serviceType.IsInterface && !implementingBaseType.IsInterface
                    && serviceType.IsGenericType && serviceType.GetGenericTypeDefinition() == serviceBaseType)
                {
                    var genericArgs = serviceType.GetGenericArguments();
                    var implementingType = implementingBaseType.MakeGenericType(genericArgs);

                    container.Register(serviceType, implementingType);
                }

                return false;
            }, null);
        }
    }
}

What this is doing is registering a fallback method to the LightInject container (which is what Umbraco v8 uses for DI under the hood) which gets called if a requested type couldn't be located.

In this function we check to see if the requested type is the interface we are interested in and if so, we extract the generic type arguments from that type and then construct our concrete type passing in those generic arguments and then registering that service with the container for future requests.

With that in place, inside our composer we can then register our v8 implementation like so.

public class MyComposer : IUserComposer
{
    public void Compose(Composition composition)
    {
        composition.RegisterAuto(typeof(ILogger<>), typeof(UmbracoLogger<>));
        // Register your other services...
    }
}

And wa-lah! We can now register our custom generic logger in Umbraco v9 and v8 and maintain a consistent usage throughout our codebase 😎

I hope this comes in useful for some of the other multi-targeting package developers out there.