Mit EF N:M-Beziehungen pflegen

Letztens wieder in einem Projekt mit Entity Framework. Da habe ich wieder eine Erkenntnis erlangt, die ich hier zu teilen versuche.

Situation

Man stelle sich vor, die Software soll eine Tabelle mit einer Detailtabelle (n:m) bearbeiten. Als Beispiel nehmen wir Produkt und Laden. Zur Einnordung: Ein Produkt kann in vielen Läden geführt werden und ein Laden führt viele Produkte. Also klassisch n:m. Das Ganze soll mit Entity Framework umgesetzt werden. Als besondere Schwierigkeit hat Produkt keine Navigationseigenschaft für die Läden. Das mag EF nicht so sehr. Passieren kann das, wenn Produkt z.B. extern zugeliefert wird. Man also keinen Einfluss auf den Code hat.

Umsetzung:

Entitäten

public class Produkt 
{
    public Produkt(Guid id, string name, decimal preis)
    {
        Id = id;
        Name = name;
        Preis = preis;
    }

    public virtual Guid Id { get; set; }

    public virtual string Name { get; set; }

    public virtual decimal Preis { get; set; }
}

public class Laden
{
    public Laden(Guid id, string name)
    {
        Id = id;
        Name = name;
        Produkte = new HashSet<Produkt>();
    }

    public Guid Id { get; }

    public virtual string Name { get; set; }

    public virtual string Inhaber { get; set; }

    public virtual ICollection<Produkt> Produkte { get; set; }
}

Laden verweist auf n Produkte, Produkte aber nicht auf Laden. Klassischerweise würde EF hier eine 1:n-Beziehung per Konvention machen. Daher ist Arbeit im Modelbuilder nötig. Hier also unser Datenkontext:

public class ErpDbContext : DbContext
{

    public DbSet<Produkt> Produkts { get; set; }

    public DbSet<Laden> Ladens { get; set; }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        builder.Entity<Produkt>(b =>
        {
            b.ToTable("Produkts"); 
            b.ConfigureByConvention();
            b.HasKey(x => x.Id);
            b.Property(x => x.Name).HasColumnName(nameof(Produkt.Name)).IsRequired();
            b.Property(x => x.Preis).HasColumnName(nameof(Produkt.Preis)).IsRequired();
        });

        builder.Entity<Laden>(b =>
        {
            b.ToTable("Ladens"); 
            b.ConfigureByConvention();
            b.HasKey(x => x.Id);
            b.Property(x => x.Name).HasColumnName(nameof(Laden.Name)).IsRequired();
            b.Property(x => x.Preis).HasColumnName(nameof(Laden.Inhaber));
            b.HasMany<Produkt>(p => p.Produkte).WithMany("Laden").UsingEntity(j => j.ToTable("Produkt2Laden"));
        });

    }
}

Hier werden die beiden Entitäten eingerichtet und auch die einseitige N:M-Beziehung von Laden auf Produkte. Entity Framework erzeugt nun im Hintergrund die nötige Zwischentabelle, die hier „Produkt2Laden“ genannt wird. Im optimalen Fall hätte man die Navigationsproperties auf beiden Seiten gesetzt, aber hier geht es ja genau darum, es nur einseitig zu haben.

Der Trick ist hier die Anweisung .HasMany(p => p.Produkte).WithMany("Ladens"). Hier wird es als n:m-Beziehung definiert. Normalerweise wäre Ladens eine Eigenschaft von Produkt. Aber die haben wir ja nicht. Bei WithMany() wird daher kein Lambda, sondern eine Zeichenkette verwendet. Mit Stringliteralen kann man wenigstens etwas faken. Es könnte also irgendwas dort stehen. Aus diesem Grund funktioniert auch nicht Alles komplett. Die Verwendung von .Include(), um die Detailtabelle mitzuladen, wird zum Problem. Es geht nicht unter allen Umständen. So z.B. bei diesem Versuch eines Delete:

public async Task DeleteProduktFromLadenAsync(Guid ladenId, Guid produktId)
{
    Laden entitywithdteails = await this.Where(d => d.Id == ladenId).Include(i=>i.Produkte).FirstOrDefaultAsync();
    Produkt detail = entitywithdteails.Teams.FirstOrDefault(z=>z.Id == orgId);
    if( detail!=null )
    {
        entitywithdteails.Teams.Remove(detail);
    }
}

Dieser Versuch funktioniert nicht. Entity Framework gibt einem eine relativ nichtssagende Fehlermeldung. Auch wenn es nicht nötig ist, möchte EF da scheinbar einmal durch alle drei Relationen durch und wieder zurück. Da die „Rückreferenz“ also die ICollection<Produkt> Läden in Produkt fehlt, geht es per default nicht.
Nebenbei (wenn man also weiß, dass eine N:M-Beziehung über eine Zwischentabelle realisiert wird) ist es auch gar nicht nötig, zunächst auch nur eine der beiden Entitäten ([Laden,Produkt]) zu laden, um an den Beziehungen der beiden zu arbeiten. Umso mehr muss man sich um eine effiziente und zuverlässige Bearbeitung der Detailtabellendaten kümmern.

Coden

Ich bin dabei auf folgende beiden Implementierungen der Add/Remove-Operationen gekommen:

public async Task AddProdukteToLaden(Guid ladenId, Guid produktId)
{
    ErpDbContext context = await GetDbContextAsync();
    // fake element attachen und dann in die Collection rein.
    var prod = new Produkt(produktId, null, null);
    var laden = new Laden (ladenId, null, null);
    context.Attach(laden);
    // zeige EF, was passieren soll
    laden.Produkte.Add(prod);
}
public async Task DeleteProduktFromLadenAsync(Guid ladenId, Guid produktId)
{
    var laden = new Laden(ladenId, string.Empty);
    var produkt = new Produkt(produktId, string.Empty, 0);
    // simuliere Zustand davor
    laden.Produkte.Add(produkt);

    var context = await GetDbContextAsync();
    context.Attach(laden);
    // zeige EF, was du willst
    laden.Produkte.Remove(produkt);
}

Der Trick besteht dabei darin, dass Entity Framework die Hauptentitäten gar nicht unbedingt holen muss. Es ist auch nicht wichtig, was in den Feldern steht. Einzig wichtig ist die Id des Datensatzes. Wenn man so eine Enität an den DB-Kontext per .Attach() anfügt, beginnt das Tracking von Entity Framework ab diesem Zeitpunkt. Werden keine anderen Eigenschaften/Felder verändert, hat es auch keine Updates zur Folge. Ergeo wird in diesem Fall nur das Add/Remove von der Detailkollektion mitgeschnitten und somit in die DB persisitiert.

Wir lernen also: Man braucht nicht die ganzen Entitäten zu laden um an Detailkollektionen Änderungen zu machen. Und: context.SaveChanges() nicht vergessen. In meinem Fall gab es ein Framework drumherum, welches alles in eine UnitOfWork einpackt und somit erfolgreiche Operationen automatisch persisitiert sind. Daher fehlt es bei meinen Beispielen.

