Pipeline .NET Khiến Source Generator Cảm Giác “Tức Thì”

Khi bạn lần đầu tiên xây dựng một source generator, nó mang lại cảm giác gần như kỳ diệu. Bạn viết một chút code, rebuild, và đột nhiên một file *.g.cs mới xuất hiện trong dự án của bạn.

Trong bài viết trước, chúng ta đã xây dựng nên phép màu đó: một incremental source generator tìm ra interface được chú thích với GenerateApiClientAttribute và phát sinh ra một HttpClient được định kiểu mạnh (strongly typed) cho nó.

Trong bài viết này, tôi muốn vén bức màn đó lên. Chúng ta sẽ xem xét pipeline incremental đã vận hành IIncrementalGenerator, và chúng ta sẽ làm điều đó thông qua chính ví dụ GenerateApiClientAttribute mà bạn đã thấy.

Đến cuối bài, bạn sẽ có một mô hình tinh thần rõ ràng về cách Roslyn engine:

  • Lấy trạng thái compiler bất biến, chẳng hạn như syntax trees và semantic models.
  • Đẩy trạng thái đó qua một chuỗi các phép biến đổi thuần túy.
  • Theo dõi sự phụ thuộc giữa đầu vào và đầu ra.
  • Chỉ tính toán lại những gì cần thiết khi bạn chỉnh sửa một dòng code.

Tất cả những điều này là những gì giữ cho IntelliSense luôn nhạy bén, ngay cả khi generator của bạn đang làm khá nhiều việc.

Kiến trúc pipeline của Incremental Generator

Ý tưởng chủ đạo đằng sau incremental generators là bạn không triển khai một phương thức Execute đơn lẻ. Thay vào đó, bạn đang mô tả một dataflow pipeline.

Ở mức độ tổng quan, pipeline cho API client generator của chúng ta trông như thế này.

Kiến trúc pipeline Incremental Generator

Mỗi ô đại diện cho một giai đoạn trong pipeline. Roslyn engine chạy các giai đoạn này một cách gia tăng (incrementally), lưu vào bộ nhớ đệm kết quả của mỗi bước để những lần biên dịch sau có thể tái sử dụng công việc thay vì bắt đầu lại từ đầu.

Trong code, bạn mô tả pipeline này trong Initialize bằng cách sử dụng các API incremental trên IncrementalGeneratorInitializationContext.

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace MyApiGenerator;

[Generator]
public sealed class ApiClientGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        context.RegisterPostInitializationOutput(static ctx =>
        {
            ctx.AddEmbeddedAttributeDefinition();
            ctx.AddSource("GenerateApiClientAttribute.g.cs", AttributeSourceCode);
        });

        var interfaceDeclarations = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "ApiClientGenerator.GenerateApiClientAttribute",
                static (node, _) => node is InterfaceDeclarationSyntax,
                static (syntaxContext, _) => TryGetTargetInterface(syntaxContext))
            .Where(static iface => iface is not null)!;

        // Các bước pipeline tiếp theo sẽ được thêm vào...
    }
}

Đoạn code này chưa phát sinh ra bất cứ thứ gì. Nó khai báo giai đoạn đầu tiên của pipeline: cách quét syntax tree và tìm các interface mà chúng ta quan tâm. Từ đây, chúng ta sẽ xếp chồng thêm nhiều giai đoạn hơn để biến đổi dữ liệu thành các hình thái hữu ích hơn cho đến khi cuối cùng chúng ta phát sinh ra C#.

Đầu vào bất biến: ảnh chụp nhanh (snapshots) của quá trình biên dịch

Khối xây dựng đầu tiên của một pipeline incremental là tập hợp các đầu vào bạn nhận được từ compiler. Chúng bao gồm:

  • Syntax trees (SyntaxTree, SyntaxNode, SyntaxToken).
  • Semantic models và symbols (SemanticModel, INamedTypeSymbol).
  • Bản thân Compilation.
  • Các file bổ sung và tùy chọn cấu hình.

Tất cả các cấu trúc này đều bất biến (immutable). Một khi compiler đưa cho bạn một syntax tree hoặc một instance compilation, nó sẽ không bao giờ thay đổi.

Tính bất biến nghe có vẻ như một chi tiết lý thuyết, nhưng nó chính là lý do tại sao incremental engine có thể an toàn lưu vào bộ nhớ đệm và chia sẻ dữ liệu. Không có nguy cơ rằng một giai đoạn trong pipeline của bạn làm biến đổi một node mà giai đoạn khác vẫn còn tham chiếu đến.

Bạn thấy tính bất biến đó trong các API incremental. Mỗi lần bạn biến đổi dữ liệu, bạn nhận được một value provider mới thay vì làm biến đổi provider hiện tại.

public void Initialize(IncrementalGeneratorInitializationContext context)
{
    var compilationProvider = context.CompilationProvider;
    var assemblyNameProvider = compilationProvider
        .Select(static (compilation, _) => compilation.AssemblyName ?? "Unknown");

    context.RegisterSourceOutput(assemblyNameProvider, static (spc, assemblyName) =>
    {
        spc.AddSource("AssemblyInfoFromGenerator.g.cs",
            $$"""
            // Generated for {assemblyName}
            """);
    });
}

compilationProvider luôn đại diện cho một ảnh chụp nhanh của quá trình biên dịch. Khi bất cứ thứ gì trong dự án thay đổi, engine tạo ra một ảnh chụp nhanh mới và một giá trị mới sẽ chảy qua pipeline. Các ảnh chụp nhanh cũ hơn vẫn có giá trị đối với bất kỳ kết quả trung gian nào đã được lưu vào bộ nhớ đệm.

Biến đổi thuần túy: mô tả công việc dưới dạng hàm

Incremental generators được xây dựng dựa trên một quy tắc đơn giản. Mỗi giai đoạn pipeline là một hàm thuần túy (pure function) từ đầu vào đến đầu ra. Với cùng một đầu vào, một giai đoạn phải luôn tạo ra cùng một đầu ra, không có tác dụng phụ.

Trong API client generator, phép biến đổi đáng kể đầu tiên là từ InterfaceDeclarationSyntax thành một mô hình phong phú hơn, nắm bắt mọi thứ chúng ta cần để phát sinh code.

internal sealed record ApiClientModel(
    string Namespace,
    string InterfaceName,
    string ClassName,
    string BaseUrl,
    ImmutableArray<ApiMethodModel> Methods);

internal sealed record ApiMethodModel(
    string Name,
    string ReturnType,
    string ParameterType,
    string ParameterName);

private static ApiClientModel? TryCreateModel(
    GeneratorSyntaxContext context,
    CancellationToken cancellationToken)
{
    var ifaceSyntax = (InterfaceDeclarationSyntax)context.Node;
    var interfaceSymbol = context.SemanticModel
        .GetDeclaredSymbol(ifaceSyntax, cancellationToken);

    if (interfaceSymbol is null)
    {
        return null;
    }

    var generateAttr = interfaceSymbol
        .GetAttributes()
        .FirstOrDefault(a => a.AttributeClass?.Name == "GenerateApiClientAttribute");

    if (generateAttr is null)
    {
        return null;
    }

    var baseUrl = generateAttr.ConstructorArguments[0].Value?.ToString()
                 ?? "https://api.example.com";

    var methods = interfaceSymbol.GetMembers()
        .OfType<IMethodSymbol>()
        .Where(m => m.Name.StartsWith("Get", StringComparison.Ordinal))
        .Select(m => new ApiMethodModel(
            Name: m.Name,
            ReturnType: m.ReturnType is INamedTypeSymbol { TypeArguments.Length: 1 } task
                ? task.TypeArguments[0].ToDisplayString() : "object",
            ParameterType: m.Parameters.FirstOrDefault()?.Type.ToDisplayString() ?? "void",
            ParameterName: m.Parameters.FirstOrDefault()?.Name ?? "_"))
        .ToImmutableArray();

    var className = interfaceSymbol.Name.TrimStart('I') + "Client";

    return new ApiClientModel(
        Namespace: interfaceSymbol.ContainingNamespace.ToDisplayString(),
        InterfaceName: interfaceSymbol.Name,
        ClassName: className,
        BaseUrl: baseUrl,
        Methods: methods);
}

Phương thức này là thuần túy. Với một interface declaration và semantic model cụ thể, nó hoặc trả về cùng một ApiClientModel hoặc trả về null. Không có bộ nhớ đệm toàn cục, không có I/O, và không có tác dụng phụ.

Tính thuần túy đó là thứ cho phép Roslyn ghi nhớ (memoize) kết quả. Nếu không có gì thay đổi về interface symbol, engine có thể tái sử dụng ApiClientModel đã được lưu trong bộ nhớ đệm thay vì tính toán lại nó.

Cập nhật gia tăng: chỉ tính toán lại những gì đã thay đổi

Bây giờ hãy xem xét trải nghiệm của nhà phát triển. Bạn đang ở trong SampleApp, chỉnh sửa interface IUserApi. Bạn thay đổi từ:

Task<User> GetUserByIdAsync(int id);

thành:

Task<User> GetUserByEmailAsync(string email);

Pipeline incremental thực sự làm gì?

  1. Compiler chỉ phân tích lại cú pháp của syntax tree đã bị thay đổi.
  2. Vị từ (predicate) CreateSyntaxProvider chạy lại, nhưng chỉ trên các node từ file bị ảnh hưởng.
  3. Đối với node IUserApi, giai đoạn biến đổi được gọi lại để tạo ra một ApiClientModel mới.
  4. Tất cả các interface khác khớp với vị từ trong các file khác sẽ tái sử dụng các mô hình đã được lưu trong bộ nhớ đệm trước đó của chúng.
  5. Giai đoạn RegisterSourceOutput thấy rằng chỉ có một ApiClientModel thay đổi, vì vậy nó chỉ tạo lại nội dung của file UserApiClient.g.cs tương ứng.

Phần còn lại của pipeline không chạy lại. Không có file được phát sinh nào khác bị động đến.

Bạn có thể hình dung điều này như một cây các giá trị được lưu trong bộ nhớ đệm, được cập nhật cục bộ khi có thay đổi.

Cập nhật gia tăng

Chỉ những node được đánh dấu là đã thay đổi mới cần được tính toán lại. Đây chính là bản chất của một incremental pipeline.

Theo dõi phụ thuộc và vô hiệu hóa chính xác

Bên dưới lớp vỏ bọc, mỗi phép biến đổi bạn thêm vào đều mã hóa thông tin về sự phụ thuộc. Engine biết đầu ra nào phụ thuộc vào đầu vào nào.

Ví dụ, khi bạn viết:

var models = interfaceDeclarations
    .Select(static (context, ct) => TryCreateModel(context, ct))
    .Where(static model => model is not null)!;

context.RegisterSourceOutput(models, static (spc, model) =>
{
    EmitClient(spc, model);
});

Engine xây dựng một đồ thị phụ thuộc có thể được tóm tắt như sau:

  • Mỗi ApiClientModel phụ thuộc vào chính xác một InterfaceDeclarationSyntax và semantic model của nó.
  • Mỗi file được phát sinh phụ thuộc vào chính xác một ApiClientModel.

Nếu sau đó bạn kết hợp các provider, đồ thị đó trở nên phong phú hơn. Hãy xem xét một file cấu hình bổ sung cho phép bạn tùy chỉnh quy tắc đặt tên cho các client được phát sinh.

var configText = context.AdditionalTextsProvider
    .Where(static file => file.Path
        .EndsWith("apiclient.config.json", StringComparison.OrdinalIgnoreCase))
    .Select(static (file, ct) => file.GetText(ct)?.ToString())
    .Where(static text => text is not null)!;

var modelsWithConfig = models.Combine(configText);

context.RegisterSourceOutput(modelsWithConfig, static (spc, pair) =>
{
    var (model, configJson) = pair;
    var options = ParseOptions(configJson);
    EmitClient(spc, model, options);
});

Bây giờ mỗi file được phát sinh phụ thuộc vào cả hai:

  • Interface nơi GenerateApiClientAttribute được áp dụng.
  • Nội dung của apiclient.config.json.

Nếu bạn chỉ thay đổi file cấu hình, tất cả các client đều được tạo lại, nhưng quá trình phân tích ngữ nghĩa (semantic analysis) tốn kém của mỗi interface được tái sử dụng. Nếu bạn chỉ thay đổi một interface, chỉ client đó được tạo lại, và cấu hình hiện tại vẫn được áp dụng.

Cơ chế vô hiệu hóa chi tiết này được xử lý hoàn toàn bởi framework. Công việc của bạn là thể hiện rõ ràng các phụ thuộc bằng cách sử dụng các API incremental.

Chuỗi biến đổi trong API Client Generator

Hãy ghép các mảnh lại với nhau và xem xét một pipeline hoàn chỉnh hơn cho API client generator.

public void Initialize(IncrementalGeneratorInitializationContext context)
{
    context.RegisterPostInitializationOutput(static ctx =>
    {
        ctx.AddEmbeddedAttributeDefinition();
        ctx.AddSource("GenerateApiClientAttribute.g.cs", AttributeSourceCode);
    });

    var interfaceDeclarations = context.SyntaxProvider
        .ForAttributeWithMetadataName(
            "ApiClientGenerator.GenerateApiClientAttribute",
            static (node, _) => node is InterfaceDeclarationSyntax,
            static (syntaxContext, _) => TryGetTargetInterface(syntaxContext))
        .Where(static iface => iface is not null)!;

    var models = interfaceDeclarations
        .Select(static (syntaxContext, ct) => TryCreateModel(syntaxContext, ct))
        .Where(static model => model is not null)!;

    context.RegisterSourceOutput(models, static (spc, model) =>
    {
        EmitClient(spc, model);
    });
}

Có một số giai đoạn riêng biệt ở đây.

  1. Post-initialization phát sinh định nghĩa attribute một lần.
  2. Syntax filter thu hẹp các ứng viên node xuống các interface có attribute, sử dụng phương thức ForAttributeWithMetadataName hoặc CreateSyntaxProvider.
  3. Semantic transform xác minh rằng attribute là GenerateApiClientAttribute và tạo ra một ApiClientModel.
  4. Source output kết xuất code C# cuối cùng.

Mỗi giai đoạn cung cấp dữ liệu cho giai đoạn tiếp theo. Bởi vì các phép biến đổi là thuần túy và các đầu vào là bất biến, Roslyn có thể lưu vào bộ nhớ đệm mọi kết quả trung gian.

Phát sinh có chọn lọc: chỉ tạo code khi cần thiết

Đôi khi bạn không muốn phát sinh bất cứ thứ gì cả. Ví dụ, bạn có thể quyết định rằng một interface không có phương thức Get* nào thì không nên tạo ra một client.

Thay vì xử lý điều này bên trong EmitClient, bạn có thể định hình pipeline của mình để những interface đó được lọc ra sớm hơn.

var models = interfaceDeclarations
    .Select(static (syntaxContext, ct) => TryCreateModel(syntaxContext, ct))
    .Where(static model => model is not null && model.Methods.Length > 0)!;

context.RegisterSourceOutput(models, static (spc, model) =>
{
    EmitClient(spc, model);
});

Bước source output bây giờ chỉ được gọi khi có thứ gì đó có ý nghĩa để phát sinh.

Phát sinh có chọn lọc mang lại hai lợi ích quan trọng.

  • Hiệu suất được cải thiện vì công việc của pipeline dừng lại sớm hơn.
  • Dự án được giữ sạch sẽ vì ít file được phát sinh hơn.

Bạn có thể tiến xa hơn nữa và có các pipeline khác nhau cho các loại đầu vào khác nhau, tất cả đều cung cấp dữ liệu cho các giai đoạn RegisterSourceOutput riêng của chúng.

Nhận biết đa file: tổng hợp nhiều interface

Incremental pipelines không bị giới hạn ở ánh xạ một-một giữa đầu vào và đầu ra. Bạn cũng có thể xây dựng các tập hợp (aggregations) nơi nhiều đầu vào đóng góp vào một file được phát sinh duy nhất.

Một mẫu phổ biến là registry hoặc mapper được phát sinh từ nhiều kiểu được chú thích. Đối với các API client của chúng ta, hãy tưởng tượng việc phát sinh một file ApiClientRegistry.g.cs duy nhất hiển thị tất cả các client đã được phát sinh ở một nơi.

var allModels = models.Collect();

context.RegisterSourceOutput(allModels, static (spc, all) =>
{
    if (all.IsDefaultOrEmpty)
    {
        return;
    }

    EmitRegistry(spc, all);
});

Ở đây, Collect biến nhiều giá trị ApiClientModel thành một ImmutableArray<ApiClientModel> duy nhất. Điều này tạo ra một phụ thuộc nhiều-một. Nếu bất kỳ interface đơn lẻ nào thay đổi, registry được tạo lại. Các file được phát sinh khác chỉ phụ thuộc vào một mô hình duy nhất tiếp tục được hưởng lợi từ bộ nhớ đệm chi tiết hơn.

Tổng hợp nhiều interface

Khả năng nhận biết đa file này rất cần thiết cho các tình huống như:

  • Tạo ra một mapper cho tất cả DTOs.
  • Phát sinh một phương thức đăng ký dependency injection duy nhất cho nhiều service.
  • Xây dựng một mô tả OpenAPI hợp nhất từ nhiều endpoint được chú thích.

Lợi ích hiệu suất trong các dự án thực tế

Cho đến nay, chúng ta đã tập trung vào hình dạng của pipeline. Hãy nói về lý do tại sao điều này lại quan trọng trong các codebase thực tế.

