Versioning methods using Attributes
The post title might be confusing (not in a positive way!). After all, why would you want that? Well, there are some situations where several versions of the same method must exist and behave differently. Sounds like a nightmare to maintain, and it surely can be. But it does not have to, and sometimes it is the best way to go.
So imagine an algorithm that works on a series of answers to a questionnaire filled out by users. Because the application is fairly new, the questionnaire is evolving all the time. This means that some questions are removed, some are added, and some are modified, e.g. at first a question was a single-choice one, but it had to be changed to a multiple-choice one. What's the problem - you could just change the algorithm to work on the modified questionnaire, and that's it.
But things may not be so simple. For example, the questionnaire might be only a part of the user's journey, and if it's long, making the user fill it out again might make them exit your website.
Okay, so the simplest solution is just using if
s to solve all your problems:
public class Algorithm
{
public bool Run(string input, int version)
{
if (version == 0)
{
return input[0] == 'a';
}
else if (version == 1)
{
return input[1] == 'b';
}
return false;
}
}
Problem solved, right? Time to clock out.
Well... maybe stay a little longer. If the possible input is constantly changing, the simplest solution will become harder and harder to maintain. The code will be difficult to read and fix in case of issues.
What if the class contained different methods for different versions of the algorithm? Some magic would happen that would run the correct method. Why not?
public class Algorithm
{
public bool Run(string input, int version)
=> version switch
{
0 => RunV0(input),
1 => RunV1(input),
> 1 and < 5 => RunV2(input)
_ => false;
};
private bool RunV0(string input)
{
return input[0] == 'a';
}
private bool RunV1(string input)
{
return input[1] == 'b';
}
private bool RunV2(string input)
{
return input[2] == 'c';
}
}
Okay, this is better. But there is no magic here, just a switch
that determines which method to run. But the dream was to have some magic decide which method to run...
Disclaimer: This post is semi-serious. Having so few opportunities to play with System.Reflection
I decided to explore it a bit. What follows is more an exploration of using Reflection to scan classes for methods rather than a guide to be strictly followed.
First, let's create an attribute that can be applied to a method that describes the minimum version of the input that the method supports.
[AttributeUsage(AttributeTargets.Method)]
public class VersionAttribute : Attribute, IComparable<VersionAttribute>
{
public int Version { get; }
public VersionAttribute(int version)
{
Version = version;
}
public int CompareTo(VersionAttribute? other)
{
if (ReferenceEquals(this, other)) { return 0; }
if (ReferenceEquals(null, other)) { return 1; }
return Version.CompareTo(other.Version);
}
public static bool operator <=(VersionAttribute? left, int right)
{
return left?.Version <= right;
}
public static bool operator >=(VersionAttribute? left, int right)
{
return left?.Version >= right;
}
}
Good, now let's define an interface for the algorithm. To make things more fun, make it generic!
public interface IVersionedAlgorithm<out TReturn, in TParam>
{
TReturn? Run(TParam parameter, int version);
}
Time for the algorithm class:
public interface IFirstAlgorithm : IVersionedAlgorithm<string, int>
{
}
public class FirstAlgorithm : IFirstAlgorithm
{
[Version(0)]
private string V0(int number) =>
number switch
{
0 => "zero",
_ => throw new ArgumentOutOfRangeException(nameof(number))
};
[Version(1)]
private string V1(int number) =>
number switch
{
0 => "zero",
1 => "one",
_ => throw new ArgumentOutOfRangeException(nameof(number))
};
[Version(2)]
private string V2(int number) =>
number switch
{
0 => "zero",
1 => "one",
2 => "two",
_ => throw new ArgumentOutOfRangeException(nameof(number))
};
}
This will not compile because the method specified in the IVersionedAlgorithm
is still not implemented. But let's do that in an abstract class that will be friendly to the type parameters:
public abstract class BaseVersionedAlgorithm<TReturn, TParam, TVersion> : IVersionedAlgorithm<TReturn, TParam> where TVersion : VersionAttribute
{
public TReturn? Run(TParam parameter, int version)
{
TVersion? GetVersionAttribute(MethodInfo m) => m.GetCustomAttribute<TVersion>();
bool FilterMethods(MethodInfo m)
{
var hcqVersionAttribute = GetVersionAttribute(m);
if (hcqVersionAttribute is null)
{
return false;
}
var parameters = m.GetParameters();
return m.ReturnType == typeof(TReturn)
&& parameters.Length is 1
&& parameters.First().ParameterType == typeof(TParam);
}
bool MatchingVersion(MethodInfo m) => GetVersionAttribute(m)! <= version;
var method = GetType()
.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(FilterMethods)
.OrderByDescending(GetVersionAttribute)
.FirstOrDefault(MatchingVersion);
if (method == null)
{
return default;
}
return (TReturn) method.Invoke(this, new object[] { parameter })!;
}
}
The Run
method will use reflection to find a private non-static method that has a matching VersionAttribute
applied to it. To use the base class, simply update the FirstAlgorithm
like so:
public class FirstAlgorithm : BaseVersionedAlgorithm<string, int, VersionAttribute>, IFirstAlgorithm
Looks good, the FirstAlgorithm
class contains only the different methods, and all the magic is hidden away using the VersionAttribute
and the BaseVersionedAlgorithm
abstract class. Let's test this with a simple console application:
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.AddSingleton<IFirstAlgorithm, FirstAlgorithm>();
services.AddHostedService<Worker>();
}).ConfigureLogging(log =>
{
log.AddFilter("Microsoft", level => level == LogLevel.Warning);
log.AddConsole();
})
.Build();
await host.RunAsync();
public class Worker : BackgroundService
{
private readonly IFirstAlgorithm _firstAlgorithm;
private readonly IHost _host;
private readonly ILogger<Worker> _logger;
public Worker(IFirstAlgorithm firstAlgorithm, IHost host, ILogger<Worker> logger)
{
_firstAlgorithm = firstAlgorithm;
_host = host;
_logger = logger;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
RunFirstAlgorithmAndPrintResult(_firstAlgorithm, 0, 0);
RunFirstAlgorithmAndPrintResult(_firstAlgorithm, 1, 0);
RunFirstAlgorithmAndPrintResult(_firstAlgorithm, 1, 2);
RunFirstAlgorithmAndPrintResult(_firstAlgorithm, 0, -1);
return _host.StopAsync(stoppingToken);
}
private void RunFirstAlgorithmAndPrintResult(IFirstAlgorithm algorithm, int input, int version)
{
try
{
var result = algorithm.Run(input, version);
switch (result)
{
case null:
_logger.LogInformation("For input {Input} and version {Version}: null", input, version);
break;
default:
_logger.LogInformation("For input {Input} and version {Version}: {Result}", input, version, result);
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "For input {Input} and version {Version}:", input, version);
}
}
}
Nothing surprising here - the Worker
class is used so that the IFirstAlgorithm
and ILogger
instances can be injected. The output looks like this:
info: Worker[0]
For input 0 and version 0: zero
fail: Worker[0]
For input 2 and version 1:
System.Reflection.TargetInvocationException: Exception has been thrown by the target of an invocation.
---> System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter 'number')
at VersionedMethods.Algorithms.FirstAlgorithm.V1(Int32 number) in C:\Projects\VersionedMethods\VersionedMethods\Algorithms\FirstAlgorithm.cs:line 25
--- End of inner exception stack trace ---
at System.RuntimeMethodHandle.InvokeMethod(Object target, Span`1& arguments, Signature sig, Boolean constructor, Boolean wrapExceptions)
at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
at System.Reflection.MethodBase.Invoke(Object obj, Object[] parameters)
at VersionedMethods.Versioning.Version1.BaseVersionedAlgorithm`3.Run(TParam parameter, Int32 version) in C:\Projects\VersionedMethods\VersionedMethods\Versioning\Version1\BaseVersionedAlgorithm.cs:line 36
at Worker.RunFirstAlgorithmAndPrintResult(IFirstAlgorithm algorithm, Int32 input, Int32 version) in C:\Projects\VersionedMethods\VersionedMethods\Program.cs:line 49
info: Worker[0]
For input 1 and version 2: one
info: Worker[0]
For input 0 and version -1: null
Good, all is working as expected. But reflection is pretty expensive. Each time the algorithm is run, the class is scanned for the matching method. But it doesn't have to do that as the methods and their versions won't be changing throughout the lifetime of the application. So let's cache them!
The VersionAttribute
and IVersionedAlgorithm
will stay the same, it's the BaseVersionedAlgorithm
that will be supercharged:
public abstract class BaseVersionedAlgorithm<TReturn, TParam, TVersion> : IVersionedAlgorithm<TReturn, TParam> where TVersion : VersionAttribute
{
private readonly (int version, Func<TParam, TReturn> method)[] _methods;
protected BaseVersionedAlgorithm()
{
_methods = GetMethods();
}
public TReturn? Run(TParam parameter, int version)
{
bool MatchingVersion((int version, Func<TParam, TReturn>) m) => m.version <= version;
var (_, method) = _methods.FirstOrDefault(MatchingVersion);
if (method == null)
{
return default;
}
return method.Invoke(parameter);
}
private (int version, Func<TParam, TReturn> method)[] GetMethods()
{
TVersion? GetVersionAttribute(MethodInfo m) => m.GetCustomAttribute<TVersion>();
bool FilterMethods(MethodInfo m)
{
var versionAttribute = GetVersionAttribute(m);
if (versionAttribute is null)
{
return false;
}
var parameters = m.GetParameters();
return m.ReturnType == typeof(TReturn)
&& parameters.Length is 1
&& parameters.First().ParameterType == typeof(TParam);
}
var methods = GetType()
.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(FilterMethods)
.OrderByDescending(GetVersionAttribute);
return methods.Select(m =>
(GetVersionAttribute(m)!.Version,
(Func<TParam, TReturn>) Delegate.CreateDelegate(typeof(Func<TParam, TReturn>), this, m)))
.ToArray();
}
}
This one scans the methods only once and stores them (and their VersionAttribute
values) in an array during the construction of the object. The methods are stored as Func
types to make the invoking syntax nicer.
Here's a SecondAlgorithm
class that is almost identical to the FirstAlgorithm
, but this one uses the improved BaseVersionAlgorithm
base class:
public interface ISecondAlgorithm : IVersionedAlgorithm<string, int>
{
}
public class SecondAlgorithm : BaseVersionedAlgorithm<string, int, VersionAttribute>, ISecondAlgorithm
{
[Version(0)]
private string V0(int number) =>
number switch
{
0 => "zero",
_ => throw new ArgumentOutOfRangeException(nameof(number))
};
[Version(1)]
private string V1(int number) =>
number switch
{
0 => "zero",
1 => "one",
_ => throw new ArgumentOutOfRangeException(nameof(number))
};
[Version(2)]
private string V2(int number) =>
number switch
{
0 => "zero",
1 => "one",
2 => "two",
_ => throw new ArgumentOutOfRangeException(nameof(number))
};
}
After checking that the results are still good, it's time for a little benchmark.
using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using VersionedMethods.Algorithms;
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.AddSingleton<IFirstAlgorithm, FirstAlgorithm>();
services.AddSingleton<ISecondAlgorithm, SecondAlgorithm>();
services.AddHostedService<Worker>();
}).ConfigureLogging(log =>
{
log.AddFilter("Microsoft", level => level == LogLevel.Warning);
log.AddConsole();
})
.Build();
await host.RunAsync();
public class Worker : BackgroundService
{
private readonly IFirstAlgorithm _firstAlgorithm;
private readonly ISecondAlgorithm _secondAlgorithm;
private readonly IHost _host;
private readonly ILogger<Worker> _logger;
public Worker(IFirstAlgorithm firstAlgorithm, ISecondAlgorithm secondAlgorithm, IHost host, ILogger<Worker> logger)
{
_firstAlgorithm = firstAlgorithm;
_secondAlgorithm = secondAlgorithm;
_host = host;
_logger = logger;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
var stopwatch = Stopwatch.StartNew();
for (var i = 0; i < 1000; ++i)
{
RunFirstAlgorithmAndPrintResult(_firstAlgorithm, 0, 0);
RunFirstAlgorithmAndPrintResult(_firstAlgorithm, 1, 0);
RunFirstAlgorithmAndPrintResult(_firstAlgorithm, 1, 2);
RunFirstAlgorithmAndPrintResult(_firstAlgorithm, 0, -1);
}
var firstTime = stopwatch.ElapsedMilliseconds;
stopwatch.Restart();
for (var i = 0; i < 1000; ++i)
{
RunSecondAlgorithmAndPrintResult(_secondAlgorithm, 0, 0);
RunSecondAlgorithmAndPrintResult(_secondAlgorithm, 1, 0);
RunSecondAlgorithmAndPrintResult(_secondAlgorithm, 1, 2);
RunSecondAlgorithmAndPrintResult(_secondAlgorithm, 0, -1);
}
var secondTime = stopwatch.ElapsedMilliseconds;
_logger.LogInformation("IFirstAlgorithm time: {FirstTime}ms\nISecondAlgorithm time: {SecondTime}ms", firstTime, secondTime);
return _host.StopAsync(stoppingToken);
}
private void RunFirstAlgorithmAndPrintResult(IFirstAlgorithm algorithm, int input, int version)
{
try
{
var result = algorithm.Run(input, version);
// switch (result)
// {
// case null:
// _logger.LogInformation("For input {Input} and version {Version}: null", input, version);
// break;
// default:
// _logger.LogInformation("For input {Input} and version {Version}: {Result}", input, version, result);
// break;
// }
}
catch (Exception ex)
{
// _logger.LogError(ex, "For input {Input} and version {Version}:", input, version);
}
}
private void RunSecondAlgorithmAndPrintResult(ISecondAlgorithm algorithm, int input, int version)
{
try
{
var result = algorithm.Run(input, version);
// switch (result)
// {
// case null:
// _logger.LogInformation("For input {Input} and version {Version}: null", input, version);
// break;
// default:
// _logger.LogInformation("For input {Input} and version {Version}: {Result}", input, version, result);
// break;
// }
}
catch (Exception ex)
{
// _logger.LogError(ex, "For input {Input} and version {Version}:", input, version);
}
}
}
The _logger
calls are commented out to increase the fairness of the results. Both algorithms are run 4000 times, and this gives the following result:
IFirstAlgorithm time: 19302ms
ISecondAlgorithm time: 8650ms
Wow, that's awfully slow! The second version is much faster, but the times are not acceptable. There is a good explanation for them, though - exceptions. These lines:
RunFirstAlgorithmAndPrintResult(_firstAlgorithm, 1, 0);
RunSecondAlgorithmAndPrintResult(_secondAlgorithm, 1, 0);
always throw an ArgumentOutOfRangeException
. Handling that is very expensive as it happens 1000 times for each algorithm. After changing them so they don't throw exceptions anymore:
RunFirstAlgorithmAndPrintResult(_firstAlgorithm, 1, 0);
RunSecondAlgorithmAndPrintResult(_secondAlgorithm, 1, 0);
The results are better:
IFirstAlgorithm time: 47ms
ISecondAlgorithm time: 1ms
The 1ms
value is the lowest it can be, so let's look at the ticks:
IFirstAlgorithm time (ticks): 456191
ISecondAlgorithm time (ticks): 16959
That's not too bad actually. All the code is available at: