Dependency Injection und WCF Service Interface

In diesem Beitrag geht es um das Dependency Injection Pattern und die Fallstricke bei der Verwendung eines WCF Services und dessen Interface als abhängige Komponente. Die Nutzung eines WCF Services in einem Programm, nachdem man sich einen Proxy erzeugt hat, folgt in der Regel dem Schema a) Proxy-Klasse erzeugen, b) Service-Methode aufrufen und c) dann den Channel wieder sauber aufräumen.

   1: var client = new SampleWcfServiceReference.SampleWcfServiceClient("tcpEndpoint");

   2: var message = "DirectServiceCall";

   3: try

   4: {

   5:   var result = client.Method(message);

   6:   client.Close();

   7: }

   8: catch (Exception ex)

   9: {

  10:   client.Abort();

  11: }

Zu beachten ist hier, dass die Methoden Close und Abort durch die Proxy-Klasse, die durch svcutil erzeugt wird, implementiert werden, genauer durch die Basis-Klasse ClientBase<T>. Und im Fehlerfall sollte die Methode Abort aufgerufen werden.

Wird nun in einem Projekt auch das DI-Pattern genutzt und dafür das Castle Windsor Framework verwendet, dann ist im Sinne von Abhängigkeiten der WCF Service eine zu injizierende Abhängigkeit für eine Businesskomponente:

   1: public class LocalBusinessService : ILocalBusinessService

   2: {

   3:   ISampleWcfService _service;

   4:  

   5:   public LocalBusinessService(ISampleWcfService service)

   6:   {

   7:     _service = service;

   8:   }

   9:  

  10:   public void Process(string message)

  11:   {

  12:

  13:     var result = _service.Method(message);

  14:

  15:   }

  16: }

Das Bootstrapping und die Verwendung sehen wie folgt aus:

   1: var container = new Castle.Windsor.WindsorContainer();

   2: container.Register(Component.For<ISampleWcfService>()

   3:   .ImplementedBy<SampleWcfServiceClient>()

   4:   .DependsOn(Dependency.OnValue("endpointConfigurationName", "tcpEndpoint")));

   5: container.Register(Component.For<ILocalBusinessService>()

   6:   .ImplementedBy<LocalBusinessService>());

   7: var localService = container.Resolve<ILocalBusinessService>(); 

   8: try

   9: {

  10:   localService.Process("CastleWindsorCall");

  11: }

  12: catch (Exception ex)

  13: {

  14:   Console.WriteLine(ex.Message);

  15: }

  16: container.Dispose();

Problem an dieser Stelle ist, dass das Interface nicht die Close und Abort Methoden anbietet und der Lifecycle des Proxy vom DI Container gemanaged wird und somit das Aufräumen des Proxy von diesem vorgenommen wird.

Die Basisklasse BaseClient<T> implementiert auch das IDisposable Interface, welches vom Castle Windsor Container genutzt wird, wenn die Komponente aufgeräumt wird, es wird also ClientBase<T>.Dispose aufgerufen. Innerhalb von Dispose wird die Close Methode aufgerufen. Ist jedoch der CommunicationState Faulted, so wirft die ClientBase<T>.Dispose Methode bzw. die CommunicationObject.Close eine CommunicationObjectFaultedException mit der Nachricht "The communication object, System.ServiceModel.Channels.ServiceChannel, cannot be used for communication because it is in the Faulted state". Um genau zu sein, tritt dieses Szenario ein, wenn die Kommunikation mit dem WCF Service über net.tcp läuft und die Exception in der Methode keine im Contract definierte FaultException ist. Letztendlich ist dies allerdings sehr unschön, da diese Exception an einer Stelle auftritt, die nicht mehr kontrolliert werden kann. Welche Möglichkeiten gibt es nun, dieses Problem einzufangen?

1. Ansatz: Castle Windsor Möglichkeiten

Für den Abbau von Objekten bietet Castle Windsor bei der Registrierung die Hinterlegung einer Funktion über die OnDestroy Methode an. Allerdings heißt es hier auch im Abschnitt zu IDisposable

„Note that if your class implements IDisposable, then Dispose will automatically be called on the object before your custom destroy function is invoked.”

Damit scheidet dieser Ansatz aus.

2. Ansatz: Wrapping des WebServices-Proxies

Es wird nicht die generierte Service-Proxy als Implementierung des WCF-Interfaces genutzt, sondern eine Klasse, die das WCF-Interface, das IDisposible Interface implementiert und intern die generierte Service-Proxy Klasse nutzt. Die WCF-Interface Methoden werden 1:1 an den Proxy weitergeleitet und im Dispose-Fall wird in Abhängigkeit des CommunicationState Abort oder Close am Proxy aufgerufen.

   1: public class SampleWcfServiceWrapper : ISampleWcfService, IDisposable

   2: {

   3:   readonly SampleWcfServiceClient _client;

   4:  

   5:   public SampleWcfServiceWrapper()

   6:   {

   7:     _client = new SampleWcfServiceClient("tcpEndpoint");

   8:   }

   9:  

  10:   ~SampleWcfServiceWrapper()

  11:   {

  12:     Dispose(false);

  13:   }

  14:  

  15:   public string Method (string message)

  16:   {

  17:     return _client.Method(message);

  18:   }

  19:  

  20:   public void Dispose()

  21:   {

  22:     Dispose(true);

  23:     GC.SuppressFinalize(this);

  24:   }

  25:  

  26:   private void Dispose(bool isDisposing)

  27:   {

  28:     if (isDisposing)

  29:     {

  30:       if (_client.State == System.ServiceModel.CommunicationState.Faulted)

  31:         _client.Abort();

  32:       else

  33:         _client.Close();

  34:     }

  35:   }

  36: }

Das Bootstrapping sieht dann wie folgt aus:

   1: var container = new Castle.Windsor.WindsorContainer();

   2: container.Register(Component.For<ISampleWcfService>()

   3:   .ImplementedBy<SampleWcfServiceWrapper>());

   4: container.Register(Component.For<ILocalBusinessService>()

   5:   .ImplementedBy<LocalBusinessService>());

   6:  

   7: var localService = container.Resolve<ILocalBusinessService>();

   8: try

   9: {

  10:   localService.Process("CastleWindsorCallWithWrapper");

  11: }

  12: catch (FaultException<SampleWcfServiceReference.FaultInformation> ex)

  13: {

  14:   Console.WriteLine(ex.Detail.Message);

  15: }

  16: catch (Exception ex)

  17: {

  18:   Console.WriteLine(ex.Message);

  19: }

  20:  

  21: container.Dispose();

Dieser Ansatz funktioniert wunderbar, doch ist es allerdings mühevoll hier ein Klasse anlegen zu müssen, die mehr oder weniger eine 1:1 Kopie der Proxy-Klasse ist.

3. Ansatz: Bridging Ansatz

Um weniger Code schreiben zu müssen und trotzdem die Sicherheit zu haben, kann ein Bridge Ansatz gewählt werden. Zunächst kann ein generisches Interface definiert werden, welches das WCF-Interface als generischen Parameter hat, dieses Interface als einzige Property anbietet und von IDisposable ableitet.

   1: public interface IWcfServiceBridge<T> : IDisposable

   2: {

   3:     T Interface { get; }

   4: }

Die Implementierung des Interfaces sieht dann wie folgt aus

   1: public class WcfServiceBrigde<T> : IWcfServiceBridge<T> where T : class

   2: {

   3:   private System.ServiceModel.ClientBase<T> _client;

   4:  

   5:   public WcfServiceBrigde(System.ServiceModel.ClientBase<T> client)

   6:   {

   7:     _client = client;

   8:   }

   9:  

  10:   ~WcfServiceBrigde()

  11:   {

  12:     Dispose(false);

  13:   }

  14:  

  15:   public T Interface

  16:   {

  17:     get { return _client as T; }

  18:   }

  19:  

  20:   public void Dispose()

  21:   {

  22:     Dispose(true);

  23:     GC.SuppressFinalize(this);

  24:   }

  25:  

  26:   private void Dispose(bool isDisposing)

  27:   {

  28:     if (isDisposing)

  29:     {

  30:       if (_client.State == System.ServiceModel.CommunicationState.Faulted)

  31:         _client.Abort();

  32:       else

  33:         _client.Close();

  34:     }

  35:   }

  36: }

Zu beachten ist hier, dass im Konstruktor die Basisklasse ClientBase<T> als Parameter erwartet wird, also eine Service-Proxy Klasse. Das Interface wird über die Proxy-Klasse geliefert und die Dispose Methode des IDisposable Interfaces kann auf den CommunicationState des Proxy reagieren. Eine weitere Anpassung ist am lokalen BusinessService noch notwendig, damit dieser nicht mehr das WCF-Interface als Abhängigkeit erwartet, sondern das Bridge-Interface.

   1: public class LocalBusinessServiceUsingBridge : ILocalBusinessService

   2: {

   3:   ISampleWcfService _service;

   4:  

   5:   public LocalBusinessServiceUsingBridge(IWcfServiceBridge<ISampleWcfService> service)

   6:   {

   7:     _service = service.Interface;

   8:   }

   9:  

  10:   public void Process(string message, bool callFaultException)

  11:   {

  12:

  13:     var result = _service.Method(message);

  14:

  15:   }

  16: }

Bootstrapping und Verwendung dieses Ansatzes sieht wie folgt aus

   1: var container = new Castle.Windsor.WindsorContainer();

   2: container.Register(Component.For<ISampleWcfService>()

   3:   .ImplementedBy<SampleWcfServiceClient>()

   4:   .DependsOn(Dependency.OnValue("endpointConfigurationName", "tcpEndpoint"))

   5:   .Named("SampleService"));

   6: container.Register(Component.For<IWcfServiceBridge<ISampleWcfService>>()

   7:   .ImplementedBy<WcfServiceBrigde<ISampleWcfService>>()

   8:   .DependsOn(Property.ForKey<ClientBase<ISampleWcfService>>().Is("SampleService")));

   9: container.Register(Component.For<ILocalBusinessService>()

  10:   .ImplementedBy<LocalBusinessServiceUsingBridge>());

  11:  

  12: var localService = container.Resolve<ILocalBusinessService>();

  13: try

  14: {

  15:   localService.Process("CastleWindsorCallWithBridge");

  16: }

  17: catch (FaultException<SampleWcfServiceReference.FaultInformation> ex)

  18: {

  19:   Console.WriteLine(ex.Message);

  20:   Console.WriteLine(ex.Detail.Message);

  21: }

  22: catch (Exception ex)

  23: {

  24:   Console.WriteLine(ex.Message);

  25: }

  26:  

  27: container.Dispose();

Fazit

Bei der Verwendung von WCF-Services und dem DI-Pattern ist das Lifecycle Management des DI-Containers zu beachten. Probleme können beim Aufräumen der Komponenten im Container entstehen. Wie sie umgangen werden können, wurde hier gezeigt. Persönlich würde ich den Bridging Ansatz wählen, weil damit kein Nachbau des WCF-Interfaces notwendig ist und dieser Ansatz somit auch stabil gegenüber Änderungen am Interface in der Entwicklungsphase ist.

Export to Excel using …

Bei Anwendungen, die große Datenmengen verarbeiten, wird sehr häufig die Anforderung gestellt, die Daten in Excel bereit zu stellen, da die Möglichkeiten der Analyse oder der Weiterverarbeitung hier schneller und flexibler von statten gehen kann. Die Funktionalität des Excels in der Anwendung bereitzustellen ist auch nicht sinnvoll, obwohl mit Hilfe von Controls von Anbietern wie Telerik bereits sehr viel machbar ist. Nun stellt sich die Frage, welche Optionen habe ich, um Daten nach Excel zu exportieren.

Die nachfolgenden Beschreibungen und Beobachtungen basieren auf einer WPF Anwendung mit Office 2010. Als Optionen für den Export wurden betrachtet

  1. Excel Automation
  2. ClosedXML (Codeplex)
  3. EPPlus (Codeplex)
  4. Telerik RadGridView Control

Die kleine WPF-Anwendung hat lediglich ein DataGrid, in denen die zu exportierenden Daten angezeigt werden, eine Methode um eine bestimmte Anzahl an Daten zu laden und vier Methoden um die Daten nach Excel zu exportieren. Die Daten bestehen aus einer Liste von Objekten mit lediglich 4 Eigenschaften, die zufällig generiert werden. In allen vier Fällen wurden unterschiedliche Anzahlen an Datensätze exportiert und dabei die Laufzeit und der temporäre maximale zusätzliche Verbrauch an Arbeitsspeicher (Working Set (Memory)) beobachtet. Die Beobachtungen betrachten nur den ersten Aufruf des Exports.

image

Beim Export ist keine besondere Formatierung vorgenommen worden. Dies ist aber bei allen 4 Lösungen machbar. Ebenso wurden die Methoden gewählt, die den geringsten Aufwand hatten.

1. Option: Excel Automation

Microsoft stellt für seine Office-2010-Programme Primary Interop Assemblies (PIA) bereit, welche über ein Restributable Paket separat installiert werden können. Im Projekt muss nun eine Reference auf das Assembly „Microsoft.Office.Interop.Excel.dll“ aus dem Verzeichnis C:WindowsassemblyGAC_MSILMicrosoft.Office.Interop.Excel 14.0.0.0__71e9bce111e9429c gesetzt werden.

   1: public void SaveWithPIA(IEnumerable<BusinessItem> businessItems)

   2: {

   3:   var resultFile = @"c:	empExportPIA.xlsx";

   4:   if (File.Exists(resultFile))

   5:   {

   6:     File.Delete(resultFile);

   7:   }

   8:  

   9:   var application = new Microsoft.Office.Interop.Excel.Application();

  10:   application.Visible = false;

  11:   application.UserControl = false;

  12:   try

  13:   {

  14:     var workbook = application.Workbooks.Add();

  15:     var worksheet = workbook.ActiveSheet as Microsoft.Office.Interop.Excel.Worksheet;

  16:  

  17:     this.ExportItems(

  18:       worksheet, 

  19:       new List<string> { "ID", "Name", "UnitPrice", "Date" },

  20:       businessItems.ToList(), 

  21:       new List<Func<BusinessItem, object>>

  22:       {

  23:         p => p.ID, 

  24:         p => p.Name, 

  25:         p => p.UnitPrice,

  26:         p => p.Date,

  27:       });

  28:  

  29:     workbook.SaveAs(resultFile);

  30:   }

  31:   finally

  32:   {

  33:     application.Quit();

  34:     application = null;

  35:   }

  36: }

  37:  

  38: private void ExportItems<T>(

  39:   Microsoft.Office.Interop.Excel.Worksheet worksheet, 

  40:   List<string> headers,

  41:   List<T> items, 

  42:   List<Func<T, object>> valueGetters)

  43:    where T : class

  44: {

  45:   var headerArray = new object[1, headers.Count];

  46:  

  47:   for (int i = 0; i < headers.Count; i++)

  48:   {

  49:     headerArray[0, i] = headers[i];

  50:   }

  51:  

  52:   var leftUpperCell = (Microsoft.Office.Interop.Excel.Range)worksheet.Cells[1, 1];

  53:   var rightLowerCell = (Microsoft.Office.Interop.Excel.Range)worksheet.Cells[1, headers.Count];

  54:   var headerRow = worksheet.Range[leftUpperCell, rightLowerCell];

  55:   headerRow.Value = headerArray;

  56:   headerRow.Font.Bold = true;

  57:  

  58:   var dataArray = new object[items.Count, valueGetters.Count];

  59:  

  60:   for (var rowIndex = 0; rowIndex < items.Count; rowIndex++)

  61:   {

  62:     T item = items[rowIndex];

  63:     var columnIndex = 0;

  64:     foreach (var valueGetter in valueGetters)

  65:     {

  66:       dataArray[rowIndex, columnIndex] = valueGetter(item);

  67:       columnIndex++;

  68:     }

  69:   }

  70:  

  71:   leftUpperCell = (Microsoft.Office.Interop.Excel.Range)worksheet.Cells[2, 1];

  72:   rightLowerCell = (Microsoft.Office.Interop.Excel.Range)worksheet.Cells[1 + items.Count, valueGetters.Count];

  73:   var dataRange = worksheet.Range[leftUpperCell, rightLowerCell];

  74:   dataRange.Value = dataArray;

  75: }

Vorteile

Bei größeren Datenmengen (100.000 Datensätze und mehr) ist diese Art des Exports mit annehmbarer Performance und auch Memory-Verbrauch verbunden. Die erzeugten Excel-Files sind von der Größe her mit einem „normalen“ Excel vergleichbar, was ja auch nicht verwunderlich ist, denn es wird ja Excel verwendet. Im Zuge eines MVVM-Pattern, kann der Export in einem ViewModel oder einer Service/Repository-Komponente implementiert werden.

Nachteile

Es ist auf dem System, wo der Export-Vorgang ausgeführt wird, die Installation von Excel notwendig. Dies kann bei einem Server gestütztem Vorgang eventuell Probleme bereiten, weil eine Office-Installation auf einem Server vom Betrieb nicht gerne gesehen wird. Ebenso muss die Liste der Objekte im Arbeitsspeicher „doppelt“ vorgehalten werden, da ein Objekt-Array erzeugt werden muss, um die Daten sinnvoll in die Excel-Zellen zu kopieren. Das Setzen des Wertes über eine einzelne Zelle verschlechtert die Performance erheblich.

Datensätze 100 1.000 10.000 100.000 1.000.000
Zuwachs

Arbeitsspeicher + Excel (K)
5.000 +

100.000
5.000 +

100.000
7.000 +

100.000
40.000 +

100.000
430.000 +

410.000
Zeit (s) 3-4 3-4 3-4 4-6 20-22
Dateigröße (KB) 14 37 261 2.545 25.420

Für Excel 2013 sind hier weitere Informationen zu finden.

2. Option: ClosedXML

ClosedXML ist eine unter MIT License verfügbare Bibliothek auf Codeplex. Hier heißt es

„ClosedXML makes it easier for developers to create Excel 2007/2010 files. It provides a nice object oriented way to manipulate the files (similar to VBA) without dealing with the hassles of XML Documents. It can be used by any .NET language like C# and Visual Basic (VB).“

Sie basiert auf dem OpenXml Format und benötigt die Library DocumentFormat.OpenXml.dll (Download). Nach der Referenzierung der beiden DLL’s „ClosedXML.dll“und „DocumentFormat.OpenXml.dll“ kann eine Liste von Objekten wie im Listing zu sehen gespeichert werden.

   1: public void SaveWithOpenXml(IEnumerable<BusinessItem> businessItems)

   2: {

   3:   var strResultFile = @"c:	empExportClosedXml.xlsx";

   4:   if (File.Exists(strResultFile))

   5:   {

   6:     File.Delete(strResultFile);

   7:   }

   8:  

   9:   using (var workbook = new XLWorkbook(XLEventTracking.Disabled))

  10:   {

  11:     using (var worksheet = workbook.Worksheets.Add("ExportData"))

  12:     {

  13:       worksheet.Cell(1, 1).InsertTable(businessItems);

  14:     }

  15:  

  16:     workbook.SaveAs(strResultFile);

  17:   }

  18: }

Vorteile

Es ist keine Excel-Installation auf dem ausführenden System notwendig. Die erzeugten Excel-Files sind von der Größe her mit einem „normalen“ Excel vergleichbar. Im Zuge eines MVVM-Pattern, kann der Export in einem ViewModel oder einer Service/Repository-Komponente implementiert werden. Ebenso ist eine akzeptable Performance bei kleineren Datenmengen zu beobachten.

Nachteile

Der zusätzliche Bedarf an Arbeitsspeicher bei größeren Datenmengen ist enorm.

Datensätze 100 1.000 10.000 100.000 1.000.000
Zuwachs

Arbeitsspeicher (K)
12.000 24.000 50.000 520.000 4.700.000
Zeit (s) 1-2 1-2 2-3 8-10 90-110
Dateigröße (KB) 11 30 218 2.114 21.091

3. Option: EPPlus

EPPlus ist eine unter GNU Library General Public License verfügbare Bibliothek auf Codeplex. Hier heißt es

„EPPlus is a .net library that reads and writes Excel 2007/2010 files using the Open Office Xml format (xlsx).“

Nach der Referenzierung der DLL „EPPlus.dll“ kann eine Liste von Objekten wie im Listing zu sehen gespeichert werden.

   1: public void SaveWithEpplus(IEnumerable<BusinessItem> businessItems)

   2: {

   3:   var strResultFile = @"c:	empExportEpplus.xlsx";

   4:   if (File.Exists(strResultFile))

   5:   {

   6:     File.Delete(strResultFile);

   7:   }

   8:  

   9:   var fileInfo = new FileInfo(strResultFile);

  10:  

  11:   using (var excelPackage = new ExcelPackage(fileInfo))

  12:   {

  13:     var worksheet = excelPackage.Workbook.Worksheets.Add("ExportData");

  14:     

  15:     worksheet.Cells.LoadFromCollection(businessItems, true);

  16:  

  17:     excelPackage.Save();

  18:   }

  19: }

Vorteile

Es ist keine Excel-Installation auf dem ausführenden System notwendig. Die erzeugten Excel-Files liegen von der Größe her leicht unter dem „normalen“ Excel. Im Zuge eines MVVM-Pattern, kann der Export in einem ViewModel oder einer Service/Repository-Komponente implementiert werden. Sehr performant bei kleineren und mittleren Datenmengen, sehr sparsam im Arbeitsspeicherverbrauch.

Nachteile

Die aktuellste Version (4.0.2) hat beim Speichern eine Exception geworfen, deswegen wurde im Test die Version 3.1 verwendet. Das Datumsfeld wurde im Excel nicht als Datum formatiert, sondern es steht dort der numerische Wert.

Datensätze 100 1.000 10.000 100.000 1.000.000
Zuwachs

Arbeitsspeicher (K)
4.000 5.000 10.000 105.000 780.000
Zeit (s) <1 <1 <1 6-7 60-70
Dateigröße (KB) 5 23 197 1.986 19.819

4. Option: Telerik RadGridView

Telerik ist einer der professionellen Anbieter von Controls um Anwendungen mit mehr User-Expierence auszustatten. Das RadGridView bietet eine Export Funktion an, die es auch ermöglicht Excel-kompatible Dateien zu erzeugen.

Eine Code-Behind Methode könnte dann so aussehen

   1: private void ExportWithTelerik(object sender, RoutedEventArgs e)

   2: {

   3:   try

   4:   {

   5:     using (var stream = File.OpenWrite(@"c:	empExportTelerik.xls"))

   6:     {

   7:       var exportOptions = new GridViewExportOptions();

   8:       exportOptions.Format = ExportFormat.ExcelML;

   9:       exportOptions.ShowColumnFooters = false;

  10:       exportOptions.ShowColumnHeaders = true;

  11:       exportOptions.ShowGroupFooters = false;

  12:       exportOptions.Encoding = Encoding.UTF8;

  13:  

  14:       ExportGridView.Export(stream, exportOptions);

  15:     }

  16:   }

  17:   catch (Exception ex)

  18:   {

  19:     TBMessage.Text = ex.Message;

  20:   }

  21: }

Vorteile

Es wird die Ansicht nahezu 1:1 auch in Excel angezeigt. Dies betrifft auch eventuell vorhandene Gruppierungen und Aggregationsfunktionen. Der Arbeitsspeicherverbrauch ist gering, da die Daten ja bereits im DataGrid vorliegen und somit nicht nochmal aufbereitet werden müssen und auch XML erzeugt wird. Gute Performance auch bei größeren Datenmengen.

Nachteile

Das Speichern erfolgt in einem Excel-kompatiblen XML Format. Es erscheint beim Öffnen der Datei immer die Warnung, dass das Format nicht korrekt vorliegt. Excel ist aber in der Lage dann die Daten anzuzeigen. Da XML abgespeichert wird, ist die Dateigröße entsprechend groß. Im Zuge eines MVVM Pattern ist dies weniger zu gebrauchen, da die Methodik am Control RadGridView vorhanden ist. Es sollte dann schon im Code-Behind des UserControls implementiert werden.

Datensätze 100 1.000 10.000 100.000 1.000.000
Zuwachs

Arbeitsspeicher (K)
1.000 1.000 1.000 1.000 10.000
Zeit (s) <1 <1 <1 2-3 22-24
Dateigröße (KB) 28 271 2.710 27.182 272.785

Fazit

Für eine Export-Methode, die dem MVVM-Pattern möglichst nahe kommt, ist eine der ersten 3 Optionen die richtige Wahl. Ist der Export nur auf dem Client notwendig und nicht Server Szenarien zu berücksichtigen würde ich die PIA Option wählen. In einem Server-Szenario ist sicherlich eine Excel-freie Option, die bessere Wahl. Hier scheint EPPlus eine gute Option zu sein, jedoch habe ich bisher keine näheren Erfahrungen mit dieser Bibliothek gemacht, da ich während des Test zufällig auf diese gestoßen bin. Kommt ein „SnapShot“ der angezeigten Daten am ehesten der fachlichen Anforderung nahe und man kann zum Beispiel die Telerik Controls im Projekt nutzen, so diese Option eine für mich zu favorisierende Wahl.

SqlBulkCopy using …

Im Rahmen von stark datengetriebenen Anwendungen kann es vorkommen, dass eine Liste von Objekten in einer SQL Server Datenbank abgespeichert werden muss. Die Liste der Datenobjekte stammt dabei aus fremden Datenquellen, liegt als Liste oder Enumeration vor und die Datenmenge kann durchaus mehrere (hundert-)tausend Elemente erreichen.

Um die Datenmenge performant in der SQL Server Datenbank abzuspeichern, bietet sich hier natürlich ein BulkCopy an, welches aus C# heraus mit der Klasse SqlBulkCopy ermöglicht wird. Diese Klasse bietet die Methode WriteToServer an, wobei sie im Wesentlichen entweder eine DataTable oder ein IDataReader als Datenquelle erwartet.

In dem folgenden Beispiel wird ein Businessobjekt mit den Eigenschaften fachliche ID, Name, Preis und Datum verwendet. Die Datenbanktabelle nimmt diese 4 Attribute plus eine Identity-Column als technischen Key auf.

image

Als erstes betrachten wir die Methode mit einer DataTable als Parameter. Hierfür muss nun aus der Enumeration der Objekte eine DataTable erzeugt werden, wobei die Struktur der DataTable der Datenbanktabelle entsprechen muss. Um keine Mappings anlegen zu müssen, ist es erforderlich die Tabelle auch in der Spaltenreihenfolge identisch zu halten.

   1: private static DataTable CreateDataTable()

   2: {

   3:   var table = new DataTable(TABLENAME);

   4:  

   5:   table.Columns.Add(DC_KEY, typeof(int));

   6:   table.Columns.Add(DC_ID, typeof(int));

   7:   table.Columns.Add(DC_NAME, typeof(string));

   8:   table.Columns.Add(DC_PRICE, typeof(double));

   9:   table.Columns.Add(DC_DATE, typeof(DateTime));

  10:  

  11:   return table;

  12: }

