Zum Inhalt
wannacode

Views mit Razor

Kurz gesagt: Razor ist die Template-Sprache der Views in ASP.NET Core: HTML mit C# darin, mit einem Model, Schleifen, einem gemeinsamen Layout, Partial Views und Formularen mit Validierung.

Problem

HTML, aus Strings zusammengeklebt

In der vorigen Lektion hat Details eine View mit drei Zeilen zurückgegeben. Der Katalog ist aber eine Tabelle mit vier Produkten, jede Seite hat dasselbe Menü, und das Bestellformular darf keinen leeren Namen und keine Menge von −3 annehmen.

Das alles mit "<tr><td>" + product.Name + ... zusammenzukleben ist langsam, und man vertut sich leicht. Es ist auch gefährlich: Wenn jemand einem Produkt den Namen <script>…</script> gibt, setzt der String ihn als echten Code in die Seite, und er läuft im Browser jeder Kundin und jedes Kunden. Dieser Angriff heißt XSS (Cross-Site Scripting).

Sieh es dir an

Razor: HTML mit C# darin

Eine View ist eine Datei .cshtml. Sie ist HTML, und @ schaltet auf C# um. Der Controller übergibt die Liste und einen Titel:

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

Die View Views/Products/Index.cshtml:

@model List<Product>
<h1>@ViewData["Title"]</h1>
<table>
<tr><th>Produkt</th><th>Preis</th><th></th></tr>
@foreach (Product product in Model)
{
<tr>
<td>@product.Name</td>
<td>@product.Price €</td>
@if (product.Price > 10)
{
<td>empfohlen</td>
}
else
{
<td></td>
}
</tr>
}
</table>
<p>Produkte: @Model.Count</p>
Die Antwort
<h1>Katalog</h1>
<table>
<tr><th>Produkt</th><th>Preis</th><th></th></tr>
<tr>
<td>Ethiopia Yirgacheffe</td>
<td>14.90 €</td>
<td>empfohlen</td>
</tr>
<tr>
<td>Colombia Supremo</td>
<td>12.50 €</td>
<td>empfohlen</td>
</tr>
<tr>
<td>Sencha</td>
<td>8.40 €</td>
<td></td>
</tr>
<tr>
<td>Earl Grey</td>
<td>6.90 €</td>
<td></td>
</tr>
</table>
<p>Produkte: 4</p>
  • @model List<Product> sagt, welche Art von Objekt der Controller übergibt. In der View ist es Model. Dadurch ist die View stark typisiert: Ein Fehler wie @Model.Cont fällt schon bei dotnet build auf.
  • @product.Name setzt einen Wert ein. @foreach und @if sind gewöhnliches C#, und das HTML darin wird wiederholt oder je nach Bedingung gezeigt.
  • ViewData["Title"] ist ein Dictionary, mit dem der Controller neben dem Model kleine Dinge an die View schickt.
  • Jeder Wert wird kodiert: < wird zu &lt;. Ein Name wie <script> wird als Text gezeigt und läuft nicht. Das ist der Schutz vor XSS, ohne dass du etwas tun musst.

Die Template-Sprache der View heißt Razor. Für das Ende des C# gibt es keine besondere Syntax: Razor erkennt selbst, wann ein HTML-Tag beginnt.

Dasselbe in einer kürzeren View, Produkt für Produkt:

@model List<Product>
<h1>Katalog</h1>
<ul>
@foreach (Product product in Model)
{
<li>@product.Name: @product.Price €</li>
}
</ul>
<p>Produkte: @Model.Count</p>
Schritt für Schritt, in Worten
  1. Reines HTML kommt so, wie es ist, in die Antwort.
  2. Der Controller übergibt eine Liste mit 4 Produkten. In der View ist sie Model.
  3. @foreach nimmt das erste Produkt. @product.Name wird zu „Ethiopia Yirgacheffe“ und @product.Price zu 14.90.
  4. Das nächste Produkt: dieselbe Zeile <li> mit „Colombia Supremo“ und 12.50.
  5. Das nächste Produkt: dieselbe Zeile <li> mit „Sencha“ und 8.40.
  6. Das nächste Produkt: dieselbe Zeile <li> mit „Earl Grey“ und 6.90.
  7. Die Schleife ist zu Ende. @Model.Count setzt 4 ein. Der Browser bekommt fertiges HTML, ohne ein einziges @.
