MSBuild: Benutzerdefinierte Build-Aufgaben entwickeln

Schon vor langer Zeit habe ich mich erfolgreich vom F5-Build befreit :-) In den letzten beiden Wochen habe ich wieder verstärkt an der Anpassung verschiedener Build-Prozesse gewerkelt und auch meine lieben Kollegen in Sachen MSBuild gepimpt. Was mich heute dazu veranlasst hat, einige Informationen zum Thema benutzerdefinierte Build-Tasks aufzuschreiben und hier zu veröffentlichen...

Alles was ich benötige, um eigene Tasks für MSBuild zu entwickeln, ist Bestandteil des .NET Framework. Wie neue Tasks mit Hilfe von Visual Studio entwickelt werden können, habe ich hier kurz und knapp aufgeschrieben (ich setze allerdings voraus, das Sie bereits wissen, was MSBuild ist und wie es benutzt wird).

Man benötigt eine leeres Assembly-Projekt (Klassenbibliothek), in dem folgende Verweise gesetzt werden: Microsoft.Build.EngineMicrosoft.Build.FrameworkMicrosoft.Build.TasksMicrosoft.Build.Utilities.v3.5

Eine MSBuild-Aufgabe wird jeweils durch eine Klasse repräsentiert, die die Microsoft.Build.Framework.ITask-Schnittstelle implementiert. Da diese Schnittstelle Member definiert, die zur Unterstützung der MSBuild-Infrastruktur bestimmt sind und diese je Aufgabe immer wieder neu zu implementieren wären, habe ich mir eine Task-Basisklasse gebastelt, die ich für neue Aufgaben nutze. Diese sieht so aus...

public abstract class Task : ITask
{
  protected Task()
  {

  }

  public IBuildEngine BuildEngine
  {
    get;
    set;
  }

  public abstract bool Execute();

  public ITaskHost HostObject
  {
    get;
    set;
  }
}

Und dann kann es auch direkt losgehen... Als Beispiel habe ich mir eine GetFileInfo-Aufgabe überlegt, die die Informationen, die mit Hilfe der System.IO.FileInfo-Klasse ermittelt werden können, in MSBuild verfügbar zu machen.

public class GetFileInfo : Task
{
  public GetFileInfoTask() : base() { }

  [Required()]
  public ITaskItem[] Files
  {
    get;
    set;
  }

  [Output()]
  public ITaskItem[] FileInfos
  {
    get;
    set;
  }

  public override bool Execute()
  {
    ...

    return true;
  }
}

Die GetFileInfo-Klasse erbt von Task und überschreibt die Execute-Methode, welche die Aktionen der Aufgabe ausführt. Geben Sie den Wert true zurück, wenn die Aufgabe ordnungsgemäß ausgeführt werden konnte, andernfalls false.

Die GetFileInfo-Klasse definiert außerdem die Eigenschaften Files und FileInfos, die die Ein- und Ausgabe-Parameter der Aufgabe darstellen. Die Files-Eigenschaft ist mit einem Required-Attribut gekennzeichnet – somit wird dieser Parameter zur Pflicht. Die FileInfos-Eigenschaft stellt einen Ausgabeparameter dar; welche mit einem Output-Attribut gekennzeichnet werden.

Für Task-Eigenschaften können nur Typen verwendet werden, die durch MSBuild unterstützt werden – in den meisten Fällen werden Sie mit System.String oder Microsoft.Build.Framework.ITaskItem bzw. mit Arrays dieser Typen auskommen, wobei einige Besonderheiten zu beachten sind. Zunächst zeige ich jedoch, wie die GetFileInfo-Aufgabe im Script verwendet werden kann.

<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" DefaultTargets="Build">
  <PropertyGroup>
    <CustomTasksDir> ... </CustomTasksDir>
  </PropertyGroup>
  <UsingTask AssemblyFile="$(CustomTasksDir)CustomTasks.dll" TaskName="CustomTasks.GetFileInfo" />
  <ItemGroup>
    <Files Include="C:\temp\*.*" />
  </ItemGroup>
  <Target Name="Build">
    <CustomTasks.GetFileInfo Files="@(Files)">
      <Output ItemName="FileInfos" TaskParameter="FileInfos" />
    </CustomTasks.GetFileInfo>
  </Target>
</Project>

Mit Hilfe der UsingTask-Aufgabe kann die GetFileInfo-Aufgabe aus dem CustomTasks.dll-Assembly geladen werden (dabei muss die Aufgabe über den TaskName-Parameter mit vollem Typ-Bezeichner deklariert werden). Der Aufruf der GetFileInfo-Aufgabe erfolgt über das Build-Ziel, wobei im Files-Parameter eine Auflistung von Dateien gesetzt wird.

Nun zurück zu den Task-Eigenschaften...

In meinem Beispiel habe ich die Files-Eigenschaft als ITaskItem-Array deklariert.

...
[Required()]
public ITaskItem[] Files
{
  get;
  set;
}
...

Das bewirkt, das jedes Element aus Files als TaskItem übergeben wird; so...

Alternativ kann aber auch System.String bzw. System.String-Array verwendet werden. Ein Array von String macht hier allerdings keinen bedeutenden Unterschied...

Wird allerdings String verwendet, werden die Elemente als komma-separierte Liste übergeben – was natürlich nicht so praktisch ist, da diese für die weitere Verarbeitung erst wieder gesplittet werden müsste.

Der ITaskItem-Typ ist für alle Einsatzzwecke am besten geeignet. In den meisten Fällen kann über die ItemSpec-Eigenschaft eine Zeichenfolgenentsprechung abgerufen werden; wenn nicht, sollte die gewünschte Information in den Metadaten des jeweiligen Elements enthalten sein.

Als Ergebnis soll über die FileInfos-Eigenschaft eine Auflistung von Dateiinformationen zurückgeliefert werden, die folgender Struktur entspricht:

<ItemGroup>
  <FileInfos Include="Item">
    <Exists />
    <FullName /> 
    <DirectoryFullName /> 
    ...
  </FileInfos>
</ItemGroup>

Dazu kann die Microsoft.Build.Utilities.TaskItem-Klasse verwendet werden (oder man bastelt eine eigene Implementierung mit Hilfe der ITaskItem-Schnittstelle). Die Implementierung der Execute-Methode könnte so aussehen...

public override bool Execute()
{
  var taskItems = new List<TaskItem>();

  foreach (ITaskItem nextItem in this.Files)
  {
    var fi = new FileInfo(nextItem.ItemSpec);

    var fileItem = new TaskItem(fi.FullName);

    fileItem.SetMetadata(&quot;Exists&quot;, fi.Exists.ToString());
    fileItem.SetMetadata(&quot;FullName&quot;, fi.FullName);
    fileItem.SetMetadata(&quot;DirectoryFullName&quot;, fi.Directory.FullName);
    ...

    taskItems.Add(fileItem);
  }

  this.FileInfos = taskItems.ToArray();
  return true;
}

Und das geht raus...

Im Build-Script können die Informationen dann wie folgt genutzt werden...

<Target Name="Build">
  <CustomTasks.GetFileInfo Files="@(Files)">
    <Output ItemName="FileInfos" TaskParameter="FileInfos" />
  </CustomTasks.GetFileInfo>
  <Message Text="File: %(FileInfos.FullName), Size: %(FileInfos.Length)" Condition="%(FileInfos.Exsist == 'true')" />
</Target>