dimohy
디모이 블로그

디모이 블로그

소스 생성기 만들기 4부 - 마커 특성으로 생성된 코드 커스터마이징

dimohy's photo
dimohy
·Mar 8, 2022·

6 min read

Table of contents

이 글은 Andrew Lock님의 소스 생성기 만들기 연재를 번역한 글입니다.

이 글은 소스 생성기 만들기 시리즈의 네 번째 게시물 입니다.

이 시리즈의 이전 게시물에서 증분 소스 생성기를 만드는 방법, 단위 및 통합 테스트 방법, NuGet 패키지에 패키지하는 방법을 보여주었습니다. 이 게시물에서는 추가 특성으로 마커 특성을 확장하여 소스 생성기의 동작을 사용자 지정하는 방법을 설명합니다.

소스 생성기 마커 특성 확장

모든 소스 생성기의 첫 번째 단계 중 하나는 프로젝트에서 소스 생성에 참여해야 하는 코드를 식별하는 것입니다. 소스 생성기는 특정 유형이나 멤버를 찾을 수 있지만 또 다른 일반적인 접근 방식은 마커 특성을 사용하는 것입니다. 이것이 이 시리즈의 첫 번째 게시물에서 설명한 접근 방식입니다.

첫 번째 게시물에서 설명한 [EnumExtensions] 특성은 다른 특성이 없는 단순한 특성이었습니다. 즉, 소스 생성기에 의해 생성된 코드를 사용자 정의할 방법이 없었습니다. 그것이 내가 포스트 말미에 논의한 한계 중 하나였습니다.

이 기능을 제공하는 일반적인 방법은 마커 특성에 속성을 추가하는 것입니다. 이 게시물에서는 생성할 확장 메서드 클래스의 이름인 단일 설정에 대해 이 작업을 수행하는 방법을 보여 드리겠습니다.

기본적으로 이름 EnumExtensions는 확장 메서드 클래스에 사용됩니다. 이 변경으로 ExtensionClassName 속성을 설정하여 대체 이름을 지정할 수 있습니다. 예를 들어 다음과 같습니다.

[EnumExtensions(ExtensionClassName = "DirectionExtensions")]
public enum Direction
{
    Left,
    Right,
    Up,
    Down,
}

다음과 같은 DirectionExtensions라는 클래스를 생성합니다.

//HintName: EnumExtensions.g.cs

namespace NetEscapades.EnumGenerators
{
    public static partial class DirectionExtensions // 👈 사용자 지정 이름을 확인
    {
        public static string ToStringFast(this Direction value)
            => value switch
            {
                Direction.Left => nameof(Direction.Left),
                Direction.Right => nameof(Direction.Right),
                Direction.Up => nameof(Direction.Up),
                Direction.Down => nameof(Direction.Down),
                _ => value.ToString(),
            };
    }
}

글의 나머지 부분에서는 이를 달성하기 위해 원본 소스 생성기에 필요한 변경 사항을 살펴보겠습니다.

여기서는 소스 생성기에 대한 전체 코드를 표시하지 않고 첫 번째 게시물에서 원본에 대한 점진적인 변경 사항만 표시합니다. GitHub에서 전체 코드를 찾을 수 있습니다.

1. 마커 특성 업데이트

첫 번째 단계는 새 속성으로 마커 특성을 업데이트하는 것입니다:

[System.AttributeUsage(System.AttributeTargets.Enum)]
public class EnumExtensionsAttribute : System.Attribute
{
    public string ExtensionClassName { get; set; } // 👈 새 속성
}

이 마커 특성은 첫 번째 게시물에 설명된 대로 소스 생성기에 의해 컴파일에 자동으로 추가되므로 실제로 특성이 아닌 여기에서 문자열을 업데이트합니다. 예를 들어 생성된 코드의 네임스페이스를 사용자 지정하는 기능과 같은 사용자 지정 기능을 더 추가하려는 경우 이 특성에 추가 속성을 추가할 수 있습니다.

2. 각 열거형에 대해 별도의 확장 클래스 이름 설정 허용

이 변경으로 사용자는 이제 각 enum에 대해 확장 클래스의 다른 이름을 설정할 수 있으므로 enum에 대한 세부 정보를 EnumToGenerate 개체로 추출할 때 확장 이름을 기록해야 합니다.

public readonly struct EnumToGenerate
{
    public readonly string ExtensionName; // 👈 새로운 필드
    public readonly string Name;
    public readonly List<string> Values;

    public EnumToGenerate(string extensionName, string name, List<string> values)
    {
        Name = name;
        Values = values;
        ExtensionName = extensionName;
    }
}

확장 메서드를 부분적으로 만들고 각 ToStringFast() 메서드는 다른 오버로드가 되므로 사용자가 동일한 확장 클래스 이름을 두 번 이상 지정해도 문제가 되지 않습니다.

3. 코드 생성 업데이트

우리는 여기에서 약간 거꾸로 작업하고 있으므로 다음은 확장 생성기에 대한 업데이트된 코드를 보여줍니다. 여기에는 복잡한 것이 없습니다. StringBuilder로 작업하는 것이 약간 번거롭습니다. 이전 반복과의 주요 차이점은 (여러 메서드가 있는 하나의 클래스 대신) 각 enum에 대해 별도의 클래스를 생성하고 클래스 이름을 EnumToGenerate에서 가져온다는 것입니다:

public static string GenerateExtensionClass(List<EnumToGenerate> enumsToGenerate)
{
    var sb = new StringBuilder();
    sb.Append(@"
namespace NetEscapades.EnumGenerators
{");
    foreach(var enumToGenerate in enumsToGenerate)
    {
        sb.Append(@"
public static partial class ").Append(enumToGenerate.ExtensionName).Append(@"
{
    public static string ToStringFast(this ").Append(enumToGenerate.Name).Append(@" value)
        => value switch
        {");
        foreach (var member in enumToGenerate.Values)
        {
            sb.Append(@"
            ")
                .Append(enumToGenerate.Name).Append('.').Append(member)
                .Append(" => nameof(")
                .Append(enumToGenerate.Name).Append('.').Append(member).Append("),");
        }

        sb.Append(@"
            _ => value.ToString(),
        };
}
");
    }
    sb.Append('}');

    return sb.ToString();
}

남은 것은 마커 특성에서 ExtensionClassName의 값을 읽도록 소스 생성기 코드 자체를 업데이트하는 것입니다.

4. 마커 특성에서 속성 값 읽기

지금까지는 이 새로운 기능을 지원하기 위해 약간만 변경하면 되지만 컴파일에서 값을 읽는 어려운 부분은 아직 수행하지 않았습니다. 특성에 속성을 설정하면 의미적으로 명명된 생성자 인수를 설정하는 것입니다.

ExtensionClassName 속성의 값을 찾으려면 먼저 [EnumExtensions] 특성에 대한 AttributeData를 찾아야 합니다. 그런 다음 특정 속성에 대한 NamedArguments를 확인할 수 있습니다. 다음은 속성 값이 제공되는 경우 이를 추출하기 위해 제거된 버전의 코드를 보여줍니다:

static List<EnumToGenerate> GetTypesToGenerate(Compilation compilation, IEnumerable<EnumDeclarationSyntax> enums, CancellationToken ct)
{
    var enumsToGenerate = new List<EnumToGenerate>();
    // [EnumExtensions] 기호에 대한 참조 가져오기
    INamedTypeSymbol? enumAttribute = compilation.GetTypeByMetadataName("NetEscapades.EnumGenerators.EnumExtensionsAttribute");

    // ... 오류 확인 및 검증 생략

    foreach (var enumDeclarationSyntax in enums)
    {
        // 열거형 기호의 의미 모델을 가져옴
        SemanticModel semanticModel = compilation.GetSemanticModel(enumDeclarationSyntax.SyntaxTree);
        INamedTypeSymbol enumSymbol = semanticModel.GetDeclaredSymbol(enumDeclarationSyntax);

        // 기본 확장 이름 설정
        string extensionName = "EnumExtensions";

        // 열거형의 모든 속성을 반복
        foreach (AttributeData attributeData in enumSymbol.GetAttributes())
        {
            if (!enumAttribute.Equals(attributeData.AttributeClass, SymbolEqualityComparer.Default))
            {
                // [EnumExtensions] 특성이 아닌 경우
                continue;
            }

            // 이것은 특성이며 명명된 모든 인수를 확인
            foreach (KeyValuePair<string, TypedConstant> namedArgument in attributeData.NamedArguments)
            {
                // ExtensionClassName 인자?
                if (namedArgument.Key == "ExtensionClassName"
                    && namedArgument.Value.Value?.ToString() is { } n)
                {
                    extensionName = n;
                }
            }

            break;
        }

        // ... 표시 안함: 열거형 이름과 멤버를 검색하는 기존 코드

        // 확장명을 기록
        enumsToGenerate.Add(new EnumToGenerate(extensionName, enumName, members));
    }

    return enumsToGenerate;
}

이러한 변경으로 마커 특성을 확장하여 소스 생성기에 더 많은 사용자 정의를 임의로 추가할 수 있습니다.

5. 속성 생성자 지원

위의 예에서는 특성에 생성자가 없기 때문에 특성의 NamedArguments만 확인하고 있으므로 ExtensionClassName 속성을 지정하는 유일한 방법입니다. 그러나 마커 특성이 다르게 정의되고 생성자가 있다면 어떻게 될까요? 예를 들어 ExtensionClassName을 필수로 만들고 새로운 선택적 속성 ExtensionNamespaceName을 추가하면 어떻게 될까요:

[System.AttributeUsage(System.AttributeTargets.Enum)]
public class EnumExtensionsAttribute : System.Attribute
{
    public EnumExtensionsAttribute(string extensionClassName)
    {
        ExtensionClassName = extensionClassName;
    }

    public string ExtensionClassName { get; }
    public string ExtensionNamespaceName { get; set; }
}

그러면 이전 절의 코드가 작동하지 않습니다. 그리고 여러 속성과 여러 생성자가 있으면 상황이 다시 복잡해집니다. 다음 코드는 소스 생성기 내에서 이러한 값을 추출하는 일반적인 접근 방식을 보여줍니다. 특히, AttributeDataConstructorArgumentsNamedArguments를 모두 읽고 올바르게 설정된 값을 유추해야 합니다.:

INamedTypeSymbol enumSymbol = semanticModel.GetDeclaredSymbol(enumDeclarationSyntax);

// 지정된 ExtensionClassName 및 ExtensionNamespaceName에 대한 자리 표시자 변수
string className = null;
string namespaceName = null;

//  [EnumExtensions] 특성을 찾을 때까지 열거형의 모든 속성을 반복
foreach (AttributeData attributeData in enumSymbol.GetAttributes())
{
    if (!enumAttribute.Equals(attributeData.AttributeClass, SymbolEqualityComparer.Default))
    {
        // [EnumExtensions] 특성이 아닌 경우
        continue;
    }

    // 올바른 특성입니다. 생성자 인자를 확인
    if (!attribute.ConstructorArguments.IsEmpty)
    {
        ImmutableArray<TypedConstant> args = attribute.ConstructorArguments;

        // 오류가 없는지 확인
        foreach (TypedConstant arg in args)
        {
            if (arg.Kind == TypedConstantKind.Error)
            {
                // 오류가 있으므로 생성을 시도하지 않음
                return;
            }
        }

        // 어떤 값이 설정되었는지 추론하기 위해 인자의 위치를 ​​사용
        switch (args.Length)
        {
            case 1:
                className = (string)args[0].Value;
                break;
        }
    }


    // 이제 명명된 인자를 확인
    if (!attribute.NamedArguments.IsEmpty)
    {
        foreach (KeyValuePair<string, TypedConstant> arg in attribute.NamedArguments)
        {
            TypedConstant typedConstant = arg.Value;
            if (typedConstant.Kind == TypedConstantKind.Error)
            {
                // 오류가 있으므로 생성을 시도하지 않음
                return;
            }
            else
            {
                // 어떤 값이 설정되었는지 추론하기 위해 생성자 인자 또는 속성 이름을 사용
                switch (arg.Key)
                {
                    case "extensionClassName":
                        className = (string)typedConstant.Value;
                        break;
                    case "ExtensionNamespaceName":
                        namespaceName = (string)typedConstant.Value;
                        break;
                }
            }
        }
    }

    break;
}

이것은 분명히 더 복잡하지만 소스 생성기 소비자에게 더 나은 사용자 경험을 제공하는 데 필요할 수 있습니다.

요약

이 게시물에서는 마커 특성에 속성을 추가하여 소스 생성기의 소비자에게 사용자 지정 옵션을 제공하는 방법을 설명했습니다. 이것은 제공된 값을 구문 분석하기 위해 약간의 훈련이 필요합니다. 특히 특성에서 필수 생성자 인수와 명명된 속성을 사용하는 경우에 그렇습니다. 전반적으로 이것은 소스 생성기 기능 확장의 좋은 일반적인 방법입니다.

원문

Andrew Lock's Series: Creating a source generator - Part 4 - Customising generated code with marker attributes