A .NET 5 egyik újdonsága a Source Generator volt. Ennek a felhasználása a .NET 7-ben teljesedett ki, beépített generátorokkal. De mik is ezek a Source generatorok? Ezek olyan C# beépülő modulok, amelyeket a fordítóprogram tölt be és a fordítás alatt álló kódunk és a generátor logikája alapján további C# kód generálására ad lehetőséget az alkalmazásunkban.
A kód generálás lehetősége számos előnyt hoz magával. Egy ilyen előny például, hogy a Reflection kiváltható vele.
A .NET-ben a Reflection számos szerializációs megoldás alapja, azonban egy hátránya, hogy a kód futásidőben történő feltérképezése időt vesz igénybe. Ez az idő legyen bármennyire is kicsi, előbb-utóbb össze tud adódni, főleg ha sok objektumot kell sűrűn szerializálnunk.
Éppen ezért a source generátorok segítségével generáltatható olyan kód az alkalmazásunkba, amely Reflection nélkül egy adott objektum szerializációját és deszerializációját tudja megvalósítani és így végeredményben rövidebb futási időt kapunk.
A sebesség növelése mellett egy nagy előnye a generátoroknak, hogy automatizálásra is lehetőséget adnak. Például a webes alkalmazásunkban automatizálhatjuk az adatátviteli (Data Transfer) objektumaink generálását és konverzióját, vagy mondjuk ha az alkalmazásunk WPF-et alkalmaz, akkor az MVVM minta ismétlődő részeinek generálására is alkalmazhatjuk őket.
Ezen felül, ha az alkalmazásunk valamilyen DSL-t1 használ, akkor könnyen készíthetünk hozzá kód generátort.
A .NET 7 óta beépítetten rendelkezésünkre áll pár ilyen generátor, amiket attribútumokkal tudunk aktiválni. A Platform Invoke fejezetben említettem a LibraryImport attribútumot, ami a natív kód hívásának költségét csökkenti. Ezen kívül még két attribútum áll rendelkezésünkre: a GeneratedRegex és a JsonSerializable attribútumok.
A GeneratedRegex segítségével a megadott reguláris kifejezésünk illesztésére használt kód nem futási időben fog generálódni, hanem a fordítási folyamat részeként. Használatára egy példa:
//partial osztály, mivel generált kód is kapcsolódik hozzá
internal partial class Generalt
{
[GeneratedRegex("^[a-z]", RegexOptions.Multiline, 100, "en-US")]
public partial Regex MyRegex();
}
A fenti kódrészlet létrehoz egy többsoros szövegekben keresésre alkalmas reguláris kifejezést, ami maximum 100ms illesztési ideig futhat és amerikai kultúra szerint vizsgálja az egyezést. Nem szükséges azonban ennyi paramétert megadnunk. Az attribútum legegyszerűbb konstruktora egy paraméterrel is használható, ami a mintát definiálja. Azonban érdemes itt is gondolni a biztonságra és egy maximális mintaillesztési időt megadni a három paraméteres konstruktor használatával. A GeneratedRegex az alábbi konstruktorokkal rendelkezik:
public GeneratedRegexAttribute(string pattern);
public GeneratedRegexAttribute(string pattern, RegexOptions options);
public GeneratedRegexAttribute(string pattern, RegexOptions options, string cultureName);
public GeneratedRegexAttribute(string pattern, RegexOptions options, int matchTimeoutMilliseconds);
public GeneratedRegexAttribute(string pattern, RegexOptions options, int matchTimeoutMilliseconds, string cultureName);
A fenti kódrészletet elnézve adódhat a gondolat, hogy a MyRegex meghívásának az eredményét érdemes lenne egy staikus adattagban tárolni valahol, hogy csak egyszer példányosodjon és ne minden alkalommal, mikor meghívjuk a metódust. A kód generátor készítői gondoltak erre és valójában a MyRegex mögé egy singleton implementáció generálódik, ami garantálja, hogy bármennyiszer is hívjuk meg a MyRegex metódust, mindig ugyanazt a példányt kapjuk vissza.
JSON szerializáció esetén a JsonSerializable attribútumunkat nem a szerializálandó objektumon kell alkalmaznunk, hanem egy olyan részleges osztályon, ami a JsonSerializerContext osztályból öröklődik. A JsonSerializerContext osztály felel azért, hogy a típus szerializációjához szükséges metaadatok rendelkezésre álljanak és a szerializálandó osztályunk még véletlenül se tartalmazzon kódot és meg tudjon maradni adat leíró osztálynak.
A JsonSerializerContext leszármazott osztályunkat érdemes úgy elnevezni, hogy a nevében benne maradjon a SerializerContext, indikálva az osztály célját. A SerializerContext osztályunkon a JsonSourceGenerationOptions attribútum segítségével meghatározhatjuk az alkalmazandó JSON szerializációs beállításokat.
A létrejött Context felhasználásával a JsonSerializer osztály segítségével tudunk szerializálni és deszerializálni, mégpedig azon változatokkal, amelyek JsonTypeInfo<T> fogadására képesek. A JsonTypeInfo<T> a szerializációt előre leíró osztályunk. Ezt a létrehozott Context esetén a Default tulajdonságáon keresztül, a típus nevével érjük el. Például ha a típusunk neve JsonPerson, és a generáltatott JsonSerializerContext osztályunk neve a JsonPersonSerializerContext, akkor a JsonTypeInfo<T> implementációnkat a JsonPersonSerializerContext.Default.JsonPerson tulajdonságon keresztül érjük el:
public class JsonPerson
{
public string FirstName { get; init; }
public string LastName { get; init; }
public JsonPerson()
{
FirstName = string.Empty;
LastName = string.Empty;
}
}
[JsonSerializable(typeof(JsonPerson))]
[JsonSourceGenerationOptions(WriteIndented = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class JsonPersonSerializerContext: JsonSerializerContext
{
}
public static class JsonPersonExtension
{
public static string ToJsonString(this JsonPerson jsonPerson)
{
return JsonSerializer.Serialize(jsonPerson, JsonPersonSerializerContext.Default.JsonPerson);
}
public static JsonPerson? FromJsonString(this string jsonString)
{
return JsonSerializer.Deserialize<JsonPerson>(jsonString, JsonPersonSerializerContext.Default.JsonPerson);
}
}
Saját Source generátor írása
Saját Source generátor írására is lehetőségünk van. Ennek első lépése, hogy létre kell hoznunk egy külön osztálykönyvtárat. Erre azért van szükség, mert a source generátor nem az alkalmazásunk része lesz, hanem egy olyan könyvtár, amit a C# fordító fog végrehajtani. Megkötés, hogy a generátor cél keretrendszerének .NET Standard 2.0-t kell céloznia.
Ezt követően a könyvtárnak tartalmaznia kell legalább egy olyan osztályt, ami implementálja a ISourceGenerator interfészt és a Generator attribútummal annotálva van.
Az ISourceGenerator interfészt a fordító adja, ezért szükségünk lesz még a Microsoft.CodeAnalysis.CSharp és Microsoft.CodeAnalysis.Analyzers NuGet csomagokra, valamint a <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> elemet hozzá kell adnunk a generátor projektünk egy <PropertyGroup> tagjához.
Az Analyzers csomag és az EnforceExtendedAnalyzerRules attribútumok azért szükségesek, hogy már a kódírás közben kiderüljön, hogy ha valami nincs rendben a generátorunkkal. Egy instabil/hibás generátor a teljes fordítási folyamatot megakaszthatja, rosszabb esetben sikertelenségbe is viheti.
Az ISourceGenerator két metódust definiál, amit meg kell valósítanunk. Az első a void Initialize(GeneratorInitializationContext context) metódus. Ez a generátor inicializálásakor fog lefutni. A context paraméterén keresztül tudunk a kódhoz hozzáférni.
A másik metódusa a void Execute(GeneratorExecutionContext context), ami a tényleges kód generálásért felel. A kódnak, amit generálunk C# kompatibilis forráskódnak kell lennie, amit majd a context paraméteren keresztül hozzá tudunk adni a fordítandó kódhoz.
Nézzünk ennek a használatára egy példát. Tételezzük fel, hogy REST metódusokat szeretnénk hívni és az ehhez szükséges letöltő és dematerializáló kód létrehozását automatizálni szeretnénk valahogy így:
using System.Text.Json.Serialization;
namespace SourceGenPelda
{
//Annotációs attribútum
[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
public sealed class RestApiAttribute : Attribute
{
public RestApiAttribute(string endpoint)
{
Endpoint = endpoint;
}
public string Endpoint { get; }
}
//DTO osztály
public class Response
{
[JsonPropertyName("ip")]
public string Ip { get; init; }
public Response()
{
Ip = string.Empty;
}
}
public partial class ApiClient
{
//Generáltatott API kliens
[RestApi("https://api.ipify.org?format=json")]
public partial Task<Response?> GetDataAsync();
}
internal sealed class Program
{
//Felhasználó Main metódus
private static async Task Main(string[] args)
{
ApiClient client = new ApiClient();
var data = await client.GetDataAsync();
if (data != null)
Console.WriteLine(data.Ip);
}
}
}
Az ApiClient osztályban a GetDataAsync kódját szeretnénk generáltatni. Az API által visszaadott osztályt definiálja a metódus szignatúrája, a meghívandó URL-t pedig a RestApiAttribute szolgáltatja. Ez alapján a szükséges implementációt tudjuk generáltatni egy ISourceGenerator implementációval:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Generator
{
[Generator]
public class RestApiGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
//debug mód aktiválása
//if (!Debugger.IsAttached)
//{
// Debugger.Launch();
//}
context.RegisterForSyntaxNotifications(() => new PartialMethodReceiver());
}
public void Execute(GeneratorExecutionContext context)
{
if (context.SyntaxReceiver is PartialMethodReceiver receiver)
{
foreach (var methodSyntax in receiver.PartialMethods)
{
var model = context.Compilation.GetSemanticModel(methodSyntax.SyntaxTree);
if (model.GetDeclaredSymbol(methodSyntax) is IMethodSymbol methodSymbol)
{
// A metódus partial és rendelkezik a megfelelő atribútummal
if (methodSymbol.IsPartialDefinition
&& methodSymbol.GetAttributes().Any())
{
// Megkeressük a RestApiAttribute attribútumot
// és kiolvassuk az adatot belőle
var restApiAttribute = methodSymbol.GetAttributes()
.FirstOrDefault(attr => attr.AttributeClass?.Name == "RestApiAttribute");
var endpoint = restApiAttribute.ConstructorArguments[0].Value as string;
// Legeneráljuk a kódot
var generatedCode = GenerateRestApiCode(methodSyntax, methodSymbol.ReturnType, endpoint);
// Hozzáadjuk a fordításhoz
context.AddSource($"{methodSymbol.Name}_Generated.cs", generatedCode);
}
}
}
}
}
private string GenerateRestApiCode(MethodDeclarationSyntax methodSyntax,
ITypeSymbol returnType,
string endpoint)
{
var namespaceName = methodSyntax.Parent
.FirstAncestorOrSelf<NamespaceDeclarationSyntax>().Name.ToString();
var parentType = methodSyntax.Parent
.FirstAncestorOrSelf<TypeDeclarationSyntax>();
var returnTypeFullName = returnType.ToDisplayString();
var returnTypeName = (returnType as INamedTypeSymbol).TypeArguments
.FirstOrDefault()?.ToDisplayString();
var code = new StringBuilder();
code.AppendLine($"namespace {namespaceName};")
.AppendLine()
.AppendLine("[System.Runtime.CompilerServices.CompilerGenerated]")
.AppendLine($"{string.Join(" ", parentType.Modifiers)} {parentType.Keyword.Text} {parentType.Identifier.Text}")
.AppendLine("{")
.AppendLine($" public async partial {returnTypeFullName} {methodSyntax.Identifier.Text}()")
.AppendLine(" {")
.AppendLine(" using var client = new System.Net.Http.HttpClient();")
.AppendLine($" var response = await client.GetAsync(\"{endpoint}\");")
.AppendLine($" response.EnsureSuccessStatusCode();")
.AppendLine($" return await System.Text.Json.JsonSerializer.DeserializeAsync<{returnTypeName}>")
.AppendLine(" (")
.AppendLine(" await response.Content.ReadAsStreamAsync(),")
.AppendLine(" new System.Text.Json.JsonSerializerOptions")
.AppendLine(" {")
.AppendLine(" PropertyNameCaseInsensitive = true ")
.AppendLine(" }")
.AppendLine(" );")
.AppendLine(" }")
.AppendLine("}")
.AppendLine();
return code.ToString();
}
}
public class PartialMethodReceiver : ISyntaxReceiver
{
public List<MethodDeclarationSyntax> PartialMethods { get; } = new List<MethodDeclarationSyntax>();
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
if (syntaxNode is MethodDeclarationSyntax methodSyntax
&& methodSyntax.Modifiers.Any(SyntaxKind.PartialKeyword))
{
PartialMethods.Add(methodSyntax);
}
}
}
}
A kódban az Initialize metódusnak két szerepe van. A fordító a kódot a Visitor tervezési minta segítségével járja be. Ebből adódóan itt fel kell iratkoznunk, hogy szeretnénk értesítést kapni, amikor egy partial metódusba botlik a fordító. A második szerepe a metódusnak, hogy a kikommentelt Debugger osztály hívásokkal engedélyezhetjük a kódunk debuggolását. Ha ezek a sorok engedélyezve vannak, akkor a generátorunk futtatásakor egy új Visual Studio segítségével tudjuk debuggolni a működést.
Az Execute metóduson belül az értesítésként kapott részleges metódusokat bejárva megnézzük, melyiken van definiálva a megfelelő attribútum. Ha találtunk ilyet, akkor a GenerateRestApiCode metódus legenerálja a megfelelő kódot, majd a context.AddSource hívással hozzáadjuk a fordítandó fájlok listájához.
A generátorok a C# fordító belső API-ját alkalmazzák, aminek a működéséről és lehetőségeiről egy egész könyvet lehetne írni. Éppen ezért a kód felépítésének részleteibe nem is mennék bele. A https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md címen található egy „szakácskönyv” generátor receptekkel, illetve a https://github.com/dotnet/roslyn-sdk/tree/main/samples/CSharp/SourceGenerators repóban található jó néhány generátor példa, ami kiindulási alap lehet a saját generátorunknak. A https://github.com/amis92/csharp-source-generators repóban pedig mások által létrehozott generátorok linkjeit találjuk, illetve további hasznos leírásokat.
-
A DSL az angol Domain Specific Language kifejezés rövidítése. A DSL egy olyan nyelv, amelyen egy adott feladatkör problémáinak megoldását lehet elkódolni olyan formában, hogy a számítógép azt meg tudja oldani. Ilyen nyelvekre példa a HTML és a SQL, illetve a reguláris kifejezések.↩