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:

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ę:

     ┌────────────┐                 ┌─────────────┐
     │            │    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:

public interface Wrapper {

    List<Integer> get();

    Integer sizeOf(List<Integer> list);
}

Dodajmy do tego jeszcze jakiś prosty mechanizm ładowania:

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:

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:


<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ą”:

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:

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

Czyli wszystko jest ok, to w czym problem?

SPI 0.2

Zmieńmy odrobinę nasze API:

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:


<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:

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…

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ś:

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ą:

$ 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:

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

a WrapperImpl:

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.