Przygotowując materiały na spotkanie z bydgoskim JUGiem postanowiłem zaktualizować kod powiązany z prezentacją. Zamiast szybkiego podbicia wersji Springa skończyło się głębszym refaktorem, ale dziś nie o tym. Pod koniec trafiłem na pewien bardzo ciekawy problem, który warto opisać. Jest to tzw. rekrutacyjny morderca niewinnych ekspertów. Pozwala na sprawdzenie, czy delikwent nie tylko zna pojęcia, ale też rozumie jak one naprawdę działają. Lecimy.

Service Provider Interface

SPI to w dużym skrócie mechanizm, na którym opiera się większość javowych specyfikacji. Pozwala on na uzyskanie implementacji interfejsu „w locie”. Wystarczy, by klasa implementująca dany interfejs znajdowała się na CLASSPATHie. Wystarczy wtedy wywołać metodę ServiceLoader.load(Interfejsik.class) by otrzymać instancję ServiceLoader<>. Klasa ta implementuje interfejs Iterable, więc możemy sobie pogrzebać w błotku i wybrać interesującą nas implementację.

Wystarczy zatem, żeby specyfikacja opisywała jedynie podstawowe struktury oraz interfejsy operujące na nich, oraz zasady implementacji (wymagania funkcjonalne). Do tego doda jakiś własny kod opakowujący generyczny mechanizm SPI, tak by ładować konkretne interfejsy, oraz rzucać konkretnymi bluzgami w razie czego i gotowe.

Następnie dostawcy muszą już tylko odpowiednio przygotować swój kod. W praktyce oznacza to, że w pliku jar musi znaleźć się katalog META-INF/services/, a w nim plik, którego nazwa odpowiada pełnej kwalifikowanej nazwie interfejsu. W pliku umieszczamy pełne kwalifikowane nazwy klas, które dany interfejs implementują i gotowe. Na przykład:

Listing 1. Położenie katalogu META-INF w pliku jar

koziolek@koziolek-laptop4 ~/.m2/repository/org/hibernate/hibernate-core/5.6.11.Final $ unzip -l hibernate-core-5.6.11.Final.jar | grep META
        0  2022-08-30 14:56   META-INF/
    92266  2022-08-30 14:56   META-INF/MANIFEST.MF
        0  2022-08-30 14:56   META-INF/services/
      276  2022-08-30 14:56   META-INF/services/javax.persistence.spi.PersistenceProvider

W ten sposób można zaimplementować dowolną speckę, stworzyć system pluginów, czy nawet pokusić się o jakąś implementację DI. I to wszystko będzie działać do czasu…

Ogólny zarys problemu

Mamy sobie taką oto aplikację:

Listing 2. Szkic komponentów naszej aplikacji

     ┌────────────┐                 ┌─────────────┐
     │            │    Implementuje │             │
     │   API      │◄────────────────┤    IMPL     │
     │            │                 │             │
     └─────┬──────┘                 └──────┬──────┘
           │                               │
           │                               │
      Kompilacja                    Wykonanie (SPI)
           │                               │
           │    ┌────────────┐             │
           │    │            │             │
           └────►   CLIENT   ◄─ ─ ─ ─ ─ ─ ─┘
                │            │
                └────────────┘

Jest sobie jakiś CLIENT, który wykorzystuje pewno API. W czasie kompilacji ma je dostępne w CLASSPATH. W czasie wykonania będzie jednak potrzebować implementacji IMPL, która jest ładowana za pomocą SPI. W sumie nic niezwykłego. Tak działa JPA, R2DBC, JTA i wiele innych mechanizmów i API dostępnych w Javie. Można zaryzykować stwierdzenie, że tak działa jakieś 90% Jakarta EE :D

Spróbujmy zatem zaimplementować takie coś.

SPI 0.1

Zacznijmy od naszej specyfikacji. Będzie ona zawierać jeden interfejs. Nazwijmy go Wrapper i niech wygląda tak:

Listing 3. Nasz przykładowy interfejs

public interface Wrapper {

    List<Integer> get();

    Integer sizeOf(List<Integer> list);
}

Dodajmy do tego jeszcze jakiś prosty mechanizm ładowania:

Listing 4. Oraz mechanizm ładowani

public class WrapperProviderSpi {

    public static Optional<Wrapper> load() {
        ServiceLoader<Wrapper> wrappers = ServiceLoader.load(Wrapper.class);
        return wrappers
                .findFirst();
    }
}

I wypuśćmy to na świat w wersji 0.1.

Implementacja 0.1

Czas na implementację. Tworzymy osobny projekt dodajemy zależność do naszego SPI:0.1 i piszemy:

Listing 5. Pierwsza implementacja

public class WrapperImpl implements Wrapper {
    @Override
    public List<Integer> get() {
        return List.of(1, 2, 3, 4);
    }

    @Override
    public Integer sizeOf(List<Integer> list) {
        return list.size();
    }
}

Instalujemy w repo i jak na razie wszystko idzie jak po maśle. Czas na klienta.

Klient

Nasz klient jest bardzo prosty. Tworzymy nową aplikację, dodajemy do niej zależności:

Listing 6. Którą podepniemy do projektu


<dependencies>
    <dependency>
        <groupId>pl.koziolekweb.spimagic</groupId>
        <artifactId>spi</artifactId>
        <version>0.1</version>
    </dependency>
    <dependency>
        <groupId>pl.koziolekweb.spimagic</groupId>
        <artifactId>impl</artifactId>
        <version>0.1</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

