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, 4 de abril de 2017
ASP.NET Core MVCComo sabemos, tradicionalmente los controladores MVC son clases cuyo nombre, según la convención, debe finalizar por “Controller”, como en InvoiceController o CustomerController, y esta convención ha continuado en ASP.NET Core MVC, la edición más reciente del framework.

Sin embargo, desde las primeras versiones del framework la convención era modificable y podíamos adaptarla a nuestras necesidades aprovechando la extensibilidad del framework. De hecho, ya vimos hace muuucho mucho tiempo cómo hacerlo con la versión "clásica" de ASP.NET MVC, pero con la llegada de MVC Core las cosas han cambiado bastante.

En este post vamos a ver cómo modificar la convención de nombrado de controladores a algo más patrio: haremos que éstos puedan llamarse “ControladorDeXYZ”, como en ControladorDeFacturas o ControladorDeClientes. Es decir, si tenemos una clase como la siguiente, pretendemos que una petición hacia "/facturas/index" retorne el texto "ControladorDeFacturas.Index":
public class ControladorDeFacturas : Controller
{
    public IActionResult Index()
    {
        return Content("ControladorDeFacturas.Index");
    }
}
Por supuesto, podríamos hacer que esta clase fuera un controlador simplemente aplicándole el atributo [Controller], pero el objetivo de este post es aprender algo sobre el funcionamiento interno del framework, así que no vamos a quedarnos con esta solución tan sencilla ;)

Pero antes de ponernos a ello, permitidme aclarar que cambiar las convenciones de nombrado de controladores no es muy conveniente porque, aparte de romper el principio de la mínima sorpresa, hay herramientas que podrían dejar de funcionar correctamente, pero sin duda hacerlo ofrece una magnífica ocasión para profundizar un poco en los entresijos del framework ;)

1. Cómo ASP.NET Core MVC descubre los controladores de una aplicación

Cuando una aplicación MVC arranca, el framework analiza la aplicación para localizar los controladores. Para ello, recorre tanto el ensamblado principal (el proyecto MVC propiamente dicho) como los ensamblados vinculados y selecciona las clases que cumplen los requisitos para ser consideradas controladores:
  • Ser públicas
  • No ser abstractas
  • No presentar parámetros genéricos
  • No estar decoradas con el atributo [NonController]
  • Que su nombre acabe por "Controller" o tengan el atributo [Controller]
Nota: Observad que esta última condición, y el hecho de que en ningún sitio se exija heredar de la tradicional clase Controller, es la que hace posible que en ASP.NET Core MVC puedan existir controladores POCO.

Estas condiciones están definidas en ControllerFeatureProvider, una clase definida en el espacio de nombres Microsoft.AspNetCore.Mvc.Core.Controllers del framework, que es la encargada de decidir si un tipo puede o no ser un controlador a través de su método virtual IsController().

2. Cambiando el criterio de descubrimiento por defecto

Seguro que ya sabéis por dónde van los tiros ;) Si queremos hacer que MVC entienda que todas nuestras clases con el nombre "ControladorDeXYZ" son controladores, lo único que tenemos que hacer es escribir una versión personalizada de ControllerFeatureProvider, en cuyo método IsController() introduciremos la lógica que nos interese. Después, haremos que MVC lo tenga en cuenta registrándolo como proveedor de features, y ya tendremos el trabajo hecho.

Veamos a nivel de código en qué queda esto.

En primer lugar, vemos una posible implementación del nuevo feature provider. Vamos a aprovechar el proveedor existente, heredando de ControllerFeatureProvider y sobreescribiendo su método IsController(), de hecho con un código prácticamente idéntico al original, al que sólo añadirmos las condiciones que buscamos:
public class SpanishControllerFeatureProvider : ControllerFeatureProvider
{
    protected override bool IsController(TypeInfo typeInfo)
    {
        if (!typeInfo.IsClass || typeInfo.IsAbstract || !typeInfo.IsPublic 
            || typeInfo.ContainsGenericParameters 
            || typeInfo.IsDefined(typeof(NonControllerAttribute)))
        {
            return false;
        }

        if (!typeInfo.Name.StartsWith(
            "ControladorDe", StringComparison.OrdinalIgnoreCase) 
            && !typeInfo.IsDefined(typeof(ControllerAttribute)))
        {
            return false;
        }
        return true;
    }
}
Tras ello, ya sólo nos falta indicar a MVC que puede utilizar este nuevo proveedor durante el arranque de la aplicación. Esto lo hacemos en el método Configure() de la clase de inicialización de ASP.NET, como sigue:
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc()
       .ConfigureApplicationPartManager(manager =>
       {
          manager.FeatureProviders.Add(new SpanishControllerFeatureProvider());
       });
}
Observad que no estamos eliminando el feature provider original de la colección FeatureProviders, de forma que nuestra aplicación descubrirá tanto los controladores que siguen la convención de nombrado por defecto ("XYZController"), como los que usan la nueva convención ("ControladorDeXYZ").
Tras finalizar el arranque de la aplicación, las clases que sigan nuestra nueva convención, como ControladorDeFacturas o ControladorDeUsuarios ya se encontrarán en el application model, que es la estructura de datos que contiene metainformación sobre el propio sistema, es decir, información sobre sus controladores, acciones, filtros, etc. Si no te suena, puedes echar un vistazo al post Convenciones personalizadas en ASP.NET Core MVC, donde ya hablamos de este tema hace algún tiempo.

