Ir al contenido
wannacode

Reservas de hotel

En resumen: Un proyecto de reservas de hotel: un servidor y muchos clientes reservan, consultan y anulan habitaciones a la vez, y cuando dos personas quieren la misma habitación en el mismo instante, solo una lo consigue.

Problema

Una habitación, dos confirmaciones

Un hotel pequeño tiene cuatro habitaciones. Las reservas llegan de todas partes: del sitio web, de la recepción, de las agencias de viajes. Un viernes por la tarde, dos agencias reservan la última habitación doble en el mismo segundo. Las dos reciben una confirmación. El sábado llegan dos familias a la habitación.

Vamos a construir un sistema en el que el servidor guarda las habitaciones y las reservas, y los clientes envían solicitudes por la red. Un cliente puede:

  • ver las habitaciones libres;
  • consultar una habitación;
  • reservar una habitación a su nombre;
  • anular su propia reserva.

Y cuando dos clientes reservan la misma habitación a la vez, solo uno de ellos lo consigue. Lo demostraremos con un programa que da la misma salida en cada ejecución.

Construimos de abajo arriba: el modelo sin red, las reglas y los errores, el protocolo, el servidor y el cliente, las solicitudes simultáneas y, al final, todo en un solo programa.

Más precisamente, para curiosos
El modelo es el mismo en Java y en C#. En Java los clientes envían líneas de texto por un socket, y cada cliente está en su propio hilo. En C# el servidor es un servicio web con ASP.NET Core, y el cliente lo llama con HttpClient. En los dos lenguajes la misma idea clave protege las habitaciones: lock.

La idea

La habitación

Una habitación tiene un número, un tipo, un precio por noche y un estado: libre u ocupada.

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 + " € por noche, " + (free ? "libre" : "ocupada");
}
}
  • El número, el tipo y el precio no cambian. Solo cambia el estado, y solo a través de dos métodos con nombres claros: Occupy y Release.
  • La habitación no sabe quién se aloja en ella. La habitación sale hacia cualquier cliente que pregunte, y el nombre del huésped no debe salir del hotel.
Más precisamente, para curiosos
IsFree tiene un set público porque el cliente recibe la habitación como JSON, y System.Text.Json tiene que escribir el estado. El número, el tipo y el precio llegan por el constructor. Sin set, el cliente vería todas las habitaciones libres. Para más de dos estados se usa una enumeración: enum RoomState { Free, Taken, Repair }.

Míralo

El hotel y la reserva

El hotel guarda las habitaciones en una lista y los huéspedes en un diccionario «número de habitación → nombre»:
private final List<Room> rooms = new ArrayList<>();
private final Map<Integer, String> guests = new HashMap<>();
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);
}

Find es privado: busca la habitación por número o lanza una excepción. Así nadie de fuera puede agarrar una habitación y ocuparla saltándose las reglas:

private Room find(int number) {
for (Room room : rooms) {
if (room.getNumber() == number) {
return room;
}
}
throw new RoomNotFoundException("No existe la habitación " + number + ".");
}

La reserva comprueba el nombre, busca la habitación y comprueba si está libre. Solo entonces la ocupa:

public void reserve(int number, String guest) {
if (guest.isBlank()) {
throw new HotelException("El nombre del huésped es obligatorio.");
}
Room room = find(number);
if (!room.isFree()) {
throw new RoomTakenException("La habitación " + number + " ya está ocupada.");
}
room.occupy();
guests.put(number, guest);
}
Hotel hotel = new Hotel();
hotel.add(new Room(101, "individual", 60));
hotel.add(new Room(102, "doble", 85));
hotel.add(new Room(201, "doble", 95));
hotel.add(new Room(202, "suite", 140));
for (Room room : hotel.freeRooms()) {
System.out.println(room);
}
hotel.reserve(102, "Ana Torres");
System.out.println("Después de la reserva: " + hotel.check(102));
System.out.println("Habitaciones libres: " + hotel.freeRooms().size());
Salida
101, individual, 60 € por noche, libre
102, doble, 85 € por noche, libre
201, doble, 95 € por noche, libre
202, suite, 140 € por noche, libre
Después de la reserva: 102, doble, 85 € por noche, ocupada
Habitaciones libres: 3

La idea

La anulación y las reglas

No toda solicitud se puede cumplir: la habitación está ocupada, no existe la habitación 305, un huésped anula la reserva de otro. Son rechazos según las reglas, así que son excepciones propias con un padre común:

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);
}
}

La anulación comprueba que la habitación esté ocupada y que la anule el mismo huésped:

public void cancel(int number, String guest) {
Room room = find(number);
if (room.isFree()) {
throw new HotelException("La habitación " + number + " no está reservada.");
}
if (!guests.get(number).equals(guest)) {
throw new HotelException("La habitación " + number + " está reservada por otro huésped.");
}
room.release();
guests.remove(number);
}

El enunciado del proyecto pide comprobar el sistema en distintos escenarios: reservar, consultar y anular, tanto cuando son válidos como cuando no lo son. Cada escenario es una línea de la salida:

Hotel hotel = new Hotel();
hotel.add(new Room(101, "individual", 60));
hotel.add(new Room(102, "doble", 85));
hotel.add(new Room(201, "doble", 95));
hotel.add(new Room(202, "suite", 140));
hotel.reserve(102, "Ana Torres");
System.out.println("1. Habitación libre: " + hotel.check(102));
try {
hotel.reserve(102, "Bruno Silva");
} catch (RoomTakenException e) {
System.out.println("2. Habitación ocupada: " + e.getMessage());
}
try {
hotel.cancel(102, "Bruno Silva");
} catch (HotelException e) {
System.out.println("3. Reserva ajena: " + e.getMessage());
}
hotel.cancel(102, "Ana Torres");
System.out.println("4. Anulación: " + hotel.check(102));
try {
hotel.cancel(102, "Ana Torres");
} catch (HotelException e) {
System.out.println("5. Segunda anulación: " + e.getMessage());
}
try {
hotel.reserve(305, "Ana Torres");
} catch (RoomNotFoundException e) {
System.out.println("6. No existe esa habitación: " + e.getMessage());
}
try {
hotel.reserve(201, " ");
} catch (HotelException e) {
System.out.println("7. Sin nombre: " + e.getMessage());
}
Salida
1. Habitación libre: 102, doble, 85 € por noche, ocupada
2. Habitación ocupada: La habitación 102 ya está ocupada.
3. Reserva ajena: La habitación 102 está reservada por otro huésped.
4. Anulación: 102, doble, 85 € por noche, libre
5. Segunda anulación: La habitación 102 no está reservada.
6. No existe esa habitación: No existe la habitación 305.
7. Sin nombre: El nombre del huésped es obligatorio.

La excepción concreta dice cuál es el problema. HotelException atrapa todos los rechazos de una vez: el servidor atrapará solo esa y la convertirá en una respuesta para el cliente.

Más precisamente, para curiosos
  • C# no tiene excepciones comprobadas. Por eso el servidor tiene que atraparlas en un solo lugar, o un rechazo según las reglas sale como un 500.
  • Escenarios como estos son tests naturales. En C# hay bibliotecas para esto, como xUnit: cada escenario se convierte en un método, y Assert.Throws<RoomTakenException>(...) comprueba la excepción.

Pruébalo

¿Quién está en la 201?

¿Qué imprimirá el programa?

Hotel hotel = new Hotel();
hotel.Add(new Room(101, "individual", 60));
hotel.Add(new Room(102, "doble", 85));
hotel.Add(new Room(201, "doble", 95));
hotel.Add(new Room(202, "suite", 140));
hotel.Reserve(101, "Ana Torres");
hotel.Reserve(201, "Bruno Silva");
hotel.Cancel(101, "Ana Torres");
try
{
hotel.Reserve(201, "Vera Gil");
Console.WriteLine("Vera está en la 201");
}
catch (RoomTakenException e)
{
Console.WriteLine("Rechazo para Vera");
}
Console.WriteLine(hotel.FreeRooms().Count);
Respuesta

La idea

El protocolo: direcciones y JSON

Cada acción es un método y una dirección, los datos son JSON, y el estado dice cómo ha ido:

Solicitud Cuerpo Respuesta
GET /rooms/free 200 y las habitaciones libres
GET /rooms/102 200 y la habitación, o 404
POST /rooms/102/reservation {"guest":"Ana Torres"} 200 y una confirmación, 409 para una habitación ocupada, 404 para una habitación que no existe
DELETE /rooms/102/reservation?guest=Ana Torres 200 y una confirmación, o 400 con el motivo

Cada rechazo del modelo tiene su propio estado. RoomNotFoundException es 404 Not Found, RoomTakenException es 409 Conflict: la solicitud está bien, pero choca con el estado actual. Cualquier otra HotelException es 400 Bad Request.

El cuerpo de la reserva es una pequeña clase solo para llevar datos:

public class ReservationRequest
{
public string Guest { get; set; }
}
Más precisamente, para curiosos
  • Los comandos podrían ser una enumeración: enum Command { Free, Check, Reserve, Cancel }. Entonces son un tipo aparte con una lista cerrada de valores. En el curso son constantes, porque no hemos visto las enumeraciones.
  • DELETE no tiene cuerpo por convención, así que el nombre está en la dirección. En un sistema real, en lugar de un nombre el cliente inicia sesión con una cuenta, y el servidor sabe quién es. Entonces nadie puede anular la reserva de otro escribiendo el nombre de otro.

Míralo

El servidor

El arranque es como en la lección del primer servidor: el puerto, sin los mensajes de ASP.NET, y JSON que no escapa las letras con tilde ni la ñ:

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:5351");

Los rechazos del modelo se convierten en estados en un solo lugar, en 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);
}
}

Las direcciones llaman al hotel. Reservar y anular son lambdas con cuerpo, porque hacen dos cosas: llaman al modelo y devuelven una confirmación:

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("La habitación " + number + " es tuya, " + request.Guest + ".");
}));
app.MapDelete("/rooms/{number:int}/reservation", (int number, string guest) => Safe(() =>
{
hotel.Cancel(number, guest);
return Results.Ok("La reserva de la habitación " + number + " está anulada.");
}));

Esto es lo que viaja por la red:

using var http = new HttpClient { BaseAddress = new Uri("http://localhost:5351") };
async Task Show(string request, HttpResponseMessage response)
{
Console.WriteLine(request + " -> " + (int)response.StatusCode + " " + await response.Content.ReadAsStringAsync());
}
var anna = new ReservationRequest { Guest = "Ana Torres" };
var boris = new ReservationRequest { Guest = "Bruno Silva" };
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=Bruno Silva", await http.DeleteAsync("/rooms/102/reservation?guest=Bruno Silva"));
await Show("DELETE /rooms/102/reservation?guest=Ana Torres", await http.DeleteAsync("/rooms/102/reservation?guest=Ana Torres"));
await Show("GET /rooms/305", await http.GetAsync("/rooms/305"));
Salida
POST /rooms/102/reservation -> 200 "La habitación 102 es tuya, Ana Torres."
POST /rooms/102/reservation -> 409 "La habitación 102 ya está ocupada."
GET /rooms/102 -> 200 {"number":102,"type":"doble","price":85,"isFree":false}
DELETE /rooms/102/reservation?guest=Bruno Silva -> 400 "La habitación 102 está reservada por otro huésped."
DELETE /rooms/102/reservation?guest=Ana Torres -> 200 "La reserva de la habitación 102 está anulada."
GET /rooms/305 -> 404 "No existe la habitación 305."

Míralo

El cliente

El cliente esconde HTTP detrás de métodos. Una respuesta con error se convierte en la misma excepción que lanzó el servidor: 404, 409 y 400 vuelven como RoomNotFoundException, RoomTakenException y 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:5355");
Console.WriteLine(await client.ReserveAsync(102, "Ana Torres"));
Console.WriteLine(await client.CheckAsync(102));
foreach (Room room in await client.FreeRoomsAsync())
{
Console.WriteLine("Libre: " + room);
}
Console.WriteLine(await client.CancelAsync(102, "Ana Torres"));
try
{
await client.CheckAsync(305);
}
catch (RoomNotFoundException e)
{
Console.WriteLine("Rechazo: " + e.Message);
}
Salida
La habitación 102 es tuya, Ana Torres.
102, doble, 85 € por noche, ocupada
Libre: 101, individual, 60 € por noche, libre
Libre: 201, doble, 95 € por noche, libre
Libre: 202, suite, 140 € por noche, libre
La reserva de la habitación 102 está anulada.
Rechazo: No existe la habitación 305.

La idea

Dos para una habitación

Una reserva son dos pasos: comprobar que la habitación está libre y luego ocuparla. Si dos solicitudes se ejecutan a la vez, puede pasar esto:
Agencia A Agencia B
1 comprueba: la 102 está libre
2 comprueba: la 102 está libre
3 ocupa la 102 para su huésped
4 ocupa la 102 para su huésped y anota a ese huésped

Las dos reciben una confirmación. No hay ningún error en ninguna línea de código por separado. El error es que los pasos de las dos solicitudes se intercalan.

La solución: solo una solicitud a la vez entra en el hotel. Entonces «comprobar y ocupar» es atómico: nadie puede meterse entre la comprobación y la ocupación de la habitación. En C# lo hace un lock en cada método público de 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("El nombre del huésped es obligatorio.");
}
Room room = Find(number);
if (!room.IsFree)
{
throw new RoomTakenException("La habitación " + number + " ya está ocupada.");
}
room.Occupy();
guests[number] = guest;
}
}

public void Reserve(int number, string guest)
{
lock (sync)
{
if (string.IsNullOrWhiteSpace(guest))
{
throw new HotelException("El nombre del huésped es obligatorio.");
}
Room room = Find(number);
if (!room.IsFree)
{
throw new RoomTakenException("La habitación " + number + " ya está ocupada.");
}
room.Occupy();
guests[number] = guest;
}
}
Paso a paso, en palabras
  1. La habitación 102 está libre. Ana Torres y Bruno Silva la reservan en el mismo instante.
  2. Primero sin lock. Este es un orden posible: la solicitud de Ana comprueba y ve «libre».
  3. La solicitud de Bruno comprueba antes de que Ana haya ocupado la habitación: también ve «libre».
  4. Ana ocupa la habitación y anota su nombre.
  5. Bruno también ocupa la habitación y anota su nombre. Los dos tienen una confirmación para la misma habitación.
  6. Desde el principio, con lock (sync): en el hotel entra solo una solicitud a la vez.
  7. Digamos que Ana es la primera: toma el cerrojo. La comprobación y la ocupación son un solo paso, y nadie se mete entre ellas.
  8. Bruno llega al hotel y espera hasta que el cerrojo quede libre.
  9. Bruno tiene el cerrojo. Ahora la comprobación ve «ocupada».
  10. RoomTakenException. El servidor la devuelve como 409 Conflict, y el cliente la vuelve a convertir en RoomTakenException.
  11. El programa de la lección cuenta las que tienen éxito: 1 por habitación. Quién fue primero cambia en cada ejecución.

La prueba: dos clientes reservan la 102 a la vez, y luego veinte agencias reservan la 201 a la vez. Se recogen las respuestas, y el programa imprime cuántas tuvieron éxito:

var client = new HotelClient("http://localhost:5352");
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 = { "Ana Torres", "Bruno Silva" };
Console.WriteLine("Dos para la habitación 102, con éxito: " + await Race(client, 102, two));
string[] agencies = new string[20];
for (int i = 0; i < agencies.Length; i++)
{
agencies[i] = "Agencia " + (i + 1);
}
Console.WriteLine("Veinte para la habitación 201, con éxito: " + await Race(client, 201, agencies));
Console.WriteLine("Libres: " + string.Join("; ", await client.FreeRoomsAsync()));
Salida
Dos para la habitación 102, con éxito: 1
Veinte para la habitación 201, con éxito: 1
Libres: 101, individual, 60 € por noche, libre; 202, suite, 140 € por noche, libre

Task.WhenAll arranca todas las solicitudes a la vez y espera las respuestas. El rechazo es RoomTakenException, y TryReserve lo convierte en false.

