پادشاهِ کُدنویسا شو!
کینگتو - آموزش برنامه نویسی تخصصصی - دات نت - سی شارپ - بانک اطلاعاتی و امنیت

کامپایلر به مثابه پلتفرم: راهنمای جامع Roslyn و توسعه ابزارهای Code Generation

8 بازدید 0 نظر ۱۴۰۵/۰۴/۲۲
در دنیای مهندسی نرم‌افزار مدرن، یکی از نشانه‌های یک تیم توسعه بالغ، میزان خودکارسازی رفتارهای تکراری و کاهش بویلرپلیت‌ها (Boilerplate Codes) است. همه‌ی ما به عنوان توسعه‌دهندگان دات‌نت، سال‌ها با پدیده‌ای به نام "کامپایلر" به عنوان یک جعبه سیاه (Black Box) رفتار کرده‌ایم؛ ورودی آن کدهای متنی ما بود و خروجی آن فایل‌های باینری (DLL یا EXE). اگر می‌خواستیم کدی را تحلیل کنیم، ابزار داینامیک بسازیم یا کدهایی را در زمان کامپایل تولید کنیم، باید به سراغ روش‌های پرهزینه، مستعد خطا و سختی مانند تکست‌تمپلیت‌ها (T4 Templates)، مانیپولاسیون رشته‌ها (String Manipulation) یا ماژول‌های سنگین System.Reflection.Emit می‌رفتیم.

ظهور Roslyn (پلتفرم کامپایلر دات‌نت) این پارادایم را برای همیشه تغییر داد. Roslyn جعبه سیاه کامپایلر را باز کرد و آن را به صورت مجموعه‌ای از APIهای غنی، قدرتمند و شیءگرا در اختیار ما قرار داد. در این مقاله تخصصی، عمیقاً بررسی خواهیم کرد که Roslyn چیست، معماری داخلی آن چگونه کار می‌کند و در نهایت، چطور می‌توانیم با استفاده از قابلیت شاهکار Source Generators، ابزارهای تولید خودکار کد (Code Generator) فوق‌العاده سریع و بهینه بسازیم.

بخش اول: Roslyn چیست؟ (پایان عصر جعبه سیاه)

Roslyn نام کد پروژه و پلتفرم متن‌باز کامپایلر سی‌شارپ و ویژوال بیسیک (VB.NET) است که توسط مایکروسافت توسعه یافته است. بر خلاف کامپایلرهای سنتی (مانند csc.exe قدیمی که با C++ نوشته شده بود)، Roslyn کاملاً با خود #C بازنویسی شده است (تکنیک Self-hosting).

هدف اصلی Roslyn این است که اطلاعات غنی کامپایلر در مراحل مختلف (تحلیل لغوی، تحلیل نحوی، تحلیل معنایی و تولید کد) را از طریق یک لایه API استاندارد به بیرون عرضه کند. این معماری به ابزارهایی مانند Visual Studio، Rider، قابلیت‌های بازآفرینی کد (Refactoring)، ابزارهای آنالیز استاتیک کد (مانند SonarQube) و صدالبته ابزارهای تولید کد اجازه می‌دهد تا کدهای شما را درست همان‌طور که خود کامپایلر می‌بیند، درک و تحلیل کنند.

معماری چهار لایه‌ای پلتفرم Roslyn

Roslyn ساختار پردازشی خود را در قالب یک پایپ‌لاین (Pipeline) متشکل از ۴ لایه اصلی سازمان‌دهی می‌کند. هر لایه، سرویس یا داده‌های خاصی را ارائه می‌دهد که برای ابزارهای Code Generation حیاتی هستند:

[ کدهای متنی سی‌شارپ ] 
        │
        ▼
┌────────────────────────────────────────────────────────┐
│ 1. Compiler API (لایه پایپ‌لاین کامپایل)                │
│    - Parser (تولید Syntax Tree)                         │
│    - Symbols & Semantic Model (کشف معنا و مفاهیم)      │
│    - Binding (اتصال مراجع و متغیرها)                   │
│    - Emit (تولید بایت‌کد IL)                           │
└────────────────────────────────────────────────────────┘
        │
        ▼
┌────────────────────────────────────────────────────────┐
│ 2. Diagnostics API (تحلیل خطاها، هشدارها و آنالایزرها) │
└────────────────────────────────────────────────────────┘
        │
        ▼
┌────────────────────────────────────────────────────────┐
│ 3. Scripting API (اجرای پویا و خط به خط کدهای سی‌شارپ) │
└────────────────────────────────────────────────────────┘
        │
        ▼
┌────────────────────────────────────────────────────────┐
│ 4. Workspaces API (مدیریت کل پروژه، Solution و فایل‌ها)│
└────────────────────────────────────────────────────────┘
  1. لایه Parser (تحلیل نحوی): در این مرحله، متن خام کد منبع خوانده شده و به یک ساختار درختی سلسله‌مراتبی به نام Syntax Tree تبدیل می‌شود. این درخت، ساختار گرامری کد را بدون درک معنای آن توصیف می‌کند.

  2. لایه Symbols و Semantic Model (تحلیل معنایی): این لایه مغز متفکر Roslyn است. درخت نحوی فقط می‌داند که کلمه‌ای به نام User وجود دارد، اما مدل معنایی (SemanticModel) مشخص می‌کند که این User یک کلاس تعریف شده در فلان نیم‌اسپیس است، چه متدهایی دارد و آیا اینترفیسی را پیاده‌سازی کرده است یا خیر.

  3. لایه Binding: در این مرحله، نام‌ها، متغیرها و متدها به ساختارهای داده‌ای و سمبل‌های دقیق خود متصل (Bind) می‌شوند.

  4. لایه Emit: در این لایه نهایی، ساختار درختی و معنایی تایید شده به بایت‌کد مانیتور شده دات‌نت (IL) و متادیتا تبدیل شده و در قالب یک فایل DLL یا EXE روی دیسک یا حافظه منتشر می‌شود.

 

بخش دوم: ساختارهای کلیدی در دنیای Roslyn

برای اینکه بتوانیم یک ابزار Code Generator کارآمد بسازیم، ابتدا باید با واژگان و ابزارهای پایه‌ای که Roslyn در اختیار ما قرار می‌دهد آشنا شویم. سه مفهوم اصلی وجود دارد که در تمام طول مسیر با آن‌ها دست‌وپنجه نرم خواهیم کرد:

۱. درخت نحوی (Syntax Tree)

