Ir al contenido
wannacode

MVC: modelo, vista, controlador

En resumen: MVC (Model-View-Controller) divide una aplicación web en un modelo con los datos, una vista con el HTML y un controlador que recibe la petición y elige qué devolver.

Problema

Todo en Program.cs

El catálogo de la lección anterior funciona. Ahora la tienda quiere una página para cada producto, un carrito, pedidos, inicio de sesión para los clientes y una página para el almacén. Con MapGet, todo eso va a Program.cs, y cada manejador obtiene los datos por su cuenta, decide por su cuenta qué mostrar y pega el HTML por su cuenta:

app.MapGet("/products/{id:int}", (int id) =>
{
Product product = products.FirstOrDefault(p => p.Id == id);
if (product == null) return Results.NotFound();
string html = "<h1>" + product.Name + "</h1><p>" + product.Price + " €</p>";
if (product.Category == "café") html += "<p>Lo molemos en la tienda.</p>";
return Results.Content(html, "text/html; charset=utf-8");
});
Salida
<h1>Etiopía Yirgacheffe</h1><p>14.90 €</p><p>Lo molemos en la tienda.</p>

Con cincuenta manejadores así, el archivo llega a miles de líneas. Un diseñador que quiere cambiar un título tiene que bucear en código C#. Si el precio se calcula en dos lugares, tarde o temprano los dos lugares no van a coincidir.

La idea

Tres partes con tareas separadas

Desde hace tiempo, las aplicaciones web se organizan en tres partes. Esta división se llama MVC (Model-View-Controller):

  • Modelo: los datos y sus reglas. La clase Product, la lista de productos, el total de un pedido. El modelo no sabe nada de HTML ni de HTTP.
  • Vista: cómo ve los datos una persona. Una plantilla HTML con huecos para los valores. En ASP.NET Core, las vistas se escriben en Razor: HTML con C# dentro (la próxima lección).
  • Controlador: recibe la petición, le pregunta al modelo y elige qué devolver. Sus métodos se llaman acciones (actions).

Cada parte tiene su carpeta en el proyecto:

Shop/
├── Controllers/
│ ├── HomeController.cs
│ └── ProductsController.cs
├── Models/
│ └── Product.cs
├── Views/
│ ├── Home/
│ │ └── Index.cshtml
│ └── Products/
│ ├── Index.cshtml
│ └── Details.cshtml
├── wwwroot/ ← css, js e imágenes, que se sirven tal como están
├── appsettings.json ← configuración (niveles de registro, dirección de la base de datos)
└── Program.cs

dotnet new mvc -n Shop crea un proyecto con esta organización.

Más precisamente, para curiosos
  • Dividir por tareas se llama separación de responsabilidades (separation of concerns). La ventaja no es solo el orden: un modelo que no sabe de HTTP se prueba con un test unitario normal, sin servidor.
  • Las carpetas son una convención. Las vistas sí tienen que estar en Views/<controlador>/, porque View() las busca ahí. Los controladores y los modelos pueden estar en cualquier lugar, pero todos los esperan en Controllers/ y Models/.
  • En los ejemplos de esta lección todo está en un solo archivo, para que el código se pueda comprobar de una vez. En un proyecto real, cada clase está en su propio archivo.

Míralo

Controlador y acciones

El Program.cs de una aplicación MVC dice dos cosas: «uso controladores» (AddControllersWithViews) y «así llega la dirección al controlador» (MapControllerRoute):

using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.Services.AddControllersWithViews();
var app = builder.Build();
app.Urls.Add("http://localhost:8222");
app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
await app.StartAsync();

Un controlador es una clase que hereda de Controller. Cada uno de sus métodos public es una acción:

public class HomeController : Controller
{
public IActionResult Index() => Content("Café y té: página de inicio");
}
public class ProductsController : Controller
{
private readonly ProductService products = new ProductService();
public IActionResult Index()
{
List<string> names = products.All().Select(p => p.Name).ToList();
return Content(string.Join(", ", names));
}
public IActionResult Details(int id)
{
Product product = products.Find(id);
if (product == null) return NotFound();
return Content(product.Name + ": " + product.Price + " €");
}
}

