Generación Automática de Clases DTO, Interfaces y Servicios de Aplicación desde Entidades Usando Complementos de Visual Studio y Roslyn

En proyectos que utilizan ABP vNext, es común necesitar convertir clases de entidad en objetos de transferencia de datos (DTOs), como clases de salida e entrada, además de generar enterfaces de servicio de aplicación y sus implementaciones, configuraciones de mapeo y otros archivos. Este trabajo repetitivo puede ser automatizado para mejorar la productividad. Se prseenta un enfoque para crear un complemento de Visual Studio que emplee Roslyn para analizar clases de entidad y generar automáticamente el código requerido.

Creación del Complemento de Visual Studio

Se utiliza el paquete de extensión Extensibility Essentials 2022 para simplificar el desarrollo de complementos. Al crear un proyecto VSIX, se selecciona la plantilla VSIX Project w/Command (Community). A continuación, se define un paquete que registra comandos y una ventana WPF para la interfaz de usuario.

[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
[ProvideMenuResource("Menus.ctmenu", 1)]
[Guid(PackageGuids.CodeGenerationAddOn)]
public sealed class CodeGenPackage : ToolkitPackage
{
    protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress<serviceprogressdata> progress)
    {
        await this.RegisterCommandsAsync();
    }
}</serviceprogressdata>

Se crea un comando que activa la ventana de diálogo al interactuar con el menú contextual de Visual Studio. El archivo VSCT configura la ubicación del comando en el menú.

<CommandTable xmlns="http://schemas.microsoft.com/VisualStudio/2005-10-18/CommandTable">
  <Commands package="CodeGenAddOn">
    <Groups>
      <Group guid="CodeGenAddOn" id="ContextMenuGroup" priority="0x0600">
        <Parent guid="VSStd2KCmdSet" id="IDM_VS_CTXT_SOLNNODE"/>
      </Group>
    </Groups>
    <Buttons>
      <Button guid="CodeGenAddOn" id="GenerateCodeCommand" priority="0x0100" type="Button">
        <Parent guid="CodeGenAddOn" id="ContextMenuGroup"/>
        <CommandFlag>DynamicVisibility</CommandFlag>
        <Strings>
          <ButtonText>Generar Código desde Entidades</ButtonText>
        </Strings>
      </Button>
    </Buttons>
  </Commands>
  <Symbols>
    <GuidSymbol name="CodeGenAddOn" value="{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}">
      <IDSymbol name="ContextMenuGroup" value="0x1020"/>
      <IDSymbol name="GenerateCodeCommand" value="0x0100"/>
    </GuidSymbol>
  </Symbols>
</CommandTable>

Análisis de Código con Roslyn

Roslyn permite analizar código C# sin compilarlo. Se instala el paquete NuGet Microsoft.CodeAnalysis.CSharp y se implementa un caminante sintáctico para extraer información de las clases de entidad.

public class EntityInfoExtractor : CSharpSyntaxWalker
{
    public Dictionary<string, List<PropertyDetails>> Entities { get; } = new();
    public List<UsingDirectiveSyntax> UsingDirectives { get; } = new();

    public override void VisitUsingDirective(UsingDirectiveSyntax node)
    {
        UsingDirectives.Add(node);
    }

    public override void VisitPropertyDeclaration(PropertyDeclarationSyntax node)
    {
        var classNode = node.Parent as ClassDeclarationSyntax;
        if (classNode != null)
        {
            string className = classNode.Identifier.Text;
            if (!Entities.ContainsKey(className))
            {
                Entities[className] = new List<PropertyDetails>();
            }
            Entities[className].Add(new PropertyDetails
            {
                TypeName = node.Type.ToString(),
                PropertyName = node.Identifier.Text,
                Attributes = node.AttributeLists
            });
        }
    }
}

public class PropertyDetails
{
    public string TypeName { get; set; }
    public string PropertyName { get; set; }
    public SyntaxList<AttributeListSyntax> Attributes { get; set; }
}

Generación de Clases DTO

Para generar DTOs, se parsea el código fuente, se extraen las entidades y se construyen nuevas clases con prefijos o sufijos específicos. Se utiliza la API de Roslyn para crear nodos sintácticos.

public void GenerateDtoClasses(string sourceCode, DtoGenerationConfig config)
{
    var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);
    var root = syntaxTree.GetRoot();
    var extractor = new EntityInfoExtractor();
    extractor.Visit(root);

    foreach (var entity in extractor.Entities)
    {
        string dtoName = $"{entity.Key}{config.Suffix}";
        var namespaceDecl = SyntaxFactory.NamespaceDeclaration(
            SyntaxFactory.ParseName($"{config.Namespace}.Contracts"))
            .NormalizeWhitespace();

        var classDecl = SyntaxFactory.ClassDeclaration(dtoName)
            .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword));

        if (!string.IsNullOrEmpty(config.BaseClass))
        {
            classDecl = classDecl.AddBaseListTypes(
                SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName(config.BaseClass)));
        }

        foreach (var prop in entity.Value)
        {
            var propertyDecl = SyntaxFactory.PropertyDeclaration(
                SyntaxFactory.ParseTypeName(prop.TypeName),
                prop.PropertyName)
                .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword))
                .AddAccessorListAccessors(
                    SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
                        .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)),
                    SyntaxFactory.AccessorDeclaration(SyntaxKind.SetAccessorDeclaration)
                        .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)));

            classDecl = classDecl.AddMembers(propertyDecl);
        }

        namespaceDecl = namespaceDecl.AddMembers(classDecl);
        string outputCode = namespaceDecl.NormalizeWhitespace().ToFullString();
        File.WriteAllText(Path.Combine(config.OutputPath, $"{dtoName}.cs"), outputCode);
    }
}

Generación de Servicios de Aplicación

Se crean clases de servicio que heredan de una base, con métodos CRUD estándar. El código se genera dinámicamente basado en las entidades extraídas.

public void GenerateAppServiceClasses(List<string> entityNames, AppConfig config)
{
    var namespaceDecl = SyntaxFactory.NamespaceDeclaration(
        SyntaxFactory.ParseName($"{config.Namespace}.{config.ModuleName}"))
        .NormalizeWhitespace();

    var classDecl = SyntaxFactory.ClassDeclaration($"{config.ModuleName}AppService")
        .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword))
        .AddBaseListTypes(
            SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName("ApplicationService")),
            SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName($"I{config.ModuleName}AppService")));

    foreach (var entity in entityNames)
    {
        string entityName = entity.Replace("_", "");
        var methods = new[]
        {
            CreateMethodDeclaration($"Create{entityName}Async", $"Task<Result>", $"{entityName}Dto input"),
            CreateMethodDeclaration($"Update{entityName}Async", "Task<Result>", $"{entityName}Dto input"),
            CreateMethodDeclaration($"Delete{entityName}Async", "Task<Result>", "Guid id"),
            CreateMethodDeclaration($"Get{entityName}Async", $"Task<{entityName}Dto>", "Guid id"),
            CreateMethodDeclaration($"GetList{entityName}Async", $"Task<List<{entityName}Dto>>", $"{entityName}Query query")
        };

        classDecl = classDecl.AddMembers(methods);
    }

    namespaceDecl = namespaceDecl.AddMembers(classDecl);
    string outputCode = namespaceDecl.NormalizeWhitespace().ToFullString();
    File.WriteAllText(Path.Combine(config.OutputPath, $"{config.ModuleName}AppService.cs"), outputCode);
}

private MethodDeclarationSyntax CreateMethodDeclaration(string name, string returnType, string parameters)
{
    return SyntaxFactory.MethodDeclaration(SyntaxFactory.ParseTypeName(returnType), name)
        .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.AsyncKeyword))
        .AddParameterListParameters(SyntaxFactory.Parameter(SyntaxFactory.Identifier(parameters.Split(' ')[1]))
            .WithType(SyntaxFactory.ParseTypeName(parameters.Split(' ')[0])))
        .WithBody(SyntaxFactory.Block(SyntaxFactory.ParseStatement("throw new NotImplementedException();")));
}

Generación de Interfaces y Configuración de Mapeo

Las interfaces se generan con métodos correspondientes a los servicios. Para el mapeo, se crea una clase de perfil para AutoMapper, con instrucciones de mapeo entre entidades y DTOs.

public void GenerateInterface(List<string> entityNames, InterfaceConfig config)
{
    var namespaceDecl = SyntaxFactory.NamespaceDeclaration(
        SyntaxFactory.ParseName($"{config.Namespace}.{config.ModuleName}.Contracts"))
        .NormalizeWhitespace();

    var interfaceDecl = SyntaxFactory.InterfaceDeclaration($"I{config.ModuleName}AppService")
        .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword));

    foreach (var entity in entityNames)
    {
        string entityName = entity.Replace("_", "");
        var methodSignatures = new[]
        {
            $"Task<Result> Create{entityName}Async({entityName}Dto input);",
            $"Task<Result> Update{entityName}Async({entityName}Dto input);",
            $"Task<Result> Delete{entityName}Async(Guid id);",
            $"Task<{entityName}Dto> Get{entityName}Async(Guid id);",
            $"Task<List<{entityName}Dto>> GetList{entityName}Async({entityName}Query query);"
        };

        foreach (var sig in methodSignatures)
        {
            interfaceDecl = interfaceDecl.AddMembers(
                SyntaxFactory.MethodDeclaration(SyntaxFactory.ParseTypeName(sig.Split(' ')[1]), sig.Split(' ')[2])
                    .AddModifiers(SyntaxFactory.Token(SyntaxKind.AbstractKeyword))
                    .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)));
        }
    }

    namespaceDecl = namespaceDecl.AddMembers(interfaceDecl);
    string outputCode = namespaceDecl.NormalizeWhitespace().ToFullString();
    File.WriteAllText(Path.Combine(config.OutputPath, $"I{config.ModuleName}AppService.cs"), outputCode);
}

public void GenerateMappingProfile(List<string> entityNames, MappingConfig config)
{
    var namespaceDecl = SyntaxFactory.NamespaceDeclaration(
        SyntaxFactory.ParseName($"{config.Namespace}.{config.ModuleName}"))
        .NormalizeWhitespace()
        .AddUsings(SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("AutoMapper")));

    var classDecl = SyntaxFactory.ClassDeclaration($"{config.ModuleName}MappingProfile")
        .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword))
        .AddBaseListTypes(SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName("Profile")));

    var constructorDecl = SyntaxFactory.MethodDeclaration(SyntaxFactory.ParseTypeName(""), $"{config.ModuleName}MappingProfile")
        .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword));

    foreach (var entity in entityNames)
    {
        string entityName = entity.Replace("_", "");
        constructorDecl = constructorDecl.AddBodyStatements(
            SyntaxFactory.ParseStatement($"CreateMap<{entity}, {entityName}Dto>();"),
            SyntaxFactory.ParseStatement($"CreateMap<{entityName}Dto, {entity}>();"));
    }

    classDecl = classDecl.AddMembers(constructorDecl);
    namespaceDecl = namespaceDecl.AddMembers(classDecl);
    string outputCode = namespaceDecl.NormalizeWhitespace().ToFullString();
    File.WriteAllText(Path.Combine(config.OutputPath, $"{config.ModuleName}MappingProfile.cs"), outputCode);
}

Etiquetas: Visual Studio Roslyn ABP C# Code Generation

Publicado el 6-26 03:56