Позволю себе привести свой перевод статьи Modular Java: What Is It?. Это мой первый более-менее крупный перевод, поэтому иногда наблюдаются отступления от канонического текста, но ногами все равно прошу не пинать.
В последние несколько лет модульность в Java является активно обсуждаемой темой. От (уже утратившего силу) JSR 277 через принятие JSR 291 и продолжаясь в JSR 294 модульность видится как необходимый этап в эволюции Java. Даже новые, основанные на Java языки (такие, как Scala), учитывают модульность. Данная статья, первая в серии о модульной Java, объясняет, что значит модульность и почему об этом нужно беспокоиться.
Что такое модульность?
Модульность - это базовая концепция, используемая в разработке ПО, допускающая создание индивидуальных модулей часто со стандартизированными интерфейсами, обеспечивающими их взаимодействие. Фактически, разделение ответственности между объектами в ОО-языке - это, в основном, такая же концепция, как и модульность, только в другом масштабе. Естественно, разделение системы на модули помогает минимизировать связанность (coupling), что должно позволить легче поддерживать код.
Язык Java не разрабатывался с учетом поддержки модулей (кроме packages, которые похожи на модули Modula-3), но де-факто много модулей представлено Java-сообществом. Естественно, что как open-source, так и closed-source приложения будут иметь одну или несколько зависимостей от внешних библиотек, а через них, транзитивно, и от других модулей.
Библиотеки - это тоже модули
Библиотеки неявно являются модулями. Они могут не всегда иметь единый интерфейс для связи, но часто будут иметь как публичный API (который должен использоваться), так и скрытые packages, имеющие документированные сценарии использования. Кроме того, они сами имеют зависимости (такие как JMX или JMS). Это может происходить, когда автоматические менеджеры зависимостей внедряют их больше, чем необходимо. В случае Log4J-1.2.15 внедряются более 10 зависимостей (включая javax.mail и javax.jms), хотя многие из них не нужны программам, использующим log4j.
В некоторых случаях зависимости модуля могут быть опциональными, т.е. модуль с отсутствующими зависимостями может предоставить только часть функциональности. Пример к вышесказанному: если JMS не представлен в classpath, логирование через JMS не будет доступно, но остальные механизмы - будут. В Java это достигается через использование позднего связывания, не требуется, чтобы класс присутствовал до тех пор, пока к нему не обращаются. Обращение к отсутствующей зависимости вызовет ClassNotFoundException. Другие платформы имеют понятие слабого связывания (Weak Linking), которое делает большинство тех же проверок среды исполнения.
Обычно модули имеют номер версии. Много проектов с открытыми исходниками выпускают релизы, которые именуются аналогично log4j-1.2.15.jar. Это позволяет разработчикам определять, какая конкретная версия библиотеки используется. Тем не менее, программа вероятно скомпилируется и с другой версией библиотеки, предполагается, что поведение программы скомпилированной с помощью log4j-1.2.3.jar и запущенной c log4j-1.2.15 будет одинаковым. Даже обновление следующей минорной версии обычно сохраняет совместимость (проблемы с log4j-1.3, которые происходят в новом бранче 2.0, вызваны отказом от совместимости). Все это обычно основано на том, что соглашения предпочтительнее, чем ограничения, присутствующие во время исполнения.
Когда модульность полезна?
Модульность полезна в качестве общей концепции разбиения приложения на части, которые могут тестироваться и развиваться независимо. Как отмечено выше, многие библиотеки - модули, во всяком случае в том смысле, что создаются для использования другими. Модульность - очень важная для понимания концепция. Обычно информация о зависимости заключена в утилитах сборки (мавеновский pom или ivy-модуль) и явно документирована в инструкции об использовании библиотеки.
Если библиотеку собирают для использования другими, то она уже полноценный модуль. Даже несколько "Hello World" библиотек являются "Hello World" модулями. Как только приложение становиться достаточно большим, в игру вступает концепция его логического разбиения на части.
Первый аспект выгоды от модульности - тестирование. Маленький модуль (с правильно определенным API) может быть протестирован лучше, чем монолитное приложение. Это особенно верно для GUI приложений, в которых сам GUI нельзя легко протестировать, а вот код, который он использует - можно.
Другим аспектом является развитие. Даже если система в целом будет иметь номер версии, в реальности это - объединение нескольких модулей со своими версиями. В результате, каждый модуль имеет свободу развиваться по тому пути, который для него подходит. Некоторые модули могут развиваться быстрее других, а некоторые могут находиться в достаточно стабильном состоянии на протяжении длительного времени (например, в Eclipse 3.5 есть бандл org.eclipse.core.boot, который не изменялся с февраля 2008 года).
Управление проектом также может получить выгоду от модульности. Факт, что к выпуску модуль будет снабжен опубликованным API, на которое можно подписаться, что делает возможным разработку разных модулей разными командами. Это неизбежно происходит, во всяком случае - в больших проектах. Подкоманды могут быть назначены ответственными за выпуск отдельных модулей.
Наконец, разбиение приложения на модули может помочь определиться с выбором необходимых версий библиотек, что особенно существенно для большого проекта.
Время исполнения против времени компиляции
Java обычно имеет плоский classpath во время как компиляции, так и исполнения. Другими словами, приложение видит все классы, присутствующие в classpath, не взирая на их порядок. Это делает возможными механизм динамического связывания в Java; класс, загруженный из начала classpath, не требует разрешения всех связей с классами, которые могут присутствовать в конце classpath, пока те действительно не потребуются.
Данная особенность часто используется при работе с множеством интерфейсов, реализации которых не известны до запуска программы. Например, SQL-утилиты могут быть скомпилированы с общим пакетом JDBC, но во время исполнения (используя дополнительную информацию о конфигурации) может инстанцироваться корректный JDBC драйвер. Это обычно достигается посредством имени класса (который реализует предопределенный интерфейс или абстрактный класс) с помощью вызова Class.forName. Если нужный класс не присутствует (или не может быть загружен по иным причинам), генерируется ошибка.
Это является причиной того, что classpath времени компиляции модуля отличается от classpath времени исполнения. Так же, каждый модуль обычно компилируется в изоляции (модуль А может быть скомпилирован с модулем C 1.1, а модуль В - с модулем С 1.2), но во время исполнения они объединяются в единый classpath (в данном случае произвольно выберется версия 1.1 или 1.2 модуля С). Это быстро приводит к Dependency Hell, особенно при наличии транзитивных замыканий зависимостей, которые формируют classpath времени исполнения. Системы сборки, такие как Maven или Ivy делают модульность видимой для разработчиков, но не для конечных пользователей.
Java имеет не оцененную по достоинству возможность - ClassLoader'ы, которые позволяют classpath'у времени исполнения быть более сегментированным. Обычно все классы загружаются системным ClassLoader'ом, но тем не менее некоторые системы разделяют свой classpath на части с помощью отдельных ClassLoader'ов. Хороший пример - Tomcat (или другой сервлет-контейнер), который обычно имеет один ClassLoader на каждое веб-приложение. Это позволяет веб-приложению работать нормально, но не видеть классы, определенные в другом веб-приложении, запущенном на той же JVM.
Путь по которому это все работает: каждое веб-приложение загружает классы с помощью своего ClassLoader, таким образом оно не загружает классы, которые конфликтуют с другими веб-приложениями. Для всех цепочек ClassLoader'ов требуется, чтобы пространства классов были изолированы, это значит, что вы можете иметь два Util.class, загруженные двумя разными ClassLoader'ами в вашу VM одновременно. Классы, загруженные одним ClassLoader'ом, не будут видны для классов, загруженных другим. (Это так же позволяет сервлет-контейнеру применять изменения без перезапуска, переставая использовать ClassLoader, вы бросаете все ссылки на его классы, тем самым делая их доступными для сборщика мусора. Сервлет-контейнер же создает новый ClassLoader, который загружает новые версии классов во время исполнения).
Модульность сверху вниз
Построение модульной системы - это действенный путь разделения приложения на (потенциально) повторно-используемые модули и минимизации зацепления (coupling) между ними. Это так же путь к декомпозиции модульных требований, например Eclipse IDE обычно имеет плагины, которые имеют отдельные зависимости от GUI и не-GUI компонентов (например, jdt.ui и jdt.core). Это допускает иное использование не-GUI модуля (автономная сборка, парсинг, проверка ошибок и т.д.) вне IDE-среды.
За исключением монолитного rt.jar, любая система обычно может быть разделена на разные модули. Возникает вопрос: в чем ценность такого подхода. С другой стороны, гораздо проще начать сразу строить модульную систему, чем разбивать на модули готовую монолитную.
Одна из причин того, что иногда случается утечка классов через границы модулей. Например, логично, что пакет java.beans не должен иметь никаких зависимостей от любого GUI-кода, но тем не менее java.beans.AppletInitializer, используемый в Beans.instantiate(), содержит ссылки на класс Applet, который конечно же прямо зависит от AWT. Так java.beans технически имеет опциональную зависимость от AWT, хотя здравый смысл подсказывает, что так быть не должно. Если бы модульность использовалась при построении библиотек уровня ядра Java, данная ошибка была бы отловлена гораздо раньше, чем было бы опубликовано API.
Существует точка зрения, что модули не могут быть в дальнейшем разбиты на подмодули. Как бы там ни было, связанные функции обычно держат внутри модуля для упрощения организации и только при необходимости производят декомпозицию. Например, поддержка рефакторинга, изначальная часть Eclipse JDT, была вынесена из этого модуля, чтобы сделать базовые возможности рефакторинга доступными и другим языкам (например в CDT).
Плагины
Многие системы являются расширяемыми с помощью использования концепции плагинов. В данном случае базовая система имеет определенный API, которому плагин должен подчиняться, и способ подключить этот плагин. Много приложений (таких, как веб-броузеры, IDE и утилиты сборки) предлагают способ кастомизации себя посредством плагинов, для которых предоставляют корректный API.
Иногда эти плагины ограничивают или исполняют общие операции (декодирование аудио или видео, например), но одинаково хорошо могут быть и сложным дополнением (например к IDE). Иногда они могут предоставлять свои собственные плагины, предназначенные для кастомизации своего поведения, что делает систему еще более настраиваемой (увеличение количества уровней косвенности, тем не менее может сделать систему слишком тяжелой для понимания).
API плагинов формируется как часть соглашения, которого каждый индивидуальный плагин должен придерживаться. Такие плагины - самостоятельные модули, которые используют цепочки зависимостей и номера версий, предоставляемые системой. С развитием сложности API, плагин тоже должен усложняться (или должна быть обеспечена обратная совместимость).
Одной из причин успеха Netscape plugin API для браузеров была ее простота: было необходимо только небольшое количество функций. Браузер обеспечивал передачу введенной информации в зависимости от MIME-типа плагину, который мог ее обработать. Но более сложные приложения, такие как IDE, обычно нуждаются в более сложной интеграции модулей и, соответственно, в более сложном API для них предоставляемом.
Текущее положение с модульностью в Java
В Java сейчас присутствует много модульных систем и плагинных инфраструктур. Наиболее известны - IDE: IntelliJ IDEA, NetBeans, Eclipse, все они предлагают свою плагинную систему для расширения возможностей. Так же системы сборки (Ant, Maven) и даже приложения для конечного пользователя (Lotus Notes, Mac AppleScript-приложения) содержат концепции, позволяющие расширять их исходную фкнкциональность.
Вероятно, наиболее зрелой модульной системой для Java является OSGi, которая существует примерно столько же, сколько и Java (первые появилась как JSR 8, но более известна, как JSR 291). OSGi определяет дополнительную метаинформацию для JAR-овского MANIFEST.MF, содержащую сведения о требуемых зависимостях на уровне пакетов. Это позволяет модулям проверять (во время исполнения), что все их зависимости разрешены и в добавок позволяет каждому модулю иметь свой собственный приватный classpath (создается с помощью одного ClassLoader'а на каждый модуль). Это помогает, но не полностью избавляет от dependency hell, описанного раньше. Как и JDBC, OSGi - это спецификация (текущая версия - 4.2), которая имеет многочисленные open-source (и коммерческие) реализации. Так как модули не нуждаются в зависимостях от любого специфичного OSGi-кода, многие open-source библиотеки сегодня включают свою мета-информацию в файл манифеста для использования в OSGi. Для тех, кто этого не делает, существуют утилиты, такие, как bnd, которые могут обработать существующий JAR-файл и сгенерировать разумные начальные значения. Eclipse 3.0 перешел на OSGi в 2004-м году со своей собственной плагинной системы, многие другие системы, имеющие проприентарное ядро (JBoss, WebSphere, Weblogic) находятся в похожей ситуации и строят свой рантайм на базе OSGi.
Недавно, в порядке модулизации JDK, был создан проект jigsaw. Хотя внутренняя часть JDK не будет поддерживаться в других SE 7 реализациях, использование jigsaw вне JDK не запрещается. Jigsaw так же может быть реализацией вышеупомянутого JSR 294, хотя работа над этим ведется незаметно. Требование к минимальной версии SE 7 (в паре с тем фактом, что это в настоящий момент не Java 7) обозначает, что jigsaw еще в процессе разработки и не подходит для систем, запускаемых на Java 6 и ранних версиях Java.
К принятию стандартного формата модульности, экспертная группа по JSR 294 обсуждает предложение простой модульной системы. Каждый из производителей Java-библиотек (таких, которые могут быть найдены в Maven-репозитории и на сайтах вроде apache.org) сможет представить метаинформацию, которая будет доступна как и для jigsaw, так и для OSGi систем. После незначительных изменений в самом языке Java (наиболее существенно добавление ключевых слов для модулей), эта информация сможет быть сгенерирована во время компиляции. Системы времени исполнения (такие, как jigsaw и OSGi) смогут использовать эту информацию для валидации множества установленных модулей и их зависимостей.
Заключение
В данной статье рассмотрены общие концепции модульности и то, как они достигаются в Java-системах. Часто пути времени компиляции и времени исполнения могут быть различны, что делает возможным иметь несовместимые библиотеки, ведущие к dependency hell. Тем не менее, использование API плагинов позволяет многим разновидностям кода загружаться, а за разрешением зависимостей должна следить базовая система. Для этого многие системы модульности времени исполнения, такие как OSGi способны проверять множество требований в строго определенное время - при запуске приложения, вместо того, чтобы проигнорировать ошибку или получить неопределенное поведение системы во время ее работы.
Наконец, в списке рассылки JSR 294 продолжается работа по созданию модульной системы для языка Java, которая полностью определит в Java Language Specification порядок, позволяющий java-разработчикам генерировать версионируемые модули, содержащие информацию о зависимостях, которые смогут в последствии работать на любых модульных системах.
З.Ы. Все указания на ошибки и неоднозначные места приветствуются.
З.Ы.Ы. Возгласы вроде "Паша, не переводи больше" будут пропущены мимо ушей. Я вас тоже люблю.
Понравилось сообщение - подпишитесь на блог или читайте меня в twitter
15 комментариев:
Интересная статья.
Придирка: 'common sense' это не "общее чувство", а "здравый смысл".
Т.е. вместо "при общем чувстве, что так быть не должно" лучше написать "хотя здравый смысл подсказывает, что так быть не должно".
Большое спасибо за замечание. Поправил статью.
"Паша, переводи больше" ;)
Такое количество букв большинству легче прочитать на родном языке. Спасибо
На самом деле на родном языке букв получилось гораздо больше. А вообще статья довольно "водянистая" как мне кажется, но понял я это когда половину уже перевел, а бросать дело было жалко.
Не водянистая, хорошая. Просто некоторые пищу водой запивают, чтоб удобней проглатывать.
Последнее время практически всё переводное читаю - переводят ведь только то что действительно заслуживает внимания - must read
Совершенно непонятно, каким таким волшебным образом модульность помогает разрешить dependency hell. Суть модульности - сокрытие сложности путём минимизации зависимостей между частями системы. Или я что-то не понимаю?
А с версионингом классов видятся неиллюзорные проблемы:
1) многочисленные дубликаты кода (одна и та же библиотека, по сути, загружается множество раз)
2) поведение системы, использующей множество реализация одного и того же класса непредсказуемо
3) Проблема ClassCastException при передаче ссылок на "один и тот же" класс между модулями.
Суть модульности вы понимаете правильно. Но чем меньше связей между частями системы, тем меньше и влияние зависимостей этих частей.
Конкретно с Dependency Hell позволяют бороться конкретные механизмы обеспечения модульности, в частности - OSGi.
По поводу описанных вами проблем:
1. Дубликаты кода могут присутствовать в памяти, но не многочисленные и не всегда. Например у нас есть бандл, предоставляющий log4j. Другие бандлы будут иметь доступ к этому log4j, но только если мы это разрешим в манифесте. Если не разрешим - тогда да, им придется загружать свой log4j. Точно так же, например, с Hibernate. Один бандл предоставляет Hibernate и полностью контролирует его использование. Это разительно отличается от простого нагромождения jar-ов в classpath.
2. Если реализации класса одинаковы, то в чем непредсказуемость?
3. Не стоит передавать класс, загруженный разными лоадерами, между модулями. В статье описан пример Tomcat, класс для каждого веб-приложения грузится своим загрузчиком, но передавать классы между приложениями нет необходимости (мне трудно придумать такой UseCase). В случае использования OSGi все разделяемые зависимости загружаются одним лоадером, который контролирует доступ к ним (права доступа прописываются в манифестах бандлов).
Насчёт модульности и dependency hell - наверное это вопрос терминологии. вопрос снимается.
Комментарии на комментарии.
1. Под дубликатами я понимаю log4j v1, log4j v2, log4j v3 - разничные версии одних и тех же библиотек, которые используют эти бандлы в целях разрешения "dependency hell"
Возможен и другой вариант - бандлы А и Б поставляют внутри себя foo.jar. Естественно, каждый класс лоадер будет использовать свою версию этого джара -> 2 копии каждого класса в памяти. + проблема 3 (с ClassCastException) - достаточно объявить в сервисе функцию, которая принимает или возвращает объект класса, расположенного в таком вложенном jar файле.
2. В том то и дело, что с разными версиями библиотек одни и те же классы (различающиеся лишь версиями) ведут себя по-разному. Жить с этим можно (если не приводит к проблеме ClassCastException), но энтропия системы неизбежно возрастает.
"old plain classpath", к слову, работает безукоризненно (при отсутствии предпосылок к dependency hell). Важны лишь зависимости на этапе компиляции (логические зависимости между частями программы). То, что всё лежит в одной куче на рантайме - это не проблема абсолютно. Как и компилятор съест код любой синтаксически корректный код любого качества, так и JVM разберётся в этой куче. При этом гарантируя (при соблюдении требований к "каноническому" класслоадеру и их иерархии) отсутствие дубликатов классов.
Как-то так.
Наверное мы в какой-то мере друг друга не понимаем.
Описанная вами ситуация (og4j v1, log4j v2, log4j v3 - разничные версии одних и тех же библиотек) это и есть Dependency Hell, по крайней мере с точки зрения автора статьи (как я его понял). Суть в том, что если вы положите эти библиотеки в плоский classpath, то системный лоадер вам загрузит только одну из них и не всегда можно сказать какую именно, а разные версии могут вести себя по-разному.
Суть разделения на бандлы еще и в том, что внутри одного бандла мы используем только одну версию библиотеки. Если несколько бандлов используют одну и ту же версию библиотеки - мы расшариваем ее между бандлами, если какой-то другой бандл использует свою версию - мы не даем ему доступа к другим.
Две копии класса в памяти - не проблема. Зачастую данные занимают гораздо больше места, чем классы. Но, повторюсь, такая ситуация очень редка. Одинаковые версии библиотек можно расшаривать между бандлами.
Мы разрабатываем довольно большое (10к тысяч классов есть точно, не считая библиотек) приложение с помощью OSGi и описанной вами проблемы с ClassCastException ниразу не было. Продуманная архитектура приложения и грамотное разбиение на бандлы предотвращают эту проблему.
Проблемы, перечисленные выше, конечно же, могут вообще остаться незамеченными.
+- 10-100 мегабайт памяти для приложений в не embedded-системах не так важны, а передача данных между бандлами ограничена довольно узким диапазоном типов. Я просто к чему - это решение всё равно не есть идеальное. Оно уменьшает вероятность возникновения dependency hell, но привносит свои "прелести".
Сам же dependency hell на своей памяти я встречал ровно один раз - и то из-за того, что какие-то неразумные создатели библиотеки версию 3.х сделали не backward-compatible с веткой 2.x. Решилось всё обновлением клиентов этой библиотеки.
С "неплоским" classpath видеть ClassCastException доводилось куда чаще (в JBoss и подобных серверах приложений иерархия класс-лоадеров нарушает канонические требования к класс-лоадерам).
Получается просто, что где-то приобретаем, а где-то теряем.
Отличная тема для холиваров. А посему заканчиваю.
Цикл статей про OSGi, кстати, весьма неплох.
Дмитрий
Спасибо за отзыв на мои статьи про OSGi, да и вообще пообщаться было интересно.
Тема действительно холиварна, поэтому продолжать тоже не буду. Замечу только, что в статье и не говорилось, что OSGi и раздельные лоадеры - идеальное решение. Там даже замечено, что полностью Dependency Hell это не решает.
В инженерной практике всегда приходиться выбирать между злом и еще большим злом, это нормально :))
Я встречал DH при обновлении xfire с 1.2.4 до 1.2.6, когда по ошибке старый jar не был удален - Java загружала часть классов из одного jar, а часть - из другого. Было весело :)
Как всё прекрасно разрекламировано в мире Java! )))
А потом пытаешься что-то реализовать на Java, даже самое простое "Hello World!", и понимаешь сколько огорода нагорожено вокруг простых вещей )))
Вот и, прочтя данную статью, понятно, что Java никак не может быть простой, а значит и надёжной системой. Слишком много "костылей" у неё в виде OSGi, Maven, ant и пр.
А модульности в Java не может быть в принципе, только "пародия" на модульность.
Потому что модульность определяется языком программирования, а не мудрёным множеством "программных технологий" прилепляемых к гибридным языкам вроде Java, C++, С# и другим по сути немодульным языкам объектной и параллельной направленности :)))
P.S. Да простят меня приверженцы фигурно-скобочных языков
По поводу скобок почитайте лучше вот это http://www.codesqueeze.com/the-ultimate-top-25-chuck-norris-the-programmer-jokes/
Спасибо за перевод, прочитал и большим интересом =) Хотя местами конкретики и не хватало.
Отличная статья! спасибо!
Отправить комментарий
Любой Ваш комментарий важен для меня, однако, помните, что действует предмодерация. Давайте уважать друг друга!