1. Einleitung
Seien wir ehrlich: die meisten Leute denken über Code-Generierung etwa so wie über einen Eier-Schneider — nützlich, aber selten lebensnotwendig. Allerdings werden moderne .NET-Projekte immer komplexer, und die Automatisierung von routinemäßiger oder boilerplate Arbeit ist keine „Spielerei“ mehr, sondern ein wichtiges Werkzeug zur Steigerung von Qualität und Produktivität.
Source Generators sind ein Mechanismus, der ab C# 9 und .NET 5 verfügbar ist. Sie wirken wie Superhelden in der Compile-Phase und können C#-Code generieren, der dann als Teil deines Projekts kompiliert wird. Dabei greifen sie nicht in den bereits kompilierten assembly-Code zur Laufzeit ein, sondern erweitern das Projekt vor der Kompilierung um neue Quellcode-Dateien.
Im Folgenden typische Einsatzszenarien für Source Generators.
Automatische Erstellung von Boilerplate-Code
In vielen Projekten muss man immer wieder ähnlichen Code schreiben: Konstruktoren, ToString-Methoden, Serializer, property change notification (INotifyPropertyChanged) usw. Source Generators können diesen Code automatisch erzeugen und befreien Entwickler von Routinearbeit und Tippfehlern.
Beispiel: Generator für ToString
Angenommen, du entwickelst DTO-Klassen (Data Transfer Object) zum Datentransfer. Du musst für jede Klasse eine sinnvolle ToString-Implementierung haben, in der alle Properties aufgeführt werden.
Statt das manuell zu kopieren, kann ein Source Generator für jede Klasse mit dem Attribut [AutoToString] die ToString-Methode generieren. So machen es z.B. Bibliotheken wie AutoToString.
// Deine Klasse mit Attribut
[AutoToString]
public partial class Person
{
public string Name { get; set; }
public int Age { get; set; }
}
// Source Generator wird (vereinfacht) erzeugen:
public partial class Person
{
public override string ToString() => $"Person: Name={Name}, Age={Age}";
}
Vorteile: der Code ist lesbar und wird automatisch aktualisiert, wenn neue Properties hinzukommen.
Vereinfachung von Serialization und Deserialization
Source Generators werden aktiv in der Standardbibliothek verwendet, z.B. in System.Text.Json, um schnellen Serialisierungs- und Deserialisierungscode zu erzeugen. Vor Generatoren war Serialization oft auf Reflection angewiesen (performance-intensiv), jetzt sorgt generierter Code für Geschwindigkeit und Sicherheit.
Was bekommt der Entwickler? Du markierst mit [JsonSerializable(typeof(MyType))] — und der Generator erzeugt performanten Serialisierungscode für den Typ.
Generierung von Code für Konfigurationen, Mapper, DI-Container
- Konfigurationen: Generatoren erzeugen automatisch Konfigurationsklassen basierend auf JSON-Dateien.
- Mapper: z.B. erzeugt das Projekt Mapster mit Generatoren Mappings zwischen Typen ohne manuelles Feldkopieren.
- Dependency Injection: einige Container (z.B. StrongInject) nutzen Generatoren, um Registrierungs-Code zu erzeugen.
Integration mit externer Infrastruktur
Manche Generatoren parsen externe Ressourcen (API-Beschreibungen, GraphQL-Schemata, Thrift-Protokolle etc.) und erzeugen C#-Klassen für die Arbeit damit. Das erspart manuelles Aktualisieren von Code bei Vertrag-Änderungen.
Code-Checks und Diagnose während der Kompilierung
Source Generators können auch zur Code-Prüfung genutzt werden: „Wenn im Projekt Methode X vorhanden ist, aber Bedingung Y nicht erfüllt ist — Compiler-Warnung!“. So arbeiten viele Linter und Analyzers; ein Generator kann eigene Meldungen hinzufügen oder Stub-Code erzeugen.
2. Dein eigener Source Generator für automatische Serialization
Schauen wir uns ein kleines Beispiel „direkt aus der Praxis“ an: ein Generator, der für eine Klasse mit dem Attribut [AutoJson] eine Methode zur JSON-Serialization erzeugt.
Der folgende Code ist nur eine Illustration; im echten Projekt verwende die offizielle Lösung wie System.Text.Json.SourceGeneration.
// Manuell geschrieben:
[AutoJson]
public partial class Book
{
public string Title { get; set; }
public int Year { get; set; }
}
// Generator fügt hinzu:
public partial class Book
{
public string ToJson() => $"{{ \"Title\": \"{Title}\", \"Year\": {Year} }}";
}
Interaktionsschema zwischen Source Generator und Projekt
flowchart TD
A(Dein Source Code) -->|Compiler ruft auf| B(Source Generator)
B -->|Hinzugefügter Code| C(Neue .cs-Dateien)
C --> D(Projektkompilierung)
- Zuerst analysiert der Compiler (Roslyn) deine Quellen.
- Dann ruft er „deinen“ Generator (implementiert ISourceGenerator) auf.
- Der Generator fügt neue .cs-Dateien hinzu, die Teil des Compilation-Trees werden.
- Am Ende enthält das Build sowohl deinen als auch den generierten Code.
Spezialisierte Aufgaben: was man sonst noch generieren kann
- Bindings für native Libraries (Wrapper um C-Code oder WinAPI).
- AOP: automatisches Logging von Aufrufen, Aspekte (wie Fody, PostSharp usw.).
- API-Beschreibungen für automatische Dokumentations-Generatoren.
- Verarbeitung externer Ressourcen: SVG, SQL, Razor — Generatoren erzeugen strongly-typed Klassen für typensicheren Zugriff.
3. Dynamische Code-Generierung mit System.Reflection.Emit
Während Source Generators C#-Code vor dem Build erzeugen, ist System.Reflection.Emit ein Werkzeug für Magie während der Laufzeit. Dein Programm kann selbst neue Typen, Methoden und sogar Assemblies erzeugen — on the fly!
Klingt beängstigend? Ein bisschen. Aber manchmal unvermeidlich: z.B. wenn du Frameworks für dynamisches Proxying (AOP, Profiling, Mocking), Runtime-Serializer, dynamische ORMs usw. schreibst.
Wann braucht man Reflection.Emit?
- Typen sind vorher nicht bekannt (der Nutzer definiert Strukturen zur Laufzeit).
- Dynamisches Proxying (Wrapper zum Abfangen von Aufrufen).
- Hochperformante Serialisierung (z.B. protobuf-net).
- Plugins und Scripting-Engines mit komplexen Lade-Szenarien.
Was lässt sich mit Reflection.Emit erstellen
- AssemblyBuilder — eine neue Assembly erstellen.
- ModuleBuilder — ein Modul in der Assembly.
- TypeBuilder — Beschreibung eines neuen Typs.
- MethodBuilder — Methode mit IL-Code.
- PropertyBuilder, FieldBuilder, EventBuilder — Properties, Felder, Events.
Mini-Beispiel: eine neue Klasse zur Laufzeit erzeugen
using System;
using System.Reflection;
using System.Reflection.Emit;
public static class DynamicTypeGenerator
{
public static Type GenerateSimpleType(string typeName)
{
// 1. Erstelle Assembly und Modul
var assemblyName = new AssemblyName("DynamicAssembly");
var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
var moduleBuilder = assemblyBuilder.DefineDynamicModule("MainModule");
// 2. Erzeuge neue Klasse
var typeBuilder = moduleBuilder.DefineType(
typeName,
TypeAttributes.Public | TypeAttributes.Class
);
// 3. Füge ein öffentliches String-Property Title hinzu
var field = typeBuilder.DefineField("_title", typeof(string), FieldAttributes.Private);
var prop = typeBuilder.DefineProperty("Title", PropertyAttributes.HasDefault, typeof(string), null);
var getMethod = typeBuilder.DefineMethod("get_Title", MethodAttributes.Public, typeof(string), Type.EmptyTypes);
var il = getMethod.GetILGenerator();
il.Emit(OpCodes.Ldarg_0); // this
il.Emit(OpCodes.Ldfld, field); // _title
il.Emit(OpCodes.Ret);
prop.SetGetMethod(getMethod);
var setMethod = typeBuilder.DefineMethod("set_Title", MethodAttributes.Public, null, new[] { typeof(string) });
il = setMethod.GetILGenerator();
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Ldarg_1);
il.Emit(OpCodes.Stfld, field);
il.Emit(OpCodes.Ret);
prop.SetSetMethod(setMethod);
// 4. Fertig! Erzeuge den Type
return typeBuilder.CreateTypeInfo();
}
}
Nun kannst du diesen Typ wie ein normales C#-Objekt verwenden, z.B. per Reflection:
var dynamicType = DynamicTypeGenerator.GenerateSimpleType("Book");
var obj = Activator.CreateInstance(dynamicType);
dynamicType.GetProperty("Title").SetValue(obj, "C# in Gesichtern");
Console.WriteLine(dynamicType.GetProperty("Title").GetValue(obj)); // C# in Gesichtern
So entstehen ORMs, Proxies, Serializer, Profiler und sogar einige Test-Frameworks.
4. Nützliche Feinheiten
Source Generators vs Reflection.Emit: wer gewinnt?
Source Generators arbeiten zur Compile-Zeit: sie machen deinen Quellcode „klüger“ und das Ergebnis landet in der kompilierten Assembly. Sie eignen sich nicht zur Generierung von Code basierend auf Daten, die erst zur Laufzeit verfügbar werden.
Reflection.Emit arbeitet zur Laufzeit: es erlaubt das dynamische Erzeugen von Assemblies, Typen und Methoden, aber solcher Code ist schwieriger zu debuggen und zu warten.
Wann was verwenden?
Eine Analogie:
- Source Generators sind wie eine Fabrik, die fertige Teile vor der Montage produziert.
- Reflection.Emit ist wie ein Ingenieur, der unterwegs einem Auto spontan einen Raketenmotor anschweißt.
Eigenschaften und Fallstricke
- Generierter Code kann schwer zu debuggen sein. Viele Generatoren bieten die Möglichkeit, die erzeugten Quellen auf die Festplatte zu schreiben — suche sie im Ordner obj\Generated.
- Reflection.Emit erzeugt Assemblies im Speicher, die nicht aus dem AppDomain entladen werden. Verwende AssemblyBuilderAccess.RunAndCollect für temporäre Assemblies (sofern unterstützt).
- Missbrauche Reflection.Emit nicht für einfache Aufgaben — manchmal ist ein Source Generator oder ein normales Template deutlich einfacher und stabiler.
- Generatoren erfordern Verständnis von Roslyn (für Source Generators) und IL (für Reflection.Emit).
Source Generators und Reflection.Emit
| Kriterium | Source Generators | Reflection.Emit |
|---|---|---|
| Verwendung | Zur Compile-Zeit | Zur Laufzeit |
| Ergebnis | C#-Quellcode, Teil der Assembly | IL-Code, neue Typen/Assemblies |
| Typische Szenarien | Autogenerierung von Boilerplate, DI, Mapping, Serialization | Proxies, dynamische ORMs, spezielle runtime-Pipelines |
| Schwierigkeitsgrad | Mittel, Kenntnisse in Roslyn nötig | Hoch, Kenntnisse in IL nötig |
| IDE- und Debug-Unterstützung | Sehr gut (Quellen sichtbar) | Kompliziert |
| Performance | Sehr hoch | Kann hoch sein |
5. Praktische Szenarien
1. Generierung eines strongly-typed API
Eine Organisation stellt eine OpenAPI-Spezifikation bereit. Ein Generator analysiert diese und erzeugt Controller-Klassen, DTOs und Client-Code für REST-APIs — typensicher und mit IntelliSense-Unterstützung.
Code (pseudo):
// Spec: GET /users -> returns User[]
// Source Generator wird erzeugen:
public class ApiClient
{
public Task<User[]> GetUsersAsync() { ... }
}
2. Automatische Injection/DI-Container (Compile-time IoC)
Generatoren erzeugen Builder für die Registrierung von Abhängigkeiten und den Aufbau des Objektgrafen. Du musst nicht manuell services.AddSingleton<IMyService, MyService>() schreiben.
Code (pseudo):
[Injectable]
public class MyService : IMyService { ... }
// Source Generator erzeugt:
partial class DIContainer
{
public void RegisterServices()
{
AddSingleton<IMyService, MyService>();
}
}
3. Dynamische Proxies — Beispiel mit Reflection.Emit
Die Bibliothek Castle DynamicProxy baut Proxy-Typen, die Methodenaufrufe abfangen — Basis für AOP, Logging, Tracing, Mocking.
Code (vereinfacht):
public interface IBookService { string GetBook(); }
public class BookService : IBookService { public string GetBook() => "C#"; }
var proxy = ProxyGenerator.CreateProxy<IBookService>(new BookService(), interceptor);
proxy.GetBook(); // Aufruf wird abgefangen, man kann loggen/Ergebnis ändern
4. Schnelle Serialization ohne Reflection
Anstatt zur Laufzeit Type-Beschreibungen per Reflection aufzubauen (langsam), können Source Generators Serialisierungs-/Deserialisierungscode im Voraus generieren — maximal schnell und ohne Overhead.
GO TO FULL VERSION