Mit AvaloniaUI Enums in XAML zeigen

Es geht um das GUI-Framework AvaloniaUI. Und es geht darum, wie man in der darin üblichen XAML-Beschreibung Enum-Werte in z.B. eine Combobox hineinbekommt – ohne extra Code.

Es gibt gewisse Kritik an diesem Vorgehen vor allem aus dem Kreise der AvaloniaUI-Kernprogrammierer. Man möchte lieber für alles und immer View-Modelle (MVVM) verwenden. Zweifellos, das ist gut. Aber es gibt auch Gründe, es anders herum zu machen. Zum Beispiel, wenn man einfach nur schnell etwas zusammenstecken möchte. Oder gerade eben kein Viewmodell will.

Zur Lösung: Man möchte z.B. diese GUI darstellen, wobei in der Combobox die Items aus einem eigenen oder bekannten Enum stammen sollen und selektierbar sein sollen. Hier: Dock aus dem DockPanel.

In dieser kleinen Demo wird die Combobox durch den Enum Dock gefüllt und im Weiteren dieser Selektionswert an die Dock-Eigenschaft des kastanienfarbenen Rechtecks gebunden. De XAML oder aXAML-Code dazu sieht so aus:

<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:adb="https://flinkebits.de/avadevbox"
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
             x:Class="AvaloniaControls.Demo.RangeSliderDemo">
  <StackPanel>
    <TextBlock>Aufzählungswerte von Dock:</TextBlock>
    <ComboBox Name="cbdock" Width="240"
              HorizontalAlignment="Left"
              SelectedIndex="2" Margin="10,5,0,30"
              Items="{Binding Source={adb:EnumBindingSource {x:Type Dock}}}"/>

    <Border BorderThickness="3" BorderBrush="AliceBlue" Margin="10" Height="300">
      <DockPanel HorizontalAlignment="Stretch" VerticalAlignment="Stretch" >
        <Rectangle DockPanel.Dock="{Binding #cbdock.SelectedItem}"
                   Width="55"
                   Height="44"
                   Fill="Maroon" />
        <Button IsEnabled="False" >Rest</Button>
      </DockPanel>
    </Border>
  </StackPanel>
</UserControl>

Früher in WPF hat man dazu nette Verrenkungen mit ObjectValueProvider gemacht. Doch den gibt es unter Avalonia nicht mehr. Stattdessen greife ich hier auf eine Markupextension zurück: EnumBindingSource. Allerdings gibt es Markupextensions in AvaloniaUI auch nicht so wirklich. Auf jeden Fall gibt es keine Ableitung von MarkupExtension. Das Problem für AvaloniaUI ist, dass man eigentlich gleichzeitig von AvaloniaObject und MarkupExtension erben wöllte, aber natrülichin C# nur eine Klasse beerbt werden kann!

Die Lösung ist, dass die XAML-Komponente von AvaloniaUI die Klasse MarkupExtension vollständig ignoriert und bei Verwendungen wie Markupextensions einfach nach Klassen sucht, die lediglich von AvaloniaObject abgeleitet sind und eine von mehreren möglichen public ProvideValue()-Signaturen hat. Avalonia lässt hier verschiedene Rückgabewerte zu. Konkrete und object, sowie verschiedene Parameter. Somit lässt sich diese Extension so schreiben:

public class EnumBindingSource : AvaloniaObject /*: MarkupExtension*/
{
    private Type _enumType;
    public Type EnumType
    {
        get { return this._enumType; }
        set
        {
            if (value != this._enumType)
            {
                if (null != value)
                {
                    Type enumType = Nullable.GetUnderlyingType(value) ?? value;

                    if (!enumType.IsEnum)
                        throw new ArgumentException("Type must be for an Enum.");
                }

                this._enumType = value;
            }
        }
    }

    public EnumBindingSource() { }

    public EnumBindingSource(Type enumType)
    {
        this.EnumType = enumType;
    }

    public Array ProvideValue(IServiceProvider serviceProvider)
    {
        if (null == this._enumType)
            throw new InvalidOperationException("The EnumType must be specified.");

        Type actualEnumType = Nullable.GetUnderlyingType(this._enumType) ?? this._enumType;
        Array enumValues = Enum.GetValues(actualEnumType);

        if (actualEnumType == this._enumType)
            return enumValues;

        Array tempArray = Array.CreateInstance(actualEnumType, enumValues.Length + 1);
        enumValues.CopyTo(tempArray, 1);
        return tempArray;
    }
}

Das Ganze geht noch besser, denn man könnte auch noch die Attribute DescriptionAttribute auf den Aufzählungswerten auswerten und damit eine Lokalisierung anbieten. Das geht natürlich genau so, dass der SelectedValue vom Enum-Typ ist und die Anzeige in der Combobox der Description-Text ist.

Aber das überlasse ich einer Übung des Lesers. Es gibt genug WPF-Beispiele, die genau das tun.

Automatisierte Builds in Azure DevOps/VSTS

Ein essentieller Teil einer erfolgreichen Softwareenwicklung in einem Unternehmen ist Continuous Integration, ein ständig verfügbarer kontinuierlicher Build. Und daneben gehört natürlich Continuus Deployment hinein. So werden fertige Builds auch gleich auf Herz und Nieren geprüft.

In diesem Artikel geht es darum, wie man aus einem .Net 4.7-Projekt mit mehreren Setups und exe-Dateien (GUI und CUI) einen continuus Build hinbekommt. Dabei sollen die Versionsnummern immer stetig hochgezählt werden und sowohl in jeder Assembly als auch in jedem Setup landen. Zusätzlich gibt es einige GUI-Komponenten, die das unsägliche .Net-licensing verwenden. Auch dafür gibt es eine Lösung.

Der Build wird eingerichtet auf Azure DevOps. Modernere Versionen sollten Ähnlich oder einfacher einzurichten sein.

Ziel

Folgende Probleme sollen adressiert und gelöst werden:

  • .Net Licensing von GUI-Komponenten (z.b. PerpetuumSoft)
  • Versionsnummern automatisch vergeben
  • Basis der Versionsnummer konfigurierbar (beim Build)
  • Versionsnummer in jeder Assembly (Asm. Ver + File Ver.)
  • Versionsnummer in jedem MSI, „Drüberinstallieren“ soll gehen.

Lizensierung

Mit .Net Licensing hat Microsoft ehemals ein Standardverfahren etabliert, mit dem Komponenten (v.a. Windows Forms) lizensiert werden können. Aufgrund des Alters und seiner Art, ein eher unsägliches Ding. Aber man kommt damit klar.

