Fluent Bash

 

Das Buch

Mein aktuelles Buch ist Fluent Forever von Gabriel Wyner. Es handelt davon, wie man sich eine Sprache so aneignet, dass man sie innerhalb kurzer Zeit flüssig sprechen kann und dabei die Vokabeln so lernt, dass man sie nicht so leicht vergisst. Er setzt dabei auf das Spaced Repetition System, eine Ausprägung des alten Karteikartensystems mit angepassten Abfragezeiten.

Seine favorisierte digitale Version des Karteikastens hört auf den Namen Anki und ist für Windows, Mac, Linux, Android sowie iOS verfügbar. Beim Ausprobieren der Software kam mir der Gedanke – was wenn man etwas anderes damit lernt als Vokabeln und Grammatiken?

Hotkeys

Noch heute kenne ich ein paar der alten WordStar-Kommandos von Turbo Pascal. Ebenso ein paar Cheats aus Doom. Nur mit der Vielfalt der Vi-Kombinationen tue ich mich noch schwer. Sicher, man kann sich für jede Woche ein paar für den Einsatz vornehmen – letztendlich hat man es aber wieder einmal eilig, und ein paar mal h drücken ist doch fast genauso gut wie jetzt b nachzuschlagen…

Die Bash

Ein nicht ganz so großes Unterfangen zum Testen stellt die Bash dar. Schon lange stört es mich, mit dem Cursor die Zeile zeichenweise nach links bzw nach rechts zu wandern, eines der zuletzt benutzten Kommandos mit Hilfe von Pfeil-hoch suchen zu müssen usw., obwohl ich doch weiß, dass es auch besser geht.
Gesagt, getan. Von einer beliebigen Seite habe ich mir die Hotkeys der Bash abgetippt in entsprechende Karteikarten von Anki.

Anki

Die Besonderheit gegenüber den Vokabeln liegt nun natürlich darin, dass man sich keine tollen Fotos mit Aussprache hinterlegt wie in der von Wyner beschriebenen Variante (wobei ich das bei manchen nicht ausschließen möchte…). Statt dessen macht man sich eine Bash auf und führt das abgefragte Kommando aus:

Bash

 

Fazit

Ob diese Vorgehensweise wirklich zum Beherrschen der Hotkeys aller von mir benutzten Programme führt, kann ich nicht sagen. Aber die 20 Tastenkombinationen der Bash sind in 2 Minuten abgefragt. Diese Zeit nimmt man sich eher als immer mal wieder nach genau diesem einen Befehl zu suchen, den man jetzt gerade braucht.
Und selbst wenn es bei Vi 5 Minuten sein mögen – durch die Kombination von Wiederholung und Anwendung des gerade Gelernten sollte man seinem (hoffentlich) gesteckten Ziel näher kommen: know your tools!

Veröffentlicht unter Bash, Build-Tools, Cygwin | Hinterlasse einen Kommentar

Select Text on Focus

 

Das Problem

Bei Bildschirmmasken möchte man gerne das Standardverhalten zurück, dass der Text beim Durchwandern der Eingabezeilen per TAB selektiert wird. WPF setzt per Default lediglich den Cursor an die erste Zeile.

Standard

Beim Suchen im Netz stößt man meist auf die Formel

protected override void OnStartup(StartupEventArgs e) 
{ 
  EventManager.RegisterClassHandler(typeof(TextBox)
   , TextBox.GotFocusEvent
   , new RoutedEventHandler(TextBox_GotFocus)); 
  base.OnStartup(e); 
} 

void TextBox_GotFocus(object sender, RoutedEventArgs e) 
{ 
  var tb = sender as TextBox; 
  if (tb == null) return; 
  tb.Focus(); tb.SelectAll(); 
}

Das funktioniert auch auf Anhieb – nur leider eben global und somit überall.

 

Als Behavior

Eher im Sinne der WPF wäre ein Behavior für Textboxen. Das liest sich analog

public class SelectTextOnFocus :  Behavior<TextBox>
{
  protected override void OnAttached()
  {
    base.OnAttached();
    AssociatedObject.GotFocus += OnFocus;
  }

  protected override void OnDetaching()
  {
    AssociatedObject.GotFocus -= OnFocus;
    base.OnDetaching();
  }

  void OnFocus(object sender, RoutedEventArgs e)
  {
    AssociatedObject.SelectAll();
  }
}

Der Vorteil liegt auf der Hand: man kann die Verhaltensweise auf diejenigen Textboxen einschränken, bei denen man dies auch haben möchte.

Selected

 

Der Nachteil ist, dass Behaviors Teil des Blend SDKs sind und waren. Nun ist Blend 4 als Teil des ursprünglichen Expression Blend in Visual Studio aufgegangen. Welches aber ist der Nachfolger?
Es gibt verschiedene inoffizielle, aber auch von Microsoft herausgegebene NuGet-Pakete. Expression.Blend.SDK scheint das kompletteste zu sein, mit verschiedenen Plattformen und .NET Versionen. Aber auch das Paket System.Windows.Interactivity v4.0 for WPF versieht klaglos seinen Dienst.

Der XAML-Code der zugehörigen View liest sich wie folgt:

<Window
    x:Class="SelectOnFocus.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"
    xmlns:local="clr-namespace:SelectOnFocus"
    Title="Eingabe"
    Width="525"
    Height="350"
    FocusManager.FocusedElement="{Binding ElementName=boxName}">
    
    <StackPanel>

        <Label Content="_Name" Target="{Binding ElementName=boxName}" />
        <TextBox x:Name="boxName" Text="Donald Duck">
            <i:Interaction.Behaviors>
                <local:SelectTextOnFocus/>
            </i:Interaction.Behaviors>
        </TextBox>

        <Label Content="_Beruf" Target="{Binding ElementName=boxBeruf}" />
        <TextBox x:Name="boxBeruf" Text="Lebemann" />

        <Label Content="Stadt" Target="{Binding ElementName=boxStadt}" />
        <TextBox x:Name="boxStadt" Text="Entenhausen">
            <i:Interaction.Behaviors>
                <local:SelectTextOnFocus/>
            </i:Interaction.Behaviors>
        </TextBox>

    </StackPanel>
</Window>

Die komplette Solution kann im GitHub abgerufen werden.

DotNetKicks-DE Image
Veröffentlicht unter .NET, C#, WPF | Verschlagwortet mit , , | Hinterlasse einen Kommentar

Tap-Through-Verhalten mit WPF

 

Das Problem

In der WPF scheint es eine kleine Inkonsistenz bezüglich des Verhaltens verdeckter Komponenten zu geben: überdeckt ein heruntergeklapptes Menü ein anderes Control, z.B. einen Button, so kann man ihn trotzdem anklicken.

MainMenu

Klickt man an dieser Stelle den Change-Background-Knopf, wird sich der Hintergrund ändern.

Etwas anders reagiert die Oberfläche, wenn die Auswahl einer Combobox den Button überdeckt:

ComboBox

Wenn man hier den Button drückt, wird sich die Combobox schließen, es ist ein erneuter Klick notwendig, um eine Reaktion zu erzielen.

 

Ansätze

Ein kurzer Blick in die ComboBox.cs offenbart das Problem: das Öffnen der Selektionsbox fängt die Maus mittels Mouse.Capture. Beim erneuten Click wird die Box geschlossen und das Event wird als behandelt markiert, die Maus wieder freigegeben.

Was tun? Ein erster Ansatz wäre natürlich eine eigene Variante der Combobox, die dieses Verhalten irgendwie unterbindet. Was nicht gerade dem Ansatz der WPF entspricht: es wäre ein Control, was genau das selbe macht wie ein anderes, und sich nur in einem bestimmten Verhalten unterscheidet. An dieser Stelle sollte es *Klick* machen: Behaviors gab es doch an anderer Stelle schon einmal, nämlich in Microsoft Expression Blend. Sie werden an Controls attached, um ihr Verhalten zu beeinflussen. Das sollte also der richtige Weg sein.

 

Umsetzung

Nach dem der Weg klar ist, ist die Hülle schnell geschrieben. Im Xaml die entsprechende Combobox mit dem Verhalten ausstatten

<Window
    x:Class="TapThroughPopup.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
    xmlns:local="clr-namespace:TapThroughPopup"
...
<ComboBox Width="200" HorizontalAlignment="Left" SelectedIndex="0">
  <i:Interaction.Behaviors>
    <local:TapThroughBehavior/>
  </i:Interaction.Behaviors>
<ComboBoxItem Content="Element 1" />
...

und die Hülle dafür schreiben

namespace TapThroughPopup
{
    class TapThroughBehavior : Behavior<ComboBox>
    {
        protected override void OnAttached()
        {
        }
    }
}

Aber was genau gilt es nun zu tun? Mein erster Versuch war, das Mausevent abzufangen, es zu Verdoppeln und dem Dialog erneut zur Verarbeitung anzubieten.

Zum Beispiel:

protected override void OnAttached() { AssociatedObject.PreviewMouseDown += PreviewMouseDown; } void PreviewMouseDown(object sender, MouseButtonEventArgs e) { if (!AssociatedObject.IsDropDownOpen) return; var parent = AssociatedObject.Parent as UIElement; if (parent == null) return; parent.RaiseEvent(new RoutedEventArgs(Button.ClickEvent, this)); }

Aber Button.ClickEvent spielte ja nur darauf ab, daß ich im Beispiel einen Button klicken wollte. Sinnvoller wäre ein neues MouseButtonDown-Event (was mir je nach Klick in den Dialog eine Exception einbrachte).

