[ASP.NET MVC] Discriminar acciones según el nombre de los parámetros

El caso que voy a exponer hoy está “basado en hechos reales”, como las películas de Antena 3. Me encontré con la necesidad de ofrecer dos rutas como:

http://mi.com/Form/Show?url=http://geeks.ms

http://mi.com/Form/Show?name=Geeks

Es decir, para quien usa la página, nuestra acción Show puede recibir una URL o bien el nombre de una página que ya tengamos previamente guardada. Son las dos formas de acceder al servicio. Por poner algo más de contexto, puede imaginar el lector un servicio como Google Mobilizer (transformar una página para facilitar su lectura en dispositivos móviles) mezclado con un gestor de marcadores (bookmarks). El uso de la misma ruta en ambos casos es una decisión de diseño que no depende de nosotros.

Este diseño admite dos posibles planteamientos en MVC:

  • Una única acción con dos parámetros opcionales, url y name.
    • Según el parámetro dado, elegir un comportamiento u otro.
    • Decidir qué hacer si nos pasan los dos parámetros (por ejemplo, dar un error).
  • Dos acciones separadas, una con un parámetro url y otra con name.
    • Pro: podemos organizar mejor nuestro código (en cada acción lo suyo).
    • Pro: la decisión la hace MVC, no necesitamos discriminar según los parámetros recibidos. Queda más natural.
    • Contra: no es soportado de forma nativa en MVC 3, como veremos.

Como el código de ambas acciones no tiene apenas nada en común, me gustaba más la segunda opción, por lo que vamos a ver si podemos ir salvando los obstáculos que nos encontremos en el desarrollo del ejemplo. Para comenzar:

  1. Creamos un nuevo proyecto ASP.NET MVC 3 de nombre SelectingActionByArgumentsName.
  2. Usamos la plantilla Vacía (Empty) y motor de vistas Razor (View engine).
  3. Añadimos un nuevo controlador: clic derecho en la carpeta Controllers, y elegimos Agregar (Add) > Controller.
  4. Le llamamos FormController, y lo creamos vacío (Empty controller).

Ya tenemos el proyecto listo para incluir nuestras dos acciones. Bajo la acción Index que se genera siempre, añadimos las dos nuestras, devolviendo sólo un contenido (para simplificar):

public ActionResult Show(string url)
{
    return Content("Loaded from url");
}

public ActionResult Show(string name)
{
    return Content("Loaded from name");
}

Y encontramos el primer obstáculo:

C# no admite dos métodos con la misma firma en una clase.

Evidentemente, aunque el nombre de los parámetros sea distinto, son del mismo tipo, por lo que C# (no ASP.NET MVC, sino el compilador) nos dará un error. Contra esto, poco podemos hacer, hay que cambiar algo. Pero existe una solución sencilla: añadir un parámetro opcional. Esto hará que los métodos sean diferentes. Por ejemplo, vamos a añadir un parámetro opcional save a la acción que recibe una url:

http://mi.com/Form/Show?url=http://geeks.ms&saveAs=Geeks

Este parámetro indica que queremos que se guarde la dirección con ese nombre. De hecho, hemos aprovechado para añadir funcionalidad, pero en la práctica podríamos haber añadido un parámetro sin utilidad y no usarlo nunca (su única utilidad sería permitir que el compilador acepte nuestros dos métodos de forma válida). Y lo haremos opcional, por lo que la URL inicial seguirá funcionando. Así que cambiamos la primera acción por:

public ActionResult Show(string url, string saveAs = "")

Y ya tenemos el primer obstáculo salvado: el compilador no se queja. Ya podemos ejecutar. Pero si navegamos a (en xxx irá el puerto de vuestro servidor web):

http://localhost:xxx/Form/Show?name=Geeks

Recibimos un error ahora de ASP.NET MVC (segundo obstáculo):

The current request for action ‘Show’ on controller type ‘FormController’ is ambiguous between the following action methods:
System.Web.Mvc.ActionResult Show(System.String, System.String) on type SelectingActionByArgumentsName.Controllers.FormController
System.Web.Mvc.ActionResult Show(System.String) on type SelectingActionByArgumentsName.Controllers.FormController

ASP.NET MVC no sabe elegir qué acción utilizar. ¿Pero no hemos puesto name en nuestra URL? Pues usa la acción que tiene un argumento name, so tonto (no sé si vosotros también soléis insultar a la pantalla). Pero no: ASP.NET MVC no usa los nombres de los argumentos para elegir qué acción llamar durante el proceso de enrutado (por defecto, que como casi todo en MVC 3, esto puede cambiarse). Ojo, sí usa los nombres después para asignar los parámetros, pero eso es cuando ya ha elegido la acción; es para elegirla cuando no los considera. Sólo sus tipos. Y usando sólo los tipos, se encuentra con esa ambigüedad.

Pero como hemos dicho, casi todo en MVC 3 puede cambiarse, y además de diferentes formas. En este caso, vamos a usar una solución vista en StackOverflow, aunque modificada para hacerla más genérica, dirigida a cambiar el mecanismo de selección de acciones (sí, ese que hemos dicho que se basa en los tipos) para que considere también los nombres de los argumentos. Y además vamos a hacerlo mediante un atributo para que sólo afecte a las acciones donde lo necesitemos. Para esto, sólo debemos crear una clase que extienda de ActionMethodSelectorAttribute y sobreescribir el método IsValidForRequest, que es quien realizará la comprobación.

public class SelectByArgumentNamesAttribute : ActionMethodSelectorAttribute {
    public override bool IsValidForRequest(ControllerContext controllerContext, 
                                           MethodInfo methodInfo) {
        return methodInfo.GetParameters()
                .All(pi => pi.IsOptional 
                        || controllerContext.HttpContext.Request[pi.Name] != null);
    }
}

Un detalle aclaratorio: no tenemos que comprobar el nombre del método, ya que esto lo hace ASP.NET MVC antes de llamar a esta validación. Sólo tenemos que comprobar si, según los datos de la petición (Request), este método (methodInfo) es el apropiado o no. Por lo que comprobamos que todos los argumentos del método vienen en la Request (con su nombre), salvo que sean opcionales.

Ahora sólo tenemos que usar este atributo en las acciones conflictivas (en ambas). Por ejemplo:

