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

Изгледи с Razor

Проблем

HTML, сглобен от низове

В предишния урок Details върна изглед с три реда. Каталогът обаче е таблица с четири продукта, всяка страница има едно и също меню, а формата за поръчка трябва да не пуска празно име и количество −3.

Да сглобиш всичко това с "<tr><td>" + product.Name + ... е бавно и лесно се греши. И опасно: ако някой даде на продукт име <script>…</script>, низът го слага в страницата като истински код и той тръгва в браузъра на всеки купувач. Тази атака се казва XSS (cross-site scripting).

Виж го

Razor: HTML с C# вътре

Изгледът е файл .cshtml. Той е HTML, а @ превключва към C#. Контролерът подава списъка и заглавие:

public class ProductsController : Controller
{
private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Index()
{
ViewData["Title"] = "Каталог";
return View(products.All());
}
}

Изгледът Views/Products/Index.cshtml:

@model List<Product>
<h1>@ViewData["Title"]</h1>
<table>
<tr><th>Продукт</th><th>Цена</th><th></th></tr>
@foreach (Product product in Model)
{
<tr>
<td>@product.Name</td>
<td>@product.Price €</td>
@if (product.Price > 10)
{
<td>подбрано</td>
}
else
{
<td></td>
}
</tr>
}
</table>
<p>Продукти: @Model.Count</p>
Отговорът
<h1>Каталог</h1>
<table>
<tr><th>Продукт</th><th>Цена</th><th></th></tr>
<tr>
<td>Етиопия Йиргачефе</td>
<td>14.90 €</td>
<td>подбрано</td>
</tr>
<tr>
<td>Колумбия Супремо</td>
<td>12.50 €</td>
<td>подбрано</td>
</tr>
<tr>
<td>Сенча</td>
<td>8.40 €</td>
<td></td>
</tr>
<tr>
<td>Ерл Грей</td>
<td>6.90 €</td>
<td></td>
</tr>
</table>
<p>Продукти: 4</p>
  • @model List<Product> казва какъв обект подава контролерът. В изгледа той е Model. Така изгледът е строго типизиран: грешка като @Model.Cont се хваща още при dotnet build.
  • @product.Name вмъква стойност. @foreach и @if са обикновен C#, а HTML-ът в тях се повтаря или показва според условието.
  • ViewData["Title"] е речник, с който контролерът праща дребни неща на изгледа, освен модела.
  • Всяка стойност се кодира: < става &lt;. Име <script> се показва като текст и не се изпълнява. Това е защитата от XSS, без да правиш нищо.

Шаблонът на изгледа се казва Razor. Няма специален синтаксис за край на C#: Razor сам познава кога започва HTML таг.

Същото в по-кратък изглед, продукт по продукт:

@model List<Product>
<h1>Каталог</h1>
<ul>
@foreach (Product product in Model)
{
<li>@product.Name: @product.Price €</li>
}
</ul>
<p>Продукти: @Model.Count</p>
Стъпка по стъпка, с думи
  1. Обикновеният HTML отива в отговора както е.
  2. Контролерът подава списък с 4 продукта. В изгледа той е Model.
  3. @foreach взима първия продукт. @product.Name става „Етиопия Йиргачефе“, @product.Price става 14.90.
  4. Следващият продукт: същият ред <li> със „Колумбия Супремо“ и 12.50.
  5. Следващият продукт: същият ред <li> със „Сенча“ и 8.40.
  6. Следващият продукт: същият ред <li> със „Ерл Грей“ и 6.90.
  7. Цикълът свърши. @Model.Count вмъква 4. Браузърът получава готов HTML, без нито едно @.
По-точно, за любопитните
  • Израз с интервали или оператори се слага в скоби: @(product.Price * 2). Блок C# без изход е @{ ... }, например @{ ViewData["Title"] = "Каталог"; } в началото на изгледа.
  • Ако наистина искаш да вмъкнеш готов HTML, без кодиране, има @Html.Raw(text). Ползва се само за текст, който си написал ти, никога за въведен от потребителя.
  • @DateTime.Now.Year вмъква текущата година: удобно за „© 2026“ най-долу, която не трябва да сменяш всяка година.
  • Защо AddWebEncoders с UnicodeRanges.All е в Program.cs, е обяснено в урока за MVC: без него кирилицата излиза като &#x41A;. Браузърът я показва правилно и в двата случая.

Пробвай

Кое ще се покаже

Действието Cheap подава всички четири продукта. Какво връща изгледът?

public class ProductsController : Controller
{
private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Cheap() => View(products.All());
}
@model List<Product>
<ul>
@foreach (Product product in Model)
{
if (product.Price < 10)
{
<li>@product.Name: @product.Price €</li>
}
}
</ul>
<p>@Model.Count(p => p.Price < 10) от @Model.Count</p>
Отговор

Идеята

Общ layout

Менюто и долният ред са на всяка страница. Вместо да ги копираш, ги слагаш в layout: рамка, в която влиза страницата. Той е във Views/Shared/_Layout.cshtml:

<!DOCTYPE html>
<html lang="bg">
<head>
<title>@ViewData["Title"] – Кафе и чай</title>
</head>
<body>
<nav>
<a asp-controller="Home" asp-action="Index">Начало</a>
<a asp-controller="Products" asp-action="Index">Продукти</a>
</nav>
<main>
@RenderBody()
</main>
<footer>Кафе и чай, ул. „Шипка“ 12</footer>
</body>
</html>

@RenderBody() е мястото на страницата. Кой layout да се ползва, казва Views/_ViewStart.cshtml, който върви преди всеки изглед:

@{
Layout = "_Layout";
}

<a asp-controller="Products" asp-action="Index"> е таг-помощник (tag helper): атрибутите asp-… се изпълняват на сървъра и сглобяват адреса от маршрута. В отговора става <a href="/Products">. Помощниците се включват веднъж за всички изгледи във Views/_ViewImports.cshtml:

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
По-точно, за любопитните
  • Заглавието на страницата идва от ViewData["Title"], което изгледът или контролерът попълва. Така layout-ът е общ, а заглавието е различно.
  • Изглед може да мине без layout с @{ Layout = null; } или да избере друг, например "_AdminLayout".
  • Layout може да има и секции: @RenderSection("Scripts", required: false) е място, което страницата попълва с @section Scripts { ... }, например със скрипт само за нея.

Виж го

Частичен изглед, ViewData и ViewBag

Редът на продукта ще трябва и в каталога, и в кошницата, и в поръчката. Затова е в частичен изглед (partial view) Views/Shared/_ProductRow.cshtml:

@model Product
<tr><td>@Model.Name</td><td>@Model.Price €</td></tr>

Каталогът го вика за всеки продукт с <partial>:

@model List<Product>
<h1>@ViewData["Title"]</h1>
<p>@ViewBag.Message</p>
<table>
@foreach (Product product in Model)
{
<partial name="_ProductRow" model="product" />
}
</table>

Контролерите слагат заглавието в ViewData, а съобщението в ViewBag:

public class HomeController : Controller
{
public IActionResult Index()
{
ViewData["Title"] = "Начало";
return View();
}
}
public class ProductsController : Controller
{
private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Index()
{
ViewData["Title"] = "Продукти";
ViewBag.Message = "Безплатна доставка над 50 €";
return View(products.All());
}
}

Отговорът на /Products: layout-ът, а в <main> страницата с четирите реда:

Отговорът
<!DOCTYPE html>
<html lang="bg">
<head>
<title>Продукти – Кафе и чай</title>
</head>
<body>
<nav>
<a href="/">Начало</a>
<a href="/Products">Продукти</a>
</nav>
<main>
<h1>Продукти</h1>
<p>Безплатна доставка над 50 €</p>
<table>
<tr><td>Етиопия Йиргачефе</td><td>14.90 €</td></tr>
<tr><td>Колумбия Супремо</td><td>12.50 €</td></tr>
<tr><td>Сенча</td><td>8.40 €</td></tr>
<tr><td>Ерл Грей</td><td>6.90 €</td></tr>
</table>
</main>
<footer>Кафе и чай, ул. „Шипка“ 12</footer>
</body>
</html>
  • ViewData["Message"] и ViewBag.Message са едно и също хранилище. ViewData е речник с ключ низ, а ViewBag е същото, но с точка. И двете не се проверяват при компилиране: правописна грешка в ключа дава празно място. За главните данни ползвай модела.
  • Името на частичния изглед по конвенция започва с _, за да се вижда, че не е цяла страница.
По-точно, за любопитните
  • В по-старите проекти частичният изглед се вика с @Html.Partial("_ProductRow", product). То работи и днес, но чака синхронно и компилаторът предупреждава. Новият начин е <partial> или @await Html.PartialAsync(...).
  • Частичен изглед със собствена логика (например кошницата в менюто, която сама брои продуктите) е View Component: клас плюс изглед, който се вика от layout-а.
  • Празните редове между <tr> са от това, че <partial> е на отделен ред в цикъла. Браузърът ги пренебрегва.

Пробвай

Мястото на страницата

Какво вика layout-ът там, където влиза страницата?

<body>
    <nav>…</nav>
    <main>
        @()
    </main>
</body>

Идеята

Форма, свързване и валидация

Поръчката е клас. Атрибутите над свойствата казват какво е допустимо:

public class Order
{
public int Id { get; set; }
[Required(ErrorMessage = "Въведи име.")]
public string Name { get; set; }
[Required(ErrorMessage = "Въведи имейл.")]
[EmailAddress(ErrorMessage = "Това не е имейл.")]
public string Email { get; set; }
[Range(1, 20, ErrorMessage = "Количеството е от 1 до 20.")]
public int Quantity { get; set; }
}

Формата е изглед с модел Order. asp-for прави поле за свойството с правилните name, id и type, а asp-validation-for е място за грешката му:

@model Order
<form asp-action="Create" method="post">
<label asp-for="Name">Име</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label asp-for="Email">Имейл</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
<label asp-for="Quantity">Брой</label>
<input asp-for="Quantity" />
<span asp-validation-for="Quantity"></span>
<button>Поръчай</button>
</form>

Контролерът има две действия Create: с GET показва празната форма, а с POST я приема:

public class OrdersController : Controller
{
private readonly OrderBook book;
public OrdersController(OrderBook book) => this.book = book;
[HttpGet]
public IActionResult Create() => View(new Order { Quantity = 1 });
[HttpPost]
public IActionResult Create(Order order)
{
if (!ModelState.IsValid) return View(order);
int id = book.Add(order);
return RedirectToAction("Thanks", new { id });
}
public IActionResult Thanks(int id)
{
Order order = book.Find(id);
if (order == null) return NotFound();
return View(order);
}
}
  • Create(Order order) получава готов обект. ASP.NET Core взима полетата от формата по имената им (Name, Email, Quantity) и ги слага в свойствата. Това се казва свързване на модела (model binding).
  • Докато свързва, проверява атрибутите. Резултатът е в ModelState: IsValid е false, ако някое правило не е спазено.
  • При грешка връщаш същата форма с View(order): въведеното остава, а до всяко поле е грешката му.
  • При успех записваш и пренасочваш. new { id } е обект с едно свойство id, от което се сглобява адресът /Orders/Thanks/1. Шаблонът се казва POST, пренасочване, GET (PRG): презареждането на страницата „Благодарим“ не праща поръчката втори път.

Виж го

Формата в действие

Клиентът отваря празната форма, праща грешна и после вярна:

var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false });
string site = "http://localhost:5244";
string empty = await http.GetStringAsync(site + "/Orders/Create");
Console.WriteLine(empty.Split('\n').First(line => line.Contains("id=\"Name\"")).Trim());
var bad = new FormUrlEncodedContent(new Dictionary<string, string> { ["Name"] = "", ["Email"] = "ana@", ["Quantity"] = "0" });
HttpResponseMessage rejected = await http.PostAsync(site + "/Orders/Create", bad);
Console.WriteLine("Грешна форма: " + (int)rejected.StatusCode);
string page = await rejected.Content.ReadAsStringAsync();
foreach (string line in page.Split('\n').Where(line => line.Contains("field-validation-error") || line.Contains("value=\"ana@\"")))
{
Console.WriteLine(line.Trim());
}
var good = new FormUrlEncodedContent(new Dictionary<string, string> { ["Name"] = "Ана", ["Email"] = "ana@example.bg", ["Quantity"] = "2" });
HttpResponseMessage accepted = await http.PostAsync(site + "/Orders/Create", good);
Console.WriteLine("Вярна форма: " + (int)accepted.StatusCode + ", Location: " + accepted.Headers.Location);
Console.Write(await http.GetStringAsync(site + accepted.Headers.Location));
Изход
<input type="text" data-val="true" data-val-required="Въведи име." id="Name" name="Name" value="" />
Грешна форма: 200
<span class="field-validation-error" data-valmsg-for="Name" data-valmsg-replace="true">Въведи име.</span>
<input type="email" class="input-validation-error" data-val="true" data-val-email="Това не е имейл." data-val-required="Въведи имейл." id="Email" name="Email" value="ana@" />
<span class="field-validation-error" data-valmsg-for="Email" data-valmsg-replace="true">Това не е имейл.</span>
<span class="field-validation-error" data-valmsg-for="Quantity" data-valmsg-replace="true">Количеството е от 1 до 20.</span>
Вярна форма: 302, Location: /Orders/Thanks/1
<p>Благодарим, Ана! Поръчка № 1 за 2 бр. е приета.</p>
<p>Потвърждение ще получиш на ana@example.bg.</p>
  • Първият ред е полето, което asp-for направи от Name. data-val-required носи съобщението до браузъра: с JavaScript библиотеката за валидация той показва грешката, още преди формата да тръгне.
  • Грешната форма връща 200: същата страница, с класа field-validation-error и съобщенията. Имейлът пази въведеното ana@.
  • Вярната форма е 302 към /Orders/Thanks/1, а там е потвърждението от изгледа Thanks.cshtml:
@model Order
<p>Благодарим, @Model.Name! Поръчка № @Model.Id за @Model.Quantity бр. е приета.</p>
<p>Потвърждение ще получиш на @Model.Email.</p>
По-точно, за любопитните
  • Проверката в браузъра е за удобство. Проверката, на която разчиташ, е ModelState.IsValid на сървъра: всеки може да прати POST и без браузър, както тази програма.
  • Още атрибути: [StringLength(50)], [Phone], [Compare("Password")] (две полета да съвпадат), [RegularExpression(...)]. [DataType(DataType.Password)] не проверява нищо, а казва на asp-for да направи type="password".
  • <form asp-action="Create" method="post"> слага и скрито поле __RequestVerificationToken. С [ValidateAntiForgeryToken] над POST действието сървърът приема само форми, които сам е дал. Така чужд сайт не може да прати поръчка от името на влезлия потребител (атаката се казва CSRF).
  • Quantity е int, затова е задължително и без [Required]: празно поле не може да стане число.

Пробвай

Поръчката минава без име

Формата с празно име се записва и потребителят вижда „Благодарим, !“. А вярната форма се връща с грешки. На кой ред е проблемът?

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

Упражнение

Страниците на магазина

Реши задачите в проект от dotnet new mvc. Продуктите и IProductService са от урока за MVC.

1. Таблица с продукти. Index подава продуктите подредени по цена. Изгледът е таблица с колони продукт, категория и цена, а последният ред е общата сума с @Model.Sum(...):

Отговорът на /Products
<table>
<tr><th>Продукт</th><th>Категория</th><th>Цена</th></tr>
<tr><td>Ерл Грей</td><td>чай</td><td>6.90 €</td></tr>
<tr><td>Сенча</td><td>чай</td><td>8.40 €</td></tr>
<tr><td>Колумбия Супремо</td><td>кафе</td><td>12.50 €</td></tr>
<tr><td>Етиопия Йиргачефе</td><td>кафе</td><td>14.90 €</td></tr>
<tr><td colspan="2">Общо</td><td>42.70 €</td></tr>
</table>

2. Общ layout с меню. HomeController има Index и Contacts. Layout-ът е с меню от тагове-помощници и заглавие от ViewData["Title"]. Contacts праща телефона с ViewBag.Phone:

Отговорът на /Home/Contacts
<!DOCTYPE html>
<html lang="bg">
<head>
<title>Контакти – Кафе и чай</title>
</head>
<body>
<nav>
<a href="/">Начало</a>
<a href="/Home/Contacts">Контакти</a>
</nav>
<h1>Контакти</h1>
<p>Телефон: 02 123 4567</p>
</body>
</html>

3. Запис за дегустация. Формата Booking има име ([Required]), имейл ([Required], [EmailAddress]) и брой места от 1 до 8 ([Range]). При грешка се връща формата, а при успех се пренасочва към Done, което казва колко места са запазени и колко са общо досега. Клиентът праща три форми: грешна (имейл без @ и 12 места) и две верни. При грешна печата броя на съобщенията, а при вярна страницата, до която стига след пренасочването:

Изход
Грешки: 2
Запазени места: 3. Общо за дегустацията: 3.
Запазени места: 2. Общо за дегустацията: 5.

Тест

Провери се

1. Какво прави @product.Name в Razor изглед?
2. Къде в _Layout.cshtml влиза съдържанието на конкретната страница?
3. Формата е пратена с празно име, а Name е с [Required]. Какво е ModelState.IsValid?
4. Защо при грешна форма действието връща View(order), а не RedirectToAction?
5. Какво дава <a asp-controller="Products" asp-action="Index">?

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