Các generator truyền thống, không gia tăng (non-incremental) triển khai ISourceGenerator và chạy phương thức Execute của chúng trên mỗi lần biên dịch. Ngay cả khi hầu như không có gì thay đổi, generator vẫn duyệt toàn bộ syntax tree, tính toán lại tất cả các phân tích và tạo lại tất cả các file.

Incremental generators tránh sự lãng phí này thông qua:

  • Bộ nhớ đệm các kết quả trung gian cho mỗi đầu vào.
  • Thực thi có chọn lọc dựa trên theo dõi phụ thuộc.
  • Các ảnh chụp nhanh bất biến an toàn để chia sẻ qua các luồng.

Tác động dễ thấy nhất là trong IDE. Khi bạn gõ trong IUserApi, syntax predicate chạy rất nhanh. Chỉ khi bạn hoàn thành một interface declaration hợp lệ, semantic transform mới chạy và cập nhật client đã được phát sinh.

Trong các giải pháp lớn, sự khác biệt là đáng kể. Thay vì dành hàng trăm mili giây hoặc hơn cho mỗi lần gõ phím để tính toán lại đầu ra của generator, engine chỉ dành micro giây để chạy các vị từ và chỉ thỉnh thoảng chạy lại các giai đoạn nặng hơn.

Bạn có thể kiểm tra hành vi này bằng cách sử dụng generator diagnostics có sẵn thông qua GeneratorDriver trong các bài kiểm tra của bạn, hoặc bằng cách bật tính năng phát sinh các file do compiler tạo ra và xem khi nào chúng thay đổi.

Nguyên tắc thực tế để xây dựng Incremental Pipeline

Khi bạn thiết kế một incremental generator, mục tiêu không phải là tỏ ra thông minh. Mục tiêu là làm cho các phụ thuộc và phép biến đổi trở nên rõ ràng để engine có thể tối ưu hóa thay cho bạn.

Dưới đây là một số nguyên tắc xuất hiện từ các khái niệm chúng ta đã đề cập.

  1. Giữ đầu vào bất biến và thuần túy. Tránh trạng thái tĩnh (static state), giá trị ngẫu nhiên hoặc I/O bên trong các phép biến đổi của bạn.
  2. Làm công việc rẻ nhất trước. Sử dụng syntax predicates để lọc các ứng viên trước khi bạn chạm vào semantic model một cách tốn kém.
  3. Định hình dữ liệu thành các mô hình nhỏ. Biến đổi syntax và symbols thành các record như ApiClientModel dễ dàng suy luận và kiểm tra.
  4. Sử dụng Where, Select, CombineCollect để thể hiện các phụ thuộc. Để framework xử lý việc vô hiệu hóa; tập trung vào tính đúng đắn.
  5. Lọc sớm để phát sinh có chọn lọc. Nếu một đầu vào không cần tạo ra code, hãy giữ nó ra khỏi các giai đoạn cuối của pipeline.
  6. Tổng hợp một cách có chủ ý. Chỉ sử dụng Collect khi bạn thực sự cần một cái nhìn tổng thể, vì nó buộc phải vô hiệu hóa trên diện rộng hơn khi bất kỳ đầu vào nào thay đổi.

Nếu bạn tuân theo những nguyên tắc này, bạn sẽ có được những generator cảm giác như “miễn phí” đối với người dùng của bạn, ngay cả trên các codebase lớn.

Suy nghĩ lại về Generator như Data Pipeline

Thật dễ dàng để nghĩ về một incremental generator như một trình tạo chuỗi (string builder) hào nhoáng chạy trong quá trình biên dịch. Tuy nhiên, khi bạn đào sâu vào pipeline, một bức tranh khác sẽ hiện ra.

Một IIncrementalGenerator là một chương trình dataflow nhỏ bên trong compiler. Nó tiêu thụ các ảnh chụp nhanh bất biến của code bạn, áp dụng một loạt các phép biến đổi thuần túy và phát sinh code mới chỉ khi dữ liệu cơ bản thay đổi.

Một khi bạn nhìn nhận generator theo cách này, những cơ hội mới sẽ mở ra.

  • Bạn có thể tự tin thêm các phân tích phong phú hơn vì bạn biết nó sẽ được lưu vào bộ nhớ đệm.
  • Bạn có thể mô hình hóa các mối quan hệ phức tạp như tổng hợp đa file.
  • Bạn có thể coi các file cấu hình như đầu vào hạng nhất thay vì các mẹo vặt tạm bợ.

Nói cách khác, bạn ngừng chiến đấu với compiler và bắt đầu cộng tác với nó.

Chỉ mục