Bu yazıda beni de uzun zamandan beri heyecanlandıran bir özelliği inceleyeceğiz. C# 9.0 ile beraber gelen programlama dili yeniliklerinin yanında belki de yeterli ilgiyi görmeyen ama uzun vadede pek çok şeyi değiştirebilecek olan özelliklerden biri source generatorlar. Source generatorlar bizim derleme zamanı sırasında varolan kodları analiz edip yeni kodlar yaratmamızı ve bunları da derlemeye dahil etmemizi sağlıyor. Üstelik tüm bu operasyonları derleyici ile tam bir entegrasyon içerisinde yapabiliyoruz.
https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/
Aslında bir kodu analiz ederek yeni bir kod üretme olayı bizim için yeni bir şey değil. Bundan önce reflection, IL weaving, MSBuild tasklarıyla bunu bir şekilde başarabiliyorduk. Ancak bunlara baktığımızda her seçeneğin diğerine göre bir takım avantajları veya dezavantajları bulunmaktaydı. Örneğin, ASP.NET tarafında uygulamanız ayağa kalktığında içerisindeki controllerların, viewların bulunması aşaması reflection ile gerçekleşmekte. Bu nedenle uygulamanız ayağa kalktığında çok kısa bir süre de olsa bu operasyon tam olarak gerçekleşmeden gelen isteklere cevap veremez. Bunun gibi operasyonlar aslında derleme zamanında kodunuz analiz edilerek yapılabilir, gerekli kod üretilebilir ve uygulamalarımız daha hızlı ayağa kalkarak isteklere hızlıca cevap verebilir hale gelebilir.
Bundan önceki yazılarımda App Trimmingden ve .NET 5 ile beraber daha da gelişen trimming yapısından bahsetmiştim. Bu linkerlar şu an preview aşamasında olsalarda .NET 6 ile beraber kullanımları daha da yaygınlaşıyor olacak. Bu nedenle uygulamalarımız içerisinde reflection kullandığımız noktaları azaltıp bu operasyonları derleme zamanına almamız bizim için oldukça önemli.
Şimdi hızlıca source generator geliştirme kısmına gelirsek. Source generatorları basit bir .NET Standart projesi olarak geliştirebilmekteyiz. .NET Standart projesini yarattıktan sonra Microsoft.CodeAnalysis.Csharp
paketini projemize eklememiz gerekiyor.
dotnet add package Microsoft.CodeAnalysis.Csharp
Sonrasında bir class yaratıp bu classı SourceGenerator
attribute'u ile işaretleyip aynı zamanda bu tipin ISourceGenerator
interface'ini implemente etmesini sağlamamız gerekli. Son durumda generator olarak çalışacak olan tipimiz şu şekilde görünecek.
[Generator] public class TestGenerator : ISourceGenerator { public void Execute(GeneratorExecutionContext context) { } public void Initialize(GeneratorInitializationContext context) { } }
Bu tip içerisindeki Initialize
metodu host (IDE, command-line compiler) tarafından bir kez çağrılmakta ve GeneratorInitializationContext
parametresi içerisindeki RegisterForSyntaxNotifications
metodunu kullanarak bir SyntaxReceiver
tanımlayarak derleme sırasında bize gelecek olan nodeları filtrelememize imkan vermekte.
Execute
metodu ise zaten tahmin edeceğiniz üzere kod yaratma aşamasının gerçekleştiği bölüm. Bu metoda parametre olarak gelen GeneratorExecutionContext
tipi içerisinde de pek çok property ve metot bulunmakta. Ama en basit olarak bizim kullanacaklarımız Compilation
propertysi ve AddSource
metodu. Burada compilation nesnesine erişip kod üzerinde çeşitli analizler yapıp sonrasında da AddSource
metodunu kullanarak yarattığımız kodu compilera bildirebiliriz. Örneğin aşağıda gibi bir Hello World uygulaması içerisinde basitçe bir kod yaratıp sonrasında da source olarak ekleyebiliriz.
[Generator] public class TestGenerator : ISourceGenerator { public void Execute(GeneratorExecutionContext context) { var builder = new StringBuilder(@" using System; namespace HelloWorldGenerated { public static class HelloWorld { public static void SayHello() { Console.WriteLine(""Hello from generated code!""); } } }"); context.AddSource("generatedCode.cs", SourceText.From(builder.ToString(), Encoding.UTF8)); } public void Initialize(GeneratorInitializationContext context) { } }
Source Generatorlar Nasıl Test Edilir
Source generatorları test etmek için varolan bir proje içerisine analyzer veya project reference olarak eklemek yeterli.
Analyzer olarak eklemek istersek...
<ItemGroup> <Analyzer Include="C:\Users\ilkay\source\repos\SourceGeneratorPOC\SourceGeneratorPOC\bin\Debug\netstandard2.0\SourceGeneratorPOC.dll" /> </ItemGroup>
Project reference olarak eklemek istersek...
<ItemGroup> <ProjectReference Include="..\SourceGeneratorPOC\SourceGeneratorPOC.csproj" OutputItemType="Analyzer" /> </ItemGroup>
Source generatorı projeye ekledikten sonra doğrudan source generatorın yarattığı metodu çağırabiliriz.
class Program { static void Main(string[] args) { HelloWorldGenerated.HelloWorld.SayHello(); } }
Gördüğünüz üzere kod yazan kod yazmak oldukça kolay 😃. Ama şimdi gelin biraz daha kompleks biraz daha gerçek hayata uygun bir örnek yapalım. Diyelim ki uygulamanız ayağa kalkarken runtimeda belirli bir interface'i implemente eden tipleri bulsun ve bunları otomatik olarak çalıştırsın. Bunu reflectionla aşağıdaki gibi basitçe yazabiliriz.
var types = typeof(Program).Assembly.GetTypes().Where(t => t.IsAssignableTo(typeof(IStartup))); foreach (var type in types) { var startup = (IStartup)Activator.CreateInstance(type); startup.Execute(); }
Bu kod reflectionla çalışacağı için performans olarak bize aslında bir dezavantaj getirecektir. Halbuki baktığımızda bir kodu derleme zamanında bu interface'i implemente eden tipleri bulabilir ve otomatik olarak kod yazarak reflection aşamasından kurtulabiliriz. Şimdi gelin bunu basitçe nasıl yaparız ona bakalım.
İlk olarak bir syntax receiver yazarak sadece class declarationları filtreleyelim.
public class StartupReceiver : ISyntaxReceiver { public List<ClassDeclarationSyntax> Candidates { get; } = new List<ClassDeclarationSyntax>(); public void OnVisitSyntaxNode(SyntaxNode syntaxNode) { if (syntaxNode is ClassDeclarationSyntax) { Candidates.Add(syntaxNode as ClassDeclarationSyntax); } } }
Bu kod ile sadece class tanımlamaları ile ilgileneceğimizi belirledik ve Execute
metodu içerisinde bu tanımlamalar üzerinden analizimizi yapacağız. Source generatorın kodu işe şu şekilde.
[Generator] public class TestGenerator : ISourceGenerator { public void Execute(GeneratorExecutionContext context) { var builder = new StringBuilder(); builder.AppendLine(@"namespace StartupExtensions { public static class StartupRunner { public static void Run() {"); var interfaceSymbol = context.Compilation.GetTypeByMetadataName(typeof(IStartup).FullName); if (context.SyntaxReceiver is StartupReceiver startupReceiver) { foreach (var classDeclaration in startupReceiver.Candidates) { var model = context.Compilation.GetSemanticModel(classDeclaration.SyntaxTree); var symbolInfo = model.GetDeclaredSymbol(classDeclaration); if (symbolInfo is ITypeSymbol typeSymbol && typeSymbol.AllInterfaces.Any(t => t.Equals(interfaceSymbol, SymbolEqualityComparer.Default))) { var variableName = classDeclaration.Identifier.Text.ToLowerInvariant(); builder.AppendLine($"{indent}var {variableName}= new {symbolInfo.ContainingNamespace.Name}.{symbolInfo.Name}();"); builder.AppendLine($"{indent}{variableName}.Execute();"); } } } builder.AppendLine(@"}}}"); context.AddSource("startupRunner.cs", SourceText.From(builder.ToString(), Encoding.UTF8)); } public void Initialize(GeneratorInitializationContext context) { context.RegisterForSyntaxNotifications(() => new StartupReceiver()); } }
Yukarıdaki kodda ilk olarak Initialize
modunda yazmış olduğumuz StartupReceiver
tipini source generatora bildirdik. Artık syntax değişiklikleri olduğunda bu receiver içerisinde gerekli filtrelemeleri yapabileceğiz.
Sonrasında ise ilk olarak StringBuilder
içerisine yaratacağımız kodun giriş bölümünü ekledik. Ardından StartupReceiver
içerisinde filtrelediğimiz class declerationların sembol bilgilerini alıp istediğimiz interface'i implemente edip etmediklerini kontrol edip eğer ediyorsa basit bir kodla bu tipi yaratıp Execute
metodunu çağıracak kodu yazdık. En son olarak ise yarattığımız kodu compilera bildirdik.
Test edeceğimiz yerde kodu derledikten sonra basitçe yaratılan metodu çağırırsak reflectionla yaptığımız işi artık derleme zamanında yapmış olacağız.
class Program { static void Main(string[] args) { StartupRunner.Run(); } }
F12 kullanarak yaratılan koda baktığımızda...
namespace StartupExtensions { public static class StartupRunner { public static void Run() { var type1 = new SourceGeneratorTest.Type1(); type1.Execute(); var type3 = new SourceGeneratorTest.Type3(); type3.Execute(); } } }
Gördüğünüz gibi çalışma zamanında yapılan bir operasyonu source generator kullanarak derleme zamanında gerçekleştirdik. Böylece hem reflectiondan kurtulmuş olduk hem de reflectionın getireceği performans dezavantajından kurtulmuş olduk.
Yukarıdaki kodlara baktıysanız anlamadığınız pek çok kavram karşınıza çıkabilir. Source generator geliştirmek tıpkı analyzer/code fix gelistirmek gibi roslyn API'larıyla oldukça içli dışlı olmanızı gerektiriyor. Aynı zamanda debugging'in de çok başarılı olduğunu söylemem mümkün değil. İnternette araştırdığım bazı debugging yöntemlerini malesef başaramadım. Çoğu zaman yarattığım koda yorum satırı ekleyerek debugging yaptım. Ama ileriki Visual Studio versiyonlarında bu deneyimler gelişecektir.
Source generatorlarla ilgili aklımıza gelen konulardan biri de varolan kodu değiştirip değiştiremeyeceğimiz. Source generatorlar bize sadece kod yaratma imkanı veriyor. Ayrıca partial class veya metotlar kullanarak varolan tipler içerisine bir şeyler eklemeniz mümkün. Aynı zamanda extension metotlar da yaratabilirsiniz source generatorlar ile. Nasıl implemente edeceğiniz aslında tamamen size kalmış.
Source generatorlar ile kod yaratma aşaması da bana kalırsa biraz sıkıntılı. Özellikle yaratılan kodda indentation düzgün olsun istiyorsanız biraz zaman harcamanız lazım. Ben yukarıda buna çok dikkat etmedim 😃 Source generatorlar şu an oldukça yeni olsalarda zamanla kullanımları oldukça artacaktır. Şu anda .NET ekibi pek çok ürün içerisinde source generator geliştirmeye başladı. Bu generatorlar ile .NET platformunun performansı daha da artacaktır diye düşünüyorum.
Bir sonraki yazıda görüşmek üzere,
Yazıda bahsettiğim örneği GitHub üzerinden incelemek isterseniz : https://github.com/ilkayilknur/SourceGeneratorPOC