Cześć wszystkim! Dziś przyjrzymy się jednemu z bardziej zaawansowanych aspektów programowania w Springu – dynamicznej podmianie implementacji. To zagadnienie jest kluczowe, gdy nasza aplikacja rośnie wraz z rozwojem i zaczynamy potrzebować bardziej elastycznego podejścia do zarządzania kodem.

Cześć! Dzisiaj chciałbym się skupić na tym, żebyś zobaczył w jak łatwy sposób, można w Springu, wymieniać jedną implementację na drugą. Do tego oczywiście będziemy potrzebowali prostej aplikacji. W niej będą już na nas czekały klasy takie jak ServiceA, ServiceB oraz ServiceC przygotowane w poprzednim przykładzie.

Sytuacja początkowa

Załóżmy, że na start ServiceC ma jedną zależność do ServiceA. Jego jedyna metoda print będzie zwracała to, co zwróci ServiceA. Plus tam dodanie prefiksu result = .

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class ServiceC {

  private final ServiceA serviceA;

  public ServiceC(ServiceA serviceA) {
    this.serviceA = serviceA;
  }

  public String print() {
    return "result = " + serviceA.returnSomething();
  }

}

Oczywiście, aby sprawdzić, czy wszystko działa, musimy sobie napisać odpowiedni test. Tutaj znowu oprzemy się o test integracyjny. Wstrzykniemy sobie ServiceC i napiszemy test. Oczywiście te testy są pisane tylko po to, żeby je pisać, żebyśmy mieli weryfikację tego, czy nasz program działa. Natomiast jeśli chodzi o dobre praktyki, to być może skupimy się na nich w następnych wpisach. Dobrze, uruchommy nasz test i sprawdźmy, czy przejdzie. Pamiętamy, że ServiceA zwraca literkę A.

1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootTest
class ServiceCIntTest {

  @Autowired
  ServiceC serviceC;

  @Test
  void test() {
    assertEquals("result = A", serviceC.print());
  }

}

Zmiana wykorzystywanego serwisu

Teraz załóżmy, że ServiceC powinien korzystać z ServiceB. Okazuje się, że ma on podobną implementację co ServiceA. Tylko aby dokonać podmiany, musimy wykonać dużą ilość pracy. Gdyby było więcej metod, z których byśmy korzystali, to ilość pracy byłaby nieporównywalna.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class ServiceC {

  private final ServiceB serviceB;

  public ServiceC(ServiceB serviceB) {
    this.serviceB = serviceB;
  }

  public String print() {
    return "result = " + serviceB.returnSomething();
  }

}

Po tej zmianie test nie przechodzi. Tego się spodziewaliśmy. Jeśli zmienimy wartość w asercji na literkę B, to test już przejdzie.

1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootTest
class ServiceCIntTest {

  @Autowired
  ServiceC serviceC;

  @Test
  void test() {
    assertEquals("result = B", serviceC.print());
  }

}

Poprawienie naszej sytuacji

Zastanówmy się teraz, w jaki sposób możemy robić takie podmiany bez ingerowania w implementację klasy ServiceC. Jak widzimy ServiceA oraz ServiceB mają identyczną sygnaturę metody. Możemy w takim razie wyciągnąć ten wniosek przed nawias i utworzyć interfejs o nazwie ReturningSomething.

1
2
3
public interface ReturningSomething {
  String returnSomething();
}

Teraz wystarczy, że obydwa serwisy, ServiceA oraz ServiceB, go zaimplementują. Dodatkowo w serwisie zrobimy podmianę, aby implementacja zależała od tej nowej abstrakcji.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Component
public class ServiceA implements ReturningSomething {

  @Override
  public String returnSomething() {
    return "A";
  }

}

@Component
public class ServiceB implements ReturningSomething {

  @Override
  public String returnSomething() {
    return "B";
  }

}

@Component
public class ServiceC {

  private final ReturningSomething returningSomething;

  public ServiceC(ReturningSomething returningSomething) {
    this.returningSomething = returningSomething;
  }

  public String print() {
    return "result = " + returningSomething.returnSomething();
  }

}

Problem z brakiem dopasowania

W tym momencie Spring nie wie, co dokładnie wstrzyknąć w miejsce pola ReturningSomething znajdującego się w ServiceC. Musimy mu w tym pomóc. Jednym ze sposobów, ale niekoniecznie najlepszym jest skorzystanie z adnotacji @Primary. Są też inne sposoby, ale na ten moment to nie jest istotne. Nie chodziło mi w tym momencie o przekazanie wszystkich dobrych praktyk, a jedynie o rozwiązanie problemu stabilnego kodu.

1
2
3
4
5
6
7
8
9
10
@Primary
@Component
public class ServiceA implements ReturningSomething {

  @Override
  public String returnSomething() {
    return "A";
  }

}

W ten sposób Spring wie, który z beanów ma pierwszeństwo. W sytuacjach konfliktowych to ServiceA będzie wykorzystany. Możemy to sprawdzić przez uruchomienie przygotowanego testu. Jeśli w asercji będzie assertEquals("result = A", serviceC.print()); to test przejdzie.

Podsumowanie

W tym momencie wystarczyłoby usunąć @Primary z ServiceA i przenieść go do ServiceB, aby to ten drugi miał pierwszeństwo. Kod w ServiceC nie musiał być w ogóle modyfikowany.

Jeśli mamy jakieś zachowanie, które może być implementowane przez dwie klasy, w różny sposób, to jest to dobry pomysł, aby przygotować taką dodatkową abstrakcję. Uzyskujemy wtedy możliwość łatwiej podmiany logiki w myśl wzorca strategii.

To by było na tyle dzisiaj, dzięki za Twój czas i do zobaczenia w następnym wpisie. Na razie i cześć!