C#, Кодогенерация и DDD Часть 1 — Настраиваем проект и запускаем простой кодогенератор
В этом цикле статей рассмотрим как можно легко и быстро делать на 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
netstandard2.0
True
Generated
True
True
Я взял старые версии пакетов, без инкрементальной кодогенерации потому что так и не понял, что кодогенерация может тормозить (увы, даже для 20+ сущностей генерирование 4 классов-хелперов и 1 обертки занимает очень мало времени).
Проекты, использующие кодогенерацию
Тут следует помнить, что мы имеем лишь один проект для кодогенерации. И нам следует как-то отличать различные проекты, к которым мы подключаем кодогенерацию.
За различие проектов отвечают строки 12–15. Здесь мы устанавливаем AssemblyMetadataAttribute для нашей сборки с именем »ProjectName» и значением »ApplicationWorkers»
За подключение самого кодогенератора строка 17.
net9.0
enable
enable
AnyCPU;x64
true
<_Parameter1>ProjectName
<_Parameter2>ApplicationWorkers
True
True
2) Делаем простейшую инфраструктуру для запуска кодогенерации
Теперь мы можем делать кодогенераторы с любыми пакетами из nuget, подключенными проектами. Держать их все в одном месте. И запускать только в нужных местах.
Для начала опишем все возможные места запуска кодогенераторов:
namespace CodeGeneration.GeneratorBase
{
///
/// Место запуска кодогенератора
///
public enum GeneratorRunPlace
{
///
/// Инфраструктура Веб
///
InfrastructureWeb,
///
/// Инфраструктура БД
///
InfrastructureDataBase,
///
/// Приложение Процессы
///
ApplicationWorkers
}
}
Почему так много? По конфигурации endpoint-а мы должны добавить:
1) объекты с которыми работает endpoint
2) контроллер, который составляет из полученных данных объект и пишет его в шину
3) объект в шине, с данными запроса
3) объект в БД, с данными запроса
4) процесс, читающий объекты из шины, составляющий батч, и пишущий их в БД
Пункт (4) самый спорный, но почти все БД поддерживают шардинг, и даже без шин и прочих супер кластеров можно делать 1m RPM просто батчами :).
Но мы хотим 1M RPS.
Все кодогенераторы у нас реализуют базовый интерфейс:
using Microsoft.CodeAnalysis;
namespace CodeGeneration.GeneratorBase
{
///
/// Базовый интерфейс для кодогенераторов
///
public interface ICodeGeneratorBase
{
///
/// Место запуска кодогенератора
///
GeneratorRunPlace place { get; set; }
///
/// Запускает кодогенератор
///
void Run(GeneratorExecutionContext context);
}
}
Очень удобный интерфейс т.к. в нем нет Generic части. Советую использовать чаще, т.к. более детализированный интерфейс уже гораздо тяжелее находить через рефлексию и вызывать:
using Microsoft.CodeAnalysis;
using System.Collections.Generic;
namespace CodeGeneration.GeneratorBase
{
///
/// Описание простейшего генератора
///
/// Тип обьектов с результатами парсинга
public interface ICodeGenerator : ICodeGeneratorBase
{
///
/// Парсинг проекта
///
/// контекст выполнения генератора
/// Данные для кодогенерации
List Parse(GeneratorExecutionContext context);
///
/// Генерация кода по результатам парсинга
///
/// Контекст генерации
/// Данные с результатами парсинга
void Generate(GeneratorExecutionContext context, List data);
}
}
Таким образом все наши генераторы разделены на 2 части — получение данных для генерации и собственно генерацию (SOLID).
И, наконец, базовый класс для всех наших кодогенераторов:
using Microsoft.CodeAnalysis;
using System.Collections.Generic;
namespace CodeGeneration.GeneratorBase
{
public abstract class CodeGeneratorBase : ICodeGenerator
{
public GeneratorRunPlace place { get; set; }
public abstract void Generate(GeneratorExecutionContext context, List data);
public abstract List 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
{
///
/// Точка входа для запуска генераторов
///
[Generator]
public class CodeGenerationEntry : ISourceGenerator
{
///
/// "Регистрируем" все генераторы
///
///
private List RegisterGenerators()
{
return new List()
{
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
{
public WorkersGenerator()
{
place = GeneratorRunPlace.ApplicationWorkers;
}
public override void Generate(GeneratorExecutionContext context, List data)
{
var i = 1;
i = i + 1;
context.AddSource("ApplicationWorkers", "//Test");
}
public override List Parse(GeneratorExecutionContext context)
{
return new List()
{
new WorkersGeneratorDTO()
{
}
};
}
}
}
И его DTO:
//using Common.Attributes.Entities;
using Common.Attributes.Entities;
using System;
namespace CodeGen.GenerateWorkers
{
///
/// Данные для кодогенерации контроллера
///
public class WorkersGeneratorDTO
{
}
}
Если вы все сделали правильно, то теперь у вас в Application.Workers появится сгенерированный файл:

Поздравляю, вы прошли самую сложную часть кодогенерации:
1) Подключение сторонних проектов\пакетов
2) Сведение всех кодогенераторов в 1 проект
Получившийся после первой части Solution можно взять тут:
https://github.com/ValeriyAndreevichPushkarev/IOT/blob/main/SuperIOT (part1).rar
В следующей публикации мы получим все синтаксические деревья из Domain и напишем все по частям :)