Visual Studio 및 소스 생성기를 사용하여 코드 자동 생성

페이지 업데이트 :
페이지 생성 날짜 :

운영 환경

비주얼 스튜디오
  • 비주얼 스튜디오 2022
.그물
  • .NET 8.0

필수 구성 요소

비주얼 스튜디오
  • 다소 오래된 버전에서도 작동합니다.
.그물
  • 다소 오래된 버전에서도 작동합니다.

처음에

사용자 고유의 정의를 사용하여 코드를 자동으로 생성하는 몇 가지 기술이 있지만 이 기사에서는 소스 생성기를 사용하는 방법을 보여 드리겠습니다. 소스 생성기의 가장 큰 장점 중 하나는 현재 프로젝트의 소스 코드 구조를 분석하고 이를 기반으로 새 코드를 생성한다는 것입니다. 예를 들어, 새 클래스를 만들 때 클래스와 일치하도록 코드가 자동으로 추가되도록 만들 수 있습니다. 생성할 코드의 종류를 프로그래밍할 수 있으므로 원하는 형태의 자동 코드 생성을 만들 수 있습니다.

코드는 기본적으로 백그라운드에서 자동 생성되고 백그라운드에서 프로젝트에 통합됩니다. 눈에 보이는 파일로 출력되지 않기 때문에 자동 생성 코드를 범용으로 재사용하는 목적으로는 사용하지 않습니다 (일단 복사하여 제거 할 수 있음). 그러나 프로젝트의 구조에 따라 코드가 자동으로 생성되기 때문에 수동 입력 비용을 줄이고 그에 따라 코드 작성 오류를 줄일 수 있어 큰 장점입니다.

이 기사에서는 코드가 자동으로 생성되는지 확인하는 방법을 설명하므로 실제로 코드를 심층적으로 분석하고 고급 출력을 수행하는 데까지는 가지 않겠습니다. 응용 프로그램으로 직접 찾아보십시오.

설치

먼저 Visual Studio를 설치합니다. 간단한 설명은 다음 팁에 요약되어 있습니다.

기본적으로 모든 프로젝트에 사용할 수 있으므로 어떤 워크로드를 설정하든 상관 없습니다. 그러나 이번에는 "개별 구성 요소"로서 ". NET 컴파일러 플랫폼 SDK를 참조하십시오. 이는 소스 제네레이터 개발 중 디버깅에 유용합니다. Visual Studio가 이미 설치되어 있는 경우 Visual Studio 메뉴의 도구 > 도구 및 기능 가져오기에서 추가할 수 있습니다.

소스 생성기 프로젝트 만들기 및 준비

소스 생성기는 기본 응용 프로그램 프로젝트와 별개의 프로젝트에서 만들어집니다. 먼저 만들거나 나중에 추가로 만들지는 중요하지 않습니다. 이 경우 Source Generator 프로젝트에서 생성하겠습니다.

Create New Project(새 프로젝트 만들기) 화면에서 Class Library(클래스 라이브러리)를 선택합니다.

프로젝트 이름은 무엇이든 될 수 있지만 지금은 CodeGenerator 그대로 두겠습니다.

클래스 라이브러리의 경우 Source Generator는 현재 . NET Standard 2.0을 참조하십시오.

프로젝트를 만든 후 NuGet을 사용하여 Microsoft.CodeAnalysis.CSharp 패키지를 가져옵니다. 버전에 따라 동작이 다를 수 있지만 이전 버전을 계속 사용하는 것은 의미가 없기 때문에 최신 버전을 넣습니다.

그런 다음 프로젝트 파일을 코드로 엽니다.

열면 다음이 표시됩니다.

<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를 다시 시작하지 않으면 업데이트되지 않을 수 있습니다. 프로그램을 만들 때 사용되는 Intellisence 및 구문 강조 표시는 비슷합니다. 다만, 코드 자체는 빌드시에 반영되어 있기 때문에, 응용 프로그램 측에서는 프로그램이 반영되고 있는 것 같습니다.

코드가 생성되면 애플리케이션에서 사용해 보세요. 잘 작동해야 합니다.

코드 분석 및 생성

코드를 정상적으로 처리하여 출력하는 것만으로는 다른 자동 코드 생성과 크게 다르지 않으며, 소스 생성기의 장점을 활용할 수 없습니다. 이제 응용 프로그램 프로젝트의 코드를 분석하고 그에 따라 코드를 생성해 보겠습니다.

그러나 코드 분석이 상당히 심층적이므로 여기에서 모든 것을 설명 할 수는 없습니다. 우선은 코드를 분석하고 출력할 수 있는 지점까지 설명하겠습니다. 더 깊이 들어가고 싶다면 직접 조사하십시오.

당분간은 예를 들어 "생성 된 모든 클래스에 Reset 메소드를 추가하고 모든 속성의 값을 default 재설정"코드를 자동으로 만들고 싶습니다. 최근에는 불변 인스턴스의 취급이 바람직한 것 같습니다만, 게임 프로그램에서는 프레임마다 새로운 인스턴스를 만들 수 없기 때문에, 값을 재설정하는 과정에 대한 용도가 있다고 생각합니다. default 그것 이외로 설정하고 싶어도 좋지만, 그렇게하면 첫 번째 예가 길어지기 때문에 직접 적용하십시오.

코드 생성기 측

이전에 만든 생성기를 유지하기 위해 새 클래스를 만듭니다. 자동으로 생성되는 콘텐츠가 변경되면 다른 클래스에서 새 콘텐츠를 만드는 것이 좋습니다.

Roslyn 컴파일러는 코드 구조를 처리하므로 여기서 할 일은 구조화된 데이터를 구문 분석하고 코드를 작성하는 것입니다.

먼저 모든 코드를 게시하겠습니다. 가능한 한 적은 코드를 사용하려고 노력했습니다. 이번에는 작동하지만 Mr./Ms.로 움직이는 수준뿐이기 때문에 실제로 개발을 진행하면 빠진 부분이 꽤 있습니다. 필요에 따라 개선하십시오.

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 출력 측에 전달합니다. 우리는 그 과정에서 "선언 된 기호"를 얻고 있지만SemanticModelSyntac 를 사용하여 많은 추가 정보를 얻을 수 있으므로 필요한 경우 얻을 수 있다고 생각합니다. 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();