The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

新手上路 C# 原始碼產生器 (Source Generators)

最近迷上 C# 原始碼產生器 (Source Generators) 這門相當新穎的技術,跟以往常用的 T4 (Text Template Transformation Toolkit) 產生器技術不太一樣,這個 Source Generators 是屬於 Roslyn 編譯器的技術之一,讓你在專案建置的過程中,可以對正在編譯的 C# 原始碼進行增補,動態加入「額外」的原始碼,最後再編譯在一起,非常神奇又實用的技術,讓人非常有想像空間! 👍

區別 T4 與 Source Generators 的差異

從概念上來說,這兩個「程式碼產生器」技術相當類似,不過執行方式卻完全不同:

  1. T4

    主要是內嵌在 Visual Studio 中的一門技術,打從 Visual Studio 2005 開始就存在,不過這套產品一直不受微軟愛戴,直到 Visual Studio 2019 都還缺乏很基本的語法高量功能,都要靠擴充套件才能顯示語法高量,而且還很容易有 Bug 出現,尤其是使用「深色」模式的布景主題。使用 T4 技術來產生任意程式碼有個好處,他可以讓你在 Visual Studio 開發工具裡自動產生任意以「文字」為主的檔案內容,可以是 C#、可以是 VB、也可以是 XML 檔案,可以說是非常靈活。

  2. Source Generators

    主要執行在 Roslyn 編譯器的管道中 (compiler pipeline),所以產生器只能執行在 C# 編譯時期,你可以透過 SyntaxTree 讀取與分析目前專案中所有 C# 程式碼,你可以在分析完原始碼之後,自行產生「額外」的新程式碼,但是這份「新的程式碼」並不會出現在專案的原始碼中,而是在編譯時期自動產生的暫時程式碼,他會被編譯到你最終的 Assembly 組件裡。所以,你不能拿 Source Generators 來做一些跟 C# 編譯無關的事情(雖然你也可以這麼做,因為執行的其實也是 C# 程式,你想寫入任何檔案也是有可能的)。

    由於 Source Generators 跟 Roslyn 相關,跟開發工具比較沒有直接關係,所以無論你用 Visual Studio 2019, Visual Studio Code 或使用 .NET CLI 建置專案,都可以自動利用 Source Generators 產生程式碼!

以下我將會使用 .NET 5 (SDK version 5.0.100) CLI 搭配 Visual Studio Code 來撰寫一份最簡單的 Source Generators 實作。

建立 Source Generators 專案

你的任何一個 .NET Core 專案都可以加入任意 Source Generators 專案,不過有一些需要注意的地方,它並不是很單純的寫一個 Class 就可以完成 Source Generator 開發!

  1. 建立一個 Console 應用程式 (TFM: net5.0)

    dotnet new console -n ConsoleApp1
    
  2. 建立一個 Class library 類別庫專案 (TFM: netstandard2.0)

    dotnet new classlib -n MySourceGenerator
    
  3. 使用 Visual Studio Code 開啟 ConsoleApp1 並加入 MySourceGenerator 資料夾到工作區

    使用 Visual Studio Code 開啟 ConsoleApp1 並加入 MySourceGenerator 資料夾到工作區

    加入成功後的畫面如下:

    工作區 (Workspace)

    建議可以將多專案的工作區儲存起來,方便下次開啟:

    將多專案的工作區儲存起來

  4. 將 MySourceGenerator 專案加入成為 ConsoleApp1 的專案參考 (Project Reference)

    dotnet add reference ..\MySourceGenerator\MySourceGenerator.csproj
    

    事實上這個命令會在 ConsoleApp1\ConsoleApp1.csproj 加入以下 <ItemGroup> 定義:

    <ItemGroup>
      <ProjectReference Include="..\MySourceGenerator\MySourceGenerator.csproj" />
    </ItemGroup>
    

    不過,這個設定必須進行微調,請調整成以下設定,這才能真正成為一個有效的 Source Generator 專案參考:

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

    這裡的 OutputItemType="Analyzer" 用來設定 MySourceGenerator 專案是一個「分析器」專案。而 ReferenceOutputAssembly="false" 則是設定該專案不會成為該專案的參考組件。詳見 Common MSBuild project items 文件。

