简体   繁体   中英

Can I change the category name given to ILogger<T> instances?

I have a custom ILogger implementation along with an ILoggerProvider and I've noticed that the categoryName parameter in ILoggerProvider.CreateLogger seems to be Type.FullName :

Gets the fully qualified name of the type, including its namespace but not its assembly.

However, my production code is obfuscated, and while not all consumers within the codebase are obfuscated, most are and their names become something insignificant (eg ), but alas, this is the nature of obfuscation.


Answering @madreflection's comment

I took some time to create a setup to test out @madreflection's curiosity surrounding the nameof expression:

public class SampleAttribute : Attribute {
    public string Name { get; set; }
    public SampleAttribute(string name) =>
        Name = name;
}
...
[Sample(nameof(InvoiceService))]
[Obfuscation(Exclude = false)]
public class TestClass {
    public TestClass(ILogger<TestClass> logger) {
        var attribute = GetType().GetCustomAttribute<SampleAttribute>();
        logger.LogInformation($"inline: {nameof(TestClass)}");
        logger.LogInformation($"attribute: {attribute.Name}");
    }
}

The output shows that the nameof expression is not obfuscated:

inline: TestClass
attribute: TestClass

Now, I'm curious as to where they're going with it!


With this in mind, is there a documented way to change the category name to a constant value?

A "simple" solution would be to inject ILoggerFactory and call CreateLogger . The category name is a parameter so it can be provided directly.

However, injecting ILogger<T> is often the preferred pattern, and switching to injecting ILoggerFactory could be a non-trivial undertaking. This solution helps avoid that.


There are 3 parts to this solution:

  1. A custom attribute that can be used to mark a class or struct with a category name.
  2. A custom ILoggerFactory implementation that replaces an obfuscated type name with the attribute value, if present.
  3. An extension method for registering the custom ILoggerFactory implementation.

Thanks to @Taco タコス's testing, we know that the obfuscator in question isn't trying to find the type name in string literals and obfuscate it there. That means that nameof can provide meaningful values for the category name in the attribute.


The Attribute

First, we need an attribute. It's nothing special, but here it is.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public class LoggerCategoryAttribute : Attribute
{
    public LoggerCategoryAttribute(string name)
    {
        Name = name;
    }

    public string Name { get; }
}

This attribute can be applied to each class or struct that's used as the T in ILogger<T> . It doesn't even have to be injected into that T 's constructor; it could be used anywhere.

The Custom Logger Factory

Next, we'll create a custom ILoggerFactory implementation that wraps a real one. The real logger factory could be the default LoggerFactory or it could be an implementation provided by another logging framework.

The CreateLogger method receives a categoryName parameter which might be a fully qualified type name, but it could be anything. If categoryName is a type name and Type.GetType is able to find its Type instance, CreateLogger looks for the LoggerCategory attribute to get the name and assigns it to categoryName if it's not null/empty. Otherwise, it leaves categoryName alone.

The other methods simply forward to the wrapped ILoggerFactory implementation.

public sealed class RecategorizingLoggerFactory : ILoggerFactory
{
    private readonly ILoggerFactory _originalLoggerFactory;

    public RecategorizingLoggerFactory(ILoggerFactory originalLoggerFactory)
    {
        _originalLoggerFactory = originalLoggerFactory;
    }

    public void AddProvider(ILoggerProvider provider) => _originalLoggerFactory.AddProvider(provider);

    public ILogger CreateLogger(string categoryName)
    {
        Type? type = Type.GetType(categoryName);
        if (type is not null)
        {
            var attribute = type.GetCustomAttribute<LoggerCategoryAttribute>();
            if (!string.IsNullOrEmpty(attribute?.Name))
                categoryName = attribute.Name;
        }

        return _originalLoggerFactory.CreateLogger(categoryName);
    }

    public void Dispose() => _originalLoggerFactory.Dispose();
}

Note: This class is sealed to avoid analysis messages about calling GC.SuppressFinalize in Dispose .

DI Registration

Finally, we need to register the custom logger factory for dependency injection.

If there's already a registration for ILoggerFactory , we can't use Add or TryAdd because the former would throw an exception and the latter would be a no-op. Instead, we need to find the existing registration and replace it in the list. Since IServiceCollection inherits IList<ServiceDescriptor> , it's easy to replace the service descriptor directly.

If there's no registration for ILoggerFactory , we add a new one, and create a new LoggerFactory to wrap, because our custom logger factory still needs to wrap something that can actually create loggers.

