Laden...

[Artikel] IDataErrorInfo implementieren

Erstellt von tom-essen vor 14 Jahren Letzter Beitrag vor 14 Jahren 23.466 Views
tom-essen Themenstarter:in
1.815 Beiträge seit 2005
vor 14 Jahren
[Artikel] IDataErrorInfo implementieren

Einleitung

IDataErrorInfo (IDataErrorInfo-Schnittstelle (System.ComponentModel)) unterstützt die zentrale Fehlerprüfung von Properties in einem Objekt. Wenn also im Folgenden von Objekten die Rede ist, sind damit Objekte gemeint, welche die IDataErrorInfo-Schnittstelle implementiert haben.

Nun kann man die Frage stellen, warum so eine Fehlerprüfung überhaupt notwendig ist, wenn doch ein Paradigma der Objektorientierung besagt, dass ein Objekt selbst für seinen konsistenten Zustand sorgen sollte und fehlerhafte Daten gar nicht erst gesetzt werden können openbook: Objektorientierung, Kapitel 2.2 - Die Kapselung von Daten.
Wenn nun aber die Daten von einer externen Quelle in nicht vorhersagbarer Reihenfolge und Zeit kommen (z.B. bei Eingaben durch einen Benutzer), könnte eine Zwischenspeicherung der Eingaben, bis alle abhängigen Daten vorhanden sind und gesetzt werden können, sehr umständlich sein. Zudem sollte der Benutzer, wenn fehlerhafte Daten eingegeben wurden, nicht nur auf einen möglichen Fehler, sondern auch auf deren Ursache und mögliche Behebung hingewiesen werden können.
Man spricht in einem solchen Fall von sogenannten "weichen" Objekten. "Harte" Objekte würden im Gegenzug bei jeder falschen Zuweisung sofort eine Exception auslösen, um die zuweisende Instanz darüber zu informieren, dass die Zuweisung fehlerhaft war. Die Zuweisung voneinander abhängigen Eigenschaften würde in einem solchen Fall über eine zusätzliche Methode erfolgen. Diese würde die voneinander abhängigen Werte entgegennehmen, vergleichen und erst dann setzen, die eigentlichen Properties hätten dann beispielsweise nur private Setter.
Dabei verdeutlicht sich, dass die Implementierung der IDataErrorInfo-Schnittstelle bei harten Objekten keinen Sinn ergibt, da hierbei die Properties immer in einem konsistenten Zustand sind. Weiche Objekte hingegen akzeptieren alle Property-Zuweisungen ungeprüft, und können somit schnell in einen inkonstistenten Zustand gelangen.

Ein sinnvoller Einsatz der IDataErrorInfo-Schnittstelle scheint somit im Bereich der manuellen Dateneingabe zu liegen. Objekte mit dieser Schnittstellen-Implementierung könnten somit als Bindeglied zwischen der Benutzer-Oberfläche und dem eigentlichen Datenobjekt dienen.

Im Anschluss des Artikels befindet sich ein Beispielprojekt, welches u.a. die Unterschiede bei der Verwendung von harten und weichen Objekten bzgl. der Fehlerprüfung aufzeigt.

Das Interface

Das Interface stellt zwei Properties für die Überprüfung zur Verfügung:*String Error *String this[String columnName] (die Item-Property)

Mittels Error kann man die gesamte Fehlerliste als String abrufen, während über die Item-Property die Fehlermeldungen zu einer einzelnen Property abgerufen werden können.
Somit scheint die Implementierung des Interface relativ einfach zu sein:
Im Item-Property-Getter finden die Einzelprüfungen statt, und Error erstellt daraus eine Gesamtliste.


public class Implementation1 : IDataErrorInfo
{
    public int IntProperty { get; set; }

    public String StringProperty { get; set; }

    public string Error
    {
        get
        {
            String result = String.Empty;
            result = this["IntProperty"] + Environment.NewLine;
            result += this["StringProperty"] + Environment.NewLine;
            return result;
        }
    }

    public string this[string columnName]
    {
        get
        {
            switch (columnName)
            {
                case "IntProperty":
                    return (IntProperty < 0) ? "IntProperty must be positive" : String.Empty;
                case "StringProperty":
                    return (String.IsNullOrEmpty(stringProperty) ? "StringPropertymust have content" : String.Empty;
            }
            return String.Empty;
        }
    }
}

Dieses Verfahren geht solange gut, bis Änderungen an den Properties durchgeführt werden (z.B. beim Refactoring). Wenn man nicht bei jeder Änderung auch den Code in den Property-Gettern der IDataErrorInfo-Implementierungen anpasst, ist die Fehlerprüfung nicht mehr konsistent.

Es muss also ein Weg gefunden werden, bei dem die Property-Namen nicht als String-Parameter angegeben werden. Weiterhin ist der oben dargestellte Weg eine ziemlich unübersichtliche und unflexible Vorgehensweise. Bei wenigen Properties wie in diesem Beispiel mag es noch überschaubar sein, aber bei großen Objekten mit mehreren Dutzend Properties wird es unsauber und unübersichtlich. Bei Blackbox-Objekten (wo also nur das Compilat ohne Quellcode vorliegt) hat ein Entwickler zudem keine Kontrolle, welche Überprüfungen nun wirklich durchgeführt werden (außer man bemüht den Reflektor).

Wo prüfen ?

Betrachten wir zunächst das Problem, wo die eigentlichen Überprüfungen durchgeführt werden sollen. Hier gibt es zunächst zwei Möglichkeiten:*Die Überprüfung erfolgt in den Settern der Object-Properties *Die Überprüfung erfolgt in den IDataErrorInfo-Property-Gettern

Die erste Möglichkeit hat den Vorteil, dass nur die Überprüfungen der wirklich geänderten Properties durchgeführt werden. Der Nachteil besteht darin, das die Überprüfungen bei jeder (!) Änderung durchgeführt werden, auch wenn die Fehlerliste nur einmal abgerufen wird. Zudem müssten die einzelnen Fehler bis zum Abruf der Liste irgendwie zwischengespeichert werden.
Ein weiterer Nachteil ist, dass ggf. zusätzliche Überprüfungen notwendig sind, um auch mögliche Zusammenhänge zwischen den Properties zu prüfen. Auch kann sich bzgl. einer Property beim mehrmaligen Aufruf des Setters je nach Wert die Art des Fehlers ändern, so dass lediglich der letzte Fehler weitergegeben werden darf.

Bei der zweiten Möglichkeit werden zwar alle Überprüfungen durchgeführt, allerdings nur, wenn diese wirklich abgerufen werden. Somit erhält man eine aktuelle Fehlerauflistung, und die Gefahr, dass sich alte Fehler in der Liste befinden, ist nicht gegeben. Und die zweite Variante ist resourcenschonender, da nicht bei jeder Änderung eine Fehlerprüfung stattfindet, sondern nur auf Anforderung. Und da diese Anforderung in der Regel zur Darstellung im Frontend gedacht ist, spielt die Performance hier nur eine untergeordnete Rolle (es sei den, die Dauer der Überprüfung ist deutlich spürbar).

FAZIT 1
Die Überprüfung sollte lediglich in den Gettern der IDataErrorInfo-Properties oder in davon aufgerufenen Methoden durchgeführt werden. Sonst könnte es Probleme bzgl. der Zwischenspeicherung der Fehler bis zum Abruf und mit der Aktualität dieser Meldungen geben. Ein weiterer Vorteil ist, dass dadurch die Setter bereits vorhandener Properties nicht angepasst werden müssen. Wenn die Überprüfung trotzdem in den Settern oder an anderen Stellen benötigt wird, reicht ein Aufruf von this[PropertyName], um den Fehlertext zu erhalten, bzw. String.IsNullOrEmpty(this[PropertyName]) zur einfachen Fehlerprüfung.

Wie prüfen ?

Bleibt noch die Frage, wie die Überprüfung durchgeführt werden soll.
Eine einfache, wie im ersten Beispiel dargestellte, Überprüfung durch eine Aneinanderreihung der Einzelprüfungen ist aus o.g. Gründen nicht empfehlenswert.

Es gibt aber zwei weitere Möglichkeiten:*Regelliste *Regelattribute

Im ersten Fall wird in einer Methode innerhalb der Klasse eine Liste mit Regeln erstellt. Im zweiten Fall wird diese Liste automatisch mit den Daten aus speziellen Attributen erstellt, welche den Properties vom Entwickler zugeordnet und zur Laufzeit mittels Reflection ausgelesen werden.
Der Vorteil der attributbasierten Lösung besteht darin, dass diese auch vom Benutzer zur Laufzeit abgefragt werden können und Property-Namen dabei praktisch nicht angegeben werden müssen.
Der Nachteil liegt auch hier wieder in der erheblich teureren Implementierung über Reflection.
Die Regelliste wiederum könnte ggf. als (Nur-Lese)-Liste extern verfügbar gemacht werden, theoretisch wäre somit auch eine Erweiterung des Regelsatzes zur Laufzeit möglich.
Der gravierende Nachteil hier ist die Notwendigkeit von String-Parametern zur Benennung der Properties, welche ja eigentlich aus o.g. Gründen vermieden werden soll.

Zum Vergleich wurden einige Messungen mit verschiedenen Implementierungen der beiden Varianten durchgeführt, hier die Messzeiten:


Variante                                        Zeit   Faktor
-------------------------------------------------------------
Manuell, leere Liste, erstellen                 50 ms      1
Manuell, leere Liste, prüfen                    40 ms      1
Manuell, 5 Einträge, erstellen                1061 ms     20
Manuell, 5 Einträge, prüfen                   4816 ms    ~90
Manuell, 10 Einträge, erstellen               1482 ms    ~30
Manuell, 10 Einträge, prüfen                  8121 ms   ~160

Automatisch Liste, leer, erstellen            3474 ms    ~70
Automatisch Liste, leer, prüfen                 50 ms      1
Automatisch Liste, 5 Attribute, erstellen    18847 ms   ~380
Automatisch Liste, 5 Attribute, prüfen        4696 ms    ~95
Automatisch Liste, 10 Attribute, erstellen   27940 ms   ~560
Automatisch Liste, 10 Attribute, prüfen       7971 ms   ~160


Variante                                        Zeit   Faktor Verhältnis
------------------------------------------------------------------------
Manuell, leere Liste, erstellen                 50 ms      1
Automatisch Liste, leer, erstellen            3474 ms    ~70     1 : 70

Manuell, leere Liste, prüfen                    40 ms      1
Automatisch Liste, leer, prüfen                 50 ms      1     1 :  1

Manuell, 5 Einträge, erstellen                1061 ms     20
Automatisch Liste, 5 Attribute, erstellen    18847 ms   ~380     1 : 19

Manuell, 5 Einträge, prüfen                   4816 ms    ~90
Automatisch Liste, 5 Attribute, prüfen        4696 ms    ~95     1 :  1

Manuell, 10 Einträge, erstellen               1482 ms    ~30
Automatisch Liste, 10 Attribute, erstellen   27940 ms   ~560     1 : 19

Manuell, 10 Einträge, prüfen                  8121 ms   ~160
Automatisch Liste, 10 Attribute, prüfen       7971 ms   ~160     1 :  1

Der automatische Ansatz ist verständlicherweise langsamer (Reflection), aber dafür im Code übersichtlicher und einfacher zu warten.
Dabei spielt es bzgl. der Performance auch durchaus eine Rolle, wie viele Properties mit Attributen versehen wurden. Ob die Attribute dabei gleichmäßig oder gehäuft verteilt sind spielt hingegen kaum eine Rolle, die Messergebnisse waren nahezu identisch.
Ebenfalls erwähnenswert ist die Tatsache, dass bei der automatischen Variante lediglich die Erstellung der Regelliste viele Resourcen benötigt (~1:19), die eigentlichen Tests entsprechen zeitlich der manuellen Variante (1:1). Wenn diese somit bereits im Konstruktor aufgerufen wird, gäbe es bei der anschließenden Verwendung keine initialie Verzögerung mehr.

Für die Implementierung des attributbasierten Ansatzes empfiehlt sich der Artikel von herbivore: [Artikel] Attribute zur Prüfung von Properties verwenden. Die Beispiel-Implementierung weiter unten im Artikel basiert zum Teil auf Erkenntnissen aus diesem Beitrag.

FAZIT 2
Eine klare Entscheidung zugunsten einer Lösung kann hier nicht gemacht werden, beide Wege haben ihre Vor- und Nachteile.
Die Entscheidung sollte daher der jeweiligen Situation angepasst werden (hat man nur ein paar wenige Objekte mit einer Hand voll Properties und entwickelt alleine, oder gibt es mehrere 100 Objekte mit zahlreichen Properties die von mehreren Entwicklern gepflegt werden).

Anmerkung:
Alle Messungen wurden mehrmals durchgeführt um mögliche Abweichungen festzustellen, die Werte entsprechen der jeweils zuletzt durchgeführten Messung, wenn keine größeren Abweichungen feststellbar waren.
Während der Durchführung der Messungen wurden alle entbehrlichen Anwendungen beendet und die Testanwendung erhielt eine erhöhte Priorität.

Beispiel-Implementierung

Der folgende Code enthält eine Beispiel-Implementierung, welche als Basis zum direkten Einsatz oder für eigene Implementierungen verwendet werden kann.
Der Vorteil dieser Implementierung liegt darin, dass die Klasse an bereits vorhandene Klassen weitervererbt werden kann.


public interface IPropertyCheckAttribute
{
	String Check(Object owner, PropertyInfo propertyInfo, object value);
}

public interface IPropertyCheckAttribute<T> : IPropertyCheckAttribute
{
	String Check(Object owner, PropertyInfo pi, T value);
}

public abstract class DataErrorInfo : IDataErrorInfo
{
	#region IDataErrorInfo Member

	/// <summary>
	/// True, if instance is initialized, otherwise false.
	/// </summary>
	private bool initialized = false;

	/// <summary>
	/// Internal list with property names and corresponding proprety check attributes
	/// </summary>
	private SortedList<String, List<IPropertyCheckAttribute>> checks;

	/// <summary>
	/// Initializes this instance.
	/// </summary>
	private void Initialize()
	{
		if (initialized)
			return;
		checks = new SortedList<string, List<IPropertyCheckAttribute>>();
		Type type = GetType();
		foreach (var property in type.GetProperties())
		{
			foreach (var attribute in property.GetCustomAttributes(typeof(IPropertyCheckAttribute), false))
			{
				IPropertyCheckAttribute attr = attribute as IPropertyCheckAttribute;
				if (attr != null)
				{
					if (!checks.ContainsKey(property.Name))
						checks.Add(property.Name, new List<IPropertyCheckAttribute>());
					checks[property.Name].Add(attr);
				}
			}
		}
		initialized = true;
	}

	/// <summary>
	/// Gets an error message indicating what is wrong with this object.
	/// </summary>
	/// <value>The error message.</value>
	/// <returns>
	/// An error message indicating what is wrong with this object. The default is an empty string ("").
	/// </returns>
	public string Error
	{
		get { return this[null]; }
	}

	/// <summary>
	/// Gets the error message indicating what is wrong with the named property.
	/// </summary>
	/// <value>The error message.</value>
	public String this[string columnName]
	{
		get
		{
			Initialize();
			Type type = GetType();
			var result = String.Empty;
			if (String.IsNullOrEmpty(columnName))
				foreach (var checkItem in checks)
					result += this[checkItem.Key];
			else
				foreach (var item in checks[columnName])
				{
					if (!String.IsNullOrEmpty(result))
						result += Environment.NewLine;
					result += item.Check(
						this, 
						type.GetProperty(columnName),
						type.InvokeMember(columnName,
						BindingFlags.GetProperty,
						null,
						this,
						null));
				}
			return result;
		}
	}

	#endregion
}


[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = true)]
public sealed class MaxIntPropertyCheckAttribute : Attribute, IPropertyCheckAttribute<int>
{
	private int maxIntValue;

	public MaxIntPropertyCheckAttribute(int maxIntValue)
	{
		this.maxIntValue = maxIntValue;
	}

	public String Check(Object owner, PropertyInfo pi, object value)
	{
		return Check(pi, (int)value);
	}

	public String Check(Object owner, PropertyInfo pi, int value)
	{
		return value <= maxIntValue ? String.Empty : "Value must be less or equal " + maxIntValue.ToString();
	}
}

public class PropertyClass : DataErrorInfo
{
	[MaxIntPropertyCheck(10)]
	public int IntegerProperty { get; set; }
}

Ein interessanter (englischer) Artikel zur Einbindung von IDataErrorInfo-Objekten in WPF findet sich unter WPF IDataErrorInfo and Databinding.

Im Anhang befindet sich ein Projekt (VS2008) welches eine Beispielimplementierung inkl. vergleichenden Zeitmessungen sowie Funktionstests enthält.

Abschließend sei noch erwähnt, dass man diese Informationen auch in die GUI einbinden kann: Für WinForms gibt es die ErrorProvider-Klasse und in WPF die ValidatesOnDataError-Eigenschaft im Binding-Kontext. Aus Architektursicht handelt es sich hierbei aber um zwei verschiedenen Schichten, daher sollten IDataErrorInfo-Objekte immer klar von der GUI getrennt bleiben.

Abschließende Worte

Ganz herzlich bedanken möchte ich mich an dieser Stelle bei dN!3L, winSharp93, herbivore, ErfinderDesRades, talla und Programmierhans für Ihre hilfreichen Kommentare und Anmerkungen bedanken.

Nobody is perfect. I'm sad, i'm not nobody 🙁