Czym jest transakcja w programowaniu?

Rok 2018 zaczniemy kilkoma materiałami o transakcjach. Zaczniemy od zdefiniowania czym one w ogóle są i standardowego przykładu z bankomatem. Potem przejdziemy do powiązania transakcji z JPA i zajmiemy się tematem adnotacji z transakcjami powiązanymi – @Transactional, @RequiredNew czy @NotSupported, zobaczymy jak na transakcje wpływają wyjątki. Zanim jednak przejdziemy do bardziej zaawansowanych tematów wypadałoby sobie wyjaśnić czym generalnie są transakcje. 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 jeś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śc 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 protfel. 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 – czyli 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 odpowiadajacy 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) { //4
            System.out.println(e.getMessage()); 
            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 procedure 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 zaczeliśmy cokolwiek zmieniać.

I to tyle – właśnie zapoznałaś, bądź zapoznałeś się z transkacjami 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

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *