Spring Boot & JUnit – Testy wielu instancji aplikacji

Spring Boot & JUnit – Testy wielu instancji aplikacji

Do napisania dzisiejszego wpisu zainspirowała mnie chęć odpowiedzi na pytanie “w jaki sposób możemy zasymulować testy end2end aplikacji Spring Boot w konfiguracji wieloinstancyjnej active-active”. Dlaczego jest to dla mnie takie ważne? Ponieważ już na etapie testów aplikacji chciałbym wiedzieć, czy pewne mechanizmy synchronizacyjne działają tak, jak założyłem.

Dla lepszego zobrazowania problemu, o którym wspomniałem, wyobraźmy sobie kolejkę maili do wysłania. Załóżmy, że na każdej instancji aplikacji, co 30 sekund wzbudza się proces wysyłający zaległe maile. Idealnie byłoby, gdybyśmy byli w stanie, już na etapie testów, upewnić się, że nawet w przypadku uruchomienia wielu instancji aplikacji, a co za tym idzie wielu procesów w tym samym czasie, wyślemy dokładnie tyle maili ile było w kolejce – nie więcej!

Aplikacja Testowa

Na potrzeby tego wpisu uprościłem nieco wspominamy przykład z wysyłaniem maili. Poniższa, aplikacja posiada komponent CounterService, którego metoda incrementAndGet odpowiedzialna jest za odczyt wartości z tabeli bazy danych, zwiększenie jej o jeden, a następnie zapis do tabeli. Załóżmy, że chcemy przetestować, czy wspomniana metoda również poprawnie zachowa się w środowisku wieloinstancyjnym wielowątkowym.

package com.bettercoding.lab;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EntityManager;
import javax.persistence.Id;
import java.util.Optional;

@Repository
interface CounterRepository extends CrudRepository<MultinodeIncrementerApp.Counter, String> {

}

@SpringBootApplication
public class MultinodeIncrementerApp {
    public static void main(String[] args) {
        SpringApplication.run(MultinodeIncrementerApp.class, args);
    }

    @Configuration
    @PropertySource("application.yml")
    @EnableTransactionManagement
    public static class DBConfig {
    }

    @Entity(name = "Counter")
    public static class Counter {
        @Id

        private String name;
        @Column(nullable = false)
        private Integer count;

        public Counter() {
        }

        public Counter(String name, Integer count) {
            this.name = name;
            this.count = count;
        }

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }

        public Integer getCount() {
            return count;
        }

        public void setCount(Integer count) {
            this.count = count;
        }
    }

    @Component
    public static class CounterService {
        private final CounterRepository counterRepository;
        private final EntityManager entityManager;

        @Autowired
        public CounterService(CounterRepository counterRepository, EntityManager entityManager) {
            this.counterRepository = counterRepository;
            this.entityManager = entityManager;
        }

        public Integer getValue(String counterName) {
            Optional<Counter> optCounter = counterRepository.findById(counterName);
            return optCounter.get().getCount();
        }

        @Transactional
        public Integer incrementAndGet(String counterName) {
            System.out.println("-> start");
            Optional<Counter> optCounter = Optional.ofNullable(entityManager.find(Counter.class, counterName));
            System.out.println("-> after read");
            Counter counter;
            if (optCounter.isPresent()) {
                counter = optCounter.get();
                counter.setCount(counter.getCount() + 1);
            } else {
                counter = new Counter(counterName, Integer.valueOf(1));
            }

            entityManager.persist(counter);
            entityManager.flush();
            System.out.println("-> end");
            return counter.getCount();
        }
    }
}

Jak sam się przekonasz już tak prosta aplikacja staje się problematyczna w środowisku wielowątkowym. Czy domyślasz się już gdzie może pojawić się problem?

Wyobraźmy sobie taką oto sytuację. Dwa wątki startują w tym samym czasie. Pierwszy z nich rozpoczyna przetwarzanie metody incrementAndGet, a co za tym idzie odczytuje wartość licznika z bazy danych (załóżmy że na tę chwile ma wartość 0). W tym momencie drugi wątek również rozpoczyna przetwarzanie wspomnianej metody i również odczytuje wartość 0. Następnie pierwszy wątek dodaje 1 i zapisuje stan licznika do bazy danych. To samo robi również drugi wątek. Tym sposobem zamiast spodziewanego stanu licznika, który wynosi 2 zostanie zapisana wartość 1. Myślę, że poniższy schemat dość dobrze obrazuje opisaną sytuację.

Zwróć proszę uwagę na fakt, że w naszym przykładzie zwykła synchronizacja wątków nie pomoże, ponieważ mamy do czynienia z wątkami pochodzącymi z różnych aplikacji, a co za tym idzie z różnych maszyn wirtualnych. Ba! W prawdziwych konfiguracjach produkcyjnych aplikacje uruchamiane są w oddzielnych kontenerach lub na oddzielnych serwerach.

Przykład testu wielu instancji aplikacji Spring Boot

Poniższy listing prezentuje przykład testu JUnit, który uruchamia dwa osobne konteksty Spring’a oraz symuluje wieloinstancyjne środowisko wielowątkowe.

Niestety ma on pewne ograniczenia, o których należy wiedzieć i pamiętać. Najważniejszym z nich jest świadomość faktu, że oba konteksty wstają na tej samej maszynie wirtualnej Java, czego konsekwencją jest to, że zmienne statyczne będą współdzieliły stan. Dodatkową sprawą, która może przysporzyć problemu jest kwestia uruchamiania dwóch aplikacji na tej samej maszynie, co znów skutkuje chociażby kolizją domyślnych portów.

Mimo opisanych wcześniej wad, korzyści z przeprowadzenia symulacji zachowania aplikacji w środowisku wieloinstancyjnym być może spowodują, że mimo wszystko zechcesz dodać takie testy do własnego portfolio.

package com.bettercoding.lab;

import org.junit.jupiter.api.Test;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ConfigurableApplicationContext;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

import static org.junit.jupiter.api.Assertions.assertEquals;


@SpringBootTest
class MultinodeIncrementerAppTest {

    @Test
    void multinodeCounterE2ETest() {
        SpringApplicationBuilder app1 = new SpringApplicationBuilder(MultinodeIncrementerApp.class).properties("server.port=8081", "spring.jmx.default-domain: domain1", "javax.persistence.query.timeout=100000");
        SpringApplicationBuilder app2 = new SpringApplicationBuilder(MultinodeIncrementerApp.class).properties("server.port=8082", "spring.jmx.default-domain: domain2", "javax.persistence.query.timeout=100000");

        ConfigurableApplicationContext ctx1 = app1.run();
        ConfigurableApplicationContext ctx2 = app2.run();

        MultinodeIncrementerApp.CounterService counterService1 = ctx1.getBean(MultinodeIncrementerApp.CounterService.class);
        MultinodeIncrementerApp.CounterService counterService2 = ctx2.getBean(MultinodeIncrementerApp.CounterService.class);

        final String counterName = "sample-counter";

        ExecutorService executorService = Executors.newFixedThreadPool(10);
        List<Callable<String>> callables = new ArrayList<>();

        // when/then:

        final int maxTasks = 10;
        for (int i = 0; i < maxTasks; i++) {

            if (i % 2 == 0) {
                callables.add(() -> counterService1.incrementAndGet(counterName).toString());
            } else {
                callables.add(() -> counterService2.incrementAndGet(counterName).toString());
            }
        }

        assertEquals(1, counterService1.incrementAndGet(counterName).intValue());

        List<Future<String>> futures;
        try {
            futures = executorService.invokeAll(callables);
        } catch (InterruptedException e) {
            throw new RuntimeException("Thread interrupted", e);
        }

        for (Future future : futures) {
            try {
                Object x = future.get();
            } catch (InterruptedException e) {
                throw new RuntimeException("Thread interrupted", e);
            } catch (ExecutionException e) {
                throw new RuntimeException("Execution exception occurred", e);
            }
        }
        assertEquals(12, counterService1.incrementAndGet(counterName).intValue());
        assertEquals(13, counterService2.incrementAndGet(counterName).intValue());

    }
}

Uruchomienie testu

Zgodnie z tym co przewidywaliśmy test zakończył się niepowodzeniem. Jak widać na poniższy zrzucie ekranu, spodziewaliśmy się wartości 12 jako zapisany stan licznika, natomiast faktycznie odłożyła się wartość 3.

Poprawiona wersja kodu

W tym przypadku poprawka jest banalnie prosta, a mianowicie wystarczy założyć blokadę LockModeType.PESSIMISTIC_WRITE na przetwarzanym obiekcie. Poniżej zamieszczam poprawioną wersję kodu oraz wynik testu.

...
        @Transactional
        public Integer incrementAndGet(String counterName) {
            System.out.println("-> start");
            Optional<Counter> optCounter = Optional.ofNullable(entityManager.find(Counter.class, counterName, LockModeType.PESSIMISTIC_WRITE));
            System.out.println("-> after read");
            Counter counter;
            if (optCounter.isPresent()) {
                counter = optCounter.get();
                counter.setCount(counter.getCount() + 1);
            } else {
                counter = new Counter(counterName, Integer.valueOf(1));
            }

            entityManager.persist(counter);
            entityManager.flush();
            System.out.println("-> end");
            return counter.getCount();
        }
...
Na zakończenie mam jeszcze jedną prośbę.

Jeśli pomogłem Ci rozwiązać Twój problem, to udostępnij proszę ten post. Dzięki temu będę miał okazję trafić do szerszej grupy odbirców. Dziękuję

Leave a Reply

avatar
  Subscribe  
Notify of
Close Menu