Кодогенерация в C#

Кодогенерация в C#

Автор: Larymar

В C# давно уже добавили возможность использовать кодогенерацию. Но покопавшись в интернетах не было найдено обширного количество гайдов. Спасибо сайту мс, за наличие информации по данной теме. Но, увы, там она достаточно поверхностна, а подробности можно найти только экспериментальным путем или изучением различных готовых примеров.


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

Условия

Есть широко известная в узких кругах библиотека VkNet содержащая в своем коде огромное количество моделей. Для некоторых из этих моделей реализован метод FromJson со следующей сигнатурой:


public static Model FromJson(VkResponse response)

Данный метод примитивно парсит модель VkResponse и заполняет его свойства. И написание данного метода хочется автоматизировать.

И того:


  • Настроить кодогенерацию и убедиться что она работает
  • Найти все partial классы среди моделей
  • Убедиться, что у соответствующего класса уже не реализован необходимый метод
  • Найти все свойства помеченные атрибутомJsonProperty
  • На основании типа свойства и параметра атрибута сформировать искомый метод
  • Добавить в partial класс сгенерированный метод
  • Убедиться, что все работает

Для разработки использована IDE Rider


Настройка

Выкачиваем себе библиотеку VKNet и добавляем в решение новый проект VkNet.Generators типа Class Library


Устанавливаем TargetFramework у новой библиотеки как netstandard2.0и добавляем в нее следующие зависимости: Microsoft.CodeAnalysis.AnalyzersMicrosoft.CodeAnalysis.CSharp.

А в проект, для которого мы хотим генерировать код (в нашем случае VkNet) добавляем ссылку на проект генератор следующего вида:

 <ItemGroup>
  <ProjectReference Include="..\VkNet.Generators\VkNet.Generators.csproj" OutputItemType="Analyzer"
        ReferenceOutputAssembly="false"  />
 </ItemGroup>

Отлично, зависимости настроены, переходим к настройке нашего генератора.

Создаем новый класс в проекте VkNet.Generators добавляем ему атрибут [Generator] и реализуем в нем интерфейс ISourceGenerator.

Теперь проверим, что все это работает.

В методе Execute добавим следующий код:

System.Diagnostics.Debugger.Launch();
Debug.WriteLine("generator start");

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

Для этого лезем в настройки и тыкаем соответствующую кнопку Set Rider as the default debugger.

Поиск нужных классов

В методе Execute мы получаем Context из которого мы можем извлекать синтаксические сущности, как было обозначено выше, нас интересуют только классы с определенными условиями:


var models =
   context.Compilation
    .SyntaxTrees
    .SelectMany(syntaxTree => syntaxTree.GetRoot().DescendantNodes())
    .Where(x => x is ClassDeclarationSyntax)
    .Cast<ClassDeclarationSyntax>()
    .Where(GetPartialModels)
    .Where(GetSerializableModels)
    .Where(NotHaveMethodFromJson)
    .ToImmutableList();

Заметим, что в 6й строке, мы уже получили синтаксические объекты классов и дальше продолжаем работать уже с ними. Рассмотрим примененные к ним условия:

Получение только partial классов:

private static bool GetPartialModels(ClassDeclarationSyntax x)
 {
  return x.Modifiers.Any(m => m.ValueText == "partial");
 }

Получаем только сериализуемые классы:

classDeclarationSyntax.AttributeLists.First().Attributes.Any(x => x.Name.ToString() == "Serializable");

Проверяем наличие FromJson метода:

classDeclarationSyntax.Members
.Any(x =>   (x.Kind() == SyntaxKind.MethodDeclaration 
            && ((MethodDeclarationSyntax) x).Identifier.ValueText != "FromJSON"));

Извлечение свойств

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

Для начала получим все свойства класса:

var properties = model.Members.OfType<PropertyDeclarationSyntax>();

И для каждого свойства получим соответствующие параметры:

Имя:

var propertyName = property.Identifier.ValueText;

Тип:

var propertyType = property.Type.ToString();

Аргумент атрибута JsonProperty:

   var attributeArgument = property.AttributeLists.First()
    .Attributes.First(x => x.Name.ToString() == "JsonProperty")
    .ArgumentList?.Arguments.First()
    .Expression.DescendantTokens()
    .First()
    .Text.Replace("\"", string.Empty);

Формирование тела метода

В общем виде, тело метода достаточно простое

Имя свойства = Ответ Вк [Ключ]

Для такого простого выражения подготовим шаблон:

const string PropertyDeclaration = "{0} = response[\"{1}\"],";

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

const string PropertyReadonlyCollectionWithLambda = "{0} = response[\"{1}\"].ToReadOnlyCollectionOf<{2}>(x => x),";

const string PropertyVkCollection = "{0} = response[\"{1}\"].ToVkCollectionOf<{2}>(x => x),";

Теперь необходимо пройтись по полученной на предыдущем этапе коллекции свойств и опираясь на их тип сформировать строку.

Count = response["count"],
Items = response["items"].ToReadOnlyCollectionOf<Conversation>(),
Profiles = response["profiles"].ToReadOnlyCollectionOf<User>(),
Groups = response["groups"].ToReadOnlyCollectionOf<Group>(),

Формирование тела класса

Для тела класса подготовим шаблон следующего вида:


// Auto-generated code
using System;
using VkNet.Utils;

namespace {0}
{{
    public  partial class {1}
    {{
        public static {2} FromJson(VkResponse response)
  {{
   return new {3}
   {{
    {4}
   }};
  }}
    }}
}}

Из имеющегося ClassDeclarationSyntax получим необходимые описания класса, а именно нам потребуется namespace, а так же 3 раза имя класса и тело метода полученное на третьем этапе.

string.Format(ClassDefinition,
   namespaceName,
   className,
   className,
   className,
   fieldDeclaration)

И соберем тело класса:

 // Auto-generated code
using System;
using VkNet.Utils;

namespace VkNet.Model
{
    public  partial class ConversationResult
    {
        public static ConversationResult FromJson(VkResponse response)
    {
          return new ConversationResult
          {
            Count = response["count"],
            Items = response["items"].ToReadOnlyCollectionOf<Conversation>(),
            Profiles = response["profiles"].ToReadOnlyCollectionOf<User>(),
            Groups = response["groups"].ToReadOnlyCollectionOf<Group>(),
          };
    }
    }
}

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

context.AddSource(model.Identifier.ValueText + ".g.cs",classDeclaration);

Тест

Проверим, что после компиляции в рантайме тестов нашего приложения у нас есть 10 классов со статическим методом FromJson.


string nspace = "Model";

  var assembly = Assembly.GetAssembly(typeof(VkApi));
  var types = assembly.GetTypes();
  var classes = types.Where(x => x.IsClass 
          && x.Namespace != null 
          && x.Namespace.Contains(nspace));

  var count = classes
   .Select(@class => @class.GetMethods(BindingFlags.Public|BindingFlags.Static)
    .Where(x => x.Name.StartsWith("FromJson")))
   .Count(methods => methods.Any());

  count.Should().BeEqualTo(10);

Итоги

Кодогенерация на C# очень мощный, но достаточно запутанный инструмент. Очевидно, что синтаксические деревья это огромные сложные структуры и разработчики из ms постарались максимально упростить пользователям работу, но это не отменяет обширности кодовой базы, с которой впервые достаточно неудобно взаимодействовать.


Очевидный спойлер

Обсуждая с коллегой он задал очевидный вопрос:

Вот эта вот вся шняга зачем тогда нужна, если там уже ньютонсовт?

Нельзя просто JsonConvert.Deserialize(response)?

Ответ на это прост, грустен и примитивен:

1) Так сложилось исторически

2) Рефактор и избавление от VkResponse требует много сил и времени

3) Это сломает совместимость


Report Page