هر فایلی که در سی‌شارپ می‌نویسید، توسط Roslyn به یک SyntaxTree مپ می‌شود. این درخت از سه المان کلیدی تشکیل شده است:

  • Syntax Nodes (گره‌ها): این‌ها المان‌های ساختاری و گرامری کد هستند؛ مانند تعریف یک کلاس (ClassDeclarationSyntax)، تعریف یک متد (MethodDeclarationSyntax) یا یک بلوک شرطی (IfStatementSyntax). گره‌ها همیشه دارای فرزند هستند.

  • Syntax Tokens (توکن‌ها): کوچک‌ترین اجزای گرامری غیرقابل تجزیه هستند؛ مانند کلمات کلیدی (public، class)، شناسه متغیرها (myVariable) یا اپراتورها (+، ==). توکن‌ها فرزند نودی ندارند و برگ‌های درخت محسوب می‌شوند.

  • Syntax Trivia (جزئیات فرعی): چیزهایی هستند که برای اجرای برنامه اهمیتی ندارند اما برای حفظ ساختار متن مهم هستند؛ مانند فضاهای خالی (Whitespace)، خطوط جدید (\n)، کامنت‌ها و دایرکتیوهای پیش‌پردازنده (مانند #region).

نکته بسیار مهم: تمام اشیاء در فرآیند تحلیل نحوی Roslyn (مانند SyntaxNode) Immutable (تغییرناپذیر) هستند. این یعنی شما هرگز نمی‌توانید یک گره را در درخت موجود تغییر دهید. اگر تغییری ایجاد کنید، Roslyn یک کپی جدید از آن گره و کل زیردرخت مربوط به آن را با تغییرات جدید به شما تحویل می‌دهد. این طراحی، ایمنی نخ‌ها (Thread-Safety) را در زمان کامپایل تضمین می‌کند.

۲. مدل معنایی (Semantic Model)

درخت نحوی به شدت سطحی است. اگر در کد داشته باشیم var x = y;، درخت نحوی فقط می‌داند y یک شناسه (Identifier) است. اما این سوالات را نمی‌تواند پاسخ دهد: نوع داده‌ای y چیست؟ از کدام کلاس ارث‌بری کرده؟ آیا یک Property است یا یک متغیر محلی؟

برای پاسخ به این سوالات، ما از Compilation.GetSemanticModel(syntaxTree) استفاده می‌کنیم. مدل معنایی به ما یک Symbol می‌دهد. سمبل‌ها (ISymbol، INamedTypeSymbol و...) حاوی اطلاعات شناسنامه‌ای و نوع‌داده‌ای دقیق المان‌های کد هستند.

 

بخش سوم: تکامل نسل‌های Code Generation در دات‌نت

برای درک عظمت کاری که Roslyn انجام می‌دهد، بیایید نگاهی به تاریخچه تولید کد در دات‌نت بیندازیم:

  1. روش دستی و رشته‌ای (String Aggregation): کدهای سی‌شارپ را در قالب رشته‌ها (Strings) یا StringBuilder به هم می‌چسباندیم و در یک فایل .cs ذخیره می‌کردیم. کوچک‌ترین اشتباه در گذاشتن یک سیمیکولن (;) یا کروشه (}) باعث خراب شدن کل کد می‌شد و خطایابی آن کابوس بود.

  2. پلاگین‌های متن T4 (Text Templates): ابزارهایی عالی در زمان خود (مورد استفاده وسیع در Entity Framework DB-First) اما خارج از فرآیند اصلی کامپایلر اجرا می‌شدند و یکپارچگی ضعیفی با تغییرات لحظه‌ای کد داشتند.

  3. مکانیزم Reflection.Emit: تولید کد در زمان اجرا (Runtime). این روش به شدت قدرتمند است (ابزارهایی مثل Dapper یا Castle DynamicProxy از آن استفاده می‌کنند) اما دو عیب بزرگ دارد: سرعت استارت‌آپ برنامه را کاهش می‌دهد و با معماری‌های مدرن دات‌نت مانند AOT (Ahead-Of-Time) Compilation سازگار نیست؛ چرا که در AOT ما مجاز به تولید و تفسیر کد در زمان اجرا نیستیم.

  4. سرانجام Roslyn Source Generators: راهکار مدرن دات‌نت. کدها در زمان کامپایل (Compile-time) تولید می‌شوند، کاملاً با کامپایلر یکپارچه‌اند، هیچ هدررفتی در زمان اجرا ندارند و با مانیتورینگ دقیق، خطاهای تولید کد را به عنوان کامپایل ارور به توسعه‌دهنده نشان می‌دهند.

 

بخش چهارم: توسعه ابزارهای Code Generator با Source Generators

قابلیت Source Generators که در دات‌نت ۵ معرفی شد و در دات‌نت ۶ به بعد به صورت Incremental Generators به تکامل رسید، به ما اجازه می‌دهد کدی بنویسیم که در طول فرآیند کامپایل اجرا شده، کدهای موجود پروژه کاربر را بازرسی (Inspect) کند و کدهای جدیدی را به صورت پویا تولید کرده و به پایپ‌لاین کامپایل اضافه کند!

قانون طلایی Source Generators: شما فقط می‌توانید کد جدید اضافه کنید (Add)؛ هرگز نمی‌توانید کدهای موجود کاربر را تغییر دهید (Modify) یا حذف کنید (Delete). برای تغییر رفتار کدهای موجود، باید از تکنیک‌هایی مثل کلاس‌های Partial (partial class) یا متدهای Partial استفاده کنید.

سناریوی عملی: پیاده‌سازی خودکار الگوی Mapping (شبیه به AutoMapper اما در زمان کامپایل)

فرض کنید در یک پروژه بزرگ معماری تمیز (Clean Architecture) یا CQRS، تعداد زیادی دیسک کاغذی به نام DTO دارید و می‌خواهید کلاس‌های Domain خود را به این DTOها مپ کنید. نوشتن کدهایی مثل dto.Name = user.Name; به صورت دستی خسته‌کننده است و ابزارهایی مثل AutoMapper هم به دلیل استفاده از Reflection در زمان اجرا، پرفورمنس را کاهش می‌دهند.

می‌خواهیم ابزاری با Roslyn بسازیم که هر کلاسی که اتریبیوت [AutoMapTo] روی آن قرار دارد را شناسایی کند و متد نگاشت آن را در زمان کامپایل به صورت خودکار تولید کند.

گام ۱: راه‌اندازی پروژه Generator

ابتدا باید یک پروژه از نوع Class Library ایجاد کنیم که هدف آن .NET Standard 2.0 باشد (این یک الزام زیرساختی برای سازگاری Roslyn با پلتفرم‌های مختلف ویژوال استودیو است).

فایل .csproj پروژه ژنراتور باید به این صورت تنظیم شود:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynComponent>true</IsRoslynComponent>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
  </ItemGroup>

</Project>

گام ۲: تعریف اتریبیوت نشانه‌گذاری

ما به یک اتریبیوت نیاز داریم تا کلاس‌های هدف را مانیتور کنیم. این کد می‌تواند توسط خود ژنراتور در زمان مقداردهی اولیه تزریق شود:

namespace SourceGenerator.Attributes
{
    public static class SourceGenerationHelper
    {
        public const string AttributeText = @"
namespace AutoMapping
{
    [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
    public sealed class AutoMapToAttribute : System.Attribute
    {
        public System.Type TargetType { get; }
        public AutoMapToAttribute(System.Type targetType)
        {
            TargetType = targetType;
        }
    }
}";
    }
}

گام ۳: پیاده‌سازی Incremental Source Generator

روش مدرن پیاده‌سازی ژنراتورها استفاده از IIncrementalGenerator است. این لایه با کش کردن (Caching) درخت‌های نحوی، سرعت کامپایل فوق‌العاده بالایی ارائه می‌دهد و در زمان تایپ لایو کاربر در ادیتور، کل پروژه را دوباره کامپایل نمی‌کند.

using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using SourceGenerator.Attributes;

namespace SourceGenerator;

[Generator]
public class AutoMappingGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // تزریق کد اتریبیوت به پروژه کاربر در ابتدای کار
        context.RegisterPostInitializationOutput(ctx => ctx.AddSource(
            "AutoMapToAttribute.g.cs", 
            SourceText.From(SourceGenerationHelper.AttributeText, Encoding.UTF8)));

        // فیلتر کردن کلاس‌هایی که اتریبیوت دارند (مرحله اول پایپ‌لاین نحوی)
        IncrementalValuesProvider<ClassDeclarationSyntax> classDeclarations = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (s, _) => IsSyntaxTargetForGeneration(s),
                transform: static (ctx, _) => GetSemanticTargetForGeneration(ctx))
            .Where(static m => m is not null)!;

        // ترکیب اطلاعات لایه نحوی با مدل معنایی کامپایلر
        IncrementalValueProvider<(Compilation Compilation, ImmutableArray<ClassDeclarationSyntax> Classes)> compilationAndClasses = 
            context.CompilationProvider.Combine(classDeclarations.Collect());

        // ثبت خروجی نهایی و تولید کدهای واقعی سی‌شارپ
        context.RegisterSourceOutput(compilationAndClasses, 
            static (spc, source) => Execute(source.Compilation, source.Classes, spc));
    }

    private static bool IsSyntaxTargetForGeneration(SyntaxNode node)
    {
        // بررسی سریع بدون نیاز به محاسبات سنگین معنایی: آیا گره یک کلاس است و اتریبیوت دارد؟
        return node is ClassDeclarationSyntax classDecl && classDecl.AttributeLists.Count > 0;
    }

    private static ClassDeclarationSyntax? GetSemanticTargetForGeneration(GeneratorAttributeSyntaxContext ctx)
    {
        // در این سناریو مستقیماً گره کلاسی که فیلتر شده را برمی‌گردانیم
        return ctx.TargetNode as ClassDeclarationSyntax;
    }

    private static void Execute(Compilation compilation, ImmutableArray<ClassDeclarationSyntax> classes, SourceProductionContext context)
    {
        if (classes.IsDefaultOrEmpty) return;

        foreach (var classDecl in classes)
        {
            // استخراج مدل معنایی کلاس منبع
            var semanticModel = compilation.GetSemanticModel(classDecl.SyntaxTree);
            if (semanticModel.GetDeclaredSymbol(classDecl) is not INamedTypeSymbol sourceSymbol) continue;

            // پیدا کردن اتریبیوت AutoMapTo و کلاس هدف (Target)
            var autoMapAttr = sourceSymbol.GetAttributes()
                .FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "AutoMapping.AutoMapToAttribute");

            if (autoMapAttr == null || autoMapAttr.ConstructorArguments.IsEmpty) continue;

            var targetSymbol = autoMapAttr.ConstructorArguments[0].Value as INamedTypeSymbol;
            if (targetSymbol == null) continue;

            // تولید متد مپینگ بر اساس ویژگی‌های مشترک دو کلاس
            string generatedCode = GenerateMappingCode(sourceSymbol, targetSymbol);
            
            // ثبت نهایی کد تولید شده در کامپایلر
            context.AddSource($"{sourceSymbol.Name}_Mapping.g.cs", SourceText.From(generatedCode, Encoding.UTF8));
        }
    }

    private static string GenerateMappingCode(INamedTypeSymbol source, INamedTypeSymbol target)
    {
        var sourceProperties = source.GetMembers().OfType<IPropertySymbol>();
        var targetProperties = target.GetMembers().OfType<IPropertySymbol>().ToDictionary(p => p.Name);

        var builder = new StringBuilder();
        builder.AppendLine($"namespace {source.ContainingNamespace.ToDisplayString()}");
        builder.AppendLine("{");
        builder.AppendLine($"    public partial class {source.Name}");
        builder.AppendLine("    {");
        builder.AppendLine($"        public {target.ToDisplayString()} MapTo{target.Name}()");
        builder.AppendLine("        {");
        builder.AppendLine($"            return new {target.ToDisplayString()}");
        builder.AppendLine("            {");

        foreach (var srcProp in sourceProperties)
        {
            // اگر کلاس هدف پروپرتی هم‌نام و با نوع داده‌ای یکسان داشت، آن را مپ کن
            if (targetProperties.TryGetValue(srcProp.Name, out var targetProp) && 
                SymbolEqualityComparer.Default.Equals(srcProp.Type, targetProp.Type))
            {
                builder.AppendLine($"                {targetProp.Name} = this.{srcProp.Name},");
            }
        }

        builder.AppendLine("            };");
        builder.AppendLine("        }");
        builder.AppendLine("    }");
        builder.AppendLine("}");

        return builder.ToString();
    }
}

گام ۴: استفاده از ژنراتور در پروژه واقعی کلاینت

اکنون توسعه‌دهنده در پروژه اصلی خود (مثلاً یک API مبتنی بر دات‌نت ۸ یا ۹) کافیست پروژه ژनراتور را به عنوان یک آرتفکت آنالایزر رفرنس دهد:

<ItemGroup>
  <ProjectReference Include="..\SourceGenerator\SourceGenerator.csproj" 
                    OutputItemType="Analyzer" 
                    ReferenceOutputAssembly="false" />
</ItemGroup>

حالا فرض کنید توسعه‌دهنده کلاسی به نام User و کلاسی به نام UserDto دارد. او فقط کلمه‌ی کلیدی partial و اتریبیوت ما را اضافه می‌کند:

