В 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.Analyzers, Microsoft.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 постарались максимально упростить пользователям работу, но это не отменяет обширности кодовой базы, с которой впервые достаточно неудобно взаимодействовать.

Хотелось бы сказать спасибо @ForNeVeR.

И отметить, что поддержку по С# можно найти здесь.

А исходники проекта тут.

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

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


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

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

Ответ на это прост, грустен и примитивен:
1) Так сложилось исторически
2) Рефактор и избавление от VkResponse требует много сил и времени
3) Это сломает совместимость

Комментарии (2)


  1. hinduCoder
    25.07.2022 13:46
    +2

    А почему в шаблоне тела класса используются разные переменные (1,2,3), хотя передаётся в них одно и то же значение?


    1. Larymar Автор
      25.07.2022 13:47

      Ну изначально были сомнения, что это разные значения могут быть, а потом просто не поправлен шаблон.
      Верное замечание, требующее доработки.