Ein kurzes Innehalten – kann das so überhaupt funktionieren? Die Maus ist ja immer noch gecaptured, jedes Event, das ich generiere, müsste immer noch von der Combobox abgefangen werden.

 

Lösung

Letztendlich war der entscheidende Hinweis, daß man aus dem Behavior die Maus wieder freigeben kann, und so das ursprüngliche Event durchgereicht werden kann. Was mich vom zeitlichen Ablauf der Ereignis-Kette immer noch verwundert. Es scheint daran zu liegen, daß die PreviewMouseDownEvents innerhalb des Element-Trees zunächst getunnelt werden, in diesem Ablauf die Freigabe der Maus erfolgt, und erst dann die MouseDownEvents wieder nach oben bubbeln, wodurch der Click weitergereicht werden kann.

Bleibt noch das Problem zu erkennen, wann die Maus außerhalb des Bereiches der Combobox geklickt wird. Man könnte die entsprechende Funktionalität vielleicht in obigen PreviewMouseDown-Handler stecken, müsste dann aber nachsehen, ob überhaupt das DropDown geöffnet wurde, ob die Maus außerhalb des erlaubten Bereichs war… es sollte doch einen einfacheren Weg geben. Den gibt es, er trägt den unscheinbaren Namen Mouse.AddPreviewMouseDownOutsideCapturedElementHandler. Danke an dieser Stelle an den entsprechenden Eintrag in StackOverflow.

Wie so oft: wenn man erst mal weiß, wie es geht, ist es einfach. Am Ende übrig bleibt ein Zweizeiler:

using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interactivity;

namespace TapThroughPopup
{
    class TapThroughBehavior : Behavior<ComboBox>
    {
        protected override void OnAttached()
        {
            Mouse.AddPreviewMouseDownOutsideCapturedElementHandler(AssociatedObject, MouseDownOutsideCapturedElement);
        }

        void MouseDownOutsideCapturedElement(object sender, MouseButtonEventArgs e)
        {
            AssociatedObject.ReleaseMouseCapture();
        }
    }
}
DotNetKicks-DE Image
Veröffentlicht unter .NET, C#, WPF | Hinterlasse einen Kommentar

Drag & Drop mit Caliburn.Micro

 

Prolog

Auf der Suche nach einer Möglichkeit, Zeilen innerhalb eines DataGrids per Drag&Drop zu verschieben, bin ich meistens auf Implementierungen gestoßen, die auf MouseUp- bzw MouseDown-Events reagieren. In dem Fall muss man sich natürlich um die ausgewählten Elemente selbst kümmern, die Selektion zwischenspeichern… sollte das WPF nicht mittlerweile selbst können? Und wie spricht man diese Funktionalität nun unter Caliburn.Micro an?

 

Die Spielwiese

Das Datenmodell bildet eine einfache Klasse GridItem, die Elemente werden in GridItems zu einer Liste zusammengefaßt und mit rudimentären Speicher- und Ladefunktionen versehen.

Um überhaupt eine Form der Reihenfolge zu ermöglichen, werden die Elemente beim Zugriff über ihre Id sortiert:

public class GridItems
{
  List<GridItem> _items;
  public List<GridItem> Items
  {
    get
    {
      if (_items==null ) Load();
      var query = from item in _items orderby item.Id select item;
      return query.ToList();
    }
  }
...

Damit die Elemente im Grid bearbeitet werden können, bildet ein GridItemRowViewModel die Properties des GridItem mit den nötigen Aufrufen von NotifyOfPropertyChange ab. Das Bereitstellen der ItemSource für das DataGrid erfolgt ebenfalls per Linq

public class ShellViewModel : Screen, IShell
{
  readonly GridItems _gridItems = new GridItems();

  ObservableCollection<GridItemRowViewModel> _gridRows;
  public ObservableCollection<GridItemRowViewModel> GridRows
  {
    get
    {
      return _gridRows ??
        (_gridRows = new ObservableCollection<GridItemRowViewModel>(
         _gridItems.Items.Select(x => new GridItemRowViewModel(x))));
    }
  }
...

Die Properties werden eins zu eins im DataGrid abgebildet. Der SelectionMode wird hier auf Single gesetzt, es sollen nur einzelne Zeilen verschoben werden können. Wichtig hier ist natürlich, AllowDrop auf True zu setzen.

<DataGrid Name="DataItemsGrid" Margin="5" AutoGenerateColumns="False"

AllowDrop="True" RowHeight="16" AlternationCount="2" AlternatingRowBackground="#0F000000" CanUserAddRows="false" CanUserResizeRows="False" SelectionMode="Single" RowStyle="{StaticResource RowStyle}" RowHeaderStyle="{StaticResource RowHeaderStyle}" ItemsSource="{Binding Path=GridRows}"> <DataGrid.Columns> <DataGridTextColumn Header="Id" IsReadOnly="True" Binding="{Binding Id}"/> ...

Das Draggen soll in diesem Beispiel mit den beiden ReadOnly-Feldern Id und Name möglich sein. Man spart sich ein zusätzliches Icon-Feld, das nur zum “Anfassen” dient.

 

Kleine Helfer

Bei den verschiedenen Mausklicks muß man auf verschiedene Ebenen innerhalb des VisualTrees zugreifen. Einmal braucht man den Zugriff auf die geklickte Zelle, ein anderes mal die komplette Zeile. Eine generische Suchfunktion bietet sich geradezu an:

static T FindElementFromSource<T>(DependencyObject source) where T: UIElement
{
  var dep = source;
  while ((dep != null) && !(dep is T))
    dep = VisualTreeHelper.GetParent(dep);
  return dep as T;
}

Als kleinen Sonderfall benötigt man noch das ViewModel einer GridRow:

static GridItemRowViewModel GetViewModelOfEvent(DependencyObject source)
{
  var row = FindElementFromSource<DataGridRow>(source);
  if (row == null) return null;

  return row.DataContext as GridItemRowViewModel;
}

Wenn wir uns den theoretischen Code eines beliebigen DragEvents anschauen, fällt einem direkt die stetige Null-Überprüfung ins Auge:

public void OnDragXXX(DragEventArgs e)
{
  var dataObject = e.Data as DataObject;
  if (dataObject == null) return;

  var draggedItem = dataObject.GetData(typeof (DraggedGridItem)) as DraggedGridItem;
  if (draggedItem == null) return;

  var row = FindElementFromSource<DataGridRow>(source);
  if (row == null) return;

  ... // Do the action

Es kristallisieren sich 3 Grundfunktionalitäten heraus: es soll etwas mit dem im Event übermittelten DraggedGridItem geschehen, dadurch wird etwas in der Zeile geändert oder im zugrunde liegenden ViewModel der Zeile. Verknüpft man das mit anonymen Methoden, genügen die entsprechenden Funktionen:

static void WithDragItemDo(DragEventArgs e, Action<DraggedGridItem> action)
{
    var dataObject = e.Data as DataObject;
    if (dataObject == null) return;

    var draggedItem = dataObject.GetData(typeof (DraggedGridItem)) as DraggedGridItem;
    if (draggedItem == null) return;

    action(draggedItem);
}

static void WithDragItemsDo(DragEventArgs e, Action<DraggedGridItems> action)
{
    var dataObject = e.Data as DataObject;
    if (dataObject == null) return;

    var draggedItems = dataObject.GetData(typeof (DraggedGridItems)) as DraggedGridItems;
    if (draggedItems == null) return;

    action(draggedItems);
}

static void WithRowFromSourceDo(DependencyObject source, Action<DataGridRow> action)
{
    var row = FindElementFromSource<DataGridRow>(source);
    if (row == null) return;

    action(row);
}

 

Ziehen und Fallenlassen

Mit diesen Hilfsfunktionen besteht das Draggen nur noch aus der Visualisierung der betroffenen Zeilen. In diesem Fall soll lediglich die Hintergrundfarbe geändert werden. Die alte Hintergrundfarbe wird hierbei in den Event-Parametern selbst gespeichert.

public void OnDragEnter(DragEventArgs e)
{
  WithDragItemDo(e, di=> WithRowFromSourceDo((DependencyObject)e.OriginalSource, row=>
  {
    di.OldBackground = row.Background;
    row.Background = Brushes.Aquamarine;
  }));
}

public void OnDragLeave(DragEventArgs e)
{
  WithDragItemDo(e, di => WithRowFromSourceDo((DependencyObject)e.OriginalSource, row =>
  {
    if (di.OldBackground != null)
      row.Background = di.OldBackground;
   }));
}

Beim Fallenlassen wird der neue Index des Elements berechnet. Sofern er sich geändert hat, wird die gesamte Liste neu durchnummeriert:

public void OnDragDrop(DragEventArgs e)
{
  OnDragLeave(e);
  WithDragItemDo(e, di=> WithViewModelFromSourceDo((DependencyObject)e.OriginalSource, vm =>
  {
    var newIndex = GridRows.IndexOf(vm);
    if (newIndex == di.Index) return;

    GridRows.RemoveAt(di.Index);
    GridRows.Insert(newIndex, di.ViewModel);
    for (var i = 0; i < GridRows.Count; i++)
      GridRows[i].Id = i;
    }));
}

 

View-Events

Bislang weiß die View natürlich noch nichts von diesen Event-Methoden. Caliburn.Miro muß noch angewiesen werden, die entsprechenden Aktionen an das ViewModel weiterzuleiten. Hierfür wird eine Referenz auf System.Windows.Interactivity benötigt, hierin werden die Interaction-Trigger bereitgestellt:

<Window x:Class="DataGridDragDrop.Views.ShellView"

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

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

xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"

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

 

<DataGrid Name="DataItemsGrid" ...> <i:Interaction.Triggers> <i:EventTrigger EventName="PreviewMouseLeftButtonDown"> <cal:ActionMessage MethodName="OnMouseDown"> <cal:Parameter Value="$eventargs" /> </cal:ActionMessage> </i:EventTrigger> <i:EventTrigger EventName="DragEnter"> <cal:ActionMessage MethodName="OnDragEnter"> <cal:Parameter Value="$eventargs"/> </cal:ActionMessage> </i:EventTrigger> <i:EventTrigger EventName="DragLeave"> <cal:ActionMessage MethodName="OnDragLeave"> <cal:Parameter Value="$eventargs" /> </cal:ActionMessage> </i:EventTrigger> <i:EventTrigger EventName="PreviewDrop"> <cal:ActionMessage MethodName="OnDragDrop"> <cal:Parameter Value="$eventargs"/> </cal:ActionMessage> </i:EventTrigger> </i:Interaction.Triggers> <DataGrid.Columns> …

Ein kurzes Durchzählen der hier aufgeführten Events bestätigt, daß noch ein Event-Handler fehlt. Das Initialisieren des Drag&Drop-Effekts geschieht nach wie vor durch den MouseDown-Event. Je nach dem, ob das Klicken auf einer schreibgeschützten Zelle stattfand, wird entweder das Draggen initialisiert oder direkt in den Bearbeitungsmodus der Zelle gewechselt:

public void OnMouseDown(MouseButtonEventArgs e)
{
  var dataGrid = (DataGrid) e.Source;
  if (dataGrid == null) return;

  var cell = FindElementFromSource<DataGridCell>((DependencyObject) e.OriginalSource);
  if (cell == null) return;

  if (!cell.Column.IsReadOnly)
  {
    cell.Focus();
    dataGrid.BeginEdit();
    return;
  }

  var viewModel = GetViewModelOfEvent((DependencyObject) e.OriginalSource);
  var index = GridRows.IndexOf(viewModel);

  var draggedElement = new DraggedGridItem {ViewModel = viewModel, Index = index};
  var dataObject = new DataObject(typeof (DraggedGridItem), draggedElement);
  DragDrop.DoDragDrop(dataGrid, dataObject, DragDropEffects.Move);
}

Multiselection

Mit oben genannter Lösung lassen sich leicht einzelne Elemente in ihrer Reihenfolge sortieren. Setzt man den SelectionMode des DataGrids jedoch auf Extended, funktioniert der Ansatz nicht mehr. Natürlich kann man sich im Datenobjekt des DragEvents die Liste der selektierten Einträge merken. Das Problem ist vielmehr, daß nun bei jedem Mausklick in das Grid jede selektierte Zeile in eine Drag-Aktion verpackt wird, die sofort als Drop interpretiert wird – ein richtiges Ziehen und Loslassen erfolgt in diesem Fall nicht mehr.

Wer an dieser Stelle weiter experimentieren möchte: das Projekt ist unter https://github.com/Slesa/Playground/tree/master/sketches/Caliburn.Micro/DataGridDragDrop abrufbar.

DotNetKicks-DE Image
Veröffentlicht unter .NET, C#, Caliburn.Micro, WPF | Hinterlasse einen Kommentar

Ein eigenes Setup mit WiX

 

Prolog

Im letzten Artikel Besser Bauen mit FAKE wurde darauf hingewiesen, dass die in FAKE integrierten Möglichkeiten zum Bauen eines Setups begrenzt sind. Statt dessen wurde durch das Build-Skript MSBuild mit einem WiX-Projekt als Target angestoßen. Um den Aufbau dieses Installer-Projekts geht es in diesem Post.

Nach Download und Installation des Windows Installer XML Toolkits wird ein neues Setup-Projekt angelegt. Da die Projektumgebung alle installierbaren Pakete enthält, genügt ein einzelnes Projekt im Verzeichnis Setup. Gleichzeitig ergibt sich daraus schon der prinzipielle Aufbau des Installers: einzelne Komponenten sollen abwählbar sein, der Installer selbst soll mehrsprachig sein und die Versionsnummer des Buildsystems soll berücksichtigt werden.

 

Der Projektrahmen

Durch den Projekt-Wizard wurde bereits das grundlegende XML-Gerüst angelegt. Wir erweitern die Informationen um die Versionsnummer sowie die Sprachunterstützung:

<Wix xmlns=http://schemas.microsoft.com/wix/2006/wi

xmlns:util="http://schemas.microsoft.com/wix/UtilExtension"> <!-- Produkt-Definition --> <Product Id="85d37c9c-685c-41b7-a996-e2c6d92031b6" UpgradeCode="f6f2968f-d114-44b9-874e-4f32ffe64427" Manufacturer="Slesa Solutions" Name="!(loc.ProductName)" Language="!(loc.Language)" Version="$(var.ProductVersion)" Codepage="utf-8">

Die gezeigten GUIDs sollten selbstverständlich durch eigens generierte ersetzt werden.

Es folgen die Beschreibung des Pakets wie dessen Systemvoraussetzungen…

    <!-- Package-Definition -->
    <Package InstallerVersion="200" 
             Manufacturer="Slesa Solutions"
             Compressed="yes"
             Description="!(loc.ProductDescr)"
             Comments="(c) 2011 Slesa Solutions"
             InstallPrivileges="elevated"
             />

    <!-- Requirements -->
    <PropertyRef Id="NETFRAMEWORK40FULL" />
    <Condition Message="!(loc.ErrorFramework)">NETFRAMEWORK40FULL</Condition>
    <Condition Message="!(loc.ErrorAdminRights)">Privileged</Condition>

… das Installer-Paket soll innerhalb einer Cabinet-Datei abgelegt werden…

    <Media Id="1" Cabinet="media1.cab" EmbedCab="yes" />

… und die Installation soll unterhalb des Programme-Verzeichnisses erfolgen

    <Directory Id="TARGETDIR" Name="SourceDir">
      <Directory Id="ProgramFilesFolder">
        <Directory Id="APPLICATIONFOLDER" Name="Poseidon" />
      </Directory>
      <Directory Id="DesktopFolder"/>
    </Directory>

Falls später noch Verknüpfungen auf dem Desktop abgelegt werden sollen, sollten die dort hinterlegten Icons an dieser Stelle definiert werden. Sie verweisen auf Bitmaps im Resources-Verzeichnis, das ebenfalls zum Projekt gehört:

    <Icon Id="I__BackOfficeIcon.exe" SourceFile="Resources\Poseidon.ico" />
    <Property Id="PRODUCTICON" Value="I__BackOfficeIcon.exe"/>

 

Die Komponenten

Mit dem Backoffice-Projekt im Hinterkopf können wir jetzt die installierbaren Komponenten auflisten. Die Dateien beziehen wir dabei aus dem Ziel-Ordner des Build-Skripts:

    <DirectoryRef Id="APPLICATIONFOLDER" FileSource="..\..\bin\build">

Wir beginnen mit den unverzichtbaren Dateien als Shared Components

      <Component Id="C__SharedComponents" Guid="b81ad615-4091-49a9-ab09-d0da690bcb04" DiskId="1">
        <File Id="F__CaliburnMicro" Name="Caliburn.Micro.dll" />
        <File Id="F__FLuentNHibernate" Name="FluentNHibernate.dll" />
        <File Id="F__IesiCollections" Name="Iesi.Collections.dll" />
        <File Id="F__NHibernate" Name="NHibernate.dll" />
        <File Id="F__Interactivity" Name="System.Windows.Interactivity.dll" />
      </Component>

Es folgt das Backoffice-Programm selbst, dass die übrigen Module nachlädt. Hier ist auch der oben erwähnte Shortcut auf den Desktop

<Component Id="C__BackOffice" Guid="{C46D1942-3C5C-4edf-8E06-36D48DFD8892}" DiskId="1"> <File Id="F__BackOffice" Name="BackOffice.exe" KeyPath="yes" />

<File Id="F__BackOfficeCfg" Name="BackOffice.exe.config" /> <File Id="F__BackOfficeContract" Name="BackOffice.Contracts.dll" /> <File Id="F__BackOfficeTheme" Name="BackOffice.Theme.dll" /> <File Id="F__ModelShared" Name="Model.Shared.dll" /> <File Id="F__PersistenceShared" Name="Persistence.Shared.dll" /> <Shortcut Id="S__BackOffice" Directory ="DesktopFolder" WorkingDirectory="APPLICATIONFOLDER" Name="Poseidon BackOffice" Icon="I__BackOfficeIcon.exe" Advertise="yes" Description="!(loc.BackOfficeDescription)" /> </Component>

und die drei Office-Module – Benutzer-, POS- sowie die Bestandsverwaltung

      <Component Id="C__BackOfficeIcs" Guid="{613F262E-156E-49FD-82F7-31F659F1511C}">
        <File Id="F__IcsModel" Name="Ics.Model.dll" />
        <File Id="F__IcsNHibernate" Name="Ics.NHibernate.dll" />
        <!-- File Id="F__IcsOfficeModule" Name="Ics.OfficeModule.dll" / -->
        <File Id="F__IcsResources" Name="Ics.Resources.dll" />
      </Component>
      
      <Component Id="C__BackOfficeUms" Guid="{A8A43C44-526D-487F-9319-855EA5BCF725}">
        <File Id="F__UmsModel" Name="Ums.Model.dll" />
        <File Id="F__UmsNHibernate" Name="Ums.NHibernate.dll" />
        <File Id="F__UmsOfficeModule" Name="Ums.OfficeModule.dll" />
        <File Id="F__UmsResources" Name="Ums.Resources.dll" />
      </Component>

      <Component Id="C__BackOfficePms" Guid="{BF7884AB-D67D-4773-B69B-6CFD563727B2}">
        <File Id="F__PmsModel" Name="Pms.Model.dll" />
        <File Id="F__PmsNHibernate" Name="Pms.NHibernate.dll" />
        <File Id="F__PmsOfficeModule" Name="Pms.OfficeModule.dll" />
        <File Id="F__PmsResources" Name="Pms.Resources.dll" />
      </Component>

    </DirectoryRef>

Es sei noch einmal darauf hingewiesen, daß es sich hierbei nur um die Auflistung der Dateien handelt. Der Installationsumfang wird noch einmal gesondert als Features angegeben.

 

Die Features

Nachdem die Komponenten und die darin enthaltenen Dateien definiert wurden, können sie von den Features referenziert werden. Zu beachten ist, dass hier bereits ein Abhängigkeitsbaum angegeben wird – ohne Office-Komponente auch keine Office-Module:

    <Feature Id="SharedFiles" Level="1" InstallDefault="local" 
             Absent="disallow" AllowAdvertise="no" TypicalDefault="install"
             Title="!(loc.SharedFeatureTitle)"
             ConfigurableDirectory="APPLICATIONFOLDER"
             Description="!(loc.SharedFeatureDescription)">
      <ComponentRef Id="C__SharedComponents" />
    </Feature>

    <Feature Id="BackOffice" Level="1" Display="expand" 
             InstallDefault="local" AllowAdvertise="no"
             Title="!(loc.BackOfficeFeatureTitle)"
             Description="!(loc.BackOfficeFeatureDescription)">
      <ComponentRef Id="C__BackOffice" />

      <Feature Id="BackOfficeUms" Level="1" InstallDefault="local" AllowAdvertise="no"
             Title="!(loc.BackOfficeUmsFeatureTitle)"
             Description="!(loc.BackOfficeUmsFeatureDescription)">
        <ComponentRef Id="C__BackOfficeUms" />
      </Feature>
      <Feature Id="BackOfficePms" Level="1" InstallDefault="local" AllowAdvertise="no"
             Title="!(loc.BackOfficePmsFeatureTitle)"
             Description="!(loc.BackOfficePmsFeatureDescription)">
        <ComponentRef Id="C__BackOfficePms" />
      </Feature>
      <Feature Id="BackOfficeIcs" Level="1" InstallDefault="local" AllowAdvertise="no"
             Title="!(loc.BackOfficeIcsFeatureTitle)"
             Description="!(loc.BackOfficeIcsFeatureDescription)">
        <ComponentRef Id="C__BackOfficeIcs" />
      </Feature>
      
    </Feature>

Ab hier sind dem Installer alle Dateien bekannt, es erfolgt lediglich noch die Anpassung der Setup-Oberfläche. Die Installation soll pro Maschine statt pro Benutzer erfolgen, es wird eine abzunickende Lizenz angegeben, die Bitmaps werden ausgetauscht, und die anzuzeigenden Wizard-Seiten werden angegeben:

    <Property Id="ApplicationFolderName" Value="Poseidon" />
    <Property Id="WixAppFolder" Value="WixPerMachineFolder" />

    <WixVariable Id="WixUILicenseRtf" Value="license.rtf"/>
    <WixVariable Id="WixUIDialogBmp" Value="$(sys.SOURCEFILEDIR)\Resources\WixUI_Bitmap.jpg" />
    <WixVariable Id="WixUIBannerBmp" Value="$(sys.SOURCEFILEDIR)\Resources\WixUI_Banner.jpg" />

    <UIRef Id="WixUI_ErrorProgressText" />
    <UIRef Id="WixUI_Advanced" />

  </Product>
</Wix>

 

Die Übersetzung

Ein beherzter Versuch, das Projekt zu kompilieren, schlägt leider fehl. Es wurden bereits Übersetzungs-Platzhalter verwandt, die es zuerst in einer Sprache anzugeben gilt. Wir fügen dem Projekt also noch zwei Wix Localization Files hinzu, hier 1031.wxl und 1033.wxl benannt nach den Windows Locale Codes. In diesen wird die Sprache angegeben, sowie die lokalisierten Strings, z.B.:

<?xml version="1.0" encoding="utf-8"?> <WixLocalization Culture="de-de" xmlns="http://schemas.microsoft.com/wix/2006/localization"> <String Id="Language">1031</String>

<String Id="ProductName">Poseidon</String> <String Id="ProductDescr">Poseidon - eine POS-Umgebung</String>

<String Id="BackOfficeDescription">BackOffice zum Bearbeiten der Stammdaten</String> <String Id="SharedFeatureTitle">Benötigte Dateien</String> <String Id="SharedFeatureDescription">Von den Anwendungen gemeinsam benutze Dateien und Komponenten.</String> <String Id="BackOfficeFeatureTitle">BackOffice</String> <String Id="BackOfficeFeatureDescription">Backoffice zum Bearbeiten der Daten und Beziehungen.</String> <String Id="BackOfficeUmsFeatureTitle">User Management System</String> <String Id="BackOfficeUmsFeatureDescription">Modul zur Verwaltung von Benutzers und Benutzerrechten.</String> <String Id="BackOfficePmsFeatureTitle">POS Management System</String> <String Id="BackOfficePmsFeatureDescription">Modul zur Verwaltung der POS-Umgebung.</String> <String Id="BackOfficeIcsFeatureTitle">Inventory Control System</String> <String Id="BackOfficeIcsFeatureDescription">Modul zur Verwaltung von Bestandszählern.</String> <String Id="ErrorAdminRights">Die Installation von [ProductName] erfordert administrative Privilegien.</String> <String Id="ErrorFramework">Microsoft .NET Framework wurde nicht gefunden. [ProductName] benötigt das .NET Framework 4.0.</String> </WixLocalization>

Mit diesem WiX-Skript steht nun bereits ein recht angenehmer Setup-Mechanismus zur Verfügung, um eigene Programme auf fremde Platten zu bannen. Angenehmer wäre es jedoch, wenn man eigene Grundeinstellungen ebenfalls eingeben könnte. In unserem Fall wäre dies z.B. der Connect-String für die Datenbank.

 

Eigene Optionen

Durch Auswahl einer der UI-Referenzen WixUI_Mondo, WixUI_Advanced oder WixUI_FeatureTree hat man bereits eine kleie Auswahl an Gestaltungsmöglichkeiten für die Oberfläche des Installers. Um jedoch eigene Seiten einzuarbeiten, um etwa einen Pfad eintragen zu lassen, muß der komplette Ablauf der Oberfläche umdefiniert werden.

Hierfür ist es zu allererst notwendig, bereits bestehende Referenzen wieder aus der Product.wxs zu entfernen, da sie sonst doppelt definiert sind. Dies betrifft die Zeilen

<!--<WixVariable Id="WixUILicenseRtf" Value="license.rtf"/>-->
<!--<WixVariable Id="WixUIDialogBmp" Value="$(sys.SOURCEFILEDIR)\Resources\WixUI_Bitmap.jpg" />-->
<!--<WixVariable Id="WixUIBannerBmp" Value="$(sys.SOURCEFILEDIR)\Resources\WixUI_Banner.jpg" />-->

sowie die bisher benutzte Oberfläche

<!--<UIRef Id="WixUI_Mondo" />-->
<!--<UIRef Id="WixUI_Advanced" />-->
<!--<UIRef Id="WixUI_FeatureTree" />-->

Sie werden durch folgende Zeile ersetzt

<UIRef Id="CustomUI_Mondo" />

Und diese Art der Oberfläche definieren wir jetzt.

 

Eine eigene Datenbankseite

Wie beginnen mit dem Layout der eigenen Optionsseite, im vorliegenden Fall der Eingabemöglichkeit des Datenbank-Verbindungsstrings. Hierfür müssen unter WiX die einzelnen Komponenten des Dialogs – ähnlich WinForms – innerhalb des Dialogs positioniert werden. Es gibt auch Vorgaben bezüglich der Fenstergrößen oder der Position der einzelnen Buttons.

Für das Installer-Skript kommen 2 zusätzliche Fragmente hinzu, ich habe die neue Datei dem Anlaß entsprechend DbConnectionDlg.wxs genannt. Der Header bereitet wieder alles für WiX vor

<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">

Jetzt folgt der neue Dialog als erstes Fragment. Die ID des Dialogs wird später als Referenz benötigt, sie sollte also sinngemäß sein. Der Layout wurde dem der übrigen Dialoge angepaßt:

<Fragment>

  <UI Id="CustomUIExtension">

    <Dialog Id="DbConnectionDlg" Width="370" Height="270" Title="!(loc.DbConnectionTitle)">
      <Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="!(loc.WixUINext)">
        <Condition Action="disable"><![CDATA[DBCONNECTION = ""]]></Condition>
        <Condition Action="enable"><![CDATA[DBCONNECTION <> ""]]></Condition>
      </Control>
      <Control Id="Back" Type="PushButton" X="180" Y="243" Width="56" Height="17" Text="!(loc.WixUIBack)" />
      <Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="!(loc.WixUICancel)">
        <Publish Event="SpawnDialog" Value="CancelDlg">1</Publish>
      </Control>

      <Control Id="Description" Type="Text" X="25" Y="23" Width="280" Height="15" Transparent="yes" NoPrefix="yes" Text="!(loc.DbConnectionDescr)" />
      <Control Id="Title" Type="Text" X="15" Y="6" Width="200" Height="15" Transparent="yes" NoPrefix="yes" Text="{\WixUI_Font_Title}!(loc.DbConnectionTitle)" />
      <Control Id="BannerBitmap" Type="Bitmap" X="0" Y="0" Width="370" Height="44" TabSkip="no" Text="!(loc.InstallDirDlgBannerBitmap)" />
      <Control Id="BannerLine" Type="Line" X="0" Y="44" Width="370" Height="0" />
      <Control Id="BottomLine" Type="Line" X="0" Y="234" Width="370" Height="0" />

      <Control Id="LblDbConnection" Type="Text" X="20" Y="60" Width="240" Height="12" NoPrefix="yes" Text="!(loc.DbConnectionStr)" />
      <Control Id="EditDbConnection" Type="Edit" X="20" Y="75" Width="240" Height="18" Property="DBCONNECTION" Indirect="no" />

    </Dialog>
  </UI>

</Fragment>

Man beachte die Beeinflussung der Darstellung des Dialog-Titels durch {\WixUI_Font_Title}!(loc.DbConnectionTitle) und das Verhindern einer leerer Eingabe durch die Conditions des Next-Buttons.

Dieser neue Dialog ist nun natürlich durch nichts mit den anderen verknüpft, es muß also eine Aufruf-Reihenfolge definiert werden. Diese bildet das zweite Fragment. Das UI-Element darin muß die selbe ID bekommen, die wir bereits als neue Oberfläche definiert haben, nämlich CustomUI_Mondo. Daneben sind alle Definitionen zu treffen, die die Standard-Oberflächen uns vormals abgenommen haben:

<Fragment>

  <UI Id="CustomUI_Mondo">
    <TextStyle Id="WixUI_Font_Normal" FaceName="Tahoma" Size="8" />
    <TextStyle Id="WixUI_Font_Bigger" FaceName="Tahoma" Size="12" />
    <TextStyle Id="WixUI_Font_Title" FaceName="Tahoma" Size="9" Bold="yes" />

    <Property Id="DefaultUIFont" Value="WixUI_Font_Normal" />
    <Property Id="WixUI_Mode" Value="Mondo" />

wir definieren unsere benötigte Variable für die Datenbankverbindung

    <Property Id="DBCONNECTION" Value="Server=.\SQLEXPRESS;initial catalog=Poseidon;Integrated Security=SSPI" Secure="yes">
      <RegistrySearch Id="Reg__DbConnection" Key="SOFTWARE\!(loc.ProductName)" Root="HKLM" Type="raw" Name="DbConnection" />
    </Property>

Wir referenzieren die benutzten Dialoge des Installers

     <DialogRef Id="ErrorDlg" />
      <DialogRef Id="LicenseAgreementDlg" />
      <DialogRef Id="FatalError" />
      <DialogRef Id="FilesInUse" />
      <DialogRef Id="MsiRMFilesInUse" />
      <DialogRef Id="PrepareDlg" />
      <DialogRef Id="ProgressDlg" />
      <DialogRef Id="ResumeDlg" />
      <DialogRef Id="UserExit" />

und verknüpfen nun die verschiedenen Dialoge miteinander:

  • der Exit-Dialog beendet den Installer
  • Der Willkommens-Dialog zum Lizenzabkommen
  • Das Lizenzabkommen führt zurück zum Willkommens-Dialog, und weiter zur Auswahl des Installationsortes
  • Die Installationsort-Nachfrage führt zurück zur Lizenz und weiter zu unserem eigenen Dialog. Daneben kann noch ein neuer Dialog zur Verzeichnisauswahl aufgerufen werden
  • Unsere Datenbank-Dialog führt zurück zum Installationsort und weiter zur Paketauswahl
  • Die Paketauswahl führt zurück zur Datenbank-Anbindung und weiter zum Sind-Sie-Sicher-Dialog
  • Der Sind-Sie-Sicher-Dialog führt zurück zum Paketauswahl-Dialog

Das hört sich zwar kompliziert an, läßt sich aber genau so auch hinschreiben

    <Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return" Order="999">1</Publish>

    <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="LicenseAgreementDlg">1</Publish>
      
    <Publish Dialog="LicenseAgreementDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg">1</Publish>
    <Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg">1</Publish>

    <Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg">1</Publish>
    <Publish Dialog="InstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
    <Publish Dialog="InstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2">1</Publish>
    <Publish Dialog="InstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
    <Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="DbConnectionDlg" Order="2">1</Publish>

    <Publish Dialog="DbConnectionDlg" Control="Back" Event="NewDialog" Value="InstallDirDlg">1</Publish>
    <Publish Dialog="DbConnectionDlg" Control="Next" Event="NewDialog" Value="CustomizeDlg">1</Publish>

    <Publish Dialog="CustomizeDlg" Control="Back" Event="NewDialog" Value="DbConnectionDlg">1</Publish>
    <Publish Dialog="CustomizeDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
               
    <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="CustomizeDlg">1</Publish>

Das mag einem als viel Arbeit vorkommen, weil man ja eigentlich “nur den einen Dialog” dazwischen schieben wollte. Man darf aber nicht vergessen, daß sich gleichzeitig auch die komplette Reihenfolge der Dialoge, aber auch das Weglassen der selben auf ziemlich einfachen Weg realisieren läßt.

Der Connection-String zur Datenbank kann wird nun abgefragt – aber wie kommt er zum Programm? Bei unserer ersten Variante des Installers haben wir uns schon die Konfigurationsdatei des Backoffices mitinstallieren lassen. Jetzt können wir die nach der Installation verändern, z.B. den erfragten String einzutragen. Dieser Vorgang gehört zur Komponente C__BackOffice und geschieht nach dem Erstellen des Shortcuts auf dem Desktop:

        <Shortcut ...
        ...>

        <util:XmlFile Id="X__DbConnection" Action="setValue"
                      Permanent="yes"
                      ElementPath="/configuration/appSettings/add[\[]@key='DbConnection'[\]]"
                      File="[#F__BackOfficeCfg]" Name="value"
                      Value="[DBCONNECTION]"  Sequence="1" />


      </Component>

Hier sehen wir übrigens auch den Grund, warum im WiX-Skript der util-Namensbereich registriert wurde.

Nun werden an dieser Stelle noch die Variablen definiert, die wir aus dem Haupt-Skript herausgenommen haben:

  </UI>

  <UIRef Id="WixUI_Common" />

  <WixVariable Id="WixUIDialogBmp" Value="$(sys.SOURCEFILEDIR)\Resources\WixUI_Bitmap.jpg" />
  <WixVariable Id="WixUIBannerBmp" Value="$(sys.SOURCEFILEDIR)\Resources\WixUI_Banner.jpg" />
  <WixVariable Id="WixUILicenseRtf" Value="license.rtf"/>

</Fragment>


</Wix>

3 übersetzbare Strings kamen auch noch hinzu, diese sind auf die beiden Sprachdateien 1033.wxl und 1031.wxl zu verteilen:

<String Id="DbConnectionTitle">Konfiguration der Datenbank-Verbindung</String>
<String Id="DbConnectionDescr">An dieser Stelle können Sie den Verbindungs-String bearbeiten </String>
<String Id="DbConnectionStr">Connection-String:</String>

Nach dem Kompilieren sollte nun ein MSI-Installer zur Verfügung stehen, der nicht nur die Datenbank-Verbindung nachfragt, sondern sie auch in die Konfigurationsdateien der entsprechenden Programme, hier das Backoffice, einträgt.

 

Fazit

Pro Sprache wird nun ein eigenes MSI-File erzeugt. Zwar gibt es auch Mittel und Wege, um alle Installer in ein großes Setup-Packet zu packen, andererseits spart sich so auch etwas Bandbreite.

Meines Erachtens gibt es mit den WiX-Tools keine Ausreden mehr, warum ein Programm per Copy-Befehl installiert werden sollte. Wenn man keine eigenen Konfigurationen vornehmen lassen will, ist mit wenigen Zeilen Code ein Setup gezaubert. Sollen doch noch Werte nachgefragt werden, ist der Aufwand auch nicht sehr viel größer.

Zum Continuous Delivery fehlt nun nur noch das automatische Ausrollen per Ftp, sowie das automatische Erstellen eines Newsletters, in dem die Release-Notes aus der Quellcode-Verwaltung extrahiert werden. Aber das steht wieder in einem ganz anderen Artikel…

DotNetKicks-DE Image
Veröffentlicht unter .NET, Installer, WiX | Ein Kommentar

Besser bauen mit FAKE

Prolog

Mein Projekt “WPF over Data” ist umgezogen. Auf Github wurden unter Poseidon die Assemblies neu konzeptioniert, von Caliburn.Micro wurde die neuste Version eingespielt, damit einhergehend wird nun MEF anstatt Windsor.Castle eingesetzt. Ziel war es, das Projekt später unter einer CI-Umgebung wie TeamCity oder Hudson automatisch bauen zu können.

Voraussetzung hierfür ist ein Build-Tool, das alle Projekte compiliert, die darin enthaltenen Software-Tests aufruft und die verschiedenen Programme zu einer Setup-Routine zusammenpackt. Der erste Blick fällt dabei auf MSBuild, da es schon für die Solutions und Projekte eingesetzt wird. Allerdings ist die verwandte XML-Sprache sehr gesprächig, das behandeln der Dateien in Filesets erscheint umständlich. Es gibt noch andere Möglichkeiten, etwa Ant oder Rake – meine Wahl fiel auf Fake.

 

Alles auf Anfang

Spielt man den Zyklus eines Continuous Integration Servers durch, steht am Anfang das Abrufen der aktuellen Version, es folgt der Aufruf des Build-Scripts. Damit das funktionieren kann, müssen die Build-Tools, also Fake, in die Quellcodeverwaltung eingecheckt werden. Der Aufruf des Builds kann durch eine Batch-Datei simuliert werden. Da viel Trial-And-Error bei der Zusammensetzung des Build-Scripts mitspielt, sollte der Batch so lange aktiv sind, bis der Benutzer abbricht. Hier also die build.cmd:

@echo off

:Build
cls

SET TARGET="Default"

IF NOT [%1]==[] (set TARGET="%1")
  
"tools\Fake\Fake.exe" "build.fsx" "target=%TARGET%"

rem Bail if we're running a TeamCity build.
if defined TEAMCITY_PROJECT_NAME goto Quit

rem Loop the build script.
set CHOICE=nothing
echo (Q)uit, (Enter) runs the build again
set /P CHOICE= 
if /i "%CHOICE%"=="Q" goto :Quit

GOTO Build

:Quit
exit /b %errorlevel%

Zusätzlich prüft das Script, ob es von TeamCity aufgerufen wird und beendet sich nach einmaligem Durchlauf.

 

Bau-Plan

Als nächstes wird das im Build-Batch benutzte Build-Skript build.fsx erstellt. Unter Fake beginnt die Datei immer mit

#I @"tools\Fake"
#r "FakeLib.dll"

open Fake

Es folgen die Eigenschaften des Projekts, in unserem Fall

let projectName = "Poseidon"
let projectSummary = "Poseidon - a POS environment"
let authors = ["J. Preiss"]
let mail = "joerg.preiss@slesa.de"
let homepage = "http://github.com/Slesa/Poseidon"

Diese können zum Generieren der Einträge in der AssemblyInfo.cs benutzt werden. Zur Vereinfachung folgen die Definitionen der Verzeichnisse

let binDir = @".\bin\"
let buildDir = binDir @@ @"build\"
let testDir = binDir @@ @"test\"
let reportDir = binDir @@ @"report\"
let deployDir = binDir @@ @"deploy\"
let packagesDir = binDir @@ @"packages"
let mspecDir = packagesDir @@ "MSpec"
let setupDir = @"Setup\"

Um die verschiedenen Programme mit Daten zu füttern, folgt die Liste der zu verarbeitenden Dateien

let appReferences = !! @"src\**\*.*sproj"
let testReferences = !! @"src\**\*.Specs.*sproj"
let deployReferences = !! @"Setup\**\*.wixproj"

Fake setzt die Versionsnummer auf “LocalBuild”, falls keine angegeben wurde. Das führt mitunter zu Fehlern, da es sich aus Sicht von Visual Studio nicht um eine gültige Versionsnummer handelt. Aus diesem Grund wird eine eigene Versionsnummer generiert, falls das Skript nicht auf einem Build-Server ausgeführt wird

let currentVersion =
  if not isLocalBuild then buildVersion else
  "0.0.0.1"

 

Targets

Nachdem alle benötigten Informationen definiert wurden, können wir nun die zu erledigenden Ziele unseres Skripts angeben.

Zuerst das Löschen der temporären Dateien und Ergebnisse bisheriger Builds:

Target "Clean" (fun _ ->
  CleanDirs [buildDir; testDir; deployDir; reportDir; packagesDir]

  CreateDir mspecDir
  !! (@"src\Domain\packages\Machine.Specifications.*\**\*.*")
    |> CopyTo mspecDir
)

Nun das Target zum Erzeugen der AssemblyInfo.cs mit den Versionsinformationen. Im ursprünglichen Beispiel wurde pro Projekt eine eigene Datei mit allen Informationen angelegt. Hier werden die Daten aufgeteilt, die Versionsnummer wird in die Datei VersionInfo.cs geschrieben. Diese muß als Link zu jedem Projekt hinzugefügt werden. Durch diese Vorgehensweise müssen jedoch die Default-Parameter der AssemblyInfo-Funktion – Guid, ComVisible und CLSCompliant – überschrieben werden, da sie sonst doppelt im Projekt vorkommen.

Target "SetAssemblyInfo" (fun _ ->
  AssemblyInfo
    (fun p ->
    {p with
      CodeLanguage = CSharp;
      Guid = "";
      ComVisible = None;
      CLSCompliant = None;
      AssemblyCompany = "Slesa Solutions";
      AssemblyProduct = "Poseidon";
      AssemblyCopyright = "Copyright ©  2012";
      AssemblyTrademark = "GPL V2";
      AssemblyVersion = currentVersion;
      OutputFileName = @".\src\VersionInfo.cs"})
)

Das Erstellen der Programme und Tests wird an MSBuild delegiert:

Target "BuildApp" (fun _ ->
  MSBuildRelease buildDir "Build" appReferences
    |> Log "AppBuild-Output: "
)

Target "BuildTest" (fun _ ->
  MSBuildDebug testDir "Build" testReferences
    |> Log "TestBuildOutput: "
)

Die Tests sind mit Hilfe von Machine.Specifications  implementiert, der Konsolenrunner von MSpec kann von Fake auf die folgende Weise angesprochen werden:

Target "Test" (fun _ ->
  let mspecTool = mspecDir @@ "mspec-clr4.exe"

  !! (testDir @@ "*.Specs.dll")
    |> MSpec (fun p ->
      {p with
        ToolPath = mspecTool
        HtmlOutputDir = reportDir})
)

Nach den Tests soll das Setup-Paket geschnürt werden. Die in Fake integrierten Funktionen des WiX-Installers dienen mehr dazu, Dateien ähnlich den Projektdateien zu sammeln und zu einem einfachen Setup-Programm zu packen. In unserem Fall genügt das nicht, da ein umfangreicheres Setup-Programm geplant ist. Dort sollen die verschiedenen Module selektiert, sowie generelle Einstellungen wie Connect-Strings konfiguriert werden. Deshalb wird hier wiederum MSBuild bemüht, um ein komplettes WiX-Projekt zu erstellen. In der aktuellen Skript-Variante bedeutet dies, daß WiX auf dem Build-Server installiert sein muß. Geplant ist, das Tool-Verzeichnis ebenfalls an MSBuild zu übergeben, sodaß auch die WiX-Binaries der Quellcodeverwaltung anvertraut werden können.

Ein weiteres Problem stellt die Versionsnummer dar. Das hier benutzte MSBuildReleaseExt existiert im Original-Fake nicht. Ich habe es wie folgt implementiert

let MSBuildReleaseExt outputPath properties targets = 
    let properties = ("Configuration", "Release") :: properties; 
    MSBuild outputPath targets properties

Vielleicht findet sich ja noch eine elegantere Möglichkeit, MSBuild weitere Parameter zu übergeben. Das Setup-Target sieht nunmehr so aus:

Target "Deploy" (fun _ ->
  MSBuildReleaseExt deployDir ["Version", currentVersion] "Build" deployReferences
    |> Log "DeployBuildOutput: "
)

Es fehlt noch das Default-Target, das einfach nichts macht:

Target "Default" DoNothing

 

Und ab dafür

Jetzt müssen noch die Abhängigkeiten der verschiedenen Targets definiert werden. Nach dem Aufräumen werden die Assembly-Infos erzeugt, die Programme und Tests werden erzeugt, die Tests werden aufgerufen und das Setup erzeugt. Falls es an irgendeiner Stelle ein Problem gab, bricht der Build-Vorgang ab. Oder anders ausgedrückt:

// Dependencies
"Clean"
  ==> "SetAssemblyInfo"
  ==> "BuildApp" <=> "BuildTest"
  ==> "Test"
  ==> "Deploy"
  ==> "Default"

Es fehlt noch der Aufruf des eigentlichen Builds

RunParameterTargetOrDefault "target" "Default"

 

Fazit

In meinen Augen beschränkt sich Fake beim Anlegen des Build-Skripts auf das wesentliche. Im Gegensatz dazu stehen die XML-basierten Tools, die durch ihre Syntax sehr viel Overhead erzeugen. Neben Fake stehen natürlich noch weitere Alternativen zur Verfügung, etwa Rake oder PSake. In jedem Fall sollte das Erstellen der Software genau so einfach sein: auschecken, Doppelklick auf den Build-Batch – fertig.

Veröffentlicht unter .NET, Build-Tools, F# | 2 Kommentare

WPF Over Data mit Caliburn.Micro – die Daten

 

Prolog

Als erstes gilt es an dieser Stelle eine Entschuldigung auszusprechen: im letzten Beitrag habe ich die ArrayDependencyFacility sowie den ArraySubDependencyResolver benutzt, ohne die Angabe zu machen, dass die Quellen aus der Feder von Alexander Groß stammen. Mea culpa und Asche auf mein Haupt.

Um es wieder gut zu machen, stammen viele der folgenden Vorgehensweisen, Methoden und Klassen auch von ihm.

 

Gesteckte Ziele

Was soll erreicht werden? Wir haben Daten. Meistens. Wie im ersten Teil schon erwähnt, soll mittels NHibernate darauf zugegriffen werden. Um der Konfiguration durch XML-Dateien zu entgehen, wird für das Mapping FluentNHibernate eingesetzt. Es sollte allerdings kein statisches Mapping sein, sondern die Module sollen ihre Mappings selbst mitbringen. Die Konfiguration der Datenverbindung soll auch irgendwie erfolgen. Tests sind nötig, sie sollten gegen SQLite gefahren werden (Speicher-Datenbanken), das Programm selbst wird durch einen Connection-String konfiguriert. Man bräuchte auch eine allgemeine Möglichkeit, mir der Datenbank zu sprechen, CRUD-Befehle abzusetzen – womöglich innerhalb von Transaktionen – aber auch Queries werden benötigt.

Eine lange Liste… fangen wir einfach mit dem an, was wir immer speichern wollen: einen Teil unserer Domäne.

 

Eine Domänen-Entität

Ungeachtet vieler Diskussionen, ob die ID eines Datenbank-Objektes nun besser durch eine GUID oder einen Integer abgebildet werden sollte, ist der allgemeine Konsens doch der, dass die ID selbst nichts mit der Businesslogik zu tun haben sollte. Die DomainEntity stellt sie in unserem Fall als int zur Verfügung.

Ebenfalls wird im Allgemeinen als unschön angesehen, wenn eine Änderung eine andere überschreibt. Hierfür bzw hiergegen wird ein Versionsfeld eingesetzt.

Damit wir 2 Entities miteinander vergleichen können, implementieren wir noch IEquatable und mappen im wesentlichen die Gleichheit auf die Gleichheit der Ids.

Insgesamt sieht unsere Basisklasse also so aus:

public class DomainEntity : IEquatable<DomainEntity>
{
  int _id;

  protected DomainEntity()
  {
  }

  protected DomainEntity(int id)
  {    _id = id;
  }

  public virtual int Id
  {
    get { return _id; }
    protected set { _id = value; }
  }

  public virtual int Version { get; protected set; }

  public virtual bool Equals(DomainEntity other)
  {
    if (ReferenceEquals(null, other)) return false;
    if (ReferenceEquals(this, other)) return true;
    return Id.Equals(default(int)) ? base.Equals(other) : other.Id.Equals(Id);
  }

  public override bool Equals(object obj)
  {
    return Equals(obj as DomainEntity);
  }

  public override int GetHashCode()
  {
    return Id.Equals(default(int)) ? base.GetHashCode() : Id.GetHashCode();
  }

  public static bool operator ==(DomainEntity left, DomainEntity right)
  {
    return Equals(left, right);
  }

  public static bool operator !=(DomainEntity left, DomainEntity right)
  {    return !Equals(left, right);
  }
}

Datenbank-Zugriffe

Wir wissen nun also ungefähr, welche Daten wir mit unserer Datenquelle austauschen wollen, aber noch nicht, wie genau das funktionieren soll. Uns fehlt ein Gespräch mit dem Server, also nehmen wir doch genau das als Interface: IDbConversation. Die Gesprächsoptionen sind ja vorgegeben, wir nehmen InsertOnCommit als Create- und Update-Funktion, DeleteOnCommit als Delete-, sowie GetById als Read-Funktion. Zusätzlich untersützen wir Transaktionen und Queries:

public interface IDbConversation : IDisposable
{
  TResult Query<TResult>(IDomainQuery<TResult> query);
  void UsingTransaction(Action action);

  TResult GetById<TResult>(object key);
  void InsertOnCommit(object instance);
  void DeleteOnCommit(object instance);
}

Aber Moment – was ist denn nun ein IDomainQuery? Sicherlich eine Abfrage, die ein irgendwie geartetes Ergebnis liefert, wie sich unschwer am Interface ablesen läßt:

public interface IDomainQuery<TResult>
{
  TResult Execute(ISession session);
}

Nun mappen wir unsere IDbConversation auf unsere NHibernate-Session unter der Annahme, wir hätten eine INHibernateSessionFactory, die uns eine solche Session erzeugen kann:

public class DbConversation : IDbConversation { readonly ISession _session; public DbConversation(INHibernateSessionFactory sessionFactory) { _session = sessionFactory.CreateSession(); } public void Dispose() { if (_session != null) _session.Dispose(); } public TResult Query<TResult>(IDomainQuery<TResult> query) { return query.Execute(_session); } public void UsingTransaction(Action action) { using( var transaction=_session.BeginTransaction()) { try { action(); transaction.Commit(); } catch (Exception) { transaction.Rollback(); throw; } } } public TResult GetById<TResult>(object key) { return _session.Load<TResult>(key); } public void InsertOnCommit(object instance) { _session.SaveOrUpdate(instance); } public void DeleteOnCommit(object instance) { _session.Delete(instance); }

}

Der Code dürfte wenig Raum für Überraschungen lassen, und das Interface INHibernateSessionFactory wurde ja quasi schon verweggenommen:

public interface INHibernateSessionFactory : IDisposable
{
  ISession CreateSession();
}

Zwischenstand: wir haben einen Weg gefunden mit der Datenbank zu kommunizieren, wir haben ein Basis-Objekt, das wir abrufen und abspeichern können. Jetzt fehlt noch die Konfiguration des Datenbankzugriffs.

 

Datenbank-Konfiguration

Was kann es an einer Datenbank-Verbindung schon viel zu konfigurieren geben? Man nehme einen Connection-String, übergebe ihn NHibernate, und fertig. Nun ja, fast fertig. Man könnte zum Beispiel noch ein paar Module mitkonfigurieren wollen, an die man vorher nicht gedacht hat. Aber nur im Debug-Modus. Oder den Aufbau zur Datenbank mitprotokollieren. Und wo kommen eigentlich die Tabellendefinitionen ins Spiel?

Fangen wir mit dem Connection-String an. Die Persistenz sollte konfigurierbar sein, durch eine IPersistenceConfiguration:

public interface IPersistenceConfiguration
{
  IPersistenceConfigurer GetConfiguration();
}

Im Falle eines SQL-Servers könnte die Implementierung der Schnittstelle etwa so aussehen:

public class SqlServerPersistenceConfiguration : IPersistenceConfiguration
{
  readonly string _connectionString;
  public bool ShowSql { get; set; }

  public SqlServerPersistenceConfiguration(string connectionString)
  {
    _connectionString = connectionString;
  }

  public IPersistenceConfigurer GetConfiguration()
  {
    var configuration = MsSqlConfiguration
      .MsSql2005
      .ConnectionString(c => c.Is(_connectionString))
      .ProxyFactoryFactory(typeof(ProxyFactoryFactory).AssemblyQualifiedName)
      .AdoNetBatchSize(10)
      .UseReflectionOptimizer()
      .UseOuterJoin();

    if (ShowSql)
    {
      configuration.ShowSql();
    }

    return configuration;
  }
}

Auf die selbe Art und Weise soll das Persistenzmodell selbst konfigurierbar sein. Der Unterschied ist, dass es im Gegensatz zur Persistenzkonfiguration mehrere Mappings gibt, die es zu berücksichtigen gilt. Nachdem diese allerdings gesammelt worden sind, sollen sie zusammen angewandt werden. Was zu folgendem Interface führt:

public interface INHibernatePersistenceModel
{
  void AddMappings(MappingConfiguration configuration); 
}

und wie oben bereits angesprochen sammelt die Implementierung lediglich alle vorkommenden Mappings:

public IMappingContributor[] MappingContributors
{
  get;
  set;
}

public void AddMappings(MappingConfiguration configuration)
{
  MappingContributors.Each(x => x.Apply(configuration));
}

Ein MappingContributor hat nur eine Aufgabe, nämlich seine eigenen Mappings der aktuellen Konfiguration hinzuzufügen:

public interface IMappingContributor
{
  void Apply(MappingConfiguration configuration);
}

Jetzt wird es allerdings so sein, daß alle weiteren MappingContributors in externen Assemblies hinzugefügt werden. Um nicht alle Mappings – also ClassMap<Type> – einzeln angeben zu müssen, lassen wir sie uns einfach hereinreichen: wir lassen FluentNHibernate selbst danach suchen:

public class FluentMappingFromAssembly : IMappingContributor
{
  readonly Assembly _assembly;

  public FluentMappingFromAssembly(string assembly)
  {
    _assembly = Assembly.LoadFrom(assembly);
  }

  public void Apply(MappingConfiguration configuration)
  {
    configuration.FluentMappings.AddFromAssembly(_assembly);
  }
}

Es fehlt noch der letzte Punkt: wie könnte man mitprotokollieren, wie weit die Konfiguration der Datenbankverbindung fortgeschritten ist? Man könnte die Fortschrittspunkte als Zustände definieren, und für jeden Zustand eine eigene Methode aufrufen. In etwa:

public interface INHibernateInitializationAware
{
  void BeforeInitialization();
  void Configuring(NHibernate.Cfg.Configuration configuration);
  void Configured(NHibernate.Cfg.Configuration configuration);
  void Initialized(NHibernate.Cfg.Configuration configuration, ISessionFactory sessionFactory);
}

Mit Hilfe dieses Interface könnte man den NHProfiler im Debug-Modus aktivieren, sobald die Initialisierung abgeschlossen ist:

public class NhProfilerInitializer : INHibernateInitializationAware
{
  public bool Enabled { get; set; }

  public NhProfilerInitializer()
  {
    EnableProfiler();
  }

  [Conditional("DEBUG")]
  void EnableProfiler()
  {
    Enabled = true;
  }

  public void BeforeInitialization() { }
  public void Configuring(NHibernate.Cfg.Configuration configuration) { }
  public void Configured(NHibernate.Cfg.Configuration configuration) { }

  public void Initialized(NHibernate.Cfg.Configuration configuration, ISessionFactory sessionFactory)
  {
    if (!Enabled)
      return;
    NHibernateProfiler.Initialize();
  }       
}

Mit Hilfe dieser Vorbereitungen können wir nun endlich die Konfiguration der Datenbank selbst angehen. Wir konfigurieren die Datenbankverbindung, fügen die Tabellendefinitionen an, lassen uns im Debug-Modus die Tabelle nach neuester Definition erzeugen, und erhalten als Gegenleistung eine INHibernateSessionFactory, die uns ISessions erzeugen kann:

public class NHibernateSessionFactory : INHibernateSessionFactory
{
  static readonly object InitializationSynchronization = new object();
  readonly IPersistenceConfiguration _persistenceConfiguration;
  readonly INHibernatePersistenceModel _persistenceModel;
  ISessionFactory _sessionFactory;

  public NHibernateSessionFactory(IPersistenceConfiguration persistenceConfiguration,
                                        INHibernatePersistenceModel persistenceModel)
  {    _persistenceConfiguration = persistenceConfiguration;
    _persistenceModel = persistenceModel;
  }

  public INHibernateInitializationAware[] Initializers
  {
    get;
    set;
  }

  ISessionFactory CreateSessionFactory()
  {
    Initializers.Each(x => x.BeforeInitialization());

    var configuration = Fluently.Configure()
                .Database(_persistenceConfiguration.GetConfiguration())
                .Mappings(_persistenceModel.AddMappings);

    configuration.ExposeConfiguration(c => Initializers.Each(x => x.Configuring(c)));

    var actualConfiguration = configuration.BuildConfiguration();
    Initializers.Each(x => x.Configured(actualConfiguration));
    CreateDatabaseWhenDebug(configuration);

    _sessionFactory = configuration.BuildSessionFactory();

    Initializers.Each(x => x.Initialized(actualConfiguration, _sessionFactory));

    return _sessionFactory;
  }

  ~NHibernateSessionFactory()
  {
    Dispose(false);
  }

  protected virtual void Dispose(bool @explicit)
  {
    if (@explicit)
    {
      if (_sessionFactory != null)
      {
        _sessionFactory.Dispose();
        _sessionFactory = null;
      }
    }
  }

  public ISession CreateSession()
  {
    if (_sessionFactory == null)
    {
      lock (InitializationSynchronization)
      {
        if (_sessionFactory == null)
        {
          _sessionFactory = CreateSessionFactory();
        }
      }
    }

    var session = _sessionFactory.OpenSession();
    session.FlushMode = FlushMode.Commit;
    return session;
  }

  [Conditional("DEBUG")]
  static void CreateDatabaseWhenDebug(FluentConfiguration configuration)
  {
    if (configuration == null) throw new ArgumentNullException("configuration");
    configuration.ExposeConfiguration(
    config => new SchemaUpdate(config).Execute(false, true));
  }

  public void Dispose()
  {
     Dispose(true);
     GC.SuppressFinalize(this);
  }

  public void Configure()
  {
    CreateSessionFactory();
  }
}

Die Persistenz-Konfiguration sowie das Persistenz-Modell werden per Construktor-Injektion vom IoC-Container hineingereicht, die optionalen Initialisierer hingegen per Property-Injection. Schön zu sehen an dieser Stelle, wie beim Erzeugen der Session Factory die einzelnen Stadien durchgereicht werden.

 

Und wofür das alles?

Jetzt haben wir diese Unmengen an Code erzeugt, und können noch nicht einmal eine einfache Tabellenabfrage generieren – kann das gut sein?

Fügen wir erst einmal die in der CastleBootstrapper.cs fehlende Funktion hinzu, die im letzten Teil noch auskommentiert blieb:

static IEnumerable<IRegistration> GetPersistenceRegistrations() {  

yield return Component

.For<IPersistenceConfiguration>()

    .ImplementedBy<SqlServerPersistenceConfiguration>()

    .Parameters(Parameter.ForKey("connectionString").Eq(ConfigurationManager.AppSettings["DbConnection"]));  

 

yield return AllTypes   

.FromAssemblyContaining(typeof (IMappingContributor)) .BasedOn(typeof (IMappingContributor)) .WithService.Base(); yield return Component .For<INHibernatePersistenceModel>() .ImplementedBy<NHibernatePersistenceModel>(); yield return AllTypes

    .FromAssemblyContaining(typeof (INHibernateInitializationAware)) .BasedOn(typeof (INHibernateInitializationAware)) .WithService.Base(); yield return Component .For<INHibernateSessionFactory>() .ImplementedBy<NHibernateSessionFactory>(); yield return Component .For<IDbConversation>() .ImplementedBy<DbConversation>() .LifeStyle.Transient; }

Man sieht auf den ersten Blick, wie die einzelnen Module der Datenbankanbindung getreu der Separation of Concerns aufgeteilt wurden. Jede Komponente ist für sich testbar, und das Gesamtpaket ist offen für Erweiterungen.

Bereits oben haben wir gesehen, wie einfach wir das NHibernate-Profiling aktivieren können, ohne die Software ändern zu müssen. Und es stimmt, mangels Tabellendefinitionen können wir nicht einmal eine Tabelle ausgeben. Das soll sich nun ändern.

 

Ein Beispielmodul

Wir fügen jetzt eine neue Tabellendefinition hinzu, ohne dass die bisherigen Projekte davon betroffen sind.

Beginnen wir zB mit einer Benutzerverwaltung für die einzelnen Module des im ersten Teil angedeuteten Backends, dem sogenannten User Management System. Wir fügen im Projekt Lucifer.Ums.Model die beiden Entities User und UserRole hinzu:

public class User : DomainEntity
{
  public virtual string Name { get; set; }
  public virtual UserRole UserRole { get; set; }

}
public class UserRole : DomainEntity
{
  public virtual string Name { get; set; }
}

Da wir wahrscheinlich auch Listenansichten befüllen möchten, erzeugen wir auch die beiden Queries hierfür:

public class AllUsersQuery : IDomainQuery<IEnumerable<User>>
{
  public IEnumerable<User> Execute(ISession session)
  {
    return session.Query<User>();
  }
}
public class AllUserRolesQuery : IDomainQuery<IEnumerable<UserRole>>
{
  public IEnumerable<UserRole> Execute(ISession session)
  {
    return session.Query<UserRole>();
  }
}

Nun machen wir im Projekt Lucifer.Ums.Mapping der Datenbank die beiden Tabellen per FluentNHibernate bekannt:

public class UserMap : ClassMap<User>
{
  public UserMap()
  {
    Id(d => d.Id).GeneratedBy.HiLo("10");
    Map(d => d.Name).Length(40);
    References(d => d.UserRole).Not.Nullable();

    Version(d => d.Version);
  }
}
public class UserRoleMap : ClassMap<UserRole>
{
  public UserRoleMap()
  {
    Id(d => d.Id).GeneratedBy.HiLo("10");
    Map(d => d.Name).Length(40);

    Version(d => d.Version);
  }
}

Und damit der IoC-Container unsere Implementierungen auch findet, registrieren wir unsere MappingContributors:

public class UmsWindsorInstaller : IWindsorInstaller
{
  public void Install(IWindsorContainer container, IConfigurationStore store)
  {    GetRegistrations().ForEach(x => container.Register(x));
  }

  static IEnumerable<IRegistration> GetRegistrations()
  {    yield return Component    .For<IMappingContributor>()
      .ImplementedBy<FluentMappingFromAssembly>()
      .Parameters(Parameter.ForKey("assembly").Eq(typeof(UserRoleMap).Assembly.CodeBase))
      .Named("UmsMappingsFromAssembly");
  }
}

Hier sehen wir den Anwendungsfall des oben erwähnten FluentMappingFromAssembly: FluentNHibernate wird sich durch diese Registrierung die ClassMaps UserMap und UserRoleMap selbst aus Lucifer.Ums.Mapping auflösen.

Und das war schon die Anbindung eines neuen Moduls an die Datenbank. Die geeigneten Initialisierungen vorausgesetzt, könnten wir nun eine Abfrage aller Benutzer in einer Konsole ausgeben:

IDbConversation dbConversation;
dbConversation
  .Query(new AllUsersQuery())
  .Each(x => 
    { 
      System.Console.WriteLine("Id {0}, Name {1}", x.Id, x.Name); 
    });

Nur bräuchte man hierfür kaum diesen Aufwand Smiley

 

Ausblick

Was noch fehlt sind weitere Module, damit die Benutzerverwaltung auch Sinn macht, der Ausbau der Benutzerverwaltung selbst, und endlich auch sichtbare Ergebnisse. So wären Dialoge und Listen etwas für einen nächsten Teil in dieser Reihe.

Wie bereits im letzten Teil erwähnt, ist der Quelltext auf GitHub verfügbar.

DotNetKicks-DE Image
Veröffentlicht unter .NET, C#, NHibernate | 2 Kommentare