Quién se quedó con la habitación cambia en cada ejecución, así que el programa no lo imprime. Cuántas tuvieron éxito es siempre lo mismo: una por habitación.

Más precisamente, para curiosos
  • Sin el bloqueo, el programa casi siempre diría 1 de todos modos: la ventana entre la comprobación y la ocupación de la habitación es pequeña. Pero «casi siempre» significa que una vez entre miles de reservas llegan dos huéspedes a la habitación. Un error así no se atrapa ejecutando el programa, sino pensando: qué datos son compartidos y quién los cambia al mismo tiempo.
  • lock (sync) bloquea todo el hotel. Mientras una solicitud reserva la 102, otra espera incluso para la 201. Con cuatro habitaciones está bien. Con miles se bloquea por habitación, por ejemplo lock (room) { ... }.
  • En un sistema real las habitaciones están en una base de datos, y la protección ocurre allí: una transacción o una condición en la propia consulta (UPDATE rooms SET free = false WHERE number = 102 AND free = true). Entonces se cumple incluso con varios servidores, no solo dentro de un programa.

Pruébalo

Bloqueado, pero no del todo

Un compañero dividió la reserva en dos métodos, los dos con lock. El servidor llama primero a la comprobación y luego a la ocupación. Con muchas solicitudes a la vez, a veces dos personas siguen consiguiendo la misma habitación. ¿En qué línea está el error?

Elige la línea con el error.

Míralo

El hotel completo

Los escenarios de prueba del enunciado, esta vez por la red, con dos clientes conectados a la vez. Al final dos personas reservan la 201 en el mismo instante:

