Człowiek uczy się całe życie. Jest to szczególnie ważne w roli programisty / architekta / modelarza. Pewnie mógłbym wymienić jeszcze sporo ról mniej lub bardziej związanych z IT, jednak nie to jest najistotniejsze. Chodzi mi o to, że ważnym elementem pracy jest powiększanie naszego toolbox-a o nowe techniki / praktyki tak, abyśmy mogli wybierać z większej przestrzeni rozwiązań.
W tym artykule chciałbym wam przedstawić rozwiązanie dość generycznego problemu, na które natrafiliśmy wraz z Arturem (serdecznie pozdrawiam jeżeli czytasz ten artykuł) podczas warsztatów Domain-Driven Design z elementami funkcyjnymi
prowadzonych przez Jakuba Pilimon-a (Bardzo dobre warsztaty! Polecam każdemu).
Problem
Podczas warsztatów zostaliśmy postawieni przed problemem rezerwacji zasobów w czasie. Myślę, że na poniższym obrazku udało mi się go dość ładnie naszkicować.
Opisując słowno-muzycznie: mamy sytuację, w której dwóch użytkowników naszego systemu chce zarezerwować zasób na jakiś czas. Jest to bardzo częsta sytuacja w przypadku takich domen jak hotele, salki konferencyjne i wiele, wiele innych. To co nasze oprogramowanie musi zrobić to zapewnić, że nie będziemy mieli kolizji rezerwacji (chyba, że akceptujmy takie sytuacje, lecz powinny być omówione z biznesem).
Problem wydaje się dość skomplikowany, jednak istnieje proste rozwiązanie. Zanim je wam przedstawię chciałbym w tym miejscu dodać pewien disclaimer:
To rozwiązanie świetnie sprawdza się na aplikacjach mniejszej skali. Tak więc zanim go użyjesz, koniecznie sprawdź jego wydajność na Twojej infrastrukturze i wolumenie danych. Osobiście testowałem je na tabeli z milionami rekordów (
X_000_000
) i wydajność wydawała się w porządku. Co oznacza w porządku? to już pozostawiam Tobie do odpowiedzenia.
PostgreSql na Pomoc
Nie zwlekając dłużej PostgreSql zaskoczył mnie kilkoma rzeczami. Pierwszą z nich jest obsługa przedziałów czasowych jako typu danych wbudowanego do bazy danych:
Na chwilę obecną PostgreSql wspiera takie przedziały czasowe jak:
tsrange
- zakres czasu pomiędzy dwoma timestampami bez strefy czasowejtstzrange
- zakres czasu pomiędzy dwoma timestampami z strefą czasowądaterange
- zakres czasu pomiędzy dwoma datami
Dodatkowo PostgreSql wspiera constraint-y typu Exclude
, które wraz z indexem typu GiST pozwalają na zabezpieczenie się przed nakładającymi się na siebie zakresami wartości.
Wystarczy zrobić tabelę jak:
CREATE TABLE example_table(
id SERIAL PRIMARY KEY,
date_range daterange,
EXCLUDE USING GIST (date_range WITH &&)
);
To właśnie dzięki operatorowi WITH &&
baza będzie wiedziała, aby sprawdzić czy zakresy nie nachodzą na siebie.
Teraz, jeżeli byśmy spróbowali dodać dwa rekordy, które nachodzą na siebie:
INSERT INTO example_table (date_range) VALUES ('[2024-03-01, 2024-03-10)');
INSERT INTO example_table (date_range) VALUES ('[2024-03-05, 2024-03-15)');
To otrzymamy pięknie brzmiący błąd:
[2024-03-14 20:07:08] [23P01] ERROR: conflicting key value violates exclusion constraint "no_overlapping_ranges"
[2024-03-14 20:07:08] Detail: Key (date_range)=([2024-03-05,2024-03-15)) conflicts with existing key (date_range)=([2024-03-01,2024-03-10)).
Na nasze nieszczęście, przypadek, w którym będziemy sprawdzali sam zakres dat to rzadkość. Dużo częściej będziemy musieli zbadać, czy zasób o podanym identyfikatorze nie ma nachodzących na siebie okresów (np. numer pokoju, numer identyfikacyjny danego urządzenia czy nawet pracownika).
Obsłużenie takiego przypadku wymaga dodania rozszerzenia do instancji PostgreSql, pozwalające na tworzenie bardziej skomplikowanych reguł wykluczania. Nazywają się one btree_gist. Możemy je załadować za pomocą następującej komendy
CREATE EXTENSION btree_gist;
W dalszej części przyjmijmy, że chcemy obsłużyć rezerwację w hotelu, dlatego stwórzmy następującą tabele
CREATE TABLE reservation (
room text,
during tsrange,
EXCLUDE USING GIST (room WITH =, during WITH &&)
);
Jak widać reguła została rozbudowana o room WITH =
, co spowoduje, że nasze wykluczenie będzie działać najpierw na identyfikatorze pokoju, a dopiero w następnej kolejności sprawdzi, czy daty nie nachodzą na siebie.
Sam kodzik możemy przetestować próbując dodać dwa konfliktujące się ze sobą rekordy:
INSERT INTO reservation VALUES
('123A', '[2010-01-01 14:00, 2010-01-01 15:00)');
INSERT INTO reservation VALUES
('123A', '[2010-01-01 14:30, 2010-01-01 15:30)');
Przy próbie procesowania drugiego insert-a. Baza powinna wyrzucić ładny błąd:
[2024-03-17 14:44:11] [23P01] ERROR: conflicting key value violates exclusion constraint "reservation_room_during_excl"
[2024-03-17 14:44:11] Detail: Key (room, during)=(123A, ["2010-01-01 14:30:00","2010-01-01 15:30:00")) conflicts with existing key (room, during)=(123A, ["2010-01-01 14:00:00","2010-01-01 15:00:00")).
Podsumowanie
I tak oto zbudowaliśmy system rezerwacji, a w zasadzie PoC takiego systemu, w 5 minut. Oczywiście to tylko część bazodanowana, która może "nadać się" do waszego case-a lub nie. Na pewno trzeba byłoby to jeszcze owrapować w logikę, która siedziałaby po stronie backend-u. Nie mniej, jak na 2 linijki kodu, jest to rozwiązanie, które warto mieć w swojej skrzynce z narzędziami 🙂
Do Następnego! 👋
Cześć! 🙂