Deklarativer Ansatz mit Code-Attributen

4. November 2011

Die Verwendung von Custom-Code-Attributen kann die Übersichtlichkeit von Quellcode durch kompaktere Schreibweise deutlich erhöhen und unübersichtliche Switch-Statements und die Verteilung von zusammen gehörigen Informationen über mehrere Dateien hinweg verhindern.

Der folgende Programm-Code zeigt exemplarisch, wie Informationen über Objekte häufig verarbeitet werden: das Property „Class“ der Instanz von „Device“ wird in Switch-Statements ausgewertet, um Meta-Informationen (Name, Beschreibung) des verwendeten Wertes auszugeben.

namespace SwitchSample
{
    using System;


    class Program
    {
        static void Main()
        {
            var device = new Device { Class = SwitchApproach.DeviceClass.WebServer };
            SwitchApproach.PrintDetails(device);
            Console.ReadLine();
        }
    }

    public class Device
    {
        public SwitchApproach.DeviceClass Class { get; set; }
    }

    public static class SwitchApproach
    {
        public static void PrintDetails(Device device)
        {
            Console.WriteLine(GetUserFriendlyName(device.Class));
            Console.WriteLine(GetDescription(device.Class));
        }

        public enum DeviceClass
        {
            WebServer,
            Switch,
            Router,
            AppServer,
            Client,
        }

        private static string GetDescription(DeviceClass device)
        {
            switch (device)
            {
                case DeviceClass.AppServer:
                    return "- server that is accessible from another server";
                case DeviceClass.Client:
                    return "- machine that might access other machines, 
                              but does not host any service";
                case DeviceClass.WebServer:
                    return "- server that is accessible via http";
                case DeviceClass.Router:
                    return "- ip-based packet routing device";
                default:
                    return "Some computing device";
            }
        }

        private static string GetUserFriendlyName(DeviceClass device)
        {
            switch (device)
            {
                case DeviceClass.WebServer:
                    return "Web-Server";
                case DeviceClass.Switch:
                    return "Network Switch";
                case DeviceClass.AppServer:
                    return "Application Server";
                case DeviceClass.Client:
                    return "End User Client";
                default:
                    return "Some computing device";
            }
        }
    }
}

Daran ist zunächst mal nichts falsches – wenn man von dem Problem absieht, dass für den „Switch“ kein Name und für den „Router“ keine Beschreibung vorhanden ist (bemerkt?). Solche Probleme sind schwierig zu erkennen, weil zusammengehörige Informationen (Name, Beschreibung, Definition der Enum) in verschiedene Programm-Abschnitte verteilt wurden – in Real-World-Szenarien sind das meinst auch noch verschiedene Klassen/Assemblies.

Eine Lösung des Problems wäre die Implementierung einer Basis-Klasse oder besser eines Interfaces (die Basisklasse hat den Nachteil, dass in C# nur von einer Klasse geerbt werden kann), welches die Informationen zur Verfügung stellt. Nachteile dieser Lösung sind, dass

  1. dieses Interface statische Inhalte zurück liefert und in der Implementierung der Klasse viel Platz verbraucht (und somit die Lesbarkeit sinkt)
  2. die Enum in eine Anzahl von Klassen zerfällt. Im Beispiel sind das 5 – in „Real World“-Szenarien dürfte die Anzahl aber stark steigen.

Der folgende Ansatz verwendet hingegen selbst definierte Code-Attribute, um die Informationen Name und Beschreibung direkt an die Enum-Mitglieder zu knüpfen:

namespace AttributeSample
{
    using System;
    using System.Linq;

    class Program
    {
        static void Main()
        {
            var device = new Device { Class = AttributeApproach.DeviceClass.WebServer };
            AttributeApproach.PrintDetails(device);
            Console.ReadLine();
        }
    }

    public class Device
    {
        public AttributeApproach.DeviceClass Class { get; set; }
    }

    public static class AttributeApproach
    {
        public static void PrintDetails(Device device)
        {
            Console.WriteLine(device.Class.GetAttrib<FriendlyNameAttribute>().Name);
            Console.WriteLine(device.Class.GetAttrib<DescriptionAttribute>().Description);
        }

        public enum DeviceClass
        {
            [FriendlyName(Name = "Web-Server")]
            [Description(Description = "- server that is accessible via http")]
            WebServer,

            [Description(Description = "- networking device for distributing 
                                          ethernet packages")]
            Switch,
            
            [FriendlyName(Name = "Nerwork Router")]
            Router,
            
            [FriendlyName(Name = "Application Server")]
            [Description(Description = "- server that is accessible from another server")]
            AppServer,
            
            [FriendlyName(Name = "EndUser Client")]
            [Description(Description = "- machine that might access other machines,
                                          but does not host any service")]
            Client,
        }
    }

    public static class Extension
    {
        public static TAttribute GetAttrib<TAttribute>(this Enum value)
            where TAttribute : Attribute, new()
        {
            var type = value.GetType();
            var fieldInfo = type.GetField(value.ToString());
            var attrib = fieldInfo.GetCustomAttributes(true)
                                  .OfType<TAttribute>().FirstOrDefault();
            return attrib ?? new TAttribute();
        }
    }

    public class FriendlyNameAttribute : Attribute
    {
        public string Name { get; set; }
    }

    public class DescriptionAttribute : Attribute
    {
        public string Description { get; set; }
    }
}

Die Zusatz-Informationen „Name“ und „Description“ werden hier durch zwei „Custom Attributes“ abgebildet, welche die Mitglieder der Enum DeviceClass deklarativ mit diesen Informationen erweitern. Das Suffix „Attribute“ der beiden Klassen entspricht der .Net-Konvention für Code-Attribute und muss bei der Verwendung der Attribute nicht mit angegeben werden.

Auf diese Art lässt sich das Fehlen von Informationen für einzelne Enum-Mitglieder deutlich einfacher erkennen, da selbst bei 10 Enum-Mitgliedern inkl. Attributen alle auf eine Bildschirmseite passen (bei 1080 Pixel vertikaler Auflösung und Standard-Schrift-Einstellungen). Durch den Einsatz einer Extension-Method ist auch das Abfragen des Attributs extrem einfach und intuitiv möglich.

Zusätzlich sinkt die zyklomatische Komplexität, so dass weniger Test-Fälle ausreichen, um den Code vollständig zu testen – das Vorhandensein eines entsprechenden Attributs an jedem Enum-Mitglied lässt sich in einer Test-Methode problemlos so implementieren, dass zusätzliche Mitglieder ohne Attribut einen Fehler auslösen:

[TestMethod]
public void EnsureAllMembersHaveFriendlyNameAttribute()
{
    var invalid =
        typeof(AttributeApproach.DeviceClass)
            .GetEnumValues()
            .Cast<Enum>()
            .Where(x => string.IsNullOrEmpty(x.GetAttrib<FriendlyNameAttribute>().Name));

    Assert.AreEqual(0, invalid.Count());
}

Der Code der Extension-Methode muss natürlich noch um NULL-Prüfung und ggf. Logging (falls das Attribut erwartet aber nicht gefunden wurde) erweitert werden – die Übersichtlichkeit des Codes nimmt aber trotzdem deutlich zu. Die Attribute sollten noch als „sealed“ gekennzeichnet und mit einem AttributeUsage-Attribut ausgestattet werden. Der Übersicht halber wurde in diesem Beispiel vollständig auf Kommentare verzichtet.

Auch diese Technik hat ihre Grenzen und speziellen Anwendungsfälle und der Einsatz von Attributen statt Interfaces muss sorgfältig abgewägt werden (Performance-Implikationen, KowHow im Projekt, was soll tatsächlich ausgedrückt werden), so dass der Gebrauch von Custom Attributes sicher nicht in jedem Fall angeraten ist. Eine Überlegung ist dieser Ansatz meiner Meinung nach aber auf jeden Fall wert. Er kann (richtig angewendet) die Testbarkeit und Eleganz des Codes, sowie die Produktivität stark erhöhen.