Transform Your .NET Projects: Master Aspect-Oriented Programming with Fody Weaving

Introduction

Imagine you're crafting the perfect symphony—different instruments contributing to a harmonious whole without drowning each other out. This is what Aspect-Oriented Programming (AOP) brings to your codebase. By isolating secondary concerns like logging, security, and exception handling, AOP ensures your core business logic remains pristine and focused.

Enter Fody, the virtuoso in your .NET toolkit. Fody allows for automatic code weaving, seamlessly bringing AOP to life. With Fody, mundane tasks like logging and exception handling gracefully vanish from your core logic, making your code more maintainable.

Aspect-Oriented Programming (AOP)

AOP is all about modularizing cross-cutting concerns – functionalities that intersect various parts of your codebase. A typical example is logging or input validation. In traditional OOP, these concerns often end up cluttering each method with repetitive code. With AOP, you define this logic once and apply it throughout your entire application.

Key AOP concepts include aspects, concerns, pointcuts, join points, and advice:

  • Aspect: A module encapsulating a concern.
  • Advice: The code to be injected.
  • Pointcut: Defines where the advice should be applied.
  • Join Point: Specific locations in the program where aspects can be applied.

Imagine a PaymentProcessor class, responsible for handling payments. Typically, you might sprinkle logging throughout its methods. Through AOP, you define a logging aspect, injected into each relevant method to keep the core logic undisturbed.

However, AOP has its challenges. Debugging can become trickier due to the abstracted logic, and understanding the interplay between aspects and the rest of the code requires additional mastery.

Weaving with Fody

Weaving is the core magic of AOP—embedding your aspects into the code seamlessly. Fody specializes in compile-time weaving for .NET, streamlining this process.

Consider a class where you want caching to be added automatically to each method without cluttering your core business logic.

[AttributeUsage(AttributeTargets.Method)]
public class CacheAttribute : Attribute
{
    /// <summary>
    /// Initialize a new instance of <see cref="CacheAttribute"/>
    /// </summary>
    public CacheAttribute()
    {
    }
}

internal static class CacheManager
{
    internal static object? GetCacheValue(string declaringType, string methodName, params object[] parameters)
    {
        throw new NotImplementedException();
    }

    internal static void SetCacheValue(object value, string declaringType, string methodName, params object[] parameters)
    {
        throw new NotImplementedException();
    }

    internal static void CaptureMethodResult(object value)
    {
        throw new NotImplementedException();
    }
}

public static class ModuleWeaverExtensions
{
    public static void InsertInstructions(this ILProcessor processor, Instruction targetInstruction, IReadOnlyCollection<Instruction> instructions)
    {
        foreach (var instruction in instructions)
        {
            processor.InsertBefore(targetInstruction, instruction);
        }
    }
}

public class ModuleWeaver : BaseModuleWeaver
{
    private const string CacheAttributeName = "CacheAttribute";

    public override void Execute()
    {
        var methods = ModuleDefinition.Types
            .SelectMany(t => t.Methods)
            .Where(m => m.CustomAttributes.Any(attr => attr.AttributeType.Name == CacheAttributeName));

        foreach (var method in methods)
        {
            if (!method.HasBody)
            {
                WriteError($"{method.DeclaringType.Name}.{method.Name} is empty.");
                continue;
            }

            if (method.ReturnType.FullName == typeof(void).FullName)
            {
                WriteError($"{method.DeclaringType.Name}.{method.Name} returns void.");
                continue;
            }

            WriteMessage($"InjectCache in {method.Name}", MessageImportance.High);
            InjectCache(method);
        }
    }