public static IServiceCollection InjectRecategorizingLoggerFactory(this IServiceCollection services)
{
    if (services is null)
        throw new ArgumentNullException(nameof(services));

    // List<T> has FindIndex(), but IList<T> does not, so it's inline as a local function here.
    static int FindIndex(IList<ServiceDescriptor> list, Func<ServiceDescriptor, bool> predicate)
    {
        for (int i = 0; i < list.Count; ++i)
        {
            if (predicate(list[i]))
                return i;
        }

        return -1;
    }

    int loggerFactoryIndex = FindIndex(services, sd => sd.ServiceType == typeof(ILoggerFactory));

    // These two variables will be assigned eventually.  Initializing them here prevents definite
    // assignment analysis from detecting that all branches of execution below have assigned
    // them, and with such large blocks, that can be a real concern, so they are intentionally
    // not initialized.
    Func<IServiceProvider, object> serviceFactory;
    ServiceLifetime lifetime;

    if (loggerFactoryIndex >= 0)
    {
        var oldServiceDescriptor = services[loggerFactoryIndex];
        lifetime = oldServiceDescriptor.Lifetime;

        // The service could be registered in one of three ways.  The first two are easy to support,
        // but the third can be fragile.  See the notes below the code for details.
        if (oldServiceDescriptor.ImplementationFactory is not null)
        {
            serviceFactory = oldServiceDescriptor.ImplementationFactory;
        }
        else if (oldServiceDescriptor.ImplementationInstance is ILoggerFactory oldLoggerFactory)
        {
            serviceFactory = sp => oldLoggerFactory;
        }
        else if (oldServiceDescriptor.ImplementationType is Type implementationType)
        {
            serviceFactory = sp =>
            {
                var loggerProviders = sp.GetServices<ILoggerProvider>();
                var filterOption = sp.GetRequiredService<IOptionsMonitor<LoggerFilterOptions>>();
                var options = sp.GetService<IOptions<LoggerFactoryOptions>>();

                try
                {
                    // BANG: ActivatorUtilities.CreateInstance is not annotated to accept null
                    //       elements in the 'arguments' parameter, but the constructor we want
                    //       to use takes a nullable IOptions<LoggerFactoryOptions>.  The method
                    //       signature is too restrictive, and passing null works where null is
                    //       allowed by the constructor.
                    return ActivatorUtilities.CreateInstance(
                        sp,
                        implementationType,
                        new object[] { loggerProviders, filterOption, options! });
                }
                catch
                {
                    return new LoggerFactory(loggerProviders, filterOption, options);
                }
            };
        }
        else
        {
            throw new InvalidOperationException("Invalid service descriptor encountered for ILoggerFactory.");
        }
    }
    else
    {
        lifetime = ServiceLifetime.Singleton;

        // No ILoggerFactory was registered.  Default to wrapping LoggerFactory.
        serviceFactory = sp => new LoggerFactory(
            sp.GetServices<ILoggerProvider>(),
            sp.GetRequiredService<IOptionsMonitor<LoggerFilterOptions>>(),
            sp.GetService<IOptions<LoggerFactoryOptions>>());
    }

    var newServiceDescriptor = new ServiceDescriptor(
        typeof(ILoggerFactory),
        sp => new RecategorizingLoggerFactory((ILoggerFactory)serviceFactory(sp)),
        lifetime);

    if (loggerFactoryIndex >= 0)
    {
        services[loggerFactoryIndex] = newServiceDescriptor;
    }
    else
    {
        services.Add(newServiceDescriptor);
    }

    return services;
}

Some points of note about the above code:

  • Although the loggerFactoryIndex >= 0 test could be done once with things moved around a bit, I did it twice because I only wanted one place where newServiceDescriptor is instantiated, since it's rather important and everything was intended to lead to that point.

  • If an ILoggerFactory implementation other than the default LoggerFactory was already registered, and only if it was registered with implementationType rather than implementationInstance or implementationFactory (these are AddSingleton parameter names), ActivatorUtilities.CreateInstance could fail. This code contains a modest attempt not to hard-code LoggerFactory . A lot more could be done to provide better support for wrapping other logger factories. I'm leaving that as an exercise to the reader.

  • I've used the "BANG:" comment to justify the use of the null-forgiving operator. I've found that in code reviews, this has led to improvements in various ways once other eyes are drawn to it, often to the point of not needing to use the operator at all. When it can't be rewritten away, it can help point out potential reasons for NullReferenceException s when the justification has ceased to be valid.

  • There should be no reason to get an exception for an "invalid service descriptor" and ServiceDescriptor ensures proper construction, unless reflection were used to null out all the private fields. It's primarily there for definite assignment analysis, as noted in one of the comments.


For completeness, and perhaps a bit of levity, here are some examples of how you might apply the attribute to a HomeController class.

// Confirmed, nameof works.
[LoggerCategory(nameof(HomeController))]

// But you don't have to use nameof if you don't wnat to.
[LoggerCategory("HomeController")]

// Who needs "Controller" anyway?  The *Name* of the controller is "Home", after all!
[LoggerCategory("Home")]

// You can include a hard-coded namespace so it looks like the original,
// unobfuscated type name.
[LoggerCategory($"MyApplication.Controllers.{nameof(HomeController)}")]

// A more robust version of the former.  As long as the depth of namespace
// doesn't change, this will capture renaming of *any* of the parts.
[LoggerCategory($"{nameof(MyApplication)}.{nameof(MyApplication.Controllers)}.{nameof(HomeController)}")]

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM