Към съдържанието
wannacode

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());
}
Отговор

Идеята

Атрибути: маршрут на самия метод

Шаблонът по конвенция е удобен за страници. Понякога искаш точно определен адрес или различно действие за всеки 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 -> 204
GET /catalog/2 -> 200 Колумбия Супремо: 11.90 €
DELETE /catalog/4 -> 204
DELETE /catalog/4 -> 404
GET /catalog -> 200 1 Етиопия Йиргачефе, 2 Колумбия Супремо, 3 Сенча, 5 Матча
DELETE /catalog -> 405
  • price=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: 404
text/csv, products.csv
1;Етиопия Йиргачефе;14.90
2;Колумбия Супремо;12.50
3;Сенча;8.40
4;Ерл Грей;6.90
Create: 302, Location: /Products
  • Json пише свойствата с малка първа буква (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 изгледите: те кодират кирилицата като &#x41A;. Браузърът я показва правилно, но в изхода на конзолата е нечетима. 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");
}

Идеята

Услугата идва отвън

Досега контролерът сам си правеше 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 и нов ProductService
4 продукта
нов ProductsController и нов ProductService
4 продукта

С всеки контролер се ражда и нов списък с продукти. Продукт, добавен при една заявка, го няма при следващата. 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);
}
}
Стъпка по стъпка, с думи
  1. Заявка GET /Products/Details/1. Маршрутът {controller}/{action}/{id} я разделя на три части.
  2. Products е контролерът ProductsController, Details е действието, а 1 става параметърът id.
  3. Контролерът не създава услугата сам: получава я в конструктора (DI).
  4. Действието пита модела: products.Find(1) връща продукта.
  5. Има продукт, значи не е NotFound().
  6. View(product) подава продукта на изгледа Details.cshtml.
  7. Изгледът прави HTML, който се връща към браузъра.

Браузърът иска /Products/Details/1:

  1. Маршрутът разглобява пътя: контролер Products, действие Details, id = 1.
  2. ASP.NET Core създава ProductsController и му подава регистрирания IProductService.
  3. Details(1) пита модела: products.Find(1) връща продукта. Ако няма такъв, действието връща NotFound() и пътят свършва с 404.
  4. View(product) търси изгледа Views/Products/Details.cshtml (папката е името на контролера, файлът е името на действието) и му подава продукта.
  5. 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'. На кой ред е грешката?

Избери реда с грешката.

Упражнение

Каталогът като 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 -> 404

2. Пренасочване след запис. Добави в IProductService метод void Add(Product product), който дава на продукта следващия свободен Id. Действието [HttpPost] Create(string name, decimal price, string category) връща 400 без име, а иначе записва продукта и пренасочва към Index. Клиентът е с AllowAutoRedirect = false, после отваря адреса от Location:

Изход
POST /Products/Create -> 302, Location: /Products
POST без име -> 400
Етиопия Йиргачефе, Колумбия Супремо, Сенча, Ерл Грей, Матча

3. Продукти по категория. Действието Category(string id) връща имената на продуктите от категорията и сумата на цените им, или 404, ако няма нито един. С маршрута по конвенция категорията идва като id. Кирилицата в адреса HttpClient кодира сам:

Изход
/Products/Category/чай -> 200 Сенча, Ерл Грей | общо 15.30 €
/Products/Category/кафе -> 200 Етиопия Йиргачефе, Колумбия Супремо | общо 27.40 €
/Products/Category/какао -> 404

После направи Index и Details с изгледи вместо Content. Как, е в следващия урок.

Тест

Провери се

1. Коя част на MVC решава какво да се случи при заявка и кой изглед да се покаже?
2. Маршрутът е {controller=Home}/{action=Index}/{id?}. Кое действие обслужва /Products/Details/5?
3. Защо след успешен POST се връща RedirectToAction, а не направо страница?
4. Контролерът иска IProductService в конструктора си. Откъде го получава?
5. Действие с [HttpPost] получава GET заявка на същия адрес. Какъв е отговорът?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·