Dabei ist auch zu beachten, dass die Identity-Column mit anzugeben ist. Ist die DataTable erzeugt, können die Daten übertragen werden und dann per WriteToServer in der Datenbank abgespeichert werden.
   1: public void SaveUsingDataTable(IEnumerable<BusinessItem> businessItems)

   2: {

   3:   var strConnectionString = System.Configuration.ConfigurationManager.ConnectionStrings["MyDB"].ConnectionString;

   4:   var insertTable = CreateDataTable();

   5:  

   6:   foreach (var businessItem in businessItems)

   7:   {

   8:     var tableRow = insertTable.NewRow();

   9:  

  10:     tableRow[DC_ID] = businessItem.ID;

  11:     tableRow[DC_NAME] = businessItem.Name;

  12:     tableRow[DC_PRICE] = businessItem.UnitPrice;

  13:     tableRow[DC_DATE] = businessItem.Date;

  14:  

  15:     insertTable.Rows.Add(tableRow);

  16:   }

  17:  

  18:   using (var tableBulkCopy = new SqlBulkCopy(strConnectionString, SqlBulkCopyOptions.UseInternalTransaction))

  19:   {

  20:     tableBulkCopy.BatchSize = insertTable.Rows.Count;

  21:     tableBulkCopy.DestinationTableName = insertTable.TableName;

  22:     tableBulkCopy.WriteToServer(insertTable);

  23:   }

  24: }

Beobachtet man den Ressourcen Verbrauch (Working Set (Memory)) und die Dauer, dann ergibt sich folgendes Bild:

Datensätze

10.000

100.000

1.000.000

Zuwachs Arbeitsspeicher (K)

5.000

22.000

150.000

Zeit (s)

0,2 – 0,3

0,8-0,9

7,5-8,0

Betrachtet man den Code so werden die Daten durch die Erzeugung der DataTable im Arbeitsspeicher „quasi verdoppelt“. Im Sinne einer Ressourcen schonenden Verarbeitung ist dies nicht der Königsweg. Daher ist eventuell die zweite Variante der WriteToServer Methode mit einem IDataReader als Parameter eine Alternative.

Ziel muss es also sein, einen DataReader für die Liste der Objekte bereitzustellen. Wie dies geht zeigen unter anderem die Beispiele hier oder hier. Nun hat die Schnittstelle IDataReader nicht wenige Methoden definiert, doch werden durch die SqlBulkCopy Methode WriteToServer nur sehr wenige genutzt, nämlich die Eigenschaft FieldCount und die Methoden Read() sowie GetValue(int i). Mit diesen Informationen lässt sich eine maßgeschneiderte Hilfsklasse implementieren:

   1: internal class BulkCopyHelper<T> : IDataReader

   2: {

   3:   private readonly IEnumerator<T> enumerator;

   4:   private readonly Func<T, object>[] getters;

   5:  

   6:   public BulkCopyHelper(IEnumerable<T> input, 

   7:     params Func<T, object>[] getters)

   8:   {

   9:     this.enumerator = input.GetEnumerator();

  10:     this.getters = getters;

  11:   }

  12:  

  13:   public int FieldCount

  14:   {

  15:     get { return this.getters.Length; }

  16:   }

  17:  

  18:   public bool Read()

  19:   {

  20:     return this.enumerator.MoveNext();

  21:   }

  22:  

  23:   public object GetValue(int i)

  24:   {

  25:     return this.getters[i](this.enumerator.Current);

  26:   }

  27:  

  28:   public void Dispose()

  29:   {

  30:     if (this.enumerator != null)

  31:     {

  32:       this.enumerator.Dispose();

  33:     }

  34:   }

  35:  

  36: ...

  37:   // sonstiges IDataReader-Methoden

  38: }

Mit einer zusätzlichen Extension-Method

   1: public static class EnumerationBulkCopyExtension

   2: {

   3:   public static IDataReader AsDataReader<T>(this IEnumerable<T> items, 

   4:     params Func<T, object>[] propertyGetters)

   5:   {

   6:     return new BulkCopyHelper<T>(items, propertyGetters);

   7:   }

   8: }

ist dann der Aufruf für den SqlBulkCopy

   1: public void SaveUsingDataReader(IEnumerable<BusinessItem> businessItems)

   2: {

   3:   var strConnectionString = System.Configuration.ConfigurationManager.ConnectionStrings["MyDB"].ConnectionString;

   4:   var reader = businessItems.AsDataReader(

   5:     item => -1, // Identity column

   6:     item => item.ID,

   7:     item => item.Name,

   8:     item => item.UnitPrice,

   9:     item => item.Date);

  10:  

  11:   using (var readerBulkCopy = new SqlBulkCopy(strConnectionString, SqlBulkCopyOptions.UseInternalTransaction))

  12:   {

  13:     readerBulkCopy.BatchSize = 10000;

  14:     readerBulkCopy.DestinationTableName = TABLENAME;

  15:     readerBulkCopy.WriteToServer(reader);

  16:   }

  17: }

Die Funktionen, die zur Ermittlung der Daten eines Elementes dienen, müssen zum Layout der Datenbanktabelle passen. Hier ist die Identity-Column nicht zu vergessen. Beobachtet man nun den Ressourcen Verbrauch (Working Set (Memory)) und die Dauer, dann ergibt sich folgendes Bild:

Datensätze

10.000

100.000

1.000.000

Zuwachs Arbeitsspeicher (K)

5.000

5.000

6.000

Zeit (s)

0,2 – 0,3

0,6-0,7

5,5 -6,0

Fazit

Sind Massendaten aus einer .NET Anwendung heraus in eine SQL Server Datenbanktabelle einzufügen und liegen die Daten als Enumeration vor, so ist es empfehlenswert die SqlBulkCopy Klasse zu verwenden und dabei den Weg über das Interface IDataReader zu gehen. Bei großen Datenmengen benötigt sie weniger Arbeitsspeicher und ist noch etwas schneller als die DataTable Variante.

WPF Wizard mit Caliburn.Micro

In vielen Applikation sind Wizards anzutreffen und auch im Rahmen von Desktop-Applikationen für Kunden kommt es immer wieder vor, dass die Bearbeitung durch den Anwender Schritt für Schritt durchgeführt werden soll. Ein Wizard zu implementieren, stellt den Entwickler immer wieder vor die gleichen Herausforderungen. In diesem Beitrag wird gezeigt, wie ein Wizard auf Basis der WPF Technologie und mit Hilfe des Caliburn.Mirco Frameworks unter Berücksichtigung des MVVM Patterns entwickelt werden kann.

Aus MVVM Gesichtspunkten heraus wäre es für einen Wizard wünschenswert, dass lediglich ein ViewModel mit mehreren Views verwendet wird. So werden die Daten in einem ViewModel gesammelt, aber nur die Teilabschnitte angezeigt, die zum jeweiligen Verarbeitungschritt gehören. Das Caliburn.Micro Framework unterstützt diese Möglichkeit, in dem es über den eigenen WindowManager für die Anzeige eines ViewModels den Context-Parameter auswertet und dann per Konvention nach einer passenden View sucht.

   1: Sdx.Sample.ViewModels.DialogViewModel vm = 

   2:   new Sdx.Sample.ViewModels.DialogViewModel();

   3: cmWndMgr.ShowDialog(vm);

   4: ...

   5: cmWndMgr.ShowDialog(vm,"Page1");

In der Zeile 3 wird durch das Caliburn.Micro Framework per Konvention die Viewklasse “Sdx.Sample.Views.DialogView” dem ViewModel zugeordnet. In der Zeile 5 hingegen wird die Viewklasse “Sdx.Sample.Views.Dialog.Page1” ermittelt. Nun möchte man nicht für jede Wizard-Seite einen eigenen Dialog anzeigen, sondern Ziel sollte es sein, den Inhalt in place auszutauschen. Auch hierfür bietet das Framework eine Möglichkeit auf Basis des WPF ContentControls und den verfügbaren attached Properties von Caliburn.Micro (siehe auch Beispiel hier).

   1: <ContentControl cal:View.Context="{Binding WizardContent.State, Mode=TwoWay}"  

   2:                 cal:View.Model="{Binding WizardContent}" />

Durch diese Definition wird in dem ContentControl die View entsprechend der Eigenschaft “State” am ViewModel “WizardContent” ausgewählt und angezeigt.

Aufbau eines Grundgerüsts

Ausgerüstet mit diesem Wissen kann nun eine Basisimplementierung für einen Wizard angegangen werden. Das Grundgerüst des Wizards soll eine Kopfzeile mit Information zum aktuellen Schritt, den Bereich für die View und in der Fußzeile vier Buttons (einer für die vorangegangene Seite, einer für die nächste Seite, einer für das Beenden und einer für das Abbrechen) aufweisen.

   1: <Window x:Class="Sdx.Sample.CaliburnMircoWizard.Views.WizardShellView"

   2:         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"

   3:         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"

   4:         xmlns:cal="clr-namespace:Caliburn.Micro;assembly=Caliburn.Micro"

   5:         Title="MainWindow" Height="350" Width="525">

   6:   <DockPanel LastChildFill="True">

   7:     <StackPanel DockPanel.Dock="Bottom" Margin="5" 

   8:                 Orientation="Horizontal" HorizontalAlignment="Right">

   9:       <Button Margin="5" Name="PreviousPage" Content="&lt;" Width="50" />

  10:       <Button Margin="5" Name="NextPage" Content="&gt;" Width="50" />

  11:       <Button Margin="5" Name="FinishWizard" Content="Finish" Width="50" />

  12:       <Button Margin="5" Name="CancelWizard" Content="Cancel" Width="50" />

  13:     </StackPanel>

  14:     <StackPanel Margin="5" DockPanel.Dock="Top" Orientation="Horizontal">

  15:       <Image Source="/Sdx.Sample.CaliburnMircoWizard;component/SDX_Logo.jpg"

  16:              Width="80" Height="30" />

  17:       <TextBlock Margin="10,0,10,0" Height="30" Name="StateDescription" 

  18:                  FontSize="14" FontWeight="Bold"/>

  19:     </StackPanel>

  20:     <ContentControl Margin="10" DockPanel.Dock="Top"

  21:                     cal:View.Context="{Binding WizardContent.State, Mode=TwoWay}"  

  22:                     cal:View.Model="{Binding WizardContent}"

  23:                     />

  24:   </DockPanel>

  25: </Window>

Das ViewModel zum WizardShellView ist abgeleitet von der Caliburn.Micro Klasse Screen und die Kommunikation mit dem ViewModel, welches im Wizard angezeigt werden soll, erfolgt über ein Interface.

   1: public interface IWizardScreen

   2: {

   3:   string State { get; set; }

   4:   string StateDescription { get; }

   5:   void NextPage();

   6:   bool CanNextPage { get; }

   7:   void PreviousPage();

   8:   bool CanPreviousPage { get; }

   9:   void CancelWizard();

  10:   bool CanCancelWizard { get; }

  11:   void FinishWizard();

  12:   bool CanFinishWizard { get; }

  13: }

Die “State” Eigenschaft ermöglicht dem ViewModel die Anzeige auszutauschen und über die Methoden passend zu den Buttons die notwendige Steuerung. Über die Guard-Properties “CanXXX” wird das Enablen/Disablen der Buttons ermöglicht. Innerhalb des konkreten Wizard-ViewModels, dem Wizard-Content, kann dann die Steuerung im einfachsten Fall bei einem 2-seitigen Wizard so aussehen.

   1: ...

   2: private string _State;

   3: public string State

   4: {

   5:   get { return _State; }

   6:   set { _State = value; NotifyOfPropertyChange(() => State); }

   7: }

   8: public void NextPage()

   9: {

  10:   if (State == "Page1")

  11:   {

  12:     // Businesslogik für nächste Wizardseite

  13:     State = "Page2";

  14:   }

  15: }

  16: public bool CanNextPage

  17: {

  18:   get { return State == "Page1" ? true : false; }

  19: }

  20: ...

Zu den verschiedenen States sind nun unterschiedliche Views bereitzustellen und der Wizard insgesamt könnte exemplarisch so aussehen:

WPF_CM_Wizard

Dieses Beispiel zeigt die Grundzüge einer Wizard Steuerung. In der Praxis werden noch mehr Anforderungen zu erfüllen sein. Vor allem die Validierung der Eingaben vor Ausführung des nächsten Schrittes ist hier noch nicht diskutiert. Mit der Steuerung bzw. dem zu Grunde gelegten Interface sind jedoch die Grundsteine gelegt. Eine Teilvalidierung des ViewModels ist in den Guard-Properties zu verankern. Ein nichtlinearer Workflow kann in den Methoden anhand der bisher vom Anwender eingegebenen Werte durch setzen des entsprechenden States umgesetzt werden.

Fazit

Einen Wizard mit Hilfe des MVVM Patterns ist dank der Unterstützung vom Caliburn.Mirco Framework für eine WPF Anwendung mit einfachen Mitteln und schnell umsetzbar. Die Konventionen des Caliburn.Micro Frameworks ermöglichen die Verwendung eines ViewModels mit mehreren Views. Für den Wizard-State genügt ein einfaches Interface zwischen der Shell des Wizards und dem anzuzeigenden ViewModel. Die Views können so nur den für den State notwendigen Inhalt anzeigen.

In diesem kurzen Beispiel ist die Validierung ausgeklammert worden. Wie dies umgesetzt werden könnte, wird in einem späteren Beitrag gezeigt. Der Wechsel zwischen den States des ViewModels kann eventuell auch noch besser unterstützt werden, so dass große switch-Statements vermieden werden können.

Verzögerte Command Ausführung in WPF

Bei Windows Desktop Anwendungen wie sie zum Beispiel mit WPF Mitteln erstellt werden können, werden häufig Listen oder Baumansichten verwendet. In einigen Fällen ist es dann erforderlich bei der Auswahl eines Eintrages detailliertere Daten zu diesem Eintrag anzuzeigen oder mehr Informationen zu ermitteln. Ebenso könnte eine Suche über ein große Datenmenge so auszugestalten sein, dass bei der Eingabe der Filterkriterien, die Suche automatisiert ausgeführt werden soll, d.h. es gibt kein “Suche” Button. >> mehr…

SharePoint 2010 Workflow mit Visual Studio 2010 und InfoPath 2010 (Part 3)

Dies ist der dritte und letzte Teil einer kleinen Reihe zu dem Thema SharePoint 2010 Workflows mit Visual Studio 2010 und InfoPath 2010.

Es soll ein Workflow für eine Dokumentenliste einer SharePoint 2010 Site erstellt werden, welcher eine Association Form, eine Initiation Form sowie eine Task Form verwendet. Alle Formulare sollen mit Hilfe des InfoPath Designers 2010 erzeugt werden und in einem Visual Studio 2010 Workflow Projekt verwendet werden. Fachlich handelt es sich auch um einen Freigabeprozess für Dokumente. Dabei soll der Workflow bei der Zuordnung zu einer Liste eine Association Form anzeigen, in der bereits Felder vorbelegt sind. Des Weiteren sollen dann die Daten aus der Association Form an die Initiation Form übergeben werden und ggfs. vom Anwender verändert werden können. Abschließend werden dann die Daten in der Task Form dem Bearbeiter angezeigt.

Ein sehr guter Beitrag zu diesem Thema ist in einer Blogreihe von Reiner Ganser (1st Quad Blog) zu finden. Diese Blogreihe ist eine Schritt für Schritt Anleitung für die Erstellung eines Workflows, allerdings werden in diesem Beispiel für Association und Initiation Form ASPX Seiten verwendet. Das Hauptaugenmerk in meiner dreiteiligen Blogreihe liegt auf dem Datenaustausch zwischen den Formularen und dem Workflow. Ansonsten  sind nur die wesentlichen Schritte beschrieben. Dabei behandeln die Blogbeiträge folgende Themen

  1. Datenübergabe an Association Form
  2. Datenübergabe von Association Form an Initiation Form
  3. Datenaustausch mit dem Workflow und Task Form

Datenaustausch mit dem Workflow und Task Form

Im ersten Teil der Reihe habe ich aufgezeigt wie Datenfelder in einer Association Form über die Metadaten des Workflows vorbelegt werden können. Im zweiten Teil wurden dann die Daten an die Initiation Form weitergereicht. Nun möchte ich im dritten und letzten Schritt zeigen, wie diese Daten in dem Workflow verwendet werden können und wie der Datenaustausch mit einer Task Form erreicht werden kann.

Die Daten der Association Form und der Initiation Form stehen der Workflow-Instanz als Properties AssociationData und InitiationData zur Verfügung, denn im MSDN Artikel How to: Design a Workflow Form to Use Association and Initiation Data heißt es:

“When a workflow instance starts, this data is also passed into the workflow via the AssociationData property of the SPWorkflowActivationProperties object.”

“After the workflow starts, the initiation data is stored in the InitiationData property of the SPWorkflowActivationProperties object returned by the WorkflowProperties property of the OnWorkflowActivated activity.”

Diese beiden Eigenschaften sind vom Typ string und beinhalten XML. Um an die einzelnen Feldinhalte zu gelangen stehen 2 Möglichkeiten zur Verfügung:

1.) Einlesen in ein XmlDocument und Auslesen über XPath-Ausdrücke, wobei die Namespaces beachtet und gegebenenfalls Datenkonvertierungen vorgenommen werden müssen.

2.) Deserialisierung in eine Klasseninstanz, die auf den Schemata der Formulare basiert.

Eine Schritt für Schritt Anleitung der Möglichkeit 2 ist hier How to: Access Association and Initiation Form Data in a Workflow nachzulesen. Anschließend stehen die Informationen typisiert für die Aufgaben des Workflows zur Verfügung.

Fehlt noch der Datenaustausch zwischen dem Workflow und einer Task Form. Eine Task Form kommt zum Einsatz, wenn durch den Workflow eine “Create Task Acitivity” verwendet wird. Möchte man hier eine InfoPath Form verwenden, so wird die InfoPath Form zum Beispiel auf Basis des “Blank Form” Templates erstellt. Da die Form Daten erhalten soll, muss eine Data Connection erstellt werden, die Daten entgegen nehmen kann. Dafür modelliert man sämtliche Daten für den Austausch in einer Datei namens ItemMetadata.xml, die lediglich eine XML Tag namens <z:row xmlns:z=”#RowsetSchema” /> beinhaltet und die für jedes Datenfeld ein Attribute namens “ows_NameDatenfeld” mit sich bringt.

   1: <z:row xmlns:z="#RowsetSchema"

   2:  ows_Instructions=""

   3:  ows_ReviewHints=""

   4:  ows_ReviewComment=""

   5:  ows_ReviewStatus=""

   6:  /> 

Im obigen Bespiel sind die Datenfelder “Instructions”, “ReviewHints”, “ReviewComment” und “ReviewStatus” definiert. Der Präfix “ows_” ist aus historischen Gründen zu verwenden. Die so definierte ItemMetadata.xml ist in der InfoPath Form Data Connection vom Typ “Retrieve data” zu definieren. Sind Felder im InfoPath Formular vorzubelegen, dann kann dies über das Setzen eines Default Values erreicht werden.

image

Nun muss das Formular publiziert (Network Location) werden, die publizierte XSN-Datei unterhalb des Workflows in das Projekt aufgenommen werden und der Deployment Type auf “ElementFile” zu setzen. Zur Verwendung des Formulars ist dann noch die Metadatendatei “Elements.xml” des Workflows an zwei Stellen anzupassen.

   1: <Elements xmlns="http://schemas.microsoft.com/sharepoint/">

   2:   <Workflow

   3:      Name="HowToWorkflow - ReviewWorkflow"

   4:      Description="My SharePoint Workflow"

   5:      Id="64261495-cba6-417c-bc7d-83ec41fd0cec"

   6:      AssociationUrl="_layouts/CstWrkflIP.aspx"

   7:      InstantiationUrl="_layouts/IniWrkflIP.aspx"

   8:      TaskListContentTypeId="0x01080100C9C9515DE4E24001905074F980F93160"

   9:      CodeBesideClass="HowToWorkflow.ReviewWorkflow.ReviewWorkflow"

  10:      CodeBesideAssembly="$assemblyname$">

  11:     <AssociationData>...</AssociationData>

  12:     <MetaData>

  13:       <AssociationCategories>List</AssociationCategories>

  14:       <!-- Tags to specify InfoPath forms for the workflow; delete tags for forms that you do not have -->

  15:       <Association_FormURN>...</Association_FormURN>

  16:       <Instantiation_FormURN>...</Instantiation_FormURN>

  17:       <Task0_FormURN>urn:schemas-microsoft-com:office:infopath:TaskForm:-myXSD-2011-03-07T09-52-14</Task0_FormURN>

  18:       <StatusPageUrl>_layouts/WrkStat.aspx</StatusPageUrl>

  19:     </MetaData>

  20:   </Workflow>

  21: </Elements>

In Zeile 8 wird ein TaskListContentTypeId Attribut auf den Wert “0x01080100C9C9515DE4E24001905074F980F93160” gesetzt und in Zeile 17 wird die URN gesetzt, die im InfoPath Designer über File –> Form Template Properties –> ID ermittelt wird. Die ContentTypeId ist der von SharePoint definierte Wert für Tasks. Das <TaskX_FormURN> Tag verweist auf die URN der zu verwendenden Task Form. Hierbei ist X ein Numerierung, die bei der Create Task Activity eine Rolle spielt. Denn hier wird zum Beispiel codiert:

   1: private void OnCreateReviewTask(object sender, EventArgs e)

   2: {

   3:   XmlSerializer serializer = 

   4:     new XmlSerializer(typeof(SDX.Workflows.myFields));

   5:   XmlTextReader reader = 

   6:     new XmlTextReader(new System.IO.StringReader(workflowProperties.InitiationData));

   7:   SDX.Workflows.myFields initform = 

   8:     (SDX.Workflows.myFields)serializer.Deserialize(reader);

   9:  

  10:   string assignedTo = initform.Reviewer[0].AccountId;

  11:  

  12:   this.createReviewTaskId = Guid.NewGuid();

  13:   this.createReviewTaskProperties.Title = "Review des Dokuments";

  14:   this.createReviewTaskProperties.AssignedTo = assignedTo;

  15:   this.createReviewTaskProperties.DueDate = DateTime.Today.AddDays(5);

  16:   this.createReviewTaskProperties.TaskType = 0; // Nr. des Formulars 

  17:   this.createReviewTaskProperties.ExtendedProperties["Instructions"] 

  18:     = initform.Instructions;

  19:   this.createReviewTaskProperties.ExtendedProperties["ReviewHints"] 

  20:     = initform.ReviewHints;

  21: }

In Zeile 16 wird die Nummer des Formulars verwendet, welches bei der Anzeige des Tasks verwendet werden soll. Die Felder aus der ItemMetadata.xml werden über die Hashtable “ExtendedProperties” gefüllt, wobei der Name des Feldes der Schlüsselwert ist (siehe Zeilen 17 – 20). Entsprechend stehen Werte, die im Formular durch den Anwender und das Formular selbst gesetzt werden, wieder in den ExtendedProperties im Rahmen einer OnTaskChanged Activity in deren AfterProperties wieder zur Verfügung (siehe unten Zeilen 3 und 11).

   1: private void OnReviewTaskChanged(object sender, ExternalDataEventArgs e)

   2: {

   3:   switch (this.onReviewTaskChangedAfterProperties.ExtendedProperties["ReviewStatus"].ToString())

   4:   {

   5:     case "freigegeben":

   6:       this.documentAccepted = true;

   7:       break;

   8:     case "zurückgewiesen":

   9:       this.documentAccepted = false;

  10:       this.reviewerComment

  11:         = this.onReviewTaskChangedAfterProperties.ExtendedProperties["ReviewComment"].ToString();

  12:       break;

  13:     default:

  14:       this.documentAccepted = false;

  15:       break;

  16:   }

  17: }

Fazit

Die Daten der Association Form und der Initiation Form im Workflow stehen über die Properties AssociationData und InitiationData zur Verfügung. Diese können per XPath oder per Deserialisierung ausgelesen werden. Um Daten mit Task Forms auszutauschen bedarf es einer ItemMetadata.xml als Data Connection in der InfoPath Form und der Verwendung der Hashtable ExtendedProperties bei Task Activities.

SharePoint 2010 Workflow mit Visual Studio 2010 und InfoPath 2010 (Part 2)

Dies ist der zweite Teil einer kleinen Reihe zu dem Thema SharePoint 2010 Workflows mit Visual Studio 2010 und InfoPath 2010.

Es soll ein Workflow für eine Dokumentenliste einer SharePoint 2010 Site erstellt werden, welcher eine Association Form, eine Initiation Form sowie eine Task Form verwendet. Alle Formulare sollen mit Hilfe des InfoPath Designers 2010 erzeugt werden und in einem Visual Studio 2010 Workflow Projekt verwendet werden. Fachlich handelt es sich auch um einen Freigabeprozess für Dokumente. Dabei soll der Workflow bei der Zuordnung zu einer Liste eine Association Form anzeigen, in der bereits Felder vorbelegt sind. Des Weiteren sollen dann die Daten aus der Association Form an die Initiation Form übergeben werden und ggfs. vom Anwender verändert werden können. Abschließend werden dann die Daten in der Task Form dem Bearbeiter angezeigt.

Ein sehr guter Beitrag zu diesem Thema ist in einer Blogreihe von Reiner Ganser (1st Quad Blog) zu finden. Diese Blogreihe ist eine Schritt für Schritt Anleitung für die Erstellung eines Workflows, allerdings werden in diesem Beispiel für Association und Initiation Form ASPX Seiten verwendet. Das Hauptaugenmerk in meiner dreiteiligen Blogreihe liegt auf dem Datenaustausch zwischen den Formularen und dem Workflow. Ansonsten  sind nur die wesentlichen Schritte beschrieben. Dabei behandeln die Blogbeiträge folgende Themen

  1. Datenübergabe an Association Form
  2. Datenübergabe von Association Form an Initiation Form
  3. Datenaustausch mit dem Workflow und Task Form

Datenübergabe von Association Form an Initiation Form

Im ersten Teil der Reihe habe ich aufgezeigt wie Datenfelder in einer Association Form über die Metadaten des Workflows vorbelegt werden können. Nun möchte ich im zweiten Schritt zeigen, wie diese Daten an die Initiation Form übergeben werden können. In der MSDN unter How to: Design a Workflow Form to Use Association and Initiation Data heißt es:

“When SharePoint Server 2010 displays a custom InfoPath form for workflow initiation on the server, it calls the Forms Server control to convert the form into an ASP.NET 2.0 page and host it. As part of the form load, the Forms Server control retrieves the initiation data from the AssociationData property of the appropriate SPWorkflowAssociation object, and passes it to the initiation form as XML. The Forms Server control automatically data-binds any elements to the form control of the same name.”

Die AssociationData wird als XML an die Initiation Form übergeben und man muss die Controls gleich benennen. Ist dies so einfach? Nein, denn auch hier müssen die XML Namespaces beachtet werden. In Teil 1 habe ich aufgezeigt, dass die Association Form eine XML Repräsention aufweist, die von XML Namespaces abhängig ist. Es ist sogar so, dass der XML Namespace der einfachen Felder unter Umständen abhängig vom Erstelldatum der Form ist und generiert wird. Lege ich nun eine neue Form als Initiation Form über das Template “Blank Form” an, so erhalten die Felder einen neuen Namespace auch bei Beibehaltung des gleichen lokalen Namens. Damit die automatische Datenbindung zum Erfolg führt, müssen die Felder der Association Form und der Initiation Form dem selben XML Namespace angehören. Daher muss die Initiation Form auf Basis der Association Form erstellt werden. Dies kann zum Beispiel erreicht werden, in dem man die Association Form unter neuem Namen abspeichert. Nach Anpassung des Aussehens ist die Form allerdings neu unter neuen Namen zu publizieren, denn mit einem Quick Publish würde die publizierte Association Form überschrieben werden. Alternativ könnte die Initiation Form auf Basis des “XML or Schema” Templates erstellt werden und dabei die Schema Datei der Association Form verwendet werden (Export Source Files).

Ist das Formular neu publiziert (Network Location), ist die publizierte XSN-Datei unterhalb des Workflows in das Projekt aufzunehmen und der Deployment Type auf “ElementFile” zu setzen. Zur Verwendung des Formulars ist dann noch die Metadatendatei “Elements.xml” des Workflows an zwei Stellen anzupassen.

   1: <Elements xmlns="http://schemas.microsoft.com/sharepoint/">

   2:   <Workflow

   3:      Name="HowToWorkflow - ReviewWorkflow"

   4:      Description="My SharePoint Workflow"

   5:      Id="64261495-cba6-417c-bc7d-83ec41fd0cec"

   6:      AssociationUrl="_layouts/CstWrkflIP.aspx"

   7:      InstantiationUrl="_layouts/IniWrkflIP.aspx"

   8:      CodeBesideClass="HowToWorkflow.ReviewWorkflow.ReviewWorkflow"

   9:      CodeBesideAssembly="$assemblyname$">

  10:     <AssociationData>

  11:       <my:myFields ...>...</my:myFields>

  12:     </AssociationData>

  13:     <MetaData>

  14:       <AssociationCategories>List</AssociationCategories>

  15:       <!-- Tags to specify InfoPath forms for the workflow; delete tags for forms that you do not have -->

  16:       <Association_FormURN>urn:schemas-microsoft-com:office:infopath:AssocForm:-myXSD-2011-02-22T14-04-28</Association_FormURN>

  17:       <Instantiation_FormURN>urn:schemas-microsoft-com:office:infopath:InitForm:-myXSD-2011-02-22T14-04-28</Instantiation_FormURN>

  18:       <StatusPageUrl>_layouts/WrkStat.aspx</StatusPageUrl>

  19:     </MetaData>

  20:   </Workflow>

  21: </Elements>

In Zeile 7 wird ein InstantiationUrl Attribut auf den Wert “_layouts/IniWrkflIP.aspx” gesetzt und in Zeile 17 wird die URN gesetzt, die im InfoPath Designer über File –> Form Template Properties –> ID ermittelt wird.

Ist dies erfolgt, so präsentiert sich das Formular beim Start des Workflows an einem Listeneintrag wie folgt:

image

Fazit

Um eine Übergabe von Association Daten an die Initiation Form zu erreichen, müssen die Felder in beiden Formularen dem selben Namespace angehören. Damit muss die Initiation Form auf Basis der Association Form erstellt werden. Dies kann entweder durch ein “Save As” der Association Form oder über das XML or Schema Template mit der XSD der Association Form erreicht werden.

Im nächsten Beitrag widme ich mich der Aufgabe die Daten der Association Form und/oder der Initiation Form im Workflow zu verwenden und Daten mit einer Task InfoPath Form auszutauschen.

SharePoint 2010 Workflow mit Visual Studio 2010 und InfoPath 2010 (Part 1)

Einiges an Internetrecherche hat zu diesem Beitrag geführt, denn die notwendigen Informationen sind nicht unbedingt zu finden bzw. müssen aus verschiedenen Bruchstücken zusammengesucht werden. Die verfügbaren Informationen in der MSDN oder in Blogbeiträgen lassen teilweise Aspekte unberücksichtigt, die jedoch im Projektalltag allzu oft angefordert werden.

Es soll ein Workflow für eine Dokumentenliste einer SharePoint 2010 Site erstellt werden, welcher eine Association Form, eine Initiation Form sowie eine Task Form verwendet. Alle Formulare sollen mit Hilfe des InfoPath Designers 2010 erzeugt werden und in einem Visual Studio 2010 Workflow Projekt verwendet werden. Fachlich handelt es sich auch um einen Freigabeprozess für Dokumente. Dabei soll der Workflow bei der Zuordnung zu einer Liste eine Association Form anzeigen, in der bereits Felder vorbelegt sind. Des Weiteren sollen dann die Daten aus der Association Form an die Initiation Form übergeben werden und ggfs. vom Anwender verändert werden können. Abschließend werden dann die Daten in der Task Form dem Bearbeiter angezeigt.

Ein sehr guter Beitrag zu diesem Thema ist in einer Blogreihe von Reiner Ganser (1st Quad Blog) zu finden. Diese Blogreihe ist eine Schritt für Schritt Anleitung für die Erstellung eines Workflows, allerdings werden in diesem Beispiel für Association und Initiation Form ASPX Seiten verwendet. Das Hauptaugenmerk in meiner dreiteiligen Blogreihe liegt auf dem Datenaustausch zwischen den Formularen und dem Workflow. Ansonsten  sind nur die wesentlichen Schritte beschrieben. Dabei behandeln die Blogbeiträge folgende Themen

  1. Datenübergabe an Association Form
  2. Datenübergabe von Association Form an Initiation Form
  3. Datenaustausch mit dem Workflow und Task Form

Datenübergabe an Association Form

Nach der Anlage eines Sharepoint 2010 Workflow Projektes mit Visual Studio 2010 ist das Projekt für die Verwendung von InfoPath Forms vorzubereiten. Dazu ist im Feature Set des Projektes eine Anpassung notwendig. Über den Designer wird zu den Features die Datei “Feature1.template.xml” geöffnet und mit folgendem Inhalt versehen:

   1: <?xml version="1.0" encoding="utf-8" ?>

   2: <Feature

   3:   ReceiverAssembly="Microsoft.Office.Workflow.Feature, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c"

   4:   ReceiverClass="Microsoft.Office.Workflow.Feature.WorkflowFeatureReceiver"

   5:   xmlns="http://schemas.microsoft.com/sharepoint/">

   6:   <Properties>

   7:     <Property Key="GloballyAvailable" Value="true" />

   8:     <Property Key="RegisterForms" Value="ReviewWorkflow*.xsn" />

   9:   </Properties>

  10: </Feature>

Mit den Attributen ReceiverAssembly und ReceiverClass des Features wird eine Komponente hinterlegt, die bei Aktivierung des Features zur Registrierung der InfoPath Forms notwendig ist. Des Weiteren ist ein <Property> Eintrag mit dem Key "RegisterForms" zu hinterlegen, wobei der Value auf den Ablageort der InfoPath Formulare zeigt. Dieser Ablageort ist in der Regel gleich dem Namen des Workflows und *.xsn schließt alle InfoPath Formulare ein.

Anschließend kann ein InfoPath Formular angelegt werden, welches als Association Form dient. Diese Form braucht neben den gewünschten Eingabefeldern noch eine Submit Data Connection an den umgebenden Host oder die ASPX Seite.

image

Nach der Fertigstellung ist das Formular selbst zu publizieren (Network Location), die publizierte XSN-Datei unterhalb des Workflows in das Projekt aufzunehmen und der Deployment Type auf “ElementFile” zu setzen. Zur Verwendung des Formulars ist dann noch die Metadatendatei “Elements.xml” des Workflows an zwei Stellen anzupassen.

   1: <Elements xmlns="http://schemas.microsoft.com/sharepoint/">

   2: <Workflow

   3:    Name="HowToWorkflow - ReviewWorkflow"

   4:    Description="My SharePoint Workflow"

   5:    Id="64261495-cba6-417c-bc7d-83ec41fd0cec"

   6:    AssociationUrl="_layouts/CstWrkflIP.aspx"

   7:    CodeBesideClass="HowToWorkflow.ReviewWorkflow.ReviewWorkflow"

   8:    CodeBesideAssembly="$assemblyname$">

   9:   <MetaData>

  10:     <AssociationCategories>List</AssociationCategories>

  11:     <!-- Tags to specify InfoPath forms for the workflow; delete tags for forms that you do not have -->

  12:     <Association_FormURN>urn:schemas-microsoft-com:office:infopath:AssocForm:-myXSD-2011-02-22T14-04-28</Association_FormURN>

  13:     <StatusPageUrl>_layouts/WrkStat.aspx</StatusPageUrl>

  14:   </MetaData>

  15: </Workflow>

  16: </Elements>

