Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 22 de mayo de 2018
ASP.NET CoreRecordaréis que hace un par de semanas iniciamos un pequeño viaje en el que nuestro objetivo era renderizar una vista Razor de forma totalmente manual, desde procesos externos a ASP.NET Core y, por supuesto, introduciendo el menor número posible de dependencias hacia este framework. Ya comentamos entonces que este proceso consistía básicamente en:
  • Procesar la plantilla CSHTML y obtener código C# que permita renderizarla.
  • A continuación, compilar dicho código para obtener un componente ejecutable.
  • Por último, ejecutar el código para renderizar la plantilla.
En el primer post vimos cómo implementar el primero de los puntos, por lo que ahora nos centraremos en los siguientes, y conseguiremos el objetivo pretendido.

¡Seguimos! ;)

Cómo compilar el código C# de la plantilla

En el artículo anterior fuimos capaces de procesar una plantilla Razor (.cshtml) y generar código C# desde ella utilizando la clase RazorGenerator que creamos a lo largo del mismo. En aquél momento, por tanto, ya podíamos ejecutar un código como el siguiente:
var generator = new RazorGenerator("Templates");
var sourceCode = generator.GenerateCode<string>("Hello"); // Templates/Hello.cshtml
El siguiente paso es compilar el código fuente que nos devuelve el método GenerateCode() en forma de cadena de caracteres. Nos valdremos para ello de la magia de Roslyn, por lo que lo primero que debemos hacer es añadir a nuestra aplicación el paquete NuGet Microsoft.CodeAnalysis.CSharp.

A continuación crearemos una clase como la siguiente, que ya deja entrever hacia dónde nos dirigimos. El método CreateTemplateInstance() recibe el código fuente y el nombre de la plantilla para compilarlo y retorna una instancia de la plantilla lista para ser utilizada. Llamaremos a esta clase RazorCompiler:
public class RazorCompiler
{
    public static TemplateBase<TModel> CreateTemplateInstance<TModel>(
        string sourceCode, string templateName)
    {
        var generatedAssembly = GenerateAssembly<TModel>(sourceCode, templateName);
        return LoadTemplateClassFromAssembly<TModel>(generatedAssembly, templateName);
    }

    private static string GenerateAssembly<TModel>(string sourceCode, string templateName)
    {
        // Compiles the code and returns the path to the generated assembly
        ...
    }

    private static TemplateBase<TModel> LoadTemplateClassFromAssembly<TModel>(
        string assemblyFilePath, string templateName)
    {
        // Loads the assembly and creates an instance of TemplateBase<TModel>
        ...
    }
}
Echemos primero un vistazo a GenerateAssembly(), cuyo objetivo se centra exclusivamente en compilar código C# y generar un ensamblado (.DLL):
private static string GenerateAssembly<TModel>(string sourceCode, string templateName)
{
    var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);
    var coreAssemblyPath = typeof(object).Assembly.Location;
    var coreAssemblyFolder = Path.GetDirectoryName(coreAssemblyPath);
    var references = new[]
    {
        // Add required framework references. See https://stackoverflow.com/a/47756437
        MetadataReference.CreateFromFile(coreAssemblyPath),
        MetadataReference.CreateFromFile(Path.Combine(coreAssemblyFolder, "System.Runtime.dll")),
        MetadataReference.CreateFromFile(Path.Combine(coreAssemblyFolder, "NetStandard.dll")),

        // We need to use TemplateBase<>, so let's add a reference to its assembly
        MetadataReference.CreateFromFile(typeof(TemplateBase<>).Assembly.Location),
        // And also we need to add the assembly where TModel is defined
        MetadataReference.CreateFromFile(typeof(TModel).Assembly.Location),
    };

    var options = new CSharpCompilationOptions(
        outputKind: OutputKind.DynamicallyLinkedLibrary,
        optimizationLevel: OptimizationLevel.Release
    );

    var className = $"{typeof(TemplateBase<>).Namespace}.Templates.{templateName}";
    var compilation = CSharpCompilation.Create(className,
        syntaxTrees: new[] { syntaxTree },
        references: references,
        options: options);

    var currentAssemblyPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
    string outputFile = Path.Combine(currentAssemblyPath, $"{className}.dll");
    var result = compilation.Emit(outputFile);
    if (!result.Success)
    {
        throw new Exception(
            "Compilation error: " + string.Join(Environment.NewLine, result.Diagnostics)
        );
    }
    return outputFile;
}
Como se puede observar, el método GenerateAssembly() recibe como argumentos el código fuente que debe compilar y el nombre de la clase que vamos a generar (que, como ya convenimos en el post anterior, coincidiría con el de la plantilla). El código fuente lo parseamos y creamos con él un árbol sintáctico que luego compilamos haciendo uso un objeto CSharpCompilation configurado para que emita un ensamblado en la carpeta de binarios del proyecto.

El resultado de la ejecución de este método será un ensamblado que seguirá la convención que estamos definiendo. Por ejemplo, para una plantilla llamada Test.cshtml, se generará el archivo <Root>.Templates.Test.dll, siendo <Root> el espacio de nombres donde se ha definido la clase TemplateBase<>.

