Ein Webserver mit Minimal API
Kurz gesagt: Ein Webserver ist ein Programm, das HTTP-Anfragen beantwortet; in C# schreibst du ihn mit ASP.NET Core, und die Minimal API beschreibt die Routen mit MapGet und MapPost in wenigen Zeilen.
Problem
Der Katalog muss von überall sichtbar sein
Der Katalog des Shops ist in deinem Programm schon eine Liste von Produkten, und du kannst ihn mit LINQ filtern. Aber er lebt im Speicher deines Computers. Ein Kunde öffnet den Shop im Browser, ein anderer in der App auf seinem Handy. Beide Programme sprechen HTTP: Sie schicken eine Anfrage wie GET /products/3 und warten auf eine Antwort mit einem Status und einem Body.
Du brauchst ein Programm, das auf einem Port lauscht, HTTP-Anfragen empfängt und sie beantwortet. Das ist ein Webserver.
Sieh es dir an
Server und Client in einem Programm
In C# ist der Webserver ASP.NET Core. Der einfachste Weg, ihn zu verwenden, ist die Minimal API: Der Server und seine Adressen werden in wenigen Zeilen beschrieben. Dieses Programm startet einen Server und stellt ihm dann als Client eine Frage:
var builder = WebApplication.CreateBuilder();builder.Logging.ClearProviders();var app = builder.Build();app.Urls.Add("http://localhost:7201");
app.MapGet("/", () => "Kaffee und Tee: geöffnet");await app.StartAsync();
using var http = new HttpClient();string text = await http.GetStringAsync("http://localhost:7201/");Console.WriteLine(text);
await app.StopAsync();Kaffee und Tee: geöffnetWebApplication.CreateBuilder()undBuild()bauen die Anwendung.app.Urls.Addsagt, auf welcher Adresse und welchem Port gelauscht wird.MapGet("/", lambda)sagt: Auf eineGET-Anfrage an den Pfad/antworte mit dem, was das Lambda zurückgibt. Der Text kommt in den Body der Antwort.await app.StartAsync()startet den Server und wartet nicht: Es geht zur nächsten Zeile weiter.await app.StopAsync()stoppt ihn am Ende.HttpClientist der Client.GetStringAsync(adresse)schickt eineGET-Anfrage und gibt dir den Body der Antwort als String.
Hier steht var, weil die Typen lang sind (WebApplicationBuilder, WebApplication). using var http bedeutet, dass der Client sich von selbst schließt, wenn das Programm endet.
Genauer, für Neugierige
builder.Logging.ClearProviders()entfernt die Meldungen, die ASP.NET in die Konsole schreibt, etwa "Now listening on: http://localhost:7201". In einem echten Server sind sie nützlich. Hier entfernen wir sie, damit in der Ausgabe nur unser Text steht.- Ein echter Server endet mit
app.Run(): Er lauscht, bis du ihn mit Strg+C stoppst. Dann ist der Client ein anderes Programm: der Browser (öffnehttp://localhost:7201/) oder die App auf dem Handy. Hier sind Server und Client in einem Programm, damit sich der Code ohne zweiten Computer prüfen lässt. - Ein Projekt erstellst du mit
dotnet new web. Das Programm steht inProgram.cs, und dieusing-Anweisungen für das Web sind standardmäßig dabei. - Ein
HttpClientist für viele Anfragen da. Erstelle nicht für jede Anfrage einen neuen: Er hält die Verbindungen zum Server offen und verwendet sie wieder.
Sieh es dir an
Routen: Welche Adresse liefert was
Der Server vergleicht den Pfad jeder Anfrage mit seinen Routen und ruft das Lambda der passenden auf. Ein Teil des Pfads kann ein Parameter sein:
app.MapGet("/products/{id:int}", (int id) =>{ Product product = catalog.FirstOrDefault(p => p.Id == id); return product == null ? "Dieses Produkt gibt es nicht" : product.Name + ": " + product.Price + " €";});app.MapGet("/search", (string q) => string.Join(", ", catalog.Where(p => p.Name.Contains(q)).Select(p => p.Name)));using var http = new HttpClient();Console.WriteLine(await http.GetStringAsync("http://localhost:7202/products/3"));Console.WriteLine(await http.GetStringAsync("http://localhost:7202/products/9"));Console.WriteLine(await http.GetStringAsync("http://localhost:7202/search?q=bohnen"));Kolumbienbohnen: 31.50 €Dieses Produkt gibt es nichtEspressobohnen, Kolumbienbohnen{id:int}in der Route ist ein Parameter im Pfad:/products/3passt, undidist 3.:intist eine Einschränkung: Nur Zahlen passen.- Das Lambda hat einen Parameter mit demselben Namen,
int id. ASP.NET nimmt den Wert aus dem Pfad und macht von selbst einenintdaraus. (string q)hat keinen Platz im Pfad, also wird er aus der Query nach?genommen:/search?q=bohnenergibtq = "bohnen".catalogist die Liste der Produkte aus der LINQ-Lektion. Die Lambdas sehen sie, weil sie im selben Code stehen, nach ihr.
Genauer, für Neugierige
- Die ganze Datei. Die Klasse
Productsteht nach dem Code, wie in der LINQ-Lektion:
var builder = WebApplication.CreateBuilder();builder.Logging.ClearProviders();var app = builder.Build();app.Urls.Add("http://localhost:7202");
List<Product> catalog = new List<Product>{ new Product { Id = 1, Name = "Espressobohnen", Price = 24.90m, Category = "kaffee" }, new Product { Id = 2, Name = "Sencha-Grüntee", Price = 9.80m, Category = "tee" }, new Product { Id = 3, Name = "Kolumbienbohnen", Price = 31.50m, Category = "kaffee" }, new Product { Id = 4, Name = "Pfefferminze", Price = 4.20m, Category = "tee" },};
app.MapGet("/products/{id:int}", (int id) =>{ Product product = catalog.FirstOrDefault(p => p.Id == id); return product == null ? "Dieses Produkt gibt es nicht" : product.Name + ": " + product.Price + " €";});app.MapGet("/search", (string q) => string.Join(", ", catalog.Where(p => p.Name.Contains(q)).Select(p => p.Name)));await app.StartAsync();
using var http = new HttpClient();Console.WriteLine(await http.GetStringAsync("http://localhost:7202/products/3"));Console.WriteLine(await http.GetStringAsync("http://localhost:7202/products/9"));Console.WriteLine(await http.GetStringAsync("http://localhost:7202/search?q=bohnen"));
await app.StopAsync();
public class Product{ public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public string Category { get; set; }}- Wenn die Adresse kein
?q=hat, istqhiernull,Contains(null)wirft eine Exception, und der Server antwortet mit 500. Ein Projekt ausdotnet new webhat die Prüfung aufnulleingeschaltet (<Nullable>enable</Nullable>), und dann antwortet ASP.NET von selbst mit 400, wenn ein Pflichtparameter fehlt. - Andere Einschränkungen:
{price:decimal},{code:alpha}(nur Buchstaben),{id:int:min(1)}. Ein Parameter mit?ist optional:/products/{page:int?}.
Sieh es dir an
Anfrage und Antwort
app.MapGet("/products/{id:int}", (int id) =>{ Product product = catalog.FirstOrDefault(p => p.Id == id); return product == null ? Results.NotFound() : Results.Ok(product);});await app.StartAsync();
using var http = new HttpClient();HttpResponseMessage response = await http.GetAsync("http://localhost:7219/products/2");Console.WriteLine((int)response.StatusCode);Console.WriteLine(await response.Content.ReadAsStringAsync());Schritt für Schritt, in Worten
- Der Server registriert eine Route: Die Adresse /products/<Nummer> führt zu dieser Methode.
- StartAsync: Der Server beginnt, auf Port 7219 zu lauschen.
- Der Client schickt eine Anfrage: Methode GET, Pfad /products/2.
- Der Pfad passt zur Route. 2 ist eine Zahl, also id = 2.
- Das Produkt ist im Katalog, also gibt die Methode Results.Ok damit zurück. Der Server macht JSON daraus.
- Die Antwort kommt zurück: Status 200 und ein Body mit JSON.
- Der Client gibt den Status und den Body aus.
Ein Lambda kann nicht nur einen Wert zurückgeben, sondern eine ganze Antwort. Results.Ok(objekt) ist Status 200 mit dem Objekt im Body, und Results.NotFound() ist 404 ohne Body. Der Server macht aus dem Produkt von selbst JSON, einen Text, der das Objekt beschreibt.
Auf der Seite des Clients gibt dir GetAsync die ganze Antwort als HttpResponseMessage. StatusCode ist der Status ((int) zeigt ihn als Zahl), und Content.ReadAsStringAsync() liest den Body. GetStringAsync aus den vorigen Karten wirft bei jedem Status außer 2xx eine Exception. GetAsync wirft nicht, also siehst du damit auch die 404.
Dasselbe für den Katalog, mit einem Produkt, das da ist, und einem, das fehlt:
app.MapGet("/products/{id:int}", (int id) =>{ Product product = catalog.FirstOrDefault(p => p.Id == id); return product == null ? Results.NotFound() : Results.Ok(product);});using var http = new HttpClient();foreach (string path in new string[] { "/products/2", "/products/9" }){ HttpResponseMessage response = await http.GetAsync("http://localhost:7203" + path); Console.WriteLine(path + " -> " + (int)response.StatusCode + " " + response.StatusCode); Console.WriteLine(await response.Content.ReadAsStringAsync());}/products/2 -> 200 OK{"id":2,"name":"Sencha-Grüntee","price":9.80,"category":"tee"}/products/9 -> 404 NotFoundProbier es aus
Text statt einer Zahl
Die Route ist dieselbe, /products/{id:int}. Der Client fragt nach einem Produkt per Name:
Was gibt es aus?
HttpResponseMessage response = await http.GetAsync("http://localhost:7204/products/espresso");Console.WriteLine((int)response.StatusCode);espresso ist keine Zahl, also passt {id:int} nicht. Für diesen Pfad gibt es keine andere Route, und der Server antwortet mit 404: „Diese Adresse gibt es nicht.“ Das Lambda wird nie aufgerufen.
Sieh es dir an
JSON: Objekte über das Netz
Über das Netz reist Text, keine Objekte. JSON (JavaScript Object Notation) ist die verbreitetste Art, ein Objekt als Text zu schreiben: {"id":2,"name":"Sencha-Grüntee","price":9.80,"category":"tee"}. Die Eigenschaften sind Paare "name":wert in geschweiften Klammern, und eine Liste steht in eckigen Klammern: [{…},{…}]. Jede Sprache und jeder Browser versteht es.
Wenn das Lambda eine Liste zurückgibt, macht der Server daraus ein JSON-Array. Im Client liest GetFromJsonAsync<List<Product>> das JSON und macht daraus wieder eine Liste von Produkten:
app.MapGet("/products", () => catalog);using var http = new HttpClient();string json = await http.GetStringAsync("http://localhost:7205/products");Console.WriteLine(json.Substring(0, 70) + "...");
List<Product> products = await http.GetFromJsonAsync<List<Product>>("http://localhost:7205/products");foreach (Product p in products.Where(p => p.Category == "tee")){ Console.WriteLine(p.Name + ": " + p.Price + " €");}[{"id":1,"name":"Espressobohnen","price":24.90,"category":"kaffee"},{"...Sencha-Grüntee: 9.80 €Pfefferminze: 4.20 €- Im JSON beginnen die Namen mit einem Kleinbuchstaben (
name), in C# mit einem Großbuchstaben (Name). Server undHttpClientregeln das von selbst. decimalist in JSON eine Zahl, mit den Ziffern nach dem Dezimalpunkt:24.90.
Die Programme in dieser Lektion, die JSON ausgeben, haben oben zwei using-Anweisungen:
using System.Text.Encodings.Web;using System.Text.Unicode;und eine Zeile mehr nach ClearProviders(). Der Grund steht unter „Genauer, für Neugierige“:
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All));Genauer, für Neugierige
- Buchstaben außerhalb des einfachen lateinischen Alphabets in JSON. Die JSON-Bibliothek von .NET (
System.Text.Json) schreibt standardmäßig alles außerhalb des einfachen lateinischen Alphabets als\uXXXX, also wirdézu\u00E9. Das ist trotzdem gültiges JSON, und jedes Programm liest es richtig, aber ein Mensch kann es nicht lesen. Hier istJsonSerializer.Serializeohne den Webserver. Die Namen der Eigenschaften bleiben hier großgeschrieben, weil die kleinen Namen eine Einstellung des Webs sind:
using System.Text.Json;
Product cafe = new Product { Id = 6, Name = "Café Crème", Price = 3.80m, Category = "kaffee" };Console.WriteLine(JsonSerializer.Serialize(cafe));
public class Product{ public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public string Category { get; set; }}{"Id":6,"Name":"Caf\u00E9 Cr\u00E8me","Price":3.80,"Category":"kaffee"}- Der Webserver in .NET 10 kodiert standardmäßig weniger: Buchstaben mit Akzent und Umlaute kommen so heraus, wie sie sind, aber auch die Zeichen
<,>,&und'werden nicht kodiert. Das ist bequem, aber gefährlich, wenn das JSON in einer HTML-Seite landet. Deshalb heißt diese EinstellungUnsafeRelaxedJsonEscaping. Die Zeile mitConfigureHttpJsonOptionssagt ausdrücklich, was passiert: Alle Buchstaben in Unicode (UnicodeRanges.All) bleiben, wie sie sind, und die für HTML gefährlichen Zeichen werden kodiert (<wird zu\u003C). So ist die Ausgabe lesbar und hängt nicht von der Standardeinstellung ab. - Die umgekehrte Umwandlung, von JSON in ein Objekt, kümmert sich nicht um diese Einstellungen:
éund\u00E9werden gleich gelesen.
Sieh es dir an
POST: ein Produkt hinzufügen
GET liest. Um ein Produkt hinzuzufügen, schickt der Client ein POST mit dem Produkt als JSON im Body. Auf dem Server ist das MapPost:
app.MapPost("/products", (Product product) =>{ product.Id = catalog.Max(p => p.Id) + 1; catalog.Add(product); return Results.Created($"/products/{product.Id}", product);});using var http = new HttpClient();Product tea = new Product { Name = "Assam-Schwarztee", Price = 7.40m, Category = "tee" };HttpResponseMessage response = await http.PostAsJsonAsync("http://localhost:7207/products", tea);Console.WriteLine((int)response.StatusCode + " " + response.Headers.Location);
Product created = await response.Content.ReadFromJsonAsync<Product>();Console.WriteLine("Neues Produkt " + created.Id + ": " + created.Name);List<Product> all = await http.GetFromJsonAsync<List<Product>>("http://localhost:7207/products");Console.WriteLine("Im Katalog: " + all.Count);201 /products/5Neues Produkt 5: Assam-SchwarzteeIm Katalog: 5- Das Lambda nimmt
Product product. Es steht nicht im Pfad, also liest ASP.NET es aus dem JSON im Body der Anfrage. Max(p => p.Id)aus LINQ gibt die größte Nummer, also ist die neue um eins größer.Results.Created(adresse, objekt)ist der Status 201 Created: Etwas wurde erstellt. Der HeaderLocationgibt die Adresse der neuen Ressource an, und der Body ist das Produkt selbst mit seiner neuen Nummer.- Im Client macht
PostAsJsonAsyncaus dem Objekt JSON und schickt einPOST.ReadFromJsonAsync<Product>liest den Body der Antwort als Produkt.
Genauer, für Neugierige
- Der Katalog lebt im Speicher des Servers: Bei einem Neustart verschwinden die neuen Produkte. Ein echter Shop speichert sie in einer Datenbank.
- Ein echter Server bearbeitet viele Anfragen gleichzeitig, und
Listist nicht für Änderungen aus mehreren Anfragen zur selben Zeit gemacht. Ein Grund mehr, die Daten in einer Datenbank zu halten, die sich darum kümmert. - Es gibt auch
MapPut(ersetzen) undMapDelete(löschen). Sie kommen in der WebAPI-Lektion.
Probier es aus
500 statt 404
Der Client fragt nach /products/9, und dieses Produkt gibt es nicht. Er erwartet 404, bekommt aber 500. In welcher Zeile ist der Fehler?
Wähle die Zeile mit dem Fehler.
First wirft eine InvalidOperationException, wenn es nichts findet. Der Server hält nicht an, macht aber aus der Exception eine Antwort 500: „Serverfehler“. Dabei liegt der Fehler in der Anfrage: Dieses Produkt gibt es nicht. Die Lösung ist FirstOrDefault, eine Prüfung auf null und Results.NotFound(), wie auf der Karte „Anfrage und Antwort“.
Übung
Der Server des Shops
Löse die Aufgaben in deinem Editor. Jede Aufgabe ist ein Programm: ein Server, ein Client, der ihn aufruft, und das Stoppen. Wenn du mehrere gleichzeitig startest, gib jedem einen eigenen Port.
1. Wie spät ist es. GET /time gibt die Uhrzeit als Text wie 14:05 zurück (DateTime.Now.ToString("HH:mm")). Die Uhrzeit ändert sich, also prüft der Client nur ihre Form: Länge 5 und ein : an Position 2:
Die Uhrzeit sieht aus wie HH:mm: True2. Ein Katalog mit Suche. GET /products?category=tee gibt die Produkte der Kategorie zurück, nach Preis sortiert, und GET /search?q=… durchsucht die Namen unabhängig von Groß- und Kleinschreibung (ToLower). Der Client liest die ersten beiden mit GetFromJsonAsync und gibt die dritte als JSON aus:
Tee: Pfefferminze 4.20 €, Sencha-Grüntee 9.80 €Gefunden: 2[{"id":4,"name":"Pfefferminze","price":4.20,"category":"tee"}]3. Ein fehlendes Produkt. GET /products/{id:int} gibt das Produkt zurück oder 404 mit einer Meldung im Body: Results.NotFound("Kein Produkt mit der Nummer 42"). Der Client fragt nach 1 und 42 und gibt Status und Body aus:
1: 200 {"id":1,"name":"Espressobohnen","price":24.90,"category":"kaffee"}42: 404 "Kein Produkt mit der Nummer 42"4. Ein Produkt hinzufügen. POST /products fügt das Produkt hinzu und gibt 201 mit seiner Adresse zurück. Wenn der Name leer ist (string.IsNullOrWhiteSpace) oder der Preis nicht positiv, gibt es Results.BadRequest("Name und Preis sind erforderlich") zurück und fügt nichts hinzu. Der Client fügt Rooibos für 6,90 € hinzu, liest ihn über die Adresse aus Location und versucht ein Produkt ohne Namen:
POST: 201 /products/5GET: 5 Rooibos 6.90 €POST ohne Name: 400 "Name und Preis sind erforderlich"Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Exceptions · Listen, Dictionaries und LINQ · async und await · Wie das Internet funktioniert
- Geht weiter in: MVC: Model, View, Controller · Views mit Razor · WebAPI: Daten für andere Programme · Nicht vertrauenswürdige Eingaben
- Aufgaben zum Thema: Web und Desktop