В 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) Это сломает совместимость
hinduCoder
А почему в шаблоне тела класса используются разные переменные (1,2,3), хотя передаётся в них одно и то же значение?
Larymar Автор
Ну изначально были сомнения, что это разные значения могут быть, а потом просто не поправлен шаблон.
Верное замечание, требующее доработки.