Zaczniemy od zdefiniowania czym są transakcje i standardowego przykładu z bankomatem. Najprościej rzecz ujmując transakcja to zestaw kroków danego algorytmu, który musi być wykonany w całości lub, jeśli wystąpi błąd w którymkolwiek z kroków, każda operacja musi zostać „cofnięta”, a stan systemu zostanie przywrócony do momentu sprzed rozpoczęcia pierwszego kroku w transakcji. Zacznijmy od oklepanego przykładu konta bankowego i wypłaty środków z bankomatu.
I tak j,eśli dwa kroki z tego algorytmu – zmiana stanu konta i wypłata gotówki – nie byłyby „owinięte” w transakcje to mogłoby dojść do dość nieprzyjemnej sytuacji, jeśli w bankomacie nie byłoby gotówki – stan konta zostałby zmniejszony, natomiast nie otrzymalibyśmy gotówki z bankomatu.
Jeśli użyjemy transakcji i wywołamy w niej te dwa kroki to, gdy nie będziemy mieli środków na koncie, bądź w bankomacie zabraknie gotówki – transakcja zostanie oznaczona jako 'failed’ i stan naszego konta wróci do momentu kiedy to deklarowaliśmy, że chcemy wypłacić gotówkę – czyli nic z naszego konta nie ubędzie.
Zaimplementujmy ten prosty przykład.
Utwórzmy w IntelliJ (czy jakimkolwiek innym IDE) nowy projekt javowy, wspierany przez maven’a.
Zacznijmy od utworzenia klasy dla konta bankowego
package domain; public class Account { private int balance; public Account(int balance) { this.balance = balance; } public void withdraw(int amount) { this.balance-=amount; } public void deposit(int amount) { this.balance+=amount; } @Override public String toString() { return "Aktualny stan konta: " + this.balance; } }
oraz dla bankomatu
package domain; public class CashMachine { private int cashAmount; public CashMachine(int cashAmount) { this.cashAmount = cashAmount; } public void withdraw(int amount) { if(this.cashAmount-amount<0) { throw new IllegalStateException("Brak dostatecznej ilosci gotówki."); } this.cashAmount-=amount; } @Override public String toString() { return "Pozostała gotówka: " + this.cashAmount; } }
Następnie w klasie głównej programu odegrajmy przypadek pozytywny
import domain.Account; import domain.CashMachine; public class App { public static void main(String[] args) { Account account = new Account(100); CashMachine machine = new CashMachine(50); System.out.println(account + System.lineSeparator() + machine); account.withdraw(20); machine.withdraw(20); System.out.println(account + System.lineSeparator() + machine); } }
Wynik operacji jest dokładnie taki jaki byśmy chcieli
Aktualny stan konta: 100 Pozostała gotówka: 50 Aktualny stan konta: 80 Pozostała gotówka: 30
Czyli zmniejszył nam się stan konta, w bankomacie zostało mniej gotówki, a my mamy grubszy portfel. Co natomiast jeśli podamy zbyt dużą ilość gotówki do wypłaty?
import domain.Account; import domain.CashMachine; public class App { public static void main(String[] args) { Account account = new Account(100); CashMachine machine = new CashMachine(50); System.out.println(account + System.lineSeparator() + machine); try { account.withdraw(60); machine.withdraw(60); } catch (Exception e) { System.out.println(e.getMessage()); } System.out.println(account + System.lineSeparator() + machine); } }
Efekt działania tego przypadku jest taki, że zmienia nam się bilans konta, jednak gotówki nie otrzymujemy, zostanie ona w bankomacie.
Aktualny stan konta: 100 Pozostała gotówka: 50 Brak dostatecznej ilosci gotówki. Aktualny stan konta: 40 Pozostała gotówka: 50
Jak już wspomniałem mogą nam tu pomóc transakcje. Chcemy, by w wypadku niepowodzenia tej operacji stan systemu wrócił do stanu przed wykonaniem operacji.
W tym wypadku stan naszego systemu jest dość banalny – są to dwa obiekty. Jeden odpowiadający za konto, drugi za bankomat. Wystarczy więc, że zapamiętamy ich stan przed wykonaniem tych metod (czyli ile pieniędzy jest gdzie) i przywrócimy go w razie wystąpienia wyjątku. Rozbudujmy klasy Account i CashMachine o takie zwracające kopie danego obiektu oraz odtwarzają stan obiektu na podstawie tejże kopii.
package domain; public class Account { //... public Account copy() { return new Account(this.balance); } public void recreate(Account account) { this.balance = account.balance; } }
I podobnie dla CashMachine
package domain; public class CashMachine { //... public CashMachine copy() { return new CashMachine(this.cashAmount); } public void recreate(CashMachine machine) { this.cashAmount = machine.cashAmount; } }
Zaimplementujmy teraz proces wypłaty z bankomatu tak by był transakcyjny, czyli wykonał się w całości bądź wcale.
import domain.Account; import domain.CashMachine; public class App { public static void main(String[] args) { Account account = new Account(100); CashMachine machine = new CashMachine(50); System.out.println(account + System.lineSeparator() + machine); transactionalWithdrawal(account, machine, 60); System.out.println(account + System.lineSeparator() + machine); } private static void transactionalWithdrawal(Account account, CashMachine machine, int amount) { Account accountBackup = account.copy(); //1 CashMachine machineBackup = machine.copy(); //2 try { //3 account.withdraw(amount); machine.withdraw(amount); } catch (Exception e) { System.out.println(e.getMessage()); //4 System.out.println("Coś poszło nie tak. Przywracam stan systemu"); account.recreate(accountBackup); machine.recreate(machineBackup); } } }
Dla większej przejrzystości kodu przeniosłem te procedurę do osobnej metody.
W pierwszych dwóch krokach (1,2) tworzymy kopie obiektów, na których będziemy działać tak by gdyby coś poszło nie tak mieć stan systemu sprzed rozpoczęcia wykonywania operacji transakcyjnej.
W cześci 'try’ (3) bloku 'try – catch’ wykonujemy operacje, które chcemy, by spięte były transakcją – w naszym wypadku zmiana stanu konta i wypłata gotówki z bankomatu. Jeśli wszystko pójdzie dobrze i nie zostanie rzucony żaden wyjątek wykonywanie metody 'transactionalWithdrawal’ tutaj się zakończy.
Natomiast jeśli pojawi się jakikolwiek wyjątek (zauważ, że łapiemy każdy 'Exception’, a nie tylko IllegalStateException rzucany przez klase CashMachine – więc gdyby oprogramować dodatkowe problemy np. brak środków już na koncie bankowym to również zostaną one przechwycone) to odpalony zostanie blok catch (4) i przywrócimy stan naszego konta bankowego i bankomatu z naszych 'backupów’ wykonanych zanim zaczęliśmy cokolwiek zmieniać.
I to tyle jeśli chodzi o transakcje w czystej postaci. Bez żadnych baz danych, JPA, JTA, Springa czy innych skomplikowanych rzeczy, o których w kolejnych wpisach.
Kod przykładu znajduje się na Githubie
hej 🙂 szczerze nie podoba mi się ten kod na stronie strasznie chaotycznie to wygląda może jakieś formatowanie tego tekstu się przyda??
Cześć! Dzięki za zwrócenie uwagi. Formatowanie postu nie przetrwało zmian w wersjach wordpresa/pluginu i jak zauważyłeś – rozjechało się zupełnie. Już poprawione. Dzięki jeszcze raz!