Звідки в Java береться магія, або Що таке SPI
Привіт, мене звати Дмитро, програмую на джаві більш як 10 років, люблю розбиратися в технологіях і ділитися знаннями з іншими.
Ця стаття про SPI (Service Provider Interface) — на диво, мало відомий функціонал в джаві, котрий відповідає за автоматичне підвантаження правильних компонентів, наприклад, драйверів до бази даних. З одного боку вона відповідає за речі, котрі збоку можуть здатися магією. Але вона настільки проста, що просто гріх про неї не знати.
Стаття написана для тих, хто ніколи не чув про SPI, або чули, але не впевнені, що це таке.
Припустімо, ви кладете JDBC-драйвер в classpath і він якось підхоплюється. При чому, навіть якщо драйверів два (наприклад, для MySQL і для PostgreSQL) все одно підхопиться правильний.
Або ви використовуєте Slf4j для логування, додаєте реалізацію в runtime dependencies у свою pom.xml і воно автоматично починає працювати. Чи ви, використовуючи JUnit5, підключаєте лібу junit-jupiter-engine
і ваші тести раптом працюють. Як так?
У випадках, коли ви чуєте, що щось «автоматично підхопилося» — часто під капотом працює SPI.
На загал SPI використовується тоді, коли є розділення на:
- інтерфейс, відносно якого пишеться код;
- наперед невідому реалізацію інтерфейса, котра буде використовуватися під час виконання.
Який механізм роботи SPI
Код, що працює з базою, вимагає присутності JDBC-драйвера. Але код пишеться не відносно якогось конкретного драйвера, а відносно інтерфейса java.sql.Driver
. Правильний драйвер обирається вже в часі виконання програми (runtime).
Щоб цього досягти, потрібні дві речі. Перша — драйвер має оголосити про те, що в нього є реалізація інтерфейсу java.sql.Driver
. Це робиться дуже просто — всередині jar-файлу по шляху META-INF/services
має лежати файл з назвою, що відповідає інтерфейсу. В нашому випадку це буде файл META-INF/services/java.sql.Driver
. Вмістом файлу є назва класу, котрий реалізує інтерфейс, наприклад для HSQLDB це буде:
org.hsqldb.jdbc.JDBCDriver
Обов’язковою умовою є те, що в класу має бути конструктор за замовчуванням. І це логічно, якщо розуміти, що для його створення під капотом все одно використовується рефлексія. Зазвичай оголошують один клас, але реалізацій може бути декілька, і тоді назви класів пишуться кожен окремим рядком.
Друга потрібна річ — це те, що клієнтський код цей драйвер шукає. А пошук драйвера відбувається з допомогою java.util.ServiceLoader
. Код пошуку драйвера може мати такий вигляд:
public Driver findCorrectDriver(String dbUrl) { //1. Пошук усіх без винятку драйверів for(Driver driver : ServiceLoader.load(Driver.class)) { // 2. Наша власна логіка пошуку правильного драйвера if (driver.acceptsURL(dbUrl)) { return driver; } } return null; }
Виклик ServiceLoader.load(Driver.class)
шукає усі файли META-INF/services/java.sql.Driver
і за допомогою рефлексії створює вказані там об’єкти. Рядочок if (driver.acceptsURL(dbUrl))
— це вже наша логіка. Її можна пропустити і просто повернути перший знайдений клас. У випадку драйверів це — не є правильним рішенням, але це цілком підхожий варіант для бібліотек логування.
Ось і все SPI.
Для чого ще можна використати SPI
SPI зараз використовується у багатьох фреймворках і бібліотеках. Це і вже згадуваний Slf4j, котрий шукає реалізацію логування. Це і JUnit5, котрий шукає реалізацію проганяння тестів (так, зазвичай просто підключають junit-jupiter-engine
, та все ж).
Є і складніші варіанти використання. До прикладу, spring-web
оголошує, що в нього є реалізація javax.servlet.ServletContainerInitializer
, а конкретно — org.springframework.web.SpringServletContainerInitializer
. Сервлет контейнер за допомогою SPI підвантажує всі реалізації javax.servlet.ServletContainerInitializer
, в тому числі спрингову. А та, своєю чергою, шукає усі WebApplicationInitializer
(але вже без механізму SPI), котрі й мають конфігурації для запуску спринга.
Зрозуміло, що все це відбувається під капотом. Зазвичай, програмісту треба просто закинути джарку в classpath, поставити анотацію або реалізувати інтерфейс, і радіти, що воно магічним чином працює.
Корисно знати про SPI, якщо ви збираєтеся писати власний фреймворк, котрий очікує підключення зовнішніх, наперед невідомих, реалізацій. Також корисно про нього знати при інтеграції з існуючими фреймворками (раптом ви хочете написати власну реалізацію логування).
Але зазвичай SPI — це не технологія на щодень. Десь приблизно як рефлексія — корисно знати, щодня не використовується, але коли треба — рятує.
В рідкісних випадках SPI можна використати у власній розробці, як заміну функціоналу автопошуку потрібного компоненту, котрий часто пропонують Dependency Injection фреймворки.
Припустімо, ви пишете програму, котра звертається до зовнішнього сервісу — наприклад, щоб відіслати листа. Доступ до сервісу, в найкращих традиціях, ізольований до інтерфейсу. Для локальної розробки ви будете використовувати одну реалізацію, котра не шле справжнього листа, але просто логує виклик.
Але вже для серйознішого тестування ви приєднаєте реальний компонент. Звісно, для таких випадків краще підходить автосканування від різних фреймворків Dependency Injection, але якщо програма занадто мала, щоб підключати цілий фреймворк і налаштовувати автоскан (для тих, хто звик до спринга, зауважу, що автоскан не всюди працює за замовчуванням), то SPI цілком може справитися.
Як можна жити без SPI
Насправді SPI — це доволі проста технологія. Не обов’язково мати клас ServiceLoader
, щоб підтягнути усі ресурси з певним іменем, які знаходяться в папці META-INF/services/
. Для цього цілком можна використати метод ClassLoader#getResources
. А потім зчитати їх вміст і використати рефлексію для створення об’єктів. Але з появою SPI є усталений спосіб, як оголошувати реалізації інтерфейсів, а також зручний спосіб їх підтягування.
Є ще як мінімум один спосіб підставляння реалізації замість інтерфейсу, про який варто згадати в контексті розмов про альтернативи SPI. І полягає він просто в підміні jar-файлу. Тобто компілимо відносно одних класів, але в часі виконання підміняємо jar-файл з таким же інтерфейсом, але вже з іншою (або просто хоча б якоюсь) реалізацією.
Зрозуміло, що тут немає ніякого контролю над тим, котра конкретно реалізація буде використана і якщо таких бібліотек у classpath декілька, то ми повністю залежимо від завантажувача класів (classloader).
Прикладом використання останнього способу є один зі шляхів міграції з Log4j на Log4j2 (або іншу бібліотеку). Оскільки Log4j не розділяв API та реалізацію, він не очікував підключення інших реалізацій крім своєї власної. Ті, хто хотів мігрувати на іншу бібліотеку логування без того, щоб змінювати весь код, котрий викликає методи логування, могли відключити (наприклад використовуючи exclude
в мейвені) власне бібліотеку log4j:log4j
і підключити іншу — org.apache.logging.log4j:log4j-1.2-api
.
Остання використовує такий самий інтерфейс, але логує вже методами Log4j2. Таким чином можна успішно мігрувати з Log4j на Log4j2 без зміни самих викликів логування в коді. Відбулася класична підміна jar-файлів.
І зрозуміло, що найбільшим конкурентом SPI є Dependency Injection фреймворки по типу Spring або Weld. Зазвичай того, що пропонують ці фреймворки цілком вистачає, саме тому програмісти не так часто звертаються до SPI. Але в SPI є своя ніша використання, особливо тоді, коли залежність від одного фреймворка виключена.
19 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів