2
\$\begingroup\$

There are many APIs that require some kind of a string key/name. I usually try to avoid using raw strings 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 strings.

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?
\$\endgroup\$
2
\$\begingroup\$

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 and DebuggerNonUserCodeAttribute


\$\endgroup\$
  • \$\begingroup\$ I use that namespace helper because e.g. Configure requires two generic parameters <TScope, T>, I need the first one to select x => x.SayHallo, without the helper I would always need to specify them both explicitly, Configure<IDemoFeature, object>, with the helper I can avoid this and let the compiler figure out the T. \$\endgroup\$ – t3chb0t May 25 at 9:06
  • \$\begingroup\$ haha, what do you mean with resharper, this day and age? You're right, scope is not used, its only purpose is to deliver TScope so that I can use it with the Expression \$\endgroup\$ – t3chb0t May 25 at 9:07
  • \$\begingroup\$ @t3chb0t the latest VS comes with much of the interesting features 'inspired' by resharper and has much better performance. \$\endgroup\$ – dfhwze May 25 at 9:09
  • \$\begingroup\$ I prefer JetBrains Rider, much faster than VS, even the latest VS cannot comepete with Rider. I use VS only sporadically these days. \$\endgroup\$ – t3chb0t May 25 at 9:27
2
\$\begingroup\$

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.

\$\endgroup\$
  • 1
    \$\begingroup\$ This is a much better design. It feels right now :) \$\endgroup\$ – dfhwze May 25 at 15:02

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.