In Zeile 6 wird ein AssociationUrl Attribut auf den Wert “_layouts/CstWrkflIP.aspx” gesetzt und in Zeile 12 wird die URN gesetzt, die im InfoPath Designer über File –> Form Template Properties –> ID ermittelt wird.

Wie können nun Daten an die Association Form übergeben werden? Über den Workflow-Code ist es nicht möglich, da zum Zeitpunkt der Anzeige des Formulars der Workflow noch nicht instanziiert wird. In der MSDN (siehe How to: Design a Workflow Form to Use Association and Initiation Data) wird die Datenbindung beschrieben, wenn auch nicht ausführlich. Dort heißt es “You can specify default association data in the AssociationData element of the workflow definition file for your workflow solution” und “Name your InfoPath form control the same as the element in the association data XML”. Der Punkt hierbei ist allerdings die Beachtung von XML Namespaces, denn die Formularfelder liegen in einem XML Namespace und bei Verwendung von Person/Group Picker kommen vordefinierte Namespaces zum Einsatz. Den Namespace eines einzelnen Formularfeldes kann über dessen Properties und den Tabreiter “Details” ermittelt werden. Einfacher ist es jedoch sich von dem gesamten Formular die XML Repräsentation zu beschaffen. Dies ist über das Menü File –> Publish –> Export Source Files möglich, denn hier werden unter anderem Schema-Dateien und auch eine Datei namens “Sampledata.xml” erstellt. Letztere ist ein XML Instanz des Formulars. Der Inhalt kann dann in das XML Tag <AssociationData> der Datei Elements.xml des Workflows eingefügt werden und mit den gewünschten Daten vorbelegt werden.

   1: <Elements xmlns="http://schemas.microsoft.com/sharepoint/">

   2:   <Workflow

   3: ...

   4:      CodeBesideAssembly="$assemblyname$">

   5:     <AssociationData>

   6:       <my:myFields

   7:         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

   8:         xmlns:my="http://schemas.microsoft.com/office/infopath/2003/myXSD/2011-02-22T14:04:28"

   9:         xmlns:xd="http://schemas.microsoft.com/office/infopath/2007"

  10:         xml:lang="en-us"

  11:         xmlns:pc="http://schemas.microsoft.com/office/infopath/2007/PartnerControls">

  12:         <my:Reviewer>

  13:           <pc:Person>

  14:             <pc:DisplayName>Matthias Straßer</pc:DisplayName>

  15:             <pc:AccountId>domainusername</pc:AccountId>

  16:             <pc:AccountType>User</pc:AccountType>

  17:           </pc:Person>

  18:         </my:Reviewer>

  19:         <my:Instructions>Bitte Dokument prüfen und freigeben</my:Instructions>

  20:       </my:myFields>

  21:     </AssociationData>

  22:     <MetaData>

  23: ...

  24:     </MetaData>

  25:   </Workflow>

  26: </Elements>

In dem Beispiel habe ich auf der Association Form zwei Felder, ein Person/Group Picker namens “Reviewer” und eine Textbox namens “Instructions”. Zu beachten ist der “my” und der “pc” Namespace. Der my-Namespace ist ein generierter Namespace und beinhaltet ein Datum und eine Uhrzeit. Dies ist der Zeitpunkt der Anlage des Formulars. Der pc-Namespace ist ein vordefinierter Namespace, der durch die Verwendung des Person/Group Pickers im Formular benötigt wird.

Hinweis: Der generierte my-Namespace kann umgangen werden, wenn ein Formular auf Basis eines zuvor angelegten Schemas erstellt wird. Dann kann man selbst den Namen des Namespaces beeinflussen.

Mit der Einbindung des <AsscociationData> Tags präsentiert sich das Formular bei der Zuordnung wie folgt:

image

Fazit

Felder in Association Formularen, die mit InfoPath Designer 2010 erstellt worden sind, können über das XML Tag <AssociationData> der Metadaten zu einem Workflow (Elements.xml) vorbelegt werden. Hierbei sind die XML Namespaces des Formulares und der verwendeten Controls zu beachten. Ist ein Workflow in Produktion deployed, so können die Werte bei Bedarf geändert werden. Allerdings muss hierfür die “Elements.xml” Datei im SharePoint 2010 Hive (C:Program FilesCommon FilesMicrosoft SharedWeb Server Extensions14TEMPLATEFEATURES<YourWorkflowFeature><YourWorkflow>) angepasst werden und der Application Pool der SharePoint Web Site neu gestartet werden.

Im nächsten Beitrag widme ich mich der Aufgabe die Daten der Association Form an eine Initiation Form zu übergeben.

.NET 4 Änderungen an string.IndexOf Methode

Mit der Umstellung auf ein neues Framework erhofft man sich ja immer auch ein bisschen bessere Software und auch eine schnellere Anwendung. Das dies aber nicht unbedingt gilt, bewies in einem Projekt die Umstellung der Software von .NET 2.0 auf .NET 4. Bei Lasttests wurden daraufhin Performance-Probleme in einer Methode identifiziert, die einen String entgegen nahm und diesen nach Patterns durchsucht hat. Nähere Untersuchungen haben ergeben, dass der Aufruf der Methode string.IndexOf der Verursacher für den Performanceeinbruch war.

Für die Nachstellung des Problems durch ein kleines Consolenprogramm wurde eine Textdatei (~400 KB) in einen string eingelesen und dann alle Auftreten eines Patterns gesucht. Der eingelesene String ist somit sehr groß und das Pattern tritt 14.700 mal in dem String auf. Hier die Methode:

   1: private static void SearchPattern(string strPattern, string strSearchString)

   2: {

   3:   int iStartIndex = 0;

   4:   int iIndex = 0;

   5:   int iContentLength = strSearchString.Length - 1;

   6:  

   7:   while (iStartIndex < iContentLength && iIndex != -1)

   8:   {

   9:     iIndex = strSearchString.IndexOf(strPattern, iStartIndex);

  10:     if (iIndex != -1)

  11:       iStartIndex = iIndex + strPattern.Length;

  12:   }

  13: }

Die Zeitmessungen mit einer unterschiedlichen Anzahl von Iterationen ergab einen dramatischen Performanceeinbruch für das Framework .NET 4.

Anzahl Iterationen .NET 2.0 .NET 4
10 00:00:00.0950380 00:00:11.4375732
100 00:00:00.9233692 00:01:58.5309650
1000 00:00:09.1416552 00:20:59.8498171

Mit Hilfe des Reflectors kann man sehen, dass die Methode System.String.IndexOf bzw. die Methode System.Globalization.CompareInfo.IndexOf (diese wird letztendlich aufgerufen) in der Implementierung eine Veränderung erfahren hat beim Wechsel von .NET 2.0 nach .NET 4. Nutzt man die IndexOf-Methode wie in Zeile 9, dann wird auf Basis der CurrentCulture und CompareOptions.None, die Suche durchgeführt.

Eine Prüfung ergab, dass die Suche nach dem Pattern auch mit der InvariantCulture und mit CompareOptions.Ordinal durchgeführt werden kann, so dass die Zeile 9 in Folgende umgewandelt werden kann

   9: iIndex = strSearchString.IndexOf(strPattern, iStartIndex,StringComparison.Ordinal);

Mit diesem Aufruf der IndexOf-Methode ergaben sich nun folgende gemessene Zeiten:

Anzahl Iterationen .NET 2.0 .NET 4
10 00:00:00.0225000 00:00:00.0170009
100 00:00:00.2380000 00:00:00.2005000
1000 00:00:02.0960000 00:00:02.0290000

Als Fazit kann man zweierlei Dinge ziehen:

  1. Nach einem Framework Wechsel sind bei zeitkritischen Routinen unbedingt Performance-Messungen durchzuführen. Nicht alles was sich unter der Haube verändert hat ist bekannt und kann somit potentiell zu Einbußen bei der Performance führen
  2. String Operationen, ob es nun die IndexOf-Methode oder andere wie Compare usw., sind, sind genau zu analysieren und explizit mit CultureInfo bzw. CompareOptions aufzurufen. Dies zeigt auch die Performance Steigerung bei .NET 2.0 im obigen Beispiel.

Mittlerweile ist diese Problematik Microsoft zur Untersuchung übergeben worden. Eine erste Einschätzung geht von einem Bug im .NET 4 Framework bei der Behandlung von großen Strings aus und es wird an einer Lösung gearbeitet. Diese wird jedoch voraussichtlich noch einige Zeit auf sich warten lassen.