DB-Anwendungen mit .NET und SQL Server

Im Blogeintrag BASTA 2009 - Unterlagen zum PowerWorkshop finden Sie die Anleitung, Musterlösung und Beispieldatenbank für das durchgängige Beispiel, das diesem Artikel zugrunde liegt! 

Die Entwicklung datenorientierter Anwendungen bringt eine ganze Reihe von Herausforderungen mit sich. Auf der BASTA 2009 durfte ich darüber einen ganztägigen Workshop abhalten. Die wichtigsten Aussagen und Beispiele sind in diesem Artikel zusammengefasst. Wir beginnen mit Tipps und Tricks, die das Leben in Zusammenhang mit der Programmierung von SQL Server erleichtern. Der nächste Schritt ist die Verwendung von Entity Framework. Auf dieser Basis erstellen wir eine WCF Serviceschicht und konsumieren diese in einer WPF Anwendung, die nach den Prinzipien von MVVM erstellt wurde. Den Abschluss bietet eine Serviceschicht mit REST (ADO.NET Data Services), die den Zugriff auf die Datenbank von Silverlight aus möglich macht.

BASTA Logo

Tipps und Tricks rund um DB-Design

Am Beginn jeder DB-Anwendungen mit .NET und SQL Server steht naturgemäß das Anlegen der Datenbank. Grundsätzlich empfehle ich, in Echtumgebungen vor diesem Schritt den Kontakt mit den jeweiligen DB-Administratoren zu suchen, um abzustimmen, was dabei zu beachten ist. In jedem Unternehmen existieren verschiedene Policies, die festlegen, wie DB zu erstellen sind, damit Dinge wie Backup, Reorganisation, etc. klaglos funktionieren. Die drei häufigsten Fehler, die ich bei Entwicklern in Zusammenhang mit dem Anlegen von Datenbanken sehe, sind:

    Autogrowth-Optionen in SQL Server
    (anklicken, um zu vergrößern):

  1. Planen Sie von Anfang an, wie groß ihre Datenbank wird und wählen Sie die Startgröße passend. Denken Sie auch über Autogrowth-Optionen nach. Ständige Erweiterung in sehr kleinen Schritten kann die Performance Ihrer Datenbank massiv beeinträchtigen.
  2. Sprechen Sie mit Ihren Administratoren das Recovery Model ab. Falls nicht explizt Log-Backups geplant sind, wählen Sie auf jeden Fall Simple.
  3. Denken Sie über den Zeichensatz für Ihre Datenbank nach. Im Zweifelsfall verwenden Sie <server default>. Klären Sie in dem Zusammenhang unbedingt ab, ob der Produktivserver für Ihre Anwendung case sensitiv eingestellt ist. Falls er das ist, stellen Sie die Collation auch am Entwicklungsserver so ein, um bei Produktivstellung keine unangenehmen Überraschungen zu erleben.

Recovery Model und Collation Settings in SQL Server (anklicken, um zu vergrößern):

Der nächste Schritt nach dem Anlegen der Datenbank ist das Erstellen der Datenbankstrukturen. Im BASTA Workshop habe ich mit den Workshopteilnehmern eine Checkliste durchgearbeitet, die möglichst viele Punkte enthält, die nicht vergessen werden sollten. Hier ein Auszug aus dieser Checkliste:

Vor dem Projekt über Namensschema in der Datenbank nachdenken

  1. Werden Schemas verwendet? Wenn ja, wie? Empfehlungen:
    1. Bei großen Datenbanken zum Strukturieren verwenden
    2. Schemas statt Präfixen
  2. Deutsche oder englische Bezeichner?
    Empfehlung: Englisch
  3. Codes oder lange, sprechende Bezeichner?
    Empfehlung: Lange, sprechende Bezeichner
  4. Casing? Prä/Postfixes? Empfehlungen:
    1. CamelCase ohne Underscores
    2. Keine Hungarian Notation
  5. Einzahl oder Mehrzahl?
    Empfehlung: Immer Einzahl
  6. Namensschema für FKs, PKs, Index, etc. festlegen
    Empfehlung: Vorschlägen von SQL Server Management Studio folgen
  7. Namensschema für Logikelemente (z.B. Procedures, Functions) festlegen
    Empfehlung: Wie Tables ohne Hungarian Notation
  8. Sonstige Einschränkungen Empfehlungen:
    1. Niemals reserviere Wörter verwenden
    2. Keine Sonderzeichen (Leerzeichen, Symbole, etc.) verwenden
  9. Generell
    1. Reglementierung nicht übertreiben
    2. Konventionen wo möglich automatisieren (speziell in Teams)
    3. Templates oder Tools
    4. DDL Trigger
    5. Konventionsprüfung über Scripts oder Reports
    6. Checklisten statt Prosa 

Regeln für Tabellendesign festlegen

Tipp: Bei der Auswahl der eingesetzten Datentypen berücksichtigen, welche Tools später zum Einsatz kommen soll (z.B. für Reporting, O/R Mapping, etc.) und welche Datentypen sie unterstützen!

  1. Jede Tabelle hat Primary Key
    1. identity oder uniqueidentifier
    2. PK Name aus Tabellenname ableitbar
    3. Im Regelfall mit clustered index
    4. Gegebenenfalls künstlichen PK einfügen
  2. Jede Tabelle kann 0..n natürliche Schlüssel haben
    1. Eindeutigkeit über Index sicherstellen
  3. Möglichkeit von Null-Werten über Spaltendefinition festhalten
  4. Wo immer sinnvoll default-Werte festlegen
    1. Z.B. newid(), getdate() 
  5. Datentypen
    1. Vermeiden
      1. Alte Datentypen – abhängig von den unterstützten DB-Releases
        (z.B. text, datetime, image, etc.)
      2. float und real
      3. sql_variant
    2. Bevorzugen
      1. Im Zweifelsfall Unicode (nchar, nvarchar statt char, varchar)
      2. date für reine Datumswerte
      3. time für reine Zeitwerte
      4. xml statt nvarchar(max) für XML Inhalte
    3. Generelle Hinweise
      1. Welche DB Versionen sind beim Endkunden verfügbar?
      2. Unterstützen die eingesetzten Tools die neuen Datentypen?
        (Z.B. Kann ADO.NET Data Services heute noch nicht mit time umgehen)
      3. Vorsicht wenn SQL CE im Spiel ist!

Regeln für Fremdschlüssel festlegen

Möglichkeiten zum Deaktivieren von Foreign Keys (anklicken, um zu vergrößern):
Disable Foreign Keys

  1. Wo immer inhaltlich sinnvoll und technisch möglich Fremdschlüssel anlegen
  2. Fremdschüssel möglich einheitlich definieren (z.B. immer über künstlichen PK)
  3. Name der Fremdschlüsselspalte = Name des referenzierten PK
    1. Bei mehreren Referenzen entspricht der Name der Bedeutung der Beziehung (z.B. OrderCustomerUuid für bestellenden Kunden und InvoiceCustomerUuid für Rechnungsempfänger).
  4. Bei alten Daten, die gegen Fremdschlüssel verstoßen
    1. Wert für „Unbekannt“ in Primärschlüsseltabelle einfügen ODER
    2. Fremdschlüssel für alte Daten nicht prüfen ODER
    3. Fremdschlüssel anlegen aber deaktivieren
  5. Fremdschlüssel wenn möglich über kleine Datentypen (nicht über lange Texte)
  6. CASCADE-Regeln können viel Arbeit sparen
    1. ON DELETE und ON UPDATE
    2. CASCADE oder SET NULL oder SET DEFAULT

Regeln für Fremdschlüssel festlegen

  1. Mit check constraints Konsistenz jeder Zeile für sich festlegen
  2. Im Notfall tabellenübergreifende Konsistenzregeln mit Hilfe von Triggern sicherstellen
    1. Große Aufmerksamkeit auf die Erstellung von Trigger legen, Gefahr!!

Tipp: Beim Erstellen von Triggern nie vergessen, dass inserted und deleted mehrere Zeilen enthalten können!

ALTER TRIGGER [Training].[TR_TrainingPlan_NotOverlapping]
ON [Training].[TrainingPlan]
AFTER INSERT, UPDATE
AS BEGIN
  SET NOCOUNT ON;

  IF EXISTS (
    SELECT 1
    FROM inserted i
      INNER JOIN Training.TrainingPlan tp on
        i.TrainingPlanUuid <> tp.TrainingPlanUuid
        and i.UserDataUuid = tp.UserDataUuid
        and ( i.StartDate between tp.StartDate and tp.EndDate
        or i.EndDate between tp.StartDate and tp.EndDate))
  BEGIN 

Tipp: raiserror alleine löst kein Rollback aus! Wenn verhindert werden soll, dass die falschen Daten eingefügt werden, muss im Trigger explizit ein rollback Kommando stehen!

    RAISERROR( N'You cannot create overlapping training plans.',19,0 )
    ROLLBACK
  END
END

Komplexe Geschäftslogik kapseln

  1. Stored Procedures primär für schreibende Operationen
    1. Manche lesende Funktionen können ebenfalls nur mit stored Procedures umgesetzt werden (Funktionen mit side effects; Details siehe MSDN)
  2. Stored Functions für lesende Operationen oder Rechenlogik
    1. Skalare Funktionen vs. Table Valued Functions 

Beispiele für skalare Stored Functions

CREATE FUNCTION Training.GetPreviousMonday (
 @Date DATE
)
RETURNS DATE AS BEGIN
 RETURN DATEADD( DD, ( DATEPART( DW, @Date ) - 1 ) * ( -1 ), @Date );
END

CREATE FUNCTION [BaseData].[GetRunUuid]()
RETURNS UNIQUEIDENTIFIER AS BEGIN
 DECLARE @RunUuid UNIQUEIDENTIFIER;
 SELECT @RunUuid = tos.TypeOfSportUuid
 FROM BaseData.TypeOfSport tos
 WHERE tos.Code = 'Run';
 RETURN @RunUuid;
END

Beispiel für eine Table Valued Function (TVF)

Tipp: Procedures und Functions mit umfangreicher Rechenlogik können mit Hilfe von SQLCLR auch in C# entwickelt werden!

CREATE FUNCTION Training.WeekList(@StartDate DATE, @NumberOfWeeks TINYINT)
RETURNS @WeekList TABLE
(
 FirstDayOfWeek DATE NOT NULL,
 WeekNumber TINYINT NOT NULL
)
AS
-- body of the function
BEGIN
 SET @StartDate = Training.GetPreviousMonday(@StartDate);
 DECLARE @i INT;
 SET @i = 0;
 
 WHILE @i < @NumberOfWeeks BEGIN
  INSERT INTO @WeekList ( FirstDayOfWeek, WeekNumber )
  VALUES ( @StartDate, DATEPART( WK, @StartDate ) );
  SET @StartDate = DATEADD( WK, 1, @StartDate );
  SET @i = @i + 1;
 END
 
 RETURN
END
 

Beispiel für eine stored Procedure

CREATE PROCEDURE [Training].[Add12WeeksMarathonPlan]
 @UserDataUuid UNIQUEIDENTIFIER,
 @StartDate DATE = NULL
AS
BEGIN
 SET NOCOUNT ON;
 SET DATEFIRST 1;
 SET @StartDate = CASE WHEN @StartDate IS NULL
  THEN Training.GetPreviousMonday(GETDATE())
  ELSE Training.GetPreviousMonday(@StartDate) END;

 DECLARE @TrainingPlanUuid UNIQUEIDENTIFIER;
 SET @TrainingPlanUuid = NEWID();
 INSERT INTO Training.TrainingPlan ( TrainingPlanUuid, UserDataUuid,
  StartDate, EndDate, GoalDescription, TrainingPlanDescription )
 VALUES ( @TrainingPlanUuid, @UserDataUuid, @StartDate,
  DATEADD( WK, 12, DATEADD( DD, 6, @StartDate ) ),
  'Marathon unter 4 Stunden',
  'Unser patentierter 12-Wochen-zum-Marathon Plan...' );
 […]
END

Generelle Empfehlungen für Datenbankentwicklung

  1. Erstellen Sie Unit Tests auch für DB-Struktur und Logik
    1. Dabei nur ADO.NET nutzen, um Seiteneffekte anderer Schichten zu vermeiden
    2. Erleichterung: Visual Studio Database Edition
  2. Checken Sie auch DB-Elemente in die Sourcecodeverwaltung ein
    1. Erleichterung: Visual Studio Database Edition
  3. Denken Sie bei Änderungen nach Inbetriebnahme an die Wartung bestehender Datenbanken
    1. SQL Scripts erstellen, sammeln und Update-Routinen bauen
    2. Jedes SQL Script muss mehrfach ausführbar sein
    3. DB-Version verwalten
    4. Erleichterung: Tools zum Ver- und Abgleichen von Datenbank
  4. Entwickeln Sie eine für Sie passende, klare Strategie darüber, ob und wie viel Logik in die Datenbank soll
    1. Gegebenenfalls Datenbankunabhängigkeit beachten
    2. String Templates sind eine Option zu Proceduren
  5. Geben Sie bei allen Datenbankoperationen explizit Parameternamen, Spaltennamen, etc. an
    1. Verlassen Sie sich niemals auf Automatismen! Die Datenbankstruktur könnte sich ändern
    2. Beispiel:
      INSERT INTO MyTable ( MyCol1, MyCol2 ) VALUES ( 5, 10 )
      statt
      INSERT INTO MyTable VALUES ( 5, 10 )
    3. Beispiel:
      EXEC MyProc @MyParam1 = 5, @MyParam2 = 10 statt EXEC MyProc 5, 10
  6. Fügen Sie niemals externe Daten mit Stringoperationen in SQL Statements ein
    1. Gefahr von SQL Injection!
    2. Nutzen Sie statt dessen Parameter
  7. Nutzen Sie wann immer möglich Windows Authentication und nicht SQL Server Authentication
    1. Application Roles können hilfreich sein
  8. Verwenden Sie den SQL Server Profiler, um langsame/häufige DB-Operationen zu entdecken
    1. Keine Angst vor längeren SQL Statements
    2. SQL Server ist hervorragend im Optimieren und Parallelisieren!

Beispiel für einen Unit Test zum Testen des oben gezeigten Triggers

[TestMethod]
public void OverlappingTrainingPlan()
{
 this.FillDatabaseWithTestData(null);
 try
 {
  this.FillDatabaseWithTestData(DateTime.Today.AddDays(20));
  Assert.Fail("There should have been an exception!");
 }
 catch (SqlException ex)
 {
  Assert.IsTrue(ex.Message.StartsWith("You cannot create overlapping training plans."));
 }
}

private void FillDatabaseWithTestData(DateTime? trainingPlanStartDate)
{
 using (var conn = this.OpenDbConnection())
 {
  using (var cmd = conn.CreateCommand())
  {
   // Create a user
   cmd.CommandText = @"
    IF NOT EXISTS ( SELECT 1 FROM BaseData.UserData )
     INSERT INTO BaseData.UserData ( Email, Password ) 
     VALUES ( 'r.stropek@cubido.at', 'pass@word' )";
   cmd.ExecuteNonQuery();

   cmd.CommandText = "SELECT TOP 1 UserDataUuid FROM BaseData.UserData";
   var userUuid = ((Guid)cmd.ExecuteScalar());

   cmd.CommandText = "Training.Add12WeeksMarathonPlan";
   cmd.CommandType = CommandType.StoredProcedure;
   cmd.Parameters.Add("UserDataUuid", SqlDbType.UniqueIdentifier).Value = userUuid;
   if (trainingPlanStartDate != null)
   {
    cmd.Parameters.Add("StartDate", SqlDbType.Date).Value =
      trainingPlanStartDate.Value;
   }
   cmd.ExecuteNonQuery();

   cmd.CommandText = "SELECT COUNT(*) FROM Training.TrainingPlanItem";
   cmd.CommandType = CommandType.Text;
   var numberOfItems = (int)cmd.ExecuteScalar();
   Assert.AreEqual(41, numberOfItems);
  }
 }
}

Schichtenarchitektur

Ausgehend von der Datenbank sind heutige Anwendungen oft in mehrere Schichten unterteilt. Die folgende Abbildung stellt einen dabei häufig gewählten Weg schematisch dar:

Schichtenarchitektur

Die Modellschicht - ADO.NET Entity Framework

Der EF-Modelldesigner in Visual Studio
EF Designer in VS

Microsoft sieht für die Modellschicht die Verwendung von ADO.NET Entity Framework (EF) vor. Es handelt sich dabei um einen sogenannten O/R-Mapper. Die Aufgabe dieser Klasse von Tools ist es, die relationale Struktur von Datenbanken auf objektorientierte Strukturen von C# abzubilden. EF bietet Funktionen zum Persistieren von Objekten in der Datenbank und nutzt LINQ zur Datenbankabfrage.

Die Definition eines EF-Modells gliedert sich wie folgt:

  1. Objektmodell = Konzeptionelles Schema
    CSDL = Conceptual Schema Definition Language
  2. Relationales Modell = Speicherschema
    SSDL = Store Schema Definition Language
  3. Mapping
    MSL = Mapping Specification Language

Die Wartung eines EF-Modells erfolgt entweder im entsprechenden Visual Studio Designer oder direkt im Sourcecode mit Hilfe des XML-Editors von VS (die EF-Modelldateien sind XML Dateien). Manche Änderungen im EF-Modell sind leider nur mit Hilfe des XML-Editors möglich; eine 100%ige Arbeit auf Basis des visuellen Designers ist mit der heute verfügbaren EF-Version nicht möglich.

Bei der Erstellung von EF-Modell sind folgende Punkte zu empfehlen:

  1. Eine sauber gestaltete Datenbank führt zu guten EF-Modellen
    1. Praxis: Beim DB-Design EF-Möglichkeiten bedenken
    2. Insbesondere bei der Abbildung von Vererbungshierarchien
  2. Namen von Relationen im EF-Modell anpassen
    1. Mehrzahl statt Einzahl
    2. Im Nachhinein schwierig zu ändern
  3. Nicht alles, was EF-Modelle können, kann im VS Designer einfach erstellt werden
    1. Manchmal ist die XML-Wartung einfacher
    2. Beispiel: Multi-Table Inheritance Hierarchy
  4. Schreiben und Lesen von Entities kann in stored Procedures gekapselt werden.

Beispiel für das Erstellen von Objekten mit EF

UserData user;
user = new UserData()
{
 UserDataUuid = Guid.NewGuid(),
 Email = "r.stropek@cubido.at",
 Password = "pass@word
};

context.AddToUserData(user);
context.SaveChanges();

Beispiel für das Löschen von Objekten mit EF

private void RemoveAllUsers()
{
 using (var context = this.OpenDbConnection()) {
  foreach (var user in context.UserData) {
   context.DeleteObject(user);
  }
  context.SaveChanges();
 }
}

Tipp: Über ADO.NET Entity Framework Extensions können skalare stored Procedures schon mit VS 2008 SP 1 aufgerufen werden.

  1. Attach/Detach Logik wenn Objekte von der Datenbank getrennt werden (z.B. über Serviceschicht)
  2. Nützliches Tool für EF: ADO.NET Entity Framework Extensions
    1. Ermöglich u.A. das Aufrufen skalarer stored Procedures

Aufrufen der oben gezeigten stored Procedure über ADO.NET EF Extensions

public partial class TrainingMonitor
{
 public void AddTwelveWeeksMarathonPlan(UserData user, DateTime startDate)
 {
  using (var command = this.CreateStoreCommand("Training.Add12WeeksMarathonPlan",
   CommandType.StoredProcedure,
   new[] {
    new SqlParameter("UserDataUuid", user.UserDataUuid),
    new SqlParameter("StartDate", startDate)
   })) {
   using (this.Connection.CreateConnectionScope()) {
    command.ExecuteNonQuery();
   }
  }
 }
}

  1.  EF-Klassen können um Geschäftslogik (Eigenschaften, Methoden, Prüfungen) erweitert werden

Einfügen einer Gültigkeitsprüfung, die sicherstellt, dass nur korrekte Email-Adressen gespeichert werden können.

public partial class UserData
{
 partial void OnEmailChanging(string value)
 {
  if ( !Regex.IsMatch(value,
                @"^([\w-\.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([\w-]+\.)+))
     ([a-zA-Z]{2,4}|[0-9]{1,3})(\]?)$"))
  {
   throw new ApplicationException("This is not a valid email adress!");
  }
 }
}

  • Auch das EF-Modell sollte mit Hilfe von Unit Tests getestet werden!

Beispiel eines Unit Tests, der die zuvor zum EF-Modell hinzugefügte Email-Prüflogik testet.

[TestMethod]
public void InvalidEmailAdress()
{
 try
 {
  var user = new UserData()
  {
   Email = "a@b",
   Password = "Test",
   UserDataUuid = Guid.NewGuid()
  };

  Assert.Fail("There should have been an exception!");
 }
 catch (ApplicationException ex)
 {
  Assert.AreEqual("This is not a valid email adress!", ex.Message);
 }
}

Serviceschicht - EF über WCF nutzen

Die Klassen des EF-Modells eignen sich dazu, als Parameter in WCF Services benutzt zu werden. Am Client werden die entsprechenden Klassen über die Servicereferenz später automatisch generiert. Sie implementieren INotifyPropertyChanged und können daher einwandfrei in Verbindung mit Data Binding verwendet werden.

Beispiel für ein WCF Service, das Klassen aus dem EF-Modell verwendet.

using System;
using System.Collections.Generic;
using System.ServiceModel;
using TrainingMonitorData;

namespace TrainingMonitorService
{
 [ServiceContract]
 public interface ITrainingMonitorAdminService
 {
  [OperationContract]
  IEnumerable<UserData> GetUsers();

  [OperationContract]
  void DeleteUser(Guid userDataUuid);

  [OperationContract]
  void AddAdmin(AdminData adminData);
 }
}

using System;
using System.Collections.Generic;
using System.Linq;
using TrainingMonitorData;

namespace TrainingMonitorService
{
 public class TrainingMonitorAdminService : ITrainingMonitorAdminService
 {
  public IEnumerable<UserData> GetUsers()
  {
   using (var context = new TrainingMonitor())
   {
    return
     (from u in context.UserData
      select u).ToList();
   }
  }

  public void DeleteUser(Guid userDataUuid)
  {
   using (var context = new TrainingMonitor())
   {
    context.DeleteObject(context.UserData.Where(u => u.UserDataUuid == userDataUuid).First());
    context.SaveChanges();
   }
  }

  public void AddAdmin(AdminData adminData)
  {
   using (var context = new TrainingMonitor())
   {
    adminData.CreationDateTime = DateTime.Now;
    context.AddToUserData(adminData);
    context.SaveChanges();
   }
  }
 }
}

Genau wie alle anderen Schichten der Applikation sind auch WCF Services über Unit Tests zu testen. Eine einfache Variante ist dabei, von einem der bestehenden Tests abzuleiten (Test für EF-Modell oder ADO.NET Test) und die Ergebnisse der WCF Services mit den bereits zuvor getesteten Methoden der darunter liegenden Schicht zu prüfen.

[TestClass]
 public class ServiceTest : EdmTest
 {
  [TestMethod]
  public void GetUserViaService()
  {
   // use existing method from EF model test to generated test data
   this.FillDatabaseWithTestData(null);
   var serviceClient = new TrainingMonitorAdminServiceClient();
   Assert.AreEqual(1, serviceClient.GetUsers().Count());
  }

[...]

Serviceschicht - Alternative REST

Der Ansatz, für alle Operationen WCF Serviceoperationen zu schreiben, hat den Nachteil, dass das bei Datenbanken mit vielen Tabellen zu sehr viel Aufwand führen kann. Praktisch hat man in solchen Fällen zwei Optionen:

Tipp: ADO.NET Data Services nutzen, um nicht so viele WCF-Services schreiben zu müssen.

  1. Man entwickelt/kauft eine Infrastruktur zum automatischen Generieren von Code für die Serviceoperationen (z.B. eine Insert-, Update-, Delete- und Select-Operation für jede Tabelle der Datenbank).
  2. Man nutzt ADO.NET Data Services und REST
    1. Entity Data Model wird dabei zur Modellierung des zugrunde liegenden Datenmodells verwendet
    2. URIs wird zur Datenabfrage verwendet (Übergabe der Abfragen über Querystring)
    3. HTTP-Verbs steuern Aktion (GET, PUT, POST, DELETE)
      1. Daten werden als Ressourcen transportiert
      2. Nutzung von Proxies, Caching etc.
    4. Formate
      1. AtomPub (XML)
      2. JSON

ADO.NET Data Services sind besonders gut geeignet, um den Datenzugriff aus Silverlight umzusetzen oder einen plattformunabhängigen Datenzugriff anzubieten. Ein großer Vorteil dabei ist, dass durch die Verwendung von REST das Testen eines ADO.NET Data Services über den Browser einfach möglich ist.

Die Serviceschicht kann bei ADO.NET Data Services um Operationen erweitert werden:

Beispiel über das Hinzufügen einer Serviceoperation zu einem Data Service

[WebGet]
public IQueryable<UserData> Login(string email, string password)
{
 var users = this.CurrentDataSource.UserData.Where(
  u => u.Email == email && u.Password == password);
 if (users.Count() != 1)
 {
  throw new ApplicationException("Login failed!");
 }

 return users;
}

Ein Nachteil von ADO.NET Data Services ist die schwierige Fehlersuche. Data Services liefern in der Regel nicht sehr sprechende Fehlermeldungen. Eine Abhilfe ist das Aktivieren detaillierter Informationen über eventuell aufgetretene Exceptions. Dies kann über die web.config Datei oder über das ServiceBehavior Attribut erreicht werden:

Aktivieren von detaillierten Fehlerinformationen mit dem ServiceBehavior Attribut.

[ServiceBehavior(IncludeExceptionDetailInFaults = true)]
public class TrainingMonitorDataService : DataService<TrainingMonitor>
{
[…]
}
 

ADO.NET Data Services können für die Verwendung von Silverlight aus speziell getestet werden. Die normalen Unit Tests von Visual Studio sind dafür nicht geeignet. Statt dessen verwendet man am besten das Silverlight Unit Test Framework. Es wird wie folgt installiert (Auszug aus derDokumentation):

  1. Download the templates
  2. SilverlightTestProject.zip
    Copy this into your %userprofile%\Documents\Visual Studio 2008\Templates\ProjectTemplates
  3. SilverlightTestClass.zip
    Copy this into your %userprofile%\Documents\Visual Studio 2008\Templates\ItemTemplates

Beim Schreiben der Unit Tests muss auf das asynchrone Ausführen von WCF Calls in Silverlight geachtet werden.

Beispiel für einen Unit Test mit asynchronem Aufruf von ADO.NET Data Services

[TestMethod]
[Asynchronous]
public void AddUser()
{
 var serviceClient = new TrainingMonitor(new Uri("TrainingMonitorDataService.svc", UriKind.Relative));

 // add a new user
 var newUser = new UserData()
 {
  UserDataUuid = Guid.NewGuid(),
  Email = emailGuid,
  CreationDateTime = DateTime.Now,
  Password = "Test"
 };
 serviceClient.AddToUserData(newUser);

 // save the new user async
 bool isSaved = false;
 Exception saveException = null;
 serviceClient.BeginSaveChanges(
  delegate(IAsyncResult r)
  {
   try
   {
    var context = r.AsyncState as TrainingMonitor;
    context.EndSaveChanges(r);
   }
   catch (Exception ex)
   {
    saveException = ex;
   }
   finally
   {
    isSaved = true;
   }
  }, serviceClient);

 // enqueue checking if an exception has occured
 EnqueueConditional(() => isSaved);
 EnqueueCallback(
  delegate()
  {
   if (saveException != null)
   {
    throw saveException;
   }
  });
 EnqueueTestComplete();
}

Die View-Schicht - MVVM (Model-View-ViewModel)

Bei der Umsetzung der View-Schicht empfiehlt sich eine strenge Trennung zwischen Ansicht ("View") und Logik ("ViewModel"). Die Verbindung zwischen View und ViewModel stellt Data Binding dar. Die folgenden beiden Codebeispiele zeigen, wie das gemacht wird. Die ViewModel Klasse bietet Eigenschaften an, an die sich die View-Schicht über Binding-Ausdrücke anbindet:

Beispiel für eine ViewModel Klasse.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows.Input;
using TrainingMonitorAdminClient.TrainingMonitorService;

namespace TrainingMonitorAdminClient.ViewModel
{
 public class UserManagement : INotifyPropertyChanged
 {
  public UserManagement()
  {
   this.RefreshUserData();
  }

  private IEnumerable<UserData> UserDataValue;
  public IEnumerable<UserData> UserData
  {
   get { return this.UserDataValue; }
   set { this.UserDataValue = value;
    this.OnPropertyChanged("UserData"); }
  }

  public event PropertyChangedEventHandler PropertyChanged;

  private void OnPropertyChanged(string propertyName)
  {
   if (this.PropertyChanged != null)
   {
    this.PropertyChanged(this,
     new PropertyChangedEventArgs(propertyName));
   }
  }

  private void RefreshUserData()
  {
   var serviceClient = new TrainingMonitorAdminServiceClient();
   this.UserData = serviceClient.GetUsers();
  }
 }
}

Beispiel für eine View-Schicht, die mit Data Binding an die ViewModel Schicht gebunden ist.

<Window x:Class="TrainingMonitorAdminClient.MainWindow"
 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:dg="http://schemas.microsoft.com/wpf/2008/toolkit"
 xmlns:vm="clr-namespace:TrainingMonitorAdminClient.ViewModel"
 Title="TrainingMonitor Administration" Height="350" Width="525">
 <Grid>
  <Grid.RowDefinitions>
   <RowDefinition Height="*" />
   <RowDefinition Height="Auto" />
   <RowDefinition Height="Auto" />
  </Grid.RowDefinitions>

  <dg:DataGrid ItemsSource="{Binding Path=UserData}"
   AutoGenerateColumns="False"
   IsReadOnly="True"
   SelectionMode="Single"
   SelectedItem="{Binding Path=SelectedUser, Mode=TwoWay}">
   <dg:DataGrid.Columns>
    <dg:DataGridTextColumn Header="Email"
     Binding="{Binding Path=Email}"
     MinWidth="150" />
   </dg:DataGrid.Columns>
  </dg:DataGrid>

  <Button Grid.Row="1" HorizontalAlignment="Right">
   Delete selected user
  </Button>

  <Expander Header="Neuer Administrator" Grid.Row="2">
  </Expander>
 </Grid>
</Window>

Data Binding ist nicht nur in der Lage, Daten von der ViewModel Schicht in die View Schicht zu übertragen. Es ist auch möglich Operationen zu binden. Das .NET Interface ICommand wird dafür verwendet. Man könnte für jedes Command eine eigene Klasse entwickeln. In der Praxis empfiehlt sich jedoch das Schreiben einer generischen Klasse, die ICommand implementiert und an vielen Stellen in der ViewModel Schicht zum Einsatz kommen kann:

Beispiel für eine generische Implementation von ICommand.

using System;
using System.ComponentModel;
using System.Windows.Input;

namespace TrainingMonitorAdminClient.ViewModel
{
 public class ViewModelCommand<T> : ICommand
  where T: INotifyPropertyChanged
 {
  private Func<T, bool> canExecuteFunc;
  private Action<T> executeFunc;
  private T viewModel;

  public ViewModelCommand(
   T viewModel,
   Func<T, bool> canExecuteFunc,
   Action<T> executeFunc)
  {
   this.canExecuteFunc = canExecuteFunc;
   this.executeFunc = executeFunc;
   this.viewModel = viewModel;
   viewModel.PropertyChanged +=
    new PropertyChangedEventHandler(OnViewModelPropertyChanged);
  }

  void OnViewModelPropertyChanged(
   object sender, PropertyChangedEventArgs e)
  {
   if (this.CanExecuteChanged != null)
   {
    this.CanExecuteChanged(this, new EventArgs());
   }
  }

  public bool CanExecute(object parameter)
  {
   return this.canExecuteFunc(this.viewModel);
  }

  public event EventHandler CanExecuteChanged;

  public void Execute(object parameter)
  {
   this.executeFunc(this.viewModel);
  }
 }
}

Auf Basis dieser Klasse kann die ViewModel Schicht um Operationen erweitert werden, die Lösch- und Einfügeoperationen abbildet:

Beispiel für eine ViewModel Schicht mit Operationen

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows.Input;
using TrainingMonitorAdminClient.TrainingMonitorService;

namespace TrainingMonitorAdminClient.ViewModel
{
 public class UserManagement : INotifyPropertyChanged
 {
  private ViewModelCommand<UserManagement> deleteCommand;
  private ViewModelCommand<UserManagement> createCommand;

  public UserManagement()
  {
   this.deleteCommand = new ViewModelCommand<UserManagement>(
    this,
    new Func<UserManagement, bool>( um=> um.SelectedUser!=null ),
    delegate(UserManagement viewModel)
    {
     if (viewModel.SelectedUser != null)
     {
      new TrainingMonitorAdminServiceClient().
       DeleteUser(viewModel.SelectedUser.UserDataUuid);
      viewModel.RefreshUserData();
     }
    });
   this.createCommand = new ViewModelCommand<UserManagement>(
    this,
    new Func<UserManagement, bool>(um => um.NewAdmin != null),
    delegate(UserManagement viewModel)
    {
     if (viewModel.NewAdmin != null)
     {
      new TrainingMonitorAdminServiceClient().
       AddAdmin(viewModel.NewAdmin);
      viewModel.NewAdmin = new AdminData() {
       UserDataUuid = Guid.NewGuid() };
      viewModel.RefreshUserData();
     }
    });
   this.NewAdmin = new AdminData() {
    UserDataUuid = Guid.NewGuid() };
   this.RefreshUserData();
  }

  private IEnumerable<UserData> UserDataValue;
  public IEnumerable<UserData> UserData
  {
   get { return this.UserDataValue; }
   set { this.UserDataValue = value;
    this.OnPropertyChanged("UserData"); }
  }

  private UserData SelectedUserValue;
  public UserData SelectedUser
  {
   get { return this.SelectedUserValue; }
   set { this.SelectedUserValue = value;
    this.OnPropertyChanged("SelectedUser"); }
  }

  private AdminData NewAdminValue;
  public AdminData NewAdmin
  {
   get { return this.NewAdminValue; }
   set { this.NewAdminValue = value;
    this.OnPropertyChanged("NewAdmin"); }
  }

  public ICommand DeleteSelectedUserCommand
  {
   get { return this.deleteCommand; }
  }
  
  public ICommand NewAdminCommand
  {
   get { return this.createCommand; }
  }

  public event PropertyChangedEventHandler PropertyChanged;

  private void OnPropertyChanged(string propertyName)
  {
   if (this.PropertyChanged != null)
   {
    this.PropertyChanged(this,
     new PropertyChangedEventArgs(propertyName));
   }
  }

  private void RefreshUserData()
  {
   var serviceClient = new TrainingMonitorAdminServiceClient();
   this.UserData = serviceClient.GetUsers();
  }
 }
}

In der View-Schicht kann an die Operationen gebunden werden:

<Button Grid.Row="1" HorizontalAlignment="Right"
  Command="{Binding Path=DeleteSelectedUserCommand}">
  Delete selected user
</Button>