В этом цикле статей рассмотрим как можно легко и быстро делать на C# любые однотипные действия просто навешивая атрибуты на доменные сущности

Первая статья посвящена основам кодогенерации и описанием пары-тройки довольно нетипичных костылей.
Что такое кодогенерация?
Создание кода при помощи кода.
Как правило на основе атрибутов либо классов конфигурации (подсветка, автозаполнение, подсказки, проверка конфигураций на уровне синтаксиса).
Описано множеством статей:
1) https://habr.com/ru/articles/455952/
2) https://habr.com/ru/companies/simbirsoft/articles/763288/
И далее
Из минусов публикаций стоит отметить:
1) Не системность
2) Отсутствие связи с DDD/CleanArchitecture. Да, я рад что Ваш проект уже пол года живет и без DDD :)
3) Как следствие нет примера кодогенерации на различных слоях/конечных точках
Давайте все кодогенерировать?
Зачем это вообще нужно:
1) Если посмотреть на теорию надежности, 1000 сгенерированных классов более надежны, чем 1000 скопированных\переписанных. Нашел ошибку - перегенерировал все.
2) Всегда проще найти программиста C# (или даже фулстека), чем искать "Специалиста по DirectumRX/Bpm Online/BPB CRM/DocsVision/etc. под Linux". Поэтому вопрос "Делаем свое или берем готовое и импортозамещенное" обычно не стоит.
3) Добавление нового модуля (генерация CRUD endpoint-ов, интеграция и т.д.) гораздо быстрее, чем делать все ручками. Конечно, встречаются любители городить все руками (по 1 методу API за спринт), но как правило из-за отсутствия документации, нужной степени унификации такие проекты довольно быстро закрывают(ся).
4) При приверженности всем YAGNI, DRY, KISS, SOLID, DDD, IOC, etc - код получается довольно слабосвязанным, все состоит из простых классов с единственной ответственностью и т.д. Такой код идеально подходит для создания генераторов такого кода.
Поэтому да, давайте.
Убедили, что будем кодогенерировать?
В качестве примера давайте сделаем кодогенерацию следующего проекта:
Web-api, которое пишет все полученные данные в шину, а оттуда кучей воркеров по батчам - в БД. Для последующей агрегации\ETL.
Это простая и распространенная задача (вендинги, куча IOT, или собственный CQRS и всего остального, с миллионом записей в минуту\секунду).
Поехали
1) Настраиваем проекты
У нас есть только 2 типа проектов:
Сам проект кодогенератора.
Проект кодогенератора должен быть под платформу netstandard2.0 или выше.
Строки 11-12 - Подключаем нужные библиотеки.
Самое интересное - строки 16-18.
Первый подводный камень - в проект кодогенератора нельзя просто взять и подключить другой проект с описанием доменных сущностей. Для подключенных проектов надо добавлять OutputItemType="Analyzer". Иначе никак, даже с копированием dll.
Так же нельзя просто подключить пакет из nuget. Проблема описана в официальном cookbook (и как ее решить - тоже). https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<EmitCompilerGeneratedFiles>True</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
<EnforceExtendedAnalyzerRules>True</EnforceExtendedAnalyzerRules>
<CopyLocalLockFileAssemblies>True</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.0.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Domain.Common.csproj" OutputItemType="Analyzer" />
</ItemGroup>
</Project>
Я взял старые версии пакетов, без инкрементальной кодогенерации потому что так и не понял, что кодогенерация может тормозить (увы, даже для 20+ сущностей генерирование 4 классов-хелперов и 1 обертки занимает очень мало времени).
Проекты, использующие кодогенерацию
Тут следует помнить, что мы имеем лишь один проект для кодогенерации. И нам следует как-то отличать различные проекты, к которым мы подключаем кодогенерацию.
За различие проектов отвечают строки 12-15. Здесь мы устанавливаем AssemblyMetadataAttribute для нашей сборки с именем "ProjectName" и значением "ApplicationWorkers"
За подключение самого кодогенератора строка 17.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>AnyCPU;x64</Platforms>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>ProjectName</_Parameter1>
<_Parameter2>ApplicationWorkers</_Parameter2>
</AssemblyAttribute>
<ProjectReference Include="..\CodeGen\CodeGen.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="True" />
<ProjectReference Include="..\Common\Domain.Common.csproj">
<Private>True</Private>
<CopyLocalSatelliteAssemblies>True</CopyLocalSatelliteAssemblies>
</ProjectReference>
</ItemGroup>
</Project>
2) Делаем простейшую инфраструктуру для запуска кодогенерации
Теперь мы можем делать кодогенераторы с любыми пакетами из nuget, подключенными проектами. Держать их все в одном месте. И запускать только в нужных местах.
Для начала опишем все возможные места запуска кодогенераторов:
namespace CodeGeneration.GeneratorBase
{
/// <summary>
/// Место запуска кодогенератора
/// </summary>
public enum GeneratorRunPlace
{
/// <summary>
/// Инфраструктура Веб
/// </summary>
InfrastructureWeb,
/// <summary>
/// Инфраструктура БД
/// </summary>
InfrastructureDataBase,
/// <summary>
/// Приложение Процессы
/// </summary>
ApplicationWorkers
}
}
Почему так много? По конфигурации endpoint-а мы должны добавить:
1) объекты с которыми работает endpoint
2) контроллер, который составляет из полученных данных объект и пишет его в шину
3) объект в шине, с данными запроса
3) объект в БД, с данными запроса
4) процесс, читающий объекты из шины, составляющий батч, и пишущий их в БД
Пункт (4) самый спорный, но почти все БД поддерживают шардинг, и даже без шин и прочих супер кластеров можно делать 1m RPM просто батчами :).
Но мы хотим 1M RPS.
Все кодогенераторы у нас реализуют базовый интерфейс:
using Microsoft.CodeAnalysis;
namespace CodeGeneration.GeneratorBase
{
/// <summary>
/// Базовый интерфейс для кодогенераторов
/// </summary>
public interface ICodeGeneratorBase
{
/// <summary>
/// Место запуска кодогенератора
/// </summary>
GeneratorRunPlace place { get; set; }
/// <summary>
/// Запускает кодогенератор
/// </summary>
void Run(GeneratorExecutionContext context);
}
}
Очень удобный интерфейс т.к. в нем нет Generic части. Советую использовать чаще, т.к. более детализированный интерфейс уже гораздо тяжелее находить через рефлексию и вызывать:
using Microsoft.CodeAnalysis;
using System.Collections.Generic;
namespace CodeGeneration.GeneratorBase
{
/// <summary>
/// Описание простейшего генератора
/// </summary>
/// <typeparam name="TDTO">Тип обьектов с результатами парсинга</typeparam>
public interface ICodeGenerator<TDTO> : ICodeGeneratorBase
{
/// <summary>
/// Парсинг проекта
/// </summary>
/// <param name="context">контекст выполнения генератора</param>
/// <returns>Данные для кодогенерации</returns>
List<TDTO> Parse(GeneratorExecutionContext context);
/// <summary>
/// Генерация кода по результатам парсинга
/// </summary>
/// <param name="context">Контекст генерации</param>
/// <param name="data">Данные с результатами парсинга</param>
void Generate(GeneratorExecutionContext context, List<TDTO> data);
}
}
Таким образом все наши генераторы разделены на 2 части - получение данных для генерации и собственно генерацию (SOLID).
И, наконец, базовый класс для всех наших кодогенераторов:
using Microsoft.CodeAnalysis;
using System.Collections.Generic;
namespace CodeGeneration.GeneratorBase
{
public abstract class CodeGeneratorBase<TDTO> : ICodeGenerator<TDTO>
{
public GeneratorRunPlace place { get; set; }
public abstract void Generate(GeneratorExecutionContext context, List<TDTO> data);
public abstract List<TDTO> Parse(GeneratorExecutionContext context);
public void Run(GeneratorExecutionContext context)
{
var data = Parse(context);
Generate(context, data);
}
}
}
Теперь добавляем основное - единую точку запуска всех наших кодогенераторов:
using CodeGen.GenerateWorkers;
using CodeGeneration.GeneratorBase;
using Microsoft.CodeAnalysis;
using System.Collections.Generic;
using System.Linq;
namespace CodeGeneration
{
/// <summary>
/// Точка входа для запуска генераторов
/// </summary>
[Generator]
public class CodeGenerationEntry : ISourceGenerator
{
/// <summary>
/// "Регистрируем" все генераторы
/// </summary>
/// <returns></returns>
private List<ICodeGeneratorBase> RegisterGenerators()
{
return new List<ICodeGeneratorBase>()
{
new WorkersGenerator()
};
}
public void Initialize(GeneratorInitializationContext context)
{
#if DEBUG
//if (!Debugger.IsAttached)
//{
// Debugger.Launch();
//}
#endif
}
public void Execute(GeneratorExecutionContext context)
{
//Получаем все генераторы
var generators = RegisterGenerators();
//Получаем все атрибуты нашей сборки из контекста
var attributes = context.Compilation.Assembly.GetAttributes();
//Получаем все AssemblyMetadataAttribute, у которых Key = ProjectName
var attr = attributes.Where(i => i.AttributeClass.Name == "AssemblyMetadataAttribute")
.Where(i => i.ConstructorArguments[0].Value.ToString() == "ProjectName")
.FirstOrDefault();
//Получаем место вызова кодогенератора
var callingPlace = attr.ConstructorArguments[1].Value.ToString();
//Запускаем все генераторы
foreach (var gen in generators)
{
//Получаем место запуска кодогенератора
var runPlace = gen.place.ToString();
//Если имена совпадают - запускаем кодогенератор
if (callingPlace == runPlace)
gen.Run(context);
}
}
}
}
И простой кодогенератор:
using CodeGeneration.GeneratorBase;
using Microsoft.CodeAnalysis;
using System.Collections.Generic;
namespace CodeGen.GenerateWorkers
{
public class WorkersGenerator : CodeGeneratorBase<WorkersGeneratorDTO>
{
public WorkersGenerator()
{
place = GeneratorRunPlace.ApplicationWorkers;
}
public override void Generate(GeneratorExecutionContext context, List<WorkersGeneratorDTO> data)
{
var i = 1;
i = i + 1;
context.AddSource("ApplicationWorkers", "//Test");
}
public override List<WorkersGeneratorDTO> Parse(GeneratorExecutionContext context)
{
return new List<WorkersGeneratorDTO>()
{
new WorkersGeneratorDTO()
{
}
};
}
}
}
И его DTO:
//using Common.Attributes.Entities;
using Common.Attributes.Entities;
using System;
namespace CodeGen.GenerateWorkers
{
/// <summary>
/// Данные для кодогенерации контроллера
/// </summary>
public class WorkersGeneratorDTO
{
}
}
Если вы все сделали правильно, то теперь у вас в Application.Workers появится сгенерированный файл:

Поздравляю, вы прошли самую сложную часть кодогенерации:
1) Подключение сторонних проектов\пакетов
2) Сведение всех кодогенераторов в 1 проект
Получившийся после первой части Solution можно взять тут.
В следующей публикации мы получим все синтаксические деревья из Domain и напишем все по частям :-)