Применение One-JAR для упрощения рапространения вашего приложения

Гибкое программирование с использованием специализированных загрузчиков классов (classloaders)

В этой статье Simon Tuffs представит вам One-JAR - программу, использующую специализированный загрузчик классов для динамической загрузки классов из JAR-файлов в исполняемый JAR-файл.

П. Саймон Таффс (P. Simon Tuffs), Независимый консультант, Indepentent

Author photoДоктор П. Саймон Таффс (P. Simon Tuffs) является независимым консультантом, в данный момент специализирующимся на масштабировании Java Web Services. В свое свободное время он создает и выпускает проекты с открытым исходным кодом, такие как One-JAR. Для получения дополнительной информации о нем и его работах посетите сайт www.simontuffs.com.



23.11.2004

Кто-то говорил, что история повторяется дважды: первый раз как трагедия, второй раз как фарс. Недавно я столкнулся с первой ситуацией, когда должен был установить исполняемое Java-приложение клиенту. Я делал это много раз и каждая установка была сопряжена с трудностями. Ошибки могут существовать повсюду: при сборке всех JAR-файлов приложения, написании сценариев запуска для DOS и Unix (и Cygwin), при проверке корректности установки всех переменных окружения на компьютере пользователя. Если все проходит гладко, приложение запускается и выполняется должным образом. Если что-то идет не так, что обычно и происходит, - то в результате требуется многочасовая работа непосредственно у заказчика.

После последних переговоров с возмущенным клиентом по поводу возникновения исключительных ситуаций ClassNotFound я решил, что с меня хватит. Я собрался найти способ упаковки моего приложения в один JAR-файл и предоставить моим клиентам простой механизм (аналогичный java -jar) для его запуска.

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

Обзор One-JAR

Перед описанием деталей One-JAR позвольте мне сначала рассказать о целях, которые я перед собой ставил. Я решил, что архив One-JAR должен:

  • Быть исполняемым с использованием механизма java -jar.
  • Быть способным содержать все необходимые приложению файлы, то есть, и классы и ресурсы в их оригинальной форме (в не распакованном виде).
  • Иметь простую внутреннюю структуру, которая может быть собрана при помощи только программы jar.
  • Быть невидимым для оригинального приложения, то есть, оригинальное приложение должно упаковываться в архив One-JAR без необходимости модификации.

Проблемы и решения

Самой большой проблемой, с которой я столкнулся в процессе разработки One-JAR, была проблема загрузки JAR-файлов, содержащихся внутри другого JAR-файла. Загрузчик классов Java sun.misc.Launcher$AppClassLoader, который работает при старте java -jar, умеет делать только две вещи:

  • Загружать классы и ресурсы, находящиеся в корне JAR-файла.
  • Загружать классы и ресурсы, находящиеся в базах кода (codebase), на которые ссылается атрибут META-INF/MANIFEST.MFClass-Path.

Более того, он сознательно игнорирует любые настройки переменных окружения для CLASSPATH или аргумент командной строки –cp, который вы указываете. Он также не знает, как загружать классы и ресурсы из JAR-файла, содержащегося внутри другого JAR-файла.

Понятно что я должен был предусмотреть все это для достижения моих целей в One-JAR.

Решение 1: Распространение вспомогательных JAR-файлов

Моей первой попыткой в создании одного исполняемого JAR-файла было сделать очевидное и разместить вспомогательные JAR-файлы внутри предназначенного для распространения JAR-файла, который мы будем называть main.jar. При наличии класса приложения с названием com.main.Main и в предположении, что он зависит от двух классов com.a.A (внутри a.jar) и com.b.B (внутри b.jar), файл One-JAR мог бы выглядеть следующим образом:

    main.jar
    |  com/main/Main.class
    |  com/a/A.class
    |  com/b/B.class

Информация о том, что класс A.class первоначально находился в a.jar, а также о первоначальном месторасположении класса B.class утрачена. И хотя это кажется мелочью, могут возникнуть серьезные проблемы, о которых я вскоре расскажу.

One-JAR и FJEP

Недавно выпущенная программа под названием FJEP (FatJar Eclipse Plugin) поддерживает построение плоских JAR-файлов непосредственно внутри Eclipse. One-JAR был интегрирован с FatJar для поддержки внедрения JAR-файлов без их размещения. Более подробную информацию вы найдете в разделе "Ресурсы".

Распаковка вспомогательных JAR-файлов в файловой системе для создания плоской структуры может занимать довольно много времени. Это требует также работы с программами компоновки, такими как Ant, для распаковки и повторной архивации вспомогательных классов.

Помимо этих маленьких недоразумений я быстро столкнулся с двумя серьезными проблемами при размещении внутри архива вспомогательных JAR-файлов:

  • Если a.jar и b.jar содержат ресурс с одним и тем же pathname (скажем, log4j.properties), какой именно вы выберете?
  • Что вы сделаете, если лицензия для b.jar четко требует от вас распространения его в не модифицированном виде? Вы не можете распространять его в таком виде без нарушения условий лицензии.

Я почувствовал, что эти ограничения требуют использования другого подхода.

Решение 2: MANIFEST Class-Path

Я решил исследовать механизм в загрузчике java –jar, который загружает классы, указанные внутри специального файла в архиве под названием META-INF/MANIFEST.MF. Указывая свойство Class-Path, я надеялся получить способность добавлять другие архивы в оригинальный загрузчик классов. Вот как мог бы выглядеть такой файл One-JAR:

    main.jar
    |  META-INF/MANIFEST.MF
    |  +  Class-Path: lib/a.jar lib/b.jar
    |  com/main/Main.class
    |  lib/a.jar
    |  lib/b.jar

Комментарий и решение

URLClassloader является базовым классом sun.misc.Launcher$AppClassLoader, поддерживающим довольно загадочный синтаксис URL, который позволяет обращаться к ресурсам внутри JAR-файла. Синтаксис выглядит примерно так: jar:file:/fullpath/main.jar!/a.resource.

Теоретически, для получения записи внутри JAR-файла вы должны использовать что-то похожее на jar:file:/fullpath/main.jar!/lib/a.jar!/a.resource, но, к сожалению, это не работает. Обработчик протокола JAR-файла рассматривает только последний символ-разделитель "!/" в качестве указателя JAR-файла.

Но этот синтаксис содержит ключ к моему окончательному решению "One-JAR"...

Работало ли это? Во всяком случае, так казалось до тех пор, пока я не переместил файл main.jar в другое место и не попробовал его запустить. Для сборки main.jar я создавал подкаталог с названием lib и помещал в него файлы a.jar и b.jar. К несчастью, загрузчик классов приложения просто выбирал вспомогательные JAR-файлы из файловой системы. Он не загружал классы из внедренных JAR-файлов.

Для преодоления этой ситуации я попробовал использовать Class-Path в нескольких вариантах с довольно загадочным синтаксисом jar:!/ (см. "Комментарий и решение"), но я ничего не мог заставить работать. Чего я смог добиться - это распространения файлов a.jar и b.jar по отдельности и размещения их в файловой системе возле main.jar; но это было именно тем, чего я стремился избежать.


Появление JarClassLoader

На этом этапе меня постигло разочарование. Как я мог заставить приложение загружать свои классы из каталога lib внутри своего собственного JAR-файла? Я решил, что должен создать специальный загрузчик классов для поднятия таких тяжестей. Написание загрузчика классов - это не та задача, которую можно воспринимать несерьезно. Хотя они не так уж и сложны на самом деле, загрузчики классов имеют настолько глубокое влияние на управляемые ими приложения, что становится трудным диагностировать и интерпретировать возникающие отказы систем. Хотя подробное рассмотрение процесса загрузки классов выходит за рамки данной статьи (см. раздел "Ресурсы"), я расскажу об основных концепциях, чтобы быть уверенным в том, что вы получите максимальное количество информации из последующего обсуждения.

Загрузка класса

Когда JVM встречает объект, чей класс неизвестен, она вызывает загрузчик классов. Работа загрузчика классов заключается в поиске байткодов для класса (основываясь на его названии) и затем в предоставлении этих байткодов в JVM, которая связывает их с остальной системой и делает новый класс доступным выполняющемуся коду. Ключевым классом в JDK является java.lang.Classloader и его метод loadClass, структура которого приводится ниже:

    public abstract class ClassLoader {
        ...
        protected synchronized Class loadClass
        (String name, boolean resolve)
            throws ClassNotFoundException {...}
    }

Главной точкой входа в класс ClassLoader является метод loadClass(). Заметьте, что ClassLoader является абстрактным классом, но не объявляет ни одного абстрактного метода. Это наталкивает на мысль, что loadClass() является главным методом для рассмотрения. На самом деле он таковым не является: возвращаясь к старым добрым дням загрузчиков классов JDK 1.1, loadClass() был единственным местом, где вы могли эффективно расширить загрузчик классов, но после JDK 1.2 его лучше оставить в покое и не мешать делать то, для чего он предназначен, а именно:

  • Проверки того, загружен ли уже класс.
  • Проверки того, может ли родительский загрузчик классов загрузить класс.
  • Вызова findClass(String name) для разрешения производному загрузчику классов загрузить класс.

Реализация ClassLoader.findClass() должна генерировать новую исключительную ситуацию ClassNotFoundException, являясь первым методом, на котором следует остановить внимание при реализации специализированного загрузчика классов.

Когда JAR-файл не является JAR-файлом?

Для того чтобы иметь способность загружать классы, находящиеся внутри JAR-файла, который сам расположен внутри JAR-файла (главный вопрос, как вы помните), я прежде всего должен был уметь открывать и читать корневой JAR-файл (main.jar из рассмотренного выше примера). Я исключил это, поскольку использовал механизм java -jar первым (и единственым) элементом в системном свойстве java.class.path был полный путь к файлу One-JAR! Вы можете получить его следующим образом:

    jarName = System.getProperty("java.class.path");

Следующим моим действием была итерация по всем записям JAR-файла моего приложения и загрузка их в память, как показано в листинге 1:

Листинг 1. Итерация для поиска внедренных JAR-файлов
    JarFile jarFile = new JarFile(jarName);
    Enumeration enum = jarFile.entries();
    while (enum.hasMoreElements()) {
        JarEntry entry = (JarEntry)enum.nextElement();
        if (entry.isDirectory()) continue;
        String jar = entry.getName();
        if (jar.startsWith(LIB_PREFIX)
         || jar.startsWith(MAIN_PREFIX)) {
            // Загружаем его! 
            InputStream is = jarFile.getInputStream(entry);
            if (is == null) 
                throw new IOException
                ("Unable to load resource /"
                 + jar + " using " + this);
            loadByteCode(is, jar);	
            ...

Обратите внимание, что LIB_PREFIX устанавливается в строку lib/, а MAIN_PREFIX - в строку main/. Я хотел в цикле загружать в память для использования байткоды всех классов, начинающихся с lib/ или с main/, и игнорировать любые другие элементы JAR-файла.

Каталог main

Я говорил о роли подкаталога lib/, но для чего нужен этот каталог main/? Вкратце: режим делегирования функций в загрузчиках классов требует размещения основного класса com.main.Main в своем собственном JAR-файле, для того чтобы он мог найти библиотечные классы (от которых зависит). Новый JAR-файл выглядел примерно так:

	one-jar.jar
	|  META-INF/MANIFEST.MF
	|  main/main.jar
	|  lib/a.jar
	|  lib/b.jar

В приведенном выше листинге 1 метод loadByteCode() получает поток из элемента JAR-файла, название элемента, загружает байты элемента в память и присваивает им до двух названий, в зависимости от того, представляет элемент класс или ресурс. Лучше всего проиллюстрировать это на примере. Предположим, что a.jar содержит класс A.class и ресурс A.resource. Загрузчик классов One-JAR создает следующую структуру Map с названием JarClassLoader.byteCode, содержащую одну пару ключ-значение для классов и два ключа для ресурса:

Рисунок 1. Внутренняя структура One-JAR
Внутренняя структура One-JAR

Если вы внимательно посмотрите на рисунок 1, то увидите, что элементы классов обозначаются в зависимости от названий класса, а элементы ресурсов обозначаются в зависимости от пары имен: глобального имени и локального имени. Этот механизм используется для разрешения конфликта имен ресурсов: если два библиотечных JAR-файла определяют ресурс с одинаковым глобальным именем, будут использоваться локальные имена на основе фрейма стека вызывающей процедуры. Для дополнительной информации смотрите раздел "Ресурсы".

Поиск классов

Вспомните, что я оставил без внимания в обзоре загрузки классов в методе findClass(). Метод findClass() получает название класса как тип String и должен найти и определить байткоды, которые представляет это название. Поскольку loadByteCode любезно создает Map между названием класса и его байткодом, реализовать это теперь очень просто: нужно найти байткоды, основываясь на названии класса, и вызвать defineClass(), как показано в листинге 2:

Листинг 2. Фрагмент findClass()
    protected Class findClass(String name) 
    throws ClassNotFoundException {
        ByteCode bytecode = (ByteCode)
        JarClassLoader.byteCode.get(name);
        if (bytecode != null) {
            ...
            byte bytes[] = bytecode.bytes;
            return defineClass(name, bytes, pd);
        }
        throw new ClassNotFoundException(name);
    }

Загрузка ресурсов

Во время разработки One-JAR findClass был первой вещью, которая начала работать как доказательство верности концепции. Но когда я начал распространять более сложные приложения, я понял, что должен иметь дело с загрузкой ресурсов, так же как и классов. Вот там работа начала пробуксовывать. Определяя в ClassLoader подходящий для переопределения метод для задачи поиска ресурсов, я выбрал один, с которым больше всего был знаком. Посмотрите листинг 3:

Листинг 3. Метод getResourceAsStream()
    public InputStream getResourceAsStream(String name) {
        URL url = getResource(name);
        try {
            return url != null ? url.openStream() : null;
        } catch (IOException e) {
            return null;
        }
    }

Звоночек в этом месте должен был прозвенеть обязательно: я просто не мог понять, почему для поиска ресурсов использовался URL. Итак, я проигнорировал эту реализацию и вставил свою собственную, показанную в листинге 4:

Листинг 4. Реализация метода getResourceAsStream() в One-JAR
    public InputStream getResourceAsStream(String resource) {
        byte bytes[] = null;
        ByteCode bytecode = (ByteCode)byteCode.get(resource);
        if (bytecode != null) {
            bytes = bytecode.bytes; 
        }
        ...
        if (bytes != null) {
            return new ByteArrayInputStream(bytes);
        }
        ...
        return null;
    }

Последняя преграда

Моя новая реализация метода getResourceAsStream(), казалось, делала все как надо, до тех пор пока я не попробовал в One-JAR приложение, которое загружало ресурс при помощи URL url = object.getClass().getClassLoader().getResource(); здесь приложение потерпело неудачу. Почему? Потому что возвращаемый реализацией по умолчанию ClassLoader URL, был равен null, что ломало код вызывающей процедуры.

С этого момента все запуталось. Я должен был разгадать, какой URL должен использоваться для ссылки на ресурс внутри JAR-файла в каталоге /lib. Должен ли он быть, например, jar:file:main.jar!lib/a.jar!com.a.A.resource?

Я попробовал все комбинации, которые только мог представить, - ни одна не работала. Синтаксис jar: просто не поддерживает вложенные JAR-файлы, что поставило меня перед явно безвыходной ситуацией в моем подходе к One-JAR. Хотя большинство приложений, по-видимому, не используют ClassLoader.getResource, некоторые определенно это делают, и я совсем не был осчастливлен исключением, говорящим "Если ваше приложение использует ClassLoader.getResource() вы не можете применять One-JAR".

И, наконец, решение ...!

Пытаясь разгадать синтаксис jar:, я споткнулся о механизм, который использует Java Runtime Environment для отображения URL-префиксов в обработчики. Это был ключ, в котором я нуждался для решения проблемы в findResource: я просто изобрел свой собственный префикс протокола с названием onejar:. Затем я смог бы отобразить новый префикс в обработчик протокола, который возвратил бы поток байтов для ресурса, как показано в листинге 5. Обратите внимание на то, что листинг 5 представляет код из двух файлов, JarClassLoader и нового файла с названием com/simontuffs/onejar/Handler.java.

Листинг 5. findResource и протокол onejar:
com/simontuffs/onejar/JarClassLoader.java

    protected URL findResource(String $resource) {
        try {
            // resolve($resource) возвращает название ресурса в
            // byteCode Map если оно известно этому загрузчику классов.
            String resource = resolve($resource);
            if (resource != null) {
                // Мы знаем как справиться с этим.
                return new URL(Handler.PROTOCOL + ":" + resource); 
            }
            return null;
        } catch (MalformedURLException mux) {
            WARNING("unable to locate " + $resource + " due to " + mux);
        }
        return null;
    }

com/simontuffs/onejar/Handler.java

    package com.simontuffs.onejar;
    ...
    public class Handler extends URLStreamHandler {
        /**
         * Название этого протокола должно соответствовать названию пакета, 
         * в котором находится этот класс.
         */
        public static String PROTOCOL = "onejar";
        protected int len = PROTOCOL.length()+1;
        
        protected URLConnection openConnection(URL u) throws IOException {
            final String resource = u.toString().substring(len);
            return new URLConnection(u) {
                public void connect() {
                }
                public InputStream getInputStream() {
                    // Используем загрузчик классов Boot classloader
                     для получения ресурса.  
                    // На один one-jar – один загрузчик.
                    JarClassLoader cl = Boot.getClassLoader();
                    return cl.getByteStream(resource);
                }
            };
        }
}

Самозагрузка JarClassLoader

В данный момент у вас, наверное, остался единственный вопрос: как вставить JarClassLoader в последовательность загрузки так, чтобы он мог начать загружать классы из файла One-JAR первым? Детальное объяснение выходит за рамки данной статьи; но, суть в следующем. Вместо использования основного класса com.main.Main в качестве атрибута META-INF/MANIFEST.MF/Main-Class я создал новый класс начальной загрузки com.simontuffs.onejar.Boot, который указывается как атрибут Main-Class. Новый класс делает следующее:

  • Создает новый JarClassLoader.
  • Использует новый загрузчик для загрузки com.main.Main из main/main.jar (основываясь на элементе META-INF/MANIFEST.MF Main-Class в main.jar).
  • Вызывает com.main.Main.main(String[]) (или с другим названием Main-Class, указанным в файле main.jar/MANIFEST.MF), загружая класс и используя отображение для вызова main(). Указанные в командной строке One-JAR аргументы передаются методу main приложения без изменений.

В заключение

Если у вас от всего этого закружилась голова, не переживайте: использовать One-JAR намного легче, чем пытаться понять, как он работает. С появлением подключаемого модуля FatJar Eclipse Plugin (см. FJEP в разделе "Ресурсы"), пользователи Eclipse могут создавать One-JAR-приложение, отметив флажок в мастере. Зависимые библиотеки помещаются в каталог /lib, основная программа и классы помещаются в main/main.jar, а файл META-INF/MANIFEST.MF записывается автоматически. Если вы используете JarPlug (снова смотрите раздел "Ресурсы") вы можете посмотреть внутрь созданного JAR-файла и запустить его из IDE.

В общем, One-JAR представляет собой простое, но мощное решение проблемы пакетирования приложений для распространения. Однако он не подходит для всех возможных сценариев. Например, если ваше приложение использует загрузчик классов старого стиля JDK 1.1, который не делегирует управление своему родителю, такой загрузчик классов потерпит неудачу при поиске классов во вложенном JAR-файле. Вы можете преодолеть это препятствие, создав и развернув "оберточный" (wrapping) загрузчик классов для изменения поведения такого упорствующего загрузчика, хотя это повлечет за собой использование техники манипуляции байткодом при помощи таких программных средств как Javassist или Byte Code Engineering Library (BCEL).

Вы можете также столкнуться с проблемами при применении некоторых специализированных типов загрузчиков классов, используемых встраиваемыми приложениями и Web-серверами. В частности, вы можете иметь проблемы с загрузчиками классов, не сразу обращающимися к родительскому загрузчику, или теми, которые ищут базы кода в файловой системе. В этом случае должен помочь включенный в One-JAR механизм, при использовании которого можно расширить элементы JAR-файла на файловую систему. Этот механизм управляется атрибутом One-JAR-Expand файла META-INF/MANIFEST.MF. В качестве альтернативы вы могли бы попробовать использовать манипуляцию байткодом для модификации загрузчика классов на лету, во время работы, без нарушения целостности вспомогательных JAR-файлов. Если вы идете этим путем, для каждого индивидуального случая, возможно, потребуется специализированный "оберточный" загрузчик классов.

В разделе "Ресурсы" приведены ссылки для загрузки подключаемых модулей FatJar Eclipse Plugin и JarPlug, а также дополнительная информация о One-JAR.

Ресурсы

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=96693
ArticleTitle=Применение One-JAR для упрощения рапространения вашего приложения
publish-date=11232004