Die primäre Idee von .Net Licensing ist es, für GUI-Komponenten Entwicklerlizenzen während der Entwicklung durchzusetzen. Das sieht dann etwa so aus: Der Entwickler bindet eine GUI-Komponente ein und bei jeder Verwendung prüft diese, ob auf dem Entwicklerrechner eine Lizenz vorhanden ist. Ist das nicht der Fall, kann etwa ein Nag-Screen aufpoppen. Dazu gehört noch der Lizenzcompiler LC.Exe, der aus *.licx-Dateien *.licenses-Dateien baut. Das erledigt jeweils die Komponente, die auf ihre Weise checkt, ob sie auf diesem Entwicklersystem lizenziert ist. Deshalb steht auch in der *.licx-Datei eine Liste von Komponenten, die der LC.EXE aufrufen soll (per Reflection). Im Ergebnis entsteht eine *.licenses-Datei, die als Eingebettete Ressource in die Assembly eincompiliert wird. Danach ist die Komponente dauerhaft auch auf Kundensystemen lizensiert.

Das Problem ist nun: So lange alles nur auf Entwicklermaschinen lief, ging alles gut. Der CI-Build läuft aber auf einer frisch instantiierten VM in der Cloud und diese hat keine Entwicklerlizenz installiert. Ergo kann es passieren, dass ein Nag-Screen aufgeht und keiner ihn kann klicken. Der Build wird nie fertig.

Info dazu auf Stackoverflow

Lösung: Man macht einmal einen ordentlichen Build auf einem Entwicklersystem und sammelt dort die *.licenses-Dateien ein (Bin-VZ). Dann setzt man den Dateityp in der SLN von *.licx auf „None„. Sodann checkt man die *.licenses Dateien pro Assembly ein und bindet sie als „Embedded Resource“ zusätzlich in die SLN ein.

Ab da läuft der Build ohne Nag-Screen durch. Nur beim Aktualisieren der Komponenten muss man diesen Vorgang wiederholen. Man macht somit LC.EXE arbeitslos und verhindert das Aufrufen des Lizensierungs-Codes.

Versionierung

Kommen wir zum Thema Versionsnummern. Die wichtigste Zutat für einen automatiserten Build sind korrekte und konfigurierbare Versionsnummern. Nur so kann man feststellen, ob ein Artefakt neuer oder älter ist und zu welchem Versionsstand er gehört. .Net bringt uns 3 verschiedene Versionsnummern, die je Assembly gesetzt werden können. Eine Erklärung dazu und zu „Semantischer Versionierung“ findet man hier:

Umsetzung

Um eine automatische Versionierung hinzubekommen braucht man zwei Zutaten:

  • Eine Pipeline-Variable für die Assemblyversion
  • Ein Skript, um die Versionsvariable zu verrechnen
  • Ein Build-Target für MSBUILD
Die Variable BuildVersionOfAssembly wird angelegt mit dem aktuellen Standardwert

Der Plan ist es, die Versionierung so zu machen, wie es in semantic versioning empfohlen wird. Also die verschiedenen Typen von Assembly-Versionsattributen korrekt zu setzen. Sprich: AssemblyVersion und AssemblyInformationalVersion ist die ‚fixe‘ Version. Darüber referenzieren und finden sich die abhängigen Assemblies. Die letzte Stelle ist leer. Derweil ist AssembyFileVersion quasi dieselbe Nummer aber mit gesetzter Build-Nummer, die irgendwie ausgerechnet wird.

Konkret kommt über die Build-Pipeline eine Variable AssemblyVersion hinein in Form einer 3-Stelligen Version. Z.B. „11.22.33“

Dies resultiert in:
AssemblyVersion = „11.22.33“
AssemblyInformationalVersion = „11.22.33“
AssembyFileVersion = „11.22.33.1928“
Das Versions-Rechen-Skript setzt die neue Variable BuildVersionOfAssembly auf den Wert „11.22.33.1928“ und exportiert sie als DevOps-Variable auf die Konsole. Die Möglichkeiten dabei sind grenzenlos. Üblicherweise sollte man die BuildID nutzen, welche eine stetig steigene Nummer je getriggerten Build im CI-System ist. Problem: Die Zahl wird irgendwann größer als die erlaubten 16-bit an dieser Stelle. Lösung: Man macht Modulo 2^16 und hofft, dass dies nur alle 65k Builds zu Problemen führt und inkrementiert dann schnell die Patch-Version (Stelle 3). Wahlweise kann man auch die oberen Bits auf Patch-Version geben; dann aber nur zwei Versionsstellen vorgeben. (alles hier nicht gemacht).

Skript

Version Script (PowerShell inline):

# This script computes the $(BuildVersionOfAssembly) variable off of the User set $(VersionOfAssembly) build var.
# adds the buid pipeline ID (ordered number) as the last figure of the version

# VersionOfAssembly is expected to be "x.y.z" BuildVersionOfAssembly becomes "x.y.z.buildID"
Write-Host  "Looking for VersionOfAssembly. Found : $Env:VersionOfAssembly"

if( -not ("$Env:VersionOfAssembly" -match "^\d+\.\d+\.\d+$" ))
{
   Write-Host  "##vso[task.LogIssue type=error;]Error: $Env:VersionOfAssembly does not match X.Y.Z format."
   exit 1
}

$BuildVersionOfAssembly = $Env:VersionOfAssembly + '.' + $Env:Build_BuildId
Write-Host  "BuildVersionOfAssembly= $BuildVersionOfAssembly"

#To set a variable in DevOps pipeline, use Write-Host
Write-Host "##vso[task.setvariable variable=BuildVersionOfAssembly]$BuildVersionOfAssembly"
Version Script in DevOps – Variablen Sektionen leer lassen

Version in Assemblies schreiben

Versionen sind als Varialben da, doch wie bekommen wir sie als Assembly-Attribute in die Assemblies hinein. MSBUILD hat das etwas unbekannte Merkmal ‚directory target‘. Dieses sind Kompile-Schritte, die MSBUILD pro Projekt durchführt. Setzt man sie richtig ein, werden diese Aufgaben für jedes Projekt im selben oder tieferen Verzeichnis ausgeführt. Siehe .

Das Skript muss also irgendwo ‚oben‘ im Projekt angelegt werden und wir nennen es ‚Directory.Build.targets‘. Innerhalb des Skripts kann sogar inline-C# verwendet werden. Dabei nutzen wir Reguläre Ausdrücke um die AssemblyInfo.cs-Dateien zu patchen (alle 3 Versionsattribute). Dabei wird aber nicht die eigentliche Datei gepatcht, sondern eine Kopie. Die Originale (im VCS eingecheckt) werden als ‚targets‘ ausgeschlossen und dafür die gepatchten hinzugefügt. Das ist besser als checkout+checkin auf Build- und Entwicklermaschinen. Denn diese Targets werden ja immer mit ausgeführt. Wenn aber BuildVersionOfAssembly leer ist, macht dieses ‚Target‘ nichts. Das Ganze sollte mit *.vb genauso funktionieren.

