使用 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();