Хотелски резервации
Проблем
Една стая, две потвърждения
Малък хотел има четири стаи. Резервации идват отвсякъде: от сайта, от рецепцията, от туристически агенции. Една петъчна вечер две агенции резервират последната двойна стая в една и съща секунда. И двете получават потвърждение. В събота в стаята пристигат две семейства.
Ще направим система, в която сървърът пази стаите и резервациите, а клиентите пращат заявки по мрежата. Клиентът може:
- да види свободните стаи;
- да провери една стая;
- да резервира стая на свое име;
- да анулира своята резервация.
А когато двама клиенти резервират една стая едновременно, само единият успява. Това ще докажем с програма, която дава един и същ изход при всяко пускане.
Строим отдолу нагоре: моделът без мрежа, правилата и грешките, протоколът, сървърът и клиентът, едновременните заявки и накрая всичко в една програма.
По-точно, за любопитните
HttpClient. В двата езика една и съща ключова идея пази стаите: lock.Идеята
Стаята
Стаята има номер, тип, цена за нощ и състояние: свободна или заета.
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 + " € на нощ, " + (free ? "свободна" : "заета"); }}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 + " € на нощ, " + (IsFree ? "свободна" : "заета"); }}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 = "свободна" if self._free else "заета" return f"{self._number}, {self._kind}, {self._price} € на нощ, {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 ? "свободна" : "заета"; return `${this.#number}, ${this.#type}, ${this.#price} € на нощ, ${state}`; }}- Номерът, типът и цената не се менят. Мени се само състоянието, и то с два метода с ясни имена:
OccupyиRelease. - Кой е отседнал, стаята не знае. Стаята стига до всеки клиент, който пита, а името на госта не бива да излиза от хотела.
По-точно, за любопитните
IsFree има публичен set, защото клиентът получава стаята като JSON и System.Text.Json трябва да запише състоянието. Номерът, типът и цената идват през конструктора. Без set клиентът би виждал всяка стая като свободна. За повече от две състояния се ползва енумерация: enum RoomState { Free, Taken, Repair }.Виж го
Хотелът и резервацията
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 е частен: търси стаята по номер или хвърля изключение. Така никой отвън не може да вземе стая и да я заеме покрай правилата:
private Room find(int number) { for (Room room : rooms) { if (room.getNumber() == number) { return room; } } throw new RoomNotFoundException("Няма стая " + number + ".");}private Room Find(int number){ Room room = rooms.FirstOrDefault(r => r.Number == number); if (room == null) { throw new RoomNotFoundException("Няма стая " + number + "."); } return room;}def _find(self, number): for room in self._rooms: if room.number == number: return room raise RoomNotFoundError(f"Няма стая {number}.")#find(number) { const room = this.#rooms.find((r) => r.number === number); if (room === undefined) { throw new RoomNotFoundError(`Няма стая ${number}.`); } return room;}Резервацията проверява името, намира стаята и проверява дали е свободна. Едва тогава я заема:
public void reserve(int number, String guest) { if (guest.isBlank()) { throw new HotelException("Името на госта е задължително."); } Room room = find(number); if (!room.isFree()) { throw new RoomTakenException("Стая " + number + " вече е заета."); } room.occupy(); guests.put(number, guest);}public void Reserve(int number, string guest){ if (string.IsNullOrWhiteSpace(guest)) { throw new HotelException("Името на госта е задължително."); } Room room = Find(number); if (!room.IsFree) { throw new RoomTakenException("Стая " + number + " вече е заета."); } room.Occupy(); guests[number] = guest;}def reserve(self, number, guest): if not guest.strip(): raise HotelError("Името на госта е задължително.") room = self._find(number) if not room.is_free: raise RoomTakenError(f"Стая {number} вече е заета.") room.occupy() self._guests[number] = guestreserve(number, guest) { if (guest.trim() === "") { throw new HotelError("Името на госта е задължително."); } const room = this.#find(number); if (!room.isFree) { throw new RoomTakenError(`Стая ${number} вече е заета.`); } room.occupy(); this.#guests.set(number, guest);}Hotel hotel = new Hotel();hotel.add(new Room(101, "единична", 60));hotel.add(new Room(102, "двойна", 85));hotel.add(new Room(201, "двойна", 95));hotel.add(new Room(202, "апартамент", 140));
for (Room room : hotel.freeRooms()) { System.out.println(room);}hotel.reserve(102, "Анна Петрова");System.out.println("След резервацията: " + hotel.check(102));System.out.println("Свободни стаи: " + hotel.freeRooms().size());101, единична, 60 € на нощ, свободна102, двойна, 85 € на нощ, свободна201, двойна, 95 € на нощ, свободна202, апартамент, 140 € на нощ, свободнаСлед резервацията: 102, двойна, 85 € на нощ, заетаСвободни стаи: 3Hotel hotel = new Hotel();hotel.Add(new Room(101, "единична", 60));hotel.Add(new Room(102, "двойна", 85));hotel.Add(new Room(201, "двойна", 95));hotel.Add(new Room(202, "апартамент", 140));
foreach (Room room in hotel.FreeRooms()){ Console.WriteLine(room);}hotel.Reserve(102, "Анна Петрова");Console.WriteLine("След резервацията: " + hotel.Check(102));Console.WriteLine("Свободни стаи: " + hotel.FreeRooms().Count);101, единична, 60 € на нощ, свободна102, двойна, 85 € на нощ, свободна201, двойна, 95 € на нощ, свободна202, апартамент, 140 € на нощ, свободнаСлед резервацията: 102, двойна, 85 € на нощ, заетаСвободни стаи: 3hotel = Hotel()hotel.add(Room(101, "единична", 60))hotel.add(Room(102, "двойна", 85))hotel.add(Room(201, "двойна", 95))hotel.add(Room(202, "апартамент", 140))
for room in hotel.free_rooms(): print(room)hotel.reserve(102, "Анна Петрова")print(f"След резервацията: {hotel.check(102)}")print(f"Свободни стаи: {len(hotel.free_rooms())}")101, единична, 60 € на нощ, свободна102, двойна, 85 € на нощ, свободна201, двойна, 95 € на нощ, свободна202, апартамент, 140 € на нощ, свободнаСлед резервацията: 102, двойна, 85 € на нощ, заетаСвободни стаи: 3const hotel = new Hotel();hotel.add(new Room(101, "единична", 60));hotel.add(new Room(102, "двойна", 85));hotel.add(new Room(201, "двойна", 95));hotel.add(new Room(202, "апартамент", 140));
for (const room of hotel.freeRooms()) { console.log(String(room));}hotel.reserve(102, "Анна Петрова");console.log(`След резервацията: ${hotel.check(102)}`);console.log(`Свободни стаи: ${hotel.freeRooms().length}`);101, единична, 60 € на нощ, свободна102, двойна, 85 € на нощ, свободна201, двойна, 95 € на нощ, свободна202, апартамент, 140 € на нощ, свободнаСлед резервацията: 102, двойна, 85 € на нощ, заетаСвободни стаи: 3Идеята
Анулиране и правилата
Не всяка заявка е изпълнима: стаята е заета, номер 305 няма, гостът анулира чужда резервация. Това са откази по правилата, затова са свои изключения с общ родител:
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"; }}Анулирането проверява, че стаята е заета и че я анулира същият гост:
public void cancel(int number, String guest) { Room room = find(number); if (room.isFree()) { throw new HotelException("Стая " + number + " не е резервирана."); } if (!guests.get(number).equals(guest)) { throw new HotelException("Стая " + number + " е резервирана от друг гост."); } room.release(); guests.remove(number);}public void Cancel(int number, string guest){ Room room = Find(number); if (room.IsFree) { throw new HotelException("Стая " + number + " не е резервирана."); } if (guests[number] != guest) { throw new HotelException("Стая " + number + " е резервирана от друг гост."); } room.Release(); guests.Remove(number);}def cancel(self, number, guest): room = self._find(number) if room.is_free: raise HotelError(f"Стая {number} не е резервирана.") if self._guests[number] != guest: raise HotelError(f"Стая {number} е резервирана от друг гост.") room.release() del self._guests[number]cancel(number, guest) { const room = this.#find(number); if (room.isFree) { throw new HotelError(`Стая ${number} не е резервирана.`); } if (this.#guests.get(number) !== guest) { throw new HotelError(`Стая ${number} е резервирана от друг гост.`); } room.release(); this.#guests.delete(number);}Условието на проекта иска системата да се провери в различни сценарии: резервация, проверка и анулиране, и когато са правилни, и когато не са. Всеки сценарий е ред в изхода:
Hotel hotel = new Hotel();hotel.add(new Room(101, "единична", 60));hotel.add(new Room(102, "двойна", 85));hotel.add(new Room(201, "двойна", 95));hotel.add(new Room(202, "апартамент", 140));
hotel.reserve(102, "Анна Петрова");System.out.println("1. Свободна стая: " + hotel.check(102));try { hotel.reserve(102, "Борис Илиев");} catch (RoomTakenException e) { System.out.println("2. Заета стая: " + e.getMessage());}try { hotel.cancel(102, "Борис Илиев");} catch (HotelException e) { System.out.println("3. Чужда резервация: " + e.getMessage());}hotel.cancel(102, "Анна Петрова");System.out.println("4. Анулиране: " + hotel.check(102));try { hotel.cancel(102, "Анна Петрова");} catch (HotelException e) { System.out.println("5. Второ анулиране: " + e.getMessage());}try { hotel.reserve(305, "Анна Петрова");} catch (RoomNotFoundException e) { System.out.println("6. Няма такава стая: " + e.getMessage());}try { hotel.reserve(201, " ");} catch (HotelException e) { System.out.println("7. Без име: " + e.getMessage());}1. Свободна стая: 102, двойна, 85 € на нощ, заета2. Заета стая: Стая 102 вече е заета.3. Чужда резервация: Стая 102 е резервирана от друг гост.4. Анулиране: 102, двойна, 85 € на нощ, свободна5. Второ анулиране: Стая 102 не е резервирана.6. Няма такава стая: Няма стая 305.7. Без име: Името на госта е задължително.Hotel hotel = new Hotel();hotel.Add(new Room(101, "единична", 60));hotel.Add(new Room(102, "двойна", 85));hotel.Add(new Room(201, "двойна", 95));hotel.Add(new Room(202, "апартамент", 140));
hotel.Reserve(102, "Анна Петрова");Console.WriteLine("1. Свободна стая: " + hotel.Check(102));try{ hotel.Reserve(102, "Борис Илиев");}catch (RoomTakenException e){ Console.WriteLine("2. Заета стая: " + e.Message);}try{ hotel.Cancel(102, "Борис Илиев");}catch (HotelException e){ Console.WriteLine("3. Чужда резервация: " + e.Message);}hotel.Cancel(102, "Анна Петрова");Console.WriteLine("4. Анулиране: " + hotel.Check(102));try{ hotel.Cancel(102, "Анна Петрова");}catch (HotelException e){ Console.WriteLine("5. Второ анулиране: " + e.Message);}try{ hotel.Reserve(305, "Анна Петрова");}catch (RoomNotFoundException e){ Console.WriteLine("6. Няма такава стая: " + e.Message);}try{ hotel.Reserve(201, " ");}catch (HotelException e){ Console.WriteLine("7. Без име: " + e.Message);}1. Свободна стая: 102, двойна, 85 € на нощ, заета2. Заета стая: Стая 102 вече е заета.3. Чужда резервация: Стая 102 е резервирана от друг гост.4. Анулиране: 102, двойна, 85 € на нощ, свободна5. Второ анулиране: Стая 102 не е резервирана.6. Няма такава стая: Няма стая 305.7. Без име: Името на госта е задължително.hotel = Hotel()hotel.add(Room(101, "единична", 60))hotel.add(Room(102, "двойна", 85))hotel.add(Room(201, "двойна", 95))hotel.add(Room(202, "апартамент", 140))
hotel.reserve(102, "Анна Петрова")print(f"1. Свободна стая: {hotel.check(102)}")try: hotel.reserve(102, "Борис Илиев")except RoomTakenError as e: print(f"2. Заета стая: {e}")try: hotel.cancel(102, "Борис Илиев")except HotelError as e: print(f"3. Чужда резервация: {e}")hotel.cancel(102, "Анна Петрова")print(f"4. Анулиране: {hotel.check(102)}")try: hotel.cancel(102, "Анна Петрова")except HotelError as e: print(f"5. Второ анулиране: {e}")try: hotel.reserve(305, "Анна Петрова")except RoomNotFoundError as e: print(f"6. Няма такава стая: {e}")try: hotel.reserve(201, " ")except HotelError as e: print(f"7. Без име: {e}")1. Свободна стая: 102, двойна, 85 € на нощ, заета2. Заета стая: Стая 102 вече е заета.3. Чужда резервация: Стая 102 е резервирана от друг гост.4. Анулиране: 102, двойна, 85 € на нощ, свободна5. Второ анулиране: Стая 102 не е резервирана.6. Няма такава стая: Няма стая 305.7. Без име: Името на госта е задължително.const hotel = new Hotel();hotel.add(new Room(101, "единична", 60));hotel.add(new Room(102, "двойна", 85));hotel.add(new Room(201, "двойна", 95));hotel.add(new Room(202, "апартамент", 140));
hotel.reserve(102, "Анна Петрова");console.log(`1. Свободна стая: ${hotel.check(102)}`);try { hotel.reserve(102, "Борис Илиев");} catch (e) { if (!(e instanceof RoomTakenError)) throw e; console.log(`2. Заета стая: ${e.message}`);}try { hotel.cancel(102, "Борис Илиев");} catch (e) { if (!(e instanceof HotelError)) throw e; console.log(`3. Чужда резервация: ${e.message}`);}hotel.cancel(102, "Анна Петрова");console.log(`4. Анулиране: ${hotel.check(102)}`);try { hotel.cancel(102, "Анна Петрова");} catch (e) { if (!(e instanceof HotelError)) throw e; console.log(`5. Второ анулиране: ${e.message}`);}try { hotel.reserve(305, "Анна Петрова");} catch (e) { if (!(e instanceof RoomNotFoundError)) throw e; console.log(`6. Няма такава стая: ${e.message}`);}try { hotel.reserve(201, " ");} catch (e) { if (!(e instanceof HotelError)) throw e; console.log(`7. Без име: ${e.message}`);}1. Свободна стая: 102, двойна, 85 € на нощ, заета2. Заета стая: Стая 102 вече е заета.3. Чужда резервация: Стая 102 е резервирана от друг гост.4. Анулиране: 102, двойна, 85 € на нощ, свободна5. Второ анулиране: Стая 102 не е резервирана.6. Няма такава стая: Няма стая 305.7. Без име: Името на госта е задължително.Конкретното изключение казва какъв е проблемът. HotelException хваща всички откази наведнъж: сървърът ще хваща само него и ще го превръща в отговор за клиента.
По-точно, за любопитните
- В C# няма проверени изключения. Затова сървърът трябва да ги хване на едно място, иначе отказът по правилата излиза като 500.
- Сценарии като тези са естествени тестове. В C# за това има библиотеки като xUnit: всеки сценарий става метод, а
Assert.Throws<RoomTakenException>(...)проверява изключението.
Пробвай
Кой е в 201
Какво ще отпечата програмата?
Hotel hotel = new Hotel();hotel.Add(new Room(101, "единична", 60));hotel.Add(new Room(102, "двойна", 85));hotel.Add(new Room(201, "двойна", 95));hotel.Add(new Room(202, "апартамент", 140));
hotel.Reserve(101, "Анна Петрова");hotel.Reserve(201, "Борис Илиев");hotel.Cancel(101, "Анна Петрова");try{ hotel.Reserve(201, "Вера Колева"); Console.WriteLine("Вера е в 201");}catch (RoomTakenException e){ Console.WriteLine("Отказ за Вера");}Console.WriteLine(hotel.FreeRooms().Count);Анна заема 101 и после я освобождава. Борис заема 201, затова Вера получава RoomTakenException и редът „Вера е в 201“ не се изпълнява. Заета е само 201, свободни са три стаи.
Идеята
Протоколът: адреси и JSON
Всяко действие е метод и адрес, данните са JSON, а статусът казва как е минало:
| Заявка | Тяло | Отговор |
|---|---|---|
GET /rooms/free |
200 и свободните стаи | |
GET /rooms/102 |
200 и стаята или 404 | |
POST /rooms/102/reservation |
{"guest":"Анна Петрова"} |
200 и потвърждение, 409 за заета стая, 404 за стая, която я няма |
DELETE /rooms/102/reservation?guest=Анна Петрова |
200 и потвърждение или 400 с причината |
Всеки отказ на модела има свой статус. RoomNotFoundException е 404 Not Found, RoomTakenException е 409 Conflict: заявката е наред, но се сблъсква с текущото състояние. Всеки друг HotelException е 400 Bad Request.
Тялото на резервацията е малък клас само за пренасяне:
public class ReservationRequest{ public string Guest { get; set; }}По-точно, за любопитните
-
Командите могат да са енумерация:
enum Command { Free, Check, Reserve, Cancel }. Тогава са отделен тип със затворен списък от стойности. В курса са константи, защото енумерации не сме учили. -
DELETEняма тяло по правило, затова името е в адреса. В истинска система вместо име клиентът влиза с акаунт и сървърът знае кой е. Тогава никой не може да анулира чужда резервация, като напише чуждо име.
Виж го
Сървърът
Началото е като в урока за първия сървър: портът, без съобщенията на ASP.NET и JSON с четима кирилица:
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:5321");Отказите на модела стават статуси на едно място, в 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); }}Адресите викат хотела. Резервацията и анулирането са ламбди с тяло, защото правят две неща: викат модела и връщат потвърждение:
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("Стая " + number + " е твоя, " + request.Guest + ".");}));app.MapDelete("/rooms/{number:int}/reservation", (int number, string guest) => Safe(() =>{ hotel.Cancel(number, guest); return Results.Ok("Резервацията за стая " + number + " е анулирана.");}));Ето какво минава по мрежата:
using var http = new HttpClient { BaseAddress = new Uri("http://localhost:5321") };
async Task Show(string request, HttpResponseMessage response){ Console.WriteLine(request + " -> " + (int)response.StatusCode + " " + await response.Content.ReadAsStringAsync());}
var anna = new ReservationRequest { Guest = "Анна Петрова" };var boris = new ReservationRequest { Guest = "Борис Илиев" };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=Борис Илиев", await http.DeleteAsync("/rooms/102/reservation?guest=Борис Илиев"));await Show("DELETE /rooms/102/reservation?guest=Анна Петрова", await http.DeleteAsync("/rooms/102/reservation?guest=Анна Петрова"));await Show("GET /rooms/305", await http.GetAsync("/rooms/305"));POST /rooms/102/reservation -> 200 "Стая 102 е твоя, Анна Петрова."POST /rooms/102/reservation -> 409 "Стая 102 вече е заета."GET /rooms/102 -> 200 {"number":102,"type":"двойна","price":85,"isFree":false}DELETE /rooms/102/reservation?guest=Борис Илиев -> 400 "Стая 102 е резервирана от друг гост."DELETE /rooms/102/reservation?guest=Анна Петрова -> 200 "Резервацията за стая 102 е анулирана."GET /rooms/305 -> 404 "Няма стая 305."Виж го
Клиентът
Клиентът крие HTTP зад методи. Отговор с грешка става същото изключение, което сървърът е хвърлил: 404, 409 и 400 се връщат като RoomNotFoundException, RoomTakenException и HotelException:
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:5325");
Console.WriteLine(await client.ReserveAsync(102, "Анна Петрова"));Console.WriteLine(await client.CheckAsync(102));foreach (Room room in await client.FreeRoomsAsync()){ Console.WriteLine("Свободна: " + room);}Console.WriteLine(await client.CancelAsync(102, "Анна Петрова"));try{ await client.CheckAsync(305);}catch (RoomNotFoundException e){ Console.WriteLine("Отказ: " + e.Message);}Стая 102 е твоя, Анна Петрова.102, двойна, 85 € на нощ, заетаСвободна: 101, единична, 60 € на нощ, свободнаСвободна: 201, двойна, 95 € на нощ, свободнаСвободна: 202, апартамент, 140 € на нощ, свободнаРезервацията за стая 102 е анулирана.Отказ: Няма стая 305.Идеята
Двама за една стая
| Агенция А | Агенция Б | |
|---|---|---|
| 1 | проверява: 102 е свободна | |
| 2 | проверява: 102 е свободна | |
| 3 | заема 102 за своя гост | |
| 4 | заема 102 за своя гост и записва него |
И двете получават потвърждение. Няма грешка в нито един ред код, грешката е в това, че стъпките на двете заявки се преплитат.
Решението: в хотела влиза само една заявка наведнъж. Тогава „провери и заеми“ е неделимо: никой не може да влезе между проверката и заемането. В C# това правиlock във всеки публичен метод на 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("Името на госта е задължително."); } Room room = Find(number); if (!room.IsFree) { throw new RoomTakenException("Стая " + number + " вече е заета."); } room.Occupy(); guests[number] = guest; }}public void Reserve(int number, string guest){ lock (sync) { if (string.IsNullOrWhiteSpace(guest)) { throw new HotelException("Името на госта е задължително."); } Room room = Find(number); if (!room.IsFree) { throw new RoomTakenException("Стая " + number + " вече е заета."); } room.Occupy(); guests[number] = guest; }}Стъпка по стъпка, с думи
- Стая 102 е свободна. Анна Петрова и Борис Илиев я резервират в един и същ миг.
- Първо без lock. Това е един възможен ред: заявката на Анна проверява и вижда „свободна“.
- Заявката на Борис проверява, преди Анна да е заела стаята: и тя вижда „свободна“.
- Анна заема стаята и записва името си.
- Борис също заема стаята и записва своето име. Двамата имат потвърждение за една стая.
- Отначало, с lock (sync): в хотела влиза само една заявка наведнъж.
- Да кажем, че първа е Анна: тя взима ключалката. Проверката и заемането са една стъпка, никой не влиза между тях.
- Борис стига до хотела и чака, докато ключалката се освободи.
- Ключалката е на Борис. Проверката вече вижда „заета“.
- RoomTakenException. Сървърът го връща като 409 Conflict, а клиентът пак го прави RoomTakenException.
- Програмата от урока брои успелите: по 1 за всяка стая. Кой е бил пръв, е различно при всяко пускане.
Доказателството: двама клиенти резервират 102 наведнъж, после двадесет агенции резервират 201 наведнъж. Отговорите се събират, а програмата печата колко са успели:
var client = new HotelClient("http://localhost:5322");
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 = { "Анна Петрова", "Борис Илиев" };Console.WriteLine("Двама за стая 102, успешни: " + await Race(client, 102, two));
string[] agencies = new string[20];for (int i = 0; i < agencies.Length; i++){ agencies[i] = "Агенция " + (i + 1);}Console.WriteLine("Двадесет за стая 201, успешни: " + await Race(client, 201, agencies));Console.WriteLine("Свободни: " + string.Join("; ", await client.FreeRoomsAsync()));Двама за стая 102, успешни: 1Двадесет за стая 201, успешни: 1Свободни: 101, единична, 60 € на нощ, свободна; 202, апартамент, 140 € на нощ, свободнаTask.WhenAll пуска всички заявки наведнъж и чака отговорите. Отказът е RoomTakenException, а TryReserve го превръща в false.
Кой е спечелил стаята, е различно при всяко пускане, затова програмата не го печата. Колко са успели, е винаги едно и също: по един за всяка стая.
По-точно, за любопитните
- Без заключването програмата пак почти винаги би казала 1: прозорецът между проверката и заемането е малък. Но „почти винаги“ значи, че веднъж на хиляди резервации в стаята пристигат двама. Такъв бъг не се хваща с пускане, а с мислене: кои данни са общи и кой ги мени наведнъж.
-
lock (sync)заключва целия хотел. Докато една заявка резервира 102, друга чака дори за 201. При четири стаи това е добре. При хиляди се заключва по стая, напримерlock (room) { ... }. - В истинска система стаите са в база данни и пазенето е там: транзакция или условие в самата заявка (
UPDATE rooms SET free = false WHERE number = 102 AND free = true). Тогава важи и при няколко сървъра, а не само в една програма.
Пробвай
Заключено, но не съвсем
lock. Сървърът вика първо проверката, после заемането. При много заявки наведнъж понякога пак двама получават една стая. На кой ред е грешката?Избери реда с грешката.
Reserve, както в нашия Hotel.Виж го
Целият хотел
Тестовите сценарии от условието, този път по мрежата, с двама клиенти, свързани наведнъж. Накрая двама резервират 201 в един миг:
var anna = new HotelClient("http://localhost:5323");var boris = new HotelClient("http://localhost:5323");
async Task Say(string who, Task<string> request){ try { Console.WriteLine(who + ": " + await request); } catch (HotelException e) { Console.WriteLine(who + ": отказ, " + 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.ReserveAsync(102, "Анна Петрова"));await Say("Борис", boris.ReserveAsync(102, "Борис Илиев"));Console.WriteLine("Борис: " + await boris.CheckAsync(102));await Say("Борис", boris.CancelAsync(102, "Борис Илиев"));await Say("Анна", anna.CancelAsync(102, "Анна Петрова"));await Say("Борис", boris.ReserveAsync(102, "Борис Илиев"));try{ await anna.CheckAsync(305);}catch (RoomNotFoundException e){ Console.WriteLine("Анна: отказ, " + e.Message);}
string[] two = { "Анна Петрова", "Вера Колева" };Console.WriteLine("Двама за стая 201 наведнъж, успешни: " + await Race(anna, 201, two));Console.WriteLine("Свободни: " + string.Join("; ", await anna.FreeRoomsAsync()));Анна: Стая 102 е твоя, Анна Петрова.Борис: отказ, Стая 102 вече е заета.Борис: 102, двойна, 85 € на нощ, заетаБорис: отказ, Стая 102 е резервирана от друг гост.Анна: Резервацията за стая 102 е анулирана.Борис: Стая 102 е твоя, Борис Илиев.Анна: отказ, Няма стая 305.Двама за стая 201 наведнъж, успешни: 1Свободни: 101, единична, 60 € на нощ, свободна; 202, апартамент, 140 € на нощ, свободнаПо-точно, за любопитните
Цялата програма, от началото до края:
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:5323");
Hotel hotel = new Hotel();hotel.Add(new Room(101, "единична", 60));hotel.Add(new Room(102, "двойна", 85));hotel.Add(new Room(201, "двойна", 95));hotel.Add(new Room(202, "апартамент", 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("Стая " + number + " е твоя, " + request.Guest + ".");}));app.MapDelete("/rooms/{number:int}/reservation", (int number, string guest) => Safe(() =>{ hotel.Cancel(number, guest); return Results.Ok("Резервацията за стая " + number + " е анулирана.");}));await app.StartAsync();
var anna = new HotelClient("http://localhost:5323");var boris = new HotelClient("http://localhost:5323");
async Task Say(string who, Task<string> request){ try { Console.WriteLine(who + ": " + await request); } catch (HotelException e) { Console.WriteLine(who + ": отказ, " + 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.ReserveAsync(102, "Анна Петрова"));await Say("Борис", boris.ReserveAsync(102, "Борис Илиев"));Console.WriteLine("Борис: " + await boris.CheckAsync(102));await Say("Борис", boris.CancelAsync(102, "Борис Илиев"));await Say("Анна", anna.CancelAsync(102, "Анна Петрова"));await Say("Борис", boris.ReserveAsync(102, "Борис Илиев"));try{ await anna.CheckAsync(305);}catch (RoomNotFoundException e){ Console.WriteLine("Анна: отказ, " + e.Message);}
string[] two = { "Анна Петрова", "Вера Колева" };Console.WriteLine("Двама за стая 201 наведнъж, успешни: " + await Race(anna, 201, two));Console.WriteLine("Свободни: " + 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 + " € на нощ, " + (IsFree ? "свободна" : "заета"); }}
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("Името на госта е задължително."); } Room room = Find(number); if (!room.IsFree) { throw new RoomTakenException("Стая " + number + " вече е заета."); } room.Occupy(); guests[number] = guest; } }
public void Cancel(int number, string guest) { lock (sync) { Room room = Find(number); if (room.IsFree) { throw new HotelException("Стая " + number + " не е резервирана."); } if (guests[number] != guest) { throw new HotelException("Стая " + number + " е резервирана от друг гост."); } room.Release(); guests.Remove(number); } }
private Room Find(int number) { Room room = rooms.FirstOrDefault(r => r.Number == number); if (room == null) { throw new RoomNotFoundException("Няма стая " + 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); }}Упражнение
Продължи сам
Работи с цялата програма от предишната карта.
1. Смяна на стая. Гостът се мести от една стая в друга с POST /rooms/102/move и тяло {"guest":"Анна Петрова","to":202}. Отговорът е „Анна Петрова е в стая 202.“, а клиентът има метод MoveAsync. Ако новата стая е заета, гостът остава в старата. Смяната е един метод в Hotel, в lock: никой не бива да заеме новата стая между освобождаването на старата и заемането на новата.
Стая 102 е твоя, Анна Петрова.Стая 201 е твоя, Борис Илиев.Отказ: Стая 201 вече е заета.Анна Петрова е в стая 202.Свободни: 101, единична, 60 € на нощ, свободна; 102, двойна, 85 € на нощ, свободнаОще идеи
- Нощувки и сума. Резервацията е за брой нощи, а потвърждението казва сумата: „3 нощи, 255 €“.
- Дати. Стаята се резервира за дни, например от 14 до 16 декември. Две резервации за една стая не бива да се застъпват. Свободна ли е стаята, зависи от датите.
- Моите резервации.
GET /reservations?guest=Анна Петроваказва кои стаи са на този гост. - Списък на чакащите. Ако стаята е заета, гостът се записва да чака. Когато се анулира, тя отива при първия в списъка.
-
Сървър, който не спира. Отдели сървъра в своя програма с
await app.RunAsync(), а клиента в друга, пусната в друг терминал.
Тест