.NET 6의 증분 생성기 - slog

.NET 6의 소스 생성기(V2)를 학습하기 위해 slog를 시작합니다.

목표는 익명 형식을 클래스로 자동 생성하는 기능을 구현하는 것입니다.

| 예시

[ReturnTypeGenerate]
public UserResponse GetUser(string searchKeywords)
{
    var result = xxx.Select(x => new { UserName = x.UserName, Address = x.Address }.FirstOrDefault();
    return result;
}

| 자동 생성

public record UserResponse(string UserName, string Address);
좋아요 3

소스 생성기(V2)를 사용하려면 별도의 라이브러리 프로젝트 csproj를 다음처럼 구성합니다.

| AnonymousTypeGen.SourceGeneration.csproj

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

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

	<ItemGroup>
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.0.1" PrivateAssets="all" />
	</ItemGroup>

	<ItemGroup>
	  <ProjectReference Include="..\AnonymousTypeGen\AnonymousTypeGen.csproj" />
	</ItemGroup>
</Project>

Attribute 등을 정의할 별도의 라이브러리를 구성합니다.

| AnonymousTypeGen.csproj

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

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

</Project>

그리고 소스 생성기를 테스트할 콘솔 프로젝트를 만듭니다.

| AnonymousTypeGen.Test.csproj

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\AnonymousTypeGen.SourceGeneration\AnonymousTypeGen.SourceGeneration.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
    <ProjectReference Include="..\AnonymousTypeGen\AnonymousTypeGen.csproj" />
  </ItemGroup>
</Project>

정상적으로 동작하는지 확인하기 위해 AnonymousTypeGen.SourceGeneration 프로젝트에 소스 생성기 클래스를 간단히 구현해봅니다.

using Microsoft.CodeAnalysis;

using System;
using System.IO;

namespace AnonymousTypeGen.SourceGeneration
{
    [Generator]
    public class AnonymousTypeSourceGenerator : IIncrementalGenerator
    {
        public void Initialize(IncrementalGeneratorInitializationContext context)
        {
            File.WriteAllText(@"W:\o.txt", "1234");
        }
    }
}

파일이 생성되는 것으로 Initialize() 메소드가 컴파일 시 호출되는 것을 확인할 수 있습니다.

좋아요 1

다음 스텝: 소스 생성기 프로젝트를 디버깅 하는 방법

좋아요 1

이 글에 의하면, csproj에 <IsRoslynComponent>true</IsRoslynComponent>를 넣어준 후

| launchSettings.json

{
  "profiles": {
    "Default": {
      "commandName": "Project"
    },
    "DebugRoslynComponent": {
      "commandName": "DebugRoslynComponent"
    }
  }
}

이런 식으로 DebugRoslynComponent로 디버그가 가능한 것으로 소개하고 있으나 Visual Studio 2022에서는 다음의 오류로 디버그 시작이 되지 않습니다.

image

좋아요 1

다른 방법으로 다음의 코드를 삽입해 디버깅 상태로 진입하게 하는 방법이 있습니다.

        public void Initialize(IncrementalGeneratorInitializationContext context)
        {
#if DEBUG
            if (Debugger.IsAttached == false)
            {
                Debugger.Launch();
            }
#endif

...

이제 소스 생성기를 사용하는 프로젝트를 다시 컴파일 할 때 다음처럼,

image

디버거를 시작할 수 있고 Debugger.Launch() 지점부터 디버깅이 가능하게 됩니다.

image

좋아요 1

다음 스텝: 소스 생성기(V2)를 이용해서 내가 필요한 구문 타켓팅

좋아요 1

Andrew Lock님의 소스 생성기(V2) 관련 글 덕분에 관련 구현 전개가 쉬워졌습니다.

LoggerMessage의 V2 구현을

참고해서 유사하게 다음처럼 구조를 만들 수 있습니다.

   [Generator]
    public class AnonymousTypeSourceGenerator : IIncrementalGenerator
    {
        public void Initialize(IncrementalGeneratorInitializationContext context)
        {
#if DEBUG
            if (Debugger.IsAttached == false)
            {
                Debugger.Launch();
            }
#endif

            var methodDeclarations = context.SyntaxProvider.CreateSyntaxProvider(
                predicate: static (s, _) => IsSyntaxTargetForGeneration(s),
                transform: static (ctx, _) => GetSemanticTargetForGeneration(ctx))
                .Where(static m => m is not null);

            var compilationAndMethods = context.CompilationProvider.Combine(methodDeclarations.Collect());

            context.RegisterSourceOutput(compilationAndMethods, static (spc, source) => Execute(source.Left, source.Right, spc));


            static bool IsSyntaxTargetForGeneration(SyntaxNode node) => node is MethodDeclarationSyntax m && m.AttributeLists.Count > 0;
            static MethodDeclarationSyntax? GetSemanticTargetForGeneration(GeneratorSyntaxContext context)
            {
                return null;
            }
        }

        private static void Execute(Compilation compilation, ImmutableArray<MethodDeclarationSyntax?> methods, SourceProductionContext context)
        {
            ;
        }
    }
좋아요 1

이제 내가 원하는 구문을 타겟팅해서 처리할 수 있는 디버깅 환경 및 소스코드 기본 구성이 되었습니다.

좋아요 1

디버그를 좀 더 편하게 하기 위해 csproj를 변경했습니다.

| AnonymousTypeGen.SourceGeneration.csproj

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

	<PropertyGroup>
		<TargetFramework>netstandard2.0</TargetFramework>
		<LangVersion>latest</LangVersion>
		<Nullable>enable</Nullable>
		<Configurations>Debug;Release;DebugSourceGenerator</Configurations>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.1.0-1.final" PrivateAssets="all" />

	</ItemGroup>

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

	<PropertyGroup Condition="$(Configuration)=='DebugSourceGenerator'">
		<DefineConstants>DEBUGGENERATOR</DefineConstants>
	</PropertyGroup>
</Project>

DebugSourceGenerator라는 구성을 추가하고, 구성이 DebugSourceGenerator일 경우에만 디버그가 시작하도록,

...
#if DEBUGGENERATOR
            if (Debugger.IsAttached == false)
                Debugger.Launch();
#endif
...

코드를 변경했고 이제 소스 생성기를 디버깅 하고 싶을 때는, 구성을 DebugSourceGenerator를 선택하는 것으로 동작하게 됩니다.

image

좋아요 1

return 또는 =>의 반환 유형을 찾는 것은 간단한 검색으로는 알 수 없었습니다. 구문은 단지 구문이므로 구문을 통해 유형을 추출하는 방법을 찾아야 합니다. Roslyn을 좀 더 심도있게 알 필요가 있습니다.

좋아요 1

처음 목표로 잡았던 익명 형식 (익명 클래스)를 클래스로 자동 생성하는 소스 생성기를 만드는 것은 생각보다 어려웠습니다. 그래서 증분 생성기를 학습하는 목적으로 내년에 달성할 다음의 골을 설정하려 합니다.

  • 목표: IService Provider 인터페이스 구현체를 자동 생성하는 소스 증분 생성기 구현
  • 의도: 소스 생성기를 통해 정적 종속성 주입 구현으로 실행 속도를 최적화 함
좋아요 1

메모리 맵 파일 등에서 활용할 수 있는 "고정 사이즈 뷰"를 코드 생성기를 이용해 만들어볼까 하는 생각도 해봅니다.

| 예시 코드

[Packet(32)]
interface ITestInfo
{
    [PacketProperty(4, EncodingType.Ascii)]
    string Command { get; }

    [PacketProperty(2)]
    short Length { get; }

    // ..
}

| 생성 코드

public class TestInfo : ITestInfo
{
    private Memory<byte> _packet;

    public TestInfo(Memory<byte> packet) => _packet = packet[..32];
    public TestInfo(byte[] packet) => _packet = new(packet, 0, 32);

    public string Command => Encoding.ASCII.GetString(_packet.Span[..4]);
    public short Length => MemoryMarshal.AsRef<short>(_packet.Span[4..2]);
}

좀 더 발전시키면 동적 사이즈 뷰도 가능할 텐데 이부분은 훌륭한 라이브러리들이 이미 있어서 의미가 있을까는 싶습니다.

좋아요 1

깊은 복사(Deep Copy) 기능을 코드 생성해주는 기능도 생각해볼 수 있습니다.

[DeepCopy]
partial record UserInfo(string Name, int Age);

| 생성코드

partial record UserInfo : IDeepCopy
{
   public UserInfo DeepCopy()
   {
      var result = new UserInfo(this.Name, this.Age);
      return result;
   }
}

사실 위의 예는 얕은 복사긴 하지만; ^^

좋아요 2

Andrew Lock님의 글을 따라 가는 것으로 시작해봅시다.

소스 증분 생성기를 이용해 열거형의 ToString()를 개선한 ToStringFast()를 자동 생성하는 소스 생성기로 테스트 한 소스코드는 다음 깃허브를 통해 확인하고 내려받을 수 있습니다.

소스 생성기 코드는 적용 프로젝트의 분석기로 컴파일 시점에서 동작하므로 디버그가 까다롭습니다. 가장 간단한 방법은, Initialize() 메소드의 진입점에 다음의 코드를 삽입하는 것입니다.

using Dimohysm.AutoGen.EnumGenerators;

var w = WeekKind.월요일;
Console.WriteLine(w.ToStringFast());

[EnumExtensions]
public enum WeekKind
{
    일요일,
    월요일,
    화요일,
    수요일,
    목요일,
    금요일,
    토요일
}

| 출력

월요일
좋아요 2

아래의 지침을 따라 프로젝트를 좀 더 구조화 하였습니다.

이제 xunit으로 단위테스트를 할 수 있으므로 다음과 같은 화면을 볼 수 있습니다.

    [Fact]
    public Task GeneratesEnumExtensionsCorrectly()
    {
        var source = """
            using Autogen.Enum;

            [AutogenEnum]
            public enum Color
            {
                Red = 0,
                Blue = 1,
            }
            """;

        return TestHelper.Verify(source);
    }

단위 테스트는 Verify를 이용해서 마법처럼 이루어집니다. 스냅샷 테스트인데요, 단위 테스트를 실행하면 Verify에 의해서 스냅샷으로 테스트를 진행합니다.

왼쪽 결과물이 이상이 없다고 판단되면 오른쪽으로 복사해 이후 테스트를 통과할 수 있게 됩니다.

image

전체 소스코드는 다음의 GitHub에서 내려받아 확인하실 수 있습니다.

좋아요 1

단위 테스트를 이용하면 디버그 테스트를 통해 소스 생성기 소스코드에 중단점을 지정해 디버깅이 가능합니다.

image

이제 TextAttribute를 통해 ToStringFast()로 반환될 텍스트를 지정해보려 합니다.

[UsesVerify]
public class EnumGeneratorTests
{
    [Fact]
    public Task TestAutogenEnum()
    {
        var source = """
            using Autogen.Enum;

            [AutogenEnum]
            public enum Color
            {
                Red,
                Blue,
            }
            """;

        return TestHelper.Verify(source);
    }

    [Fact]
    public Task TestAutogenEnumWithTextAttribute1()
    {
        var source = """
            using Autogen.Enum;

            [AutogenEnum]
            public enum Color
            {
                [Text("빨강")]
                Red = 0,
                [Text("파랑")]
                Blue = 1,
            }
            """;

        return TestHelper.Verify(source);
    }

    [Fact]
    public Task TestAutogenEnumWithTextAttribute2()
    {
        var source = """
            using Autogen.Enum;

            [AutogenEnum]
            public enum Color
            {
                [Text("빨강")]
                Red = 0,
                Blue = 1,
                [Text("노랑")]    
                Yellow = 2,
            }
            """;

        return TestHelper.Verify(source);
    }
}

Verify의 스냅샷으로 테스트를 진행할 수 있으므로 테스트 코드가 한결 깔끔합니다. Verify에 의해 생성된 파일이 올바름을 확인하면 생성된 *.received.cs*.verified.cs로 바꿔주는 것으로 검증이 완료됩니다.

| 샘플) EnumGeneratorTests.TestAutogenEnumWithTextAttribute2.01.verified.cs

//HintName: Autogen.Enum.EnumExtensions.g.cs

namespace Autogen.Enum
{
    public static partial class EnumExtensions
    {
        public static string ToStringFast(this Color value) => value switch
        {
            Color.Red => "빨강",
            Color.Blue => nameof(Color.Blue),
            Color.Yellow => "노랑",
            _ => value.ToString(),
        };
    }
}

모든 테스트가 통과되었습니다.

image

적절하게 프로젝트 참조를 하고,

| AutogenEnum.Sample.proj

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

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

	<ItemGroup>
		<ProjectReference Include="..\..\src\Autogen.Core\Autogen.Core.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
		<ProjectReference Include="..\..\src\Autogen.Enum\Autogen.Enum.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
		<ProjectReference Include="..\..\src\Autogen.SourceGeneration\Autogen.SourceGeneration.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
	</ItemGroup>

</Project>

| Program.cs

using Autogen.Enum;


var w1 = Week.Sunday;
Console.WriteLine(w1.ToStringFast());

var w2 = Week.Monday;
Console.WriteLine(w2.ToStringFast());


[AutogenEnum]
public enum Week
{
    [Text("일요일")]
    Sunday,
    Monday,
}

| 결과

일요일
Monday

그런데 실제 컴파일은 잘되지만 다음처럼 IDE 화면에 컴파일 오류가 메시지가 표시됩니다.

image

제가 Analyzer DLL 참조 방식이라던가 환경을 몰라 아직은 이 문제를 해결하지 못하고 있습니다.

전체 소스

좋아요 1

IDE 화면에서 컴파일 오류가 나는 것을 해결했습니다.

프로젝트 참조 시 OutputItemType 속성을 Analyzer로 주면,
| Autogen.SourceGeneration.csproj

...
	<ItemGroup>
		<ProjectReference Include="..\Autogen.Core\Autogen.Core.csproj" OutputItemType="Analyzer" />
		<ProjectReference Include="..\Autogen.Enum\Autogen.Enum.csproj" OutputItemType="Analyzer" />
	</ItemGroup>
...

소스 생성기를 사용하는 프로젝트에서는 관련 프로젝트를 포함하지 않을 수 있습니다.
| AutogenEnum.Sample.csproj

...
	<ItemGroup>
		<ProjectReference Include="..\..\src\Autogen.SourceGeneration\Autogen.SourceGeneration.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
	</ItemGroup>
...

이제 다음의 화면처럼 IDE에서 오류가 표시되지 않습니다.

image

관련 내용을 조사하면서 덕분에 분석기에서 사용할 패키지 참조 방법을 알았는데 아래 링크를 참조하면 됩니다.

좋아요 1

마찬가지로 아래 글의 도움을 받아 소스생성기 디버그를 손쉽게 하는 방법을 찾았습니다.

소스 생성기 프로잭트에 아래의 속성을 추가하고,

<PropertyGroup>
...
<IsRoslynComponent>true</IsRoslynComponent>
...

다음처럼 Properties\launchSettings.json에 프로필을 작성합니다. 이때 targetProject는 소스 생성기를 사용한 프로젝트입니다.

| launchSettings.json

{
  "profiles": {
    "AutogenEnum.Sample": {
      "commandName": "DebugRoslynComponent",
      "targetProject": "..\\..\\samples\\AutogenEnum.Sample\\AutogenEnum.Sample.csproj"
    }
  }
}

이제 적절한 위치에 중단점을 찍고, 해당 프로필을 선택하면 중단점에서 멈추는 것을 확인할 수 있습니다.

image

좋아요 1

Andrew Lock님의 3부 지침에 따라 통합 테스트 및 NuGet 패키징을 진행하였습니다.

단위테스트, 통합테스트 및 NuGet에 배포하기 전 로컬 레파지토리의 패키지를 이용한 테스트까지 진행 한 후,

image

패키지를 NuGet에 게시하였습니다. 이제 NuGet 패키지 관리에서 Autogen.Enum시험판 포함을 선택해서 검색할 수 있으며

image

패키지 설치 후 다음처럼 간단히 동작을 테스트 할 수 있습니다.

using Autogen.Enum;

Console.WriteLine(PermissionKind.Normal.ToStringFast());
Console.WriteLine(PermissionKind.SuperAdmin.ToStringFast());

[AutogenEnum]
public enum PermissionKind
{
    [Text("일반")]
    Normal,
    [Text("일반 관리자")]
    Manager,
    [Text("관리자")]
    Admin,
    [Text("수퍼 관리자")]
    SuperAdmin
}

| 출력

일반
수퍼 관리자
좋아요 1