.NET-Code-Generierung

Neue Tricks zum Generieren von Code in .NET und C#
5
Jan

C# Source Generators

C# Source Generators dienen der automatischen Erstellung von Code, was zum Beispiel bei Routinearbeiten hilfreich sein kann, wenn Codeteile nach einem fixen Schema erstellt werden sollen. In diesem Artikel wird gezeigt, wie C# Source Generators eingesetzt werden können und was sie von bestehenden Optionen zur Codegenerierung unterscheidet.

Ein wichtiger Trick, um beim Entwickeln von Code produktiver zu werden, ist, Routinetätigkeiten zu vermeiden. Code, den man ohne großes Nachdenken nach einem gewissen Schema schreiben kann, könnte man wahrscheinlich generieren, statt ihn manuell zu erstellen. In Visual Studio gibt es dafür ein Werkzeug, das recht weit verbreitet ist: T4 Templates. T4 Templates können von Visual Studio zur Entwicklungszeit ausgeführt werden. Alternativ kann man T4 in sein eigenes Programm einbetten und zur Laufzeit Code generieren (z. B. SQL Statements). Leider hat T4 jedoch erhebliche Nachteile. Erstens ist es eng an Visual Studio gebunden. Die Zeit, in der jede C#-Entwicklerin zum Schreiben von C# Visual Studio verwendet hat, ist vorbei. Manche bevorzugen Visual Studio Code oder IDEs von Drittanbietern, wie beispielsweise Rider. Zweitens ist T4 veraltet, kann nur mit Mühe in .NET-Core-Projekten eingesetzt werden und wird nicht mehr aktiv weiterentwickelt. Es muss also eine Alternative her.

Wie auch beim Compiler geht Microsoft im Bereich Codegenerierung weg von Lösungsansätzen, die an eine bestimmte IDE gebunden sind. Wenn Code zur Entwicklungs- oder Übersetzungszeit generiert werden soll, gibt es eigentlich nur einen Platz, an den eine solche Komponente gut hinpasst: Roslyn. Microsoft hat für Roslyn vor einigen Monaten die neue C#-Source-Generator-Funktion als Preview vorgestellt. Sie löst Werkzeuge wie T4 zum Generieren von Code zur Übersetzungszeit ab. Code- oder Textgenerierung zur Laufzeit ist kein Thema für die neuen Source Generators.

Verschaffen Sie sich den Zugang zur .NET- und Microsoft-Welt mit unserem kostenlosen Newsletter!

Routinearbeiten automatisieren

Bevor wir uns ansehen, wie C# Source Generators funktionieren, behandeln wir mögliche Einsatzszenarien. Das erste und offensichtlichste Einsatzszenario ist, Codeteile, die keine originäre Logik abbilden, sondern nach einem fixen Schema erstellt werden, automatisch zu generieren. Ein typisches Beispiel ist das in C# gut bekannte INotifyPropertyChanged-Interface. Es ist langweilig und zeitraubend, für eine C#-Klasse mit normalen Properties INotifyPropertyChanged zu implementieren. Man braucht dafür keine Kreativität, es ist einfach nur eine Menge Tipparbeit. Diese Aufgabe könnte ein Generator übernehmen. Ein weiteres Beispiel ist die Generierung von Modellklassen auf Basis von strukturierten Dateien wie zum Beispiel CSV, XML oder JSON. Viele Leserinnen und Leser haben sicherlich schon die diversen Internetseiten zum Generieren von C#-Klassen aus solchen Dateien genutzt, weil man sich die Arbeit des manuellen Erstellens des entsprechenden Codes sparen will. Auch in einem solchen Szenario könnte ein C# Source Generator das Codegenerieren übernehmen.

Erfahrene C#-Entwicklerinnen und -Entwickler könnten an dieser Stelle einwenden, dass man schon jetzt Code generieren kann, unter anderem mit den Klassen aus dem Namespace System.Reflection.Emit oder mit Hilfe von Expression Trees. Ideal sind solche Ansätze aber nicht. Erstens sind sowohl Reflection.Emit als auch Expression Trees komplex zu verwenden und zweitens erfolgt die Codegenerierung zur Laufzeit. Das beeinflusst die Performance negativ.

Dependency Injection und AoT Compilation

Neben dem Automatisieren von Routinetätigkeiten gibt es noch weitere Einsatzbereiche von Codegenerierung, die nicht so offensichtlich sind. Ein Beispiel ist Dependency Injection (kurz DI). Die DI-Magie von ASP.NET basiert heute zu großen Teilen auf Reflection. Controller werden zur Laufzeit über Reflection gesucht. Die Konstruktorparameter werden mit Hilfe von Reflection untersucht und die notwendigen Werte werden zur Laufzeit bereitgestellt. Funktional ist das einwandfrei. Es hat jedoch zwei wesentliche Nachteile: Erstens braucht der Einsatz von Reflection zur Laufzeit CPU-Zeit und verlangsamt daher den Start einer Webanwendung. Dieser Performancenachteil ist zwar nicht riesig, er macht sich aber speziell bei Cloud-Native-Lösungen bemerkbar, bei denen häufig Serverinstanzen neu starten (z. B. Cold Start bei Serverless Functions, Scale Out bei Autoscaling).

Zweitens erschwert Reflection das Optimieren des Codes durch einen Linker beim Ahead-of-Time-Übersetzen (kurz AOT). Der Compiler könnte beim Einsatz von AOT Teile des Programms, die nicht verwendet werden, entfernen und dadurch die Größe der Anwendung reduzieren. Wenn aber Reflection zum Einsatz kommt, tut sich der Compiler schwer, zu erkennen, was zur Laufzeit benötigt wird und was nicht. Durch Generierung von Source Code könnte man einen grundlegend neuen Ansatz für DI verfolgen. Die Abhängigkeiten könnten zur Übersetzungszeit analysiert und der sich ergebende Code könnte generiert werden. Reflection würde zur Laufzeit komplett entfallen und der Compiler könnte unnötige Codeteile problemlos entfernen. In anderen Programmiersprachen wird dieser Ansatz zum Teil bereits eingesetzt (vgl. Wire-Komponente von Google für Go).

Grundlagen

Das Entwickeln eines C# Source Generators hat Ähnlichkeit mit dem Programmieren eines Roslyn Analyzers. Wer daher einen Generator schreiben möchte, sollte mit dem Roslyn API vertraut sein. Aus Platzgründen enthält dieser Artikel keine Einleitung in die Programmierung mit Roslyn. In einem Artikel wäre das auch unmöglich zu schaffen, man bräuchte eine ganze Artikelserie. Wer noch nie mit Roslyn entwickelt hat, braucht aber keine Angst zu haben. Die wichtigsten Konzepte werde ich erklären und die Codebeispiele sind einfach gehalten. Bei der Planung einer eventuellen Generatorentwicklung sollte man aber nicht vergessen, Zeit für die Einarbeitung in die Roslyn-Grundlagen einzuplanen. Technisch gesehen ist ein Generator eine .NET Standard 2.0 Class Library, die zumindest die NuGet-Pakete Microsoft.CodeAnalysis.Analyzers und Microsoft.CodeAnalysis.CSharp referenziert. Zum Zeitpunkt des Schreibens dieses Artikels war die C#-Source-Generator-Funktion noch in der Preview-Phase. Man musste daher die Preview-Pakete verwenden, um Generatoren programmieren zu können.

In dem Assembly, in dem man Code durch den Generator erzeugen will, referenziert man die Generator Class Library wie einen Roslyn Analyzer. Wenn C# Source Generators einmal etabliert sein werden, wird man sich die Generatoren, die man möchte, so von NuGet holen, wie man das heute mit Roslyn Analyzers macht. Da wir in diesem Artikel hinter die Kulissen von Generatoren schauen wollen, gehe ich davon aus, dass wir eine Solution haben, in der sowohl der Generator als auch das Programm enthalten sind, in dem der Code generiert werden soll. In diesem Fall referenziert man den Analyzer mit einer Projektreferenz mit den Optionen OutputItemType=”Analyzer” und ReferenceOutputAssembly=”false“. Abbildung 1 zeigt die Projektkonfiguration eines Generators aus einem Beispielprojekt von mir.

Abb. 1: Projektkonfiguration für C# Source Generator

 

Der Roslyn-C#-Compiler erkennt, dass ein Source Generator referenziert wurde und führt ihn im Rahmen des Kompilierens der Anwendung automatisch aus. Visual Studio erkennt die Referenz auf den Generator ebenfalls und lässt ihn schon während des Editierens laufen. Dadurch erhält man in Visual Studio IntelliSense für den generierten Code. In der Praxis gibt es hier aber noch eine Menge Verbesserungsbedarf. Bei meinen bisherigen Versuchen mit der aktuellen Visual-Studio-2019-Preview-Version funktionierte IntelliSense in Verbindung mit den C# Source Generators nur bedingt. Auch für das Debuggen von Generatoren gibt es noch keine befriedigende Lösung. Das kann man zum Teil umgehen, indem man sich für die Logik des Generators eigene Unit-Tests schreibt und sie bei Bedarf mit dem Debugger startet. Alles in allem muss man sich aber auf einige Ecken und Kanten einstellen, wenn man schon jetzt in die Entwicklung von Generatoren einsteigt.

Entwicklung eines Generators

Der Generator selbst ist eine C#-Klasse, die das Interface ISourceGenerator implementiert und die mit dem Attribut Generator versehen ist. ISourceGenerator verlangt zwei zu implementierende Methoden:

  • Initialize: Diese Methode enthält üblicherweise dann Code, wenn der generierte Code auf bestehendem, selbst geschriebenem Code aufbaut. Ein Beispiel dafür wäre ein Generator, der INotifyPropertyChanged-kompatible Properties auf Basis bestehender, mit einem bestimmten Attribut versehener Properties erzeugt. In der Initialize-Methode kann man ein Visitor-Objekt vom Typ ISyntaxReceiver registrieren, das Roslyn für jede Syntax Node im bestehenden C#-Programm aufruft. Man kann die Syntax Node untersuchen und sich jene Knoten merken, die für die spätere Codegenerierung (siehe Execute-Methode) relevant sind. Wenn der generierte Code unabhängig vom restlichen C#-Code ist (z. B. Generieren einer C#-Modellklasse, die auf Basis einer CSV-Datei erzeugt wird), bleibt die Initialize-Methode leer.
  • Execute: In dieser Methode passiert das eigentliche Generieren des C#-Codes. Über das Roslyn API kann man bei Bedarf auf den Syntax Tree sowie das semantische Modell (z. B. Symbole mit den zugehörigen Typinformationen) des manuell geschriebenen Codes zugreifen. Auf die Ergebnisse der Codeanalyse in der oben erwähnten Initialize-Methode hat man ebenfalls Zugriff. Aber Achtung: Man bekommt nicht den generierten Code anderer Generatoren, die ebenfalls angewandt werden, und man muss damit rechnen, dass im C#-Code eventuell Fehler enthalten sind.

Lassen Sie uns zum Starten einen Blick auf ein „Hello World“-Beispiel werfen. Listing 1 enthält einen einfachen Generator, der eine statische Klasse HelloWorld mit einer statischen Methode SayHello generiert. Diese Methode gibt eine Liste aller .cs-Dateien des Projekts auf der Konsole aus. In diesem einfachen Beispiel lassen wir die Initialize-Methode leer. In Execute verwenden wir einen StringBuilder, um den generierten Code zusammenzubauen. Als Grundlage für das Codegenieren dienen uns die Roslyn Syntax Trees, auf die wir über GeneratorExecutionContext.Compilation.SyntaxTrees Zugriff haben. Im Programm, das den Generator referenziert, können wir den generierten Code mit HelloWorldGenerated.HelloWorld.SayHello() aufrufen und sehen dadurch die Liste der .cs-Dateien unseres Projekts auf dem Bildschirm.

 
using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace SourceGeneratorSamples
{
  [Generator]
  public class HelloWorldGenerator : ISourceGenerator
  {
    public void Initialize(GeneratorInitializationContext context) { }

    public void Execute(GeneratorExecutionContext context)
    {
      // Start building C# code
      var sourceBuilder = new StringBuilder(@"
using System;
namespace HelloWorldGenerated
{
  public static class HelloWorld
  {
    public static void SayHello() 
    {
      Console.WriteLine(""The following syntax trees existed in the compilation that created this program:"");
");

      // Get a list of syntax trees (.cs files)
      var syntaxTrees = context.Compilation.SyntaxTrees;

      // Add output of each file path for each syntax tree
      foreach (SyntaxTree tree in syntaxTrees)
      {
        sourceBuilder.AppendLine([email protected]"Console.WriteLine(@"" - {tree.FilePath}"");");
      }

      // Close generated code block
      sourceBuilder.Append(@"
    }
  }
}");

      // Inject the created source
      context.AddSource("helloWorldGenerated", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8));
    }
  }
}

Die Initialize-Methode

Jetzt, da wir die Grundlagen kennen, bauen wir einen Generator, der erstens auf bestehendem Code aufbaut und zweitens das semantische Modell des bestehenden Codes analysiert. Der Code, den ich in diesem Teil des Artikels zeige, ist Teil eines größeren Beispiels, das ich für .NET- und C#-Workshops und Trainings entwickelt habe. Interessierte Leserinnen und Leser finden es auf GitHub.

In diesem Beispiel geht es darum, Computerspieler für ein klassisches Schiffe-versenken-Spiel zu identifizieren. Man erkennt Computerspielerklassen daran, dass sie von der Basisklasse PlayerBase abgeleitet sind. Natürlich könnte man das Problem lösen, indem man zur Laufzeit über Reflection die entsprechenden Klassen heraussucht. Über die Probleme, die dieser Lösungsansatz mit sich bringt, habe ich oben schon geschrieben. In diesem Artikel möchten wir über einen C# Source Generator automatisch eine Datenstruktur erzeugen, in der alle Computerspieler referenziert sind. Reflection ist dadurch zur Laufzeit nicht mehr notwendig.

Listing 2 zeigt den ersten Teil unseres Generators. Im Mittelpunkt steht die zuvor schon erwähnte Initialize-Methode, die im „Hello World“-Beispiel noch leer war. Listing 2 zeigt, wie man in Initialize einen Syntax Receiver registriert. In unserem Syntax Receiver prüfen wir bei jeder Klassendeklaration, ob die Klasse eine Basisklasse hat. Die Klassen, bei denen das der Fall ist, sind potenziell zu berücksichtigende Computerspieler. Wir sammeln alle Kandidatenklassen in einer Liste, auf die wir später in Execute zurückgreifen werden.

 
namespace NBattleshipCodingContest.PlayersGenerator
{
  using Microsoft.CodeAnalysis;
  using Microsoft.CodeAnalysis.CSharp;
  using Microsoft.CodeAnalysis.CSharp.Syntax;
  using Microsoft.CodeAnalysis.Text;
  using System;
  using System.Collections.Generic;
  using System.Linq;
  using System.Text;

  /// <summary>
  /// Generates a static list of battleship players.
  /// </summary>


  /// <remarks>
  /// This generator makes runtime reflection for identifying battleship player no
  /// longer necessary. Leads to faster startup.
  /// </remarks></span>
  [Generator]
  public class PlayersGenerator : ISourceGenerator
  {
    /// <summary>
    /// Visitor class that finds possible players.
    /// </summary>

    internal class SyntaxReceiver : ISyntaxReceiver
    {
      public List<ClassDeclarationSyntax> CandidateClasses { get; } = new();

      public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
      {
        // Players must derive from PlayerBase -> candidate classes must
        // have at least one base type.
        if (syntaxNode is ClassDeclarationSyntax cds && cds.BaseList != null && cds.BaseList.Types.Any())
        {
          CandidateClasses.Add(cds);
        }
      }
    }

    public void Initialize(GeneratorInitializationContext context) =>
      context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());

    ...
  }
}

Die Execute-Methode

Listing 3 enthält die Execute-Methode unseres Generators. Hier einige wichtige Hinweise zum Code:

  • Über GeneratorExecutionContext.SyntaxReceiver können wir auf unseren Syntax Receiver (siehe Listing 2) zugreifen und kommen dadurch zur Liste möglicher Computerspielerklassen.
  • Um zu überprüfen, ob eine Klasse die richtige Basisklasse hat, reicht der Blick auf den Syntax Tree nicht aus. Es könnte zum Beispiel sein, dass es auch in anderen Namespaces eine PlayerBase-Klasse gibt. Im Syntax Tree wäre nicht zu erkennen, ob wir es mit dem richtigen Typ zu tun haben. Aus diesem Grund sucht sich der Code in Listing 3 das entsprechende Symbol aus dem semantischen Modell von Roslyn mit Hilfe von GetTypeByMetadataName bzw. GetDeclaredSymbol.
  • Um den Code etwas interessanter zu gestalten, ermögliche ich es, Computerspieler über das Attribut Ignore auszuschließen. Dadurch zeigt Listing 3, wie man im Generator überprüfen kann, ob ein Attribut vorhanden ist. Diese Anforderung findet man meiner Erfahrung nach häufig bei Generatoren.
 
public void Execute(GeneratorExecutionContext context)
{
  if (context.SyntaxReceiver is not SyntaxReceiver receiver || receiver.CandidateClasses == null)
  {
    throw new InvalidOperationException("Wrong syntax receiver or receiver never ran. Should never happen!");
  }

  // Begin creating the source we'll inject into the users compilation
  StringBuilder sourceBuilder = new(@"
namespace NBattleshipCodingContest.Players
{
using System;

public static class PlayerList
{
public static readonly PlayerInfo[] Players = new PlayerInfo[] 
{
");

  // Mandator base class for players
  var playerBaseSymbol = context.Compilation.GetTypeByMetadataName("NBattleshipCodingContest.Players.PlayerBase");
  if (playerBaseSymbol == null)
  {
    return;
  }

  // Attribute used to ignore specific players (e.g. players that crash or are not finished)
  var playerIgnoreSymbol = context.Compilation.GetTypeByMetadataName("NBattleshipCodingContest.Players.IgnoreAttribute");
  if (playerIgnoreSymbol == null)
  {
    return;
  }

  var playerClasses = new List<INamedTypeSymbol>();
  foreach (var f in receiver.CandidateClasses)
  {
    var model = context.Compilation.GetSemanticModel(f.SyntaxTree);
    if (model.GetDeclaredSymbol(f) is not INamedTypeSymbol ti)
    {
      continue;
    }

    // Check whether the class has the correct base class
    if (!ti.BaseType?.Equals(playerBaseSymbol, SymbolEqualityComparer.Default) ?? false)
    {
      continue;
    }

    // Check whether the class has no ignore attribute
    if (!ti.GetAttributes().Any(a => a.AttributeClass?.Equals(playerIgnoreSymbol, SymbolEqualityComparer.Default) ?? false))
    {
      // Found a player
      playerClasses.Add(ti);
    }
  }

  // Add all players to generated code
  sourceBuilder.Append(string.Join(",", playerClasses.Select(c =>
  {
    var displayString = c.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
    return @$"new PlayerInfo(""{displayString}"", () => new {displayString}())";
  })));

  // finish creating the source to inject
  sourceBuilder.Append(@"
};
}
}");

 // inject the created source into the users compilation
  context.AddSource("PlayersGenerated", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8));
}

Die Listings in diesem Artikel sind im Vergleich zum Code auf GitHub etwas vereinfacht. Ich habe hier die Unit-Tests weggelassen. Interessierte Leserinnen und Leser finden den Testcode im GitHub Repository.

Fazit

Aus meiner Sicht schließt Microsoft mit den C# Source Generators eine wichtige Lücke, die es in Roslyn und C# seit dem De-facto-Wegfall von T4 Templates gibt. Ich vermute, dass ich in meinen Projekten in Zukunft viele Anwendungsfälle für C# Source Generators finden werde. Im Lauf der Zeit erwarte ich mehr und mehr fertige Generatoren von Microsoft und von Drittanbietern und es wird seltener werden, dass man sich individuelle Generatoren selbst programmieren muss. Es ist außerdem wahrscheinlich, dass Microsoft Generatoren intern in Frameworks wie ASP.NET nutzen wird, um Reflection-basierenden Code zu reduzieren.

Jetzt ist die richtige Zeit, um mit den Generatoren zu experimentieren und sich mit dem API vertraut zu machen. Ein Nebeneffekt ist, dass man gezwungen ist, eventuelle Wissenslücken in Sachen Roslyn API zu schließen. Wer sich schon jetzt an eigene Generatoren heranwagt, der muss aber ein wenig Frust aushalten, da das Visual Studio Tooling noch stark verbesserungswürdig ist. Das ändert aber nichts an der Sache, dass C# Source Generators großes Potenzial für zukünftige Projekte haben.

Weiterführende Information zum Thema finden Sie am BASTA!-C#-Day


● C# 9 – what’s the cool stuff?

● C# Records Inside Out

Ihr aktueller Zugang zur .NET- und Microsoft-Welt.
Der BASTA! Newsletter:

Behind the Tracks

.NET Framework & C#
Visual Studio, TFS, C# & mehr

Agile & DevOps
Best Practices & mehr

Web Development
Alle Wege führen ins Web

Data Access & Storage
Alles rund um´s Thema Data

HTML5 & JavaScript
Leichtegewichtig entwickeln

User Interface
Alles rund um UI- und UX-Aspekte

Microservices & APIs
Services, die sich über APIs via REST und JavaScript nutzen lassen

Security
Tools und Methoden für sicherere Applikationen

Cloud & Azure
Cloud-basierte & Native Apps