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

RubyGems unter Cygwin

 

In dem Buch “Sieben Wochen, sieben Sprachen” von Bruce A. Tate beginnt die Reise bei Ruby. Die mitgelieferten Beispiele lassen sich anfangs unter Cygwin noch relativ zügig mit irb durchspielen. Allerdings möchte man vielleicht auch Katas dazu machen, wie zB die Yellow Belt Ruby Katas auf github.

Diese benötigen RubyGems, was nicht mit Cygwin installierbar ist. Laut der Anleitung von Steve Harman sollte man den Tarball von Ruby Forge laden, ihn entpacken, und in der Bash die beiden Kommandos ausführen:

ruby setup.rb install
gem update –system

Danach können die für die Katas benötigten Pakete installiert werden, zunächst mit

gem install bundle

den Bundler aktivieren, dann mit

bundle install

die Umgebung einrichten. Wenn man anschließend die beiden Katas fib und gcd gelöst hat, sollte in etwa die folgende Ausgabe erscheinen:

$ rake
/usr/bin/ruby.exe -S bundle exec rspec ./spec/fib_spec.rb ./spec/gcd_spec.rb
.......

Finished in 0.001 seconds
7 examples, 0 failures
DotNetKicks-DE Image
Veröffentlicht unter Cygwin, Ruby | Hinterlasse einen Kommentar

Ein lokales Repository für NuGet

 

Das Bereitstellen von Installationspaketen per NuGet ist schon ein echter Gewinn, allerdings muß man ständig online sein. Hier kann das Skript von Jon Galloway helfen, es kopiert das offizielle Repository auf die lokale Festplatte.

Was mir allerdings auffiel: die Konfiguration des Skriptes geschieht per Quelltextänderung. Unschön, wie ich finde. Und das gibt mir eine schöne Gelegenheit, mir einmal die Feinheiten der PowerShell anzuschauen.

 

Kommandozeilen-Parameter

Es ist ziemlich schwierig, alleine mit der PowerShell-Hilfe herauszubekommen wie man Parameter richtig an ein Skript übergibt bzw wie man sie dort ausliest. Zum Glück bin ich auf DevCentral fündig geworden: man gibt als erste Anweisung die bekannten Argumente an, wenn nötig mit ihren Default-Werten:

param(
[int] $top = 500, # use $top = 0 to grab all,
[string] $destination  
)

Unschön ist, daß das mit den Optionswerten, sogenannten Switches, nicht so einfach funktioniert. Der Schalter latest ist per Default true, allerdings kann man die Schalter nur setzen. Es hilft in dem Fall nur die Aussage des Schalters umzudrehen. Aus latest wurde deshalb hier notlatest.

 

Ternärer Operator

Der aus anderen Sprachen wie zB C++ bekannte ?-Operator wurde bei der PowerShell leider weggelassen. Man kann ihn aber leicht nachimplementieren, wie zB auf dieser Seite gezeigt:

function Invoke-Ternary ([bool]
$decider,[scriptblock]$iftrue,[scriptblock]$iffalse)
{
begin {}
process {
if ($decider) { &$iftrue} else { &$iffalse }
}
end {}
}
set-Alias ?: Invoke-Ternary

 

Das komplette Skript

Hinzu kamen noch die Ausgabe der aktuellen Einstellungen, sowie eine kleine Hilfeseite:

# the parameters when converting to funclet
param(
[switch] $help,
[switch] $notlatest,
[switch] $overwrite,
[int] $top = 500, # use $top = 0 to grab all,
[string] $destination  
)

# --- settings ---
$feedUrlBase = "http://go.microsoft.com/fwlink/?LinkID=206669"

# --- locals ---
$webClient = New-Object System.Net.WebClient

# --- helpers ---
# ternary operator
function Invoke-Ternary ([bool]
$decider,[scriptblock]$iftrue,[scriptblock]$iffalse)
{
begin {}
process {
if ($decider) { &$iftrue} else { &$iffalse }
}
end {}
}
set-Alias ?: Invoke-Ternary

# download entries on a page, recursively called for page continuations
function DownloadEntries {
 param ([string]$feedUrl) 
 $feed = [xml]$webClient.DownloadString($feedUrl)
 $entries = $feed | select -ExpandProperty feed | select -ExpandProperty entry
 $progress = 0
             
 foreach ($entry in $entries) {
    $url = $entry.content.src
    $fileName = $entry.properties.id + "." + $entry.properties.version + ".nupkg"
    $saveFileName = join-path $destination $fileName
    $pagepercent = ((++$progress)/$entries.Length*100)
    if ((-not $overwrite) -and (Test-Path -path $saveFileName)) 
    {
        write-progress -activity "$fileName already downloaded" -status "$pagepercent% of current page complete" -percentcomplete $pagepercent
        continue
    }
    write-progress -activity "Downloading $fileName" -status "$pagepercent% of current page complete" -percentcomplete $pagepercent
    $webClient.DownloadFile($url, $saveFileName)
  }
  $link = $feed.feed.link | where { $_.rel.startsWith("next") } | select href
  if ($link -ne $null) {
    # if using a paged url with a $skiptoken like 
    # http:// ... /Packages?$skiptoken='EnyimMemcached-log4net','2.7'
    # remember that you need to escape the $ in powershell with `
    $feedUrl = $link.href
    DownloadEntries $feedUrl
  }
}  

# the NuGet feed uses a fwlink which redirects
# using this to follow the redirect
function GetPackageUrl {
 param ([string]$feedUrlBase) 
 $resp = [xml]$webClient.DownloadString($feedUrlBase)
 return $resp.service.GetAttribute("xml:base")
}

if( $help ) {
    ""
    "-help               this help"
    "-notlatest          do not only pull latest version of package"
    "-overwrite          do overwrite if package already present"
    "-top [count]        Pull only <count> packages, pull all packages when count is 0"
    "-destination [path] Pull all packages to <path>. Default is ~/Documents/LocalNuGet"
    ""
    exit
}

# -- output current settings ---
""
"Current settings:"
"---------------------------------------------------"
?: ($notlatest) { "Do not take the latest packages" } { "Get the latest packages" }
?: ($overwrite) { "Overwrite existing packages" } { "Do not overwrite existing packages" }
?: ($top) { "The number of packages to pull is $top" } { "Pull all packages" }
if( !$destination ) { $destination = join-path ([Environment]::GetFolderPath("MyDocuments")) "LocalNuGet" }
"Destination directy: $destination"
""

# --- do the actual work ---
# if dest dir doesn't exist, create it
if (!(Test-Path -path $destination)) { New-Item $destination -type directory }
# set up feed URL
$serviceBase = GetPackageUrl($feedUrlBase)
$feedUrl = $serviceBase + "Packages"
if(-not $notlatest) {
    $feedUrl = $feedUrl + "?`$filter=IsLatestVersion eq true"
    if($top) {
        $feedUrl = $feedUrl + "&`$orderby=DownloadCount desc&`$top=$top"
    }
}
DownloadEntries $feedUrl

Nach dem Herunterladen der Pakete kann, wie an anderer Stelle bereits beschrieben, im Visual Studio per Tools, Options, Package Manager, Package Sources das lokale Repository eingetragen werden.

DotNetKicks-DE Image
Veröffentlicht unter NuGet, PowerShell | Hinterlasse einen Kommentar