使用 Visual Studio 和原始程式碼產生器自動生成代碼

更新頁 :
頁面創建日期 :

操作環境

Visual 工作室
  • Visual Studio 2022
。網
  • .NET 8.0

先決條件

Visual 工作室
  • 它甚至可以與較舊的版本一起使用
。網
  • 它甚至可以與較舊的版本一起使用

起先

有幾種技術可以自動生成具有您自己的定義的代碼,但在本文中,我將向您展示如何使用原始程式碼生成器。 原始碼產生器的最大優點之一是它分析當前專案原始碼的結構並基於它生成新代碼。 例如,在創建新類時,可以創建新類,以便自動添加代碼以匹配該類。 您可以對要生成的代碼類型進行程式設計,因此您可以建立您喜歡的任何形式的自動代碼生成。

代碼基本上是在後台自動生成的,並在幕後合併到專案中。 由於它不是以可見的方式作為文件輸出的,因此它不用於將自動生成的代碼重用於一般目的(儘管可以通過暫時複製它來刪除它)。 但是,由於代碼是根據專案的結構自動生成的,因此降低了人工輸入的成本,並相應地減少了代碼編寫錯誤,這是一個巨大的優勢。

在本文中,我將解釋如何檢查代碼是否是自動生成的,因此我不會深入分析代碼並執行高級輸出。 請自行查找作為應用程式。

設置

首先,安裝Visual Studio。 以下提示總結了簡要說明。

基本上,您可以將其用於任何專案,因此設置哪個工作負載並不重要。 然而,這一次,作為一個“單獨的組成部分”,“. NET 編譯器平臺 SDK。 這對於在源生成器開發期間進行調試非常有用。 如果已安裝 Visual Studio,則可以從“工具”>“獲取工具和功能”下的 Visual Studio 功能表添加它。

創建和準備源產生器專案

源產生器是在獨立於主應用程式專案的專案中創建的。 無論您是先創建它們還是稍後創建其他它們都無關緊要。 在這種情況下,我將從Source Generator項目創建它。

在「創建新項目」螢幕上,選擇「類庫」。

專案名稱可以是任何名稱,但現在 CodeGenerator ,我們將保留它作為。

對於類庫,原始程式碼產生器目前支援 . NET 標準 2.0。

創建專案後,使用 Microsoft.CodeAnalysis.CSharp NuGet 獲取包。 行為可能因版本而異,但繼續使用舊版本沒有意義,所以我會放最新版本。

然後以代碼形式打開項目檔。

當您打開它時,您將看到以下內容。

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

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
  </ItemGroup>

</Project>

按如下方式添加: 隨意添加您需要的任何其他內容。

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

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>

    <!-- Roslyn によるコード解析を行う -->
    <IsRoslynComponent>true</IsRoslynComponent>
    <!-- 入れないと警告が表示されるので入れておく -->
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <!-- 最新の C# の記述を使いたいので入れておく -->
    <LangVersion>Latest</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
  </ItemGroup>

</Project>

接下來,為將自動生成代碼的類編寫代碼。 首先,我們只會創建一個框架,所以請從頭開始 Class1.cs 重寫現有的代碼或添加新的代碼。

代碼應如下所示: 類名可以是任何名稱,但最好有一個顯示自動生成的代碼類型的名稱。 SampleGenerator 現在,將其保留為 . Initialize 在此方法中,您將編寫代碼分析和代碼生成過程。

using Microsoft.CodeAnalysis;

namespace CodeGenerator
{
  [Generator(LanguageNames.CSharp)]
  public partial class SampleGenerator : IIncrementalGenerator
  {
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
    }
  }
}

接下來,為要自動生成代碼的應用程式創建一個專案。 它可以是任何東西,但在這裡我們將簡單地將其用作控制台應用程式。 自動生成代碼的專案框架的類型和版本與通用編號相對應。

從應用程式端專案添加對源產生器專案的引用。

某些設置無法在屬性中設置,因此請在代碼中打開項目檔。

我認為它看起來像這樣:

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

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

</Project>

添加引用項目的設置,如下所示: 請確保 XML 的語法沒有錯誤。

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\CodeGenerator\CodeGenerator.csproj">
      <OutputItemType>Analyzer</OutputItemType>
      <ReferenceOutputAssembly>False</ReferenceOutputAssembly>
    </ProjectReference>
  </ItemGroup>

</Project>

接下來,在“代碼生成器端”打開項目屬性。

按兩下連結以打開除錯啟動屬性UI。

刪除原始配置檔,因為您不想使用它。

添加新的配置檔。

選擇“Roslyn 元件”。

如果到目前為止已進行設置,則應該能夠選擇應用程式專案,因此請選擇它。

準備工作到此結束。

檢查是否可以調試

打開源生成器代碼,並在 Initialize 方法末尾放置一個斷點。

讓我們調試源生成器。

如果進程在斷點處停止,則可以確認調試正常。 這應該使源生成器的開發變得相當容易。

暫時,讓我們輸出一個固定的代碼

首先,讓我們嘗試輕鬆輸出固定代碼。 這很容易,因為您甚至不需要分析代碼。 即使它是固定代碼,它也是作為字串處理的,因此可以通過構建程式來增加固定形式的代碼的產生。

如果要輸出固定代碼, context.RegisterPostInitializationOutput 可以使用 . 下面是代碼輸出的範例。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using System.Text;

namespace CodeGenerator;

[Generator(LanguageNames.CSharp)]
public partial class SampleGenerator : IIncrementalGenerator
{
  public void Initialize(IncrementalGeneratorInitializationContext context)
  {
    // コードを解析せずそのままコードを出力するならこれを使用します
    context.RegisterPostInitializationOutput(static postInitializationContext =>
    {
      // コード生成処理が中断されることもあるので CancellationToken 処理は入れたほうがいいです
      System.Threading.CancellationToken token = postInitializationContext.CancellationToken;
      token.ThrowIfCancellationRequested();

      // 出力するコードを作ります
      var source = """
internal static class SampleClass
{
  public static void Hello() => Console.WriteLine("Hello Source Generator!!");
}
""";
      
      // コードファイルに出力します (実際に目に見えるどこかのフォルダに出力されるわけではありません)
      postInitializationContext.AddSource("SampleGeneratedFile.cs", SourceText.From(source, Encoding.UTF8));
    });
  }
}

代碼內容與註釋中寫的相同,因此我將省略詳細資訊。 構建它並確保沒有錯誤。

生成完成後,可以在應用程式項目中展開「分析器」,以查看代碼生成器生成的代碼。

如果未看到它,請重新啟動 Visual Studio 並檢查它。 目前看來,除非重新啟動Visual Studio,否則它可能不會更新。 創建程式時使用的智慧和語法突出顯示是相似的。 但是,由於代碼本身在構建時就被反映出來了,因此程式似乎被反映在應用程式端。

生成代碼後,請嘗試在應用程式中使用它。 它應該工作正常。

分析和生成代碼

如果只是正常處理代碼並輸出,它與其他自動代碼生成沒有太大區別,並且無法利用原始程式碼生成器的優勢。 所以現在讓我們分析應用程式專案的代碼並相應地生成代碼。

但是,對代碼的分析相當深入,所以我無法在這裡解釋所有內容。 目前,我將解釋到您可以分析和輸出代碼的程度。 如果您想更深入,請自己做研究。

目前,作為示例,我想自動創建代碼「向 Reset 所有創建的類添加方法並將所有屬性的值 default 重置為」。 近年來,處理不可變實例似乎是首選,但在遊戲程式中,不可能每幀都創建一個新實例,所以我認為重置值的過程是有用的。 default 您可能還想設置其他內容,但如果您這樣做,這將是一個很長的第一個示例,因此請自己應用它。

代碼生成器端

創建一個新類,以便保留之前創建的生成器。 如果要自動生成的內容發生變化,最好在另一個類中創建一個新內容。

Roslyn 編譯器負責代碼的結構化,因此我們在這裡要做的是解析結構化數據並編寫代碼。

首先,我將發佈所有代碼。 我嘗試使用盡可能少的代碼。 這一次它起作用了,但它只是在作為先生/女士移動的級別上,所以當你真正進行開發時,會有很多缺失的部分。 請根據需要改進。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Linq;
using System.Text;
using System.Threading;

namespace CodeGenerator;

[Generator(LanguageNames.CSharp)]
public partial class Sample2Generator : IIncrementalGenerator
{
  public void Initialize(IncrementalGeneratorInitializationContext context)
  {
    // 構文を解析し処理対象のノードをコード出力用に変換します
    var syntaxProvider = context.SyntaxProvider.CreateSyntaxProvider(
      // すべてのノードが処理対象となるため、predicate で処理対象とするノードを限定します
      predicate: static (node, cancelToken) =>
      {
        // 処理が中断される場面は多々あるのでいつでもキャンセルできるようにしておく
        cancelToken.ThrowIfCancellationRequested();

        // クラスのノードのみを対象とする (record とかつけると別な種類のノードになるので注意)
        // また partial class であること
        return node is ClassDeclarationSyntax nodeClass && nodeClass.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword));
      },
      // 対象のノードをコード出力に必要な形に変換します
      transform: static (contextSyntax, cancelToken) =>
      {
        // いつでもキャンセルできるようにしておく
        cancelToken.ThrowIfCancellationRequested();

        // 今回は ClassDeclarationSyntax 確定なのでそのままキャストします
        ClassDeclarationSyntax classDecl = (ClassDeclarationSyntax)contextSyntax.Node;

        // データと構文から宣言されたシンボルを取得します
        // 今回利用場面がほとんどないですが取得しておくといろんな情報を取得できるので便利です
        var symbol = Microsoft.CodeAnalysis.CSharp.CSharpExtensions.GetDeclaredSymbol(contextSyntax.SemanticModel, classDecl, cancelToken)!;

        // 出力に必要な値を返します。大抵の型を渡すことができます
        return (symbol, classDecl);
      }
    );

    // 解析し変換した情報をもとにコードを出力します
    context.RegisterSourceOutput(syntaxProvider, static (contextSource, input) =>
    {
      // いつでもキャンセルできるようにしておく
      CancellationToken cancelToken = contextSource.CancellationToken;
      cancelToken.ThrowIfCancellationRequested();

      // 解析後に渡された値を受け取ります
      var (symbol, classDecl) = input;

      StringBuilder sbInitValues = new();

      // プロパティを列挙して値を初期化するコードを生成
      foreach (var syntax in classDecl.Members.OfType<PropertyDeclarationSyntax>())
      {
        sbInitValues.AppendLine($"{syntax.Identifier.ValueText} = default;");
      }

      // 出力するコードを作成
      var source = @$"
        {string.Join(" ", classDecl.Modifiers.Select(x => x.ToString()))} class {symbol.Name}
        {{
          public void Reset()
          {{
            {sbInitValues}
          }}
        }}";

      // ファイル名はクラス名に合わせておきます。作成したコードを出力します
      contextSource.AddSource($"{symbol.Name}.g.cs", source);
    });
  }
}

我已經在評論中寫過了,但我會在某些地方解釋它。

// 構文を解析し処理対象のノードをコード出力用に変換します
var syntaxProvider = context.SyntaxProvider.CreateSyntaxProvider(
  // すべてのノードが処理対象となるため、predicate で処理対象とするノードを限定します
  predicate: ...,
  // 対象のノードをコード出力に必要な形に変換します
  transform: ...
);

context.SyntaxProvider.CreateSyntaxProvider 分析應用程式專案中的代碼並盡可能地對其進行結構化。 一切都被劃分為節點,我們用它來確定我們 predicate 要處理的節點。 此外,如有必要 transform ,轉換處理后的物件並將其傳遞到輸出端。

predicate在這種情況下,我們正在執行以下操作。

// すべてのノードが処理対象となるため、predicate で処理対象とするノードを限定します
predicate: static (node, cancelToken) =>
{
  // 処理が中断される場面は多々あるのでいつでもキャンセルできるようにしておく
  cancelToken.ThrowIfCancellationRequested();

  // クラスのノードのみを対象とする (record とかつけると別な種類のノードになるので注意)
  // また partial class であること
  return node is ClassDeclarationSyntax nodeClass && nodeClass.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword));
},

首先,與任何過程一樣,自動生成代碼的過程總是可以過早取消。 因此,請使用取消令牌進行 ThrowIfCancellationRequested 呼叫,以便您可以隨時中斷。

predicate 現在每個節點都被調用了,我們要確定哪個節點是我們要處理的節點。 由於數量眾多,因此最好在一定程度上縮小範圍。

由於這次我們要向類添加處理,因此我們將 ClassDeclarationSyntax 確定它是否是,並確定是否只處理該類。 partial class 此外,由於代碼附加了 ,因此將其 partial class 作為判斷。

// 対象のノードをコード出力に必要な形に変換します
transform: static (contextSyntax, cancelToken) =>
{
  // いつでもキャンセルできるようにしておく
  cancelToken.ThrowIfCancellationRequested();

  // 今回は ClassDeclarationSyntax 確定なのでそのままキャストします
  ClassDeclarationSyntax classDecl = (ClassDeclarationSyntax)contextSyntax.Node;

  // データと構文から宣言されたシンボルを取得します
  // 今回利用場面がほとんどないですが取得しておくといろんな情報を取得できるので便利です
  var symbol = Microsoft.CodeAnalysis.CSharp.CSharpExtensions.GetDeclaredSymbol(contextSyntax.SemanticModel, classDecl, cancelToken)!;

  // 出力に必要な値を返します。大抵の型を渡すことができます
   return (symbol, classDecl);
}

transform 允許您將分析轉換為所需的形式,並將其傳遞給代碼輸出。 這一次,我們沒有做太多的轉換,而是將值按原樣 return 傳遞到輸出端。 在此過程中,我們得到了「聲明的符號」,但我們SemanticModel 可以使用 和 Syntac 獲得很多額外的資訊,所以我認為我們可以在必要時獲得它。 return 元組是創建和返回的,但要傳遞到輸出端的數據的配置可以是任何配置。 如果要傳遞多個數據,可以創建這樣的元組,也可以定義自己的類並將其傳入。

// 解析し変換した情報をもとにコードを出力します
context.RegisterSourceOutput(syntaxProvider, static (contextSource, input) =>
{
  // いつでもキャンセルできるようにしておく
  CancellationToken cancelToken = contextSource.CancellationToken;
  cancelToken.ThrowIfCancellationRequested();

  // 解析後に渡された値を受け取ります
  var (symbol, classDecl) = input;

  StringBuilder sbInitValues = new();

  // プロパティを列挙して値を初期化するコードを生成
  foreach (var syntax in classDecl.Members.OfType<PropertyDeclarationSyntax>())
  {
    sbInitValues.AppendLine($"{syntax.Identifier.ValueText} = default;");
  }

  // 出力するコードを作成
  var source = @$"
    {string.Join(" ", classDecl.Modifiers.Select(x => x.ToString()))} class {symbol.Name}
    {{
      public void Reset()
      {{
        {sbInitValues}
      }}
    }}";

  // ファイル名はクラス名に合わせておきます。作成したコードを出力します
  contextSource.AddSource($"{symbol.Name}.g.cs", source);
});

context.RegisterSourceOutputcontext.SyntaxProvider.CreateSyntaxProvider現在,我們將根據要處理的數據生成和輸出代碼。 context.SyntaxProvider.CreateSyntaxProvider return的值可以作為 的第二個Action參數接收,因此代碼是基於該值生成的。

在本例中,我們將創建代碼以從類的語法中獲取屬性的語法清單,並將屬性設置為 default 來自每個名稱。

之後,根據 Reset 類名創建一個方法,嵌入之前創建的屬性清單的代碼,並將所有屬性的值 default 設置回 Reset “方法完成”。

該代碼將 contextSource.AddSource 針對方法中的每個類單獨輸出。 順便說一句,我之所以在檔名中加上“g”,是為了在出現構建錯誤時更容易判斷是手動創建的代碼還是自動生成的代碼錯誤。

如果您真的這樣做了,您將收到諸如“我想重置字段”、“我想初始化它而不是預設值”、“我只想重置自動屬性”之類的請求。 如果你把它們放進去,繩子會很長,所以盡量自己做。

應用程式專案端

這一次,我創建了一個擴展類方法的代碼,因此我將適當地創建一個類。

內容如下,但如果有屬性,則可以根據需要使用其餘屬性。

Player.cs

public partial class Player
{
  public string Name { get; set; } = "";


  public float PositionX { get; set; }
  public float PositionY { get; set; }

  public int Level { get; set; } = 1;

  public override string ToString()
  {
    return $"{nameof(Player)} {{{nameof(Name)} = {Name}, {nameof(PositionX)} = {PositionX}, {nameof(PositionY)} = {PositionY}, {nameof(Level)} = {Level}}}";
  }
}

Item.cs

public partial class Item
{
  public string Name { get; set; } = "";

  public float PositionX { get; set; }
  public float PositionY { get; set; }

  public int Type { get; set; }

  public override string ToString()
  {
    return $"{nameof(Item)} {{{nameof(Name)} = {Name}, {nameof(PositionX)} = {PositionX}, {nameof(PositionY)} = {PositionY}, {nameof(Type)} = {Type}}}";
  }
}

該方法在創建代碼時 Reset 進行了擴展,但可能不會反映在Visual Studio中,因此此時請重啟Visual Studio。 我想你可以看到代碼會自動擴展到分析器。

縮進很奇怪,但你可以忽略它,因為你基本上不碰自動生成的代碼。

Program.cs嘗試編寫代碼,看看Reset是否可以調用該方法。 我認為執行結果按預期反映。

Program.cs

Console.WriteLine("Hello, World!");

SampleClass.Hello();
Console.WriteLine();

Player player = new()
{
  Name = "Mike",
  PositionX = 10,
  PositionY = 5.5f,
  Level = 3,
};
Console.WriteLine(player);
player.Reset();
Console.WriteLine(player);
Console.WriteLine();

Item item = new()
{
  Name = "Banana",
  PositionX = 50,
  PositionY = 53.5f,
  Type = 12,
};
Console.WriteLine(item);
item.Reset();
Console.WriteLine(item);
Console.WriteLine();