Home > Back-end >  Can attributes be used to modify a method's code/behavior?
Can attributes be used to modify a method's code/behavior?

Time:06-22

Let's say you have several methods of the following form (greatly simplified here for illustration purposes):

Snippet 1

public async Task<T> DoSomething<T>()
{
    return await MyApi.DoSomeOperation<T>();
}

You would like to add exception-handling functionality to all such methods, but without needing to write a bunch of repetitive code. In the end, your methods should look (or perform) something like this:

Snippet 2

public async Task<T> DoSomething<T>()
{
    try
    {    
        return await MyApi.DoSomeOperation<T>();
    }
    catch (Exception ex)
    {
        var apiEx = new MyApiException(ex);        
        MyLogger.Log(apiEx);
        throw apiEx;  
    }
}

Is there a way to do this with attributes? For example, could I do something like this?

Snippet 3

public class TryCatchLogThrowAttribute : Attribute
{
    public Type ExceptionType { get; set; }
    public Type LoggerType { get; set; }
    // logic goes here:
}

And then decorate my methods as follows (with the expectation that the actual executed code looks or behaves like Snippet 2 above)?

Snippet 4

[TryCatchLogThrow(typeof(MyApiException), typeof(MyLogger))]
public async Task<T> DoSomething<T>()
{
    return await MyApi.DoSomeOperation<T>();
}

How doable is this?

CodePudding user response:

The simple answer is this: No. Neither C# nor .NET support this out of the box. Attributes are metadata attached to a member or type, but they have to be picked up and used by code in order for them to do anything at all.

A few attributes influence the compiler directly, a few are not actually stored as attributes but instead influence metadata flags, the rest are simply stored alongside each member of type as metadata.

In almost all cases, out of the box, they're only available through reflection and thus you have to write additional code to inspect and act upon their presence. The members themselves, however, are oblivious to the presence of these attributes and the code inside does not change depending on it.

What you're describing is AOP, Aspect Oriented Programming, where you attach aspects, usually cross-cutting behavior, to types or members, such as logging, performance monitoring, error handling, access control, etc. without actually writing the behavior into the members as readable code.

This is not supported out of the box, but there are products and libraries out there that allows you to do this.

I can list a few, but this list is not at all exhaustive as it has been a few years since I looked at AOP:

Both of these allow you, with or without automating it for you, to load an already compiled assembly, decode and take it apart, inject additional code in certain places, then write out a rewritten assembly to disk.

Of the two I would highly recommend PostSharp as it is by far the easiest of the two to use, but again, it's been a long time since I looked at AOP so there may be more contenders to this throne.

CodePudding user response:

You are looking for a way to achieve Aspect-Oriented Programming. A worthy goal.

Is there a way to do this with attributes?

If you want to achieve this using attributes, this can only be done using special tooling. There are two types to choose from:

  1. Interception libraries
  2. Post compilers that do IL weaving

Both options come with their own set of consequences:

  • Interception libraries require you to define interfaces. The library will generate a proxy class based on that interface, which allows wrapping the original and add this behavior.
  • Interception libraries limit the addition of behavior to method boundaries. You can't completely rewrite entire methods, although I must admit this is something I never required.
  • A very popular Interception library in this Castle Dynamic Proxy. It is a free and open source tool
  • Post compilers are much more powerful and allow any code to be altered, often in complex ways. This can be done using static methods and doesn't require interfaces.
  • Consequence of post-compilers, however, is that they tightly couple your code with the added behavior. This complicates testing and makes your code less flexible.
  • A well-known post-compiler is PostSharp. It is a commercial product. There's no free version.
  • Another post-compiler library is Fody. It's free to use, but for support you must be a (paying) 'customer'.

There is, however, a third option, which requires no tooling, which is the use of the Decorator design pattern. It does require the use of interfaces though.

Consider your DoSomething<T>() method to be part of an interface:

public interface IDoStuff
{
    Task<T> DoSomething<T>();
}

Where your default implementation has the original code of your question:

public class DefaultDoStuff : IDoStuff
{
    public async Task<T> DoSomething<T>()
    {
        return await MyApi.DoSomeOperation<T>();
    }
}

Now you can extend the behavior of DefaultDoStuff by creating a new IDoStuff implementation that allows wrapping the original IDoStuff as follows:

public class ExceptionLoggingDoStuff : IDoStuff
{
    private readonly ILogger logger;
    private readonly IDoStuff original;

    public ExceptionLoggingDoStuff(ILogger logger, IDoStuff original)
    {
        this.logger = logger;
        this.original = original;
    }

    public async Task<T> DoSomething<T>()
    {
        try
        {    
            return await this.original.DoSomething<T>();
        }
        catch (Exception ex)
        {
            var apiEx = new MyApiException(ex);        
            this.logger.Log(apiEx);
            throw apiEx;  
        }
    }
}

Now, using this second ExceptionLoggingDoStuff implementation, you can construct the following object graph:

// Construct objects
IDoStuff stuff =
    new ExceptionLoggingDoStuff(
        new MyLogger(),
        new DefaultDoStuff());

// Use it
stuff.DoSomething<object>();

The ExceptionLoggingDoStuff is a decorator as it allows decorating (or wrapping) another IDoStuff. This allows you to extend the behavior of DefaultDoStuff without having to alter it.

Decorators can be very powerful, for them to be usable in a way that prevents code repetition, it requires a very specially designed application, i.e. a design that follows the SOLID principles; this is not always easy to achieve. In chapter 10 our book, Mark Seemann and I call this technique "Aspect-Oriented Programming by Design." because it tries to achieve AOP using mere software design principles and patterns opposed to using tooling. Chapter 11 of our book describes this tool-based approach of AOP and discusses its downsides (in the context of DI and loose coupling).

  • Related