Content devuelve texto, y NotFound devuelve 404. Los dos son resultados de acción (IActionResult); enseguida hablamos más de ellos. El => después del método es una forma corta de escribir un cuerpo de una sola expresión: Index() => Content(...) es lo mismo que { return Content(...); }.

El cliente pide varias direcciones:

var http = new HttpClient();
foreach (string path in new string[] { "/", "/Products", "/Products/Details/2", "/Products/Details/9", "/products/details/3" })
{
HttpResponseMessage response = await http.GetAsync("http://localhost:8222" + path);
string body = await response.Content.ReadAsStringAsync();
Console.WriteLine($"{path} -> {(int)response.StatusCode} {body}".TrimEnd());
}
await app.StopAsync();
Salida
/ -> 200 Café y té: página de inicio
/Products -> 200 Etiopía Yirgacheffe, Colombia Supremo, Sencha, Earl Grey
/Products/Details/2 -> 200 Colombia Supremo: 12.50 €
/Products/Details/9 -> 404
/products/details/3 -> 200 Sencha: 8.40 €

El patrón {controller=Home}/{action=Index}/{id?} desarma la ruta:

  • /Products/Details/2 es el controlador Products (la clase ProductsController), la acción Details e id = 2. El parámetro int id del método recibe 2 por sí solo, por su nombre.
  • / no tiene ninguna de las partes, así que se usan los valores que van después de =: Home e Index.
  • El ? después de id significa que puede faltar. Las mayúsculas y minúsculas en la ruta no importan.
Más precisamente, para curiosos
  • Por convención, el nombre de la clase termina en Controller, y en la dirección se escribe sin ese sufijo.
  • En los proyectos más antiguos (hasta .NET 5), la configuración estaba en una clase aparte, Startup, con dos métodos: ConfigureServices registra los servicios, y Configure organiza cómo se atienden las peticiones. Te la vas a encontrar en muchos artículos y en proyectos viejos:
public class Startup
{
public IConfiguration Configuration { get; }
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
}

Se arranca así:

IHost host = Host.CreateDefaultBuilder()
.ConfigureLogging(logging => logging.ClearProviders())
.ConfigureWebHostDefaults(web => web.UseStartup<Startup>().UseUrls("http://localhost:8229"))
.Build();
await host.StartAsync();

Las plantillas de hoy ponen todo en Program.cs, como en esta lección. El trabajo es el mismo: builder.Services es ConfigureServices, y app.Map... es Configure.

  • appsettings.json guarda la configuración que se cambia sin compilar: los niveles de registro, la dirección de la base de datos, los hosts permitidos.

Pruébalo

Qué dirección va a dónde

La ruta es la misma, {controller=Home}/{action=Index}/{id?}, pero la aplicación tiene solo este controlador:

public class OrdersController : Controller
{
public IActionResult Index() => Content("todos los pedidos");
public IActionResult Details(int id) => Content("pedido " + id);
}
var http = new HttpClient();
foreach (string path in new string[] { "/Orders", "/Orders/Details/7", "/Orders/Details", "/" })
{
HttpResponseMessage response = await http.GetAsync("http://localhost:8231" + path);
string body = await response.Content.ReadAsStringAsync();
Console.WriteLine($"{path} -> {(int)response.StatusCode} {body}".TrimEnd());
}
Respuesta

La idea

Atributos: la ruta en el propio método

El patrón por convención es cómodo para páginas. A veces quieres una dirección exacta, o una acción distinta para cada método HTTP. Entonces escribes la ruta encima de la clase y encima del método, entre corchetes:

[Route("catalog")]
public class CatalogController : Controller
{
private static readonly ProductService products = new ProductService();
[HttpGet("")]
public IActionResult List() => Content(string.Join(", ", products.All().Select(p => p.Id + " " + p.Name)));
[HttpGet("{id:int}")]
public IActionResult Show(int id)
{
Product product = products.Find(id);
if (product == null) return NotFound();
return Content(product.Name + ": " + product.Price + " €");
}
}

[Route("catalog")] y [HttpGet("{id:int}")] son atributos, como tu propio [Label] de la lección sobre reflexión. Por sí solos no hacen nada, pero el framework los lee y actúa según ellos. Aquí ASP.NET Core entiende: «este controlador está en /catalog, y Show responde a un GET a /catalog/<número>».

Hay un atributo para cada método HTTP: [HttpGet] lee, [HttpPost] crea, [HttpPut] cambia, [HttpDelete] borra. El cambio de un precio:

[HttpPut("{id:int}")]
public IActionResult ChangePrice(int id, decimal price)
{
Product product = products.Find(id);
if (product == null) return NotFound();
product.Price = price;
return NoContent();
}

NoContent() es 204: «hecho, pero no tengo nada que mostrarte». El cliente envía peticiones con distintos métodos. Send es un método auxiliar que envía un HttpRequestMessage (la petición como objeto: método, dirección, cuerpo) e imprime la respuesta:

var form = new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = "Matcha", ["price"] = "18" });
await Send(HttpMethod.Get, "/catalog");
await Send(HttpMethod.Post, "/catalog", form);
await Send(HttpMethod.Put, "/catalog/2?price=11.90");
await Send(HttpMethod.Get, "/catalog/2");
await Send(HttpMethod.Delete, "/catalog/4");
await Send(HttpMethod.Delete, "/catalog/4");
await Send(HttpMethod.Get, "/catalog");
await Send(HttpMethod.Delete, "/catalog");
Salida
GET /catalog -> 200 1 Etiopía Yirgacheffe, 2 Colombia Supremo, 3 Sencha, 4 Earl Grey
POST /catalog -> 200 añadido 5 Matcha
PUT /catalog/2?price=11.90 -> 204
GET /catalog/2 -> 200 Colombia Supremo: 11.90 €
DELETE /catalog/4 -> 204
DELETE /catalog/4 -> 404
GET /catalog -> 200 1 Etiopía Yirgacheffe, 2 Colombia Supremo, 3 Sencha, 5 Matcha
DELETE /catalog -> 405
  • price=11.90 viene de la dirección, y en el POST, name y price vienen del cuerpo del formulario. En los dos casos, los parámetros se rellenan por su nombre.
  • La última petición es un DELETE a /catalog. Esa dirección existe, pero solo para GET y POST, así que la respuesta es 405, no 404.
  • La lista es static para que sea la misma en todas las peticiones. Por qué esta no es la buena manera, lo veremos dentro de dos tarjetas.
Más precisamente, para curiosos
  • Como en la lección sobre reflexión, los atributos son clases que heredan de Attribute. [HttpGet("{id:int}")] crea un objeto HttpGetAttribute con el argumento "{id:int}". El framework los encuentra con reflexión mientras prepara las rutas.
  • [Route("api/[controller]")] pone el nombre del controlador en la dirección: para ProductsController es api/Products. Así se hacen las API de la lección sobre WebAPI.
  • Las rutas por convención y las rutas con atributos pueden convivir en una misma aplicación. A un controlador con [Route] ya no se llega por el patrón por convención.

La idea

Qué devuelve una acción

Una acción devuelve un IActionResult: una descripción de la respuesta, que ASP.NET Core convierte en un estado, unas cabeceras y un cuerpo. Los métodos de Controller crean los tipos ya hechos:

Método Qué envía Estado
View(model) HTML de una vista Razor 200
Content(text) texto simple 200
Json(obj) el objeto como JSON 200
File(bytes, type, name) un archivo para descargar 200
RedirectToAction("Index") «ve a otra dirección» 302
NotFound() / BadRequest() / NoContent() una respuesta vacía 404 / 400 / 204
public class ProductsController : Controller
{
private readonly ProductService products = new ProductService();
public IActionResult Hours() => Content("Abrimos todos los días de 8:00 a 20:00.");
public IActionResult Info(int id)
{
Product product = products.Find(id);
if (product == null) return NotFound();
return Json(product);
}
public IActionResult Report()
{
IEnumerable<string> lines = products.All().Select(p => p.Id + ";" + p.Name + ";" + p.Price);
byte[] bytes = Encoding.UTF8.GetBytes(string.Join("\n", lines) + "\n");
return File(bytes, "text/csv", "products.csv");
}
[HttpPost]
public IActionResult Create(string name)
{
if (string.IsNullOrWhiteSpace(name)) return BadRequest();
// Aquí se guarda el producto (la tarea 2 al final de la lección).
return RedirectToAction("Index");
}
}

El cliente tiene AllowAutoRedirect = false, para que veamos la redirección misma. Si no, HttpClient va solo a la nueva dirección, como un navegador:

Salida
Abrimos todos los días de 8:00 a 20:00.
{"id":3,"name":"Sencha","price":8.40,"category":"té"}
Info/9: 404
text/csv, products.csv
1;Etiopía Yirgacheffe;14.90
2;Colombia Supremo;12.50
3;Sencha;8.40
4;Earl Grey;6.90
Create: 302, Location: /Products
  • Json escribe las propiedades con la primera letra en minúscula (name, price), como es costumbre en JSON.
  • File añade una cabecera Content-Disposition con el nombre del archivo. El navegador la ve y ofrece «Guardar como products.csv». Así se sirven informes en PDF o Excel: File(bytes, "application/pdf", "MonthlyReport.pdf").
  • RedirectToAction("Index") responde con 302 y la cabecera Location: /Products. La dirección se arma a partir de la ruta, así que no la escribes a mano.
Más precisamente, para curiosos
  • ASP.NET Core escribe JSON con System.Text.Json. Con la configuración por defecto, Json(...) sustituye cada carácter fuera de ASCII por un código: «té» se convierte en té. Es JSON válido y cualquier programa lo lee bien, pero una persona no lo puede leer. Por eso el programa lo configura de forma explícita, como en la lección anterior con ConfigureHttpJsonOptions: todas las letras se quedan como están, y los caracteres peligrosos para HTML (<, >) se codifican. Para los controladores, la configuración es AddJsonOptions:
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.Services.AddControllersWithViews()
.AddJsonOptions(o => o.JsonSerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All));
var app = builder.Build();
app.Urls.Add("http://localhost:8224");
app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
await app.StartAsync();
  • Lo mismo pasa con las vistas Razor: codifican letras como é como &#xE9;. El navegador las muestra bien, pero en la salida de la consola no se pueden leer. AddWebEncoders con UnicodeRanges.All (en la tarjeta sobre DI) deja las letras como están. Ninguna de las dos configuraciones es obligatoria para un sitio real; están para que la salida se pueda leer.
  • RedirectToAction es 302 (una redirección temporal). Para «la dirección cambió para siempre» existe RedirectPermanent, 301. Redirect("https://…") redirige a cualquier dirección.
  • Además de un IActionResult, una acción también puede devolver un objeto normal, como un Product. Entonces se envía como JSON. Es lo habitual en las API.

Pruébalo

Después de guardar

El formulario de un producto nuevo se envió con POST. El producto está guardado, y el usuario debe ir a la lista. ¿Con qué método?

¿Qué devuelve la acción para que el navegador vaya a la lista?

[HttpPost]
public IActionResult Create(string name)
{
    if (string.IsNullOrWhiteSpace(name)) return BadRequest();
    return ("Index");
}

La idea

El servicio llega desde fuera

Hasta ahora, el controlador creaba su propio new ProductService(). Pero ASP.NET Core crea un controlador nuevo para cada petición:

public class ProductsController : Controller
{
private readonly ProductService products = new ProductService();
public ProductsController()
{
Console.WriteLine("nuevo ProductsController y nuevo ProductService");
}
public IActionResult Index() => Content(products.All().Count + " productos");
}
Salida
nuevo ProductsController y nuevo ProductService
4 productos
nuevo ProductsController y nuevo ProductService
4 productos

Con cada controlador nace también una lista nueva de productos. Un producto añadido en una petición ya no está en la siguiente. static resuelve esto, pero entonces cada controlador queda atado justo a esta clase. Cuando los productos pasen a una base de datos, tendrás que cambiar cada controlador.

La mejor manera: el controlador dice qué necesita, no de dónde sacarlo. Lo que necesita es una interfaz:

public interface IProductService
{
Product Find(int id);
List<Product> All();
}
public class ProductService : IProductService
{
private readonly List<Product> products = new List<Product>
{
new Product { Id = 1, Name = "Etiopía Yirgacheffe", Price = 14.90m, Category = "café" },
new Product { Id = 2, Name = "Colombia Supremo", Price = 12.50m, Category = "café" },
new Product { Id = 3, Name = "Sencha", Price = 8.40m, Category = "té" },
new Product { Id = 4, Name = "Earl Grey", Price = 6.90m, Category = "té" },
};
public Product Find(int id) => products.FirstOrDefault(p => p.Id == id);
public List<Product> All() => products;
}

En Program.cs dices una sola vez qué clase está detrás de la interfaz:

builder.Services.AddControllersWithViews();
builder.Services.AddWebEncoders(o => o.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All));
builder.Services.AddSingleton<IProductService, ProductService>();

AddSingleton<IProductService, ProductService>() significa: «cuando alguien pida un IProductService, dale un objeto ProductService, el mismo para toda la aplicación». El controlador lo pide como parámetro del constructor, y ASP.NET Core se lo pasa. Esto se llama inyección de dependencias (dependency injection, DI). El controlador está en la próxima tarjeta.

Más precisamente, para curiosos
  • Además de AddSingleton, hay dos tiempos de vida más para un servicio. AddScoped da un objeto nuevo para cada petición, pero el mismo dentro de una petición. Así se suele registrar la conexión con la base de datos (DbContext). AddTransient da un objeto nuevo cada vez que se pide.
  • Cambiarlo es una sola línea: AddSingleton<IProductService, DatabaseProductService>(). En un test, le pasas al controlador tu propia clase de prueba con unos pocos productos, sin servidor y sin base de datos.
  • La lista de un singleton la usan muchas peticiones al mismo tiempo. En una aplicación real, el acceso a ella se tiene que proteger. Cómo, lo veremos en la lección sobre WebAPI.

Míralo

Una petición de principio a fin

public class ProductsController : Controller
{
private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Details(int id)
{
Product product = products.Find(id);
if (product == null) return NotFound();
return View(product);
}
}
Paso a paso, en palabras
  1. Una petición GET /Products/Details/1. La ruta {controller}/{action}/{id} la divide en tres partes.
  2. Products es el controlador ProductsController, Details es la acción, y 1 pasa a ser el parámetro id.
  3. El controlador no crea el servicio por sí mismo: lo recibe en el constructor (DI).
  4. La acción le pregunta al modelo: products.Find(1) devuelve el producto.
  5. Hay un producto, así que no es NotFound().
  6. View(product) le pasa el producto a la vista Details.cshtml.
  7. La vista genera el HTML, que vuelve al navegador.

El navegador pide /Products/Details/1:

  1. La ruta desarma la ruta de acceso: controlador Products, acción Details, id = 1.
  2. ASP.NET Core crea un ProductsController y le pasa el IProductService registrado.
  3. Details(1) le pregunta al modelo: products.Find(1) devuelve el producto. Si no existe ese producto, la acción devuelve NotFound() y el recorrido termina con 404.
  4. View(product) busca la vista Views/Products/Details.cshtml (la carpeta es el nombre del controlador, el archivo es el nombre de la acción) y le pasa el producto.
  5. Razor rellena los huecos, y el HTML va a la respuesta.

La vista Views/Products/Details.cshtml:

@model Product
<h1>@Model.Name</h1>
<p>Precio: @Model.Price €</p>
<p>Categoría: @Model.Category</p>

@model Product dice qué tipo de objeto recibe la vista, y @Model.Name inserta su propiedad. El programa abre las dos direcciones:

Salida
<h1>Etiopía Yirgacheffe</h1>
<p>Precio: 14.90 €</p>
<p>Categoría: café</p>
/Products/Details/99 -> 404
Más precisamente, para curiosos
  • El programa es un proyecto con tres archivos. Además de Program.cs con las clases y de Details.cshtml, tiene el .csproj que crea dotnet new mvc. Las vistas se compilan junto con el código C# en dotnet build, así que un error en @Model.Nme se detecta en ese momento.
  • Los filtros son código que se ejecuta antes o después de las acciones, sin que lo escribas en cada una. [Authorize] encima de un controlador deja pasar solo a los usuarios que iniciaron sesión, y un filtro de errores convierte cualquier excepción en una página «Algo salió mal».
  • Las áreas (areas) dividen una aplicación grande en partes con sus propios controladores y vistas, por ejemplo Areas/Admin/ para la administración de la tienda.

Pruébalo

No se encuentra el servicio

La aplicación arranca, pero cada petición a /Products/Details/1 devuelve 500. La consola dice Unable to resolve service for type 'IProductService' while attempting to activate 'ProductsController'. ¿En qué línea está el error?

Elige la línea con el error.

Ejercicio

El catálogo como MVC

Resuélvelas en tu editor, en un proyecto de dotnet new mvc o en un solo archivo, como en la lección. Los productos son los cuatro de la lección, y el servicio está registrado con AddSingleton<IProductService, ProductService>() y llega por el constructor del controlador.

1. Catálogo. Un ProductsController con un Index que devuelve todos los productos, uno por línea, y un Details(int id) que devuelve el nombre, la categoría y el precio, o 404. El cliente pide /Products, /Products/Details/3 y /Products/Details/42:

Salida
/Products -> 200 1. Etiopía Yirgacheffe – 14.90 €
2. Colombia Supremo – 12.50 €
3. Sencha – 8.40 €
4. Earl Grey – 6.90 €
/Products/Details/3 -> 200 Sencha (té): 8.40 €
/Products/Details/42 -> 404

2. Redirección después de guardar. Añade a IProductService un método void Add(Product product) que le da al producto el siguiente Id libre. La acción [HttpPost] Create(string name, decimal price, string category) devuelve 400 si no hay nombre, y si lo hay, guarda el producto y redirige a Index. El cliente tiene AllowAutoRedirect = false, y después abre la dirección de Location:

Salida
POST /Products/Create -> 302, Location: /Products
POST sin nombre -> 400
Etiopía Yirgacheffe, Colombia Supremo, Sencha, Earl Grey, Matcha

3. Productos por categoría. La acción Category(string id) devuelve los nombres de los productos de la categoría y la suma de sus precios, o 404 si no hay ninguno. Con la ruta por convención, la categoría llega como id. La é de «té» y «café» en la dirección la codifica HttpClient por sí solo:

Salida
/Products/Category/té -> 200 Sencha, Earl Grey | total 15.30 €
/Products/Category/café -> 200 Etiopía Yirgacheffe, Colombia Supremo | total 27.40 €
/Products/Category/cacao -> 404

Después haz Index y Details con vistas en lugar de Content. Cómo, está en la próxima lección.

Test

Ponte a prueba

1. ¿Qué parte de MVC decide qué pasa con una petición y qué vista se muestra?
2. La ruta es {controller=Home}/{action=Index}/{id?}. ¿Qué acción atiende /Products/Details/5?
3. ¿Por qué un POST que sale bien devuelve RedirectToAction y no una página directamente?
4. El controlador pide un IProductService en su constructor. ¿De dónde lo obtiene?
5. Una acción con [HttpPost] recibe una petición GET en la misma dirección. ¿Cuál es la respuesta?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·