Использование SPI механизма для создания расширений

Архитектура большинства Java(и не только) приложений сегодня предусматривает возможность расширения функционала посредством различного рода магических воздействий на код. В последнее время это также стало возможно, если использовать какой-нибудь модный фреймворк или IoC-контейнер. Но что делать, если приложение долгоживущее и слишком сложное для того, чтобы переводить его на использование какого либо фреймворка?

В последнем приложении, с которым я работал, был реализован на тот момент неизвестный мне велосипед(SPI механизм), который искал в джарках текстовые файлы вида META-INF/services/<qualified interface name> и брал оттуда название нужного класса, реализующего этот интерфейс, далее этот класс использовался как расширение. Поискав в интернете, узнал, что Service Provider Interface(SPI) представляет собой программный механизм для поддержки сменных компонентов и что этот механизм уже довольно давно используется в Java Runtime Environment(JRE), например в Java Database Connectivity(JDBC):

ps = Service.providers(java.sql.Driver.class);
try {
  while (ps.hasNext()) {
    ps.next();
  }
} catch (Throwable t) {
  // Do nothing
}

Благодаря этому коду приложения больше не нуждаются в конструкции Class.forName(<driver class>) (хотя и с ней будут работать), JDBC драйверы будут подгружены автоматически при первом обращении к методам класса DriverManager.

SPI механизм также используется в Java Cryptography Extension(JCE), Java Naming and Directory Service(JNDI), Java API for XML Processing(JAXP), Java Business Integration(JBI), Java Sound, Java Image I/O.

Как это работает?

Весь смысл в разделении логики на сервис(Service) и провайдеры(Service Providers). Ссылки на провайдеры сохраняются в джарках расширений в текстовом файле(UTF-8) META-INF/services/<qualified service class>, в каждой строке полное имя класса провайдера. Пустые строки и комментарии(начинающиеся с символа #) игнорируются. Ограничения на провайдеры: они должны реализовывать интерфейс либо наследоваться от класса сервиса и иметь конструктор по умолчанию(zero-argument public constructor).

Основное приложение для получения списка провайдеров может воспользоваться входящей в состав Java SE 6 API утилитой java.util.ServiceLoader, которая работает по следующему принципу:

Пользовательский код запрашивает загрузчик конфигурации для определенного сервиса, загрузчик по мере надобности загружает из конфигурации провайдеры и сохраняет их в кэш. Также есть возможность очистить кэш и заново загрузить конфигурацию.

В более ранних версиях Java SE есть аналогичная утилита sun.misc.Service, работает по тому же принципу, но является частью проприетарного ПО Sun(Oracle) и может быть удалена в следующих релизах Java SE.

Пример использования

Например, у нас есть программа, которая ищет музыку на компе и выводит отсортированный по имени результат на экран.

public class MusicFinder {

  public static List<String> getMusic() {
    //some code
  }
}

public class ReportRenderer {

  public void generateReport() {
    final List<String> music = findMusic();
    for (String composition : music) {
      System.out.println(composition);
    }
  }

  public List<String> findMusic() {
    final List<String> music = MusicFinder.getMusic();
    Collections.sort(music);
    return music;
  }

  public static ReportRenderer getInstance() {
    return new ReportRenderer();
  }

  public static void main(final String[] args) {
    final ReportRenderer renderer = ReportRenderer.getInstance();
    renderer.generateReport();
  }
}

В некоторый момент времени мы осознали всю значимость этой программы для общества и решили поделиться ей со своими друзьями. Друзья попользовались сервисом и решили, что чего-то не хватает. Может выводить в отдельный файл? Но тогда придется переписывать весь этот клевый код. Не придется, можно воспользоваться SPI механизмом.

Например, создадим плагин для нашей супер-программы:

public class FileReportRenderer extends ReportRenderer {

  @Override
  public void generateReport() {
    final List<String> music = findMusic();
    try {
      final FileWriter writer = new FileWriter("music.txt");
      for (String composition : music) {
        writer.append(composition);
        writer.append("\n");
      }
      writer.flush();
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

Поместим в META-INF/services/com.example.ReportRenderer следующее:

com.example.FileReportRenderer

Сделаем исходную программу расширяемой:

public class ReportRenderer {
  //...

  public static ReportRenderer getInstance() {
    final Iterator<ReportRenderer> providers = ServiceLoader.load(ReportRenderer.class).iterator();
    if (providers.hasNext()) {
      return providers.next();
    }

    return new ReportRenderer();
  }

  //...
}

При запуске приложение, как и прежде, будет выводить всю найденную музыку на экран. Но если мы поместим только что созданную джарку расширения в classpath, то мы получим в результате файлик music.txt, содержащий результаты поиска.

Теперь пришло время поиграться с MusicFinder-ом. Сделаем его тоже расширяемым. Для этого поменяем класс на интерфейс:

public interface MusicFinder {

  List<String> getMusic();
}

Добавим в основном модуле реализацию:

public class DummyMusicFinder implements MusicFinder {
  public List<String> getMusic() {
    return Collections.singletonList("From DummyMusicFinder...");
  }
}

Поддержка расширений в ReportRenderer:

public class ReportRenderer {
  //...

  public List<String> findMusic() {
    final List<String> music = new ArrayList<String>();
    for (final MusicFinder finder : ServiceLoader.load(MusicFinder.class)) {
      music.addAll(finder.getMusic());
    }
    Collections.sort(music);
    return music;
  }

  //...
}

Как и в случае с ReportRenderer добавим текстовый файл META-INF/services/com.example.MusicFinder, содержащий:

com.example.DummyMusicFinder

Опять же результат выполнения первой программы не поменялся. Теперь расширение. Здесь сделаем две реализации MusicFinder-а:

public class ExtendedMusicFinder implements MusicFinder {
  public List<String> getMusic() {
    return Collections.singletonList("From ExtendedMusicFinder...");
  }
}

public class MyMusicFinder implements MusicFinder {
  public List<String> getMusic() {
    return Collections.singletonList("From MyMusicFinder...");
  }
}

META-INF/service/com.example.MusicFinder:

com.example.MyMusicFinder
com.example.ExtendedMusicFinder

Ну, вот и все, программа поддерживающая расширения готова, теперь с расширением в classpath, она выдаст список:

From DummyMusicFinder...
From ExtendedMusicFinder...
From MyMusicFinder...

Исходники примера можно найти здесь.

Заключение

Приведенный пример далек от совершенства, и я не претендую на автора самой крутого в мире поисковика музыки. Также я не призываю к фанатическому использованию этого механизма, так как не везде он применим, да и считаю использование IoC-контейнера более красивым решением, но все же кое-где и кое-кому такой подход может оказаться полезным. Спасибо за уделенное на прочтение статьи время.