Binding de colecciones en ASP.NET MVC
Publicado
9/7/2011 12:32
por
Eduard Tomàs i Avellana
Buenas! Hoy voy a comentar un temilla que me comentó un colega el otro día y que puede dar algunos quebraderos de cabeza: el binding de colecciones.
Supongamos el siguiente viewmodel:
public class QuestionModel
{
public int IdQuestion {get; set;}
public string Text { get; set; }
public int IdAnswer { get; set; } // Id de la respuesta seleccionada
public IEnumerable<Answer> Answers { get; set; }
}
public class Answer
{
public int IdAnswer{ get; set; }
public string Text { get; set; }
}
Básicamente una pregunta contiene un Id, un texto y una lista de respuestas. Bien, supongamos que tenemos un método en el controlador que nos cree una lista de 10 preguntas, cada una de las cuales con 3 posibles respuestas, y la mande a una vista:
public ActionResult Index()
{
var questionModelList = new List<QuestionModel>();
for (var i = 1; i <= 10; i++)
{
questionModelList.Add(new QuestionModel()
{
IdQuestion = i,
Text = "Texto pregunta " + i,
IdAnswer = 0,
Answers = new List<Answer>() {
new Answer() {Text = "Respuesta A de " + i, IdAnswer = 1},
new Answer() {Text = "Respuesta B de " + i, IdAnswer = 2},
new Answer() {Text = "Respuesta C de " + i, IdAnswer = 3} }
});
}
return View(questionModelList);
}
Al igual que a la vista le mandamos una List<QuestionModel>, esperamos que eso sea lo que la vista nos devuelva en el controlador:
[HttpPost]
public ActionResult Index(List<QuestionModel> questionModelList)
{
// codigo...
}
Bueno, ahora veamos una posible implementación de la vista Index:
@model List<Ejemplo.ConRadioButtonNormal.Models.QuestionModel>
@{
ViewBag.Title = "Index";
}
@using (Html.BeginForm())
{
<div>
@foreach (var question in Model)
{
<div>
@Html.LabelFor(x=>question.Text, question.Text)
@foreach (var answer in question.Answers)
{
@Html.RadioButtonFor(m => answer.Text, answer.IdAnswer)
}
</div>
}
</div>
<p><input type="submit" value="Submit" /></p>
}
Buenooo… parece una implementación correcta no? Iteramos sobre todas las preguntas y por cada pregunta generamos una label y tantas radio buttons como respuestas tenga la pregunta… El problema es que… no es correcta! :)
Veamos el código HTML que nos genera:
<div>
<label for="question_Text">Texto pregunta 1</label>
<input id="answer_Text" name="answer.Text" type="radio" value="1" />
<input id="answer_Text" name="answer.Text" type="radio" value="2" />
<input id="answer_Text" name="answer.Text" type="radio" value="3" />
</div>
<div>
<label for="question_Text">Texto pregunta 2</label>
<input id="answer_Text" name="answer.Text" type="radio" value="1" />
<input id="answer_Text" name="answer.Text" type="radio" value="2" />
<input id="answer_Text" name="answer.Text" type="radio" value="3" />
</div>
<!-- Y así sucesivamente... :p -->
Fijaos que tanto el id como el name de todas las radiobutton son iguales! Y eso? Eso es porque name e id se sacan de la lambda expresion que se pasa como parametro al helper Html.RadioButtonFor<>. Y esa lambda es siempre la misma. Bueno… al menos el error es fácil de detectar porque una vez marqueis una radio button, al marcar cualquier otra de cualquier otra pregunta la primera se seleccionará. Recordad que HTML agrupa las radiobuttons según el atributo “name”, y si todas tienen el mismo, todas las radiobutton forman un solo grupo, y por lo tanto sólo una puede estar seleccionada.
Nota: Recordad la clave: la expresión lambda que se pasa a Html.RadioButtonFor<> es la que se usa para generar el valor del atributo name.
La siguiente pregunta que debemos hacernos es… cual debe ser el valor correcto de los atributos name de cada radiobutton? Para eso que ver como el DefaultModelBinder enlaza los datos cuando hay colecciones, y por suerte la regla es muy sencilla. Si estamos enlazando datos de una colección el DefaultModelBinder espera que el valor del atributo name tenga el formato: parametro[idx]. Es decir, analicemos la estructura de clases que el controlador espera.
El controlador espera una List<QuestionModel> llamada questionModelList. De ese modo:
- questionModelList[0].Text se enlazaría con la propiedad Text del primer elemento de la lista.
- questionModelList[1].Text se enlazaría con la propiedad Text del primer elemento de la lista.
- questionModelList[0].Answers[0].Id se enlazaria con el valor de la propiedad Id de la primera respuesta del primer elemento de la lista.
- questionModelList[0].Answers[1].Id se enlazaria con el valor de la propiedad Id de la segunda respuesta del primer elemento de la lista.
¿Lo veis? El DefaultModelBinder espera que el valor del atributo name sea “lo mismo que usaríamos en C#”.
Nota: Si te “hace daño a los ojos” lo de questionModelList al principio del atributo name (recuerdo que esto es el nombre del parámetro), que sepas que si el controlador sólo recibe una colección (como en nuestro caso) puedes eliminar el nombre del parámetro. En nuestro caso podriamos usar [0].Text como valor del atributo name p.ej.
Así, p.ej. podríamos modificar la vista para que quede parecida a:
@model List<Ejemplo.ConRadioButtonNormal.Models.QuestionModel>
@{
ViewBag.Title = "Index";
}
@using (Html.BeginForm())
{
<div>
@for (int idx = 0; idx < Model.Count; idx++)
{
<div>
<label>@Model[idx].Text</label>
@for (int idxAnswer = 0; idxAnswer < Model[idx].Answers.Count(); idxAnswer++)
{
var lstAnswers = Model[idx].Answers.ToList();
<input type="radio"
name="@string.Format("questionModelList[{0}].Answers[0].IdAnswer", idx)"
value="@lstAnswers[idxAnswer].IdAnswer" />
}
</div>
}
</div>
<p><input type="submit" value="Submit" /></p>
}
Eso genera un HTML como el siguiente:
<div>
<label>Texto pregunta 1</label>
<input type="radio"
name="questionModelList[0].Answers[0].IdAnswer" value="1" />
<input type="radio"
name="questionModelList[0].Answers[0].IdAnswer" value="2" />
<input type="radio"
name="questionModelList[0].Answers[0].IdAnswer" value="3" />
</div>
<div>
<label>Texto pregunta 2</label>
<input type="radio"
name="questionModelList[1].Answers[0].IdAnswer" value="1" />
<input type="radio"
name="questionModelList[1].Answers[0].IdAnswer" value="2" />
<input type="radio"
name="questionModelList[1].Answers[0].IdAnswer" value="3" />
</div>
Ahora los campos siguen la convención que espera el DefaultModelBinder. Con lo que los datos ahora si que serán recibidos por el controlador.
Bueno… ¿pero esto que sentido tiene?
Si os fijáis veréis que las radiobuttons de cada pregunta tienen todas el mismo name. Eso es correcto ya que sólo una respuesta puede seleccionarse por cada pregunta. Así todas las radiobuttons de la primera pregunta tienen el valor de questionModelList[0].Answers[0].IdAnswer y así sucesivamente.
Así pues, cual es la información que viaja del cliente al servidor? Aunque tengamos tres radiobuttons por pregunta, todas tienen el mismo name y sólo una viajará. Es decir, dadas las 10 radiobuttons viajarán 10 valores del cliente al servidor:
- questionModelList[0].Answers[0].IdAnswer
- questionModelList[1].Answers[0].IdAnswer
- …
- questionModelList[9].Answers[0].IdAnswer
Es decir, para el servidor tendremos una lista de 10 preguntas cada una de las cuales tendrá UNA sola respuesta (la seleccionada por el usuario). Y eso es exactamente lo que se bindea en el controlador:

Fijaos como p.ej. el campo Text de la pregunta es “null”. Normal, la vista no lo está enviando al controlador.
Parémonos un momento en este punto. Desde el controlador hemos mandado a la vista toda una List<QuestionModel> llena. Es decir la vista si que recibe el texto de las preguntas y las respuestas… Por que cuando estamos de vuelta en el controlador hemos perdido esta información?
Si te estás preguntando esto: bienvenido al mundo stateless de la web. Efectivamente el controlador envía toda una List<QuestionModel> a la vista y la vista usa esta información para generar un HTML, que es mandado al cliente. En este punto el trabajo del servidor ha terminado. El controlador muere y será creado de nuevo cuando se reciba otra petición del browser. Esta otra petición llega cuando pulsamos el botón de submit, pero el controlador sólo recibe lo que la vista envía. Y la vista sólo envia lo que está en el formulario: los IDs de las respuestas seleccionadas. La vista no envia los textos ni de la pregunta, ni de la respuesta. Por eso no los tenemos de vuelta. Recordad: la web es stateless. Si venís de Webforms, webforms os ocultaba esto y os hacía creer que la web es statefull. Pero MVC no oculta nada de nada: lo que enseña es, simplemente, lo que hay.
Bueno… dicho esto, ahora preguntaos, si tiene sentido que este método del controlador reciba una lista de QuestionModel. El tema es que si esta vista tiene que enviar las respuestas eso debería ser lo que debería esperar el controlador. Es decir, la vista recibe un List<QuestionModel> pero devuelve un array con los IDs de las preguntas seleccionadas… Total… la vista no va a devolver el texto de las preguntas y las respuestas al controlador, no? Si lo que la vista devuelve son los IDs de las respuestas seleccionadas esto es lo que debería recibir el controlador:

Fijaos simplemente que el controlador recibe una colección con los IDs de las respuestas seleccionados. Así idRespuestas[0] el ID de la respuesta seleccionada de la primera pregunta y así sucesivamente. La información del texto de las preguntas (en caso de ser necesaria) la obtendría el propio controlador (leyéndola de la BBDD, de la cache, de donde fuera).
Y como nos quedaría la vista? Pues como ahora no mandamos una estructura tan compleja al controlador, generar el nombre de los atributos name es mucho más fácil:
@model List<Ejemplo.ConRadioButtonNormal.Models.QuestionModel>
@{
ViewBag.Title = "Index";
}
@using (Html.BeginForm())
{
<div>
@for (int idx = 0; idx < Model.Count; idx++)
{
<div>
<label>@Model[idx].Text</label>
@for (int idxAnswer = 0; idxAnswer < Model[idx].Answers.Count(); idxAnswer++)
{
var lstAnswers = Model[idx].Answers.ToList();
<input type="radio"
name="@string.Format("[{0}]", idx)"
value="@lstAnswers[idxAnswer].IdAnswer" />
}
</div>
}
</div>
<p><input type="submit" value="Submit" /></p>
}
Vale, fijaos en dos cosillas:
- La vista sigue recibiendo una List<QuestionModel>. Normal, porque esto es lo que el controlador le sigue mandando. Pero eso NO implica que la vista deba mandar de vuelta esto mismo!
- El valor de los atributos “name” de las distintas radiobutton, es simplemente [0],[1],… y así sucesivamente (recordad como hemos comentado antes que si el controlador recibe una sola colección no es necesario poner el nombre del parámetro en el atributo name).
Una nota final…
Bueno, hemos visto como funciona el binding de colecciones en ASP.NET MVC, pero os quiero comentar sólo una cosilla más :)
Que ocurriria si le enviasemos al controlador los siguientes campos (sus valores son irrelevantes)?
- [0], [1], [3], [4]
- [1], [2], [3], [4]
En el primer caso vemos que falta [2]. Entonces el DefaultModelBinder se para en este punto. Es decir el controlador recibirá una colección con dos elementos (los valores de [0] y [1]. Los valores de [3] y [4] se han perdido).
En el segundo caso vemos que falta el primer elemento [0]. Entonces el DefaultModelBinder ni enlaza el parámetro. Es decir el controlador no recibirá una colección vacía, no… recibirá null en el parámetro.
En un próximo post veremos como podemos evitar esto ;-)
Un saludo!
ds