Zum Inhalt
wannacode

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:

Program.cs
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();
Ausgabe
Kaffee und Tee: geöffnet
  • WebApplication.CreateBuilder() und Build() bauen die Anwendung. app.Urls.Add sagt, auf welcher Adresse und welchem Port gelauscht wird.
  • MapGet("/", lambda) sagt: Auf eine GET-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.
  • HttpClient ist der Client. GetStringAsync(adresse) schickt eine GET-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 (öffne http://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 in Program.cs, und die using-Anweisungen für das Web sind standardmäßig dabei.
  • Ein HttpClient ist 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"));
Ausgabe
Kolumbienbohnen: 31.50 €
Dieses Produkt gibt es nicht
Espressobohnen, Kolumbienbohnen
  • {id:int} in der Route ist ein Parameter im Pfad: /products/3 passt, und id ist 3. :int ist 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 einen int daraus.
  • (string q) hat keinen Platz im Pfad, also wird er aus der Query nach ? genommen: /search?q=bohnen ergibt q = "bohnen".
  • catalog ist 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 Product steht nach dem Code, wie in der LINQ-Lektion:
Program.cs
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, ist q hier null, Contains(null) wirft eine Exception, und der Server antwortet mit 500. Ein Projekt aus dotnet new web hat die Prüfung auf null eingeschaltet (<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
  1. Der Server registriert eine Route: Die Adresse /products/<Nummer> führt zu dieser Methode.
  2. StartAsync: Der Server beginnt, auf Port 7219 zu lauschen.
  3. Der Client schickt eine Anfrage: Methode GET, Pfad /products/2.
  4. Der Pfad passt zur Route. 2 ist eine Zahl, also id = 2.
  5. Das Produkt ist im Katalog, also gibt die Methode Results.Ok damit zurück. Der Server macht JSON daraus.
  6. Die Antwort kommt zurück: Status 200 und ein Body mit JSON.
  7. 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());
}
Ausgabe
/products/2 -> 200 OK
{"id":2,"name":"Sencha-Grüntee","price":9.80,"category":"tee"}
/products/9 -> 404 NotFound

Probier 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);
Antwort

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 + " €");
}
Ausgabe
[{"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 und HttpClient regeln das von selbst.
  • decimal ist 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 ist JsonSerializer.Serialize ohne 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; }
}
Ausgabe
{"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 Einstellung UnsafeRelaxedJsonEscaping. Die Zeile mit ConfigureHttpJsonOptions sagt 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 \u00E9 werden 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);
Ausgabe
201 /products/5
Neues Produkt 5: Assam-Schwarztee
Im 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 Header Location gibt die Adresse der neuen Ressource an, und der Body ist das Produkt selbst mit seiner neuen Nummer.
  • Im Client macht PostAsJsonAsync aus dem Objekt JSON und schickt ein POST. 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 List ist 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) und MapDelete (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.

Ü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:

Ausgabe
Die Uhrzeit sieht aus wie HH:mm: True

2. 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:

Ausgabe
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:

Ausgabe
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:

Ausgabe
POST: 201 /products/5
GET: 5 Rooibos 6.90 €
POST ohne Name: 400 "Name und Preis sind erforderlich"

Test

Teste dich

1. Was macht app.MapGet("/products/{id:int}", (int id) => ...)?
2. Das Lambda gibt Results.NotFound() zurück. Was bekommt der Client?
3. Das Lambda gibt ein Product-Objekt zurück. Wie kommt es zum Client?
4. Warum haben die Programme in dieser Lektion await app.StartAsync() und await app.StopAsync()?
5. Das Lambda sucht das Produkt mit catalog.First(p => p.Id == id), und das Produkt gibt es nicht. Was bekommt der Client?

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