Installer einfach erstellen

Einführung in Windows Installer XML – das WiX Toolset

Einführung in Windows Installer XML – das WiX Toolset

Installer einfach erstellen

Einführung in Windows Installer XML – das WiX Toolset


Wer komplexe Anwendungen entwickelt und sie dann auf bequeme Weise ausliefern möchte, stößt bei seiner Suche nach dem richtigen Hilfsmittel auf Windows Installer XML – kurz WiX Toolset. Dieser Artikel bietet eine Einführung zum Aufbau eines WiX-Projekts.

Zu Beginn eines Entwicklungsprozesses muss man sich um vieles kümmern: Architektur, Konzepte und Verantwortlichkeiten sind nur ein kleiner Teil des Ganzen. Dabei wird oft ein wesentlicher Bestandteil im DevOps Cycle vergessen: die Verteilung der Anwendung, wenn sie fertig ist. Anders formuliert: Wie installiert der Nutzer später die Software auf seinem System?

Bei seiner Suche nach dem richtigen Hilfsmittel für dieses Problem stößt man auf Windows Installer XML – kurz WiX Toolset. In der Regel folgt dann die Frage: Warum soll ich das WiX Toolset nutzen? Gibt es doch eine Vielzahl an Alternativen wie ClickOnce, InstallShield oder dotNetInstaller, um nur drei zu nennen. Was sind also die Vorteile des WiX Toolsets?

Der wohl größte Vorteil ist die enorme Flexibilität, die das Toolset bietet. Es ist nicht nur möglich, einfache Installationsroutinen zu erstellen, die ausschließlich Dateien an die richtige Stelle kopieren. Es lassen sich auch komplexe Prozesse abbilden und eigene Custom Actions integrieren. Das Ganze wird, wie der Name sagt, in einer XML-Syntax abgebildet, wodurch Versionsverwaltung und Mergen relativ einfach sind. Besonders komfortabel ist zudem die Integration in Visual Studio. Auf der offiziellen Homepage des Projekts gibt es neben den Binaries auch einen Installer, um das WiX Toolset zu installieren. Nach einem Neustart von Visual Studio kann ohne weiteren Aufwand ein neues Set-up-Projekt angelegt werden. Erkauft wird diese Einfachheit durch das Fehlen diverser Komfortfunktionen. So ist im WiX Toolset beispielsweise keinerlei UI-Editor vorhanden, der Entwickler muss alles von Hand in XML implementieren. Dass dies aber kein echter Nachteil ist, wird im Folgenden gezeigt. Dazu widmen wir uns erst dem Grundaufbau eines Projekts, bevor wir uns besondere Kernfunktionalitäten näher ansehen wollen.

Aufbau des WiX-Projekts

Zentraler Punkt eines WiX-Projekts ist das Product-Element. Das Product kann als Einstiegspunkt verstanden werden, unter dem sich weitere Elemente vereinen. Ein Pflichtelement ist dabei das Package. Die dort eingetragenen Attribute sind später in den Eigenschaften der Installationsdatei einsehbar, beeinflussen aber auch deren Ausführung. Hier können beispielsweise Versionsnummern, Herstellerinformationen, aber auch die benötigten Rechte, um die Installation ausführen zu können, definiert werden.

Ein weiteres Kernelement ist das Feature. Damit lassen sich funktionale Unterscheidungen sauber trennen sowie später aus dem Installer herausnehmen, ohne große Anpassungen vornehmen zu müssen. Ein Feature-Element kann als „kleinste installierbare Einheit“ gesehen werden. Unter ihr werden einzelne Komponenten aufgeführt, die gemeinsam installiert werden. Über das Level-Attribut können Features von der Installation ausgeklammert werden. Dies kann beispielsweise für verschiedene Lizenzen oder aber für während der Installation vom Nutzer an- und abwählbare Features genutzt werden. In der Standardeinstellung werden alle Features mit Level 1 installiert, Features mit Level 0 sind deaktiviert. Dies kann über die InstallLevel Property, deren Default 1 ist, auch feingranularer gestaltet werden.

Über das Media-Element besteht die Möglichkeit, die Dateien der Installation auf mehrere Cabinets, und somit die Installation auf mehrere Datenträger zu verteilen. Da dies in Zeiten der Softwaredownloads selten nötig ist, empfiehlt es sich, alle Dateien innerhalb einer *.msi zu bündeln.

Die Ordnerstruktur der Anwendung wird über einfache Verschachtelung von Directory-Elementen festgelegt. Zur besseren Übertragbarkeit auf unterschiedliche Systeme werden bestimmte Werte vordefiniert. So verweist beispielsweise das Directory-Element mit der ID „WINDOWSVOLUME“ auf jedem System auf die Installationspartition des aktuellen Windows-Betriebssystems.

Um die eigentlichen Installationsinhalte, zu strukturieren, werden Component- oder ComponentGroup-Elemente, die mehrere Component-Elemente zusammenfassen, benutzt. Diese werden über ihre ID jeweils mindestens einem Feature zugeordnet. Component-Elemente, die keinem Feature zugeordnet wurden, erzeugen Link Errors und verhindern so einen erfolgreichen Build des WiX-Projekts. Wichtig ist an dieser Stelle, dass die angesprochenen Elemente nicht in der gleichen Datei liegen müssen. Es ist also möglich, die Anwendungsarchitektur auch im Installationsprojekt abzubilden und so den Dokumentationsaufwand zu minimieren.

Das Component-Element selbst kann beliebig viele Elemente verschiedener Typen beinhalten. Das am häufigsten benötigte Element ist das File-Element. Es dient zur Definition einer Datei, die während der Installationsroutine kopiert werden soll. In diesem Fall kann ein File auch als Key Path für die Komponente dienen. Der Windows Installer überprüft vor der Installation, ob der entsprechende Key Path bereits vorhanden ist und führt die Installation der Komponente nur durch, wenn dies nicht der Fall ist. Für andere Elemente müssen Key Paths entsprechend manuell vergeben werden. Abbildung 1 visualisiert den Aufbau eines WiX-Toolset-Projekts.

Abb. 1: Aufbau eines WiX-Projekts

Shortcuts

Um es dem Anwender so komfortabel wie möglich zu machen, verzichtet heutzutage kaum eine Installation darauf, Desktop-Shortcuts oder Startmenüeinträge anzulegen. Das WiX Toolset erlaubt uns, auch diese Funktionen in einfachem XML zu definieren. Desktop-Shortcuts und Startmenüeinträge folgen dem bekannten Muster aus Components, die einem Pfad zugeordnet werden und die eigentliche Datei enthalten. Um den Pfad zu definieren, können die mitgelieferten Standardwerte ProgramMenuFolder und DesktopFolder benutzt werden. Dieses Component-Element erhält drei Kindelemente:

  • Das Shortcut-Element, das den eigentlichen Shortcut enthält.
  • Das RegistryValue-Element, das den Key Path der Komponente enthält. Dieses Element ist notwendig, da ein Shortcut-Element, im Gegensatz zu einem File-Element, nicht selbst als Key Path fungieren kann.
  • Das RemoveFolder/File-Element, um die Deinstallation zu ermöglichen.

Um dem Anwender die Entscheidung zu ermöglichen, ob ein Desktop-Shortcut angelegt werden soll, muss eine Property angelegt werden, die über das Installer-UI befüllt wird. Zusätzlich muss innerhalb der Shortcut-Komponente ein Condition-Element hinzugefügt werden, das diese Property überprüft. Listing 1 beschreibt, wie die Shortcut-Komponente aufgebaut ist. Um Operatoren abzubilden, die der XML-Parser nicht als XML-Syntax erkennen soll, wird das CDATA-Element verwendet.

Listing 1: „Shortcuts.wxs“

...
<Property Id="APP_DESKTOP_CREATION">1</Property>

<DirectoryRef Id="DesktopFolder">
  <Component Id="DesktopShortcut" Guid="{ XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX }">
<Shortcut Id="DesktopShortcut"
  Name="Demo"
  WorkingDirectory='ApplicationFolder'
  Target="[ApplicationFolder]AIT.Demo.exe"
  IconIndex="0"
  Advertise="no"/>
<RemoveFile Id="DesktopFolder"
  Name="Demo"
  On="uninstall"/>
<RegistryValue Root='HKCU'
  Key='Software\[Manufacturer]\[ProductName]'
  Type='string'
  Value=''
  KeyPath='yes'/>
  <Condition <![CDATA[APP_DESKTOP_CREATION = 1]]></Condition>
  </Component>
</DirectoryRef>
...

User Interface und Properties

Doch wie kann der Nutzer diese Property befüllen? Dazu wird das UI des Installers angepasst. Das WiX Toolset wird bereits mit verschiedenen Benutzeroberflächen ausgeliefert. Damit kann ein einfacher Installationsassistent durch das Hinzufügen eines UIRef-Elements innerhalb des ProductElements erstellt werden. Diese bereits mitgelieferten User Interfaces bieten verschiedene anpassbare Optionen. So ist beispielsweise eine Installationsroutine, die das Einstellen des Installationspfads erlaubt, genauso enthalten wie ein Assistent, der das An- und Abwählen von Features integriert. Eine Liste aller bereits enthaltenen Benutzeroberflächen und deren Anwendung ist in der Dokumentation zu finden. Doch auch wenn kein UIRef-Element gesetzt wird, kann der Installer benutzt werden. Abbildung 2 zeigt einen Installer ohne explizit gesetztes User Interface.

Abb. 2: Das Beispiel in Aktion

Für das optionale Hinzufügen von Desktop-Shortcuts reichen die mitgelieferten User Interfaces jedoch nicht aus. Zum Glück erlaubt uns WiX, eigene User Interfaces zu gestalten. Dazu wird an beliebiger Stelle innerhalb eines UI-Elements ein neues Dialog-Element inklusive ID erstellt und mit beliebig vielen Control-Elementen befüllt. Die Control-Elemente besitzen immer einen eindeutigen Typ, wie zum Beispiel PushButton oder CheckBox. Über X- und Y-Werte kann die Position des Controls innerhalb des Fensters bestimmt werden. Width und Height geben die Größe des Controls an. Listing 2 zeigt einen vollständigen Dialog, der es ermöglicht, eine Wahl bezüglich des Desktop-Shortcuts zu treffen. Besonderes Augenmerk muss dabei auf die Checkbox gelegt werden. Diese wird über die Attribut-Property mit der in Listing 1 angelegten Property verknüpft. Das Attribut CheckBoxValue definiert den initialen Zustand der CheckBox. Wird jetzt die Checkbox deaktiviert, so wird auch der Wert der verknüpften Property verändert und somit die zu installierenden Komponenten über Conditions angepasst (Abb. 3).

Um den Dialog anzuzeigen, muss er noch veröffentlicht werden. Dafür wird für jeden möglichen Schritt ein Publish-Element angelegt. Dieses Element beinhaltet die ID des Controls, das eine Aktion ausführt, sowie das Event, das ausgelöst werden soll, zumeist NewDialog oder EndDialog, und ein Value, der der ID eines neuen Dialogs entspricht.

Listing 2: „Setup.UI.wxs“

 
...
<Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return">1</Publish>
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="DesktopShortCutCreationDlg">1</Publish>
<Publish Dialog="DesktopShortCutCreationDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
<Publish Dialog="DesktopShortCutCreationDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg">1</Publish>
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="DesktopShortCutCreationDlg">1</Publish>

<Dialog Id="DesktopShortCutCreationDlg" Width="370" Height="270" Title="Demo">

  <Control Id="Back" Type="PushButton" X="180" Y="243" Width="56" Height="17" Text="Back" />
  <Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="Next">
  </Control>

  <Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="Cancel">
    <Publish Event="SpawnDialog" Value="CancelDlg">1</Publish>
  </Control>

  <Control Id="CreateDesktopItem" Type="CheckBox" Height="18" Width="295" X="20" Y="170" Text="Create Desktop Icon" Property="APP_DESKTOP_CREATION" CheckBoxValue="1"></Control>

  <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="Description" Type="Text" X="25" Y="23" Width="280" Height="15" Transparent="yes" NoPrefix="yes" Text="Demo Setup" />

  <Control Id="Title" Type="Text" X="15" Y="6" Width="200" Height="15" Transparent="yes" NoPrefix="yes" Text="Demo Title" />