Genauer, für Neugierige
  • Ein Ausdruck mit Leerzeichen oder Operatoren kommt in Klammern: @(product.Price * 2). Ein Block C# ohne Ausgabe ist @{ ... }, zum Beispiel @{ ViewData["Title"] = "Katalog"; } am Anfang der View.
  • Wenn du wirklich fertiges HTML ohne Kodierung einsetzen willst, gibt es @Html.Raw(text). Nimm es nur für Text, den du selbst geschrieben hast, nie für Text, den ein Nutzer eingegeben hat.
  • @DateTime.Now.Year setzt das aktuelle Jahr ein: praktisch für das „© 2026“ unten, das du nicht jedes Jahr ändern willst.
  • Warum AddWebEncoders mit UnicodeRanges.All in Program.cs steht, erklärt die MVC-Lektion: Ohne die Zeile kommen Buchstaben außerhalb des einfachen lateinischen Alphabets als Codes wie &#xFC; heraus. Der Browser zeigt sie so oder so richtig an.

Probier es aus

Was angezeigt wird

Die Action Cheap übergibt alle vier Produkte. Was gibt die View zurück?

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) von @Model.Count</p>
Antwort

Die Idee

Ein gemeinsames Layout

Menü und Fußzeile stehen auf jeder Seite. Statt sie zu kopieren, legst du sie in ein Layout: einen Rahmen, in den die Seite kommt. Es liegt in Views/Shared/_Layout.cshtml:

<!DOCTYPE html>
<html lang="de">
<head>
<title>@ViewData["Title"] | Kaffee und Tee</title>
</head>
<body>
<nav>
<a asp-controller="Home" asp-action="Index">Startseite</a>
<a asp-controller="Products" asp-action="Index">Produkte</a>
</nav>
<main>
@RenderBody()
</main>
<footer>Kaffee und Tee, Hauptstraße 12</footer>
</body>
</html>

@RenderBody() ist die Stelle für die Seite. Welches Layout verwendet wird, steht in Views/_ViewStart.cshtml, das vor jeder View läuft:

@{
Layout = "_Layout";
}

<a asp-controller="Products" asp-action="Index"> ist ein Tag-Helper: Die Attribute asp-… laufen auf dem Server und bauen die Adresse aus der Route. In der Antwort wird daraus <a href="/Products">. Die Helper schaltest du einmal für alle Views in Views/_ViewImports.cshtml ein:

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
Genauer, für Neugierige
  • Der Titel der Seite kommt aus ViewData["Title"], das die View oder der Controller füllt. So ist das Layout gemeinsam, aber der Titel verschieden.
  • Eine View kommt mit @{ Layout = null; } ohne Layout aus oder wählt ein anderes, etwa "_AdminLayout".
  • Ein Layout kann auch Sections haben: @RenderSection("Scripts", required: false) ist eine Stelle, die die Seite mit @section Scripts { ... } füllt, zum Beispiel mit einem Skript nur für diese Seite.

Sieh es dir an

Partial Views, ViewData und ViewBag

Die Zeile eines Produkts wird im Katalog, im Warenkorb und in der Bestellung gebraucht. Also kommt sie in eine Partial View, Views/Shared/_ProductRow.cshtml:

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

Der Katalog ruft sie mit <partial> für jedes Produkt auf:

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

Die Controller legen den Titel in ViewData und die Meldung in ViewBag:

public class HomeController : Controller
{
public IActionResult Index()
{
ViewData["Title"] = "Startseite";
return View();
}
}
public class ProductsController : Controller
{
private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Index()
{
ViewData["Title"] = "Produkte";
ViewBag.Message = "Kostenlose Lieferung ab 50 €";
return View(products.All());
}
}

Die Antwort für /Products: das Layout und in <main> die Seite mit den vier Zeilen:

Die Antwort
<!DOCTYPE html>
<html lang="de">
<head>
<title>Produkte | Kaffee und Tee</title>
</head>
<body>
<nav>
<a href="/">Startseite</a>
<a href="/Products">Produkte</a>
</nav>
<main>
<h1>Produkte</h1>
<p>Kostenlose Lieferung ab 50 €</p>
<table>
<tr><td>Ethiopia Yirgacheffe</td><td>14.90 €</td></tr>
<tr><td>Colombia Supremo</td><td>12.50 €</td></tr>
<tr><td>Sencha</td><td>8.40 €</td></tr>
<tr><td>Earl Grey</td><td>6.90 €</td></tr>
</table>
</main>
<footer>Kaffee und Tee, Hauptstraße 12</footer>
</body>
</html>
  • ViewData["Message"] und ViewBag.Message sind derselbe Speicher. ViewData ist ein Dictionary mit String-Schlüsseln, und ViewBag ist dasselbe, aber mit Punkt. Keins von beiden wird beim Kompilieren geprüft: Ein Tippfehler im Schlüssel ergibt eine leere Stelle. Für die Hauptdaten nimm das Model.
  • Nach Konvention beginnt der Name einer Partial View mit _, damit man sieht, dass sie keine ganze Seite ist.
Genauer, für Neugierige
  • In älteren Projekten wird eine Partial View mit @Html.Partial("_ProductRow", product) aufgerufen. Das funktioniert auch heute, wartet aber synchron, und der Compiler warnt davor. Der neue Weg ist <partial> oder @await Html.PartialAsync(...).
  • Eine Partial View mit eigener Logik (zum Beispiel der Warenkorb im Menü, der seine Produkte selbst zählt) ist eine View Component: eine Klasse plus eine View, aus dem Layout aufgerufen.
  • Die Leerzeilen zwischen den <tr> kommen daher, dass <partial> in der Schleife in einer eigenen Zeile steht. Der Browser ignoriert sie.

Probier es aus

Wohin die Seite kommt

Was ruft das Layout dort auf, wo die Seite hineinkommt?

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

Die Idee

Formulare, Binding und Validierung

Eine Bestellung ist eine Klasse. Die Attribute über den Eigenschaften sagen, was erlaubt ist:

public class Order
{
public int Id { get; set; }
[Required(ErrorMessage = "Gib einen Namen ein.")]
public string Name { get; set; }
[Required(ErrorMessage = "Gib eine E-Mail-Adresse ein.")]
[EmailAddress(ErrorMessage = "Das ist keine E-Mail-Adresse.")]
public string Email { get; set; }
[Range(1, 20, ErrorMessage = "Die Menge muss zwischen 1 und 20 liegen.")]
public int Quantity { get; set; }
}

Das Formular ist eine View mit dem Model Order. asp-for macht für die Eigenschaft ein Feld mit dem richtigen name, id und type, und asp-validation-for ist ein Platz für ihren Fehler:

@model Order
<form asp-action="Create" method="post">
<label asp-for="Name">Name</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label asp-for="Email">E-Mail</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
<label asp-for="Quantity">Menge</label>
<input asp-for="Quantity" />
<span asp-validation-for="Quantity"></span>
<button>Order</button>
</form>

Der Controller hat zwei Actions Create: Mit GET zeigt er das leere Formular, mit POST nimmt er es entgegen:

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) bekommt ein fertiges Objekt. ASP.NET Core nimmt die Felder des Formulars nach ihren Namen (Name, Email, Quantity) und setzt sie in die Eigenschaften. Das heißt Model Binding.
  • Beim Binding prüft es die Attribute. Das Ergebnis steht in ModelState: IsValid ist false, wenn eine Regel verletzt ist.
  • Bei einem Fehler gibst du mit View(order) dasselbe Formular zurück: Was getippt wurde, bleibt, und jedes Feld hat seinen Fehler daneben.
  • Bei Erfolg speicherst du und leitest weiter. new { id } ist ein Objekt mit einer Eigenschaft, id, aus der die Adresse /Orders/Thanks/1 gebaut wird. Das Muster heißt POST, Redirect, GET (PRG): Wer die Seite „Danke“ neu lädt, schickt die Bestellung nicht ein zweites Mal.

Sieh es dir an

Das Formular in Aktion

Der Client öffnet das leere Formular, schickt ein falsches und dann ein richtiges:

var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false });
string site = "http://localhost:7244";
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"] = "anna@", ["Quantity"] = "0" });
HttpResponseMessage rejected = await http.PostAsync(site + "/Orders/Create", bad);
Console.WriteLine("Ungültiges Formular: " + (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=\"anna@\"")))
{
Console.WriteLine(line.Trim());
}
var good = new FormUrlEncodedContent(new Dictionary<string, string> { ["Name"] = "Anna", ["Email"] = "anna@example.bg", ["Quantity"] = "2" });
HttpResponseMessage accepted = await http.PostAsync(site + "/Orders/Create", good);
Console.WriteLine("Gültiges Formular: " + (int)accepted.StatusCode + ", Location: " + accepted.Headers.Location);
Console.Write(await http.GetStringAsync(site + accepted.Headers.Location));
Ausgabe
<input type="text" data-val="true" data-val-required="Gib einen Namen ein." id="Name" name="Name" value="" />
Ungültiges Formular: 200
<span class="field-validation-error" data-valmsg-for="Name" data-valmsg-replace="true">Gib einen Namen ein.</span>
<input type="email" class="input-validation-error" data-val="true" data-val-email="Das ist keine E-Mail-Adresse." data-val-required="Gib eine E-Mail-Adresse ein." id="Email" name="Email" value="anna@" />
<span class="field-validation-error" data-valmsg-for="Email" data-valmsg-replace="true">Das ist keine E-Mail-Adresse.</span>
<span class="field-validation-error" data-valmsg-for="Quantity" data-valmsg-replace="true">Die Menge muss zwischen 1 und 20 liegen.</span>
Gültiges Formular: 302, Location: /Orders/Thanks/1
<p>Danke, Anna! Die Bestellung Nr. 1 über 2 Artikel ist eingegangen.</p>
<p>Du bekommst eine Bestätigung an anna@example.bg.</p>
  • Die erste Zeile ist das Feld, das asp-for aus Name gemacht hat. data-val-required trägt die Meldung zum Browser: Mit der JavaScript-Bibliothek für Validierung zeigt er den Fehler schon, bevor das Formular geschickt wird.
  • Das falsche Formular gibt 200 zurück: dieselbe Seite, mit der Klasse field-validation-error und den Meldungen. Die E-Mail behält, was getippt wurde, anna@.
  • Das richtige Formular ist eine 302 auf /Orders/Thanks/1, und dort steht die Bestätigung aus der View Thanks.cshtml:
@model Order
<p>Danke, @Model.Name! Die Bestellung Nr. @Model.Id über @Model.Quantity Artikel ist eingegangen.</p>
<p>Du bekommst eine Bestätigung an @Model.Email.</p>
Genauer, für Neugierige
  • Die Prüfung im Browser ist Komfort. Die Prüfung, auf die du dich verlässt, ist ModelState.IsValid auf dem Server: Jeder kann ein POST ohne Browser schicken, genau wie dieses Programm.
  • Weitere Attribute: [StringLength(50)], [Phone], [Compare("Password")] (zwei Felder müssen übereinstimmen), [RegularExpression(...)]. [DataType(DataType.Password)] prüft nichts; es sagt asp-for, dass es type="password" machen soll.
  • <form asp-action="Create" method="post"> fügt außerdem ein verstecktes Feld __RequestVerificationToken hinzu. Mit [ValidateAntiForgeryToken] über der POST-Action nimmt der Server nur Formulare an, die er selbst ausgegeben hat. So kann eine andere Website keine Bestellung im Namen des angemeldeten Nutzers schicken (der Angriff heißt CSRF).
  • Quantity ist ein int, also ist es auch ohne [Required] Pflicht: Aus einem leeren Feld wird keine Zahl.

Probier es aus

Die Bestellung geht ohne Namen durch

Ein Formular mit leerem Namen wird gespeichert, und der Nutzer sieht „Danke, !“. Und ein richtiges Formular kommt mit Fehlern zurück. In welcher Zeile liegt das Problem?

Wähle die Zeile mit dem Fehler.

Übung

Die Seiten des Shops

Löse die Aufgaben in einem Projekt aus dotnet new mvc. Die Produkte und IProductService sind aus der MVC-Lektion.

1. Eine Produkttabelle. Index übergibt die Produkte nach Preis sortiert. Die View ist eine Tabelle mit Spalten für Produkt, Kategorie und Preis, und die letzte Zeile ist die Summe mit @Model.Sum(...):

Die Antwort für /Products
<table>
<tr><th>Produkt</th><th>Kategorie</th><th>Preis</th></tr>
<tr><td>Earl Grey</td><td>tee</td><td>6.90 €</td></tr>
<tr><td>Sencha</td><td>tee</td><td>8.40 €</td></tr>
<tr><td>Colombia Supremo</td><td>kaffee</td><td>12.50 €</td></tr>
<tr><td>Ethiopia Yirgacheffe</td><td>kaffee</td><td>14.90 €</td></tr>
<tr><td colspan="2">Summe</td><td>42.70 €</td></tr>
</table>

2. Ein gemeinsames Layout mit Menü. HomeController hat Index und Contacts. Das Layout hat ein Menü aus Tag-Helpern und einen Titel aus ViewData["Title"]. Contacts schickt die Telefonnummer mit ViewBag.Phone:

Die Antwort für /Home/Contacts
<!DOCTYPE html>
<html lang="de">
<head>
<title>Kontakt | Kaffee und Tee</title>
</head>
<body>
<nav>
<a href="/">Startseite</a>
<a href="/Home/Contacts">Kontakt</a>
</nav>
<h1>Kontakt</h1>
<p>Telefon: 030 123 4567</p>
</body>
</html>

3. Eine Verkostung buchen. Das Formular Booking hat einen Namen ([Required]), eine E-Mail ([Required], [EmailAddress]) und eine Zahl von Plätzen von 1 bis 8 ([Range]). Bei einem Fehler kommt das Formular zurück, bei Erfolg leitet es auf Done weiter, das sagt, wie viele Plätze gebucht wurden und wie viele es bisher insgesamt sind. Der Client schickt drei Formulare: ein falsches (eine E-Mail ohne @ und 12 Plätze) und zwei richtige. Für ein falsches gibt er die Zahl der Meldungen aus, für ein richtiges die Seite, auf der er nach der Weiterleitung landet:

Ausgabe
Fehler: 2
Gebuchte Plätze: 3. Insgesamt für die Verkostung: 3.
Gebuchte Plätze: 2. Insgesamt für die Verkostung: 5.

Test

Teste dich

1. Was macht @product.Name in einer Razor-View?
2. Wohin in _Layout.cshtml kommt der Inhalt der jeweiligen Seite?
3. Das Formular wird mit leerem Namen geschickt, und Name hat [Required]. Was ist ModelState.IsValid?
4. Warum gibt die Action bei einem falschen Formular View(order) zurück und nicht RedirectToAction?
5. Was ergibt <a asp-controller="Products" asp-action="Index">?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·