Exportieren (0) Drucken
Alle erweitern
Erweitern Minimieren

ASP.NET-Datenbindung für eigene Objekte (Teil 1)

Veröffentlicht: 12. Sep 2002 | Aktualisiert: 22. Jun 2004
Von Alexander Jung

Die Bindung eines ASP.NET-DataGrids an eine Datenquelle (Databinding, Datenbindung) ist sehr leistungsfähig, insbesondere im Zusammenhang mit DataSets. Allerdings ist es auf den ersten Blick alles andere als offensichtlich, wie die Komponente mit der Datenquelle zusammenarbeitet oder wie der Entwickler eingreifen kann. Dieser Artikel untersucht die grundlegende Arbeitsweise des Databinding und stellt die Eingriffsmöglichkeiten vor.

Auf dieser Seite

 Kurze Bestandsaufnahme
 Anbindung an ein einfaches Array
 Anbindung an eine Objektliste
 Erster eigener Eingriff: "Umbenennen" von Properties
 Anbinden eines DataSets
 Das Füllen des DataGrid im Überblick
 Fazit

Die Theorie: Die Datenbindung (Databinding) in ASP.NET ist eine feine Sache. DataGrid anlegen, DataSource auf ein DataSet setzen, fertig. Liegen die Daten in einem XML-Dokument vor, dann liest man sie vorher in ein DataSet ein. Selbst eine Objektstruktur kann man in XML serialisieren und diese Daten wiederum in ein DataSet packen.

Die Praxis: Die Datenbindung in ASP.NET ist immer noch eine feine Sache. Aber wie arbeitet Databinding nun eigentlich? Was kann ein ASP.NET-DataGrid noch verarbeiten außer DataSets? Und wie kann der Entwickler eingreifen wenn ein DataSet mal nicht die adäquate Lösung ist?

Kurze Bestandsaufnahme

Sehen wir zunächst, was ein DataGrid (System.Web.UI.WebControls.DataGrid) kann und danach, wie das ganze funktioniert.

Die Datenbindung eines ASP.NET-DataGrids bietet folgende Features:

  • Eine Datenquelle kann einfach durch Setzen des Properties DataSource angegeben werden

  • zur Laufzeit lassen sich die Spalten automatisch aus der Datenquelle erzeugen

  • Mittels <asp:BoundColumn/> können Spalten explizit an Felder gehängt werden

  • mittels <asp:TemplateColumn/> und DataBinder.Eval() können die Daten noch weitergehend aufbereitet und z.B. umformatiert oder berechnet werden.

BoundColumn und TemplateColumn sind sicher sehr mächtige Ansätze um ein DataGrid nach eigenen Anforderungen anzupassen, für die folgenden Betrachtungen sind sie jedoch nachrangig. Vielmehr soll es im Folgenden darum gehen, was sich hinter dem "simplen" Setzen des DataSource Properties verbirgt und wie die automatische Erzeugung vonstatten geht.

Über die Online-Hilfe und die diversen verfügbaren Beispiele findet man sehr schnell heraus, dass diese Dinge sehr gut mit DataSets funktionieren, ebenso für flache Listen (z.B. Arrays), sowohl solche mit Datentypen wie int oder string als auch solche mit komplexeren Objekten. In letzterem Fall werden dann die Properties der Objekte an die Spalten angebunden.

Anbindung an ein einfaches Array

Gegeben ist folgender Codeausschnitt aus einer ASP.NET-Seite:

DataGrid1.DataSource= XYZ;
DataGrid1.DataBind();

Laut Online-Hilfe (beim Property DataSource des DataGrids) muß XYZ das Interface IEnumerable (System.Collections.IEnumerable) implementieren, was auf nahezu alle Container (Arrays, Collections, Hashtables, etc.) zutrifft. Für die erste Betrachtung nehmen wir ein einfaches Array von anbindbaren Typen. Anbindbar bedeutet nichts anderes, als dass die Methode IsBindableType() der Klasse BaseDataList (System.Web.UI.WebControls.BaseDataList) - der Basisklasse des DataGrids - true liefert, was neben den üblichen primitiven Datentypen (int, byte, etc.) auch bei String, DateTime und Decimal der Fall ist.

Aber Vorsicht: Nicht anbindbar im Sinne dieser Methode bedeutet nicht, daß das DataGrid mit diesem Objekt nichts anfangen kann. Es kann nur eben nicht direkt in eine Zelle eines DataGrids eingetragen werden. Nur das wird durch IsBindableType() geprüft.

object[] ol= new object[]{ 'c', "text", null, 4711, DateTime.Now, 
                            new Customer("AM-4711", "Anton", "Meier") };
DataGrid1.DataSource= ol;

Customer ist eine recht triviale Klasse, die uns als Beispiel in unterschiedlichen Formen begleiten wird. Für den Moment können wir annehmen, dass sie aus nichts weiter als einem passenden Konstruktor und der Methode ToString() besteht:

public class Customer 
{
    private string id;
    private string firstName;
    private string lastName;
    public Customer(string id, string firstName, string lastName) 
    {
        this.id= id;
        this.firstName= firstName;
        this.lastName= lastName;
    }
    public string ID 
    {
        get { return this.id; }
    }
    public string FirstName 
    {
        get { return this.firstName; }
        set { this.firstName = value; }
    }
    public string LastName 
    {
        get { return this.lastName; }
        set { this.lastName = value; }
    }
    public override string ToString()
    {
        return id;
    }
}

Und hier ist das Ergebnis. Das DataGrid wurde ohne besondere Formatierung erstellt und in größer-als- und kleiner-als-Zeichen eingefaßt (der Grund dafür wird deutlich, wenn wir uns leere Listen anschauen):

Bild01

Abb. 1: Databinding an ein einfaches Array

Die über den Enumerator des Arrays gelieferten Listeneinträge stellen beim Befüllen die eigentlichen Daten bereit. Im vorliegenden Fall wird einfach die Methode ToString() des aktuellen Eintrags aufgerufen.

Bevor das DataGrid mit den Datenzeilen befüllt wird muss die Spalte allerdings erst einmal erzeugt werden. Auch diese Information stammt nicht etwa vom Container, im Beispiel also dem Array, sondern wird über das erste Element des Containers gewonnen. Bei anbindbaren Typen wird das immer eine Spalte vom Typ BoundColumn (System.Web.UI.WebControls.BoundColumn) mit dem Titel "Item" sein.

Die Entscheidung über die zu erzeugenden Spalten findet anhand des ersten Elementes im Container statt. Das hat durchaus Konsequenzen. Bei einem leeren Container etwa erhält man nichts. Deutlicher gesagt, es fehlen nicht nur die Datenzeilen, es gibt noch nicht einmal die Spaltenüberschriften. Die HTML-Ausgabe des DataGrids ist tatsächlich leer:

object[] ol= new object[]{};
DataGrid1.DataSource= ol;

Bild02

Abb. 2: Databinding an ein leeres Array

Aber es gibt noch mehr Probleme. Schauen wir noch mal kurz auf das Array im ersten Beispiel. Es enthält einen null-Wert sowie eine Instanz von Customer. Solange diese Elemente irgendwo in der Liste stehen ist die Ausgabe eine leere Zeile bzw. das Ergebnis des ToString()-Aufrufs.

Erscheint der null-Wert jedoch als erstes Element, so ist das Ergebnis eine Exception - genauer eine System.Web.HttpException - weil für die Spaltenerzeugung benötigte Informationen nicht vorhanden sind. Falls das Customer-Objekt am Anfang der Liste steht kommt es ebenfalls zu einer Exception, jedoch ist dafür gar nicht das Customer-Objekt selbst, sondern eines der folgenden Listenelemente verantwortlich.

Dieser Fall führt uns aber bereits zum nächsten Abschnitt - vielleicht ist Ihnen aufgefallen, dass IsBindableType() für Customer false liefert -, dort wird auch die eigentliche Fehlerursache deutlich werden. Tatsächlich wurde das Customer-Objekt hier nur aufgeführt, um den Aufruf der Methode ToString() zu demonstrieren.

Anbindung an eine Objektliste

Der nächste Schritt ist die Anbindung an einen Container mit komplexeren Objekten, solche für die IsBindableType()false liefert. Für die Anbindung nehmen wir nun ein Array von Customer-Objekten:

