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

Избавляемся от постоянного написания конструкторов для инжекта зависимостей с помощью C# Source Generators

Время на прочтение 5 мин
Количество просмотров 11K

В апреле 2020-го года разработчиками платформы .NET 5  был анонсирован новый способ генерации исходного кода на языке программирования C# — с помощью реализации интерфейса ISourceGenerator. Данный способ позволяет разработчикам анализировать пользовательский код и создавать новые исходные файлы на этапе компиляции. При этом, API новых генераторов исходного кода схож с API анализаторов Roslyn. Генерировать код можно как с помощью Roslyn Compiler API, так и методом конкатенации обычных строк.

В данном материале рассмотрим библиотеку HarabaSourceGenerators.Generators и то, как она реализована

HarabaSourceGenerators.Generators

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

public partial class HomeController : Controller
{
     private readonly TestService _testService;
        
     private readonly WorkService _workService;
        
     private readonly ExcelService _excelService;
        
     private readonly MrNService _mrNService;
        
     private readonly DotNetTalksService _dotNetTalksService;
       
     private readonly ILogger<HomeController> _logger;

     public HomeController(
         TestService testService,
         WorkService workService,
         ExcelService excelService,
         MrNService mrNService,
         DotNetTalksService dotNetTalksService,
         ILogger<HomeController> logger)
     {
         _testService = testService;
         _workService = workService;
         _excelService = excelService;
         _mrNService = mrNService;
         _dotNetTalksService = dotNetTalksService;
         _logger = logger;
     }
}

Пора с этим кончать!

Представляю вашему вниманию новый, удобный и элегантный способ:

public partial class HomeController : Controller
{
    [Inject]
    private readonly TestService _testService;
        
    [Inject]
    private readonly WorkService _workService;
        
    [Inject]
    private readonly ExcelService _excelService;
        
    [Inject]
    private readonly MrNService _mrNService;
        
    [Inject]
    private readonly DotNetTalksService _dotNetTalksService;
        
    [Inject]
    private readonly ILogger<HomeController> _logger;
 }

А что, если лень указывать для каждой зависимости атрибут Inject?

Не проблема, можно указать атрибут Inject для всего класса. В таком случае будут браться все приватные поля с модификатором readonly:

[Inject]
public partial class HomeController : Controller
{
    private readonly TestService _testService;
        
    private readonly WorkService _workService;
        
    private readonly ExcelService _excelService;
        
    private readonly MrNService _mrNService;
        
    private readonly DotNetTalksService _dotNetTalksService;
        
    private readonly ILogger<HomeController> _logger;
}

Отлично. Но что, если есть поле, которое нужно не для инжекта?

Указываем для такого поля атрибут InjectIgnore:

[Inject]
public partial class HomeController : Controller
{
    [InjectIgnore]
    private readonly TestService _testService;
        
    private readonly WorkService _workService;
        
    private readonly ExcelService _excelService;
        
    private readonly MrNService _mrNService;
        
    private readonly DotNetTalksService _dotNetTalksService;
        
    private readonly ILogger<HomeController> _logger;
}

Ну окей, а что, если я хочу указать последовательность для зависимостей?

Угадайте что? Правильно, не проблема. Есть два способа:

1) Расставить поля в нужной последовательности в самом классе.
2) В атрибут Inject передать порядковый номер зависимости

public partial class HomeController : Controller
{
    [Inject(2)]
    private readonly TestService _testService;

    [Inject(1)]
    private readonly WorkService _workService;

    [Inject(3)]
    private readonly ExcelService _excelService;

    [Inject(4)]
    private readonly MrNService _mrNService;

    [Inject(5)]
    private readonly DotNetTalksService _dotNetTalksService;

    [Inject(6)]
    private readonly ILogger<HomeController> _logger;
}

Как видим, последовательность успешно сохранена.

Взглянем на реализацию

У нас есть класс InjectSourceGenerator, который реализует интерфейс ISourceGenerator.
Мы пробегаемся по синтаксическому дереву. Получаем семантическую модель, а так же все классы, которые имеют атрибут Inject. После чего генерируем для каждого такого класса - новый partial класс, в который мы помещаем конструктор.
Сгенерированный файл "{className}.Constructor.cs" мы помещаем в контекст выполнения

public void Execute(GeneratorExecutionContext context)
{
	var compilation = context.Compilation;
	var attributeName = nameof(InjectAttribute).Replace("Attribute", string.Empty);
	foreach (var syntaxTree in compilation.SyntaxTrees)
	{
		var semanticModel = compilation.GetSemanticModel(syntaxTree);
		var targetTypes = syntaxTree.GetRoot().DescendantNodes()
			.OfType<ClassDeclarationSyntax>()
			.Where(x => x.ContainsClassAttribute(attributeName) || x.ContainsFieldAttribute(attributeName))
			.Select(x => semanticModel.GetDeclaredSymbol(x))
			.OfType<ITypeSymbol>();

		foreach (var targetType in targetTypes)
		{
			string source = GenerateInjects(targetType);
			context.AddSource($"{targetType.Name}.Constructor.cs", SourceText.From(source, Encoding.UTF8));
		}
	}
}

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

private string GenerateInjects(ITypeSymbol targetType)
{
            return $@" 
using System;
namespace {targetType.ContainingNamespace}
{{
    public partial class {targetType.Name}
    {{
        {GenerateConstructor(targetType)}
    }}
}}";
}

Давайте взглянем на метод генерации самого конструктора (самая важная часть кода).
И так, сперва мы получаем поля. Если атрибут Inject указан у класса, то мы берем все поля, которые имеют модификатор readonly и не имеют атрибута InjectIgnore. Иначе мы берем все поля, у которых есть атрибут Inject. Дальше мы выполняем сортировку, чтобы дать возможность пользователям выбирать последовательность параметров. Думаю остальное все понятно

private string GenerateConstructor(ITypeSymbol targetType)
{
	var parameters = new StringBuilder();
	var fieldsInitializing = new StringBuilder();
	var fields = targetType.GetAttributes().Any(x => x.AttributeClass.Name == nameof(InjectAttribute)) 
					? targetType.GetMembers()
						.OfType<IFieldSymbol>()
						.Where(x => x.IsReadOnly && !x.GetAttributes().Any(y => y.AttributeClass.Name == nameof(InjectIgnoreAttribute)))
					: targetType.GetMembers()
						.OfType<IFieldSymbol>()
						.Where(x => x.GetAttributes().Any(y => y.AttributeClass.Name == nameof(InjectAttribute)));

	var orderedFields = fields.OrderBy(x => x.GetAttributes()
											 .First(e => e.AttributeClass.Name == nameof(InjectAttribute))
											 .ConstructorArguments.FirstOrDefault().Value ?? default(int)).ToList();
	foreach (var field in orderedFields)
	{
		var parameterName = field.Name.TrimStart('_');
		parameters.Append($"{field.Type} {parameterName},");
		fieldsInitializing.AppendLine($"this.{field.Name} = {parameterName};");
	}

	return $@"public {targetType.Name}({parameters.ToString().TrimEnd(',')})
			  {{
				  {fieldsInitializing}
			  }}";
}

Минусы

Класс обязательно должен иметь ключевое слово partial, чтобы была возможность создать конструктор в стороннем файле. На мой взгляд, это единственный минус!

Исходный код генератора доступен на GitHub.
Скачать Nuget пакет HarabaSourceGenerators

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

Публикации

Истории

Работа

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

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