In modern C# applications, cross-cutting concerns like logging are essential yet challenging to implement efficiently. Traditional approaches often rely on external tools like Aspect-Oriented Programming (AOP) frameworks (e.g., PostSharp) or dynamic proxy generation libraries like Castle DynamicProxy. While effective, these solutions introduce third-party dependencies, potential runtime overhead, and complexity. This article presents an alternative approach: a lightweight, dependency-free logging mechanism leveraging C#'s built-in DispatchProxy class.
The solution described here prioritizes performance through caching and early exits, ensuring minimal runtime impact. It includes benchmarks comparing different configurations, demonstrating the negligible overhead when logging is disabled. Let's explore the implementation in detail.
LoggingProxy Class
The LoggingProxy<T> class acts as the backbone of this solution. It wraps an object of type T, intercepting method calls to log their invocation and results. Here are some key features.
- Reflection Caching: Reflection results are cached using ConcurrentDictionary to minimize repeated overhead.
- Conditional Logging: The proxy only logs methods annotated with a custom LogAttribute or methods in types/classes annotated with the same attribute.
- Error Handling: Exceptions thrown by proxied methods are caught and logged with their full stack trace.
Key Implementation Highlights
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
ArgumentNullException.ThrowIfNull(targetMethod);
if (_decorated == null || _logger == null)
{
throw new InvalidOperationException("LoggingProxy is not properly initialized.");
}
(bool shouldLog, LogLevel logLevel) = _logAttributeCache.GetOrAdd(targetMethod, method =>
{
LogAttribute? attribute = method.GetCustomAttribute<LogAttribute>()
?? method.DeclaringType?.GetCustomAttribute<LogAttribute>();
return (attribute != null, attribute?.Level ?? LogLevel.Information);
});
shouldLog &= _logger.IsEnabled(logLevel);
if (!shouldLog)
{
return targetMethod.Invoke(_decorated, args);
}
string methodName = targetMethod.Name;
_logger.Log(logLevel, "{MethodName} invoked.", methodName);
object? result = targetMethod.Invoke(_decorated, args);
_logger.Log(logLevel, "{MethodName} returned {Result}.", methodName, result);
return result;
}
LogAttribute
The LogAttribute class is a simple, declarative way to annotate methods or classes for logging. It specifies the logging level, defaulting to Information.
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Interface, Inherited = true)]
public class LogAttribute : Attribute
{
public LogLevel Level { get; }
public LogAttribute(LogLevel level = LogLevel.Information)
{
Level = level;
}
}
This attribute ensures fine-grained control over which methods or classes should be logged and at what level.
ILoggingProxy Interface
The ILoggingProxy interface provides access to the underlying object being proxied. This enables advanced scenarios, such as unwrapping proxied instances or combining proxies with other features.
internal interface ILoggingProxy
{
object? Decorated { get; }
}
LoggingProxyHelper Class
The LoggingProxyHelper class adds utility functionality for safely unwrapping proxies. It allows seamless interaction with both proxied and non-proxied instances.
public static T? SafeCast<T>(object instance) where T : class
{
if (instance is T target)
{
return target;
}
if (instance is ILoggingProxy proxy && proxy.Decorated is T decorated)
{
return decorated;
}
return null;
}
This ensures robust and predictable behavior when working in mixed scenarios.
The Cool Factor: DebuggerDisplay Attribute
One of the standout features of this logging proxy implementation is its use of the DebuggerDisplay attribute. This attribute enhances debugging by customizing how objects are displayed in the debugger, making it easier to identify and understand their state at a glance.
In the LoggingProxy implementation, DebuggerDisplay values are dynamically resolved and cached for efficient reuse. When logging method parameters or return values, the proxy attempts to use the DebuggerDisplay representation if available, falling back to the default ToString() behavior when necessary.
Why is this useful?
- Enhanced Readability: Instead of displaying raw object types or complex structures, meaningful representations are shown in debugger windows.
- Performance Optimization: By caching DebuggerDisplay values, the implementation minimizes the overhead of repeatedly evaluating the attribute during logging.
Example
[DebuggerDisplay("Name = {Name}, Age = {Age}")]
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
}
var person = new Person
{
Name = "John",
Age = 30
};
When this Person instance is logged through the proxy, its DebuggerDisplay value ("Name = John, Age = 30") will appear in the logs, providing clear and concise information.
Usage Example
Here’s how to integrate and use the logging proxy in your application.
public interface IExampleService
{
void PerformAction(string input);
}
[Log(LogLevel.Debug)]
public class ExampleService : IExampleService
{
public void PerformAction(string input)
{
Console.WriteLine($"Action performed with input: {input}");
}
}
var service = LoggingProxy<IExampleService>.Create(new ExampleService(), logger);
service.PerformAction("Test");
In this example, the ExampleService class is wrapped with a logging proxy. Method calls are logged according to the specified log level.
Benchmarking Results
Benchmarks were conducted using Benchmark.Net to measure the impact of this approach. The results are as follows.
Without Proxy/Logging
Method |
Mean |
Error |
StdDev |
Allocated |
Print |
330.3 ms |
12.22 ms |
35.05 ms |
114.89 MB |
Proxy Enabled, Logging Disabled
Method |
Mean |
Error |
StdDev |
Allocated |
Print |
331.6 ms |
11.77 ms |
33.59 ms |
121.28 MB |
Proxy Enabled, Full Logging
Method |
Mean |
Error |
StdDev |
Allocated |
Print |
587.0 ms |
27.47 ms |
80.13 ms |
281.18 MB |
These results demonstrate minimal overhead when logging is disabled. Even with full logging, the performance remains reasonable given the complexity of the operations.
Conclusion
This proxy-based logging mechanism offers a lightweight, dependency-free solution to implement cross-cutting concerns in C#. By leveraging DispatchProxy and thoughtful performance optimizations, it achieves robust logging without the pitfalls of traditional AOP or third-party frameworks. With the addition of tools like Benchmark.Net, developers can ensure their implementation meets performance expectations in production environments.
The complete source code is attached to this article for your reference. Feel free to adapt and enhance it as needed.