</Dialog>
...
Abb. 3: Angepasstes Installer-UI

Custom Actions

Das mächtigste Feature des WiX Toolsets ist die Möglichkeit, Custom Actions zu definieren. Die Custom Actions ermöglichen es, komplexe Operationen während einer Installation oder auch einer Deinstallation auszuführen. Diese Custom Actions können sowohl nativ (beispielsweise in C++) oder in managed .NET-Code implementiert werden. In letzterem Fall steht somit die vollständige Funktionalität des .NET Frameworks zur Verfügung. Dies wird gewährleistet, indem Custom Actions nicht im WiX-Projekt, sondern in einer eigenen Klassenbibliothek implementiert werden. Das führt zu einer vieldiskutierten Einschränkung: Als Voraussetzung für die Installation mit Custom Actions muss auf dem Zielsystem zwingend das .NET Framework installiert sein. Da das WiX Toolset den Bootstrapper Burn enthält, kann als Teil des Installers das .NET Framework mitgeliefert und installiert werden. Vorsicht ist nur beim Deinstallieren geboten, damit die einzelnen Komponenten in der richtigen Reihenfolge entfernt werden. Kritiker sehen in der Implementierung in .NET jedoch eine zusätzliche Abhängigkeit, die während der Installation aufgelöst werden muss. Was schwerer wiegt, muss von Fall zu Fall entschieden werden.

Bevor eine Custom Action implementiert wird, sollte man sich Gedanken darüber machen, in welchem Kontext sie ausgeführt werden muss. Es wird generell zwischen Deferred und Immediate Custom Actions unterschieden. Beide Arten von Custom Actions können im Userkontext ausgeführt werden. Darüber hinaus können Deferred Custom Actions auch innerhalb des Systemkontexts als Elevated Actions, also mit Administratorrechten, laufen. Während eine Immediate Action sofort ausgeführt wird, wenn sie in der Sequenztabelle aufgerufen wird, wird eine Deferred Action in das Installationsskript aufgenommen und erst ausgeführt, wenn das Skript ausgeführt wird. Das bringt die Einschränkung mit sich, dass eine Deferred Action zwingend zwischen InstallInitialize und InstallFinalize ausgeführt werden muss. Dadurch hat die Deferred Action auch keinen Zugriff auf die innerhalb der Installation erstellte Installationsdatenbank, beziehungsweise nur sehr eingeschränkten Zugriff auf die Session. Dem gegenüber stehen die Immediate Actions, die vollen Zugriff auf Properties, Features und Komponenten der Installation haben. Somit kann eine Immediate Action auch den weiteren Verlauf der Installation beeinflussen, etwa indem Features deaktiviert oder Komponenten beeinflusst werden.

Im folgenden Beispiel entscheiden wir uns dafür, eine Custom Action in C# zu implementieren. Dazu sind einige Vorbereitungen notwendig. Zunächst muss eine neue Klassenbibliothek angelegt werden. In dieser Klassenbibliothek wird anschließend eine CustomActions.config-Datei erstellt. Damit die Konfigurationsdatei in die *.msi eingebunden wird, muss der Output Type der Datei auf „Content“ gestellt werden. Der Inhalt dieser Datei ist in Listing 3 beschrieben. Darin wird definiert, welche .NET-Versionen unterstützt werden. Hierbei muss darauf geachtet werden, dass bis Version 3.5 alle SupportedRuntimeVersions als 2.0 deklariert werden.

Soll die Custom Action diese Versionen unterstützen, muss das Flag useLegacyV2RuntimeActivationPolicy auf True gesetzt werden. Anschließend muss die *.csproj-Datei um die Zeilen aus Listing 4 erweitert werden. Zu guter Letzt muss noch die Assembly Microsoft.Deployment.WindowsInstaller aus ..\Program Files (x86)\WiX Toolset v3.XX\SDK referenziert werden. Damit ist die Vorbereitung der Klassenbibliothek abgeschlossen und die Custom Action kann eingebunden werden.

Listing 3: „CustomActions.config“

 
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <startup useLegacyV2RuntimeActivationPolicy="true">
    <supportedRuntime version="v4.0" />
    <supportedRuntime version="v2.0.50727"/>
  </startup>
</configuration>

Listing 4: „CustomActions.csproj“

 

<PropertyGroup>
...
<WixCATargetsPath Condition=" '$(WixCATargetsPath)' == '' ">$(MSBuildExtensionsPath)\Microsoft\WiX\v3.x\Wix.CA.targets</WixCATargetsPath>
...
</PropertyGroup>
<Import Project="$(WixCATargetsPath)" />


Um das zu erreichen, muss die Bibliothek über das Binary-Element geladen werden. Unterhalb des ProductElements werden die in Listing 5 aufgeführten Zeilen eingefügt. Trifft die Bedingung, die innerhalb des Custom-Elements deklariert wird, zu, wird die verknüpfte Methode DemoMethod ausgeführt. Im Beispiel wird die Methode ausschließlich ausgeführt, wenn die Anwendung deinstalliert wird.

Listing 5: „Product.wxs“

 
<Binary Id="CustomActions" SourceFile="$(var.AIT.SetupDemo.CA.TargetDir)AIT.SetupDemo.CA.dll" />
<CustomAction Id="CA.DemoId" BinaryKey="CustomActions" DllEntry="DemoMethod" Execute="deferred" />
<InstallExecuteSequence>
<Custom Action="CA.DemoId" Before="InstallFinalize"><![CDATA[(NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")]]></Custom>
</InstallExecuteSequence>


Die eigentliche Custom Action wird als statische Methode in einer statischen Klasse angelegt. Listing 6 stellt solch eine Methode dar. Besonderes Augenmerk sollte an dieser Stelle auf das Exception Handling gelegt werden. Tritt im Laufe einer Installation eine Exception auf oder wird die Installation manuell abgebrochen, wird ein Rollback der Installation ausgeführt. Für Custom Actions, die am Zustand der Maschine etwas verändern, empfiehlt es sich daher, eine angepasste Rollback Custom Action zu implementieren, die im Fehlerfall Änderungen rückgängig macht. Dazu wird das Attribut Execute auf den Wert „Rollback“ gesetzt.

Generell empfiehlt sich die in Listing 7 gezeigte Methode, Exceptions generell zu fangen und mit dem ActionResult-Rückgabewert zu arbeiten. Dadurch kann über das Session-Objekt der Fehler sauber geloggt werden, und der Effekt ist durch das ActionResult der gleiche.

Listing 6: „CustomAction.cs“

 
[CustomAction]
public static ActionResult DemoMethod(Session session)
{
  if (session == null)
  {
    throw new ArgumentNullException(nameof(session));
  }

  try
  {
// Do Something...

    return ActionResult.Success;
  }
  catch (Exception ex)
  {
    session.Log(ex.Message);
    return ActionResult.Failure;
  }
}

Versionierung

Um den Installer eindeutig identifizieren zu können, wird er mit einer Versionsnummer im ProductElement versehen. Dabei ist es sinnvoll, die Version aus der Assembly oder der Anwendung zu beziehen, die durch den Installer verteilt wird. Um auf den ersten Blick die Version eines Installers erkennen zu können, sollte diese im Namen ersichtlich sein. Das kann durch eine einfache Anpassung der WiX-Projektdatei realisiert werden.

Listing 7: „Product.wxs“

 
<Product Id=" XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX "
  Name="!(loc.SetupProductName)"
  Language="!(loc.SetupLCID)"
  Version="!(bind.FileVersion.InstalledAssembly.dll)"
  Manufacturer="!(loc.SetupManufacturer)"
  UpgradeCode="{ XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX }" >

<Target Name="AfterBuild">
  <GetAssemblyIdentity AssemblyFiles="$(OutDir)\ InstalledAssembly.dll">
    <Output TaskParameter="Assemblies" ItemName="AssemblyVersion"/>
  </GetAssemblyIdentity>
  <Copy SourceFiles="$(OutputPath)en-us\$(OutputName).msi" DestinationFiles="$(OutputPath)en-us\$(OutputName) %(AssemblyVersion.Version).msi" />
  <Copy SourceFiles="$(OutputPath)en-us\$(OutputName).wixpdb" DestinationFiles="$(OutputPath)en-us\$(OutputName) %(AssemblyVersion.Version).wixpdb" />
  <Delete Files="$(OutputPath)en-us\$(OutputName).msi" />
  <Delete Files="$(OutputPath)en-us\$(OutputName).wixpdb" />
</Target>


Deinstallation

Wer Anwendungen installiert, möchte sie in der Regel auch irgendwann wieder deinstallieren. Natürlich bietet das WiX Toolset auch hierfür umfangreiche Konfigurationsmöglichkeiten. Für alle über ComponentElemente installierten Dateien reicht es aus, das Attribut Permanent der Komponente auf no zu setzen. Dadurch werden die Dateien beim Deinstallieren entfernt. Es kann jedoch auch sein, dass eine Anwendung selbst noch weitere Dateien dem Installationsordner hinzufügt, gespeicherte Konfigurationen etwa. Um eine Deinstallation dieser Dateien zu ermöglichen, wird ein eigenes Component-Element angelegt. Diesem Element werden beliebig viele Elemente vom Typ RemoveFolder oder RemoveFile hinzugefügt.

Das RemoveFolder-Element enthält mindestens zwei Attribute: eine eindeutige ID sowie das Attribut On, das den Vorgang bezeichnet, mit dem der Ordner entfernt werden soll. Hier sind die Werte Uninstall, Install und Both möglich. Dadurch ist es also auch möglich, bei der Installation definierte Ordner und Dateien zu entfernen.

Der zu entfernende Ordner wird durch das DirectoryRef-Element definiert, in dem sich die Komponente befindet. Listing 8 zeigt ein Component-Element, das den Anwendungsordner inklusive aller Dateien darin entfernt. Dabei werden alle Files des Ordners über RemoveFile-Elemente entfernt. Um hier mehrere Dateien auf einmal zu löschen, können für die Dateinamen Wildcards vergeben werden. Abschließend wird noch der Ordner über das bekannte RemoveFolder-Element gelöscht. Ist ein Registry Eintrag als Key Path einer Komponente hinterlegt, so erfolgt das Entfernen des Eintrags automatisch mit dem Entfernen der Komponente.

Listing 8

 
<DirectoryRef Id="ApplicationFolder">
<Component Id="Uninstall"
Win64="yes"
Guid="{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}">
<RemoveFile Id="RemoveDll"
            Name="*.dll"
            On="uninstall"/>
<RemoveFile Id="RemoveExe"
            Name="*.exe"
            On="uninstall"/>
<RemoveFile Id="RemoveConfig"
            Name="*.config"
            On="uninstall"/>
<RemoveFolder Id="RemoveFolder"
              Property="ApplicationFolder"
              On="uninstall"/>
</Component>
</DirectoryRef>


Fazit

Das XML-Format und der fehlende UI-Editor mögen den einen oder anderen Entwickler abschrecken. Nicht leugnen kann man jedoch die Tatsache, dass sich mit etwas Übung einfache Installationen innerhalb von Minuten implementieren lassen und sukzessive ausgebaut werden können. Besonders was die Quellcodeverwaltung, Versionierung und die Möglichkeiten durch Custom Actions angeht, punktet das WiX Toolset. Durch den Burn Bootstrapper können darüber hinaus mehrere Installationspakete hintereinander ausgeführt werden. Dadurch wird es nicht nur für kleine, sondern auch für sehr komplexe Installationen interessant.

Eike Brändle

Eike Brändle ist Consultant bei der AIT GmbH & Co. KG. Er berät Unternehmen bei der Konzeption und Umsetzung von Softwareentwicklungsprojekten auf Basis von Microsoft-Technologien.


Weitere Artikel zu diesem Thema