Использование 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-контейнера более красивым решением, но все же кое-где и кое-кому такой подход может оказаться полезным. Спасибо за уделенное на прочтение статьи время.

    Литература


    Плагин
    Service Provider Interface
    Service Provider
    Service Provider Interface: Creating Extensible Java Applications
    Service Loader
    Поделиться публикацией

    Комментарии 14

      0
      Интересно, не знал про такую штуку. Но полностью согласен с вашим финальным комментарием про IoC. Что-то как-то некомфортно себя чувствовать, когда какой-то хитрый код что-то за меня делает незаметно.

      Непонятно, где использование этого механизма оправданно. Даже при работе с JDBC обычно грузишь название драйвера из пропертей, чтобы поменять можно было легко, поправив один текстовый файл. А тут, получается, надо будет проект пересобирать.

      Сдается мне, этот SPI агент Сатаны, чтобы всех нас утащить в jar-hell. Есть у кого идеи, как можно удобно использовать этот SPI? У меня ничего в голове не появилось после 10 минут обдумывания.
        +3
        пример где это можно было бы использовать: liquibase (вместо этого автор сделал свой собственный костыль на основе манифеста). Скажем, у liquibase есть свой собственный класс для работы с MySQL. Работает это так: код хочет создать новую таблицу в БД, запрашивает у сервис локатора все доступные имплементации интерфейса СоздаваторТаблиц, идет по ним и у каждой интересуется, умеет ли она работать с MySQL. Находит одну и с ее помощью создает таблицу. Как я сказал все работает прямо out-of-the-box. Однако, код не умеет устанавливать engine. И в моей личной базе данных плодит MyISAM таблицы, что мне не нравится. Благодаря механизму расширений, я создаю свой джарничек, который кладу в класспаф, в нем правильно настраиваю окружение (манифест/сервисы) и пишу свою собственную имплементацию, которая твикает sql так, как мне нужно: pastebin.com/PRxFuWTa. Чтобы избежать проблем с jar-hell автор использует метод getPriority, чтобы выбирать между иначе равнозначными имплементациями.
          0
          Еще похожий пример — slf4j, используется свой костыль.

          Еще может быть полезно, как я уже в статье говорил, если приложение достаточно старое и громоздкое и очень сложно перейти на использование IoC-контейнера, например если построено на синглтонах и фабриках. Вот здесь этот механизм как раз кстати, и волки целы, и овцы сыты и код практически не надо переделывать, и расширения писать удобно.
            0
            кроме всего прочего, IoC — это доп либа, а этот механизм доступен прямо с jvm — нет никаких лишних зависимостей.
        0
        Стандартный ServiceLoader не пригоден для использования — в нем нельзя (или по крайней мере очень трудно) подменить возвращаемые сервисы, а без этого не возможно тестировать методы. Поэтому нужно писать свою реализацию.
          0
          имхо ServiceLoader используется на этапе «сборки» графа объектов и для него нужно писать интеграционные тесты из серии «собрали джарник, запустили — работает», а не юнит-тесты, т.к. не очень понятно, что именно тут можено про-юнит-тестировать.
            0
            ServiceLoader тем и хорош, что его можно использовать везде. Вот только тестировать после этого нельзя.
              0
              Все очень просто — если надо во время тестов подменить провайдер, нужно просто подложить в тест класпас нужный файлик, ну или как вариант подложить свой фэйковый класлоадер.
                0
                А если у нас 2 теста и каждому из них нужна своя мок-реализация сервиса?
                С фейковым класслоадером тоже есть проблемы. Общаться с тестируемым объектом нужно через рефлекшен — ибо он не совместим с обычным классом (класслоадер-то другой!). Написать фейковый класслоадер тоже весьма нетривиально.
                  +1
                  На каждую архитектуру приложения нужна своя особенная архитектура тестов, и она тоже важна. У большинства фреймворков есть готовые реализации архитектуры для тестов, у голой джавы нет, что тут поделаешь, приходится писать свою, но все равно нерешаемых задач нет.
                    +1
                    Mock classloader гуглится по сочетанию «mock classloader», их там дофига
            0
            Кроме прочего SPI штатно используется для определения текущего XML парсера. Очень кривое решение, с теми же XML парсерами от него больше проблем чем пользы (грузится тот, который раньше указан в classpath-е, чтобы это изменить приходится прибегать к разного рода шаманству).
            К тому же, SPI-механизм не поддерживает lazy загрузку и, как следствие, жаден до ресурсов. Например перебор доступных реализаций JDBC загрузит в jvm все найденные драйверы — если их много, то это тьма памяти и времени. А нужен-то обычно всего один.
              0
              В коде

              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;
                }
              
                //...
              }
              


              ServiceLoader будет всякий раз создавать итератор и заполнять внутренний кэш на итерировании. Если в ClassLoader не добавляются динамически новые ресурсы с META-INF/services/..., то было бы более производительно создать ServiceLoader один раз (например, записав его в статическое финальное поле), и просто итерировать по нему в других методах.

              Если все-таки в ClassLoader в рантайме добавляются ресурсы, то можно при добавлении такого ресурса вызывать ServiceLoader.reload(), чтобы очистить кэш уже загруженных инстансов.

              Единственная проблема — в том, что ServiceLoader не потокобезопасен. Однако проблему можно решить, создав какой-нибудь метод типа
              public static void visitAllMusicFinders(Consumer<MusicFinder> consumer) {
                synchronized (serviceLoader) {
                  for (final MusicFinder musicFinder : serviceLoader) {
                      consumer.accept(musicFinder);
                  }
                }
              }
              
                0
                Спасибо! Через 8 лет статья оказалась полезной. Нагуглена по java SPI mechanism :)

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое