Как стать автором
Обновить

Современные Source Generators в .NET, часть 2

Уровень сложности Средний
Время на прочтение 19 мин
Количество просмотров 6.9K

Введение

Это вторая часть серии (надеюсь) статей про современные Source Generators в .NET. Мотивация и общее описание есть в первой части, рекомендую начинать знакомство с неё.

Благодарю Татьяну Пушкарёву за редактирование и корректуру, а Анастасию Полунину и Александра Беспоясова за отзывы на статью.

Перед тем как освещать тему именно генерации кода, хочется дополнить предыдущую часть двумя разделами: описанием типовых способов и приёмов организации генератора и рекомендациями по диагностике и обработке ошибок.

Организация генератора

Хотя спектр задач, которые можно решать при помощи генераторов, достаточно велик, в нём есть существенный перекос в сторону "догенерации" кода, когда часть какого-либо типа, которая требует творческой работы, реализуется разработчиком, а всё что могут сделать генераторы, поручается им.
Чаще всего это boilerplate-код для реализации каких-либо контрактов: интерфейсов (вспомним IEquatable<T> до появления record, набивший оскомину в примерах INotifyPropertyChanged) или конвенций (operator==/!= для IEquatable<T>, или арифметические операторы для алгебраических типов). Кроме того (хотя и значительно реже), встречаются механики, которые позволяют расширить параметрический полиморфизм в C#, предоставляя, например, механизм конструирования типов высшего порядка.

Таким образом, обычно целью кодогенерации являются какие-то конкретные узлы синтаксического дерева. Фильтром для них может служить соответствие их собственных свойств определённому шаблону, например, "все объявления структур, которые реализуют ISomeInterface" или "все readonly-поля, которые заканчиваются на Test", однако очень часто хочется использовать узлы дерева, которые должны участвовать (или не участвовать, т.е. opt-out) как источники данных для кодогенерации. Стандартным решением для этого является использование атрибутов. Некоторые особенности определения самих атрибутов мы рассмотрим позже в разделе "Способы реализации общей функциональности", но пока допустим, что мы хотим расширить пример про генерацию списка имён классов из первой части так, чтобы:

  1. В список попадали только классы, отмеченные атрибутом [IncludeIntoList].

  2. В список попадали только публичные классы.

Для упрощения добавим его определение IncludeIntoListAttribute.cs вручную в целевой проект:

namespace System;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class IncludeIntoListAttribute : Attribute
{
}

Кроме того, я хочу попробовать представить две концепции одним примером и предлагаю подумать — всегда ли стоит пытаться генерировать код? Допустим, кто-то написал следующий код:

using System;

[IncludeIntoList(abc)] // A
internal class MyClass
{
}

Обратите внимание на этап "A". Здесь явная синтаксическая ошибка, и среда разработки уже наверняка отметила эту строку как ошибочную. Стоит ли включать этот класс в список типов, если даже атрибут, который должен его отмечать как включённый в список, написан с ошибкой? А если дополнительно учесть, что определение класса должно быть отмечено как ошибочное из-за требования публичности? Вместо одной ошибки, которую разработчик скорее всего знает как исправить, он увидит две ошибки — ненужный стресс. А если у атрибута есть ещё и параметры, которые влияют на тип? Итого, я рекомендую не генерировать код, если данные для его генерации заведомо содержат ошибки, а это значит, что их тоже нужно проверять.

Давайте посмотрим на определение генератора и утилит:

[Generator]
public sealed class FilteredClassNameListGenerator : IIncrementalGenerator
{
    private const string AttributeShortName = "IncludeIntoList";
    private const string AttributeFullName = AttributeShortName + "Attribute";
    private const string AttributeTypeName = "System." + AttributeFullName;

    private static readonly DiagnosticDescriptor NonPublicTypesAreNotSupported =
        new(
            "TS0001",
            "Non-public types types are not supported",
            "Change the visibility of type '{0}' to public",
            GeneratorDiagnostics.DiagnosticCategory,
            DiagnosticSeverity.Error,
            true);

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var comparer = TypeComparer.Instance;
        var pipeline =
            context.SyntaxProvider.CreateSyntaxProvider(
                (node, _) => NodePredicate(node),
                (syntax, cancellationToken) => TargetFactory(syntax, cancellationToken))
                .Where(target => target is not null)! // A
                .WithComparer(comparer) // B1
                .Collect() // B2
                .SelectMany((targets, _) => targets.Distinct(comparer)) // B3
                .Collect(); // C

        context.RegisterSourceOutput(pipeline, Build);
    }

    private void Build(
        SourceProductionContext context,
        ImmutableArray<BuilderTarget> source)
    {
        var builder = new StringBuilder();
        builder.AppendLine("/*");
        foreach (var target in source)
        {
            if (target.Type.DeclaredAccessibility != Accessibility.Public)
            {
                var diagnostic = Diagnostic.Create(
                    NonPublicTypesAreNotSupported,
                    target.Declaration.Keyword.GetLocation(),
                    target.Type); // D
                context.ReportDiagnostic(diagnostic);
                continue;
            }

            builder.AppendLine(target.Type.Name);
        }

        builder.AppendLine("*/");
        context.AddSource("ClassNames", builder.ToString());
    }

    private static bool NodePredicate(SyntaxNode node)
        => node is AttributeSyntax attribute // E1
            && GetNameText(attribute.Name) is AttributeShortName or AttributeFullName // E2
            && !ContainsErrors(attribute) // E3
            && attribute.Parent?.Parent is ClassDeclarationSyntax; // E4

    private static BuilderTarget? TargetFactory(GeneratorSyntaxContext context, CancellationToken cancellationToken)
    {
        var declaration = (ClassDeclarationSyntax)context.Node.Parent!.Parent!; // F1
        return context.SemanticModel
            .GetDeclaredSymbol(declaration, cancellationToken) is INamedTypeSymbol type // F2
            && type.GetAttributes()
                .Any(attr => attr.AttributeClass?.ToDisplayString(NullableFlowState.None, DisplayFormats.Local) == AttributeTypeName) // F3
            ? new(declaration, type)
            : null;
    }

    private sealed record BuilderTarget(ClassDeclarationSyntax Declaration, INamedTypeSymbol Type);

    private sealed class TypeComparer : IEqualityComparer<BuilderTarget>
    {
        private TypedGeneratorTargetComparer()
        {
        }

        public static TypedGeneratorTargetComparer Instance { get; } = new();

        public bool Equals(BuilderTarget x, BuilderTarget y)
            => x.Type.Equals(y.Type, SymbolEqualityComparer.IncludeNullability);

        public int GetHashCode(BuilderTarget obj)
            => SymbolEqualityComparer.IncludeNullability.GetHashCode(obj.Type);
    }

    private static bool ContainsErrors(CSharpSyntaxNode node)
        => node
            .GetDiagnostics()
            .Any(d => d.Severity == DiagnosticSeverity.Error);

    private static string? GetNameText(NameSyntax? name)
        => name switch
        {
            SimpleNameSyntax ins => ins.Identifier.Text,
            QualifiedNameSyntax qns => qns.Right.Identifier.Text,
            _ => null,
        };
}

Этот пример немного великоват, но без него не получится показать всё в сборе. Давайте разберём его по частям. Рекомендую открыть исходный код в отдельном редакторе, чтобы проще осуществлять навигацию между моими пояснениями и кодом.

Метод Initialize устроен традиционным образом, в качестве предиката используется NodePredicate, а в качестве преобразования — TargetFactory, результат которого обрабатывается, следуя рекомендациям из предыдущей части. Важным отличием от предыдущих примеров является то, что TargetFactory возвращает не какой-либо из наследников SyntaxNode, а определённый нами тип BuilderTarget, который содержит в себе как узел синтаксического дерева Declaration, так и элемент семантической модели Type. Первый можно использовать для получения информации об исходном коде, а второй — о том, во что этот исходный код будет транслирован при компиляции. Использование record как результата провайдера значений упрощает их создание и технику сравнения. Кроме того, TargetFactory возвращает nullable BuilderTarget, осуществляя дополнительную фильтрацию (напомню, что NodePredicate должен быть максимально быстрым и не может, как следствие, использовать семантическую модель). Соответственно, провайдер значений дополняется фильтром null (этап "A"), далее (этап "B1") ему назначается способ сравнения TypeComparer, который проверяет только на равенство типов, а не их определений, а на этапе "B2" делается Collect для исправления известного бага, затем на этапе "B3" удаляются потенциальные множественные определения в синтаксическом дереве одного и того же типа (причины всего этого описаны в первой части серии). В завершение, так как нам нужен один список всех типов, на этапе "C" вызывается Collect, аналогичный в данном случае LINQ-методу Buffer.

Обратим внимание на NodePredicate. В нём выбираются только узлы определения атрибутов ("E1"), названные подходящим именем ("E2"), причём учитываются обе формы именования атрибутов, не содержащие ошибок в определении ("E3") и привязанные к определению класса ("E4"). Реализация самого предиката и методов-помощников в целом тривиальна, но есть пара возможно неочевидных моментов.
Первый — сравните реализацию GetNameText и реализацию из первой части, которую я не рекомендовал использовать:

return node is AttributeSyntax attribute
    // Эта проверка упрощена для примера
    && attribute.Name is SimpleNameSyntax name
    // Не используйте такую проверку в реальном коде
    && name.Identifier.Text == "MyAttribute"
    && attribute.Parent?.Parent is ClassDeclarationSyntax;

Всегда используйте метод, аналогичный GetNameText для того, чтобы ваш генератор обрабатывал разные варианты идентификации узлов синтаксического дерева.
Второй момент — такая проверка на имя атрибута не учитывает т.н. using alias. Рассмотрим следующий код:

using Included = System.IncludeIntoListAttribute;

namespace Test;

[Included]
public class MyClass
{
}

Здесь атрибут IncludeIntoListAttribute именуется как Included и таким же образом используется, соответственно, проверка на этапе "E2" не сработает. Единственный вариант, который позволит нормально функционировать генератору в этом случае, — исключение проверки в NodePredicate. К сожалению, такое исключение сильно понижает селективность предиката (он будет выбирать все атрибуты классов), поэтому я не рекомендую так делать. Using alias применяется достаточно редко, а для атрибутов — чрезвычайно редко (я в производственном коде такого не встречал), предлагаю вам согласиться и пожертвовать корректностью в пользу производительности.

Отмечу, что шаблон по фильтрации целей при помощи атрибутов настолько популярен, что в современных версиях Roslyn (Visual Studio 17.3 и выше) есть метод SyntaxValueProvider.ForAttributeWithMetadataName, который позволяет фильтровать узлы по имени атрибутов.

Теперь перейдём к TargetFactory. Так как предикат находит атрибуты, а не классы, то на этапе "F1" мы находим интересующее нас определение класса, "F2" находит нам определение типа в семантической модели, а в "F3" мы убеждаемся, что полное название атрибута именно такое, какое нам нужно, чтобы случайно не использовать какой-то другой атрибут с таким же именем. Если все эти проверки проходят, мы возвращаем BuilderTarget, содержащий и узел синтаксического дерева, и определение типа из семантической модели.

В завершение рассмотрим метод Build. Он остался очень простым в реализации, можно разве что отметить этап "D", где мы "жалуемся" клиенту генератора, что класс должен быть публичным. В создании объекта Diagnostic нет ничего сложного, но отмечу что желательно использовать максимально сфокусированный, относительно причины диагностики, узел синтаксического дерева. В примере используется target.Declaration.Keyword, а не target.Declaration, хотя это было бы проще. Такая фокусировка позволяет не помечать излишнее количество узлов как ошибочных, в случае несоответствия видимости достаточно отметить ключевое слово определения типа (в данном случае class), а не всё определение типа, которое может занимать несколько экранов.
Дополню, что можно было бы кэшировать StringBuilder для Compilation по схеме, описанной в первой части, но эта оптимизация была опущена для сокращения размера и без того достаточно объёмного (для примера) генератора.

Итого, если представить, что целью генератора является какой-то другой элемент семантической модели, например, член класса, то можно заметить, что многие элементы генератора на самом деле не меняются, что позволяет выделить определённый шаблон:

  • достаточно селективный предикат, проверяющий целевой узел синтаксического дерева на ошибки;

  • провайдер значений, возвращающий record, содержащий узел синтаксического дерева и соответствующую ему семантическую модель;

  • этап пост-обработки провайдера, оптимизирующий пайплайн и предотвращающий типовые ошибки.

Для выделения такой функциональности в шаблон может возникнуть желание обобщить какие-то части генератора так, чтобы уменьшить количество кода, необходимое для реализации типового генератора. Об этом мы поговорим чуть позже, но пока я предлагаю обсудить мою любимую тему — что делать, когда что-то идёт не так?

Диагностика и обработка ошибок

С точки зрения способов обработки ошибок в генераторах — видов ошибок всего два: ожидаемые и неожиданные.

Ожидаемые ошибки передаются клиенту генератора как объекты типа Diagnostic, в предыдущем разделе был пример использования такого механизма. Опишу основные рекомендации при объявлении диагностики:

  • создавайте объекты DiagnosticDescriptor заранее, статическими полями, это минимизирует шансы проявления неожиданных ошибок при конструировании;

  • при создании DiagnosticDescriptor используйте правильные сообщения для Title и MessageFormat, первое должно содержать короткий заголовок, описывающий что произошло, а второе — форматную строку с детальным описанием, желательно описывающим потенциальный способ исправления;

  • При вызове Diagnostic.Create указывайте максимально сфокусированный Location, причины и примеры есть в конце предыдущего раздела.

Неожиданные ошибки обычно проявляют себя в виде исключений. Несмотря на то, что IDE обычно оборачивает генераторы кода так, чтобы ошибки в генераторах не приводили к краху самой IDE, например, Visual Studio отключает генератор, в котором произошла ошибка, причём иногда до перезапуска всей среды разработки. Это очень неудобно, особенно в процессе разработки и отладки генератора. Для решения этой проблемы я рекомендую оборачивать методы генерации кода в блок try/catch, который перехватывает исключения, передавая их клиенту как объекты типа Diagnostic, при этом добавляя их текстовые представления в сгенерированные файлы, в которых их можно посмотреть после возникновения ошибки:

[Generator]
public sealed class FilteredClassNameListGenerator : IIncrementalGenerator
{
    // Остальное содержимое опущено для простоты

    private static readonly DiagnosticDescriptor UnhandledException =
        new(
            "TS1001",
            "Unhandled exception occurred",
            "The generator caused an exception {0}: {1}",
            DiagnosticCategory,
            DiagnosticSeverity.Error,
            true);

    private void Build(
        SourceProductionContext context,
        ImmutableArray<BuilderTarget> source)
    {
        try
        {
            BuildCore(context, source);
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception exception)
        {
            ReportExceptionDiagnostic(
              context,
              exception, 
                e => CreateExceptionDiagnostic(e, null));
        }
    }

    private void BuildCore(
        SourceProductionContext context,
        ImmutableArray<BuilderTarget> source)
    {
        // То, что раньше было в методе Build
        var builder = new StringBuilder();
        // ...
    }

    private static Diagnostic CreateExceptionDiagnostic(
        Exception exception, 
        Location? location)
        => Diagnostic.Create(
            UnhandledException,
            location,
            exception?.GetType(),
            exception?.Message);

    private static void ReportExceptionDiagnostic(
        SourceProductionContext context, 
        Exception exception, Func
        <Exception, Diagnostic> diagnosticFactory)
    {
        try
        {
            var diagnostic = diagnosticFactory(exception);
            context.ReportDiagnostic(diagnostic);
            var exceptionInfo = "#error " + exception.ToString().Replace("\n", "\n//");
            context.AddSource(
                "!" + diagnostic.Descriptor.Id + "-" + Guid.NewGuid(), 
                exceptionInfo);
        }
        catch
        {
        }
    }
}

Здесь метод Build переместился в BuildCore, а на его место пришла обёртка, перехватывающая исключения и отправляющая их методу ReportExceptionDiagnostic. Этот метод отправляет информацию об ошибке клиенту генератора, чтобы он мог отобразить ошибку (например, в списке ошибок в Visual Studio) и записывает исключение в виде строки #error в файл со специальным именем, что позволяет посмотреть на информацию об исключении более детально.

Подходы к генерации кода

Допустим пайплайн построен, исключения перехвачены, что же теперь? Теперь пора переходить к тому, что мы так долго откладывали — генерации кода.

Для генерации кода есть два основных способа:

  1. Использование Roslyn для построения новых синтаксических деревьев с последующим их преобразованием в код;

  2. Генерация исходного кода в виде текста, например, при помощи StringBuilder.

Несмотря на то, что первый способ видится более "правильным", я рекомендую пользоваться вторым. Основная причина — существенно более высокая производительность второго способа из-за значительно меньшего количества создаваемых в процессе генерации объектов. Ни для кого не секрет, что создание значительного количества объектов в куче отнюдь не бесплатное, и сборщик мусора за него спасибо не скажет. Корректность сгенерированного вручную кода сразу же проверяется компилятором, а потенциальные неоднозначности в именовании типов достаточно просто устранить использованием правильных форматов именования типов. Если сомневаетесь, посмотрите, например, на то, как RegexGenerator реализован в .NET 7. Кроме того бонусом второго варианта является то, что вам не придётся изучать все аспекты функционирования Roslyn, достаточно будет минимума.

В первой части серии, приводился пример с кэшированием типа Builder, использующего для реализации StringBuilder, при этом кэширование осуществляется для каждого Compilation (условно — проекта). Я рекомендую пользоваться таким шаблоном и для реальных генераторов, он немного сокращает нагрузку на GC в IDE, при этом так как внутри одного проекта генератор не вызывается в нескольких потоках, то ему не потребуются блокировки.

Генерация кода и имена типов

При генерации кода необходимо однозначно именовать типы, чтобы предотвратить ошибки компиляции из-за столкновения имён типов. Это может быть непросто, особенно если сгенерированный код содержит нетривиальную логику либо использует внешние, относительно него, типы, определённые пользователем. В этой ситуации при написании генератора важно выводить имена типов полностью, используя метод ITypeSymbol.ToDisplayString (а не object.ToString или ISymbol.ToDisplayString), с явным указанием NullableFlowState (чтобы генерировать правильный код для nullable reference types) и правильным форматом, например, SymbolDisplayFormat.FullyQualifiedFormat + SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier (см. DisplayFormats.Full в библиотеке, рекомендуемой дальше).

Например, в следующем примере будут использованы правильные значения для типа:

var valueTypeName = valueType.ToDisplayString(
    NullableFlowState.NotNull, 
    DisplayFormats.Full);
var nullableValueTypeName = valueType.ToDisplayString(
    NullableFlowState.MaybeNull, 
    DisplayFormats.Full);

Если valueType это reference-тип IReadOnlyList<MetricTagDescriptor>, то valueTypeName будет содержать global::System.Collections.Generic.IReadOnlyList<global::Dnet.Diagnostics.Metrics.Core.MetricTagDescriptor>, а nullableValueTypeNameglobal::System.Collections.Generic.IReadOnlyList<global::Dnet.Diagnostics.Metrics.Core.MetricTagDescriptor>?.

Способы реализации общей функциональности

Рано или поздно какому-то из ваших генераторов потребуется доступ к какой-то функциональности, общей для генерируемого кода. Самый простой пример — атрибут [IncludeIntoList], вспомните, как мы обошли тему с местом его определения. Пример чуть посложнее — допустим, сгенерированному коду требуется метод, который не хочется дублировать в каждом файле. Куда его поместить?

Здесь есть два основных варианта:

  1. Добавление общего кода в выдачу генератора;

  2. Ожидание, что пользователи вашего генератора будут использовать заранее предоставленную библиотеку.

Есть ещё и третий (помещение общего кода в библиотеку генератора и использование ссылки на неё), но он мне настолько не нравится, что я не могу его предлагать.

Самый очевидный вариант — первый. Если нужно добавить атрибут [IncludeIntoList], то можно сделать следующее:

[Generator]
public sealed class FilteredClassNameListGenerator : IIncrementalGenerator
{
    private const string AttributeShortName = "IncludeIntoList";
    private const string AttributeFullName = AttributeShortName + "Attribute";

    private const string AttributeSource = @"namespace System;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
internal sealed class IncludeIntoListAttribute : Attribute
{
}
";

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Прочая инициализация опущена
        context.RegisterPostInitializationOutput(AddExtraSources);
    }

    private static void AddExtraSources(
        IncrementalGeneratorPostInitializationContext context)
    {
        context.AddSource(
            AttributeFullName, 
            SourceText.From(AttributeSource, Encoding.UTF8));
    }
}

Здесь метод AddExtraSources добавляет в результат выдачи генератора независимый от входных данных пайплайна (см. RegisterPostInitializationOutput в первой части) файл с кодом, содержащим атрибут.
Однако давайте посмотрим на отличия этого атрибута от того, который был показан в начале этой части. Отличие только одно — он internal, а не public. Если вдуматься — становится понятно почему. Если его сделать public, то во всех сборках, где используется этот генератор, будет по копии такого типа, и если они будут зависеть друг от друга или какая-то третья сборка будет зависеть от такой — нас ждёт неминуемая ошибка столкновения типов. Кажется, что internal решает этот вопрос, но не всегда. Существует атрибут [InternalsVisibleTo], который часто используется для разрешения каким-либо сборкам с тестами или элементами композиции (в терминах dependency injection) получать доступ к internal-членам другого проекта. Как можно догадаться, при использовании такого атрибута, если и определяющая его сборка, и целевая используют такой генератор, то их ждёт та же самая проблема — столкновение типов.

Кроме того, общий код, который нужен сгенерированному коду, может быть слишком объёмным, чтобы дублировать его в каждой сборке, или должен использовать какие-то статические переменные (это антипаттерн, но иногда без этого не обойтись). Для таких случаев имеет смысл применять внешнюю библиотеку, которую можно просто добавлять везде, где должен использоваться ваш генератор. Да, это лишний проект, но в целом это несмертельно, зато если вы, например, разрабатываете какую-то библиотеку, то вы можете не переживать, что её пользователи проклянут вас за столкновение типов, которое вы им неявно "подложили", воспользовавшись первым вариантом.

В качестве общей идеи я предлагаю начинать разработку генераторов с первого варианта, но переходить на второй если:

  • ваш генератор будет часто применяться для библиотечных сборок;

  • объём общего кода достаточно велик (больше сотни строк кода или трёх файлов);

  • вы легко можете обеспечить добавление зависимости от сборки второго варианта.

Нюансы расширения существующих типов

Многие типы подходят для расширения при помощи генераторов, но есть несколько требований, которые проще проверить перед генерацией кода, чем оставлять пользователя с непонятными ошибками или наоборот, не генерировать код, пока пользователь не устранит все ошибки.

Список типовых проблем, при которых не стоит даже браться за генерацию или, как минимум, крепко подумать, следующий:

  • отсутствие ключевого слова partial у расширяемого типа — здесь всё понятно, расширить тип не выйдет;

  • отсутствие пространства имён у расширяемого типа — аналогично, сгенерировать тип, который не принадлежит к пространству имён не выйдет;

  • некорректное определение видимости расширяемого типа — для типов верхнего уровня допустимы только public и internal, использование другой видимости является ошибкой и генерировать код в этом случае не стоит, если он опирается на видимость расширяемого;

  • вложение расширяемого типа — несмотря на то, что это преодолимая проблема, она сильно усложняет генерацию за счёт того, что все типы, в которые вложен расширяемый тип, должны быть тоже объявлены как partial, а определение типа должно содержать в себе весь каскад этих типов.

Избыточная проверка на ошибки тоже может быть вредна, если проверять в примере с ошибочным атрибутом не только атрибут, но и определение класса, то опечатка в, допустим, свойстве-члене класса, приведёт к тому, что генератор не будет работать, хотя ему это свойство совершенно не нужно. Стоит проверять на ошибочность только те части синтаксического дерева, которые действительно требуются для работы генератора.

Зависимости для source generators

Теперь я хочу сделать небольшое отступление от программирования генераторов и дать практический совет по устройству проекта.

Рано или поздно вы можете захотеть сделать библиотеку общей функциональности для генераторов, а ей, в свою очередь, может быть нужна какая-то сторонняя библиотека, не входящая в BCL, например, JSON-парсер. Если просто добавить эту зависимость к библиотеке, то всё будет компилироваться, но при этом сам генератор при сборке работать не будет, примерно с такими симптомами:

CSC : warning CS8032: An instance of analyzer SomeGenerator cannot be created from SomeGenerator.dll : Could not load file or assembly 'MyGeneratorLibrary, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' or one of its dependencies. The system cannot find the file specified.

Это вызвано тем, что Roslyn-анализаторы (а генератор считается анализатором) при использовании автоматически не копируют ссылки от других библиотек. Чтобы исправить это, нужно добавить требуемые зависимости.

Если ваша библиотека MyGeneratorLibrary для генератора оформлена как проект и используется в генераторе как ProjectReference, то в проект генератора нужно добавить следующее:

<PropertyGroup>
    <GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
</PropertyGroup>

<Target Name="GetDependencyTargetPaths">
    <ItemGroup>
        <TargetPathWithTargetPlatformMoniker Include="MyGeneratorLibraryPath\MyGeneratorLibrary\$(OutputPath)\MyGeneratorLibrary.dll" IncludeRuntimeDependency="false" />
    </ItemGroup>
</Target>

Атрибут Include для TargetPathWithTargetPlatformMoniker должен указывать на все зависимости, можно использовать и маску, например, ...\*.dll.

Если же ваша библиотека оформляется как NuGet-пакет и используется как PackageReference, то лучше добавить в пакет специальный файл build/MyGeneratorLibrary.targets (разумеется все вхождения MyGeneratorLibrary стоит заменить на соответствующие названию вашей библиотеки):

<Project>
  <PropertyGroup>
    <GetTargetPathDependsOn>$(GetTargetPathDependsOn);MyGeneratorLibrary_GetDependencyTargetPaths</GetTargetPathDependsOn>
  </PropertyGroup>

  <Target Name="MyGeneratorLibrary_GetDependencyTargetPaths">
    <ItemGroup>
      <TargetPathWithTargetPlatformMoniker Include="$(MSBuildThisFileDirectory)..\lib\netstandard2.0\MyGeneratorLibrary.dll" IncludeRuntimeDependency="false" />
    </ItemGroup>
  </Target>
</Project>

При добавлении такого файла PackageReference автоматически включит этот файл в сценарий сборки основного проекта и зависимости будут указаны верно.

Если ваш NuGet-пакет собирается без явного указания nuspec-файла, то можно положить описанный выше файл в каталог Properties/MyGeneratorLibrary.targets и добавить следующее в проектный файл библиотеки-пакета:

  <ItemGroup Label="PackageItems">
    <None Include="Properties\MyGeneratorLibrary.targets" Pack="true" PackagePath="build" Visible="false" />
  </ItemGroup>

Dnet.SourceGenerators

В первой части я обещал безжалостную рекламу библиотеки для написания генераторов. Обещания нужно держать, поэтому вот она. Размеры библиотеки невелики, но её функциональность покрывает некоторые очень полезные части создания генераторов, особенно ориентированных на дополнение и расширение пользовательских типов.

Я опишу её основные элементы, но для примеров применения придётся подождать выхода третьей части, потому что эта, кажется, уже слишком велика.

Библиотека состоит из нескольких основных частей:

  1. Утилитный класс GeneratorTools для упрощения работы с объектами Roslyn, в нём вы найдёте реализации описанных ранее методов ContainsErrors, GetNameText и некоторых других. В следующей версии часть этих методов превратится в методы-расширения, но пока предполагается использовать их напрямую.

  2. Представления целей генераторов:

    • DeclaredSymbolTarget<TDeclaration, TSymbol> — базовое представление цели генератора, опирающейся на узел синтаксического дерева, связанное с его семантической моделью;

    • TypeTarget<TDeclaration> и его наследник TypeTarget — конкретные цели генератора, опирающиеся на определение типа, при этом наследник ограничивает допустимые узлы синтаксического дерева определением любого типа, а не какого-то конкретного (class, struct, record).

  3. Представления типовой функции генераторов, собственно генерации кода:

    • ISourceBuilder<TTarget> — интерфейс генератора кода, отделённого от пайплайна.

  4. Ядро библиотеки — фабрики для производства типовых генераторов (CommonGenerators) и провайдеров значений (CommonProviders).

  5. Средство для композиции генераторов, CompositeGeneratorBase, которое упрощает объединение нескольких примитивных генераторов (например, созданных при помощи CommonGenerators) в один.

  6. Наборы типовых диагностик в GeneratorDiagnostics и форматов имён типов для ToDisplayFormat в DisplayFormats.

На момент написания статьи в репозитории есть пример нетривиального генератора, который вы можете посмотреть самостоятельно. Разбор его устройства и реализации будет в следующей части.

Выводы

Если вдруг вы решили не читать статью, то основные моменты следующие:

  • не генерируйте код, если определяющий для генератора узел синтаксического дерева содержит ошибку;

  • учитывайте, что имя типа может быть не только типа SimpleNameSyntax;

  • используйте record как результат накопления информации при прохождении пайплайна;

  • перехватывайте исключения и конвертируйте их в диагностическую информацию;

  • правильно указывайте имена типов при генерации, используя метод ToDisplayString с нужными форматами;

  • знайте, что есть два метода добавления общей функциональности для сгенерированного кода: добавление исходных файлов и добавление внешней библиотеки;

  • применяйте подход fail-fast при генерации кода для типов, которые не допускают расширения;

  • не забывайте про дополнительные шаги при добавлении зависимостей для вашей реализации генератора;

  • посмотрите на Dnet.SourceGenerators.

В следующей части:

  • разбор реального генератора, построенного по рекомендациям из первой и второй частей с использованием библиотеки Dnet.SourceGenerators;

  • способы тестирования генераторов.

UPD: в комментарии к предыдущей части, @netpiligrimупомянул про интересный метод, краткое описание которого я добавил в эту часть, за что ему большое спасибо! В Dnet.SourceGenerators тоже добавлю его поддержку.

Источники

  1. Jeske Dominik, Source Generators - real world example

  2. Lock Andrew, Series: Creating a source generator — ещё один детальный разбор инкрементных генераторов

  3. Alpert Collin, Lombok.NET

  4. Разработчики Roslyn, Incremental Generators

Теги:
Хабы:
+9
Комментарии 6
Комментарии Комментарии 6

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн
PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн