Hotelbuchungen
Kurz gesagt: Ein Projekt für Hotelbuchungen: Ein Server und viele Clients buchen, prüfen und stornieren gleichzeitig Zimmer, und wenn zwei im selben Moment dasselbe Zimmer wollen, bekommt es nur einer.
Problem
Ein Zimmer, zwei Bestätigungen
Ein kleines Hotel hat vier Zimmer. Buchungen kommen von überall: von der Website, von der Rezeption, von Reisebüros. An einem Freitagabend buchen zwei Agenturen in derselben Sekunde das letzte Doppelzimmer. Beide bekommen eine Bestätigung. Am Samstag stehen zwei Familien vor dem Zimmer.
Wir bauen ein System, in dem der Server die Zimmer und die Buchungen verwaltet und die Clients Anfragen über das Netz schicken. Ein Client kann:
- die freien Zimmer sehen;
- ein Zimmer prüfen;
- ein Zimmer auf seinen Namen buchen;
- seine eigene Buchung stornieren.
Und wenn zwei Clients gleichzeitig dasselbe Zimmer buchen, hat nur einer Erfolg. Das beweisen wir mit einem Programm, das bei jedem Lauf dieselbe Ausgabe liefert.
Wir bauen von unten nach oben: das Modell ohne Netz, die Regeln und die Fehler, das Protokoll, der Server und der Client, die gleichzeitigen Anfragen und am Ende alles in einem Programm.
Genauer, für Neugierige
HttpClient auf. In beiden Sprachen schützt dieselbe Grundidee die Zimmer: lock.Die Idee
Das Zimmer
Ein Zimmer hat eine Nummer, einen Typ, einen Preis pro Nacht und einen Zustand: frei oder belegt.
class Room { private final int number; private final String type; private final int price; private boolean free = true;
public Room(int number, String type, int price) { this.number = number; this.type = type; this.price = price; }
public int getNumber() { return number; }
public String getType() { return type; }
public int getPrice() { return price; }
public boolean isFree() { return free; }
public void occupy() { free = false; }
public void release() { free = true; }
@Override public String toString() { return number + ", " + type + ", " + price + " € pro Nacht, " + (free ? "frei" : "belegt"); }}public class Room{ public int Number { get; } public string Type { get; } public int Price { get; } public bool IsFree { get; set; } = true;
public Room(int number, string type, int price) { Number = number; Type = type; Price = price; }
public void Occupy() { IsFree = false; }
public void Release() { IsFree = true; }
public override string ToString() { return Number + ", " + Type + ", " + Price + " € pro Nacht, " + (IsFree ? "frei" : "belegt"); }}class Room: def __init__(self, number, kind, price): self._number = number self._kind = kind self._price = price self._free = True
@property def number(self): return self._number
@property def kind(self): return self._kind
@property def price(self): return self._price
@property def is_free(self): return self._free
def occupy(self): self._free = False
def release(self): self._free = True
def __str__(self): state = "frei" if self._free else "belegt" return f"{self._number}, {self._kind}, {self._price} € pro Nacht, {state}"class Room { #number; #type; #price; #free = true;
constructor(number, type, price) { this.#number = number; this.#type = type; this.#price = price; }
get number() { return this.#number; }
get type() { return this.#type; }
get price() { return this.#price; }
get isFree() { return this.#free; }
occupy() { this.#free = false; }
release() { this.#free = true; }
toString() { const state = this.#free ? "frei" : "belegt"; return `${this.#number}, ${this.#type}, ${this.#price} € pro Nacht, ${state}`; }}- Nummer, Typ und Preis ändern sich nicht. Nur der Zustand ändert sich, und zwar nur über zwei Methoden mit klaren Namen:
OccupyundRelease. - Das Zimmer weiß nicht, wer darin wohnt. Das Zimmer geht an jeden Client, der fragt, und der Name des Gastes soll das Hotel nicht verlassen.
Genauer, für Neugierige
IsFree hat ein öffentliches set, weil der Client das Zimmer als JSON bekommt und System.Text.Json den Zustand schreiben muss. Nummer, Typ und Preis kommen über den Konstruktor. Ohne set würde der Client jedes Zimmer als frei sehen. Für mehr als zwei Zustände nimmt man eine Aufzählung (Enum): enum RoomState { Free, Taken, Repair }.Sieh es dir an
Das Hotel und die Buchung
private final List<Room> rooms = new ArrayList<>();private final Map<Integer, String> guests = new HashMap<>();private readonly List<Room> rooms = new List<Room>();private readonly Dictionary<int, string> guests = new Dictionary<int, string>();def __init__(self): self._rooms = [] self._guests = {}#rooms = [];#guests = new Map();public void add(Room room) { rooms.add(room);}
public List<Room> freeRooms() { return rooms.stream().filter(Room::isFree).toList();}
public Room check(int number) { return find(number);}public void Add(Room room){ rooms.Add(room);}
public List<Room> FreeRooms(){ return rooms.Where(room => room.IsFree).ToList();}
public Room Check(int number){ return Find(number);}def add(self, room): self._rooms.append(room)
def free_rooms(self): return [room for room in self._rooms if room.is_free]
def check(self, number): return self._find(number)add(room) { this.#rooms.push(room);}
freeRooms() { return this.#rooms.filter((room) => room.isFree);}
check(number) { return this.#find(number);}Find ist privat: Sie sucht das Zimmer nach Nummer oder wirft eine Exception. So kann niemand von außen ein Zimmer nehmen und es an den Regeln vorbei belegen:
private Room find(int number) { for (Room room : rooms) { if (room.getNumber() == number) { return room; } } throw new RoomNotFoundException("Es gibt kein Zimmer " + number + ".");}private Room Find(int number){ Room room = rooms.FirstOrDefault(r => r.Number == number); if (room == null) { throw new RoomNotFoundException("Es gibt kein Zimmer " + number + "."); } return room;}def _find(self, number): for room in self._rooms: if room.number == number: return room raise RoomNotFoundError(f"Es gibt kein Zimmer {number}.")#find(number) { const room = this.#rooms.find((r) => r.number === number); if (room === undefined) { throw new RoomNotFoundError(`Es gibt kein Zimmer ${number}.`); } return room;}Die Buchung prüft den Namen, findet das Zimmer und prüft, ob es frei ist. Erst dann belegt sie es:
public void reserve(int number, String guest) { if (guest.isBlank()) { throw new HotelException("Der Name des Gastes ist Pflicht."); } Room room = find(number); if (!room.isFree()) { throw new RoomTakenException("Zimmer " + number + " ist schon belegt."); } room.occupy(); guests.put(number, guest);}public void Reserve(int number, string guest){ if (string.IsNullOrWhiteSpace(guest)) { throw new HotelException("Der Name des Gastes ist Pflicht."); } Room room = Find(number); if (!room.IsFree) { throw new RoomTakenException("Zimmer " + number + " ist schon belegt."); } room.Occupy(); guests[number] = guest;}def reserve(self, number, guest): if not guest.strip(): raise HotelError("Der Name des Gastes ist Pflicht.") room = self._find(number) if not room.is_free: raise RoomTakenError(f"Zimmer {number} ist schon belegt.") room.occupy() self._guests[number] = guestreserve(number, guest) { if (guest.trim() === "") { throw new HotelError("Der Name des Gastes ist Pflicht."); } const room = this.#find(number); if (!room.isFree) { throw new RoomTakenError(`Zimmer ${number} ist schon belegt.`); } room.occupy(); this.#guests.set(number, guest);}Hotel hotel = new Hotel();hotel.add(new Room(101, "Einzelzimmer", 60));hotel.add(new Room(102, "Doppelzimmer", 85));hotel.add(new Room(201, "Doppelzimmer", 95));hotel.add(new Room(202, "Suite", 140));
for (Room room : hotel.freeRooms()) { System.out.println(room);}hotel.reserve(102, "Anna Petrova");System.out.println("Nach der Buchung: " + hotel.check(102));System.out.println("Freie Zimmer: " + hotel.freeRooms().size());101, Einzelzimmer, 60 € pro Nacht, frei102, Doppelzimmer, 85 € pro Nacht, frei201, Doppelzimmer, 95 € pro Nacht, frei202, Suite, 140 € pro Nacht, freiNach der Buchung: 102, Doppelzimmer, 85 € pro Nacht, belegtFreie Zimmer: 3Hotel hotel = new Hotel();hotel.Add(new Room(101, "Einzelzimmer", 60));hotel.Add(new Room(102, "Doppelzimmer", 85));hotel.Add(new Room(201, "Doppelzimmer", 95));hotel.Add(new Room(202, "Suite", 140));
foreach (Room room in hotel.FreeRooms()){ Console.WriteLine(room);}hotel.Reserve(102, "Anna Petrova");Console.WriteLine("Nach der Buchung: " + hotel.Check(102));Console.WriteLine("Freie Zimmer: " + hotel.FreeRooms().Count);101, Einzelzimmer, 60 € pro Nacht, frei102, Doppelzimmer, 85 € pro Nacht, frei201, Doppelzimmer, 95 € pro Nacht, frei202, Suite, 140 € pro Nacht, freiNach der Buchung: 102, Doppelzimmer, 85 € pro Nacht, belegtFreie Zimmer: 3hotel = Hotel()hotel.add(Room(101, "Einzelzimmer", 60))hotel.add(Room(102, "Doppelzimmer", 85))hotel.add(Room(201, "Doppelzimmer", 95))hotel.add(Room(202, "Suite", 140))
for room in hotel.free_rooms(): print(room)hotel.reserve(102, "Anna Petrova")print(f"Nach der Buchung: {hotel.check(102)}")print(f"Freie Zimmer: {len(hotel.free_rooms())}")101, Einzelzimmer, 60 € pro Nacht, frei102, Doppelzimmer, 85 € pro Nacht, frei201, Doppelzimmer, 95 € pro Nacht, frei202, Suite, 140 € pro Nacht, freiNach der Buchung: 102, Doppelzimmer, 85 € pro Nacht, belegtFreie Zimmer: 3const hotel = new Hotel();hotel.add(new Room(101, "Einzelzimmer", 60));hotel.add(new Room(102, "Doppelzimmer", 85));hotel.add(new Room(201, "Doppelzimmer", 95));hotel.add(new Room(202, "Suite", 140));
for (const room of hotel.freeRooms()) { console.log(String(room));}hotel.reserve(102, "Anna Petrova");console.log(`Nach der Buchung: ${hotel.check(102)}`);console.log(`Freie Zimmer: ${hotel.freeRooms().length}`);101, Einzelzimmer, 60 € pro Nacht, frei102, Doppelzimmer, 85 € pro Nacht, frei201, Doppelzimmer, 95 € pro Nacht, frei202, Suite, 140 € pro Nacht, freiNach der Buchung: 102, Doppelzimmer, 85 € pro Nacht, belegtFreie Zimmer: 3Die Idee
Stornieren und die Regeln
Nicht jede Anfrage lässt sich ausführen: Das Zimmer ist belegt, es gibt kein Zimmer 305, ein Gast storniert die Buchung eines anderen. Das sind Absagen nach den Regeln, deshalb sind es eigene Exceptions mit einer gemeinsamen Basisklasse:
class HotelException extends RuntimeException { public HotelException(String message) { super(message); }}
class RoomNotFoundException extends HotelException { public RoomNotFoundException(String message) { super(message); }}
class RoomTakenException extends HotelException { public RoomTakenException(String message) { super(message); }}public class HotelException : Exception{ public HotelException(string message) : base(message) { }}
public class RoomNotFoundException : HotelException{ public RoomNotFoundException(string message) : base(message) { }}
public class RoomTakenException : HotelException{ public RoomTakenException(string message) : base(message) { }}class HotelError(Exception): pass
class RoomNotFoundError(HotelError): pass
class RoomTakenError(HotelError): passclass HotelError extends Error { constructor(message) { super(message); this.name = "HotelError"; }}
class RoomNotFoundError extends HotelError { constructor(message) { super(message); this.name = "RoomNotFoundError"; }}
class RoomTakenError extends HotelError { constructor(message) { super(message); this.name = "RoomTakenError"; }}Das Stornieren prüft, dass das Zimmer belegt ist und dass derselbe Gast es storniert:
public void cancel(int number, String guest) { Room room = find(number); if (room.isFree()) { throw new HotelException("Zimmer " + number + " ist nicht gebucht."); } if (!guests.get(number).equals(guest)) { throw new HotelException("Zimmer " + number + " ist von einem anderen Gast gebucht."); } room.release(); guests.remove(number);}public void Cancel(int number, string guest){ Room room = Find(number); if (room.IsFree) { throw new HotelException("Zimmer " + number + " ist nicht gebucht."); } if (guests[number] != guest) { throw new HotelException("Zimmer " + number + " ist von einem anderen Gast gebucht."); } room.Release(); guests.Remove(number);}def cancel(self, number, guest): room = self._find(number) if room.is_free: raise HotelError(f"Zimmer {number} ist nicht gebucht.") if self._guests[number] != guest: raise HotelError(f"Zimmer {number} ist von einem anderen Gast gebucht.") room.release() del self._guests[number]cancel(number, guest) { const room = this.#find(number); if (room.isFree) { throw new HotelError(`Zimmer ${number} ist nicht gebucht.`); } if (this.#guests.get(number) !== guest) { throw new HotelError(`Zimmer ${number} ist von einem anderen Gast gebucht.`); } room.release(); this.#guests.delete(number);}Die Projektaufgabe verlangt, das System in verschiedenen Szenarien zu prüfen: buchen, prüfen und stornieren, sowohl wenn alles gültig ist als auch wenn nicht. Jedes Szenario ist eine Zeile in der Ausgabe:
Hotel hotel = new Hotel();hotel.add(new Room(101, "Einzelzimmer", 60));hotel.add(new Room(102, "Doppelzimmer", 85));hotel.add(new Room(201, "Doppelzimmer", 95));hotel.add(new Room(202, "Suite", 140));
hotel.reserve(102, "Anna Petrova");System.out.println("1. Freies Zimmer: " + hotel.check(102));try { hotel.reserve(102, "Boris Iliev");} catch (RoomTakenException e) { System.out.println("2. Belegtes Zimmer: " + e.getMessage());}try { hotel.cancel(102, "Boris Iliev");} catch (HotelException e) { System.out.println("3. Fremde Buchung: " + e.getMessage());}hotel.cancel(102, "Anna Petrova");System.out.println("4. Stornierung: " + hotel.check(102));try { hotel.cancel(102, "Anna Petrova");} catch (HotelException e) { System.out.println("5. Zweite Stornierung: " + e.getMessage());}try { hotel.reserve(305, "Anna Petrova");} catch (RoomNotFoundException e) { System.out.println("6. Kein solches Zimmer: " + e.getMessage());}try { hotel.reserve(201, " ");} catch (HotelException e) { System.out.println("7. Ohne Namen: " + e.getMessage());}1. Freies Zimmer: 102, Doppelzimmer, 85 € pro Nacht, belegt2. Belegtes Zimmer: Zimmer 102 ist schon belegt.3. Fremde Buchung: Zimmer 102 ist von einem anderen Gast gebucht.4. Stornierung: 102, Doppelzimmer, 85 € pro Nacht, frei5. Zweite Stornierung: Zimmer 102 ist nicht gebucht.6. Kein solches Zimmer: Es gibt kein Zimmer 305.7. Ohne Namen: Der Name des Gastes ist Pflicht.Hotel hotel = new Hotel();hotel.Add(new Room(101, "Einzelzimmer", 60));hotel.Add(new Room(102, "Doppelzimmer", 85));hotel.Add(new Room(201, "Doppelzimmer", 95));hotel.Add(new Room(202, "Suite", 140));
hotel.Reserve(102, "Anna Petrova");Console.WriteLine("1. Freies Zimmer: " + hotel.Check(102));try{ hotel.Reserve(102, "Boris Iliev");}catch (RoomTakenException e){ Console.WriteLine("2. Belegtes Zimmer: " + e.Message);}try{ hotel.Cancel(102, "Boris Iliev");}catch (HotelException e){ Console.WriteLine("3. Fremde Buchung: " + e.Message);}hotel.Cancel(102, "Anna Petrova");Console.WriteLine("4. Stornierung: " + hotel.Check(102));try{ hotel.Cancel(102, "Anna Petrova");}catch (HotelException e){ Console.WriteLine("5. Zweite Stornierung: " + e.Message);}try{ hotel.Reserve(305, "Anna Petrova");}catch (RoomNotFoundException e){ Console.WriteLine("6. Kein solches Zimmer: " + e.Message);}try{ hotel.Reserve(201, " ");}catch (HotelException e){ Console.WriteLine("7. Ohne Namen: " + e.Message);}1. Freies Zimmer: 102, Doppelzimmer, 85 € pro Nacht, belegt2. Belegtes Zimmer: Zimmer 102 ist schon belegt.3. Fremde Buchung: Zimmer 102 ist von einem anderen Gast gebucht.4. Stornierung: 102, Doppelzimmer, 85 € pro Nacht, frei5. Zweite Stornierung: Zimmer 102 ist nicht gebucht.6. Kein solches Zimmer: Es gibt kein Zimmer 305.7. Ohne Namen: Der Name des Gastes ist Pflicht.hotel = Hotel()hotel.add(Room(101, "Einzelzimmer", 60))hotel.add(Room(102, "Doppelzimmer", 85))hotel.add(Room(201, "Doppelzimmer", 95))hotel.add(Room(202, "Suite", 140))
hotel.reserve(102, "Anna Petrova")print(f"1. Freies Zimmer: {hotel.check(102)}")try: hotel.reserve(102, "Boris Iliev")except RoomTakenError as e: print(f"2. Belegtes Zimmer: {e}")try: hotel.cancel(102, "Boris Iliev")except HotelError as e: print(f"3. Fremde Buchung: {e}")hotel.cancel(102, "Anna Petrova")print(f"4. Stornierung: {hotel.check(102)}")try: hotel.cancel(102, "Anna Petrova")except HotelError as e: print(f"5. Zweite Stornierung: {e}")try: hotel.reserve(305, "Anna Petrova")except RoomNotFoundError as e: print(f"6. Kein solches Zimmer: {e}")try: hotel.reserve(201, " ")except HotelError as e: print(f"7. Ohne Namen: {e}")1. Freies Zimmer: 102, Doppelzimmer, 85 € pro Nacht, belegt2. Belegtes Zimmer: Zimmer 102 ist schon belegt.3. Fremde Buchung: Zimmer 102 ist von einem anderen Gast gebucht.4. Stornierung: 102, Doppelzimmer, 85 € pro Nacht, frei5. Zweite Stornierung: Zimmer 102 ist nicht gebucht.6. Kein solches Zimmer: Es gibt kein Zimmer 305.7. Ohne Namen: Der Name des Gastes ist Pflicht.const hotel = new Hotel();hotel.add(new Room(101, "Einzelzimmer", 60));hotel.add(new Room(102, "Doppelzimmer", 85));hotel.add(new Room(201, "Doppelzimmer", 95));hotel.add(new Room(202, "Suite", 140));
hotel.reserve(102, "Anna Petrova");console.log(`1. Freies Zimmer: ${hotel.check(102)}`);try { hotel.reserve(102, "Boris Iliev");} catch (e) { if (!(e instanceof RoomTakenError)) throw e; console.log(`2. Belegtes Zimmer: ${e.message}`);}try { hotel.cancel(102, "Boris Iliev");} catch (e) { if (!(e instanceof HotelError)) throw e; console.log(`3. Fremde Buchung: ${e.message}`);}hotel.cancel(102, "Anna Petrova");console.log(`4. Stornierung: ${hotel.check(102)}`);try { hotel.cancel(102, "Anna Petrova");} catch (e) { if (!(e instanceof HotelError)) throw e; console.log(`5. Zweite Stornierung: ${e.message}`);}try { hotel.reserve(305, "Anna Petrova");} catch (e) { if (!(e instanceof RoomNotFoundError)) throw e; console.log(`6. Kein solches Zimmer: ${e.message}`);}try { hotel.reserve(201, " ");} catch (e) { if (!(e instanceof HotelError)) throw e; console.log(`7. Ohne Namen: ${e.message}`);}1. Freies Zimmer: 102, Doppelzimmer, 85 € pro Nacht, belegt2. Belegtes Zimmer: Zimmer 102 ist schon belegt.3. Fremde Buchung: Zimmer 102 ist von einem anderen Gast gebucht.4. Stornierung: 102, Doppelzimmer, 85 € pro Nacht, frei5. Zweite Stornierung: Zimmer 102 ist nicht gebucht.6. Kein solches Zimmer: Es gibt kein Zimmer 305.7. Ohne Namen: Der Name des Gastes ist Pflicht.Die konkrete Exception sagt, was das Problem ist. HotelException fängt alle Absagen auf einmal: Der Server wird nur sie fangen und in eine Antwort für den Client verwandeln.
Genauer, für Neugierige
- C# hat keine Checked Exceptions. Deshalb muss der Server sie an einer Stelle fangen, sonst geht eine Absage nach den Regeln als 500 hinaus.
- Szenarien wie diese sind natürliche Tests. In C# gibt es dafür Bibliotheken wie xUnit: Jedes Szenario wird zu einer Methode, und
Assert.Throws<RoomTakenException>(...)prüft die Exception.
Probier es aus
Wer ist in 201
Was gibt das Programm aus?
Hotel hotel = new Hotel();hotel.Add(new Room(101, "Einzelzimmer", 60));hotel.Add(new Room(102, "Doppelzimmer", 85));hotel.Add(new Room(201, "Doppelzimmer", 95));hotel.Add(new Room(202, "Suite", 140));
hotel.Reserve(101, "Anna Petrova");hotel.Reserve(201, "Boris Iliev");hotel.Cancel(101, "Anna Petrova");try{ hotel.Reserve(201, "Vera Koleva"); Console.WriteLine("Vera ist in 201");}catch (RoomTakenException e){ Console.WriteLine("Absage für Vera");}Console.WriteLine(hotel.FreeRooms().Count);Anna belegt 101 und gibt es danach wieder frei. Boris belegt 201, deshalb bekommt Vera RoomTakenException, und die Zeile „Vera ist in 201“ wird nicht ausgeführt. Belegt ist nur 201, also sind drei Zimmer frei.
Die Idee
Das Protokoll: Adressen und JSON
Jede Aktion ist eine Methode und eine Adresse, die Daten sind JSON, und der Status sagt, wie es gelaufen ist:
| Anfrage | Body | Antwort |
|---|---|---|
GET /rooms/free |
200 und die freien Zimmer | |
GET /rooms/102 |
200 und das Zimmer oder 404 | |
POST /rooms/102/reservation |
{"guest":"Anna Petrova"} |
200 und eine Bestätigung, 409 für ein belegtes Zimmer, 404 für ein Zimmer, das es nicht gibt |
DELETE /rooms/102/reservation?guest=Anna Petrova |
200 und eine Bestätigung oder 400 mit dem Grund |
Jede Absage des Modells hat ihren eigenen Status. RoomNotFoundException ist 404 Not Found, RoomTakenException ist 409 Conflict: Die Anfrage ist in Ordnung, aber sie kollidiert mit dem aktuellen Zustand. Jede andere HotelException ist 400 Bad Request.
Der Body der Buchung ist eine kleine Klasse, nur um Daten zu transportieren:
public class ReservationRequest{ public string Guest { get; set; }}Genauer, für Neugierige
-
Die Befehle könnten eine Aufzählung (Enum) sein:
enum Command { Free, Check, Reserve, Cancel }. Dann sind sie ein eigener Typ mit einer geschlossenen Liste von Werten. Im Kurs sind es Konstanten, weil wir Enums nicht behandelt haben. -
DELETEhat per Konvention keinen Body, deshalb steht der Name in der Adresse. In einem echten System meldet sich der Client statt mit einem Namen mit einem Konto an, und der Server weiß, wer er ist. Dann kann niemand die Buchung eines anderen stornieren, indem er einen fremden Namen eintippt.
Sieh es dir an
Der Server
Der Anfang ist wie in der Lektion über den ersten Server: der Port, keine Meldungen von ASP.NET und JSON, das Umlaute nicht maskiert:
var builder = WebApplication.CreateBuilder();builder.Logging.ClearProviders();builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All));var app = builder.Build();app.Urls.Add("http://localhost:5341");Die Absagen des Modells werden an einer Stelle zu Statuscodes, in Safe:
IResult Safe(Func<IResult> action){ try { return action(); } catch (RoomNotFoundException e) { return Results.NotFound(e.Message); } catch (RoomTakenException e) { return Results.Conflict(e.Message); } catch (HotelException e) { return Results.BadRequest(e.Message); }}Die Adressen rufen das Hotel auf. Buchen und Stornieren sind Lambdas mit Rumpf, weil sie zwei Dinge tun: das Modell aufrufen und eine Bestätigung zurückgeben:
app.MapGet("/rooms/free", () => hotel.FreeRooms());app.MapGet("/rooms/{number:int}", (int number) => Safe(() => Results.Ok(hotel.Check(number))));app.MapPost("/rooms/{number:int}/reservation", (int number, ReservationRequest request) => Safe(() =>{ hotel.Reserve(number, request.Guest); return Results.Ok("Zimmer " + number + " gehört dir, " + request.Guest + ".");}));app.MapDelete("/rooms/{number:int}/reservation", (int number, string guest) => Safe(() =>{ hotel.Cancel(number, guest); return Results.Ok("Die Buchung für Zimmer " + number + " ist storniert.");}));Das geht über das Netz:
using var http = new HttpClient { BaseAddress = new Uri("http://localhost:5341") };
async Task Show(string request, HttpResponseMessage response){ Console.WriteLine(request + " -> " + (int)response.StatusCode + " " + await response.Content.ReadAsStringAsync());}
var anna = new ReservationRequest { Guest = "Anna Petrova" };var boris = new ReservationRequest { Guest = "Boris Iliev" };await Show("POST /rooms/102/reservation", await http.PostAsJsonAsync("/rooms/102/reservation", anna));await Show("POST /rooms/102/reservation", await http.PostAsJsonAsync("/rooms/102/reservation", boris));await Show("GET /rooms/102", await http.GetAsync("/rooms/102"));await Show("DELETE /rooms/102/reservation?guest=Boris Iliev", await http.DeleteAsync("/rooms/102/reservation?guest=Boris Iliev"));await Show("DELETE /rooms/102/reservation?guest=Anna Petrova", await http.DeleteAsync("/rooms/102/reservation?guest=Anna Petrova"));await Show("GET /rooms/305", await http.GetAsync("/rooms/305"));POST /rooms/102/reservation -> 200 "Zimmer 102 gehört dir, Anna Petrova."POST /rooms/102/reservation -> 409 "Zimmer 102 ist schon belegt."GET /rooms/102 -> 200 {"number":102,"type":"Doppelzimmer","price":85,"isFree":false}DELETE /rooms/102/reservation?guest=Boris Iliev -> 400 "Zimmer 102 ist von einem anderen Gast gebucht."DELETE /rooms/102/reservation?guest=Anna Petrova -> 200 "Die Buchung für Zimmer 102 ist storniert."GET /rooms/305 -> 404 "Es gibt kein Zimmer 305."Sieh es dir an
Der Client
Der Client versteckt HTTP hinter Methoden. Eine Antwort mit Fehler wird zu derselben Exception, die der Server geworfen hat: 404, 409 und 400 kommen als RoomNotFoundException, RoomTakenException und HotelException zurück:
public class HotelClient{ private readonly HttpClient http;
public HotelClient(string address) { http = new HttpClient { BaseAddress = new Uri(address) }; }
public async Task<List<Room>> FreeRoomsAsync() { return await http.GetFromJsonAsync<List<Room>>("/rooms/free"); }
public async Task<Room> CheckAsync(int number) { HttpResponseMessage response = await http.GetAsync("/rooms/" + number); await ThrowIfError(response); return await response.Content.ReadFromJsonAsync<Room>(); }
public async Task<string> ReserveAsync(int number, string guest) { var request = new ReservationRequest { Guest = guest }; HttpResponseMessage response = await http.PostAsJsonAsync("/rooms/" + number + "/reservation", request); await ThrowIfError(response); return await response.Content.ReadFromJsonAsync<string>(); }
public async Task<string> CancelAsync(int number, string guest) { HttpResponseMessage response = await http.DeleteAsync("/rooms/" + number + "/reservation?guest=" + guest); await ThrowIfError(response); return await response.Content.ReadFromJsonAsync<string>(); }
private static async Task ThrowIfError(HttpResponseMessage response) { if (response.IsSuccessStatusCode) { return; } string message = await response.Content.ReadFromJsonAsync<string>(); int status = (int)response.StatusCode; if (status == 404) { throw new RoomNotFoundException(message); } if (status == 409) { throw new RoomTakenException(message); } throw new HotelException(message); }}var client = new HotelClient("http://localhost:5345");
Console.WriteLine(await client.ReserveAsync(102, "Anna Petrova"));Console.WriteLine(await client.CheckAsync(102));foreach (Room room in await client.FreeRoomsAsync()){ Console.WriteLine("Frei: " + room);}Console.WriteLine(await client.CancelAsync(102, "Anna Petrova"));try{ await client.CheckAsync(305);}catch (RoomNotFoundException e){ Console.WriteLine("Abgelehnt: " + e.Message);}Zimmer 102 gehört dir, Anna Petrova.102, Doppelzimmer, 85 € pro Nacht, belegtFrei: 101, Einzelzimmer, 60 € pro Nacht, freiFrei: 201, Doppelzimmer, 95 € pro Nacht, freiFrei: 202, Suite, 140 € pro Nacht, freiDie Buchung für Zimmer 102 ist storniert.Abgelehnt: Es gibt kein Zimmer 305.Die Idee
Zwei für ein Zimmer
| Agentur A | Agentur B | |
|---|---|---|
| 1 | prüft: 102 ist frei | |
| 2 | prüft: 102 ist frei | |
| 3 | belegt 102 für ihren Gast | |
| 4 | belegt 102 für ihren Gast und trägt ihn ein |
Beide bekommen eine Bestätigung. In keiner einzelnen Codezeile steckt ein Fehler. Der Fehler ist, dass sich die Schritte der beiden Anfragen verschränken.
Die Lösung: Ins Hotel kommt immer nur eine Anfrage auf einmal. Dann ist „prüfen und belegen“ unteilbar (atomar): Niemand kann zwischen die Prüfung und das Belegen kommen. In C# macht das einlock in jeder öffentlichen Methode von Hotel:private readonly List<Room> rooms = new List<Room>();private readonly Dictionary<int, string> guests = new Dictionary<int, string>();private readonly object sync = new object();public void Reserve(int number, string guest){ lock (sync) { if (string.IsNullOrWhiteSpace(guest)) { throw new HotelException("Der Name des Gastes ist Pflicht."); } Room room = Find(number); if (!room.IsFree) { throw new RoomTakenException("Zimmer " + number + " ist schon belegt."); } room.Occupy(); guests[number] = guest; }}public void Reserve(int number, string guest){ lock (sync) { if (string.IsNullOrWhiteSpace(guest)) { throw new HotelException("Der Name des Gastes ist Pflicht."); } Room room = Find(number); if (!room.IsFree) { throw new RoomTakenException("Zimmer " + number + " ist schon belegt."); } room.Occupy(); guests[number] = guest; }}Schritt für Schritt, in Worten
- Zimmer 102 ist frei. Anna Petrova und Boris Iliev buchen es im selben Moment.
- Zuerst ohne lock. Das ist eine mögliche Reihenfolge: Annas Anfrage prüft und sieht „frei“.
- Boris’ Anfrage prüft, bevor Anna das Zimmer belegt hat: Auch sie sieht „frei“.
- Anna belegt das Zimmer und trägt ihren Namen ein.
- Boris belegt das Zimmer auch und trägt seinen Namen ein. Beide haben eine Bestätigung für dasselbe Zimmer.
- Von vorn, mit lock (sync): Nur eine Anfrage auf einmal kommt ins Hotel.
- Sagen wir, Anna ist zuerst dran: Sie nimmt den Lock. Prüfen und Belegen sind ein Schritt, und niemand kommt dazwischen.
- Boris kommt beim Hotel an und wartet, bis der Lock frei ist.
- Boris hat den Lock. Jetzt sieht die Prüfung „belegt“.
- RoomTakenException. Der Server schickt sie als 409 Conflict zurück, und der Client macht daraus wieder eine RoomTakenException.
- Das Programm aus der Lektion zählt die Erfolgreichen: 1 pro Zimmer. Wer zuerst dran war, ist bei jedem Lauf anders.
Der Beweis: Zwei Clients buchen 102 gleichzeitig, dann buchen zwanzig Agenturen 201 gleichzeitig. Die Antworten werden gesammelt, und das Programm gibt aus, wie viele Erfolg hatten:
var client = new HotelClient("http://localhost:5342");
async Task<bool> TryReserve(HotelClient client, int number, string guest){ try { await client.ReserveAsync(number, guest); return true; } catch (RoomTakenException) { return false; }}
async Task<int> Race(HotelClient client, int number, string[] guests){ var attempts = new List<Task<bool>>(); foreach (string guest in guests) { attempts.Add(TryReserve(client, number, guest)); } bool[] reserved = await Task.WhenAll(attempts); return reserved.Count(r => r);}
string[] two = { "Anna Petrova", "Boris Iliev" };Console.WriteLine("Zwei für Zimmer 102, erfolgreich: " + await Race(client, 102, two));
string[] agencies = new string[20];for (int i = 0; i < agencies.Length; i++){ agencies[i] = "Agentur " + (i + 1);}Console.WriteLine("Zwanzig für Zimmer 201, erfolgreich: " + await Race(client, 201, agencies));Console.WriteLine("Frei: " + string.Join("; ", await client.FreeRoomsAsync()));Zwei für Zimmer 102, erfolgreich: 1Zwanzig für Zimmer 201, erfolgreich: 1Frei: 101, Einzelzimmer, 60 € pro Nacht, frei; 202, Suite, 140 € pro Nacht, freiTask.WhenAll startet alle Anfragen gleichzeitig und wartet auf die Antworten. Die Absage ist RoomTakenException, und TryReserve macht daraus false.
Wer das Zimmer bekommen hat, ist bei jedem Lauf anders, deshalb gibt das Programm es nicht aus. Wie viele Erfolg hatten, ist immer gleich: einer pro Zimmer.
Genauer, für Neugierige
- Ohne das Sperren würde das Programm trotzdem fast immer 1 sagen: Das Fenster zwischen Prüfen und Belegen ist klein. Aber „fast immer“ heißt, dass einmal in Tausenden Buchungen zwei Gäste vor dem Zimmer stehen. So einen Fehler findest du nicht durch Ausführen, sondern durch Nachdenken: Welche Daten sind gemeinsam, und wer ändert sie gleichzeitig.
-
lock (sync)sperrt das ganze Hotel. Während eine Anfrage 102 bucht, wartet eine andere sogar für 201. Bei vier Zimmern ist das in Ordnung. Bei Tausenden sperrt man pro Zimmer, zum Beispiellock (room) { ... }. - In einem echten System liegen die Zimmer in einer Datenbank, und der Schutz passiert dort: eine Transaktion oder eine Bedingung in der Abfrage selbst (
UPDATE rooms SET free = false WHERE number = 102 AND free = true). Dann gilt er auch bei mehreren Servern, nicht nur in einem Programm.
Probier es aus
Gesperrt, aber nicht ganz
lock. Der Server ruft zuerst die Prüfung auf, dann das Belegen. Bei vielen Anfragen gleichzeitig bekommen manchmal trotzdem zwei dasselbe Zimmer. In welcher Zeile steckt der Fehler?Wähle die Zeile mit dem Fehler.
Reserve stehen, wie in unserem Hotel.Sieh es dir an
Das ganze Hotel
Die Testszenarien aus der Aufgabe, diesmal über das Netz, mit zwei gleichzeitig verbundenen Clients. Am Ende buchen zwei im selben Moment 201:
var anna = new HotelClient("http://localhost:5343");var boris = new HotelClient("http://localhost:5343");
async Task Say(string who, Task<string> request){ try { Console.WriteLine(who + ": " + await request); } catch (HotelException e) { Console.WriteLine(who + ": abgelehnt, " + e.Message); }}
async Task<bool> TryReserve(HotelClient client, int number, string guest){ try { await client.ReserveAsync(number, guest); return true; } catch (RoomTakenException) { return false; }}
async Task<int> Race(HotelClient client, int number, string[] guests){ var attempts = new List<Task<bool>>(); foreach (string guest in guests) { attempts.Add(TryReserve(client, number, guest)); } bool[] reserved = await Task.WhenAll(attempts); return reserved.Count(r => r);}
await Say("Anna", anna.ReserveAsync(102, "Anna Petrova"));await Say("Boris", boris.ReserveAsync(102, "Boris Iliev"));Console.WriteLine("Boris: " + await boris.CheckAsync(102));await Say("Boris", boris.CancelAsync(102, "Boris Iliev"));await Say("Anna", anna.CancelAsync(102, "Anna Petrova"));await Say("Boris", boris.ReserveAsync(102, "Boris Iliev"));try{ await anna.CheckAsync(305);}catch (RoomNotFoundException e){ Console.WriteLine("Anna: abgelehnt, " + e.Message);}
string[] two = { "Anna Petrova", "Vera Koleva" };Console.WriteLine("Zwei gleichzeitig für Zimmer 201, erfolgreich: " + await Race(anna, 201, two));Console.WriteLine("Frei: " + string.Join("; ", await anna.FreeRoomsAsync()));Anna: Zimmer 102 gehört dir, Anna Petrova.Boris: abgelehnt, Zimmer 102 ist schon belegt.Boris: 102, Doppelzimmer, 85 € pro Nacht, belegtBoris: abgelehnt, Zimmer 102 ist von einem anderen Gast gebucht.Anna: Die Buchung für Zimmer 102 ist storniert.Boris: Zimmer 102 gehört dir, Boris Iliev.Anna: abgelehnt, Es gibt kein Zimmer 305.Zwei gleichzeitig für Zimmer 201, erfolgreich: 1Frei: 101, Einzelzimmer, 60 € pro Nacht, frei; 202, Suite, 140 € pro Nacht, freiGenauer, für Neugierige
Das ganze Programm, von Anfang bis Ende:
using System.Text.Encodings.Web;using System.Text.Unicode;
var builder = WebApplication.CreateBuilder();builder.Logging.ClearProviders();builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All));var app = builder.Build();app.Urls.Add("http://localhost:5343");
Hotel hotel = new Hotel();hotel.Add(new Room(101, "Einzelzimmer", 60));hotel.Add(new Room(102, "Doppelzimmer", 85));hotel.Add(new Room(201, "Doppelzimmer", 95));hotel.Add(new Room(202, "Suite", 140));
IResult Safe(Func<IResult> action){ try { return action(); } catch (RoomNotFoundException e) { return Results.NotFound(e.Message); } catch (RoomTakenException e) { return Results.Conflict(e.Message); } catch (HotelException e) { return Results.BadRequest(e.Message); }}
app.MapGet("/rooms/free", () => hotel.FreeRooms());app.MapGet("/rooms/{number:int}", (int number) => Safe(() => Results.Ok(hotel.Check(number))));app.MapPost("/rooms/{number:int}/reservation", (int number, ReservationRequest request) => Safe(() =>{ hotel.Reserve(number, request.Guest); return Results.Ok("Zimmer " + number + " gehört dir, " + request.Guest + ".");}));app.MapDelete("/rooms/{number:int}/reservation", (int number, string guest) => Safe(() =>{ hotel.Cancel(number, guest); return Results.Ok("Die Buchung für Zimmer " + number + " ist storniert.");}));await app.StartAsync();
var anna = new HotelClient("http://localhost:5343");var boris = new HotelClient("http://localhost:5343");
async Task Say(string who, Task<string> request){ try { Console.WriteLine(who + ": " + await request); } catch (HotelException e) { Console.WriteLine(who + ": abgelehnt, " + e.Message); }}
async Task<bool> TryReserve(HotelClient client, int number, string guest){ try { await client.ReserveAsync(number, guest); return true; } catch (RoomTakenException) { return false; }}
async Task<int> Race(HotelClient client, int number, string[] guests){ var attempts = new List<Task<bool>>(); foreach (string guest in guests) { attempts.Add(TryReserve(client, number, guest)); } bool[] reserved = await Task.WhenAll(attempts); return reserved.Count(r => r);}
await Say("Anna", anna.ReserveAsync(102, "Anna Petrova"));await Say("Boris", boris.ReserveAsync(102, "Boris Iliev"));Console.WriteLine("Boris: " + await boris.CheckAsync(102));await Say("Boris", boris.CancelAsync(102, "Boris Iliev"));await Say("Anna", anna.CancelAsync(102, "Anna Petrova"));await Say("Boris", boris.ReserveAsync(102, "Boris Iliev"));try{ await anna.CheckAsync(305);}catch (RoomNotFoundException e){ Console.WriteLine("Anna: abgelehnt, " + e.Message);}
string[] two = { "Anna Petrova", "Vera Koleva" };Console.WriteLine("Zwei gleichzeitig für Zimmer 201, erfolgreich: " + await Race(anna, 201, two));Console.WriteLine("Frei: " + string.Join("; ", await anna.FreeRoomsAsync()));
await app.StopAsync();
public class Room{ public int Number { get; } public string Type { get; } public int Price { get; } public bool IsFree { get; set; } = true;
public Room(int number, string type, int price) { Number = number; Type = type; Price = price; }
public void Occupy() { IsFree = false; }
public void Release() { IsFree = true; }
public override string ToString() { return Number + ", " + Type + ", " + Price + " € pro Nacht, " + (IsFree ? "frei" : "belegt"); }}
public class Hotel{ private readonly List<Room> rooms = new List<Room>(); private readonly Dictionary<int, string> guests = new Dictionary<int, string>(); private readonly object sync = new object();
public void Add(Room room) { lock (sync) { rooms.Add(room); } }
public List<Room> FreeRooms() { lock (sync) { return rooms.Where(room => room.IsFree).ToList(); } }
public Room Check(int number) { lock (sync) { return Find(number); } }
public void Reserve(int number, string guest) { lock (sync) { if (string.IsNullOrWhiteSpace(guest)) { throw new HotelException("Der Name des Gastes ist Pflicht."); } Room room = Find(number); if (!room.IsFree) { throw new RoomTakenException("Zimmer " + number + " ist schon belegt."); } room.Occupy(); guests[number] = guest; } }
public void Cancel(int number, string guest) { lock (sync) { Room room = Find(number); if (room.IsFree) { throw new HotelException("Zimmer " + number + " ist nicht gebucht."); } if (guests[number] != guest) { throw new HotelException("Zimmer " + number + " ist von einem anderen Gast gebucht."); } room.Release(); guests.Remove(number); } }
private Room Find(int number) { Room room = rooms.FirstOrDefault(r => r.Number == number); if (room == null) { throw new RoomNotFoundException("Es gibt kein Zimmer " + number + "."); } return room; }}
public class HotelException : Exception{ public HotelException(string message) : base(message) { }}
public class RoomNotFoundException : HotelException{ public RoomNotFoundException(string message) : base(message) { }}
public class RoomTakenException : HotelException{ public RoomTakenException(string message) : base(message) { }}
public class ReservationRequest{ public string Guest { get; set; }}
public class HotelClient{ private readonly HttpClient http;
public HotelClient(string address) { http = new HttpClient { BaseAddress = new Uri(address) }; }
public async Task<List<Room>> FreeRoomsAsync() { return await http.GetFromJsonAsync<List<Room>>("/rooms/free"); }
public async Task<Room> CheckAsync(int number) { HttpResponseMessage response = await http.GetAsync("/rooms/" + number); await ThrowIfError(response); return await response.Content.ReadFromJsonAsync<Room>(); }
public async Task<string> ReserveAsync(int number, string guest) { var request = new ReservationRequest { Guest = guest }; HttpResponseMessage response = await http.PostAsJsonAsync("/rooms/" + number + "/reservation", request); await ThrowIfError(response); return await response.Content.ReadFromJsonAsync<string>(); }
public async Task<string> CancelAsync(int number, string guest) { HttpResponseMessage response = await http.DeleteAsync("/rooms/" + number + "/reservation?guest=" + guest); await ThrowIfError(response); return await response.Content.ReadFromJsonAsync<string>(); }
private static async Task ThrowIfError(HttpResponseMessage response) { if (response.IsSuccessStatusCode) { return; } string message = await response.Content.ReadFromJsonAsync<string>(); int status = (int)response.StatusCode; if (status == 404) { throw new RoomNotFoundException(message); } if (status == 409) { throw new RoomTakenException(message); } throw new HotelException(message); }}Übung
Mach allein weiter
Arbeite mit dem ganzen Programm aus der vorigen Karte.
1. Zimmerwechsel. Der Gast zieht von einem Zimmer in ein anderes mit POST /rooms/102/move und dem Body {"guest":"Anna Petrova","to":202}. Die Antwort ist „Anna Petrova ist in Zimmer 202.“, und der Client hat eine Methode MoveAsync. Ist das neue Zimmer belegt, bleibt der Gast im alten. Der Wechsel ist eine Methode in Hotel, in einem lock: Niemand darf das neue Zimmer zwischen dem Freigeben des alten und dem Belegen des neuen nehmen.
Zimmer 102 gehört dir, Anna Petrova.Zimmer 201 gehört dir, Boris Iliev.Abgelehnt: Zimmer 201 ist schon belegt.Anna Petrova ist in Zimmer 202.Frei: 101, Einzelzimmer, 60 € pro Nacht, frei; 102, Doppelzimmer, 85 € pro Nacht, freiWeitere Ideen
- Nächte und Summe. Eine Buchung gilt für eine Anzahl Nächte, und die Bestätigung nennt die Summe: „3 Nächte, 255 €“.
- Daten. Ein Zimmer wird für Tage gebucht, zum Beispiel vom 14. bis 16. Dezember. Zwei Buchungen für dasselbe Zimmer dürfen sich nicht überschneiden. Ob das Zimmer frei ist, hängt von den Daten ab.
- Meine Buchungen.
GET /reservations?guest=Anna Petrovasagt, welche Zimmer zu diesem Gast gehören. - Warteliste. Ist das Zimmer belegt, trägt sich der Gast zum Warten ein. Wird es storniert, geht es an den Ersten auf der Liste.
-
Ein Server, der nicht anhält. Verschiebe den Server mit
await app.RunAsync()in ein eigenes Programm und den Client in ein anderes, das du in einem anderen Terminal startest.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: async und await · Wie das Internet funktioniert · Ein Webserver mit Minimal API · WebAPI: Daten für andere Programme
- Aufgaben zum Thema: Projekt