Script: \Directory.Build.targets

<Project DefaultTargets="Build" InitialTargets="UpdateAssemblyVersion" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<!-- from:
    http://www.lionhack.com/2014/02/13/msbuild-override-assembly-version/
    Creates modified version of AssemblyInfo.cs, replaces
    [AssemblyVersion] attribute with the one specifying actual build version
    (from MSBuild properties), and includes that file instead of the
    original AssemblyInfo.cs in the compilation.

    Works with both, .cs and .vb version of the AssemblyInfo file, meaning
    it supports C# and VB.Net projects simultaneously.

    see:
    Global .NET Versioning Strategy – AssemblyInformationalVersion
    or https://intovsts.net/2015/08/24/tfs-build-2015-and-versioning/ for
    further information

    Note: C++ projects have a CommonCpp.targets
-->

  <Target Name="UpdateAssemblyVersion" BeforeTargets="Compile"
       Condition="'$(VersionOfAssembly)' != '' or '$(BuildVersionOfAssembly)' != ''">
    <!-- Find AssemblyInfo.cs or AssemblyInfo.vb in the "Compile"
        Items. Remove it from "Compile" Items because we will use a modified
        version instead. -->
    <ItemGroup>
      <OriginalAssemblyInfo Include="@(Compile)" Condition="%(Filename) == 'AssemblyInfo' And (%(Extension) == '.vb' Or %(Extension) == '.cs')" />
      <Compile Remove="**/AssemblyInfo.vb" />
      <Compile Remove="**/AssemblyInfo.cs" />
    </ItemGroup>
    <!--  Copy the original AssemblyInfo.cs/.vb to obj\ folder, i.e.
          $(IntermediateOutputPath). The copied filepath is saved into
          @(ModifiedAssemblyInfo) Item. -->
    <Copy SourceFiles="@(OriginalAssemblyInfo)"
          DestinationFiles="@(OriginalAssemblyInfo->'$(IntermediateOutputPath)%(Identity)_patched')">
      <Output TaskParameter="DestinationFiles" ItemName="ModifiedAssemblyInfo"/>
    </Copy>

    <!-- DON'T Use VersionAssembly if InfoVersionAssembly is empty -->
    <!-- <PropertyGroup> -->
    <!-- <InfoVersionAssembly Condition="'$(BuildVersionOfAssembly)'== ''">$(VersionOfAssembly)</InfoVersionAssembly> -->
    <!-- </PropertyGroup> -->

    <Message  Text="-------------------------------------------------------------------------------" Importance="high" />
    <Message Text="Setting AssemblyVersionAttribute to $(VersionOfAssembly) " Importance="high" />
    <Message Text="Setting AssemblyFileVersionAttribute to $(BuildVersionOfAssembly) " Importance="high" />
    <Message Text="Setting InfoVersionAssemblyAttribute to $(VersionOfAssembly) " Importance="high" />
    <Message Text="Temp file is %(ModifiedAssemblyInfo.FullPath) " Importance="high" />
    <Message Text="--------------------------------------------------------------------------------" Importance="high" />

    <!-- Replace the version bit (in AssemblyVersion and
        AssemblyFileVersion attributes) using regular expression. Use the
        defined property: $(VersionOfAssembly). -->

    <!-- TODO: For Relseases, AssemblyVersion should be set to InfoVersionAssembly -->
    <Message Text="Setting AssemblyVersion to $(VersionOfAssembly)" />
    <RegexUpdateFile Files="@(ModifiedAssemblyInfo)" 
                     Condition="'$(VersionOfAssembly)' != ''"
                     Regex="AssemblyVersion(Attribute)?\("(\d+)\.(\d+)\..*"\)"
                     ReplacementText="AssemblyVersion("$(VersionOfAssembly)")" 
                     />
    <Message Text="Setting AssemblyFileVersion to $(BuildVersionOfAssembly)" />
    <RegexUpdateFile Files="@(ModifiedAssemblyInfo)"
                     Condition="'$(VersionBuildVersionOfAssemblyOfAssembly)' != ''"
                     Regex="AssemblyFileVersion(Attribute)?\("(\d+)\.(\d+)\..*"\)"
                     ReplacementText="AssemblyFileVersion("$(BuildVersionOfAssembly)")"
                     />
    <Message Text="Setting InfoVersionAssembly to $(VersionOfAssembly)" />
    <RegexUpdateFile Files="@(ModifiedAssemblyInfo)"
                     Condition="'$(VersionOfAssembly)' != ''"
                     Regex="AssemblyInformationalVersion(Attribute)?\("(\d+)\.(\d+)\..*"\)"
                     ReplacementText="AssemblyInformationalVersion("$(VersionOfAssembly)")"
                     />
    <!-- Include the modified AssemblyInfo.cs/.vb file in "Compile" items (instead of the original). -->
    <ItemGroup>
      <Compile Include="@(ModifiedAssemblyInfo)" />
    </ItemGroup>
  </Target>

  <UsingTask TaskName="RegexUpdateFile" TaskFactory="CodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.v4.0.dll">
    <ParameterGroup>
      <Files ParameterType="Microsoft.Build.Framework.ITaskItem[]" Required="true" />
      <Regex ParameterType="System.String" Required="true" />
      <ReplacementText ParameterType="System.String" Required="true" />
    </ParameterGroup>
    <Task>
      <Reference Include="System.Core" />
      <Using Namespace="System" />
      <Using Namespace="System.IO" />
      <Using Namespace="System.Text.RegularExpressions" />
      <Using Namespace="Microsoft.Build.Framework" />
      <Using Namespace="Microsoft.Build.Utilities" />
      <Code Type="Fragment" Language="cs">
        <![CDATA[
try {
var rx = new
System.Text.RegularExpressions.Regex(this.Regex);
for (int i = 0; i < Files.Length; ++i)
{
var path = Files[i].GetMetadata("FullPath");
if (!File.Exists(path)) continue;

var txt = File.ReadAllText(path);
txt = "// <auto-generated />\r\n" +
rx.Replace(txt, this.ReplacementText);
File.WriteAllText(path, txt);
}
return true;
}
catch (Exception ex) {
Log.LogErrorFromException(ex);
return false;
}
]]>
      </Code>
    </Task>
  </UsingTask>

  <!-- ConsoleToMsBuild="True" IgnoreStandardErrorWarningFormat="true" IgnoreExitCode="True" -->
  <!-- MsBuild is probably the only build tool in the world that can't copy dependencies without help.-->
  <!-- Therefore, I can't recommend msbuild for professional use. -->
  <!-- Add this lines to your project file:
  <PropertyGroup
  Condition="Exists('$(SolutionDir)CommonSettings.targets')">
  <BuildDependsOn>
  $(BuildDependsOn);
  CopyLocalAgain;
  </BuildDependsOn>
  </PropertyGroup>
  -->
  <Target Name="CopyLocalAgain">
    <!-- Bug with incremental clean should be fixed. -->
    <!-- <CreateItem Include="%(Reference.HintPath)" -->
    <!-- Condition="'%(Reference.Private)'=='true'"> -->
    <!-- ItemName defines the name of the item list. Who came up with this syntax? -->
    <!-- <Output TaskParameter="Include" -->
    <!-- ItemName="_CopyLocalReferencesAgain"/> -->
    <!-- </CreateItem> -->
    <!-- <Message Text="Copy CopyLocal references again: @(_CopyLocalReferencesAgain)" /> -->
    <!-- <Copy SourceFiles="@(_CopyLocalReferencesAgain)" -->
    <!-- DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true"> -->
    <!-- </Copy> -->
  </Target>
