понедельник, 6 апреля 2015 г.

Плагины, плагины, кругом одни плагины

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


Конечно, архитектура решения всегда зависит от поставленной задачи, и может довольно сильно варьироваться, но общие черты имеются всегда. Эти общие черты заключаются в том, что приложение предоставляет некоторый стандартный набор сервисов для подключаемых модулей, а также определяет протоколы взаимодействия этих модулей и между собой, и с основным приложением. А модули… а что модули? Они просто могут выполнять то, для чего предназначены, и отдельно от приложения, как правило, не живут.

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

Не буду скрывать, сам грешен: в бытность программирования на Delphi создавал самописный фреймворк на базе bpl. До этого, работая на C, тоже создавал, только на базе dll. Даже начиная программировать на Java - не удержался и... ну, сами понимаете - на базе jar файлов.

Так случилось, что, в очередной раз, при разработке ПО передо мной встал вопрос использования подключаемых модулей для решения некоторых задач. Почему? Ну, было множество мест, в которых производилась масса однотипных действий, типа, замена символьных переменных в строках на их вычисляемые значения. При этом получилось так, что правила задания этих переменных оказались разными. Разве не идеальная задача для выделения сервиса для осуществления таких замен? Хорошо, может и не идеальная. И, как всегда, существует тысяча и один способ решения. Но все же, почему бы и нет?

Или, вот, задача разбора подписанных сообщений - с целью выцепить информацию об электронной подписи. Существует несколько инструментов, или, если хотите, библиотек, которые могут позволить это сделать. Например? Ну вот сразу на ум приходит Bouncy Castle, или, скажем, Oracle Security Developer Tools. Оба инструмента позволят решить поставленную задачу, хотя, вряд ли, конечно, эти решения полностью совпадут, в каждом, наверняка, будет куча нюансов и особенностей, и, из-за этого, могут возникнуть определённые предпочтения использовать тот или другой инструмент в различных обстоятельствах. Чем не повод выделить функционал в сервис?

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

Круги я уже обозначил: подстановка значений вместо имен переменных - это раз, и вытаскивание информации об электронной подписи из подписанного сообщения - это два. Для второго круга задач первую реализацию я решил выполнять с использованием библиотеки Bouncy Castle - все-таки это более современный инструмент, который продолжает разрабатываться, и который, ко всему прочему, поддерживает ГОСТ алгоритмы шифрования и подписи, хотя, эта поддержка на первом этапе может и не понадобиться. Для задач же замены переменных на значения было решено, для первой реализации,  использовать составляющую библиотеки Apache Commons Lang - StrSubstitutor.

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

Тут весьма уместно повторить мою любимую мантру: решений может быть множество. Но выбор, все-таки, нужно делать, и, после некоторого раздумья, я решил попробовать опереться на API, которое появилось в Java, начиная с версии 1.6 (это официально, знающие люди говорят о версии 1.3) - это SPI, или Service Provider Interface.

И причин у этого выбора несколько. Ну, хотя бы то, что в самом JDK есть некоторое количество примеров использования этого подхода. Самым известным применением являются JDBC драйвера. А еще SPI используется для задания XML DOM и/или SAX парсера. На самом деле, если честно, именно пример с XML парсерами показался мне очень похожим на стоящие передо мной задачи. Хотя, почему именно он, боюсь, объяснить толком не смогу - пример с JDBC драйверами ничем не хуже. (На самом деле, у меня есть подозрение, почему XML парсеры произвели на меня такое сильное впечатление, но об этом, как-нибудь, в другой раз).

Еще одним аргументом за использование SPI, причем, далеко не последним, стало то, что я никогда до сих пор его не применял. Кроме того, я всегда стараюсь использовать стандартные механизмы, встроенные в Java, даже если они в чем-то уступают альтернативным решениям. Так что, SPI - мой выбор, по крайней мере, на данном этапе, или, в данном конкретном случае.

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

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

    public interface VariableSubstitutorInterface {
        public String substitute(String source, Map values);
    }

Объясню, как и почему получаем именно такой интерфейс. Название интерфейса - не бог весть какое оригинальное. Да, незамысловато, но суть отражает - VariableSubstitutorInterface  или ИнтерфейсПодменителяПеременных. И метод назван по тому же принципу: нам что надо сделать? Именно, подменить. Подменять мы будем переменные, которые встроены в исходную строку (эта строка и есть первый параметр), ну и вместо этих самых переменных надо будет подставить значения. А как проще всего в Java задать множество пар имя=значение? Первое, что приходит в голову: при помощи объекта, реализующего интерфейс Map. Отсюда - второй параметр метода. Имена переменных будут ключами, значения - значениями. Только вот давайте без критики. Наша цель заключается сейчас не в том, чтобы идеальный интерфейс для службы подмены разработать, а в том, чтобы научиться применять конкретный механизм для решения поставленной задачи. Механизм наш - SPI. Пока что нас вполне устроит и такой вариант интерфейса. Кстати, вместо интерфейса можно использовать абстрактный класс. Что выбрать - тема отдельного разговора.

Итак, интерфейс готов. Следующим шагом является реализация спроектированного интерфейса. Класс (или классы), разработанный на этом этапе, и будет реальным предоставителем сервиса - провайдером, так сказать. В какой-то момент пришлось решать, каким будет первенец. Можно, конечно, реализовать класс с использованием метода replace (или replaceAll) класса String, но, в конце концов, было принято решение использовать какой-нибудь продвинутый класс из известной библиотеки. И выбор пал, как я уже писал выше, на StrSubstitutor из Apache Commons Lang. Я не буду пересказывать его функциональность, отмечу лишь, что она вполне удовлетворяет условиям поставленной задачи. Опять же, название весьма созвучно.

Реализация, как можно догадаться, весьма незамысловата.

    public class ApacheVariableSubstitutor
        implements VariableSubstitutorInterface {

        @Override
        public String substitute(String source, Map values) {
            StrSubstitutor substitutor = new StrSubstitutor(values);
            try {
                return substitutor.replace(source);
            }
            finally {
                substitutor = null;
            }
        }
    }

Первым делом, мы создаем экземпляр класса StrSubstitutor, причем, используем для этого конструктор, который в качестве параметра принимает объект, реализующий интерфейс Map, и содержащий пары ключ-значение, где ключ - переменная, которую собираемся заменить, а значение - то, на что, собственно, будем заменять переменную. После этого, вызываем метод replace созданного объекта, передавая в качестве параметра строку, содержащую переменные, подлежащие замене. Этот метод возвращает строку, в которой должны быть заменены все переменные, для которых в объекте, реализующем интерфейс Map и переданном в конструктор, были заданы значения. Эту строку мы и возвращаем в качестве результата работы нашего сервиса по замене в строке переменных на значения.

Что ж, реализация интерфейса тоже завершена. Двигаемся дальше. А дальше необходимо понять, как должен будет задействован только что созданный класс. И тут мы вплотную подходим к сердцу, так сказать, механизма SPI - классу ServiceLoader. Именно этот класс используется для нахождения, загрузки и создания объектов, реализующих интерфейсы, разработанные нами для создаваемой подсистемы плагинов и сервисов.

Опять же, оставим документацию для самостоятельного изучения, и, мой совет - не откладывайте этот момент в долгий ящик. А я перейду к тому, как данным классом можно пользоваться. Сразу оговорюсь, всех возможностей этого класса я не использовал, да и для нашего простенького примера это совсем не нужно.

Так вот. У класса ServiceLoader есть статический метод load. Если в качестве входного параметра передать в него тот самый интерфейс, который должны реализовывать конкретные классы, предоставляющие нужную функциональность, то метод вернет объект, используя который, можно будет по очереди перебрать эти самые классы. Вот как это можно провернуть:

    ...
   protected List<VariableSubstitutorInterface> providers =
           new LinkedList<>();

    private VariableSubstitutorService() {
        ServiceLoader loader = ServiceLoader.load
            (VariableSubstitutorInterface.class);
        Iterator<VariableSubstitutorInterface> substitutors = 
            loader.iterator();
        while(substitutors.hasNext()) {
            VariableSubstitutorInterface substitutor = 
                substitutors.next();
            providers.add(substitutor);
        }
    }
    ...

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

Наверное, тут самое время объяснить, почему конструктор private и зачем я в этом конструкторе пробегаюсь по всем классам-провайдерам и записываю их в список. Возможно, ответ покажется неожиданным, но, все-таки... я делаю это для того, чтобы обеспечить потоковую безопасность (thread safety).

"Вот так поворот, - скажете вы, - причем тут потоковая безопасность?". Но, если задуматься, все вполне закономерно. Разрабатываемый сервис может вызываться откуда угодно, в том числе, из разных потоков. Лично мне за примерами далеко ходить не пришлось - приложение, в котором я собирался использовать сервис, многопоточное. Ну а для многопоточного приложения важно обеспечить потоковую безопасность (так и слышу: "Спасибо, Кэп!").

Тут можно задать следующий, и, тоже, вполне логичный, вопрос. Как связаны потоковая безопасность и класс LinkedList? Тем более, что если сходить по приведенной ссылке, то можно сразу увидеть текст, выделенный жирным. Что ж, сделаем небольшое отступление, тем более, что это, как будет видно чуть позже, и не отступление вовсе. Итак.

Из описания ServiceLoader следует, что этот класс находит и загружает классы-провайдеры, и держит их в своем внутреннем кэше. Но в документации также написано, что ServiceLoader не обладает потоковой безопасностью, а, следовательно, надо предпринимать какие-то усилия, чтобы эту безопасность все-таки обеспечить. Существует множество способов решить озвученную задачу, я выбрал следующий.

Между программой и классами-провайдерами обычно существуют промежуточные классы, можно сказать, что это такие прокси классы, которые и используется кодом основной программы (и не только) для доступа к сервисам. Если уж быть до конца щепетильным, и придерживаться шаблонов, то эти промежуточные классы сами должны быть скрыты за интерфейсами. Я в своем примере не стал так углубляться, хотя, рассматриваемый промежуточный прокси класс и объявлен, как реализующий тот же интерфейс, что и классы-провайдеры. У меня, в реальном проекте, некоторые промежуточные прокси классы для доступа к сервис-провайдерам тоже напрямую не используются, только через интерфейсы. Но помните, это совсем не обязательно.

Вернемся, однако, к потоковой безопасности. Довольно часто эти прокси классы реализуются с использованием шаблона проектирования (design pattern) Singleton. Методы, с помощью которых достигается потоковая безопасность таких классов, известны, надо лишь выбрать один из них. Мне, например, очень нравится метод с красивым и непереводимым названием Initialization-on-demand holder idiom. Он очень изящен. Вот код из моего проекта, реализующий этот подход.

    public class VariableSubstitutorService
        implements VariableSubstitutorInterface {
        private static class ServiceInstanceHolder {
            private static final VariableSubstitutorService
                srvInstance =
                    new VariableSubstitutorService();
        }
    
        public static VariableSubstitutorService getInstance() {
            return ServiceInstanceHolder.srvInstance;
        }
    ...
    }

В чем тут фишка? Во-первых, ничего не будет проинициализировано до тех пор, пока не будет произведен первый вызов метода getInstance(). А во-вторых, Java гарантирует, что до тех пор, пока статические инициализаторы не будут полностью выполнены, доступ к экземпляру класса не будет разрешен. А в статическом инициализаторе вызывается конструктор VariableSubstitutorService, код которого приведен чуть раньше, и который заполняет LinkedList экземплярами классов, реализующих нужный нам интерфейс. Соответственно, работать с этим списком на чтение потоки будут уже тогда, когда он полностью будет заполнен данными, а на чтение LinkedList вполне может быть использован одновременно многими потоками. Таким образом, получаем совершенно безопасное, с точки зрения многопоточности, решение, без использования специальных языковых конструкций типа synchronized. Ведь после инициализации мы не будем больше обращаться к экземпляру класса ServiceLoader и не будем иметь дело с его небезопасным кэшем.

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

С использованием все довольно просто. Хочу просто отметить, что в моей реализации производится полный обход списка классов-провайдеров. Делается это по одной простой причине. Где-то в начале повествования я упоминал, что правила записи переменных в строках отличаются, в силу определенных исторических причин. В принципе, я собираюсь реализовать несколько  провайдеров, хотя, может, если извернуться, можно довольствоваться и одним. Так вот, я хочу дать возможность отработать всем классам, реализующим подстановку значений вместо переменных, поэтому и обхожу весь список полностью.

    @Override
    public String substitute(String source, Map values) {
        String wrk = source;
        Iterator<VariableSubstitutorInterface> it =

            providers.iterator();
        while (it.hasNext()) {
            VariableSubstitutorInterface substitutor = it.next();
            wrk = substitutor.substitute(wrk, values);
        }
        return wrk;
    }


Собственно, вот и весь код. Как видим, программировать надо не так уж и много. Конечно, для более сложной функциональности, например, той же работы с подписями, потребуется значительно больше кода, но это обусловлено самой задачей. Схема же, используемая для организации плагина, остается прежней: надо объявить интерфейс сервиса, разработать класс (или набор классов), реализующий требуемую функциональность, и, конечно же, интерфейс сервиса, и, напоследок, промежуточный класс, или прокси класс, обеспечивающий доступ к функциональности сервиса, или классов-провайдеров, для всего остального кода программы. Надо помнить так же, что этот прокси класс должен реализовывать некоторую логику, позволяющую выбрать, какие из провайдеров должны отработать, а какие - нет. И, наверное, неплохо было бы позволить пользователям прокси класса, каким-то образом, управлять этой логикой.

Теперь совсем немного о том, как заставить ServiceLoader найти классы, реализующие тот или иной интерфейс. Тут тоже все довольно просто. В соответствии с документацией, для успешного нахождения класса провайдера, в jar файле, в котором будет размещен данный класс, должен существовать специальный подкаталог META-INF/services. В этом подкаталоге должен находиться специальный конфигурационный файл.

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

Я веду разработку при помощи Netbeans. Все, что я делаю, когда мне надо добавить такой вот конфигурационный файл - создаю в каталоге, где размещаются исходники для нужного jar файла (это каталог src внутри каталога проекта), подкаталог META-INF/service и создаю в нем обычный текстовый файл с полным именем интерфейса, в который записываю полное имя реализующего его класса. Под полным именем подразумевается имя класса (или интерфейса) с именем пакета, точками и т.д. При сборке этот, с позволения сказать, конфигурационный файл, оказывается там, где ему и положено по спецификации - внутри jar файла. Более того, при запуске программы все работает.

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

И еще. Иногда приходится пользоваться такой возможностью IDE, как рефакторинг. Так вот, я не знаю, что будет, если попробовать переименовать интерфейс или провайдер. Но что-то мне подсказывает, что все упоминания переименовываемой сущности будут правильно обработаны в файлах *.java, а вот этот самый конфигурационный файл будет обойден стороной.

Вот, собственно, пока и все. Скорее всего, в дальнейшем, я буду еще писать о чем-то, что тем или иным образом будет опираться на использование SPI. Так что, надеюсь, врасплох не застану.

Ну и напоследок - ссылка на демонстрационный проект. Как я уже говорил, при разработке мною используется Netbeans, соответственно, этот проект, или, точнее, проекты, тоже созданы в этой IDE. Чтобы их использовать в родной (для них) среде, достаточно скачать zip файл, запустить Netbeans, зайти в меню File, выбрать пункт Import Project и далее - пункт From ZIP....После этого на экране появится такое окошко:


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

Если захочется получить дистрибутив для запуска, можно воспользоваться контекстным меню для проекта (доступно по правой кнопке мыши) - там есть пункт Clean and Build, ну а для запуска - пункт Run все в том же контекстном меню.


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

Комментариев нет:

Отправить комментарий