    public override IEnumerable<string> GetAssembliesForScanning()
    {
        yield return "netstandard";
        yield return "mscorlib";
    }

    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));
    }

    private IReadOnlyCollection<Instruction> ReturnGetValueCacheIfAny(MethodDefinition method)
    {
        var instructions = new List<Instruction>
        {
            Instruction.Create(OpCodes.Ldstr, method.DeclaringType.FullName),
            Instruction.Create(OpCodes.Ldstr, method.Name),
            Instruction.Create(OpCodes.Ldc_I4, method.Parameters.Count),
            Instruction.Create(OpCodes.Newarr, ModuleDefinition.TypeSystem.Object)
        };

        for (var i = 0; i < method.Parameters.Count; i++)
        {
            instructions.Add(Instruction.Create(OpCodes.Dup));
            instructions.Add(Instruction.Create(OpCodes.Ldc_I4, i));
            instructions.Add(Instruction.Create(OpCodes.Ldarg, method.Parameters[i]));

            if (method.Parameters[i].ParameterType.IsValueType)
            {
                instructions.Add(Instruction.Create(OpCodes.Box, method.Parameters[i].ParameterType));
            }

            instructions.Add(Instruction.Create(OpCodes.Stelem_Ref));
        }

        var cacheManagerType = ModuleDefinition.Types.First(t => t.Name == nameof(CacheManager));
        var getCacheValueMethod = cacheManagerType.Methods.First(m => m.Name == nameof(CacheManager.GetCacheValue));
        var getCacheValueMethodRef = ModuleDefinition.ImportReference(getCacheValueMethod);
        instructions.Add(Instruction.Create(OpCodes.Call, getCacheValueMethodRef));

        instructions.Add(Instruction.Create(OpCodes.Dup));
        var continueExecutionInstruction = Instruction.Create(OpCodes.Nop);
        instructions.Add(Instruction.Create(OpCodes.Brfalse_S, continueExecutionInstruction));

        if (method.ReturnType.IsValueType)
        {
            instructions.Add(Instruction.Create(OpCodes.Unbox_Any, method.ReturnType));
        }
        else
        {
            instructions.Add(Instruction.Create(OpCodes.Castclass, method.ReturnType));
        }
        instructions.Add(Instruction.Create(OpCodes.Ret));

        instructions.Add(continueExecutionInstruction);
        instructions.Add(Instruction.Create(OpCodes.Pop));

        return instructions;
    }

    private IReadOnlyCollection<Instruction> SetCacheValue(MethodDefinition method)
    {
        var instructions = new List<Instruction>
        {
            Instruction.Create(OpCodes.Dup),
            Instruction.Create(OpCodes.Ldstr, method.DeclaringType.FullName),
            Instruction.Create(OpCodes.Ldstr, method.Name),
            Instruction.Create(OpCodes.Ldc_I4, method.Parameters.Count),
            Instruction.Create(OpCodes.Newarr, ModuleDefinition.TypeSystem.Object)
        };

        for (var i = 0; i < method.Parameters.Count; i++)
        {
            instructions.Add(Instruction.Create(OpCodes.Dup));
            instructions.Add(Instruction.Create(OpCodes.Ldc_I4, i));
            instructions.Add(Instruction.Create(OpCodes.Ldarg, method.Parameters[i]));

            if (method.Parameters[i].ParameterType.IsValueType)
            {
                instructions.Add(Instruction.Create(OpCodes.Box, method.Parameters[i].ParameterType));
            }

            instructions.Add(Instruction.Create(OpCodes.Stelem_Ref));
        }

        var cacheManagerType = ModuleDefinition.Types.First(t => t.Name == nameof(CacheManager));
        var setCacheValue = cacheManagerType.Methods.First(m => m.Name == nameof(CacheManager.SetCacheValue));
        var setCacheValueReference = ModuleDefinition.ImportReference(setCacheValue);

        instructions.Add(Instruction.Create(OpCodes.Call, setCacheValueReference));

        return instructions;
    }

    private IReadOnlyCollection<Instruction> CaptureMethodResult(MethodDefinition method)
    {
        var instructions = new List<Instruction>
        {
            Instruction.Create(OpCodes.Dup)
        };

        var cacheManagerType = ModuleDefinition.Types.First(t => t.Name == nameof(CacheManager));
        var captureMethodResul = cacheManagerType.Methods.First(m => m.Name == nameof(CacheManager.CaptureMethodResult));
        var captureMethodResultReference = ModuleDefinition.ImportReference(captureMethodResul);

        instructions.Add(Instruction.Create(OpCodes.Call, captureMethodResultReference));

        return instructions;
    }

    public override bool ShouldCleanReference => true;
}

Upon compilation, Fody will weave this caching code into every method annotated with the CacheAttribute.

Practical Implementation

Here’s how you can apply this in a sample project:

internal static class CacheManager
{
    static Dictionary<string, object?> _cache = new();

    private static string GetKey(string declaringType, string methodName, params object[] parameters)
    {
        var values = (new[] { declaringType, methodName })
                     .Concat(parameters.Select(obj => obj ?? string.Empty)
                                       .Select(obj => (obj ?? string.Empty).ToString()));

        var key = string.Join("/", values);
        return key;
    }

    internal static object? GetCacheValue(string declaringType, string methodName, params object[] parameters)
    {
        var key = GetKey(declaringType, methodName, parameters);
        if (_cache.ContainsKey(key))
        {
            Console.WriteLine($"[Cache hit for key: {key}]");
        }
        else
        {
            Console.WriteLine($"[No cached value for key: {key}]");
        }
        return _cache.GetValueOrDefault(key);
    }

    internal static void SetCacheValue(object value, string declaringType, string methodName, params object[] parameters)
    {
        var key = GetKey(declaringType, methodName, parameters);
        _cache[key] = value;
        Console.WriteLine($"[Cache set for key: {key}]");
    }

    internal static void CaptureMethodResult(object value)
    {
        Console.WriteLine($"Captured value: {value}");
    }
}

public class TestClass
{
    [Cache]
    public string TestMethod1(string someThing, int otherThings)
    {
        if (otherThings <= 0)
        {
            return $"Just do this {someThing}";
        }

        if (otherThings == 1)
        {
            return $"you should {someThing} and one thing else";
        }

        return $"you should {someThing} and {otherThings} things else";
    }

    [Cache]
    public string TestMethod2(string someThing, int otherThings)
    {
        if (otherThings <= 0)
        {
            return $"Just do this {someThing}";
        }

        if (otherThings == 1)
        {
            return $"you should {someThing} and one thing else";
        }

        return $"you should {someThing} and {otherThings} things else";
    }
}

using MyConsoleApp;

var testClass = new TestClass();

var methodResult0 = testClass.TestMethod1("do this",1);
Console.WriteLine(methodResult0);
var methodResult1 = testClass.TestMethod1("do this", 1);
Console.WriteLine(methodResult1);
var methodResult3 = testClass.TestMethod2("do this", 1);
Console.WriteLine(methodResult3);
var methodResult4 = testClass.TestMethod2("do this", 1);
Console.WriteLine(methodResult4);

Console.ReadKey();

Result after injection

Execution result

Advanced Considerations

Weaving introduces some overhead; additional bytecode instructions can slightly degrade performance. Profiling your application will ensure it adheres to performance benchmarks.

Security is another consideration. Modifying assemblies can open your application to vulnerabilities if not carefully done. Ensure that all transformations by Fody are secure and thoroughly tested.

Real-world applications for AOP range from API rate limiting to method-level security. In financial software, it can manage transaction consistency. In e-commerce platforms, it can perform security checks. The key is to balance the theoretical benefits with practical limitations.

Conclusion

Aspect-Oriented Programming offers an elegant solution for managing cross-cutting concerns, keeping your core business logic neat and focused. With Fody handling the weaving, implementation in .NET is seamless. Paying attention to performance and security implications will make AOP and Fody invaluable tools in your development toolkit.

Have a goat day 🐐

Github : https://github.com/goatreview/FodyWeaverSample

Associated resources : https://github.com/Fody/Home