Dmytro Polovynka

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

SPI (Service Provider Interface) - це вбудована в джаву технологія пошуку потрібних компонентів. З одного боку вона відповідає за речі, котрі збоку можуть здатися магією. Але вона настільки проста, що просто гріх про неї не знати.

Припустімо ви кладете JDBC драйвер в classpath і він якось підхоплюється. При чому навіть якщо драйверів два (наприклад для MySQL і для PostgreSQL) все одно підхопиться правильний. Або ви використовуєте Slf4j для логування, додаєте реалізацію в runtime dependencies в свою pom.xml і воно автоматично починає працювати. Чи ви, використовуючи JUnit5, підключаєте лібу junit-jupiter-engine і ваші тести раптом працюють. Як так?

У випадках, коли ви чуєте, що щось “автоматично підхопилося” - часто під капотом працює SPI.

На загал SPI використовується тоді, коли є розділення на

Який механізм роботи SPI?

Поясню на прикладі java.sql.Driver.

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


Ця ж стаття на ДОУ