Problem
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?
Solution
Review
Is this helper intuitive and easy to use?
In the code snipper below:
INamespace<TScope> scope
seems unused?
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);
}
About that generic type helper, I’m just missing how this helps you out. Could you provide an example why INamespace
, INamespace<T>
and Use<T>
are required? To me, atleast, it is not intuitive.
public static class Use<T> where T : INamespace
{
[DebuggerNonUserCode]
public static INamespace<T> Namespace => default;
}
miscellaneous, off-topic
-
hard-coded string detected :-p
private static readonly IKeyFactory DefaultKeyFactory = new TypedKeyFactoryAttribute("Feature");
-
resharper, this day and age .. really? 🙂
// ReSharper disable once UnusedTypeParameter - 'T' is required. public interface INamespace<out T> where T : INamespace { }
-
good usage of
DebuggerStepThroughAttribute
andDebuggerNonUserCodeAttribute
I think the original API is really not as intuitive as I thought. I simplified it by replacing this
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);
}
with
public static FeatureService Configure
(
this FeatureService features,
string name,
Func<FeatureOptions, FeatureOptions> configure
)
{
return features.Configure(name, configure);
}
where I’m using just the string
. The creation of the name
is entirely up to the caller. I extracted the previous logic into a new helper:
public static class From<T> where T : INamespace
{
[NotNull]
public static string Select<TMember>([NotNull] Expression<Func<T, TMember>> selector)
{
if (selector == null) throw new ArgumentNullException(nameof(selector));
var member = selector.ToMemberExpression().Member;
return
GetKeyFactory(member)
.FirstOrDefault(Conditional.IsNotNull)
?.CreateKey(selector)
?? throw DynamicException.Create("KeyFactoryNotFound", $"Could not find key-factory on '{selector}'.");
}
[NotNull, ItemCanBeNull]
private static IEnumerable<IKeyFactory> GetKeyFactory(MemberInfo member)
{
// Member's attribute has a higher priority and can override type's default factory.
yield return member.GetCustomAttribute<KeyFactoryAttribute>();
yield return member.DeclaringType?.GetCustomAttribute<KeyFactoryAttribute>();
}
}
that I use like this:
_features.Configure(From<IDemo>.Select(x => x.Greeting), o => o ^ Enabled);
It gets all information about how to create the name Demo.Greeting
from an interface that needs to be properly decorated with.
namespace Features
{
[TypeMemberKeyFactory]
[RemoveInterfacePrefix]
public interface IDemo : INamespace
{
object Greeting { get; }
}
}
This unhides the default key-factory and also extracts the strategy of cleaning-type names into other attributes that can be chained and the user can provide his own logic anytime.
public class TypeMemberKeyFactoryAttribute : KeyFactoryAttribute
{
public override string CreateKey(LambdaExpression keyExpression)
{
var memberExpression = keyExpression.ToMemberExpression();
var typeName = memberExpression.Member.DeclaringType.ToPrettyString();
typeName = memberExpression.Member.DeclaringType.GetCustomAttributes<TypeNameCleanerAttribute>().Aggregate(typeName, (name, cleaner) => cleaner.Clean(name));
return $"{typeName}.{memberExpression.Member.Name}";
}
}
The new set of attributes are of the type ITypeNameCleaner
:
public interface ITypeNameCleaner
{
[NotNull]
string Clean(string name);
}
[AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class)]
public abstract class TypeNameCleanerAttribute : Attribute, ITypeNameCleaner
{
public abstract string Clean(string name);
}
public class RemoveInterfacePrefixAttribute : TypeNameCleanerAttribute
{
public override string Clean(string name)
{
return Regex.Replace(name, "^I", string.Empty);
}
}
So, there are no more unsued parameters now and it looks like every part of it can be now customized.