上述這四個步驟,是相當重要的初步設定,尤其是最後一步,沒有正確設定的話 Source Generators 是完全無法執行的!

撰寫基礎 Source Generator 類別

以下步驟都是在 MySourceGenerator 專案下進行設定:

  1. 請將 Class1.cs 更名為 MySourceGenerator.cs,並將內容修改為以下內容:

    using System;
    
    namespace MySourceGenerator
    {
        public class MySourceGenerator
        {
        }
    }
    
  2. 加入 Microsoft.CodeAnalysis.CSharpMicrosoft.CodeAnalysis.Analyzers 套件

    dotnet add package Microsoft.CodeAnalysis.CSharp
    dotnet add package Microsoft.CodeAnalysis.Analyzers
    
  3. MySourceGenerator 類別套用 [Generator] Attribute 與實作 ISourceGenerator 介面

    using System;
    using Microsoft.CodeAnalysis;
    
    namespace MySourceGenerator
    {
        [Generator]
        public class MySourceGenerator : ISourceGenerator
        {
            public void Execute(GeneratorExecutionContext context)
            {
            }
    
            public void Initialize(GeneratorInitializationContext context)
            {
            }
        }
    }
    

    這裡的 MySourceGenerator 類別已經設定完成,當 ConsoleApp1 專案再進行建置時,該類別就會自動執行,先執行 Initialize() 方法,再執行 Execute() 方法!

  4. 加入「程式碼產生器」到 Execute() 方法中 (記得加入必要的 using 匯入命名空間)

    我直接提供官方提供的 Source Generators 範例程式來進行實作,請參考 HelloWorldGenerator.cs 檔案:

    using System;
    using System.Collections.Generic;
    using System.Text;
    using Microsoft.CodeAnalysis;
    using Microsoft.CodeAnalysis.Text;
    
    namespace MySourceGenerator
    {
        [Generator]
        public class MySourceGenerator : ISourceGenerator
        {
            public void Execute(GeneratorExecutionContext context)
            {
                // begin creating the source we'll inject into the users compilation
                StringBuilder sourceBuilder = new StringBuilder(@"
    using System;
    namespace HelloWorldGenerated
    {
        public static class HelloWorld
        {
            public static void SayHello()
            {
                Console.WriteLine(""Hello from generated code!"");
                Console.WriteLine(""The following syntax trees existed in the compilation that created this program:"");
    ");
    
                // using the context, get a list of syntax trees in the users compilation
                IEnumerable<SyntaxTree> syntaxTrees = context.Compilation.SyntaxTrees;
    
                // add the filepath of each tree to the class we're building
                foreach (SyntaxTree tree in syntaxTrees)
                {
                    sourceBuilder.AppendLine($@"Console.WriteLine(@"" - {tree.FilePath}"");");
                }
    
                // finish creating the source to inject
                sourceBuilder.Append(@"
            }
        }
    }");
    
                // inject the created source into the users compilation
                context.AddSource("helloWorldGenerated", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8));
            }
    
            public void Initialize(GeneratorInitializationContext context)
            {
            }
        }
    }
    

    從這段程式碼,你應該可以看出這裡的「原始碼產生器」非常的「原始」,其實就是「組字串」成一個 C# 程式碼而已,這是 Source Generator 最簡單的撰寫方式,我想應該大家都可以勝任這個工作。

    比較進階的方式則是使用 SyntaxTree 進行深入分析與修改。

使用 Source Generator 自動產生的類別

從上述程式碼可以看出,我們動態產生的 C# 程式碼會有一個 HelloWorldGenerated 命名空間,有一個靜態類別名稱為 HelloWorld,有個靜態方法 SayHello() 被建立。

  1. 修改 ConsoleApp1\Program.cs 程式碼如下

    using System;
    
    namespace ConsoleApp1
    {
        class Program
        {
            static void Main(string[] args)
            {
                HelloWorldGenerated.HelloWorld.SayHello();
            }
        }
    }
    

    請注意:目前 Visual Studio Code 的 C# 語言伺服器尚未支援 Source Generators 語法,所以會出現以下錯誤訊息:

    請注意:目前 Visual Studio Code 的 C# 語言伺服器尚未支援 Source Generators 語法,所以會出現以下錯誤訊息:

  2. 透過 dotnet run 執行 ConsoleApp1 程式

    dotnet run
    

    透過 dotnet run 執行 ConsoleApp1 程式

關於 Visual Studio 2019

目前 Visual Studio 2019 16.8 已經支援 Source Generators 特性,所以無論 IntelliSense 或各種開發輔助都支援的非常到位,你甚至可以看到「自動產生」的程式碼內容!

  1. 建立 *.sln 方案檔,並加入專案到方案中

    dotnet new sln
    dotnet sln add MySourceGenerator
    dotnet sln add ConsoleApp1
    
  2. 使用 Visual Studio 2019 開啟專案並執行

    如果你在 Visual Studio 2019 看到以下錯誤,千萬不要覺得意外,目前就算是最新的 Visual Studio 2019 16.8 版,目前對 Source Generators 還是時好時壞,非常不穩定!

    如果你在 Visual Studio 2019 看到以下錯誤,千萬不要覺得意外,目前就算是最新的 Visual Studio 2019 16.8 版,目前對 Source Generators 還是時好時壞,非常不穩定!

    請手動將 MySourceGenerator.csproj 專案檔內的 <TargetFramework> 修改設定為 netstandard2.0,你的 Visual Studio 2019 就可以認得 Source Generators 產生的類別了!

    Visual Studio 2019 可以認得 Source Generators 產生的類別

    如果你按 F12 移至定義,就會看到這些自動產生的程式碼內容:

    This file is auto-generated by the generator 'MySourceGenerator.MySourceGenerator' and cannot be edited.

注意:Visual Studio 2019 對於 Source Generators 的支援度還很有限,問題很多,主要是會經常出現 IntelliSense 消失與編譯錯誤的問題,但即便 Visual Studio 2019 說編譯錯誤,但其實是可以正常編譯與執行的!請追蹤 Source Generators: design-time completion/intellisense is never fixed #44093 這個 Issue 的後續更新!

如何偵錯 Source Generators 自動產生程式碼的過程

你只要加入以下這行程式碼在 MySourceGenerator.csInitialize(GeneratorInitializationContext context) 方法中即可:

public void Initialize(GeneratorInitializationContext context)
{
    System.Diagnostics.Debugger.Launch();
}

當你的 ConsoleApp1 專案有任何更新時,專案只要重新建置,就會自動觸發 Source Generator 執行,畫面上便會自動提示要不要啟動偵錯器:

Choose Just-In-Time Debugger

結語

透過上述簡單的實作,你應該可以看出 Source Generators 的強大魅力,我已經使用 Source Generators 在自己的專案上,成效卓越! 👍

不過我真的也遇到 Source Generators 的許多地雷,耗費了我不少時間:

  • 變更 Source Generator 專案中的類別,並不會導致 Generator 自動重新建置!
  • 你必須變更主要專案(ConsoleApp1)的原始碼,或是清空主要專案,才會讓 Source Generator 重新執行!
  • 如果主要專案建置失敗,那麼每次建置都會重新執行 Source Generator
  • 你的 Visual Studio 2019 會一直在背景重新建置專案,此時你在編輯程式碼打字的過程中,就會導致 Generator 類別不斷重複執行!
  • 從 Visual Studio 2019 的輸出視窗可以很輕易的看出 Source Generator 的執行錯誤!
  • 我有寫一個 Source Generator 範例程式,包含完整的開發步驟與流程:https://github.com/doggy8088/SourceGeneratorDemo

相關連結

留言評論