Звідки в Java береться магія, або Що таке SPI

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Привіт, мене звати Дмитро, програмую на джаві більш як 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 є своя ніша використання, особливо тоді, коли залежність від одного фреймворка виключена.

👍ПодобаєтьсяСподобалось11
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

мені цей шаблон не дуже подобається — схоже на магію. «Я буду щось шукати що перше найду то й буду використовувати»

Не зовсім так. «Перше, що знайду — те і буду використовувати» — це лише один зі способів вибору компонента. Чи робити саме так — залежить від фреймворку і потреб.
Наприклад так можна робити, коли шукається проганяч тестів (test runner) для JUnit5. Ніхто ж не очікує, що при двох наявних реалізаціях в classpath, тести проганятимуться теж двічі. Але в тому ж випадку можна викинути помилку — логіка вибору компоненту самим SPI не задається, і JUnit5 міг би відмовитися проганяти тести при наявних двох реалізаціях (я, чесно, не перевіряв, що він зробить).
Інтерфейс, який шукають, може оголосити метод .priority(). І тоді після того, як завантажаться всі реалізації, можна посортувати по пріорітету і взяти з найвищим.
Мало того, можна обрати декілька імплементацій. Саме так відбувається з javax.servlet.ServletContainerInitializer — саме тому в одному контейнері сервлетів може існувати паралельно і SpringMVC і Jersey — обоє оголошують реалізацію javax.servlet.ServletContainerInitializer і контейнер сервлетів ініціалізує обидва.
Але зазвичай ми самі рідко контролюємо логіку підбору компонентів, адже фреймворки ми пишемо рідко. І відповідно ми залежимо від того, як SPI реалізували самі фреймворки. Те, що вони роблять, і справді часто виглядає, як магія — тут я погоджуюся.

Я досить скептично ставлюся до можливостей сучасного ШІ та ChatGPT.
Але такий же обсяг матеріалу (причому вище якістю) зараз можна отримати від ChatGPT за 5 секунд, просто запитавши у нього «Що таке SPI в Java?»

Для того, щоб спитати в ChatGPT «Що таке SPI в Java?» для початку треба знати, що SPI в джаві взагалі існує.

q: Що таке SPI в Java?
a:
SPI в Java означає «Service Provider Interface» (інтерфейс постачальника сервісу). Це механізм, що дозволяє розширювати функціональність додатків, шляхом динамічного завантаження інтерфейсних реалізацій зовнішніх постачальників. Цей механізм використовуєся для створення розширень, плагінів та інших модулів, які можуть бути додані або виключені з програми без її перекомпіляції.

Сам користуюся ChatGPT практично кожного дня, але ви явно його переоцінюєте в деяких речах)

Хмм

Q: Що таке SPI в Java?

A: SPI в Java відноситься до Service Provider Interface, який є частиною Java API, що визначає інтерфейс, який повинен бути реалізований. Це дозволяє створювати модульні додатки, в яких можливо використовувати замінні компоненти.

Java SPI (Service Provider Interface) використовується для побудови служб, які можуть бути динамічно виявлені та використані без жорсткого кодування служби, яку ви хочете використовувати.

Наприклад, JDBC (Java Database Connectivity) є SPI, який дозволяє розробникам писати код, що взаємодіє з базою даних, не знаючи, який драйвер бази даних буде використовуватися.

Ось як це працює:

1. Ви створюєте інтерфейс для служби, яку ви хочете надавати.

2. Потім ви створюєте класи, які реалізують цей інтерфейс.

3. Ви розміщуєте імена цих класів у файлі, який знаходиться у директорії META-INF/services вашого JAR-файлу.

4. Ви використовуєте ServiceLoader для завантаження реалізацій на етапі виконання.

Це дозволяє вам змінювати реалізації, просто замінюючи JAR-файли, без необхідності зміни коду, що використовує ці служби.

Навіщо ви це написали? До чого тут ChatGPT?

Пошук драйвера регулюється конфігурацією, чи як?
Тоді добре було б хоч кілька слів про те, як ця конфігурація виглядає (скоріш за все, якийсь XML), де її шукає код, які замовчування, якщо її нема.

Залежить від того, хто шукає драйвер. По-перше драйвер можна взагалі не шукати, просто беремо потрібний драйвер, ініціалізуємо його за допомогою new і працюємо надалі з ним.
Далі все залежить від фреймворка. Наприклад є фреймворки, де правильний драйвер задається прямо в конфігураційних файлах — наприклад Hibernate дійсно може шукати назву драйвера в якісь xml-ці. Я впевнений, що драйвер може бути заданий і в property файлі, чи environment variable.
Але якщо драйвер явно не вказується, тоді пошук може відбуватися (і часто таки відбувається) за допомогою SPI. Тобто вантажаться усі класи, вказані в усіх ресурсах під іменем

META-INF/services/java.sql.Driver
.
Далі, використовуючи метод
driver.acceptsUrl

обираєтсья той драйвер, котрий вміє працювати з даною базою (адже в кожної бази урла інша).
Щодо замовчувань — то замовчувань немає. Якщо жоден драйвер не підтримує задану урлу до бази, то ніякий драйвер і не повернеться і, швидше всього, ви отримаєте повідомлення про помилку.

Дякую за статтю!
Толкові технічні статті по джаві (а не «як зробити хеловорлд на спрінг») нині рідкість на доу.

SPI
Java

Оце клікбейт здорової людини.

Чесно, випадково вийшло. Як справжнього джавіста мене мало цікавлять низькорівневі інтерфейси, тому була відустня стійка асоціація абревіатури SPI з чимось окрім як джавішної технології.

або Що таке SPI

Serial Peripheral Interface, SPI bus

SPI для доступу к SPI — цікава ідея :))

Дуже правильне зауваження від Дмитра, хоч і в стилі «ха-ха»... :)
В цій темі теж є SPI bus.

SPI є реалізацією двох паттернів — SOA та IoC/DI.

для SOA SPI надає «контракт сервісу», де потім обереться кращий контракт.
У вашому прикладі з драйверами, це потрібно щоб вибрати що вам більше підходить — MySQL чи PostgreSQL...

І так, SPI bus як USB...

Загалом, в такого плана статтях було б добре підніматися вище за «я знайшов класну штуку — подивіться як класно вона в мене працює»...

Бо так чи інакше, наприклад, той самий SPI — це інструмент-патерн і, як будь-який патерн, вирішує ПЕВНІ проблеми. Якщо ЦИХ проблем немає, то патерн легко перетвориться на антіпатерн.

Ну добре, хоча б написали про плюси-мінуси...

Наприклад, з власного досвіду...

Була собі така міграція на Oracle WebLogic 14... Міграцію робила безпосередньо команда Оракла. І от ...не йде. Конект з базою валиться, Oracle Coherence меморі грід падає... —розброд і шатанія...

Підключився, знайшов... хлопці включили SPI, але ручками прописали правильний OJDBCrmda, який і прописався правильно у Кохеренсі. Але потім, при запуску адмін-сервера, вмикається SPI, вивантажує правильний драйвер та завантажує «звичайний» OJDBC. І, тим самим,валить весь меморі грід...

Тобто, по простому, SPI треба викоритовувати там. де ви можете використовувати рефлекшн, і навпаки — там, де ви не можете використовувати рефлекшн — наприклад на продакшн серверах — то і SPI не гарна ідея...

А я було подумав, що на жаві драйвери до дівайсів почати писати...

Взагалі то був компілятор GCJ, який компілював не в байт код — а безпосередньо в машинний, тобто чисто технічно можна написати і драйвер пристрою на Java. Та це безумовно содомія і збочення.

Зараз існує GraalVM, там теж є AOT (Ahead of Time) компілятор, що компілює безпосередньо в машинний код.

Підписатись на коментарі