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.
Top comments (2)
Hi Matt,
it turned out that the solution to my question was really easy. Thanks to Bjarke Berg for the key hint:
This did the trick. I can use
Microsoft.Extensions.Logging.Abstractions
in my .Net Standard libraries, logging against its MS ILogger abstraction, and provide the actual logger in Umbraco directly from Serilog, so all my class libraries log directly to the Umbraco 8 log. TheSerilog.Extensions.Logging
package does the heavy lifting between MS ILogger and Serilog ILogger.May be this is also interesting for you, in this way, your Umbraco 8 and 9 code can just log against the MS ILogger and all you need for Umbraco 8 is a small service to provide the MS Logger. In case you are interested: I updated my issue on Our with the solution.
Hi Matt,
thank you for posting this. I have a kind of similar problem in Umbraco 8. For some of my .Net Standard libraries, I need to provide a
Microsoft.Extensions.Logging.ILogger
. There is a Serilog extension for this, see this StackOverflow answer, which creates an MS logger from the Serilog logger.The only trouble I have in Umbraco 8 is: how to get an instance of the Serilog logger through dependency injection or otherwise? Do you know a way?
I also posted a question on Our about this.