MVC: модел, изглед, контролер
Проблем
Всичко в Program.cs
Каталогът от предишния урок работи. Сега магазинът иска страница за всеки продукт, кошница, поръчки, вход за клиенти и страница за склада. С MapGet всичко това отива в Program.cs, а всеки обработчик сам взима данните, сам решава какво да покаже и сам лепи HTML:
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 == "кафе") html += "<p>Мелим на място.</p>"; return Results.Content(html, "text/html; charset=utf-8");});<h1>Етиопия Йиргачефе</h1><p>14.90 €</p><p>Мелим на място.</p>При петдесет такива обработчика файлът става хиляди редове. Дизайнерът, който иска да смени заглавието, трябва да рови в C# код. Ако цената се смята на две места, рано или късно двете места ще се разминат.
Идеята
Три части с отделна работа
Уеб приложенията отдавна се подреждат на три части. Подредбата се казва MVC (Model-View-Controller):
- Модел: данните и правилата за тях. Класът
Product, списъкът с продукти, сметката на поръчката. Моделът не знае нищо за HTML и HTTP. - Изглед: как изглеждат данните за човека. HTML шаблон с празни места за стойностите. В ASP.NET Core изгледите се пишат на Razor: HTML с C# вътре (следващият урок).
- Контролер: приема заявката, пита модела и избира какво да върне. Методите му се казват действия (actions).
Всяка част има своя папка в проекта:
Shop/├── Controllers/│ ├── HomeController.cs│ └── ProductsController.cs├── Models/│ └── Product.cs├── Views/│ ├── Home/│ │ └── Index.cshtml│ └── Products/│ ├── Index.cshtml│ └── Details.cshtml├── wwwroot/ ← css, js и картинки, които се дават както са├── appsettings.json ← настройки (нива на логване, адрес на базата)└── Program.csПроект с тази подредба прави dotnet new mvc -n Shop.
По-точно, за любопитните
- Разделянето по задачи се казва separation of concerns. Ползата е не само в реда: модел, който не знае за HTTP, се тества с обикновен unit тест, без сървър.
- Папките са конвенция. Изгледите наистина трябва да са във
Views/<контролер>/, защото там ги търсиView(). Контролерите и моделите могат да са навсякъде, но всички ги очакват вControllers/иModels/. - В примерите от урока всичко е в един файл, за да се провери кодът наведнъж. В истинския проект всеки клас е в свой файл.
Виж го
Контролер и действия
Program.cs на MVC приложение казва две неща: „ползвам контролери“ (AddControllersWithViews) и „как адресът стига до контролера“ (MapControllerRoute):
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder();builder.Logging.ClearProviders();builder.Services.AddControllersWithViews();
var app = builder.Build();app.Urls.Add("http://localhost:5222");app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");await app.StartAsync();Контролерът е клас, който наследява Controller. Всеки негов public метод е действие:
public class HomeController : Controller{ public IActionResult Index() => Content("Кафе и чай: начална страница");}
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 връща текст, а NotFound връща 404. И двете са резултати на действието (IActionResult), повече за тях след малко. => след метода е кратък запис на тяло от един израз: Index() => Content(...) е същото като { return Content(...); }.
Клиентът пита няколко адреса:
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:5222" + path); string body = await response.Content.ReadAsStringAsync(); Console.WriteLine($"{path} -> {(int)response.StatusCode} {body}".TrimEnd());}await app.StopAsync();/ -> 200 Кафе и чай: начална страница/Products -> 200 Етиопия Йиргачефе, Колумбия Супремо, Сенча, Ерл Грей/Products/Details/2 -> 200 Колумбия Супремо: 12.50 €/Products/Details/9 -> 404/products/details/3 -> 200 Сенча: 8.40 €Шаблонът {controller=Home}/{action=Index}/{id?} разглобява пътя:
/Products/Details/2е контролерProducts(класътProductsController), действиеDetails,id= 2. Параметърътint idна метода получава 2 сам, по името./няма нито една част, затова се ползват стойностите след=:HomeиIndex.?следidзначи, че може да липсва. Главните и малките букви в пътя нямат значение.
По-точно, за любопитните
- Името на класа завършва на
Controllerпо конвенция, а в адреса се пише без тази наставка. - В по-старите проекти (до .NET 5) настройките бяха в отделен клас
Startupс два метода:ConfigureServicesрегистрира услугите, аConfigureподрежда обработката на заявките. Ще го срещнеш в много статии и в стари проекти:
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?}"); }); }}Пуска се така:
IHost host = Host.CreateDefaultBuilder() .ConfigureLogging(logging => logging.ClearProviders()) .ConfigureWebHostDefaults(web => web.UseStartup<Startup>().UseUrls("http://localhost:5229")) .Build();await host.StartAsync();Днешните шаблони слагат всичко в Program.cs, както в урока. Работата е същата: builder.Services е ConfigureServices, а app.Map... е Configure.
appsettings.jsonдържи настройките, които се сменят без компилиране: нивата на логване, адреса на базата, разрешените хостове.
Пробвай
Кой адрес къде отива
Маршрутът е същият, {controller=Home}/{action=Index}/{id?}, но в приложението има само този контролер:
public class OrdersController : Controller{ public IActionResult Index() => Content("всички поръчки");
public IActionResult Details(int id) => Content("поръчка " + id);}
var http = new HttpClient();foreach (string path in new string[] { "/Orders", "/Orders/Details/7", "/Orders/Details", "/" }){ HttpResponseMessage response = await http.GetAsync("http://localhost:5231" + path); string body = await response.Content.ReadAsStringAsync(); Console.WriteLine($"{path} -> {(int)response.StatusCode} {body}".TrimEnd());}/Orders отива на Index, защото действието липсва и се ползва Index. В /Orders/Details id липсва, а ? разрешава това, затова int id получава стойността по подразбиране, 0. / търси HomeController, а такъв няма: 404.
Идеята
Атрибути: маршрут на самия метод
Шаблонът по конвенция е удобен за страници. Понякога искаш точно определен адрес или различно действие за всеки HTTP метод. Тогава маршрутът се пише над класа и над метода, в квадратни скоби:
[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")] и [HttpGet("{id:int}")] са атрибути. Атрибутът е бележка към класа, метода или параметъра. Сам по себе си не прави нищо, но рамката го чете и се съобразява с него. Тук ASP.NET Core разбира: „този контролер е на /catalog, а Show отговаря на GET към /catalog/<число>“.
За всеки метод от HTTP има атрибут: [HttpGet] чете, [HttpPost] създава, [HttpPut] променя, [HttpDelete] изтрива. Промяната на цена:
[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() е 204: „стана, но нямам какво да ти покажа“. Клиентът праща заявки с различни методи. Send е помощен метод, който праща HttpRequestMessage (заявката като обект: метод, адрес, тяло) и печата отговора:
var form = new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = "Матча", ["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 Етиопия Йиргачефе, 2 Колумбия Супремо, 3 Сенча, 4 Ерл ГрейPOST /catalog -> 200 добавен 5 МатчаPUT /catalog/2?price=11.90 -> 204GET /catalog/2 -> 200 Колумбия Супремо: 11.90 €DELETE /catalog/4 -> 204DELETE /catalog/4 -> 404GET /catalog -> 200 1 Етиопия Йиргачефе, 2 Колумбия Супремо, 3 Сенча, 5 МатчаDELETE /catalog -> 405price=11.90идва от адреса, аnameиpriceпри POST идват от тялото на формата. Параметрите се попълват по име и в двата случая.- Последната заявка е DELETE към
/catalog. Такъв адрес има, но само за GET и POST, затова отговорът е 405, не 404. staticна списъка е, за да е един и същ за всички заявки. Защо това не е добрият начин, ще видим след две карти.
По-точно, за любопитните
- Атрибутите в C# са класове, наследници на
Attribute.[HttpGet("{id:int}")]създава обектHttpGetAttributeс аргумент"{id:int}". Рамката ги намира с reflection, докато подготвя маршрутите. [Route("api/[controller]")]слага името на контролера в адреса: заProductsControllerтой еapi/Products. Така са направени API-тата в урока за WebAPI.- Маршрутите по конвенция и с атрибути могат да живеят заедно в едно приложение. Контролер с
[Route]вече не се стига през шаблона по конвенция.
Идеята
Какво връща действието
Действието връща IActionResult: описание на отговора, което ASP.NET Core превръща в статус, заглавки и тяло. Методите на Controller правят готовите видове:
| Метод | Какво праща | Статус |
|---|---|---|
View(model) |
HTML от Razor изглед | 200 |
Content(text) |
обикновен текст | 200 |
Json(obj) |
обекта като JSON | 200 |
File(bytes, type, name) |
файл за изтегляне | 200 |
RedirectToAction("Index") |
„иди на друг адрес“ | 302 |
NotFound() / BadRequest() / NoContent() |
празен отговор | 404 / 400 / 204 |
public class ProductsController : Controller{ private readonly ProductService products = new ProductService();
public IActionResult Hours() => Content("Работим всеки ден от 8:00 до 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(); // Тук продуктът се записва (упражнение 2 в края на урока). return RedirectToAction("Index"); }}Клиентът е с AllowAutoRedirect = false, за да видим самото пренасочване. Иначе HttpClient сам отива на новия адрес, като браузъра:
Работим всеки ден от 8:00 до 20:00.{"id":3,"name":"Сенча","price":8.40,"category":"чай"}Info/9: 404text/csv, products.csv1;Етиопия Йиргачефе;14.902;Колумбия Супремо;12.503;Сенча;8.404;Ерл Грей;6.90Create: 302, Location: /ProductsJsonпише свойствата с малка първа буква (name,price), както е прието в JSON.Fileслага заглавкаContent-Dispositionс името на файла. Браузърът я вижда и предлага „Запази като products.csv“. Така се дават отчети в PDF или Excel:File(bytes, "application/pdf", "MonthlyReport.pdf").RedirectToAction("Index")отговаря с 302 и заглавкаLocation: /Products. Адресът се сглобява от маршрута, затова не го пишеш на ръка.
По-точно, за любопитните
- ASP.NET Core пише JSON със
System.Text.Json. С настройките по подразбиранеJson(...)заменя всеки символ извън ASCII с код: „Сенча“ става\u0421\u0435\u043D\u0447\u0430. Това е валиден JSON и всяка програма го чете правилно, но е нечетим за човек. Затова програмата задава изрично, като в предишния урок сConfigureHttpJsonOptions: всички букви остават, а опасните за HTML знаци (<,>) се кодират. За контролерите настройката е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:5224");app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");await app.StartAsync();- Същото важи за Razor изгледите: те кодират кирилицата като
К. Браузърът я показва правилно, но в изхода на конзолата е нечетима.AddWebEncodersсUnicodeRanges.All(в картата за DI) оставя буквите както са. И двете настройки не са задължителни за истински сайт, а са за да е изходът четим. RedirectToActionе 302 (временно пренасочване). За „адресът се смени завинаги“ имаRedirectPermanent, 301.Redirect("https://…")пренасочва към произволен адрес.- Освен
IActionResultдействието може да върне и обикновен обект, напримерProduct. Тогава той се праща като JSON. Това е честото при API.
Пробвай
След записа
Формата за нов продукт е пратена с POST. Продуктът е записан и потребителят трябва да отиде на списъка. С кой метод?
Какво връща действието, за да отиде браузърът на списъка?
[HttpPost]
public IActionResult Create(string name)
{
if (string.IsNullOrWhiteSpace(name)) return BadRequest();
return ("Index");
}RedirectToAction("Index") праща 302 с адреса на Index. Браузърът отива там с GET. Ако потребителят натисне „Презареди“, се повтаря само показването на списъка, не записът. Това е шаблонът POST, пренасочване, GET, по-подробно в следващия урок.
Идеята
Услугата идва отвън
Досега контролерът сам си правеше new ProductService(). Обаче ASP.NET Core създава нов контролер за всяка заявка:
public class ProductsController : Controller{ private readonly ProductService products = new ProductService();
public ProductsController() { Console.WriteLine("нов ProductsController и нов ProductService"); }
public IActionResult Index() => Content(products.All().Count + " продукта");}нов ProductsController и нов ProductService4 продуктанов ProductsController и нов ProductService4 продуктаС всеки контролер се ражда и нов списък с продукти. Продукт, добавен при една заявка, го няма при следващата. static решава това, но тогава всеки контролер е вързан за точно този клас. Когато продуктите се преместят в база, ще трябва да промениш всеки контролер.
По-добрият начин: контролерът казва какво му трябва, а не откъде да го вземе. Това, което му трябва, е интерфейс:
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 = "Етиопия Йиргачефе", Price = 14.90m, Category = "кафе" }, new Product { Id = 2, Name = "Колумбия Супремо", Price = 12.50m, Category = "кафе" }, new Product { Id = 3, Name = "Сенча", Price = 8.40m, Category = "чай" }, new Product { Id = 4, Name = "Ерл Грей", Price = 6.90m, Category = "чай" }, };
public Product Find(int id) => products.FirstOrDefault(p => p.Id == id);
public List<Product> All() => products;}В Program.cs казваш веднъж кой клас стои зад интерфейса:
builder.Services.AddControllersWithViews();builder.Services.AddWebEncoders(o => o.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All));builder.Services.AddSingleton<IProductService, ProductService>();AddSingleton<IProductService, ProductService>() значи: „когато някой поиска IProductService, дай му обект ProductService, един и същ за цялото приложение“. Контролерът го иска като параметър на конструктора и ASP.NET Core му го подава. Това се казва подаване на зависимости (dependency injection, DI). Контролерът е в следващата карта.
По-точно, за любопитните
- Освен
AddSingletonима още два живота на услугата.AddScopedдава нов обект за всяка заявка, но един и същ в рамките на заявката. Така обикновено се регистрира връзката с базата (DbContext).AddTransientдава нов обект при всяко искане. - Подмяната е на един ред:
AddSingleton<IProductService, DatabaseProductService>(). В тест подаваш на контролера своя тестов клас с няколко продукта, без сървър и без база. - Списъкът в singleton се ползва от много заявки едновременно. За истинско приложение достъпът до него трябва да се пази. Как, ще видим в урока за WebAPI.
Виж го
Една заявка от край до край
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); }}Стъпка по стъпка, с думи
- Заявка GET /Products/Details/1. Маршрутът {controller}/{action}/{id} я разделя на три части.
- Products е контролерът ProductsController, Details е действието, а 1 става параметърът id.
- Контролерът не създава услугата сам: получава я в конструктора (DI).
- Действието пита модела: products.Find(1) връща продукта.
- Има продукт, значи не е NotFound().
- View(product) подава продукта на изгледа Details.cshtml.
- Изгледът прави HTML, който се връща към браузъра.
Браузърът иска /Products/Details/1:
- Маршрутът разглобява пътя: контролер
Products, действиеDetails,id= 1. - ASP.NET Core създава
ProductsControllerи му подава регистриранияIProductService. Details(1)пита модела:products.Find(1)връща продукта. Ако няма такъв, действието връщаNotFound()и пътят свършва с 404.View(product)търси изгледаViews/Products/Details.cshtml(папката е името на контролера, файлът е името на действието) и му подава продукта.- Razor попълва празните места и HTML-ът отива в отговора.
Изгледът Views/Products/Details.cshtml:
@model Product<h1>@Model.Name</h1><p>Цена: @Model.Price €</p><p>Категория: @Model.Category</p>@model Product казва какъв обект получава изгледът, а @Model.Name вмъква свойството му. Програмата отваря двата адреса:
<h1>Етиопия Йиргачефе</h1><p>Цена: 14.90 €</p><p>Категория: кафе</p>/Products/Details/99 -> 404По-точно, за любопитните
- Програмата е проект с три файла. Освен
Program.csс класовете иDetails.cshtmlв него е и.csproj, който правиdotnet new mvc. Изгледите се компилират заедно с C# кода приdotnet build, затова грешка в@Model.Nmeсе хваща още тогава. - Филтрите са код, който върви преди или след действията, без да го пишеш във всяко.
[Authorize]над контролер пуска само влезли потребители, а филтър за грешки превръща всяко изключение в страница „Нещо се обърка“. - Областите (areas) делят голямо приложение на части със свои контролери и изгледи, например
Areas/Admin/за администрацията на магазина.
Пробвай
Услугата не се намира
Приложението тръгва, но всяка заявка към /Products/Details/1 връща 500. В конзолата пише Unable to resolve service for type 'IProductService' while attempting to activate 'ProductsController'. На кой ред е грешката?
Избери реда с грешката.
AddSingleton<ProductService>() регистрира класа под собственото му име. Контролерът обаче иска IProductService и рамката не знае кой клас да му даде. Поправката е AddSingleton<IProductService, ProductService>(): първо интерфейсът, който се иска, после класът, който го изпълнява.
Упражнение
Каталогът като MVC
Реши задачите в редактора си, в проект от dotnet new mvc или в един файл като в урока. Продуктите са четирите от урока, а услугата е регистрирана с AddSingleton<IProductService, ProductService>() и подадена в конструктора на контролера.
1. Каталог. ProductsController с Index, който връща всички продукти по един на ред, и Details(int id), който връща името, категорията и цената или 404. Клиентът пита /Products, /Products/Details/3 и /Products/Details/42:
/Products -> 200 1. Етиопия Йиргачефе – 14.90 €2. Колумбия Супремо – 12.50 €3. Сенча – 8.40 €4. Ерл Грей – 6.90 €/Products/Details/3 -> 200 Сенча (чай): 8.40 €/Products/Details/42 -> 4042. Пренасочване след запис. Добави в IProductService метод void Add(Product product), който дава на продукта следващия свободен Id. Действието [HttpPost] Create(string name, decimal price, string category) връща 400 без име, а иначе записва продукта и пренасочва към Index. Клиентът е с AllowAutoRedirect = false, после отваря адреса от Location:
POST /Products/Create -> 302, Location: /ProductsPOST без име -> 400Етиопия Йиргачефе, Колумбия Супремо, Сенча, Ерл Грей, Матча3. Продукти по категория. Действието Category(string id) връща имената на продуктите от категорията и сумата на цените им, или 404, ако няма нито един. С маршрута по конвенция категорията идва като id. Кирилицата в адреса HttpClient кодира сам:
/Products/Category/чай -> 200 Сенча, Ерл Грей | общо 15.30 €/Products/Category/кафе -> 200 Етиопия Йиргачефе, Колумбия Супремо | общо 27.40 €/Products/Category/какао -> 404После направи Index и Details с изгледи вместо Content. Как, е в следващия урок.
Тест