</Project>

MSI und Bootstrapper Setups

Die MSI Setups (WIX) und WIX-Bootstrapper-Setups funktionieren ein wenig anders um ihre Versionsnummer eingeimpft zu bekommen. Da es aber MSBUILD-Projekte sind, kann man ähnlich vorgehen. Quelle der Inspiration: Dieser Stackoverflow Artiel.

  1. In der Projektdatei *.wxiproj muss dieser Teil relativ an den Anfang. Hier wird die Umgebungsvariable als ‚Version‘ genommen. Bei fehlender Variable gibt es einen Fallback. Auf diese Weise gibt es immer eine gesetzte Version. Ansonsten scheitert der WIX-Build.
    <!-- define version constant if CI-Build not running  use a default -->
    <Version Condition=" '$(BuildVersionOfAssembly)' == '' ">5.99.99</Version>
    <Version Condition=" '$(BuildVersionOfAssembly)' != '' ">$(BuildVersionOfAssembly)</Version>
    <DefineConstants>Version=$(Version)</DefineConstants>
  1. In die Dateien *.wxs nutzt man die soeben gesetzte Variable (Verfpgbar als $(ver.Version)).

Beispielausschnitt einer *.wxs:

  <Product Id="CDE4C223-7A78-45FF-A984-675F414BD516" Name="!(loc.PRODUCT)" Language="1033" Version="$(var.Version)" Manufacturer="!(loc.MANUFACTURER)" UpgradeCode="cae20b70-d924-4b23-b90e-9ea1b5f5026b">
    <Package InstallerVersion="200" Manufacturer="!(loc.MANUFACTURER)" Compressed="yes" Description="!(loc.PRODUCT) $(var.Version)" />
    <Property Id="PREVIOUSVERSIONSINSTALLED" Secure="yes" />
    <Property Id="TAKEBACKUP" Secure="yes" />
    <Upgrade Id="cae20b70-d924-4b23-b90e-9ea1b5f5026b">
      <UpgradeVersion Minimum="$(var.Version)" IncludeMinimum="no" OnlyDetect="yes" Language="1033" Property="NEWERPRODUCTFOUND" />
      <UpgradeVersion Minimum="0.79.0" IncludeMinimum="yes" IncludeMaximum="no" Maximum="$(var.Version)" Language="1033" Property="UPGRADEFOUND" />
    </Upgrade>

Release

Zu einer Build-Pipeline gehört immer auch eine Release-Pipeline. Die soll aber nicht das Thema dieses Betrags werden. Hier ging es vornehmlich um das korrekte Errechnen und setzen von Assembly-Versionen und MSI-Versionen.

Als Bemerkunt oder Tip sei hier nur gesagt: Eine Build-Pipeline erzeugt Artefakte (irgendwo in Azure in einem Storage). Diese kann man im DevOps zwar herunterladen und damit arbeiten. Aber die verschwinden irgendwann. Daher benötigt man eine Release-Pipeline. Damit macht man eigentlich eher ‚Deployments‘ und Tests von Software. Man kann aber auch einfach eine leere Pipeline anlegen und einfach nur einen Copy-Task anlegen. Wenn der passende Agent ‚inhouse‘ läuft, kann man dann die Buildartefakte auf einen netzlokalen SMB-Share kopieren.

Fazit

Wir haben hier gesehen, wie man dynamisch beim Build eine jeweils neue Version errechnet und damit eine DevOps-Pipeline-Variable setzt und diese dann erfolgreich in die Buildartefakte (Assemblies und MSIs) hineinbekommt. Dies soll eine Inspiration sein, um jederzeit korrekt versionierte Builds zu haben und MSI-Installer, die automatische Upgrades machen können.

Multi Document Interface mit Angular

Problemstellung

Es geht in diesem Beitrag um eine Möglichkeit, in einer ASP.Net+Angular-Anwendung mehrere Dokumente und die Standardnavigation darzustellen. Man stelle sich vor, es wird eine Business-Anwendung in Angular entwickelt und diese kann z.B. Akten anzeigen. Zusätzlich hat sie aber, wie jede vernünftige Anwendung, eine Navigation über diverse Bereiche. Was man also will, ist: Es gehen für die Akten eigene Tabs auf und in einem „Haupt-Tab“ wird weiterhin die übliche Navigation durchgeführt (z.b. Profileinstellungen, andere Aspekte der Anwendung). Hier zeige ich das Beispielhaft an einer von mir entwickelten ASP.Net + Angular- Anwendung (auf Basis von ABP). Es sollte sich aber leicht auf alle Angular-Anwendungen übertragen lassen.

Router

Kurze Rekapitulation, wie man in Angular Navigiert. Die Anwendung wird als eine Hierarchie von Komponenten (components) definiert. Typischerweise mit *.html*-Dateien. Irgendwo befindet sich darin ein Element *<router-outlet></router-outlet>*. Hierdurch weiß der *Router* von Angular, dass an dieser Stelle die Komponenten des Routing-Ziels eingeblendet werden sollen. Was dann auch passiert.

Tatsächlich kann man mehrere *router-outlet*s definieren. Die Weiteren müssen individuelle Namen haben. Dann kann man mit einer tollen URL-Syntax mit Doppelpunkten und Outlet-Namen für alle Outlets gleichzeitig verschiedene Komponenten „ernavigieren“. Allerdings hat sich das meinen Kenntnissen und meiner Vorstellungskraft entzogen… Also ob man nur einzelne Outlets ändern kann, derweil der Rest bleibt und wie man dann dynamisch mehr und mehr solche Outlets erstellt (die Tabs) und auch wie man sie sinnvollerweise dann benennt. Also dieser Ansatz wurde ad Acta gelegt.

Der Ansatz

Datenhaltung

Wie sollte das Ganze sinnvoll gestaltet werden? Die Idee wäre, dass man ein Objekt hat, welches die Tabs als Datenstruktur enthält und einige Methoden bietet, um die Tabs zu verwalten. Ich habe es als Singleton realisiert. Es kann aber, je nach Architektur, auch sinnvoll sein, mehrere davon zu haben. Z.B. wenn man mehrere Tabs aufbauen will. Dann muss aber irgendwie das „default“-Router-Otulet abschaltbar werden.