3. ¿Problemas en el routing?

Si introducimos el código anterior y añadimos al proyecto el controlador ControladorDeFacturas que vimos algo más arriba, veremos que una petición como "/facturas/index" nos retorna un error 404.¿Por qué?

(Os doy tiempo para pensar… ;D)



Seguro que lo habéis visto claro ;) Por defecto, una petición como "/facturas/index" intentará ser procesada por un controlador llamado "facturas", pero para el sistema no existe un controlador con dicho nombre. Aunque nuestras clases hayan sido identificadas como controladores y estén en el application model, el framework no tiene forma de determinar cuál es el nombre del controlador real porque nos estamos saltando las convenciones estándar, es decir, no son nombres de la forma "XYZController", sino "ControladorDeXYZ".

Según la convención por defecto de ASP.NET Core MVC, el nombre del controlador asociado a una clase como "InvoiceController" será "Invoice"; en cambio, si se trata de un controlador POCO implementado en una clase llamada, por ejemplo, "HelloWorldClass", será el mismo nombre de la clase. Por tanto, en una clase como "ControladorDeFacturas" el nombre de controlador será "ControladorDeFacturas"; por esta razón, para acceder a la acción sí podremos utilizar una ruta como "/ControladorDeFacturas/index".

Por el mismo motivo tampoco funcionarán bien los métodos que utilizan el sistema de routing en sentido contrario, es decir, cuando deseamos generar la URL hacia una acción con helpers como Url.Action() o métodos similares.

Para solucionarlo, sin duda el método más sencillo y directo consiste en aplicar el atributo [Route] a los elementos que nos interese, como en el siguiente ejemplo:
[Route("facturas")]
public class ControladorDeFacturas : Controller
{
    [Route("index")]
    [Route("")]
    public IActionResult Index()
    {
        return Content("ControladorDeFacturas.Index");
    }
}
De esta forma, ya podremos acceder a la acción mediante peticiones dirigidas a rutas como "/facturas" o "/facturas/index". Y también funcionará bien en sentido inverso, puesto que el sistema de routing dispone de toda la información que necesita para generar las URL de acceso a las acciones.

Pero claro, no queremos quedarnos ahí ;) Además de ser una solución tediosa, internamente aún los controladores no tendrían el nombre correcto, y esto podría darnos problemas en otros puntos.

Vamos a ver cómo podemos modificar la forma en que el framework nombra los controladores para que al final el controlador implementado en "ControladorDeXYZ" sea denominado internamente "XYZ", y encaje bien en los esquemas de ruta por defecto.

4. Modificando el nombrado de los controladores

Pues supongo que habrá más fórmulas, pero se me ocurren al menos dos enfoques posibles para conseguir que los controladores sean nombrados apropiadamente, de acuerdo a nuestra nueva convención:
  • Desarrollar una convención personalizada que modifique el nombre del controlador.
  • Modificar la implementación del componente encargado de construir los metadatos de los controladores (ApplicationModelProvider) para que el nombre sea extraído teniendo en cuenta el nuevo criterio.
Veamos cada uno de ellos, que sin duda son nuevas ocasiones para seguir conociendo el framework por dentro ;)

4.1. Creando una convención personalizada

Ya vimos hace algún tiempo el concepto de convenciones en ASP.NET Core MVC. Básicamente se trata de un interesante mecanismo, integrado en el framework, que nos ofrece acceso al application model durante el arranque la aplicación con objeto de que podamos "retocar" la metainformación que la describe.

Dado que en esta metainformación se incluye el propio nombre del controlador, esta puede ser una buena forma modificar la manera en que internamente se nombran los controladores para alinearla con nuestra nueva directiva de nombrado.

