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:

  1. Verbose and complex code, requiring a deep understanding of CIL.
  2. Difficult maintenance due to the low-level nature of the code.
  3. Complex debugging, as the generated code is not directly visible in the IDE.
  4. Steep learning curve for developers unfamiliar with these advanced concepts.
  5. 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:

  1. The Calculator class has an Add method that simulates an expensive calculation with a "Thinking..." message.
  2. InvocationCounts allows tracking the actual number of method executions.
  3. 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:

  1. 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.

  2. Separation of concerns: The caching logic will be completely separate from the business logic of the Add method.

  3. Reusability: Once created, this aspect can be easily applied to other methods or classes requiring caching.

  4. 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.

  5. 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:

  1. OverrideMethodAspect: This Metalama base class allows us to completely replace the behavior of the target method.

  2. [IntroduceDependency]: This annotation tells Metalama to automatically inject an ICache dependency. This allows us to use an external cache service, thus promoting flexibility and testability.

  3. OverrideMethod(): This method defines the new behavior of the target method. It encapsulates the caching logic.

  4. CacheKeyBuilder.GetCachingKey(): This utility method (defined elsewhere) generates a unique cache key based on the method name and its parameters.

  5. 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:

  1. Readability: The code is clear and follows an easy-to-understand logical structure.
  2. Maintainability: Modifying the caching behavior only requires changes in this single class.
  3. Separation of concerns: The caching logic is completely separate from the business code.
  4. Flexibility: The use of ICache allows for easy changing of the cache implementation without modifying the aspect.
  5. 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:

  1. 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.

  1. 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.

  1. 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));
    }
}
  1. 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 !