Wir brauchen auf alle Fälle einen injezierbaren Service (@Injectable), der in den verschiedensten Modulen der Anwendung verfügbar ist und die Tabs programmatisch modifizieren kann. Zusätzlich muss er aber auch für die Darstellungskomponente verfügbar sein.

Code von src\app\shared\layout\open-document-service.ts:

import { Injectable,
    Inject,
    Type,
    ReflectiveInjector,
    ViewContainerRef, 
    Injector } from '@angular/core';
import { AppComponentBase } from '@shared/common/app-component-base';

// Injezierbarer Service, um auf die globale Definition
// der Tabs zuzugreifen und zu manipulieren
@Injectable()
export class OpenDocumentService {
  
    constructor(private _inj : Injector)
    {
    }

    offeneDokumente: Array<Dokument> = [  ];

    addTab(d:Dokument){
        let newId = d.id;
        const i = this.offeneDokumente.findIndex( elem=>elem.id == newId);
        if(i > -1)
        {
            // Tab aktivieren
            this.offeneDokumente[i].active = true;
            return;
        }

        // sonst kann es hinzukommen
        this.offeneDokumente.push(d);
    }

    // remove a tab
    remove(id: string) {
        const i = this.offeneDokumente.findIndex( elem=>elem.id == id);
        this.offeneDokumente.splice(i, 1);
    }
}

export class Dokument {
    public titel : string;
    public id: string;
    public removable : boolean;
    public disabled : boolean;
    public active : boolean;
    public compref: any;
}

Hier werden die Tabs in einem TS-Array gehalten und es gibt simple und eventuell verbesserbare Methoden zum Hinzufügen und entfernen. Als Struktur wurde die neue Datenklasse Dokument eingeführt, die eien Tab repräsentiert.

Darstellung

Das Ganze muss natürlich irgendwie Dargestellt werden. Zunächst steht das Control, das zur Realisierung genommen wird. Dabei habe ich mich für TabsetComponent von „ngx-bootstrap“ entschieden. Eingebunden wird das über eine neu zu erstellende Komponente in die Anwendung. Nennen wir das Teilchen mal *multdoc-control*. Im Prinzip ist diese Komponente nichts anderes als eine geschickte Tab-Definition mit ngFor. Der immer vorhandene Tab ist das „default“ *router-otulet*; und der Rest ist die per ngFor definierte Magie, mit der die offenen Dokumente/Akten dargestellt werden.

Code von src\app\shared\layout\multi-document.component.ts:

import { Component, ViewContainerRef, ViewChild } from "@angular/core";
import { OpenDocumentService, Dokument } from "./open-document-service";
import { ContentPresenterComponent } from "./content-presenter.component";
import { TabsetComponent } from "ngx-bootstrap";

// Das ist so ziemlich nur eine GUI (html zusammenfassung) Komp.
// https://valor-software.com/ngx-bootstrap/#/tabs
@Component({
    template: `  
    <div>
        <tabset #mainTabs>
            <tab heading="Titel">
                <router-outlet (activate)='onOutletActivate($event)'></router-outlet>
            </tab>

            <tab *ngFor="let tabz of offeneTabs.offeneDokumente"
                [heading]="tabz.titel"
                [active]="tabz.active"
                (selectTab)="tabz.active = true"
                (deselect)="tabz.active = false"
                [disabled]="tabz.disabled"
                [removable]="tabz.removable"
                (removed)="removeTabHandler(tabz)"
                [customClass]="tabz.customClass">
                <content-presenter [component]="tabz.compref"></content-presenter>
            </tab>
        </tabset>
    </div>`,
    selector: 'multi-document-component',
    providers: [
        OpenDocumentService,
    ],
    viewProviders: [ ContentPresenterComponent ],
})
export class MultiDocumentComponent  {

    @ViewChild('mainTabs') mainTabs: TabsetComponent;
    offeneTabs: OpenDocumentService

    constructor(private _vcr: ViewContainerRef,
         offeneTabs : OpenDocumentService)
    {
        this.offeneTabs = offeneTabs;
    }
     
    removeTabHandler(tab:Dokument){
        //alert("REM: tabid " + tab.id);
        this.offeneTabs.remove(tab.id);
    }
    
    onOutletActivate(ev): void {
        this.mainTabs.tabs[0].active = true;
    }
}

Hier ist also relativ geradelinig das router-outlet und die dynamische Definition der weiteren Tabs. Da so wenig HTML dabei ist, ist alles in eine .ts-Datei gewandert. Dazu noch einige Events, wie z.B. dass bei Start der erste Tab mit dem router-outlet aktiviert wird. Nur, wie werden diese Tabinhalte Dargestellt und on-demand erstellt? Also erst bei Tab-Klick die korrekte Komponente gefunden und eingefügt? Die Lösung ist mein selbst entwickelter Contentpresenter. Hier sieht man recht klar meine Herkunft aus der WPF-Welt. Die hat mir dabei durchaus genutzt, vielleicht hätte es aber auch eine direktere Angular-Lösung gegeben.

Code von src\app\shared\layout\multi-document.component.ts:

import { Component, OnInit, 
    ViewContainerRef, AfterViewInit, ViewChild, 
    Input, 
    ComponentFactoryResolver, Inject, ViewRef, OnDestroy } from "@angular/core";
import { OpenDocumentService } from "./open-document-service";

// Komp. wie ein ContentPresenter
// https://medium.com/front-end-weekly/dynamically-add-components-to-the-dom-with-angular-71b0cb535286
@Component({
    template: `<ng-template #dynamic></ng-template>`,
    selector: 'content-presenter',
    providers: [
        OpenDocumentService,
    ]
})
export class ContentPresenterComponent implements AfterViewInit, OnDestroy   {
    @ViewChild('dynamic', { 
        read: ViewContainerRef 
      }) viewContainerRef: ViewContainerRef

    // This property is bound using its original name.
    @Input() component: any;
    factoryResolver: ComponentFactoryResolver;

    constructor(
          @Inject(ComponentFactoryResolver) factoryResolver: ComponentFactoryResolver)
    {
        this.factoryResolver = factoryResolver;
    }

    ngAfterViewInit() {
        let view: ViewRef = this.component.hostView
        this.viewContainerRef.clear();
        this.viewContainerRef.insert(view);
        view.detectChanges();
    }

    ngOnDestroy(): void {
    }
}

In ngAfterViewInit() passiert die Magie. Generell wird eine Komponente im gewählten Tab-Control erst initialisiert, wenn sie angezeigt wird. Das ist aber eine Eigenschaft des Tab-Controls. Dann aber wird die eigentliche Zielkomponente, wie sie in compref übergeben wird, einfach nur an dieser Stelle eingefügt. Sicherheitshalber wird vorher der alte Inhalt gelöscht. Zuvor muss aber der Tab-Inhalt von irgendwem erzeugt worden sein. Dazu wird die passende „Factory“ über den ComponentFactoryResolver (angular Komponente) gesucht und die Komponente erstellt. Siehe dazu den Teil Verwendung. Wichtigster Teil hier ist die Angular-Komponente <ng-temlpate>. Sie kann im TS-Code gefunden werden und mit beliebigen anderen Views gefüllt werden.

Test

Jetzt haben wir alle Teile zusammen und können alles zusammenfügen und ausprobieren. Zum Test bietet es sich an, sich eine kleine Testkomponente zu erstellen, die möglichst wenige Abhängigkeiten hat. Hier ist ein Beispiel dafür:

import { Component, OnInit, Input, OnDestroy } from "@angular/core";

// eine Testkomponente
@Component({
    template: `  
    <div>
        Ich bin ein Test von ID={{id}}
    </div>`,
    selector: 'test-component',
    viewProviders: [  ],
})
export class TestComponent implements OnInit, OnDestroy  {
    @Input() id: string;
    
    ngOnDestroy(): void {
        alert("OnDestoy TestComp:" + this.id);
    }
    
    ngOnInit(): void {
    }
}

Durch das Implementieren von OnDestroy wird die Methode ngOnDestroy() bei Entfernung aufgerufen. Das soll durch alert() veranschaulicht werden.

Verwendung

Einbindung

Die Einbindung der neuen Multi-Dokument-Komponente sieht wie folgt aus. In meinem Fall ist es in einem ABP-Theme geschehen. Grundsätzlich gilt es, alle Vorkommen von <router-outlet> durch <multi-document-component> zu ersetzen:

        ...
        <div class="m-grid__item m-grid__item--fluid m-wrapper">
            <multi-document-component></multi-document-component>
        </div>
       ...

An dieser Stelle sei erwähnt, dass bei Angular wirklich die XML-Elemente in der Form von öffnendem und schließendem Teil geschrieben werden müssen. Sonst geht es nicht.

Steuerung

Gesteuert wird die Tab-Komponente über die injezierbare Datenhalter-Instanz. Dazu muss diese zunächst als Injectable eingebunden werden und dann kann man sie zum Beispiel so verwenden:

import { Component, Injector, ViewEncapsulation, ViewChild, Input, Type, ComponentFactoryResolver, ComponentRef } from '@angular/core';
import { OpenDocumentService, Dokument } from '@app/shared/layout/open-document-service';


...

    //--Constructor-------------------------------------------------
    constructor(
        injector: Injector,
        private _fr:ComponentFactoryResolver
        ...,
        private _openDocSrv: OpenDocumentService,
    ) {
        super(injector);
    }

addAkteToView(ka: KarteiDto): void {

        const factory = this._fr.resolveComponentFactory(TestComponent);
        
        const componentref: ComponentRef<TestComponent> = factory.create(this._injector);
        componentref.instance.id = ka.id;

        let dokument:Dokument = {
            active: true,
            titel: ka.akte,
            disabled:false,
            removable:true,
            id: ka.id,
            compref: componentref,
        }
        this._openDocSrv.addTab(dokument);
    }

In dieser ‚anderen‘ Komponente wird die einzufügende Komponente (TestComponent) aufgelöst, erzeugt und eingefügt. Also nicht im Contentpresenterdaselbst, sondern hier. Dazu braucht man den Injector und die den ComponentFactoryResolver. Nach dem Resolve kann die neue Komponente angesprochen werden und vermittels einem passenden Dokument-Objekt in den OpenDocumentService eingefügt werden. Das Ergebnis ist sofort sichtbar. Die Elementvariable compref übergibt die eigentliche Komponente.

Gedanken

Ich hatte im ersten Moment natürlich schon geplant, die Instantiierung der View im Tab in diesen ContentPresenter hineinzulegen. Tatsächlich ist der Stand ja, dass die „tabanlegende“ Komponente die View instantiiert. Vorteil dieser jetzigen Vorgehensweise: Man kann die neue Komponenteninstanz, die in den Tab hineinkommt, noch weiter konfigurieren. Also an der Stelle im Code, wo das meiste Fachwissen zu dieser Komponente vorhanden ist. Würde man die Komponente im ContentPresenter instantiieren, könnte man sie nicht individuell konfigurieren – man bräuchte beispielsweise einen weiteren Callback. Diesen Weg wollte ich durchaus beschreiten, bin aber letztendlich ein wenig vor der höheren Komplexität zurückgeschreckt (war bei dieser Lösung auch nicht nötig) und ich habe es syntaktisch nicht hinbekommen, den generischen Typ ComponentRef<Comp> und den Typ Comp parametrisch in eine Schnittstelle zu gießen, sodass dieser Zweisatz funktionieren könnte:

const factory = this._fr.resolveComponentFactory(Comp);
const componentref: ComponentRef<Comp> = factory.create(this._injector);

Unit-Tests über fremdem Code mit „Microsoft Fakes“

Ja sowas geiles. Testen von statischen Methoden, Interfaces und sammeln von Ergebnissen (Aufrufen). Das alles geht mit dem ehemaligen MS-Research-Tool „Moles“, welches heute in „Microsoft Fakes“ aufgegangen ist und seit VS2012 mitgeliefert wird. Da allerdings schon jemand einen tollen Artikel darüber geschrieben hat, verweise ich einfach darauf:

Unit-Tests mit MS-Fakes…. Links:

Böser Dispatcher

An dieser Stelle eines der vielen Gotchas bei .Net-Entwicklung.

Es gibt manchmal Klassen (oft ViewModel), die haben prim​är mal keine GUI-Referenz, werden aber manchmal wecheslweise in einem Worker-Thread ausgeführt. Problematisch wird dann eine eventuell daran gebundene GUI. Oder man muss einfach nur unterscheiden, ob man im Worker-Thread oder im GUI-Thread lebt.

Der Dispatcher ist mehr oder weniger das unterscheidbare Merkmal, an dem ein GUI-Thread von anderen unterschieden werden kann:

Gleicher Thread wie der Dispatcher… Gut, möchte man meinen. Doch diese Prüfung hat einen bösen Fehler.CurrentDispatcher hat Seiteneffekte: Existiert kein Dispatcher zum aktuellen Thread, wird ein neuer erzeugt. Dispatcher sind nämlich keineswegs GUI-Spezifisch, sondern ein allgmeiner Dispatch-Mechanismus. Der Name ist irreführend.

Korrekt ist dagegen die Prüfung mit diesem Idiom:

Doch leider zu kurz gedacht. Im Prinzip ist das Richtig. Doch nur so lange, als keiner das erstere Idiom mit Dispatcher.CurrentDispatcher verwendet hat und uns somit einen parasitären Dispatcher auf den Worker-Thread gesetzt hat. Irgendwann hat dann jeder Worker-Thread einen Dispatcher.

Also was tun? Gleich auf Application zugreifen … tja leider. Denn Applicaiton ist der einzig gute Ankerpunkt in diesem Fall.

Dabei soll aber nicht unerwähnt bleiben, dass es in einer GUI-Applikation durchaus mehrere UI-Threads geben kann. Typischerweise dann in mehreren Fenstern. Es ist unwahrscheinlich, aber es geht und dann wäre auch dieser Code hier wahrscheinlich inkorrekt.

Bei WinXP-Fotoanzeige Diashow-Intervall einstellen

Womit man auf seine alten Tage noch so zu tun bekommt. Windows XP glaubte ich ja eigentlich schon hinter mich gebracht zu haben. Doch da gibt es Leute, die es tatsächlich noch einsetzen. Es war ja auch nicht so schlecht… nur ein wenig unsicher sonst aber…

Und wer es einsetzt um damit Urlaubsfotos durchzublättern kommt auf die Diashow der Fotoanzeige. Da die Zeitintervalle zwischen den Bildern mit 3 sek nicht allen Leuten taugen, kommen dann Fragen, wie man dieses Intervall wohl ändert. Da es keine Oberfläche dafür gibt, bleibt nur die Registrierung zu bearbeiten. Konkret muss man unter HKEY_CURRENT_USERSoftwareMicrosoftWindowsCurrentVersionExplorerShellImageView gehen und dort den DWORD-Wert Timeout erstellen. Hinein kommt die Zeit in Millisekunden, die die Fotoanzeige zwischen zwei Bildern warten soll.

Aus gegebenem Anlass habe ich für diese einfache Aufgabe ein Programm geschrieben. Verwendet habe ich dazu WinForms und C#. Man wird also das .Net Framework 2.0 benötigen. Das Programm bietet eine simple Oberfläche um genau diese Eine Aufgabe zu erledigen. Gut für Leute, die häufiger mal die Zeitabstände ändern müssen, mit der Registrierung aber nix am Hut haben.

Download Programmdatei

Download Quellcode

Anmerkung: Das Programm ist nach dem QnD-Prinzip entstanden. Daher nur schnell zackzack entstanden… einfach so.

WPF Dependency Properties von innen Setzen

Entwicklung eines WPF-Composite-Controls mit Dependency Properties (aka. Abhängkgeitseigenschaften)

Ab und zu muss man bei der Etnwicklung von WPF-Oberflächen neue Controls erstellen. Nun gibt es verschiedene Arten von Controls. Man unterscheided sog. Lookless Controls und Composite Controls. Erstere sind quasi rein nur eine von UIElement abgeleitete Datenstruktur. Das gesamte Verhalten, das Aussehen und die modifikation der Datenfelder (die Dependency Properties) geschieht über Styles und dort wiederum mit Triggern und Binungen. Darum soll es hier aber nicht gehen. Hier geht es um die andere Art von Control: Um Kompositum-Kontrollelemente bzw. User-Controls. Also in Etwa ein Panel oder ein Window. Mehrere Controls sind in einem neuen Control zusammengepfärcht und interagieren intern miteinander, während sie nach außen hin wie ein einziges auftreten. Dies lässt sich mit und ohne View-Model machen. Man hat also die Wahl zwischen MVVM und code behind. Je nach komplexität des Kontrollements ist es entweder sinnvoll oder einfach Overhead, ein View-Model dazu zu bauen. Hier soll es jetzt um ein einfaches Control gehen und daher greifen wir auf code behind zurück.

Das Control

Entwickelt wird ein Datei-Auswahl-Control. Es besteht aus einem Label und einem Button. Klickt man den Button, so erscheint ein Datei-Öffnen-Dialog und die fürderhin ausgewählte Datei wird angezeigt. Gleichzeitig hat auch die extern sichtbare Eigenschaft „FileName“ ihren Wert geändert und alle Bindungen  darauf ändern sich mit. Die Wahl fällt auf ein Control mit Code-Behind. Somit können wir einfach auf das Click-Ereignis des Buttons reagieren. Dort muss dan aktiv der Wert der Eigenschaft geändert werden. Tatsächlich ist hier par Bindung das Control sein eigenes View-Model.
Bestandteile:

Vorgehen

Zunächst benötigen wir ein Control.
Daher legen wir eine XAML-Datei an und passend dazu eine Code-Behind-Datei. Wir erben von System.Windows.Control.

 

und

 

Controlaufbau

Das Control besteht aus zwei weiteren Controls: Button und Label. Wir fügen beide ein und Binden das Label an die noch zu erstellende Eigenschaft FileName. Damit das nacher funktioniert, muss die Source noch korrekt sein. Wir erreichen das recht einfach, indem wir dem Conttol(!) den DataContext setzen und auf sich selbs verweisen lassen. Das Control ist
so gesehen sein eigenes View-Model.

 

Abhängigkeitseigenschaften

Zur erfolgreichen Bindung benötigt das Control noch eine Dependency Property FileName:

mah beachte, wie per FrameworPropertyMetadata ein Standardwert mitgegeben wurde und die Bindungsoptionen standardmäßgig auf TwoWay definiert wurden. Dies hat im Folgenden den Vorteil, dass man von Extern (bei Verwendung) nicht bei Binungen Mode=TwoWay angeben muss.

Events

Wir wollten es einfacher mit dem Button. Nun fehlt noch der Eventhandler:

Man beachte hier die beiden Aufrufe der von DependencyObject stammenden Methodena: SetCurrenValue und ClearValue. Damit wird der Wert bzw. die Bindung hinter einem Dependency-Property geänder bzw. auf den Std.-Wert (aus den Metadaten) zurückgesetztgesetzt. Verwendet man vergleichsweise dazu GetValue/SetValue wie in der Implementierung des CLR-Properties, zerstört man die Bindung. Das wäre fatal, da dann die Funktionalität zusammenbricht. An dieser Stelle sein noch kurz auf die Doku verwiesen… Demnach seien CLR-Getter/Setter nur so zu implementieren, wie hier gezeigt. Nur Aufrufe von GetValue/SetValue und keine weiteren Aktionen nebenher. Denn WPF ruft gerne selbst GetValue/SetValue mit passenden Parametern auf und umgeht dabei die CLR-Properties. Zusatzaktionen
muss man daher in passenden PropertyChanged– bzw. CoerceValue– oder ValidateValue-Callbacks machen. Auf selbige wurde hier verzichtet.

Eingebaut

Nun sehen wir uns noch an, wie dieses Control zu verwenden ist. Dazu wurde ein WPF-Fenster gestaltet, dass dieses Control verwendet und gleichzeitig einen Textblock an unsere neue Dependency-Property bindet:

 

… es ist kein Code-Behind nötig….
Man beachte, wie zunächst der text „aus window“ im Control steht, und später der Wert aus dem Eventhandler des Controls (siehe im Code: cancel oder OK-Zweig).