.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개의 좋아요

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

image

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()를 자동 생성하는 소스 생성기로 테스트 한 소스코드는 다음 깃허브를 통해 확인하고 내려받을 수 있습니다.

https://github.com/dimohy/csharp-check/tree/main/No3.EnumGenerator

소스 생성기 코드는 적용 프로젝트의 분석기로 컴파일 시점에서 동작하므로 디버그가 까다롭습니다. 가장 간단한 방법은, 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

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

image

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

1개의 좋아요

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

image

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

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개의 좋아요