var anna = new HotelClient("http://localhost:5353");
var boris = new HotelClient("http://localhost:5353");
async Task Say(string who, Task<string> request)
{
try
{
Console.WriteLine(who + ": " + await request);
}
catch (HotelException e)
{
Console.WriteLine(who + ": rechazo, " + 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("Ana", anna.ReserveAsync(102, "Ana Torres"));
await Say("Bruno", boris.ReserveAsync(102, "Bruno Silva"));
Console.WriteLine("Bruno: " + await boris.CheckAsync(102));
await Say("Bruno", boris.CancelAsync(102, "Bruno Silva"));
await Say("Ana", anna.CancelAsync(102, "Ana Torres"));
await Say("Bruno", boris.ReserveAsync(102, "Bruno Silva"));
try
{
await anna.CheckAsync(305);
}
catch (RoomNotFoundException e)
{
Console.WriteLine("Ana: rechazo, " + e.Message);
}
string[] two = { "Ana Torres", "Vera Gil" };
Console.WriteLine("Dos para la habitación 201 a la vez, con éxito: " + await Race(anna, 201, two));
Console.WriteLine("Libres: " + string.Join("; ", await anna.FreeRoomsAsync()));
Salida
Ana: La habitación 102 es tuya, Ana Torres.
Bruno: rechazo, La habitación 102 ya está ocupada.
Bruno: 102, doble, 85 € por noche, ocupada
Bruno: rechazo, La habitación 102 está reservada por otro huésped.
Ana: La reserva de la habitación 102 está anulada.
Bruno: La habitación 102 es tuya, Bruno Silva.
Ana: rechazo, No existe la habitación 305.
Dos para la habitación 201 a la vez, con éxito: 1
Libres: 101, individual, 60 € por noche, libre; 202, suite, 140 € por noche, libre
Más precisamente, para curiosos

El programa completo, de principio a fin:

Program.cs
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:5353");
Hotel hotel = new Hotel();
hotel.Add(new Room(101, "individual", 60));
hotel.Add(new Room(102, "doble", 85));
hotel.Add(new Room(201, "doble", 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("La habitación " + number + " es tuya, " + request.Guest + ".");
}));
app.MapDelete("/rooms/{number:int}/reservation", (int number, string guest) => Safe(() =>
{
hotel.Cancel(number, guest);
return Results.Ok("La reserva de la habitación " + number + " está anulada.");
}));
await app.StartAsync();
var anna = new HotelClient("http://localhost:5353");
var boris = new HotelClient("http://localhost:5353");
async Task Say(string who, Task<string> request)
{
try
{
Console.WriteLine(who + ": " + await request);
}
catch (HotelException e)
{
Console.WriteLine(who + ": rechazo, " + 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("Ana", anna.ReserveAsync(102, "Ana Torres"));
await Say("Bruno", boris.ReserveAsync(102, "Bruno Silva"));
Console.WriteLine("Bruno: " + await boris.CheckAsync(102));
await Say("Bruno", boris.CancelAsync(102, "Bruno Silva"));
await Say("Ana", anna.CancelAsync(102, "Ana Torres"));
await Say("Bruno", boris.ReserveAsync(102, "Bruno Silva"));
try
{
await anna.CheckAsync(305);
}
catch (RoomNotFoundException e)
{
Console.WriteLine("Ana: rechazo, " + e.Message);
}
string[] two = { "Ana Torres", "Vera Gil" };
Console.WriteLine("Dos para la habitación 201 a la vez, con éxito: " + await Race(anna, 201, two));
Console.WriteLine("Libres: " + 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 + " € por noche, " + (IsFree ? "libre" : "ocupada");
}
}
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("El nombre del huésped es obligatorio.");
}
Room room = Find(number);
if (!room.IsFree)
{
throw new RoomTakenException("La habitación " + number + " ya está ocupada.");
}
room.Occupy();
guests[number] = guest;
}
}
public void Cancel(int number, string guest)
{
lock (sync)
{
Room room = Find(number);
if (room.IsFree)
{
throw new HotelException("La habitación " + number + " no está reservada.");
}
if (guests[number] != guest)
{
throw new HotelException("La habitación " + number + " está reservada por otro huésped.");
}
room.Release();
guests.Remove(number);
}
}
private Room Find(int number)
{
Room room = rooms.FirstOrDefault(r => r.Number == number);
if (room == null)
{
throw new RoomNotFoundException("No existe la habitación " + 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);
}
}

Ejercicio

Sigue por tu cuenta

Trabaja con el programa completo de la tarjeta anterior.

1. Cambio de habitación. El huésped se muda de una habitación a otra con POST /rooms/102/move y el cuerpo {"guest":"Ana Torres","to":202}. La respuesta es «Ana Torres está en la habitación 202.», y el cliente tiene un método MoveAsync. Si la habitación nueva está ocupada, el huésped se queda en la antigua. El cambio es un solo método en Hotel, dentro de un lock: nadie debe ocupar la habitación nueva entre la liberación de la antigua y la ocupación de la nueva.

Salida
La habitación 102 es tuya, Ana Torres.
La habitación 201 es tuya, Bruno Silva.
Rechazo: La habitación 201 ya está ocupada.
Ana Torres está en la habitación 202.
Libres: 101, individual, 60 € por noche, libre; 102, doble, 85 € por noche, libre

Más ideas

  • Noches y total. Una reserva es para un número de noches, y la confirmación dice el total: «3 noches, 255 €».
  • Fechas. Una habitación se reserva por días, por ejemplo del 14 al 16 de diciembre. Dos reservas de la misma habitación no deben solaparse. Si la habitación está libre depende de las fechas.
  • Mis reservas. GET /reservations?guest=Ana Torres dice qué habitaciones son de este huésped.
  • Lista de espera. Si la habitación está ocupada, el huésped se apunta para esperar. Cuando se anula, pasa al primero de la lista.
  • Un servidor que no se detiene. Pasa el servidor a su propio programa con await app.RunAsync(), y el cliente a otro, ejecutado en otra terminal.

Test

Ponte a prueba

1. Dos agencias reservan la habitación 102 en el mismo instante, y la reserva no está sincronizada. ¿Qué puede pasar?
2. La comprobación «¿está libre?» y la ocupación de la habitación están en dos métodos sincronizados distintos. ¿Basta con eso?
3. La habitación 102 está ocupada. ¿Con qué estado responde POST /rooms/102/reservation?
4. ¿Por qué el programa imprime cuántas reservas tuvieron éxito y no quién se quedó con la habitación?
5. ¿Por qué los nombres de los huéspedes están en un diccionario del hotel y no en la habitación?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·