object[] ol= new object[]{
    new Customer("AM-4711", "Anton", "Meier"), 
    null,
    new Customer("WM-0815", "Willhelm", "Müller")};
DataGrid1.DataSource= ol;

Führt man im DataGrid ein Databinding an diesen Container durch, dann erhält man eine Tabelle mit den Inhalten der öffentlichen (public) Properties der Objekte in den Spalten, als Spaltenüberschrift dient der Name des Properties. Es ist leicht nachvollziehbar, wie sich das DataGrid diese Informationen über Reflection besorgt.

Insgesamt folgt das DataGrid diesem Kochrezept:

  • es holte sich aus den Typ-Informationen des ersten Objektes die Properties per Reflection

  • es legt zu jedem Property eine Spalte an und bildet aus den Namen der Properties die Spaltenüberschrift

  • es durchläuft in einer Schleife die Objekte im Container und bildet jedes Objekt auf eine Zeile ab

  • für jede Zeile fragt es der Reihe nach beim aktuellen Objekt den Inhalt der Properties ab und füllt damit die Zelle (aktuelle Zeile bzw. Objekt und aktuelle Spalte bzw. Property)

Und hier ist das Ergebnis:

Bild03

Abb. 3: Databinding an ein Array von Objekten

Während im vorigen Abschnitt die Listenelemente selbst anbindbar sein mussten gilt hier die Forderung, dass dies für die Properties der Listenelemente gelten muss. Solche Properties, auf die das nicht zutrifft, werden ignoriert. Und da wiederum das erste Element die benötigten Informationen liefert, hat man entsprechende Konsequenzen im Bezug auf leere Container oder Objekte unterschiedlichen Typs im Container.

Hier kommt auch endlich die noch ausstehende Erklärung für die Exception im vorangegangen Teil: Das DataGrid verlangt von allen Elementen, dass sie die Properties bereitstellen, die es beim ersten Element gefunden hat. Im obigen Beispiel war das jedoch nicht gegeben. Die Folge war eine System.Reflection.TargetException beim Auslesen der nicht vorhandenen Properties.

Insbesondere sollte man dies beachten, wenn man unterschiedliche Ableitungen einer gemeinsamen Basisklasse anbinden will. Die Bildung von Klassenhierarchien ist ein zentrales Konzept der Objektorientierung, man benötigt nicht sehr viel Phantasie um sich zu Customer als Basisklasse eine Ableitung RegularCustomer oder BusinessCustomer vorzustellen. Sobald diese Klassen eigene Properties mitbringen und in einer Liste gemischt auftreten können, kann diese Liste nicht mehr problemlos angebunden werden.

Erster eigener Eingriff: "Umbenennen" von Properties

Die Art und Weise der Ausgabe der Objekte als Liste ist zwar genau das, was zu erwarten war, allerdings ist die Form nicht unbedingt ansprechend. Das Problem dabei ist, dass die eigentlichen Properties zwangsweise der C#-Syntax folgen. Eine Spaltenüberschrift "first name" ist aber sicher anwendergerechter, als FirstName.

Natürlich ließe sich das durch explizite Angabe der Spaltenüberschriften im DataGrid erreichen, aber dann müssten wir den Weg der automatischen Spaltenerzeugung verlassen.

An dieser Stelle gibt es also bereits Grund den ersten Eingriff vornehmen. Die Frage ist nur wie. Nun, das DataGrid liest nicht einfach die Properties aus, es fragt vorher höflich beim Customer-Objekt nach, ob man ihm die Properties nicht freiwillig geben möchte. Dies kann man tun, indem man ICustomTypeDescriptor (System.ComponentModel.ICustomTypeDescriptor) implementiert und die nach eigenen Ansprüchen aufgebauten Properties bereitstellt.

Nahezu alle Methoden von ICustomTypeDescriptor können auf triviale Art und Weise implementiert werden, einfach indem man die Aufrufe an TypeDescriptor (System.ComponentModel.TypeDescriptor) delegiert - eine Hilfsklasse in der .NET Framework Class Library (FCL) zum Auslesen von Typinformationen per Reflection.

Lediglich die Methode GetProperties() ist für unser Problem von Interesse. Eine Beschreibung eines Properties ist vom Typ PropertyDescriptor (System.ComponentModel.PropertyDescriptor), für die Liste gibt es einen speziellen Container PropertyDescriptorCollection, den GetProperties() als Ergebnis liefern muss. Für eigene "Pseudo-Properties" leitet man eine Klasse von PropertyDescriptor ab - im Beispiel CustomerPropertyDescriptor - und implementiert sie entsprechend.

Der Name des Properties wird bereits durch die Klasse PropertyDescriptor verwaltet und muss dieser nur im Konstruktor übergeben werden. Da dies der für die Spaltenüberschriften verwendete Bezeichner ist, übergibt GetProperties() hier die Bezeichnungen im Klartext (also "First Name" statt "FirstName") - für eine ansprechendere Titelzeile sollte damit gesorgt sein.

Hier sind die bisher angefallen relevanten Ergänzungen:

class CustomerPropertyDescriptor: PropertyDescriptor
{
    public CustomerPropertyDescriptor(string name )
        : base(name, null)
    { 
    }
    [...]
}
public class Customer : ICustomTypeDescriptor
{
    [...]
    public PropertyDescriptorCollection GetProperties()
    {
        PropertyDescriptor[] props= new CustomerPropertyDescriptor[3];
        props[0]= new CustomerPropertyDescriptor("Identifier");
        props[1]= new CustomerPropertyDescriptor("First Name");
        props[2]= new CustomerPropertyDescriptor("Last Name");
        PropertyDescriptorCollection pdc= 
            new PropertyDescriptorCollection(props);
        return pdc;
    }
    [...]
}

Das ist bereits ausreichend um die Spalten zu generieren. Damit ist das DataGrid aber noch nicht gefüllt. Da es keine echten Properties dieser Namen gibt, muss CustomerPropertyDescriptor während des Füllens des DataGrids auch die passenden Daten liefern. Dies passiert, indem die Methode GetValue() überschrieben wird. In diesem einfachen Beispiel wird lediglich anhand des Namens entschieden, welches echte Property aufzurufen ist. Die Prüfung auf null ist wegen des null-Wertes im Array notwendig (dieses Thema wird uns später noch beschäftigen).

class CustomerPropertyDescriptor: PropertyDescriptor
{
    [...]
    public override object GetValue(object component)
    {
        if (component==null) 
            return null;
        switch (Name) // Name ist Property von PropertyDescriptor
        {
            case "Identifier": return ((Customer)component).ID;
            case "First Name": return ((Customer)component).FirstName;
            case "Last Name":  return ((Customer)component).LastName;
        }
        return "";
    }
    [...]
}

Das Ergebnis ist wie erwartet:

Bild04

Abb. 4: Databinding an ein Array von Objekten mit eigener Infrastruktur

Natürlich kann man noch weitergehend eingreifen und muss sich nicht auf einfaches "Umbenennen" von Properties beschränken. Man kann sich z.B. die Liste der Properties per Reflection holen (mit Hilfe von TypeDescriptor) und dort gezielt einzelne Properties unterdrücken, bzw. deren Anzeige von einem eigenen Attribut oder den Rechten des Anwenders abhängig machen.

Genauso denkbar sind berechnete künstliche Properties, die als solche in der Klasse selbst nicht vorhanden sind, z.B. kumulierte Werte oder eine laufende Nummer.
Gerade der triviale Fall des Umbenennens lässt sich sehr einfach zu einer wiederverwendbaren Klasse ausbauen (indem man den Property-Namen als string mitgibt und es per Reflection ausliest). Niemand verlangt schließlich, dass alle PropertyDescriptor-Objekte - abgesehen von der Basisklasse - vom gleichen Typ sind. Auf diese Weise lassen sich auch kontextabhängige Dinge, Berechtigungen des Anwenders oder anderes abdecken.

Einige dieser Beispiele sind so allgemeiner Natur, dass man hier mit wiederverwendbaren PropertyDescriptor-Klassen arbeiten kann (z.B. das Umbenennen eines echten Properties). Sobald man diesen Ansatz aber exzessiver ausnutzt läuft man in die Falle, dass Wissen um die Zusammenhänge, also Geschäftslogik, sowohl in der eigentlichen Klasse Customer, als auch in den einzelnen PropertyDescriptor-Klassen verteilt ist. Hier wird dann ein weitergehendes objektorientiertes Design notwendig, um dieses Wissen in der Klasse Customer vorzuhalten - wo es hingehört - und aus den PropertyDescriptor-Klassen lediglich darauf zurückzugreifen.

Ist die Lösung wasserdicht?
Wir haben erreicht, dass das Databinding auf Ebene der Datenklassen - und nicht etwa des DataGrids - den eigenen Bedürfnissen angepasst werden kann. Das kommt auf jeden Fall einer strikteren Trennung zwischen Oberfläche und Geschäftslogik entgegen.
Einer wasserdichten Lösung stehen jedoch die zwei bereits angesprochenen Probleme im Weg: unterschiedliche Typen und leere Listen.

Sicherzustellen, dass Container mit Objekten unterschiedlichen Typs sauber arbeiten, liegt in der Verantwortung des Entwicklers. Aber welche Möglichkeit gibt es, eine leere Liste auch als solche mit korrekten Überschriften darzustellen und in diesen Fällen das DataGrid nicht einfach verschwinden zu lassen? Das erste Element steht nicht zur Verfügung, und wenn wir wegen eines Sonderfalls auf das DataGrid zurückgreifen zu müssten hätten wir uns die bisherigen Verrenkungen auch gleich sparen können. Ergo muss der Container selbst diese Informationen bereitstellen und das DataGrid muss diese Informationen nutzen.

Glücklicherweise ist die dafür notwendige Infrastruktur in der FCL nicht vergessen worden. Bevor das DataGrid bei der Erzeugung der Spalten beim ersten Element nachfragt, stellt es die gleiche Frage an den Container. Dieser hat die Möglichkeit ITypedList (System.ComponentModel.ITypedList) zu implementieren - dieses Interface existiert alleine zu diesem Zweck - und in der Methode GetItemProperties() die gleichen Ergebnisse zu liefern, die auch die Klasse Zeilen-Objekte in der Methode ICustomTypeDescriptor.GetProperties() bereitstellt.

Im Beispiel war es lediglich notwendig, die Implementierung der oben beschriebenen Methode Customer.GetProperties() in eine statische Methode zu verschieben und aus beiden Interface-Methoden - ICustomTypeDescriptor.GetProperties() in Customer und ITypedList.GetItemProperties() im Container - aufzurufen. Die möglicherweise gravierendere Konsequenz ist, dass man nun eine eigene Klasse für den Container (zur Implementierung des Interfaces) benötigt, wo vorher ein einfaches Array ausreichend war. Hier ist der Code der Container-Klasse:

public class CustomersList : IEnumerable, ITypedList
{
    ArrayList m_alData= new ArrayList();
    public ArrayList Data
    {
        get { return m_alData; }
    }
    // Enumerator bereitstellen
    System.Collections.IEnumerator IEnumerable.GetEnumerator()
    {
        return m_alData.GetEnumerator();
    }
    // Properties bereitstellen
    System.ComponentModel.PropertyDescriptorCollection
        ITypedList.GetItemProperties(
        System.ComponentModel.PropertyDescriptor[] listAccessors)
    {
        return Customer.GetPropertiesImpl();
    }
    string ITypedList.GetListName(
        System.ComponentModel.PropertyDescriptor[] listAccessors)
    {
        return null;
    }
}

Und hier die verbesserte Ausgabe:

Bild05

Abb. 5: Databinding an einen leeren Container der ITypedList implementiert

Ein kleines Loch gibt es aber noch, dass sich leider auch nicht schließen lässt: null-Werte. Solange sie irgendwo in der Liste stehen tun sie nicht weh, aber als erstes Element verursachen sie nach wie vor eine Exception. Grund dafür ist, dass die Spalten des DataGrids sich beim Füllen den PropertyDescriptor des Properties holen wollen. (Die von ITypedList gelieferten Informationen werden hier nicht wiederverwendet.)

Einmal gefunden wird der PropertyDescriptor gesichert, so dass das Problem bei nachfolgenden Zeilen nicht mehr auftritt. Um null-Werte für diese Fälle abzudecken muss lediglich GetValue() mit einem null-Wert im übergebenen Argument zurechtkommen, was ja im obigen Beispiel geschehen ist. Im Nachhinein betrachtet keine so gute Idee, denn die erhaltene Sicherheit ist trügerisch. Vielmehr sollte man streng darauf achten, null-Werte zu vermeiden und ggf. durch eine spezielle Instanz seiner Klasse abzudecken.

Anbinden eines DataSets

Beim DataSet (System.Data.DataSet) kommt nun zum bisher Gesagten noch etwas mehr hinzu. Das DataSet implementiert IEnumerable nicht. Trotzdem kann man es anbinden, was eigentlich der Online-Hilfe beim Property DataSource widerspricht. Ein Interface, das von IEnumerable ableitet, ist IList (System.Collections.IList). Für dieses existiert eine Indirektion, das Interface IListSource (System.ComponentModel.IListSource), dessen einzige Methode ein IList zurückliefert. Und genau an dieser Stelle kommen DataGrid und DataSet, bzw. auch DataTable (System.Data.DataTable), zusammen, denn IListSource wird von beiden implementiert.

Über diesen Mechanismus erhält das DataGrid eine Referenz auf ein DataView (System.Data.DataView). DataView ist eine der Klassen, die ITypedList implementieren, auf diesem Weg erhält das DataGrid also seine Spalteninformationen. Die einzelnen Datenzeilen werden über DataRowView-Objekte (System.Data.DataRowView) angesprochen, die u.a. die Aufgabe übernehmen, ICustomTypeDescriptor zu implementieren.

Ergo: Trotz alle Komplexität eines DataGrids und der diversen Klassen in diesem Umfeld wird von der FCL zur Anbindung keinerlei Infrastruktur verwendet, die nicht auch vollständig dem Entwickler zur Verfügung stünde.

Das Füllen des DataGrid im Überblick

Bevor das DataGrid mit den Datenzeilen befüllt wird, müssen die Spalten erzeugt werden. Hierzu gibt es verschiedene Varianten, die in folgender Reihenfolge abgeprüft werden:

  • die Datenquelle implementiert ITypedList, deren Methode GetItemProperties() die notwendigen Informationen liefert.

  • das erste Element im Container implementiert ICustomTypeDescriptor, dessen Methode GetProperties() die notwendigen Informationen liefert

  • das erste Element ist direkt anbindbar, d.h. BaseDataList.IsBinddableType() liefert true.

  • das erste Element ist nicht direkt anbindbar, per Reflection werden die Properties ausgelesen, mindestens eines der Properties ist anbindbar

Wenn alle diese Punkte fehlschlagen erhält man eine Exception.

Das Befüllen der Zeilen des DataGrids erfolgt über einen Enumerator, der die eigentlichen Daten bereitstellt. Auch hier kommt wieder die gerade angesprochene Liste der Varianten zum Zug, wobei aber der erste Punkt entfällt und das Auslesen nicht mehr nur auf den ersten Eintrag beschränkt ist:

  • das Element implementiert ICustomTypeDescriptor dessen Methode GetValue() die Daten liefert

  • das Element ist direkt anbindbar, der Wert wird über die Methode ToString() gebildet

  • per Reflection wird das jeweilige Property ausgelesen

Fazit

Wow. Zwei Zeilen Code zur Anbindung und dann das! Die erste gute Nachricht ist, dass Sie das alles gar nicht wissen müssen, weil die typischen Container der FCL das alles schon vorbereitet haben. Die zweite gute Nachricht ist, dass Sie an vielen Stellen eingreifen können, wenn der Bedarf dazu da ist. Aber die Mächtigkeit gepaart mit der Neuheit des .NET Frameworks hat auch zur Folge, dass ein großer Teil an Erfahrung und Wissen um die Zusammenhänge noch gar nicht vorhanden sein kann. Die Online-Hilfe ist in erster Linie eine Referenz, diese Aufgabe kann sie nicht erfüllen.

Wollen Sie die Darstellung eines DataGrids an eigene Bedürfnisse anpassen, so entsteht oft der Eindruck, das sei nur auf Ebene des DataGrids möglich. Die hier vorgestellten Mechanismen zeigen, dass Sie sehr wohl die Möglichkeit haben, Anpassungen auf Ebene der Datenquelle vorzubereiten bzw. eigene Datenstrukturen ähnlich komfortabel und direkt anzubinden, wie ein DataSet.


Anzeigen:
© 2015 Microsoft