Esto podría implementarse así de fácil:
public class SpanishControllerConvention : IControllerModelConvention
{
   public void Apply(ControllerModel controller)
   {
      if (controller.ControllerName
            .StartsWith("ControladorDe", StringComparison.OrdinalIgnoreCase)
            && controller.ControllerName.Length > 13)
      {
          controller.ControllerName = controller.ControllerName.Substring(13);
      }
   }
}
Como se puede observar, la lógica que encontramos en Apply() simplemente modifica el nombre del controlador cuando detecta que cumple nuestra regla de nombrado. Por tanto, un controlador llamado "ControladorDeFacturas" lo dejaremos simplemente en "Facturas", que sería lo correcto.

Para registrar esta convención y hacer que sea aplicada durante el arranque, basta con añadirla a la colección Conventions de las opciones de configuración de MVC en la clase Startup:
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc(opt => 
        opt.Conventions.Add(new SpanishControllerConvention())
    );
}

4.2. Creando un application model provider personalizado

La solución anterior es válida, e incluso diría que recomendable, para conseguir los objetivos que nos hemos propuesto.

Pero como el objetivo es profundizar en el framework, vamos a ver otro enfoque que requiere que nos remanguemos un poco más y nos sumerjamos en las interioridades de ASP.NET Core MVC. En particular, vamos a "puentear" ligeramente el proceso de creación de metainformación sobre controladores en el application model, sobrescribiendo el nombre de controlador que por defecto le asigna el framework para que se atenga a nuestra nueva convención.

La información sobre controladores que es añadida al application model es obtenida a través del método CreateControllerModel() de la clase DefaultApplicationModelProvider del framework. Este método retorna un objeto de tipo ControllerModel que contiene todo tipo de datos sobre el controlador: acciones, filtros, propiedades… y también el nombre del controlador, que es el dato que nos interesa en este momento.

Por tanto, vamos a heredar de DefaultApplicationModelProvider y sobrescribiremos su método CreateControllerModel(), de forma que seamos nosotros los que decidamos el nombre que queremos dar a los controladores:
public class SpanishControllerApplicationModelProvider 
    : DefaultApplicationModelProvider
{
   public SpanishControllerApplicationModelProvider(IOptions<MvcOptions> opt) 
       : base(opt)
   {
   }

   protected override ControllerModel CreateControllerModel(TypeInfo typeInfo)
   {
      var model = base.CreateControllerModel(typeInfo);
      if(model.ControllerName
              .StartsWith("ControladorDe", StringComparison.OrdinalIgnoreCase) 
         && model.ControllerName.Length > 13)
      {
          model.ControllerName = model.ControllerName.Substring(13);
      }
      return model;
    }
}
Como se puede observar, la lógica del nuevo método CreateControllerModel() es bastante sencilla y parecida a la que vimos anteriormente al implementar la clase SpanishControllerConvention. Detectamos que se trata de un controlador que se atiene a nuestra convención, como en "ControladorDeFacturas", y eliminamos el prefijo, dejándolo como "Facturas".

Una vez hecho esto, debemos informar al framework de que debe usar este proveedor en lugar del inicial, por lo que eliminaremos del registro de dependencias la instancia de DefaultApplicationModel e insertaremos nuestro nuevo provider.

El método ConfigureServices() de la clase de inicialización quedaría como sigue:
public void ConfigureServices(IServiceCollection services)
{
   services.AddMvc().ConfigureApplicationPartManager(manager =>
   {
       manager.FeatureProviders.Add(new SpanishControllerFeatureProvider());
   });

   var svc = services.FirstOrDefault(
               s => s.ServiceType == typeof(IApplicationModelProvider));
   if (svc != null)
   {
      services.Remove(svc);       
      services.AddTransient
        <IApplicationModelProvider, SpanishControllerApplicationModelProvider>();
   }
}

5. Y recapitulando un poco…

En este post hemos visto cómo modificar la convención de nombrado por defecto de controladores, de forma que en lugar de usar el tradicional "XYZController" en las clases podamos utilizar "ControladorDeXYZ", y que todo siga funcionando como sería de esperar.

Para ello hemos tenido que:
  • Modificar el proceso de descubrimiento de controladores, haciendo que clases que comiencen por "ControladorDe" sean consideradas controladoras.
  • Modificar la forma de obtención del nombre de controlador y hacerlo consistente con nuestra nueva convención de nombrado, es decir, que el controlador implementado en "ControladorDeXYZ" se denomine internamente "XYZ".
Como comentamos al principio, no es algo que tenga mucha utilidad en el día a día, pero espero que os haya resultado interesante para comprender mejor cómo funcionan las cosas por dentro y conocer algunos puntos de extensibilidad del framework.

Publicado en Variable Not Found.

Aún no hay comentarios, ¡sé el primero!