I piszemy naszą „skomplikowaną logikę biznesową”:

Listing 7. I użyjemy w „logice”

public class App {

    public static void main(String[] args) {
        Wrapper wrapper = WrapperProviderSpi.load().orElseThrow(() -> new RuntimeException("No impl provided"));
        List<Integer> list = wrapper.get();
        Object size = wrapper.sizeOf(list);
        System.out.println(size.getClass());
        Object o = list.get(0);
        System.out.println(o.getClass());
    }
}

Odpalamy i naszym oczom ukazuje się następujący wynik:

Listing 8. Nawet działa!

class java.lang.Integer
class java.lang.Integer

Czyli wszystko jest ok, to w czym problem?

SPI 0.2

Zmieńmy odrobinę nasze API:

Listing 9. Drugie podejście i drobna zmiana

public interface Wrapper {

    List<Long> get();

    Integer sizeOf(List<Long> list);
}

Teraz będziemy produkować listę Long i taką listę przyjmować jako parametr. Zainstalujmy nasze nowe API jako wersję 0.2 i zaktualizujmy zależności w kliencie:

Listing 10. Aktualizujemy zależności


<dependencies>
    <dependency>
        <groupId>pl.koziolekweb.spimagic</groupId>
        <artifactId>spi</artifactId>
        <version>0.2</version>
    </dependency>
    <dependency>
        <groupId>pl.koziolekweb.spimagic</groupId>
        <artifactId>impl</artifactId>
        <version>0.1</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Zmieniło się API, trzeba zatem zmienić naszą implementację klienta.

NIE DOTYKAMY IMPLEMENTACJI SAMEGO API!

Nowa „skomplikowana logika biznesowa” niech wygląda w następujący sposób:

Listing 11. …uruchamiamy…

public class App {

    public static void main(String[] args) {
        Wrapper wrapper = WrapperProviderSpi.load().orElseThrow(() -> new RuntimeException("No impl provided"));
        List<Long> list = wrapper.get();
        Object size = wrapper.sizeOf(list);
        System.out.println(size.getClass());
        Long o = list.get(0);
        System.out.println(o.getClass());
    }
}

Poza oczywistą zmianą typu zmiennej list na nową zmieniliśmy też typ zmiennej o na Long. W końcu mamy listę obiektów tego typu. Całość się ładnie skompiluje. Odpalamy i…

Listing 12. …ups

class java.lang.Integer
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.Long (java.lang.Integer and java.lang.Long are in module java.base of loader 'bootstrap')
	at pl.koziolekweb.spimagic.App.main(App.java:14)

O kurwa diabeł… SPIERDALAMY!!!

Jakim cudem lista przechowująca Long nagle zawiera Integer?

Wymazywanie typów

Jak ktoś chwilę pracuje z Javą, to zapewne spotkał się z typami generycznymi. Jak już na nie trafił, to dość szybko też trafi na pojęcie type erasure, czyli wymazywanie typów. W dużym skrócie, w czasie kompilacji część informacji o typach generycznych jest usuwana z bytecode’u. Nie wszystko, bo np. takie coś:

Listing 13. Źródło problemu

List<String> list=wrapper.get();

nie przejdzie. Sygnatury metod nadal mają informacje o typach i kompilator może nas powstrzymać. Nie mamy jej jednak w czasie uruchomienia. Zresztą:

Listing 14. Jak wygląda to w klasie

$ javap Wrapper.class 
Compiled from "Wrapper.java"
public interface pl.koziolekweb.spimagic.spi.Wrapper {
  public abstract java.util.List<java.lang.Long> get();
  public abstract java.lang.Integer sizeOf(java.util.List<java.lang.Long>);
}

Nie ma więc tych informacji i ServiceLoader, który weryfikuje, czy to, co podaliśmy jako implementację danego interfejsu, pasuje do tego interfejsu. Dla niego nasz Wrapper w wersji 0.2 wygląda:

Listing 15. Bez generyków

public interface pl.koziolekweb.spimagic.spi.Wrapper {
  public abstract java.util.List get();
  public abstract java.lang.Integer sizeOf(java.util.List);
}

a WrapperImpl:

Listing 16. Implementacja też ich nie ma

public class pl.koziolekweb.spimagic.impl.WrapperImpl implements pl.koziolekweb.spimagic.spi.Wrapper {
  public pl.koziolekweb.spimagic.impl.WrapperImpl();
  public java.util.List get();
  public java.lang.Integer sizeOf(java.util.List);
}

Sygnatury się zgadzają, ponieważ informacja o typie generycznym została wymazana. Ładuje zatem naszą „starą” implementację, a my kończymy w niezbyt ciekawej sytuacji.

Dlaczego jest to ważne?

Z kilku powodów. Po pierwsze, gdy pracujemy z bibliotekami dostarczanymi przez runtime może okazać się, że sama aktualizacja wersji API to za mało. Trzeba też sprawdzić, czy na docelowym środowisku znajdzie się odpowiednia implementacja. Po drugie, ponieważ sami też jesteśmy dostawcami API i implementacji. Czasami drobna zmiana typu generycznego, którą u nas kompilator ładnie wyłapie, nie będzie widoczna u klienta. Po trzecie, ponieważ warto znać tego typu „kruczki” związane z językiem.

Na całe szczęście tego typu problemy są bardzo rzadko spotykane, ponieważ zazwyczaj zmiany w API są dużo głębsze i wymuszają zmianę wersji implementacji.

Kod znajdziecie tutaj, ale będzie pewno jeszcze zmieniony i uporządkowany.