There are many APIs that require some kind of a string
key/name. I usually try to avoid using raw string
s and prefer to use helpers that can create them for me.
One such a tool is intended to be used with my FeatureService
that requries feature names as string
s.
Usage examples
In order to use it, I need a new set of extensions that provide alternative APIs / overloads:
public static class FeatureServiceExtensions
{
public static Task<T> ExecuteAsync<TScope, T>
(
this FeatureService features,
INamespace<TScope> scope,
Expression<Func<TScope, T>> feature,
Func<Task<T>> body
) where TScope : INamespace
{
return features.ExecuteAsync(FeatureKey<TScope>.Create(feature), body, default);
}
public static FeatureService Configure<TScope, T>
(
this FeatureService features,
INamespace<TScope> scope,
Expression<Func<TScope, T>> feature,
Func<FeatureOptions, FeatureOptions> configure
) where TScope : INamespace
{
return features.Configure(FeatureKey<TScope>.Create(feature), configure);
}
}
Generic type helper
The first interface you see there is actually a trick to provide the TScope
to the method so I don't have to do it explicitly on the Execute
method. This is all it is:
// ReSharper disable once UnusedTypeParameter - 'T' is required.
public interface INamespace<out T> where T : INamespace { }
public static class Use<T> where T : INamespace
{
[DebuggerNonUserCode]
public static INamespace<T> Namespace => default;
}
// Protects the user form using an unsupported interface by mistake.
public interface INamespace { }
Classic names vs helper
It allows me to rewrite this
_features.Configure(nameof(SayHallo), o => o ^ Enabled);
which uses the feature name SayHallo
as
_features.Configure(Use<IDemoFeature>.Namespace, x => x.SayHallo, o => o ^ Enabled);
which will now use Demo.SayHallo
.
Interface as name provider
The names are built from an interface. The property's type doesn't matter as it won't be used actually anywhere here. It's all about not having string
or even a const
.
public interface IDemoFeature : INamespace
{
object SayHallo { get; }
}
by the FeatureKey
helper
public static class FeatureKey<TNamespace>
{
private static readonly IKeyFactory DefaultKeyFactory = new TypedKeyFactoryAttribute("Feature");
[DebuggerStepThrough]
public static string Create(LambdaExpression keyExpression)
{
var keyFactory = keyExpression.ToMemberExpression().Member.GetCustomAttribute<KeyFactoryAttribute>(inherit: true) ?? DefaultKeyFactory;
return keyFactory.CreateKey(keyExpression);
}
[DebuggerStepThrough]
public static string Create<TMember>(Expression<Func<TNamespace, TMember>> selectMember)
{
return Create((LambdaExpression)selectMember);
}
}
Naming conventions with attribute factories
It creates the name by using IKeyFactory
. Currently there are two implementations. The SimpleKeyFactory
uses only the member name and the TypedKeyFactory
also the type name which is the default for the FeatureKey
.
public interface IKeyFactory
{
string CreateKey(LambdaExpression keyExpression);
}
[AttributeUsage(AttributeTargets.Property)]
public abstract class KeyFactoryAttribute : Attribute, IKeyFactory
{
public abstract string CreateKey(LambdaExpression keyExpression);
}
public class SimpleKeyFactoryAttribute : KeyFactoryAttribute
{
public override string CreateKey(LambdaExpression keyExpression)
{
return keyExpression.ToMemberExpression().Member.Name;
}
}
public class TypedKeyFactoryAttribute : KeyFactoryAttribute
{
private readonly string _suffix;
public TypedKeyFactoryAttribute(string suffix)
{
_suffix = suffix;
}
public override string CreateKey(LambdaExpression keyExpression)
{
var memberExpression = keyExpression.ToMemberExpression();
return $"{GetScopeName(memberExpression.Member.DeclaringType)}.{memberExpression.Member.Name}";
}
private string GetScopeName(Type type) => Regex.Replace(type.ToPrettyString(), $"^I|{_suffix}$", string.Empty);
}
I can change this behavior by decorating a property with a different attribute like:
public interface IDemoFeature : INamespace
{
[SimpleyKeyFactory]
object SayHallo { get; }
}
I'm not decorating the class because chaning conventions is a rare operation so I think it's ok to use it only for the exceptional cases.
I often use the MemberExpression
so the above code uses one more convenience extension that makes sure the expression is actually a member-expression.
public static class ExpressionExtensions
{
[NotNull]
public static MemberExpression ToMemberExpression(this LambdaExpression lambdaExpression)
{
return
lambdaExpression.Body is MemberExpression memberExpression
? memberExpression
: throw DynamicException.Create
(
$"NotMemberExpression",
$"Expression '{lambdaExpression}' is not a member-expression."
);
}
}
Questions
- Is this helper intuitive and easy to use?
- Is it easy to extend and customize?
- Is it missing any obvious features?