Cómo cargar en memoria el ensamblado generado

La ruta del ensamblado generado es retornada y utilizada como entrada para la llamada al método LoadTemplateClassFromAssembly(), que lo cargará en memoria y obtendrá de él una instancia de TemplateBase<T>, donde T es el tipo de la propiedad Model de la plantilla. Su código es casi trivial:
private static TemplateBase<TModel> LoadTemplateClassFromAssembly<TModel>(
    string assemblyFilePath, string templateName)
{
    var asm = Assembly.LoadFile(assemblyFilePath);
    var templateType = asm.GetType(
        $"{typeof(TemplateBase<>).Namespace}.Templates.{templateName}"
    );
    var template = (TemplateBase<TModel>)Activator.CreateInstance(templateType);
    return template;
}

Cómo ejecutar la plantilla y obtener el resultado de la renderización

Por fin llegamos a la parte más sencilla ;) Una vez que ya tenemos la instancia de la plantilla, sólo tenemos que cargar llamar al método ExecuteAsync() suministrándole el TextWriter que éste debe utilizar para emitir el resultado de la ejecución de la plantilla y el objeto del modelo que utilizará ésta para renderizarse.

O expresado en forma de código, sería así de sencillo:
...
var templateInstance = RazorCompiler.CreateTemplateInstance<TModel>(code, templateName);
var sb = new StringBuilder();
var output = new StringWriter(sb);

templateInstance.ExecuteAsync(model, output);
var result = sb.ToString(); // Voila!

¿Lo vemos todo junto?

El método RenderTemplate() que veremos a continuación implementa el proceso completo: usará nuestra clase RazorGenerator para parsear la plantilla y generar el código C#, y la clase RazorCompiler para obtener la instancia de la clase generada anteriormente. Finalmente, ejecutamos la plantilla sobre un StringBuilder, del cual obtenemos el resultado del renderizado:
public static string RenderTemplate<TModel>(string templateName, TModel model)
{
    var generator = new RazorGenerator("Templates");
    var code = generator.GenerateCode<TModel>(templateName);
    var template = RazorCompiler.CreateTemplateInstance<TModel>(code, templateName);
    var sb = new StringBuilder();
    var output = new StringWriter(sb);
    template.ExecuteAsync(model, output);
    return sb.ToString();
}
Y unos ejemplos de uso de este método podrían ser los siguientes:
class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Templates/Hello.cshtml, with string model");
        Console.WriteLine(RenderTemplate("Hello", "Peter"));
        Console.ReadLine();

        Console.WriteLine("Templates/Multiplication.cshtml, with int model");
        Console.WriteLine(RenderTemplate("Multiplication", 5));
        Console.ReadLine();

        Console.WriteLine("Templates/TemplateWithModel.cshtml with custom model");
        var templateModel = new MyModel { Name1 = "John", Name2 = "Peter" };
        Console.WriteLine(RenderTemplate("TemplateWithModel", templateModel));
        Console.ReadLine(); 
    }

    public static string RenderTemplate<TModel>(string templateName, TModel model)
    {
        ... // As seen before
    }
}

public class MyModel
{
    public string Name1 { get; set; }
    public string Name2 { get; set; }
}

Conclusión

¡Y esto es todo! Con el código que hemos visto ya conseguimos nuestro objetivo, que era renderizar plantillas Razor desde una aplicación de consola o cualquier tipo de proyecto no ASP.NET Core. Pero lo más importante es que durante su creación hemos aprendido un poco más del funcionamiento interno de Razor :)

Obviamente el código que hemos visto aquí es bastante mejorable, pero creo que ofrece un buen punto de partida para desarrollar soluciones más complejas. Os apunto un par de ideas:
  • En este momento, cada vez que renderizamos la plantilla se ejecuta el proceso completo: generar código, compilarlo, cargar el ensamblado y renderizar la plantilla. Sin embargo, si el ensamblado ya existe y el archivo no ha sido modificado con anterioridad, podríamos saltarnos tanto la generación de código como la compilación e ir directamente a cargar el ensamblado (si ha sido cargado previamente) y al renderizado.
     
  • Otra mejora que podemos implementar, aunque sería bastante más complejo, es la recompilación automática de plantillas en tiempo de ejecución. Esto lo podríamos conseguir detectando si la plantilla ha sido modificada después de generar el ensamblado (comparando fechas o mediante hashes), y volviendo a compilarla. La dificultad aquí reside en que si un ensamblado está en uso no podemos sobrescribirlo, por lo que deberíamos implementar alguna suerte de shadow copy o usar alguna estrategia de nombrado que evite esta sobrescritura.
¡Espero que os haya resultado interesante!

Publicado en Variable not found.

1 comentario:

Unknown dijo...

Muy interesante, me recuerda a cosas como poder crear una ventana de winforms con Xaml, o poder "compilar" código Xaml en tiempo de ejecuión.