Đường Ống .NET Khiến Source Generator Cảm Giác Tức Thì

Khi bạn lần đầu xây dựng một source generator, nó có cảm giác gần như ma thuật. Bạn viết một đoạn code, build lại, 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 đầu tiên, chúng ta đã xây dựng phép màu đó: một incremental source generator tìm interface được chú thích với GenerateApiClientAttribute và phát sinh ra một implementation HttpClient với kiểu mạnh (strongly typed) cho nó.

Trong bài viết này, tôi muốn hé lộ những gì đằng sau bức màn. Chúng ta sẽ xem xét đường ống incremental (incremental pipeline) cung cấp sức mạnh cho IIncrementalGenerator, và chúng ta sẽ làm điều đó thông qua cùng ví dụ GenerateApiClientAttribute mà bạn đã thấy trước đâ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 các trạng thái compiler bất biến, như cây cú pháp (syntax trees) và mô hình ngữ nghĩa (semantic models).
  • Đẩy trạng thái đó qua một chuỗi các phép biến đổi thuần túy (pure transformations).
  • Theo dõi các phụ thuộc giữa đầu vào và đầu ra.
  • Tính toán lại chỉ những gì cần thiết khi bạn chỉnh sửa một dòng duy nhất.

Tất cả điều này giúp IntelliSense luôn phản hồi nhanh, ngay cả khi generator của bạn đang làm khá nhiều việc.

Kiến trúc đường ống của Incremental Generator

Ý tưởng chính đằng sau incremental generators là bạn không triển khai một phương thức Execute duy nhất. Thay vào đó, bạn đang mô tả một đường ống luồng dữ liệu (dataflow pipeline).

Ở mức cao, đường ống cho API client generator của chúng ta trông như thế này.

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

Trong code, bạn mô tả đường ống 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ẽ theo sau...
    }
}

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

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

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

  • Cây cú pháp (SyntaxTree, SyntaxNode, SyntaxToken).
  • Mô hình ngữ nghĩa và symbol (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. Một khi compiler trao cho bạn một cây cú pháp hoặc một instance biên dịch, nó sẽ không bao giờ thay đổi.

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

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

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 chảy qua đường ống. Các ảnh chụp nhanh cũ vẫn hợp lệ cho bất kỳ kết quả trung gian nào đã được lưu trong bộ nhớ đệm.

Phép 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 trong đường ống là một hàm thuần túy 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ụ (side effects).

Trong API client generator, phép biến đổi quan trọng đầ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 mã.

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 khai báo interface cụ thể và mô hình ngữ nghĩa, nó hoặc trả về cùng một ApiClientModel hoặc null. Không có bộ đệ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 Incremental: 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);

Đường ống incremental thực sự làm gì?

  1. Compiler phân tích lại chỉ cây cú pháp đã thay đổi.
  2. Vị từ 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 tái sử dụng các model đã được lưu trong bộ nhớ đệm trước đó.
  5. Giai đoạn RegisterSourceOutput thấy rằng chỉ một ApiClientModel thay đổi, vì vậy nó chỉ tạo lại nội dung UserApiClient.g.cs tương ứng.

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

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 và được cập nhật cục bộ khi có thứ gì đó thay đổi.

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

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

Bên dưới nắp máy, mỗi phép biến đổi bạn thêm vào đều mã hóa thông tin 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à mô hình ngữ nghĩa 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 này 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);
});

Giờ đây, 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ả client đều được tạo lại, nhưng quá trình phân tích ngữ nghĩa 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ới 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 đường ống 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 node ứng viên thành các interface có attribute bằng cách sử dụng các 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 mã C# cuối cùng.

Mỗi giai đoạn nuôi dưỡng giai đoạn tiếp theo. Bởi vì các phép biến đổi là thuần túy và đầ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 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 client.

Thay vì xử lý điều này bên trong EmitClient, bạn có thể định hình đường ống của mình để các 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 phát sinh source output giờ chỉ được gọi khi có thứ gì đó có ý nghĩa để phát ra.

Phát sinh có chọn lọc có 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 luôn sạch sẽ vì ít file được phát sinh hơn.

Bạn có thể đưa điều này đi xa hơn và có các đường ống khác nhau cho các loại đầu vào khác nhau, tất cả đều nuôi dưỡng 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

Đường ống incremental 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ổng 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à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 model tiếp tục được hưởng lợi từ bộ nhớ đệm chi tiết hơn.

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ư:

  • Phát sinh một mapper cho tất cả DTO.
  • 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 đường ống. Hãy nói về lý do tại sao điều này lại quan trọng trong các cơ sở mã thực tế.

Các generator truyền thống, không 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 qua toàn bộ cây cú pháp, 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.
  • Ảnh chụp nhanh bất biến an toàn để chia sẻ trên các luồng.

Tác động rõ rệt nhất là trong IDE. Khi bạn gõ trong IUserApi, vị từ cú pháp chạy rất nhanh. Chỉ khi bạn hoàn thành một khai báo interface hợp lệ, phép biến đổi ngữ nghĩa 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à 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 chẩn đoán generator 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à theo dõi khi nào chúng thay đổi.

Hướng dẫn thực tế để xây dựng đường ống Incremental

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ố hướng dẫn 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, 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 vị từ cú pháp để lọc ứng viên trước khi bạn mạnh tay chạm vào mô hình ngữ nghĩa.
  3. Định hình dữ liệu thành các model nhỏ. Biến đổi cú pháp và symbol thành các record như ApiClientModel dễ suy luận và kiểm tra.
  4. Sử dụng Where, Select, Combine và Collect để thể hiện 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 đường ống.
  6. Tổng hợp có chủ đí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 rộng hơn khi bất kỳ đầu vào nào thay đổi.

Nếu bạn tuân theo các nguyên tắc này, bạn sẽ có các 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 cơ sở mã lớn.

Suy nghĩ lại về Generator như Đường ống Dữ liệu

Thật dễ để nghĩ về một incremental generator như một trình xây dựng chuỗi được tôn vinh chạy trong quá trình biên dịch. Tuy nhiên, khi bạn đào sâu vào đường ống, một bức tranh khác hiện ra.

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

Một khi bạ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 giải pháp 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ó.