using AutoMapping;

namespace MyApplication.Models;

public class UserDto
{
    public string Name { get; set; }
    public string Email { get; set; }
}

[AutoMapTo(typeof(UserDto))]
public partial class User
{
    public string Name { get; set; }
    public string Email { get; set; }
    public int Age { get; set; } // در DTO نیست پس مپ نمی‌شود
}

معجزه زمان کامپایل: به محض اینکه کاربر پروژه را بیلد می‌کند یا در IDE کد می‌زند، Roslyn در پشت صحنه فایل فیزیکی زیر را تولید کرده و بدون اینکه در پوشه پروژه‌ی کاربر فایلی ساخته شود، آن را کامپایل می‌کند:

// کدی که خودکار توسط Roslyn تولید شده است:
namespace MyApplication.Models
{
    public partial class User
    {
        public MyApplication.Models.UserDto MapToUserDto()
        {
            return new MyApplication.Models.UserDto
            {
                Name = this.Name,
                Email = this.Email,
            };
        }
    }
}

اکنون توسعه‌دهنده می‌تواند در کدهای خود به سادگی و با بالاترین سرعت بنویسد:

var dto = user.MapToUserDto();

بخش پنجم: بهترین الگوها (Best Practices) در توسعه ابزارهای Roslyn

توسعه ابزار با Roslyn به دلیل درگیر شدن با فرآیند داخلی کامپایلر، نیازمند دقت بالایی است. اشتباه در طراحی ژنراتور می‌تواند سرعت IDE کاربر را به شدت کاهش داده یا فرآیند CI/CD سازمان را مختل کند.

  1. استفاده اجباری از Incremental Generators: هرگز از ساختار قدیمی ISourceGenerator استفاده نکنید. معماری قدیمی با هر تغییر کاراکتر توسط کاربر، کل کدهای ژنراتور را دوباره اجرا می‌کرد. ساختار جدید (IIncrementalGenerator) مبتنی بر مانیتورینگ گام‌به‌گام و خط لوله کش‌شده (Cached Pipeline) است.

  2. پرهیز از تخصیص سنگین حافظه (Allocation) در بدنه فیلترها: توابع شرطی مانند predicate در متد Initialize میلیون‌ها بار در زمان تایپ کاربر اجرا می‌شوند. در این بخش‌ها هرگز کارهای سنگین معنایی (Semantic) انجام ندهید و فقط به بررسی ویژگی‌های سطحی درخت نحوی (مانند نام نود یا وجود اتریبیوت) بسنده کنید.

  3. مستندسازی و استفاده از Diagnostics: اگر کاربر شما اتریبیوت را در جای اشتباهی استفاده کرد (مثلاً روی یک کلاس غیر partial)، با استفاده از سیستم context.ReportDiagnostic به او هشدار (Warning) یا خطای کامپایل (Error) واضح نشان دهید تا قبل از اجرای برنامه متوجه اشتباه خود شود.

 

 

پلتفرم Roslyn با عمومی‌سازی هوشمندی کامپایلر، انقلابی عمیق در مهندسی دات‌نت ایجاد کرده است. استفاده از این ابزار برای توسعه سیستم‌های Code Generation به ما کمک می‌کند تا هزینه‌های پنهان نگهداری کدهای تکراری را به صفر برسانیم، سرعت اجرای نرم‌افزار را بهینه‌تر کنیم (با تکیه بر سازگاری کامل با فرآیندهای Native AOT) و بهره‌وری تیم‌های توسعه را به طور چشمگیری افزایش دهیم. فهم عمیق پایپ‌لاین نحوی و معنایی Roslyn، مرز بین یک برنامه نویس عادی و یک مهندس نرم‌افزار ارشد ارکستراتور در اکوسیستم مدرن دات‌نت است.

 
لینک استاندارد شده: AIwKFV

0 نظر

    هنوز نظری برای این مقاله ثبت نشده است.
جستجوی مقاله و آموزش
دوره‌ها با تخفیفات ویژه