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");});<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.csdotnet 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>/, porqueView()las busca ahí. Los controladores y los modelos pueden estar en cualquier lugar, pero todos los esperan enControllers/yModels/. - 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();/ -> 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/2es el controladorProducts(la claseProductsController), la acciónDetailseid= 2. El parámetroint iddel 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=:HomeeIndex.- El
?después deidsignifica 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:ConfigureServicesregistra los servicios, yConfigureorganiza 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.jsonguarda 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());}/Orders va a Index, porque falta la acción y se usa Index. En /Orders/Details falta el id, y el ? lo permite, así que int id recibe el valor por defecto, 0. / busca un HomeController, y no existe: 404.
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");GET /catalog -> 200 1 Etiopía Yirgacheffe, 2 Colombia Supremo, 3 Sencha, 4 Earl GreyPOST /catalog -> 200 añadido 5 MatchaPUT /catalog/2?price=11.90 -> 204GET /catalog/2 -> 200 Colombia Supremo: 11.90 €DELETE /catalog/4 -> 204DELETE /catalog/4 -> 404GET /catalog -> 200 1 Etiopía Yirgacheffe, 2 Colombia Supremo, 3 Sencha, 5 MatchaDELETE /catalog -> 405price=11.90viene de la dirección, y en el POST,nameypricevienen 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
staticpara 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 objetoHttpGetAttributecon 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: paraProductsControlleresapi/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:
Abrimos todos los días de 8:00 a 20:00.{"id":3,"name":"Sencha","price":8.40,"category":"té"}Info/9: 404text/csv, products.csv1;Etiopía Yirgacheffe;14.902;Colombia Supremo;12.503;Sencha;8.404;Earl Grey;6.90Create: 302, Location: /ProductsJsonescribe las propiedades con la primera letra en minúscula (name,price), como es costumbre en JSON.Fileañade una cabeceraContent-Dispositioncon 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 cabeceraLocation: /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 enté. 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 conConfigureHttpJsonOptions: todas las letras se quedan como están, y los caracteres peligrosos para HTML (<,>) se codifican. Para los controladores, la configuración esAddJsonOptions:
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é. El navegador las muestra bien, pero en la salida de la consola no se pueden leer.AddWebEncodersconUnicodeRanges.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. RedirectToActiones 302 (una redirección temporal). Para «la dirección cambió para siempre» existeRedirectPermanent, 301.Redirect("https://…")redirige a cualquier dirección.- Además de un
IActionResult, una acción también puede devolver un objeto normal, como unProduct. 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");
}RedirectToAction("Index") envía un 302 con la dirección de Index. El navegador va ahí con GET. Si el usuario pulsa «Recargar», solo se repite mostrar la lista, no el guardado. Es el patrón POST, redirección, GET, que se ve con más detalle en la próxima lección.
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");}nuevo ProductsController y nuevo ProductService4 productosnuevo ProductsController y nuevo ProductService4 productosCon 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.AddScopedda 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).AddTransientda 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
- Una petición GET /Products/Details/1. La ruta {controller}/{action}/{id} la divide en tres partes.
- Products es el controlador ProductsController, Details es la acción, y 1 pasa a ser el parámetro id.
- El controlador no crea el servicio por sí mismo: lo recibe en el constructor (DI).
- La acción le pregunta al modelo: products.Find(1) devuelve el producto.
- Hay un producto, así que no es NotFound().
- View(product) le pasa el producto a la vista Details.cshtml.
- La vista genera el HTML, que vuelve al navegador.
El navegador pide /Products/Details/1:
- La ruta desarma la ruta de acceso: controlador
Products, acciónDetails,id= 1. - ASP.NET Core crea un
ProductsControllery le pasa elIProductServiceregistrado. Details(1)le pregunta al modelo:products.Find(1)devuelve el producto. Si no existe ese producto, la acción devuelveNotFound()y el recorrido termina con 404.View(product)busca la vistaViews/Products/Details.cshtml(la carpeta es el nombre del controlador, el archivo es el nombre de la acción) y le pasa el producto.- 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:
<h1>Etiopía Yirgacheffe</h1><p>Precio: 14.90 €</p><p>Categoría: café</p>/Products/Details/99 -> 404Más precisamente, para curiosos
- El programa es un proyecto con tres archivos. Además de
Program.cscon las clases y deDetails.cshtml, tiene el.csprojque creadotnet new mvc. Las vistas se compilan junto con el código C# endotnet build, así que un error en@Model.Nmese 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.
AddSingleton<ProductService>() registra la clase con su propio nombre. Pero el controlador pide IProductService, y el framework no sabe qué clase darle. La corrección es AddSingleton<IProductService, ProductService>(): primero la interfaz que se pide, después la clase que la implementa.
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:
/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 -> 4042. 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:
POST /Products/Create -> 302, Location: /ProductsPOST sin nombre -> 400Etiopía Yirgacheffe, Colombia Supremo, Sencha, Earl Grey, Matcha3. 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:
/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 -> 404Después haz Index y Details con vistas en lugar de Content. Cómo, está en la próxima lección.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Listas, diccionarios y LINQ · async y await · Cómo funciona internet · Un servidor web con Minimal API
- Continúa en: Vistas con Razor · WebAPI: datos para otros programas
- Ejercicios sobre el tema: Web y escritorio