Как сообщить приложению на Java подлинную модульность? Данная статья — попытка дать ответ на этот вопрос.
Допустим, у нас есть развивающееся приложение на Java с достаточно интересным функционалом. Но, к сожалению, со временем становится все сложнее добавлять в него новые детали, программа начинает расклеиваться в неожиданных местах. Вполне возможно, проблема заключается в недостаточной модульности приложения. Но не казните себя — не вы в этом виноваты. Просто традиционный язык Java печально известен как раз плохо организованной модульностью. Но так быть не должно.
Повышая модульность, удается создавать расширяемые системы, более удобные в поддержке и компоновке. Если у вас есть четко определенные границы между модулями, то все хорошо. Любые функции можно тестировать отдельно от других, на уровне кода и работы с командой отлично действует принцип «разделяй и властвуй». Разработка ускоряется, и такая высокая скорость сохраняется не только в первый год существования системы, но и на протяжении всего ее жизненного цикла.
От архитектуры к программам
Как же построить по-настоящему модульную систему? Конкретное решение мы рассмотрим ниже, а пока давайте четко определим стоящую перед нами проблему. Модульность очень важна на многих уровнях абстрагирования. Архитектура нашей системы устроена по принципу «SOA» (сервис-ориентированная архитектура). Правильно организованная сервис-ориентированная архитектура обеспечивает создание явных и версионированных общедоступных интерфейсов (в основном речь идет о веб-службах) между слабосвязанными системами, скрывающими свои внутренние детали. Эти подсистемы вполне могут работать на основе совершенно автономных технологических стеков, и поэтому каждую из таких подсистем можно легко заменить, не затрагивая все остальные.
Тем не менее при создании отдельно взятых сервисов или подсистем на Java зачастую не удается избавиться от монолитного подхода. К сожалению, характерным примером этого является среда времени исполнения языка Java, rt.jar. Разумеется, вы можете разбить ваше монолитное приложение на три обязательных уровня, но это лишь жалкое подобие истинной модульности. Просто задайтесь вопросом: что потребуется сделать, чтобы извлечь из вашего приложения базовый уровень и заменить этот уровень на совершенно иную реализацию? Очень часто такая замена отражается на всем приложении. А теперь давайте подумаем, как сделать это, не повреждая остальных частей приложения, при горячей замене прямо во время исполнения. Ведь это возможно в SOA-приложениях, так почему бы не реализовать такую возможность в наших приложениях?
Истинная модульность
Итак, что же такое «истинная модульность», которой мы уже слегка коснулись в предыдущем разделе? Сформулирую несколько определяющих характеристик полноценного модуля:
- модуль — это независимый блок развертывания (допускающий переиспользование в любом контексте);
- модуль обладает стабильными конкретными идентификационными признаками (например, именем и версией);
- модуль предъявляет определенные требования (например, зависимости);
- один модуль может использоваться другими, но при этом внутренние детали данного модуля остаются для них скрытыми.
В идеале такие модули должны существовать в легковесной среде исполнения, которая сочетает требования и возможности модулей, выстраивая максимально устраивающую нас компоновку. Короче говоря, мы добиваемся модульности приложений, чтобы взять от сервис-ориентированной архитектуры все ее достоинства, но реализовать их в значительно более мелком масштабе. Причем не только на доске с маркерами, но и при написании кода, и при эксплуатации приложения. Более того, композиция модулей не должна быть статической: нам нужны эластичные и расширяемые приложения, которые не будут ложиться и требовать полномасштабного переразвертывания.
Объекты: это и есть настоящие модули?
Обратимся к самому нижнему уровню структурных абстракций в языке Java: классам и объектам. Разве объектная ориентация не обеспечивает четкой идентификации, скрытия информации и слабого связывания — при помощи интерфейсов? Да, до некоторой степени. Но идентичность объектов в данном случае эфемерна, а интерфейсы не версионированы. Вне всяких сомнений, классы в Java нельзя называть «независимыми блоками развертывания». На практике классы обычно слишком тесно «знакомятся» друг с другом. «Общедоступный» (public) означает «видимый практически любому классу» в пути (classpath), обозначенном на JVM. Такая открытость является нежелательной практически для любых сущностей, кроме по-настоящему общедоступных интерфейсов. Хуже того, применение модификаторов видимости Java во время исполнения не является строго обязательным (например, при отражении).
Переиспользовать классы вне их исходного контекста сложно, если никто не навязывает вам дополнительные издержки, связанные с неявными внешними зависимостями. Я так и слышу, как у вас в уме сейчас проносятся слова «Внедрение зависимостей» и «Инверсия управления». Да, эти принципы помогают сделать зависимости классов явными. Но, к сожалению, их архетипические реализации в Java по-прежнему вынуждают создавать приложения, напоминающие огромные комья из объектов, статически связываемых во время исполнения и образующих уродливые конструкции. Настоятельно рекомендую почитать книгу «Java Application Architecture: Modularity Patterns», если вы действительно хотите разобраться в паттернах модульного проектирования. Но вы также узнаете, что при применении этих паттернов в Java без обязательного соблюдения модульности во время исполнения вы сразу серьезно осложняете себе работу.
Пакеты: может быть, это настоящие модули?
А что если истинные модули языка Java следует искать где-то на полпути между приложением и объектами? Например, не являются ли пакеты такими полноценными модулями? Комбинация имен пакетов, операторов импорта и модификаторов видимости (например, public/protected/private) создает иллюзию того, что пакеты обладают как минимум некоторыми характеристиками модулей. К сожалению, пакеты остаются чисто косметическими конструкциями, они всего лишь обеспечивают пространства имен для классов. Даже их, казалось бы, явная иерархичность иллюзорна.
Разумеется, в любом случае следует использовать пакеты для структурирования базы кода, разбиения ее на логически связанные фрагменты. Просто не думайте, что пакеты повышают модульность по сравнению с обычными именами. Следует отметить, что существуют инструменты, помогающие продвигать зависимости пакетов путем статической верификации во время разработки. Но такое решение едва ли является удовлетворительным.
JAR-файлы: настоящие модули?
В таком случае не является ли настоящим модулем Java JAR-файл? И да, и нет. Да — потому что JAR-файлы являются независимыми единицами развертывания в Java-приложениях. Нет, поскольку они не обладают тремя остальными характеристиками. В файле MANIFEST.MF указывается имя, а иногда — даже версия JAR-файла. Ни то ни другое не входит в состав модели времени исполнения и, следовательно, не может служить явной фактической идентификационной информацией. Нельзя объявлять зависимости между разными JAR-файлами. Вы сами должны обеспечить соблюдение всех зависимостей в пути к классу. Кстати, путь — это просто плоская коллекция классов: связи с исходными JAR-файлами в нем теряются. Этим же объясняется еще одна большая проблема: любой общедоступный класс из JAR-архива остается видимым в пределах всего пути к классу. Не существует модификатора видимости «jar-scope», который позволил бы скрывать детали реализации внутри JAR.
Итак, JAR-файлы являются необходимым механизмом обеспечения модульности приложений, но одних их недостаточно. Есть специалисты, успешно выстраивающие системы из многочисленных JAR-файлов (но с применением паттернов модульной архитектуры), умеющие управлять идентификационной информацией и зависимости при помощи излюбленных инструментов, предназначенных именно для этого. Взять хотя бы Netflix API, состоящий из 500 JAR-файлов. Но, к сожалению, путь к классу во время компиляции и исполнения непременно будет отклоняться непредсказуемым образом, и разверзнется JAR-ад. Увы, с этим приходится мириться.
Наборы OSGi
Итак, обычный язык Java не может обеспечить достаточной модульности. Это признанная проблема, возможно, найти для нее нативное решение удастся в рамках проекта Jigsaw. Однако он так и не вошел в Java 8, а до этого — в Java 7, поэтому в обозримом будущем придется обходиться без него. Если вообще придется. Остается OSGi: модульная платформа для работы с Java, уже зрелая и обкатанная в боевых условиях. Она используется в некоторых серверах приложений и в IDE и является основой для их расширяемых архитектур.
Технология OSGi добавляет на JVM модульность, которая становится при этом первоклассным аспектом (first-class citizen) платформы. Это делается путем дополнения JAR-файлов и пакетов необходимой семантикой, позволяющей в полной мере реализовать истинную модульность. Модуль OSGi, также именуемый «набором» (bundle), — это JAR++. Он определяет в манифесте JAR дополнительные поля для версии (желательно семантической), имени набора и списка пакетов из набора, которые должны экспортироваться. Экспорт пакета означает, что вы присваиваете пакету версию, все общедоступные классы этого пакета будут видимы другим наборам. Все классы из неэкспортированных пакетов будут видимы только внутри набора. OSGi обеспечивает такой ход работы во время исполнения не в последнюю очередь благодаря тому, что у каждого набора есть отдельный загрузчик классов. Набор может выбрать для импорта пакеты, экспортированные другим набором — опять же, определяя его импортированные зависимости в манифесте JAR-файла. Разумеется, при таком импорте должна определяться и версия (диапазон), что позволяет получить разумные зависимости и руководить процессом разрешения наборов в OSGi. Таким образом, у вас даже могут одновременно работать несколько версий пакета и его классов. Небольшой пример манифеста с некоторыми параметрами OSGi:
И соответствующий манифест для сервисного пакета:
Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: MyService bundle Bundle-SymbolicName: com.foo.service Bundle-Version: 1.0.0 Import-Package: org.apache.commons.logging;version="[1.0.4, 2.0.0)" Export-Package: com.foo.service.api;version="1.0.0"
Имеем: OSGi предоставляет нам независимо развертываемый JAR со стабильной идентификационной информацией, который может требовать или предлагать зависимости (то есть версионированные пакеты). Все остальное строго заключено внутри наборов. Среда времени исполнения OSGi улаживает все тонкости, необходимые для сохранения четкого разделения в ходе работы. Вы даже можете производить горячую замену, добавление или удаление пакетов во время исполнения!
Сервисы OSGi
Итак, наборы OSGi обеспечивают соблюдение зависимостей, определяемых на уровне пакета и определяют динамический жизненный цикл для наборов, содержащих эти пакеты. Может быть, вот и все, что требуется для реализации SOA-подобного решения в миниатюре? Почти. Есть еще одна важнейшая концепция, отделяющая нас от создания настоящих модульных микросервисов на базе OSGi-наборов.
Работая с наборами OSGi, мы можем программировать в расчете на интерфейс, экспортируемый набором. Но как получить реализацию этого интерфейса? Экспорт реализующего класса — плохое решение, если это делается только для инстанцирования его в пакетах-потребителях. Можно было бы использовать фабричный паттерн и экспортировать фабрику как часть API. Но перспектива написания фабрики для каждого интерфейса кажется несколько… примитивной. Не годится. Но решение существует: надо работать с OSGi-сервисами. OSGi предоставляет сервисно-реестровый механизм, позволяющий зарегистрировать вашу реализацию с ее интерфейсом в реестре сервисов. Как правило, вы будете регистрировать ваш сервис при запуске набора, содержащего нужную реализацию. Другие наборы могут запрашивать реализацию для заданного общедоступного интерфейса из сервисного реестра. Они получат реализацию из реестра, и для этого даже не требуется знать базового класса реализации в их коде. OSGi автоматически управляет зависимостями между поставщиками и потребителями сервисов практически так же, как и зависимости, действующие на уровне пакета.
Звучит заманчиво, правда? Есть, правда одна небольшая ложка дегтя: оказывается, использовать низкоуровневый API сервисов OSGi сложно, и для этого требуется писать много кода. Ведь сервисы могут появляться и исчезать во время исполнения. Дело в том, что наборы предоставляют сервисы, которые можно совершенно произвольно запускать и останавливать, причем на такую остановку или запуск могут пойти даже уже работающие наборы. Этот механизм обладает огромным потенциалом, если вы стремитесь строить гибкие и долговечные приложения, но как разработчик вы должны строго придерживаться выбранной стратегии. К счастью, для устранения этой проблемы уже создано несколько высокоуровневых абстракций. Такие инструменты, как Declarative Services или Felix Dependency Manager, помогают с легкостью создавать и потреблять сервисы при работе с OSGi. Можете не благодарить за совет.
Стоит ли игра свеч?
Пожалуй, вы согласитесь, что выстраивание по-настоящему модульной базы кода — это амбициозная цель. И, конечно же, достичь этой цели можно и без OSGi. В свою очередь, модульные среды исполнения вроде OSGi не спасут такую базу кода, где модульностью и не пахнет. В конце концов, модульность — это архитектурный принцип, применимый практически на любом материале, было бы желание. Но спроектировать модульную конструкцию нелегко. Среда времени исполнения будет гладко работать с модулями лишь при условии, что модули и их зависимости являются в такой среде первоклассными сущностями, от этапа проектирования до этапа эксплуатации.
На практике все это кажется сложным? Да, тут, разумеется, есть что проштудировать, но все не так страшно, как многие полагают. За последние несколько лет инструментальная поддержка для разработки в стиле OSGi радикально улучшилась. Особого упоминания заслуживают bnd и bndtools. Если вы хотите ощутить, что такое разработка полноценных модульных приложений на Java, посмотрите этот деморолик моего коллеги Пола Баккера.
Модульные архитектуры и варианты проектирования в настоящее время вызывают все больший интерес. Если вы решитесь не откладывая опробовать такие приемы в вашем коде на Java, то рекомендую вам почитать материалы, на которые я поставил ссылки в этой статье, и дать OSGi новый импульс. Предупреждаю: действительно, будет непросто. Но освоить OSGi реально, а вот добиться истинной модульности куда сложнее.
Релоцировались? Теперь вы можете комментировать без верификации аккаунта.