SPI, generyki i dlaczego jest to ważne
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.