Это - третья публикация в серии DDD и кодогенерация. (первая часть). В этой статье мы сгенерируем код класса для хранения всех данных запроса, код MVC контроллера.

Правильное подключение nuget пакетов

Да, отображение сгенерированных файлов в проекте еще не дает гарантии того, что эти файлы используются при сборке.

Выглядит это обычно вот так:

Файл сгенерирован, однако при сборке у partial класса нет нужных полей\методов. В файле они есть.
Файл сгенерирован, однако при сборке у partial класса нет нужных полей\методов. В файле они есть.

Проблема оказалась в подключении nuget пакетов. Не смотря на официальный cookbook, подключать nuget в кодогенерацию следует вот так:

	<ItemGroup>
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.9.0" PrivateAssets="all" GeneratePathProperty="true" />
		<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.0.0" PrivateAssets="all" GeneratePathProperty="true" />
		<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.5" PrivateAssets="all" GeneratePathProperty="true" />

		<PackageReference Include="Newtonsoft.Json" Version="12.0.1" PrivateAssets="all" GeneratePathProperty="true" />
		<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> 
		<None Include="$(PkgNewtonsoft_Json)\lib\netstandard2.0\*.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
	</ItemGroup>
	
	<PropertyGroup>
		<GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
	</PropertyGroup>

	<Target Name="GetDependencyTargetPaths">
		<ItemGroup>
			<!-- <TargetPathWithTargetPlatformMoniker Include="$(PKGCsvTextFieldParser)\lib\netstandard2.0\CsvTextFieldParser.dll" IncludeRuntimeDependency="false" />
			<TargetPathWithTargetPlatformMoniker Include="$(PKGHandlebars_Net)\lib\netstandard2.0\Handlebars.dll" IncludeRuntimeDependency="false" />-->
			<TargetPathWithTargetPlatformMoniker Include="$(PkgNewtonsoft_Json)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
		</ItemGroup>
	</Target>

Без GetTargetPathDependsOn файлы генерируются и отображаются в VisualStudio, но в компиляцию не попадают (по причине того, что генератор вдруг не отрабатывает).

Итоговый результат

Описание конечных точек:

using Domain.Common.Generation.WebApiMethod.Attributes;
using Domain.Common.Generation.WebApiWithBulkInsert.Interfaces;

namespace Domain.Entities.RequestEntities.MachineOne.Alert
{
    [WebApiMethod(Endpoint = "/MachineOne/alert", Methods = WebApiMethodRequestTypes.Post)]
    internal class MachineOneRequestAlert : IWebApiWithBulkInsert
    {
        [WebApiMethodParameterFromBody()]
        public AlertBodyObject alert { get; set; }
    }
}
using Domain.Common.Generation.WebApiMethod.Attributes;
using Domain.Common.Generation.WebApiWithBulkInsert.Interfaces;

namespace Domain.Entities.RequestEntities.MachineOne.State
{
    [WebApiMethod(Endpoint = "/MachineOne/state", Methods = WebApiMethodRequestTypes.Get)]
    internal class MachineOneRequestState : IWebApiWithBulkInsert
    {
        [WebApiMethodParameterFromUri(ParameterName = "stateObject")]
        public StateUriObject state { get; set; }
    }
}

Вот такой красивый сгенерированный код конечных точек WebApi, записывающий все в шину. C IP источника запроса и датой.

using Microsoft.AspNetCore.Mvc;

using Domain.Common.Interfaces.Infrastructure.MessageBus;

using Domain.Entities.RequestEntities.MachineOne.State;
using Domain.Entities.RequestEntities.MachineOne.Alert;

namespace Infrastructure.Web.Controllers
{
    //[ApiController]
    public partial class GeneratedWebController : ControllerBase
    {
        
        [HttpGet("/MachineOne/state")]
        public IActionResult GetMachineOneRequestState([FromServices] ILogger logger,[FromServices] IMessageBus busService, [FromQuery]StateUriObject stateObject)
        {
            try
            {
                
                GeneratedMachineOneRequestStateRequestObject request = new GeneratedMachineOneRequestStateRequestObject()
                {
                    stateObject = stateObject,

                    SourceIp = Request.HttpContext.Connection.RemoteIpAddress.ToString(),
                    Date = DateTime.Now
                };
            
            

                busService.Send(request, MessageBus.WebApiBulk).Wait();
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "WebAPIWithBulkInsert", Request);
            }
           
            return Ok();
        }

        [HttpPost("/MachineOne/alert")]
        public IActionResult GetMachineOneRequestAlert([FromServices] ILogger logger,[FromServices] IMessageBus busService, [FromBody]AlertBodyObject alert)
        {
            try
            {
                
                GeneratedMachineOneRequestAlertRequestObject request = new GeneratedMachineOneRequestAlertRequestObject()
                {
                    alert = alert,

                    SourceIp = Request.HttpContext.Connection.RemoteIpAddress.ToString(),
                    Date = DateTime.Now
                };
            
            

                busService.Send(request, MessageBus.WebApiBulk).Wait();
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "WebAPIWithBulkInsert", Request);
            }
           
            return Ok();
        }

    }
}

А так же классы, сохраняющие информацию о запросах. Эти классы мы складываем в шину, читаем из шины и ложем в БД.

using System;

using Domain.Entities.RequestEntities.MachineOne.State;
using Domain.Entities.RequestEntities.MachineOne.Alert;

namespace Infrastructure.Web.Controllers
{
    
    public class GeneratedMachineOneRequestStateRequestObject
    {
        
    
        
        public StateUriObject stateObject {get;set;}

    
        public DateTime Date {get;set;}

        public string SourceIp {get;set;}
    }


    public class GeneratedMachineOneRequestAlertRequestObject
    {
        
        public AlertBodyObject alert {get;set;}

    
        
    
        public DateTime Date {get;set;}

        public string SourceIp {get;set;}
    }


}

Генерация

Из общих рекомендаций следует отметить:

1) Не пишите все в 1 методе - да, это написано у Стива Макконнелла. Разбивайте генерацию методов, метода, параметров, присвоение.

2) Используйте интерполируемые строки - да, строки в стиле

@$"
using Microsoft.AspNetCore.Mvc;

using Domain.Common.Interfaces.Infrastructure.MessageBus;
{GenerateUsings(data)}

namespace Infrastructure.Web.Controllers
{{
    //[ApiController]
    public partial class GeneratedWebController : ControllerBase
    {{
        {GenerateMethods(data)}
    }}
}}
"

читается очень легко и просто. Не забывайте про отступы.

3) Добавляйте префикс Generated - да, мы сделали отличный проект, где информация из доменных сборок доступна всюду, а генераторы запускаются только в нужных местах. Однако у нас уже есть public DTO, и их будет много. Да и простых Internal классов будет тоже много. Чтобы отгородить эту кучу классов следует добавлять префикс Generated.

4) Работа со строками предпочтительна - это гораздо проще, чем делать код как-либо иначе. И переводить готовые решения тоже гораздо проще используя строки. Даже если вы будете использовать что-то для генерации кода - помните, что написав 500 строк и сделав нормальный класс в нормальном namespace с нормальным методом придется писать еще тело метода. И отлаживать это все.

Код сканера:
using CodeGen.GeneratorBase;
using CodeGen.GeneratorBase.Context;
using CodeGen.GeneratorBase.FileManager;
using CodeGen.Utils.Scan;
using Domain.Common.Generation.WebApiMethod.Attributes;
using Domain.Common.Generation.WebApiWithBulkInsert.Interfaces;
using System.Collections.Generic;
using System.Linq;

namespace CodeGen.Generators.WebApiWithBulkInsert
{
    internal class RequestEntityScanner : ICodeGeneratorScanner<RequestEntityGeneratorDTO>
    {
        public RequestEntityScanner()
        {
        }

        public GeneratedFileInfo GetDescription(RequestEntityGeneratorDTO data)
        {
            throw new System.NotImplementedException();
        }

        public List<RequestEntityGeneratorDTO> Scan(GenerationContext projectContext)
        {
            var result = new List<RequestEntityGeneratorDTO>();

            //Получаем все типы с интерфейсом IRequestEntity
            var items = projectContext.GetAllClassesWithInterface<IWebApiWithBulkInsert>();

            foreach (var item in items)
            {
                //Получаем атрибут с указанием Endpoint-а и Http метода
                WebApiMethodAttribute requestAttr = item.GetAttribute<WebApiMethodAttribute>();

                //Получаем все параметры приходящие в запросе
                var uriParamsRaw = item.Properties.Where(i=>i.GetAttribute<WebApiMethodParameterFromUriAttribute>()!=null).ToList();
                var bodyParamsRaw = item.Properties.Where(i => i.GetAttribute<WebApiMethodParameterFromBody>() != null).ToList();

                
                //Добавляем все в DTO
                result.Add(new RequestEntityGeneratorDTO()
                {
                    defaultPath = requestAttr.Endpoint,
                    methods = requestAttr?.Methods ?? WebApiMethodRequestTypes.Get,

                    //requestEntityType = item,
                    bodyParam = bodyParamsRaw.Select(i => new RequestEntityParam()
                    {
                        Name = i.Name,
                        UriNameParameter = i.Name,
                        Parameter = i.Type
                    })
                    .ToList(),

                    uriParameters = uriParamsRaw.Select(i => new RequestEntityParam()
                    {
                        Name = i.Name,
                        UriNameParameter = i.GetAttribute<WebApiMethodParameterFromUriAttribute>().ParameterName,
                        Parameter = i.Type
                    })
                    .ToList(),

                    requestEntityType = item
                });
            }

            return result;
        }
    }
}
Код генератора WebApi:
using CodeGen.GeneratorBase;
using CodeGen.GeneratorBase.Context;
using CodeGen.Utils.Scan;
using CodeGeneration.GeneratorBase;
using Domain.Common.Generation.WebApiMethod.Attributes;
using System.Collections.Generic;

namespace CodeGen.Generators.WebApiWithBulkInsert.Infrastructure.Web
{
    class RequestEntityWebGenerator : CodeGeneratorBase<RequestEntityGeneratorDTO>
    {
        private ICodeGeneratorScanner<RequestEntityGeneratorDTO> scanner;
        public RequestEntityWebGenerator() 
        {
            place = GeneratorRunPlace.InfrastructureWeb;
            scanner = new RequestEntityScanner();
        }
        
        public override void Generate(GenerationContext context, List<RequestEntityGeneratorDTO> data)
        {
            //Добавляем шапку
            string txtExample = $@"
using Microsoft.AspNetCore.Mvc;

using Domain.Common.Interfaces.Infrastructure.MessageBus;
{GenerateUsings(data)}

namespace Infrastructure.Web.Controllers
{{
    //[ApiController]
    public partial class GeneratedWebController : ControllerBase
    {{
        {GenerateMethods(data)}
    }}
}}
";

            context.AddSource("Generated_WebApiWithBulkInsert_WebControllers", txtExample);
        }

        //Генерируем методы WebAPI
        private string GenerateMethods(List<RequestEntityGeneratorDTO> data)
        {
            var txtExample = "";

            foreach (var item in data)
            {
                txtExample += GenerateMethod(item)+"\r\n";
            }

            return txtExample;
        }

        //Генерируем метод WebApi
        private string GenerateMethod(RequestEntityGeneratorDTO item)
        {
            var txtExample = "";

            //Добавляем атрибут к методу
            if (item.methods == WebApiMethodRequestTypes.Get)
                txtExample += $@"
        [HttpGet(""{item.defaultPath}"")]";
            else if (item.methods == WebApiMethodRequestTypes.Post)
                txtExample += $@"
        [HttpPost(""{item.defaultPath}"")]";

            //Делаем метод
            txtExample += $@"
        public IActionResult Get{item.requestEntityType.Name}([FromServices] ILogger logger,[FromServices] IMessageBus busService, {GenerateParameters(item)})
        {{
            try
            {{
                {GenerateBody(item)}

                busService.Send(request, MessageBus.WebApiBulk).Wait();
            }}
            catch (Exception ex)
            {{
                logger.LogError(ex, ""WebAPiWithBulkInsert"", Request);
            }}
           
            return Ok();
        }}";

            return txtExample;
        }

        //Добавляем using-и
        private string GenerateUsings(List<RequestEntityGeneratorDTO> data)
        {
            var txtExample = "";
            foreach (var item in data)
            {
                foreach (var uri in item.uriParameters)
                    txtExample += $"\r\nusing {uri.Parameter.Namespace};";

                foreach (var body in item.bodyParam)
                    txtExample += $"\r\nusing {body.Parameter.Namespace};";

            }
            
            return txtExample;
        }

        //Генерируем тело метода
        private object GenerateBody(RequestEntityGeneratorDTO data)
        {
            return $@"
                Generated{data.requestEntityType.Name}RequestObject request = new Generated{data.requestEntityType.Name}RequestObject()
                {{
                    {GenerateAssigns(data)}
                    SourceIp = Request.HttpContext.Connection.RemoteIpAddress.ToString(),
                    Date = DateTime.Now
                }};
            
            ";
        }

        //Генерируем присваивание
        private string GenerateAssigns(RequestEntityGeneratorDTO data)
        {
            string result = "";

            foreach (var item in data.uriParameters)
            {
                result += $"{item.UriNameParameter} = {item.UriNameParameter},\r\n";
            }

            foreach (var item in data.bodyParam)
            {
                result += $"{item.UriNameParameter} = {item.UriNameParameter},\r\n";
            }

            return result;
        }

        //Генерируем строку параметров
        private object GenerateParameters(RequestEntityGeneratorDTO data)
        {
            var result = "";
            foreach(var item in data.uriParameters)
            {
                result += "[FromQuery]";
                result += item.Parameter.Name;
                result += " " + item.UriNameParameter + ", ";
            }

            foreach (var item in data.bodyParam)
            {
                result += "[FromBody]";
                result += item.Parameter.Name;
                result += " " + item.UriNameParameter + ", ";
            }

            return result.Substring(0, result.Length - 2);
        }

        public override List<RequestEntityGeneratorDTO> Parse(GenerationContext projectContext)
        {
            return scanner.Scan(projectContext);
        }
    }
}
И код генератора DTO для сохранения данных запроса:
using CodeGen.GeneratorBase;
using CodeGen.GeneratorBase.Context;
using CodeGen.Utils.Scan;
using CodeGeneration.GeneratorBase;
using System.Collections.Generic;

namespace CodeGen.Generators.WebApiWithBulkInsert.Application.Common
{
    class RequestEntityObjectWebGenerator : CodeGeneratorBase<RequestEntityGeneratorDTO>
    {
        private ICodeGeneratorScanner<RequestEntityGeneratorDTO> scanner;
        public RequestEntityObjectWebGenerator() 
        {
            place = GeneratorRunPlace.ApplicationCommon;
            scanner = new RequestEntityScanner();
        }
        
        public override void Generate(GenerationContext context, List<RequestEntityGeneratorDTO> data)
        {
            //Добавляем шапку
            string txtExample = $@"
using System;
{GenerateUsings(data)}

namespace Infrastructure.Web.Controllers
{{
    {GenerateClasses(data)}
}}
";

            context.AddSource("Generated_WebApiWithBulkInsert_RequestDTOs", txtExample);
        }

        private string GenerateClasses(List<RequestEntityGeneratorDTO> data)
        {
            var txtExample = "";

            foreach (var item in data)
            {
                txtExample += GenerateClass(item)+"\r\n";
            }

            return txtExample;
        }

        private string GenerateClass(RequestEntityGeneratorDTO item)
        {
            var txtExample = $@"
    public class Generated{item.requestEntityType.Name}RequestObject
    {{
        {GenerateProperties(item)}
    
        {GenerateFields(item)}
    
        public DateTime Date {{get;set;}}

        public string SourceIp {{get;set;}}
    }}
";
            return txtExample;
        }

        private object GenerateFields(RequestEntityGeneratorDTO data)
        {
            var result = "";

            foreach (var item in data.uriParameters)
            {
                result += @"
        public ";
                result += item.Parameter.Name;
                result += " " + item.UriNameParameter + " {get;set;}\r\n";
            }

            return result;
        }

        private object GenerateProperties(RequestEntityGeneratorDTO data)
        {
            var result = "";

            foreach (var item in data.bodyParam)
            {
                result += @"
        public ";
                result += item.Parameter.Name;
                result += " " + item.UriNameParameter + " {get;set;}\r\n";
            }

            return result;
        }

        private string GenerateUsings(List<RequestEntityGeneratorDTO> data)
        {
            var txtExample = "";
            foreach (var item in data)
            {
                foreach (var uri in item.uriParameters)
                    txtExample += $"\r\nusing {uri.Parameter.Namespace};";

                foreach (var body in item.bodyParam)
                    txtExample += $"\r\nusing {body.Parameter.Namespace};";

            }
            
            return txtExample;
        }

        public override List<RequestEntityGeneratorDTO> Parse(GenerationContext projectContext)
        {
            return scanner.Scan(projectContext);
        }
    }
}

Что добавили еще

Код DTO для работы в Application (запись и чтение из шины) вынесен в Application.Common.

Так же добавлен интерфейс для работы с шинами (и проект Infrastructure.MessageBus).

А что с рефлексией?

По совету@onets(https://habr.com/ru/articles/542300/) убрал получение данных через рефлексию. Оказалось информацию о типах (с атрибутами и методами) можно получать через глобальный Namespace компиляции. Даже тех типов, которые не public. Обертки вокруг информации о типах оставил, т.к. гораздо удобнее и читабельней.

Так что теперь даже @IvanG(https://habr.com/ru/articles/906778/comments/#comment_28260784) должен быть доволен. (И, да, у нас все генераторы для всего Solution в одном проекте).

Так же подобная работа со сборками уже позволяет реализовать генераторы, создающие описания для других генераторов. (См. часть 2 - некоторые генераторы можно представить как генераторы описаний для других генераторов. И убрать дублирование кода)

Итог

Наши конечные точки генерируются и их видно в Swagger

Swagger
Swagger

Вызвать Api еще нельзя - мы не сделали работу с шинами и Worker-ы, поэтому все запросы будут падать из-за отсутствующих сервисов.

Однако у нас уже есть база даже для создания CRUD по атрибутам в БД. И даже база для создания CRUD через CQRS :).

Даже не смотря на то, что основной код еще не написан, у нас уже генерируется в 10 раз больше кода, чем пишется (20 строк при задании точек WebApi против 200 в контроллере\DTO).

Файл проекта можно взять тут

В следующей части мы разберемся с шинами и воркерами (и их регистрации в контейнере).

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


  1. AlexCatLeva
    13.06.2025 17:21

    Ddd, это когда у разработчиков множество свободного времени, а у компании много деняк


    1. ValeriyPus Автор
      13.06.2025 17:21

      Да, без DDD бизнес-логика размазывается, появляется дублирование кода, а при поддержке все эти копипасты еще получают различное поведение.


  1. IvanG
    13.06.2025 17:21

    Ничего себе в статье тэгнут, но кстати это никак не уведомляется на Хабре, и контекста читателям не даёт, почему кого-то это касается, лучше дать ссылку на коммент.

    Надо будет пример покрутить на досуге, сейчас проблем решаемых генерацией уже нет, но для развития на будущее полезно.


    1. ValeriyPus Автор
      13.06.2025 17:21

      Бойлерплейт же - основная проблема, решаемая генерацией.

      А все, что не объекты и флоу - boilerplate (увы).