Simplifying Caching with Aspect-Oriented Programming and Metalama in .NET
1. Introduction: Simplifying Caching with Aspect-Oriented Programming in .NET
Aspect-Oriented Programming (AOP) offers elegant solutions for handling cross-cutting concerns in our .NET applications, such as method caching. In a previous article on GoatReview 🐐, we explored using Fody to implement a caching aspect. Although powerful, this tool presents significant challenges.
Let's briefly recall the implementation with Fody:
public class ModuleWeaver : BaseModuleWeaver
{
// ... (more than 100 lines of complex code)
private void InjectCache(MethodDefinition method)
{
var processor = method.Body.GetILProcessor();
var instructions = method.Body.Instructions;
var returnInstructions = instructions.Where(instr => instr.OpCode == OpCodes.Ret).ToList();
foreach (var returnInstruction in returnInstructions)
{
processor.InsertInstructions(returnInstruction, SetCacheValue(method));
}
var firstInstruction = instructions.First();
processor.InsertInstructions(firstInstruction, ReturnGetValueCacheIfAny(method));
}
// ... (several complex helper methods)
}
The full code is available on GitHub
This approach, although functional, has several drawbacks:
- Verbose and complex code, requiring a deep understanding of CIL.
- Difficult maintenance due to the low-level nature of the code.
- Complex debugging, as the generated code is not directly visible in the IDE.
- Steep learning curve for developers unfamiliar with these advanced concepts.
- No native support of dependency injection.
These challenges raise a crucial question: how can we simplify the implementation of caching aspects while maintaining their power and flexibility?
This is where Metalama comes into play. This innovative tool promises to revolutionize how we approach AOP in .NET, offering a more intuitive syntax and advanced debugging features.
In this article, we will explore how Metalama can transform our approach to method caching. We'll see how it simplifies not only the writing of code but also its maintenance and debugging, making AOP accessible to a wider range of .NET developers.
Get ready to discover a new way of thinking about and implementing your caching aspects, which could well change your approach to .NET development.
2. Use Case: Method Caching with Metalama 🦙
In our scenario, we will implement a caching aspect for a simple Calculator
class. The goal is to cache the result of the Add
method to avoid repetitive calculations and improve performance.
Here's our Calculator
class:
public class Calculator
{
public int InvocationCounts { get; private set; }
[Cache]
public int Add(int a, int b)
{
Console.WriteLine("Thinking...");
this.InvocationCounts++;
return a + b;
}
}
In this example:
- The
Calculator
class has anAdd
method that simulates an expensive calculation with a "Thinking..." message. InvocationCounts
allows tracking the actual number of method executions.- The
[Cache]
attribute is used to indicate that we want to cache the result of this method.
The caching aspect we will create with Metalama should:
- Check if the result for a given pair of parameters (a, b) already exists in the cache.
- If yes, return this result directly without executing the method.
- If no, execute the method, store the result in the cache, then return it.
Expected advantages of using Metalama:
-
Simplicity of implementation: Metalama will allow us to define this caching aspect in a concise and readable manner, without having to modify the existing code of the
Calculator
class. -
Separation of concerns: The caching logic will be completely separate from the business logic of the
Add
method. -
Reusability: Once created, this aspect can be easily applied to other methods or classes requiring caching.
-
Ease of maintenance: If we need to modify the caching logic, we will only have to update the aspect, without touching the methods that use it.
-
Simplified debugging: With tools provided by Metalama like LamaDebug, we will be able to easily inspect and debug the generated code for caching.
To illustrate the effectiveness of this approach, we could use the following code:
var calculator = new Calculator();
Console.WriteLine(calculator.Add(2, 3)); // Will display "Thinking..." and return 5
Console.WriteLine(calculator.Add(2, 3)); // Will use the cache, no "Thinking..."
Console.WriteLine(calculator.Add(3, 4)); // Will display "Thinking..." and return 7
Console.WriteLine(calculator.Add(2, 3)); // Will use the cache again
Console.WriteLine($"Method actually invoked {calculator.InvocationCounts} times.");
This code will demonstrate that the Add
method is only actually executed when necessary, thanks to caching, while keeping the Calculator
class simple and focused on its primary responsibility.
3. Setting Up the Aspect with Metalama
With Metalama, setting up the caching aspect becomes remarkably simple and intuitive. Here's how we can implement our caching aspect:
using Metalama.Framework.Aspects;
using Metalama.Framework.Code;
using Metalama.Framework.Code.SyntaxBuilders;
using Metalama.Extensions.DependencyInjection;
public class CacheAttribute : OverrideMethodAspect
{
[IntroduceDependency]
private readonly ICache _cache;
public override dynamic? OverrideMethod()
{
// Build the cache key
var cacheKey = CacheKeyBuilder.GetCachingKey().ToValue();
// Check if the value is in the cache
if (this._cache.TryGetValue(cacheKey, out object? value))
{
// Cache hit
return value;
}
// Cache miss. Execute the method
var result = meta.Proceed();
// Add the result to the cache
this._cache.TryAdd(cacheKey, result);
return result;
}
}
Let's explain the key elements of this aspect:
-
OverrideMethodAspect
: This Metalama base class allows us to completely replace the behavior of the target method. -
[IntroduceDependency]
: This annotation tells Metalama to automatically inject anICache
dependency. This allows us to use an external cache service, thus promoting flexibility and testability. -
OverrideMethod()
: This method defines the new behavior of the target method. It encapsulates the caching logic. -
CacheKeyBuilder.GetCachingKey()
: This utility method (defined elsewhere) generates a unique cache key based on the method name and its parameters. -
meta.Proceed()
: This instruction executes the original method if the value is not found in the cache.
The implementation of ICache
and CacheKeyBuilder
is provided in the attached files. Here's a quick overview of CacheKeyBuilder
:
[CompileTime]
internal static class CacheKeyBuilder
{
public static InterpolatedStringBuilder GetCachingKey()
{
var stringBuilder = new InterpolatedStringBuilder();
stringBuilder.AddText(meta.Target.Type.ToString());
stringBuilder.AddText(".");
stringBuilder.AddText(meta.Target.Method.Name);
stringBuilder.AddText("(");
foreach (var p in meta.Target.Parameters)
{
if (p.Index > 0)
{
stringBuilder.AddText(", ");
}
stringBuilder.AddText("(");
stringBuilder.AddText(p.Type.ToString());
stringBuilder.AddText(")");
stringBuilder.AddText("{");
stringBuilder.AddExpression(p);
stringBuilder.AddText("}");
}
stringBuilder.AddText(")");
return stringBuilder;
}
}
This implementation generates a unique cache key based on the type, method name, and its parameters, thus ensuring accurate and collision-free caching.
Compared to the Fody implementation, the advantages of Metalama are evident:
- Readability: The code is clear and follows an easy-to-understand logical structure.
- Maintainability: Modifying the caching behavior only requires changes in this single class.
- Separation of concerns: The caching logic is completely separate from the business code.
- Flexibility: The use of
ICache
allows for easy changing of the cache implementation without modifying the aspect. - Native dependency injection support: Metalama natively supports dependency injection mechanisms, making it easier to manage dependencies in aspect-oriented programming.
In summary, Metalama allows us to create a powerful and flexible caching aspect with much less code and complexity than the Fody approach. This simplicity not only facilitates initial development but also long-term maintenance and evolution of our caching aspect.
3. Previewing generated code with Visual Studio Tools for Metalama
One of the major advantages of Metalama compared to Fody is the ease of previewing the generated code. After all, Metalama is based on Roslyn, so it generates C# and not IL.
The easiest way to get there is to install Visual Studio Tools for Metalama.
Once this is installed, you will see that CodeLens signals that an aspect has been applied to your code. Click on the CodeLens hint and you will see a hyperlink. It diff your source code with the generated code.
TODO: Add screenshot if you want.
4. Debugging with LamaDebug
Metalama lets you not only preview the generated code, but also debug it. Besides Release
and Debug
, Metalama defines a new build configuration named LamaDebug
. It allows you to debug generated code directly in their IDE, as if it were manually written code. Here's how to use LamaDebug to inspect our caching aspect:
- Creating the
LamaDebug
solution configuration
Metalama is able to create a LamaDebug
build configuration at project level, but you still need to create it manually at solution level. Go to the configuration manager and create this configuration. Select LamaDebug
instead of Debug
for all Metalama-enabled projects.
- Selecting the
LamaDebug
configuration
Once activated, LamaDebug will allow you to see the generated code directly in your IDE. Place a breakpoint on the Add
method of our Calculator
class and start debugging.
- Building the project
After you build the project, look at the files under the obj/LamaDebug/<target-framework>/metalama
directory. It contains the generated code. It will look something like this:
public class Calculator
{
public int InvocationCounts { get; private set; }
[Cache]
public int Add(int a, int b)
{
var cacheKey = $"Calculator.Add((int){{{a}}}, (int){{{b}}})";
if (_cache.TryGetValue(cacheKey, out object? value))
{
return (int)value;
}
int result;
Console.WriteLine("Thinking...");
this.InvocationCounts++;
result = a + b;
_cache.TryAdd(cacheKey, result);
return result;
}
private ICache _cache;
public Calculator(ICache? cache = default)
{
this._cache = cache ?? throw new System.ArgumentNullException(nameof(cache));
}
}
- Adding breakpoints
You can now add breakpoints to the generated code using the IDE. Note that breakpoints added to your source code won't work! That's because the .NET runtime now "believes" that the source code is the generated code.
If you really want to add a breakpoint from your source code, the only option is to add a call to System.Diagnostics.Debugger.Break()
. Don't forget to remove it before you commit your code!
5. Advantages of Debugging with LamaDebug
- Total visibility: You can see exactly how Metalama has transformed your code, which facilitates understanding and debugging.
- Step-by-step debugging: You can go through the generated code line by line, as you would with normal code.
- Variable inspection: All variables, including those introduced by the aspect (such as
cacheKey
), are visible and inspectable.
6. Comparison with Fody Debugging
Unlike Fody, where debugging often requires inspection of generated IL or the use of external tools, LamaDebug makes the process transparent and intuitive. You work directly with readable C# code, which significantly reduces the learning curve and the time needed to effectively debug.
This visual and interactive debugging approach greatly simplifies the process of solving aspect-related problems, making development and maintenance much more efficient than with tools like Fody.
In conclusion, LamaDebug transforms the aspect debugging experience in .NET, making it as simple and intuitive as debugging standard code. This ease of debugging, combined with the simplicity of writing aspects with Metalama, makes this approach a superior choice for implementing AOP in .NET projects.
Have 🦙-zing day !