[SelectByArgumentNames]
public ActionResult Show(string url, string saveAs = "") {
…

Tras usar el atributo en las dos acciones, ya podemos probar a navegar a las URL:

http://localhost:xxx/Form/Show?url=http://geeks.ms

http://localhost:xxx/Form/Show?name=Geeks

¿Pero qué sucede? Que todavía nos queda por salvar el tercer obstáculo, aunque no está relacionado con lo anterior. Si comprobáis, veréis que funciona correctamente con la primera dirección (con o sin el parámetro opcional) pero falla con la segunda, la que usa el parámetro name.

¿Por qué? Bueno, pues este es uno de esos problemas que nos invitan a creer en la magia y abandonar la programación para dedicarnos a echar las cartas, hasta que descubrimos de qué se trata: la culpa es del nombre elegido para el parámetro url, dado que siempre que preguntemos por Request[“url”] obtendremos un valor, nunca es null, aunque no hayamos pasado ningún parámetro con ese nombre en la URL, porque el valor obtenido es ¡el de la propia URL de la petición!

Por suerte, este obstáculo es el de más fácil solución: en lugar de buscar en Request, profundizamos un poco más hasta Request.QueryString, donde sólo están los parámetros que vienen en la QueryString, con lo que sólo habrá un url si se lo pasamos, y si no devolverá null. La solución pasa por cambiar:

controllerContext.HttpContext.Request[pi.Name] != null

por

controllerContext.HttpContext.Request.QueryString[pi.Name] != null

Y resuelto. He querido incluir este último obstáculo por 1. ser fiel a la realidad (recordad que el artículo está basado en hechos reales) y 2. por la moraleja, parafraseando: “el más mínimo desconocimiento de la herramienta puede hacerse pasar por magia”.

En conclusión, hemos visto cómo se puede cambiar de forma muy sencilla la forma en que ASP.NET MVC selecciona la acción a partir de la URL. Recordad que, igualmente, muchos otros comportamientos generales se pueden modificar y adaptar a los casos concretos, o incluso esto podíamos haberlo conseguido de otras formas. Y sobre todo, lo más importante: hay que conocer bien la herramienta con la que trabajamos para adaptarla, entenderla… y distinguirla de la magia.

Un placer.

Anuncios
Esta entrada fue publicada en Sin categoría. Guarda el enlace permanente.

3 respuestas a [ASP.NET MVC] Discriminar acciones según el nombre de los parámetros

  1. Buenas!!!
    Muy buen post! 😀 Muy interesante!!!
    Eso sí, reconozco que no me gusta nada el uso de parámetros de querystring en una aplicación MVC. A mi el ? me echa atrás (pero yo soy muy maniático,eh?) (Aunque en el caso de un parámetro cuyo valor es una URL se entiende!).
    Por otro lado, si me permites un poco de autobombo (jejejeee) publiqué hace algún tiempecillo en mi blog un artículo parecido al tuyo (bueno, de hecho no, pero es un tema “un poco” relacionado), para gestionar URLs del tipo: http://controlador/accion/nombre-param/valor-param. Donde nombre-param y valor-param eran variables (y podían existir en cualquier número). Lo dejo por si a alguien le interesa: http://geeks.ms/blogs/etomas/archive/2011/04/03/asp-net-mvc-pasar-par-225-metros-a-trav-233-s-del-pathinfo.aspx
    En mi artículo, a diferencia del tuyo, esas URLs no se enrutan a acciones distintas, sino a la misma acción con mas o menos routevalues.

    Un abrazo tio… te estás saliendo con los últimos posts! 😀

  2. pablonete dijo:

    Gracias Edu, viniendo de ti la felicitación vale mucho.

    Un día tenemos que hablar con tiempo de esa fobia tuya a la ? más propia de una peli de Woody Allen 😉 Yo valoro mucho la claridad/elegancia de la URL y me gusta la transparencia que ofrecen los parámetros visibles en la QueryString.
    De ahí también el empeño en mantener el nombre de la acción Show en ambos métodos, cuando podrían ser ShowNew y ShowExisting, por poner, pero al tener una semántica casi idéntica, prefiero que parezcan una misma acción con opciones en lugar de dos acciones. Y luego prefiero tener muchos métodos que uno con mucha lógica, porque todo lo que pueda discriminar el motor de enrutamiento de MVC es lógica que me ahorro en mi código, lo que básicamente supone menos errores. Regla de oro: “Lo que puedas hacer declarativo, no lo hagas imperativo.”

  3. Ariel Ganc dijo:

    Hola Amigos .. estoy luchando c asp mvc y esto me a sifo util pero no logro completamente hacer q funcione.. y no se pq.
    Si funciona en Create:

    [HttpGet]
    [RequiredAuthentication(Check = true)]
    [SelectByArgumentNames]
    public ActionResult Create()

    [HttpPost]
    [RequiredAuthentication(Check = true)]
    [SelectByArgumentNames]
    public ActionResult Create([ModelBinder(typeof(PerfilModelBinder))] ABM_PerfilViewModel p_PerfilViewModel)

    ********************************************************************
    No en Edit 😦 😦 😦

    [HttpGet]
    [RequiredAuthentication(Check = true)]
    [SelectByArgumentNames]
    public ActionResult Edit(int? id = null)

    [HttpPost]
    [RequiredAuthentication(Check = true)]
    [SelectByArgumentNames]
    public ActionResult Create([ModelBinder(typeof(PerfilModelBinder))] ABM_PerfilViewModel p_PerfilViewModel)

    No se encuentra el recurso.
    Descripción: HTTP 404. El recurso que está buscando (o una de sus dependencias) se puede haber quitado, haber cambiado de nombre o no estar disponible temporalmente. Revise la dirección URL siguiente y asegúrese de que está escrita correctamente.

    Dirección URL solicitada: /Perfil/Edit

    ..Helppppp

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s