Как стать автором
Обновить

Автономная кроссплатформенная монолитная программа на Java

Время на прочтение21 мин
Количество просмотров62K
Я люблю desktop-приложения. Признаваться в этом нынче, похоже, стыднее, чем в связях с иностранной разведкой, но это так. Нет, это не значит, что я не люблю интернет-технологии. Более того, некоторые я не только уважаю, а даже более-менее знаю. Но, тем не менее, я скучаю по тем временам, когда программа писалась на одном компьютере, потом компилировалась и запускалась на других, разных компьютерах. Тогда везде (почти) была одна система — Windows с одной и той же API, почти не было проблем совместимости на уровне приложений, никто не материл разработчиков браузеров — все берегли нервы на разработчиков WinAPI, которые умудрялись создавать конфликты даже внутри нее одной. Но это я, конечно, иронизирую, а если серьезно — иногда и сейчас хочется написать просто desktop-приложение, да так, чтобы работало оно на всех популярных системах. Трудно? Если подумать и покопать, то не очень.

Еще я люблю языки высокого уровня с аккуратной архитектурой и строгой типизацией. Мои фавориты — Java и C#. Оба они предоставляют разработчику множество преимуществ по сравнению с C++, оба избавляют от ряда забот. Чем приходится платить? Тем, что таскаешь за собой тяжелую колоду, которая называется Oracle JVM, .NET или mono. Все три колоды весят сотни мегабайт и лицензию имеют такую, что каждый пользователь вынужден качать эту штуку сам, не путая при этом разрядность своего компьютера, а главное — программа на Java не может быть совместима со всеми версиями JVM разом, не так ли? И вот — мы приходим к тому, что просто скинуть программку другу (или миллиону друзей) и не заботиться о том, что она у него не запустится, не выходит. Приходится делать хитрые сетапы, вбивать костыли, и это я еще не упомянул .NET — однажды я видел у друга сразу 3 установленных версии, причем все три были нужны разным приложениям…

Стоп! А давайте напишем программу на Java, но так, чтобы она не требовала установки на машину какой-либо JVM, чтобы одним касанием собиралась под Windows, Linux и OS X и чтобы при этом занимала совсем чуть-чуть; так, чтобы никто даже не понял, что она написана, скажем, не на C. Невозможно? Совсем наоборот! (И нет, я имею в виду не gcj, который лишает Java всех ее прелестей. Рефлексия будет работать и даже сторонние jar вы сможете запускать).


Разумеется, я не волшебник. Я только нашел один волшебный артефакт. Называется он Avian, лежит по адресу oss.readytalk.com/avian и представляет собой легковесную, но полноценную стороннюю реализацию JVM, о которой Oracle, возможно, даже не слышали. Он поддерживает кучу платформ и архитектур, имеет лицензию «бери и делай, что хочешь», и — нет, я не имею ни малейшего отношения к этому проекту, я даже не контрибьютор, я только научился им пользоваться и хочу поделиться этим могучим знанием с уважаемыми обитателями Хабра. Стоит также отметить, что он является JIT-компилятором, то есть имеет конкурентно-высокую производительность (хотя я пока что не измерял ее).

Avian можно встроить в ваше приложение вместе с его весьма урезанной, но терпимой по функционалу стандартной библиотекой классов, причем «потяжелеет» программа всего только на мегабайт с гаком. Давайте соберем такую программу вместе.

0. Среда


Для построения нам прежде всего понадобятся утилиты командной строки unix-разработчика, в частности — компилятор g++. Тут сложнее всего придется Windows-пользователям. Раньше я под Windows использовал MinGW32 с ее замечательной средой MSYS, эмулирующей unix-терминал. Компиляторы, входящие в MinGW32 32-битные, что в некотором роде ограничивает полученную программу. В комментариях мне подсказали, что уже давно существует удобная mingw-w64, которая регулярно обновляется и в которой есть не только MSYS, но даже и git.

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

Windows
Чтобы скачать MinGW, идем на sourceforge.net/projects/mingwbuilds и там скачиваем два архива: x64-X.X.X-release-posix-seh-revX.7z и external-binary-packages/msys+7za+wget+svn+git+mercurial+cvs-revX.7z

После того, как они скачаны, распаковываем оба из них в удобные нам директории. Далее необходимо указать среде MSYS, где находится MinGW. Для этого идем в msys/etc и там в файле fstab прописываем путь к /mingw так, как это показано в файле fstab.sample У меня получилось вот так:
c:/mingw64         /mingw


После установки всего перечисленного вы открываете терминал MSYS. Для этого необходимо запустить файл msys\msys.bat (рядом с ним лежат две иконки). Все дальнейшие действия мы будем делать из этого терминала, так как он, во-первых, поддерживает формат unix-команд и unix-пути, а, во-вторых, в нем прописаны все необходимые параметры окружения.

OS X
Под OS X вам придется скачать XCode 4 и в его настройках, в разделе Downloads, установить Command Line Tools. Затем вы просто открываете окно терминала через Launcher.

Linux
В linux, основанном на Debian, просто откроем терминал и пишем:

> sudo apt-get install build-essential

После этого необходимо закрыть окно терминала и открыть его снова, чтобы загрузились новые параметры среды.


Итогом в каждой операционной системе должно быть то, что вы вводите в командной строке

> g++

и в ответ видите что-то вроде

g++: fatal error: no input files

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

1. Avian


Для начала предложим нашему g++ собрать Avian.
Открываем: oss.readytalk.com/avian. Выбираем ссылку status. На открывшейся странице скачиваем Avian 0.6. Несмотря на скромный номер версии, программа совершенно стабильна (во всяком случае, мне ни разу не удалось уронить ее, а в их багтрекере значатся весьма заковыристые баги, означающие высокую стабильность того, что есть).

Распакуем исходник Avian, скачанный нами, в некоторую папку (пусть это будет ~/Projects).

> cd ~/Projects

Под Windows папка "~" в среде MSYS присоединяется не к домашней папке пользователя, а к папке C:\MinGW64\msys\home\username. В нашем случае, раз мы хотим максимально дистанцироваться от платформы, это даже преимущество.

Допустим, скачанный архив называется avian-0.6.tar.bz2 и лежит в ~/Downloads, тогда распаковываем его в текущую папку, набрав

> tar -xjf ~/Downloads/avian-0.6.tar.bz2

В Windows путь необходимо указывать в формате mingw с прямыми слешами в виде /c/Users/username/Downloads/avian-0.6.tar.bz2. Разумеется, вы можете воспользоваться одним из ста альтернативных способов распаковать архив — главное, чтобы он попал в текущую папку. В итоге в ней появится распакованная из архива подпапка avian. Зайдем в нее:

> cd avian

Теперь можно попробовать запустить команду make, но, если запустить сборку прямо сейчас, скорее всего, мы получим сообщение о том, что не найден zlib. Что-нибудь вроде zlib.h: No such file or directory.

Windows
Под Windows придется слегка попотеть и воспользоваться методом, предложенным авторами Avian, а именно — подсунуть библиотеку zlib из их специального вспомогательного репозитария win64. Для этого вам потребуется установленный в системе git. К счастью, git входит в установленную нами сборку msys. К сожалению, на момент написания этой статьи сборка сделана криво — в ней не хватает файла msys-crypto-0.9.8.dll, который придется найти в Google и положить рядом с его бесполезным братом msys-crypto-1.0.0.dll, которым эта сборка укомплектована.

Далее необходимо из папки avian выполнить команду

> git clone git://oss.readytalk.com/win64.git ../win64

которая положит рядом с папкой avian папку win64 со всеми библиотеками, которые могут понадобиться avian-у, в частности, с zlib.

OS X
Под OS X, насколько я помню, эта библиотека устанавливается автоматически (возможно, с инструментарием разработчика, который мы уже поставили).

Linux
Под linux эта проблема решается простым

> sudo apt-get install zlib1g-dev


Однако, установив zlib и снова набрав make, мы получим еще одну ошибку — сборщик Avian не находит программу /bin/javac. Java-разработчики, вероятно, узнают эту программу — это компилятор Java. Так как Avian — только виртуальная машина, компилятор мы по-прежнему используем официальный — от Oracle. При сборке самой VM он нужен для того, чтобы собрать из исходных java-файлов классы маленькой стандартной библиотеки Avian, такие, например, как System, ArrayList или HashMap. Соответственно, на машине разработчика всё равно должна стоять JDK — как при сборке Avian, так и при сборке приложений, которые будут его использовать. Причем ставить желательно JDK7, с которой совместим Avian 0.6. Пользователю ваших приложений она, как и JRE, будет уже не нужна (собственно, ради этого и стараемся).

Windows
В Windows идём на сайт Oracle и качаем нужный дистрибутив, а затем устанавливаем.

OS X
В OS X, как и в Windows, идём на сайт Oracle и качаем нужный дистрибутив, а затем устанавливаем.

Linux
В Linux обходимся привычной мантрой

> sudo apt-get install openjdk-7-jdk

(Возможно, в вашем дистрибутиве не будет OpenJDK или пакет будет называться как-то иначе. Но Linux есть Linux — ищите и обрящете.)

Для того, чтобы увидеть, куда вы только что положили ваше java-окружение для разработчиков, make читает переменную среды JAVA_HOME, которую нам сейчас надо правильно задать. Эта же самая переменная с тем же значением потребуется нам впоследствии для того, чтобы собирать наш собственный проект.

Windows
Ваш путь в MinGW под Windows, скорее всего, будет что-то вроде /c/Program\ Files/Java/jdk1.7.0_07/ (вы его указывали при установке JDK7).

OS X
Под OS X ваша JDK7 установится в /Library/Java/JavaVirtualMachines/jdk1.7.0_17.jdk/Contents/Home

Linux
У меня в Linux это выглядит так:

> update-java-alternatives -l
java-1.7.0-openjdk-amd64 1071 /usr/lib/jvm/java-1.7.0-openjdk-amd64
> export JAVA_HOME=/usr/lib/jvm/java-1.7.0-openjdk-amd64


И вот, наконец, звёздный час — мы собираем Avian:

> make

Если вы всё проделали правильно, вы увидите последовательность строчек вида compiling build/<имя_вашей_платформы>/<какой-то файл>, а затем linking build/<имя_вашей_платформы>/<какой-то файл>. По окончании сборочного процесса мы получим много файлов в папке build/<имя_вашей_платформы>, но заинтересуют нас отсюда только:

  • classpath.jar — та самая маленькая библиотека базовых классов, которую вы будете использовать в своих программах. Существует возможность собрать Avian с использованием библиотеки OpenJDK, но, во-первых, как я понимаю, у нее менее добрая лицензия, а во-вторых, она существенно тяжелее.
  • binaryToObject(.exe) — необходимая для встраивания Avian в ваши приложения утилита, назначение которой будет раскрыто позже.
  • libavian.a — самый главный файл. Это и есть, собственно, встраиваемая виртуальная машина. У меня он занимает аж 23 мегабайта, но не пугайтесь. Он заметно похудеет после некоторых несложных и, что важно, совершенно безвредных и автоматических манипуляций, суть которых также будет раскрыта позднее.


2. Кроссплатформенный независимый монолитный привет на Java


Мы скомпилировали весь необходимый сторонний код. Теперь займёмся созданием своего собственного.

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

2.1. Немного про JNI

Начнем, пожалуй, с теории. Обсудим, как JVM взаимодействует с системой. Любая виртуальная машина создается, в первую очередь, для того, чтобы абстрагироваться от внешней среды. Поэтому неудивительно, что самым узким местом в реализации VM является как раз вызов системных функций. Современная программа не может даже «чихнуть» без участия ОС. Читать/писать на диск — системная функция. Вывод текста в консоль — системная функция. Нарисовать окошко на экране — а вы сами как думаете?

Фактически, единственное, что приложение может делать «внутри себя» — это расчеты и принятие решений. Именно эти действия — арифметика и логика — являются функциями VM. Как только надо сделать что-то еще, она зовёт внешнюю среду. Но как? В случае Java для этого существует JNI (Java Native Interface). Суть его весьма проста. Программа, написанная на Java, содержит в себе заголовок функции, помеченный модификатором native. Например,

package packagename;
{
	class ClassName
	{
		void native foo();
	}
}

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

extern "C" JNIEXPORT void JNICALL Java_packagename_ClassName_foo(JNIEnv * env, jobject caller)
{
	…
}

При вызове в Java-коде функции foo() мы фактически вызываем функцию из native-библиотеки, передав ей указатель на среду JNIEnv — объект, позволяющий «общаться» с данными и кодом внутри VM, и указатель на объект, из которого вызвана функция, — jobject caller (если бы функция была статической, вместо дескриптора объекта здесь бы был дескриптор класса jclass caller_class). Людям, хорошо знакомым с Java, но не изучавшим JNI, можно объяснить этот принцип взаимодействия так: JNI позволяет внешнему native-коду выполнять рефлексию над программой на Java. Если хотите изучить эту технологию подробнее, милости прошу в специальный раздел на официальном сайте Oracle.

2.2. JNI «наборот»

Зачем был весь этот ликбез? Затем, что в данный момент перед нами стоит весьма занятная, почти обратная, задача. Нам надо запустить native-исполняемый файл, который, будучи статически слинкован с библиотекой libavian.a, будет содержать JVM прямо внутри себя. Помимо этого, он будет содержать внутри себя все необходимые java-классы, включая и «точку входа» — класс вида

class Application
{
	public static void main(String... args)
	{
		…
	}
}

Звучит это всё довольно пугающе, однако задача эта вполне простая. Необходимо написать довольно несложный код на C, который вытащит библиотеку классов Avian (с добавленным в нее нашим классом Application) изнутри собственного бинарного файла и скормит ее JVM вместе с параметрами командной строки с помощью всё того же JNI. Затем мы линкуем этот C-файл специальным образом, чтобы всё оказалось на своих местах, и наслаждаемся результатом.

2.3. Новый проект и библиотеки

Сейчас мы притащим и разложим по полочкам все нужные нам для дальнейшей работы компоненты. То, что я буду описывать здесь — это мой собственный подход. Разумеется, вы вольны сделать всё иначе, так как вам заблагорассудится. Но если вы хотите в итоге получить в точности то, что я выложил на GitHub (ссылка будет в конце), постарайтесь делать всё в точности.

Создаем папку crossbase где захотим (я создал ее в Projects, рядом с avian и win32)

> mkdir crossbase && cd crossbase

Внутри создаем подпапку libs

> mkdir lib && cd lib

Внутри создаем подпапку с именем вашей текущей OS. Им должно быть «linux», «win32» или «osx».

> mkdir win-x86_64 && cd win-x86_64

В эту папку необходимо скопировать libavian.a, который мы собрали ранее. У меня это выглядит так:

> cp ../../../avian/build/windows-i386/libavian.a ./

Кроме того, в системе Windows, где нет zlib, в эту же папку придется скопировать еще и libz.a:

> cp ../../../win-x86_64/lib/libz.a ./

Таким образом, мы собрали минимум необходимых нам библиотек. Этого хватит для простейшей программы.

Помимо библиотек, нам понадобится classpath.jar, который также был собран вместе с avian.

> cd ..
> mkdir java && cd java
> cp ../../../avian/build/windows-i386/classpath.jar ./

И теперь пришло время раскрыть назначение таинственного binaryToObject. Он нужен нам, чтобы преобразовать наш jar-файл в специальный объектный файл, который затем будет передан линковщику и добавлен им в нашу программу. Так как эта процедура должна выполняться при каждой сборке, его тоже надо утащить в наш новый проект.

> cd ../..	

(мы снова в папке crossbase, где мы создали lib)

> mkdir -p tools/win-x86_64 && cd tools/win-x86_64

Имя win-x86_64 назначено внутренней папке по тому же принципу, что и в прошлый раз. Кидаем сюда binaryToObject. (в Windows он, разумеется, имеет расширение exe)

> cp ../../../avian/build/windows-i386/binaryToObject/binaryToObject.exe ./

Можно запустить его и увидеть usage:

usage: c:\Users\imizus\Projects\crossbase\crossbase\tools\win32\binaryToObject.exe <input file> <output file> <start name> <end name> <platform> <architecture> [<alignment> [{writable|executable}...]]


2.4. Код программы

А теперь приступим к написанию кода. Создадим новый исходный файл на C++ (вы можете воспользоваться любым текстовым редактором, какой вам нравится, я использую eclipse, в котором можно редактировать и C++, и Java в рамках одного проекта, хотя для этого его придется немного настроить).

> mkdir -p src/cpp && cd src/cpp

Внутри создаем файл main.cpp со следующим содержанием (приведу его целиком, а потом объясню, что там к чему):

#include <stdint.h>
#include <string.h>

#ifdef __MINGW32__
#include <windows.h>
#endif

#include <jni.h>

#if (defined __MINGW32__)
#  define EXPORT __declspec(dllexport)
#else
#  define EXPORT __attribute__ ((visibility("default"))) \
  __attribute__ ((used))
#endif

#if (! defined __x86_64__) && (defined __MINGW32__)
#  define SYMBOL(x) binary_boot_jar_##x
#else
#  define SYMBOL(x) _binary_boot_jar_##x
#endif

extern "C"
{

	extern const uint8_t SYMBOL(start)[];
	extern const uint8_t SYMBOL(end)[];

	EXPORT const uint8_t* bootJar(unsigned* size)
	{
		*size = SYMBOL(end) - SYMBOL(start);
		return SYMBOL(start);
	}

} // extern "C"

int main(int argc, const char** argv)
{
#ifdef __MINGW32__
	// For Windows: Getting command line as a wide string
	int wac = 0;
	wchar_t** wav;
	wav = CommandLineToArgvW(GetCommandLineW(), &wac);
#else
	// For other OS: Getting command line as a plain string (encoded in UTF8)
	int wac = argc;
	const char** wav = argv;
#endif

	JavaVMInitArgs vmArgs;
	vmArgs.version = JNI_VERSION_1_2;
	vmArgs.nOptions = 1;
	vmArgs.ignoreUnrecognized = JNI_TRUE;

	JavaVMOption options[vmArgs.nOptions];
	vmArgs.options = options;

	options[0].optionString = const_cast<char*>("-Xbootclasspath:[bootJar]");

	JavaVM* vm;
	void* env;
	JNI_CreateJavaVM(&vm, &env, &vmArgs);
	JNIEnv* e = static_cast<JNIEnv*>(env);

	jclass c = e->FindClass("crossbase/Application");
	if (not e->ExceptionCheck())
	{
		jmethodID m = e->GetStaticMethodID(c, "main", "([Ljava/lang/String;)V");
		if (not e->ExceptionCheck())
		{
			jclass stringClass = e->FindClass("java/lang/String");
			if (not e->ExceptionCheck())
			{
				jobjectArray a = e->NewObjectArray(wac - 1, stringClass, 0);
				if (not e->ExceptionCheck())
				{
					for (int i = 1; i < wac; ++i)
					{
#ifdef __MINGW32__
						// For Windows: Sending wide string to Java
						int arglen = wcslen(wav[i]);
						jstring arg = e->NewString((jchar*) (wav[i]), arglen);
#else
						// For other OS: Sending UTF8-encoded string to Java
						int arglen = strlen(wav[i]);
						jstring arg = e->NewStringUTF((char*) (wav[i]));
#endif
						e->SetObjectArrayElement(a, i - 1, arg);
					}

					e->CallStaticVoidMethod(c, m, a);
				}
			}
		}
	}

	int exitCode = 0;
	if (e->ExceptionCheck())
	{
		exitCode = -1;
		e->ExceptionDescribe();
	}

	vm->DestroyJavaVM();

	return exitCode;
}


__MINGW32__ — символ препроцессора, который (как неожиданно!) автоматически задается внутри среды MinGW32. Он позволяет нам отличить Windows, который, как вы, думаю, уже успели заметить, сильно непохож на все прочие системы. В частности, только под Windows нам понадобится специальная системная API, которую мы подключаем строкой #include <windows.h>. На остальных платформах мы обходимся стандартными библиотеками POSIX и ANSI C++. Зачем понадобится? Станет ясно чуть позже. Будем просматривать код по порядку.

#if (defined __MINGW32__)
#  define EXPORT __declspec(dllexport)
#else
#  define EXPORT __attribute__ ((visibility("default"))) \
  __attribute__ ((used))
#endif

Этот код знаком и понятен всем, кто писал кроссплатформенные динамические библиотеки с использованием gcc. Суть его в том, что в разных операционных системах по-разному описываются функции, которые должны быть экспортированы из библиотеки. «Причем тут динамическая библиотека, ведь мы же исполняемый файл собираем?» — спросите вы. В ответ я напомню, что взаимодействие Avian с платформозависимым кодом осуществляется через механизм JNI, подразумевающий вызов функции из библиотеки. Иными словами, для вашего Java-кода исполняемый файл это не только пусковая программа, а еще и динамическая библиотека функций.

Следующая часть — это странноватая магия:
#if (! defined __x86_64__) && (defined __MINGW32__)
#  define SYMBOL(x) binary_boot_jar_##x
#else
#  define SYMBOL(x) _binary_boot_jar_##x
#endif

extern "C"
{

	extern const uint8_t SYMBOL(start)[];
	extern const uint8_t SYMBOL(end)[];

	EXPORT const uint8_t* bootJar(unsigned* size)
	{
		*size = SYMBOL(end) - SYMBOL(start);
		return SYMBOL(start);
	}

} // extern "C"

Давайте разберемся. Мы декларируем некую экспортную функцию (посмотрим на extern "C" и директиву EXPORT, которую мы только что ввели. Имя функции — bootJar. Запомним это имя и посмотрим, что она делает. Если мысленно разобрать директивы препроцессора, то увидим, что она вычисляет расстояние между некими _binary_boot_jar_start и _binary_boot_jar_end (В MinGW32 они не будут иметь подчерк вначале). Сами эти символы декларированы как extern, то есть их должен подставить линковщик. Загадочная деятельность, не правда ли?

На самом деле, как мы увидим ниже, всё довольно просто, если знаешь, что делать. Так как Avian разрабатывался для встраивания его в приложения, авторы предусмотрели возможность добавления библиотеки классов непосредственно в исполняемый файл с последующей ее загрузкой оттуда. Для этого надо всего лишь преобразовать библиотеку в объектный файл. Да-да, я тоже поначалу удивился, но это очень элегантная идея. В объектном файле, содержащем наш jar, когда мы его создадим, будет декларировано 2 символа, указывающих на начало (_binary_boot_jar_start) и конец (_binary_boot_jar_end) этого jar-файла. А функция bootJar будет использована Avian-ом, чтобы узнать, где он начинается и какую длину имеет. Забегая вперед, скажу, что имя этой функции передается строкой

options[0].optionString = const_cast<char*>("-Xbootclasspath:[bootJar]");

Наконец-то мы дошли до точки входа — функции main. В ее задачу входит:
  • Считать строку параметров
  • Загрузить Avian, передав ему библиотеку классов
  • Вызвать функцию main из класса crossbase.Application, передав ей параметры командной строки
  • Красиво вылететь с ошибкой, если что-либо из вышеперечисленного не удастся


Поехали с начала фцнкции:

#ifdef __MINGW32__
	// For Windows: Getting command line as a wide string
	int wac = 0;
	wchar_t** wav;
	wav = CommandLineToArgvW(GetCommandLineW(), &wac);
#else
	// For other OS: Getting command line as a plain string (encoded in UTF8)
	int wac = argc;
	const char** wav = argv;
#endif

Здесь как всегда отличился Windows. Когда повсеместно было принято решение переходить от старых неудобных однобайтных кодировок к более сложным, все ОС перешли к удобной UTF-8, а любимое детище Microsoft перешло на фиксированную двухбайтную. При этом они вообще не позаботились о том, какая кодировка используется, например, в именах файлов. Но кодировка нас сейчас тоже не очень заботит. Нам надо передать строку параметров в Java (в которой тоже принят двухбайтный char). Поэтому для Windows мы вызываем API-функцию (ради которой мы и тащили windows.h), которая выдаст нам строку параметров в правильной двухбайтной кодировке. Так мы получим возможность, например, открывать файлы с кириллицей в названии. Во всех прочих системах мы просто читаем параметры из аргументов функции main.

Далее следует создание виртуальной машины Java:

JavaVMInitArgs vmArgs;
vmArgs.version = JNI_VERSION_1_2;
vmArgs.nOptions = 1;
vmArgs.ignoreUnrecognized = JNI_TRUE;

JavaVMOption options[vmArgs.nOptions];
vmArgs.options = options;

options[0].optionString = const_cast<char*>("-Xbootclasspath:[bootJar]");

JavaVM* vm;
void* env;
JNI_CreateJavaVM(&vm, &env, &vmArgs);
JNIEnv* e = static_cast<JNIEnv*>(env);

Еще мы вытаскиваем указатель на объект JNIEnv, который будем использовать, чтобы командовать только что созданной Java-машиной.

Дальнейший код читается как стих Маяковского, если только немного знать JNI.

jclass c = e->FindClass("crossbase/Application");
if (not e->ExceptionCheck())
{
	jmethodID m = e->GetStaticMethodID(c, "main", "([Ljava/lang/String;)V");
	if (not e->ExceptionCheck())
	{
		jclass stringClass = e->FindClass("java/lang/String");
		if (not e->ExceptionCheck())
		{
			jobjectArray a = e->NewObjectArray(wac - 1, stringClass, 0);
			if (not e->ExceptionCheck())
			{
				for (int i = 1; i < wac; ++i)
				{
#ifdef __MINGW32__
					// For Windows: Sending wide string to Java
					int arglen = wcslen(wav[i]);
					jstring arg = e->NewString((jchar*) (wav[i]), arglen);
#else
					// For other OS: Sending UTF8-encoded string to Java
					int arglen = strlen(wav[i]);
					jstring arg = e->NewStringUTF((char*) (wav[i]));
#endif
					e->SetObjectArrayElement(a, i - 1, arg);
				}

				e->CallStaticVoidMethod(c, m, a);
			}
		}
	}
}

int exitCode = 0;
if (e->ExceptionCheck())
{
	exitCode = -1;
	e->ExceptionDescribe();
}

Возьмём класс crossbase/Application. Если смогли, найдем в нем статический метод main с сигнатурой ([Ljava/lang/String;)V. Если смогли, достанем из стандартной библиотеки класс java/lang/String. Если смогли, создадим массив объектов этого класса (они и будут параметрами). Если смогли, то во всех операционных системах создаем java-строку из каждого параметра, заданного в кодировке UTF-8, а в Windows создаем напрямую, используя двухбайтное представление.

Если мы что-нибудь не смогли, выдаем ошибку пользователю.

Вот, собственно, и весь «пусковой механизм». Теперь нам надо создать нашу программу на Java. Она, как минимум, должна содержать класс crossbase.Application с методом public static void main(String... args).

Создадим в нашей папке crossbase/src подпапку java, в ней — подпапку crossbase (это — имя пакета), а внутри создадим файл Application.java следующего содержания:

package crossbase;

public class Application
{
	public static void main(String... args)
	{
		System.out.println("This is a crossplatform monolith application with Java code inside. Freedom to Java apps!");
		for (int i = 0; i < args.length; i++)
		{
			System.out.println("args[" + i + "] = " + args[i]);
		}
	}
}

Если вы хоть немного знаете Java, то, думаю, комментарии здесь излишни. Скажу только, что в стандартной библиотеке классов Avian нету средств форматирования строк (которые никто не мешает тихонько утянуть, к примеру, из OpenJDK).

2.5. Сборка


Теперь перейдем к задаче сборки нашего проекта. Я использую make, потому что он есть всегда и везде, где есть gcc. А еще он достаточно мощный, чтобы написать на нем почти любую автоматизированную систему сборки. Нет, правда. Можно по пальцам перечислить, что мне не удавалось сделать на make и это едва ли были жизненно важные вещи. Наш Makefile будет лежать прямо в папке crossbase и выглядеть он будет вот так:

UNAME := $(shell uname)
ARCH := $(shell uname -m)

SRC = src
BIN = bin
OBJ = obj

JAVA_SOURCE_PATH = $(SRC)/java
JAVA_CLASSPATH = $(BIN)/java
CPP_SOURCE_PATH = $(SRC)/cpp
OBJECTS = $(OBJ)


DEBUG_OPTIMIZE = -O3 #-O0 -g

ifeq ($(UNAME), Darwin)	# OS X
  PLATFORM_ARCH = darwin x86_64
  PLATFORM_LIBS = osx-x86_64
  PLATFORM_GENERAL_INCLUDES = -I"$(JAVA_HOME)/include" -I"$(JAVA_HOME)/include/darwin"
  PLATFORM_GENERAL_LINKER_OPTIONS = -framework Carbon
  PLATFORM_CONSOLE_OPTION = 
  EXE_EXT=
  STRIP_OPTIONS=-S -x
  RDYNAMIC=-rdynamic
else ifeq ($(UNAME) $(ARCH), Linux x86_64)	# linux on PC
  PLATFORM_ARCH = linux x86_64
  PLATFORM_LIBS = linux-x86_64
  PLATFORM_GENERAL_INCLUDES = -I"$(JAVA_HOME)/include" -I"$(JAVA_HOME)/include/linux"
  PLATFORM_GENERAL_LINKER_OPTIONS = -lpthread -ldl
  PLATFORM_CONSOLE_OPTION = 
  EXE_EXT=
  STRIP_OPTIONS=--strip-all
  RDYNAMIC=-rdynamic
else ifeq ($(OS), Windows_NT)	# Windows
  PLATFORM_ARCH = windows x86_64
  PLATFORM_LIBS = win-x86_64
  PLATFORM_GENERAL_INCLUDES = -I"$(JAVA_HOME)/include" -I"$(JAVA_HOME)/include/win32"
  PLATFORM_GENERAL_LINKER_OPTIONS = -static -lmingw32 -lmingwthrd -lws2_32 -mwindows -static-libgcc -static-libstdc++
  PLATFORM_CONSOLE_OPTION = -mconsole
  EXE_EXT=.exe
  STRIP_OPTIONS=--strip-all
  RDYNAMIC=
endif

JAVA_FILES = $(shell cd $(JAVA_SOURCE_PATH); find . -name \*.java | awk '{ sub(/.\//,"") }; 1')
JAVA_CLASSES := $(addprefix $(JAVA_CLASSPATH)/,$(addsuffix .class,$(basename $(JAVA_FILES))))

CPP_FILES = $(shell cd $(CPP_SOURCE_PATH); find . -name \*.cpp | awk '{ sub(/.\//,"") }; 1')
CPP_OBJECTS := $(addprefix $(OBJECTS)/,$(addsuffix .o,$(basename $(CPP_FILES))))

all: $(BIN)/crossbase

$(JAVA_CLASSPATH)/%.class: $(JAVA_SOURCE_PATH)/%.java
	@echo $(PLATFORM_GENERAL_INCLUDES)
	if [ ! -d "$(dir $@)" ]; then mkdir -p "$(dir $@)"; fi
	"$(JAVA_HOME)/bin/javac" -sourcepath "$(JAVA_SOURCE_PATH)" -classpath "$(JAVA_CLASSPATH)" -d "$(JAVA_CLASSPATH)" $<

$(OBJ)/%.o: $(SRC)/cpp/%.cpp
	@echo $(PLATFORM_GENERAL_INCLUDES)
	mkdir -p $(OBJ)
	g++ $(DEBUG_OPTIMIZE) -D_JNI_IMPLEMENTATION_ -c $(PLATFORM_GENERAL_INCLUDES) $< -o $@

$(BIN)/crossbase: $(JAVA_CLASSES) $(CPP_OBJECTS)
	mkdir -p $(BIN);
	@echo $(PLATFORM_GENERAL_INCLUDES)

	# Extracting libavian objects
	( \
	    cd $(OBJ); \
	    mkdir -p libavian; \
	    cd libavian; \
	    ar x ../../lib/$(PLATFORM_LIBS)/libavian.a; \
	)

	# Making the java class library
	cp lib/java/classpath.jar $(BIN)/boot.jar; \
	( \
	    cd $(BIN); \
	    "$(JAVA_HOME)/bin/jar" u0f boot.jar -C java .; \
	)

	# Making an object file from the java class library
	tools/$(PLATFORM_LIBS)/binaryToObject $(BIN)/boot.jar $(OBJ)/boot.jar.o _binary_boot_jar_start _binary_boot_jar_end $(PLATFORM_ARCH); \
	g++ $(RDYNAMIC) $(DEBUG_OPTIMIZE) -Llib/$(PLATFORM_LIBS) $(OBJ)/boot.jar.o $(CPP_OBJECTS) $(OBJ)/libavian/*.o $(PLATFORM_GENERAL_LINKER_OPTIONS) $(PLATFORM_CONSOLE_OPTION) -lm -lz -o $@
	strip $(STRIP_OPTIONS) $@$(EXE_EXT)


clean:
	rm -rf $(OBJ)
	rm -rf $(BIN)

.PHONY: all

Будьте осторожны! Не путайте табуляции с пробелами, в make табуляцией выделяются команды внутри правила сборки, а пробел синтаксическим элементом не является. Присмотримся немного, как он работает. Единственная более-менее мозгодробительная конструкция — назначение вот этих переменных:

JAVA_FILES = $(shell cd $(JAVA_SOURCE_PATH); find . -name \*.java | awk '{ sub(/.\//,"") }; 1')
JAVA_CLASSES := $(addprefix $(JAVA_CLASSPATH)/,$(addsuffix .class,$(basename $(JAVA_FILES))))

CPP_FILES = $(shell cd $(CPP_SOURCE_PATH); find . -name \*.cpp | awk '{ sub(/.\//,"") }; 1')
CPP_OBJECTS := $(addprefix $(OBJECTS)/,$(addsuffix .o,$(basename $(CPP_FILES))))

Здесь мы с помощью unix-команды find отыскиваем все файлы .java в папке $(JAVA_SOURCE_PATH). Эти файлы нам предстоит компилировать. Далее мы откусываем от них расширение и заменяем его на .class, а пусть заменяем на $(JAVA_CLASSPATH), получая таким образом имена файлов классов, которые надо получить, т.е. имена целей. Аналогично мы поступаем с файлами .cpp и .o. Далее в makefile мы видим следующие правила сборки:

$(JAVA_CLASSPATH)/%.class: $(JAVA_SOURCE_PATH)/%.java
	@echo $(PLATFORM_GENERAL_INCLUDES)
	if [ ! -d "$(dir $@)" ]; then mkdir -p "$(dir $@)"; fi
	"$(JAVA_HOME)/bin/javac" -sourcepath "$(JAVA_SOURCE_PATH)" -classpath "$(JAVA_CLASSPATH)" -d "$(JAVA_CLASSPATH)" $<

$(OBJ)/%.o: $(SRC)/cpp/%.cpp
	@echo $(PLATFORM_GENERAL_INCLUDES)
	mkdir -p $(OBJ)
	g++ $(DEBUG_OPTIMIZE) -D_JNI_IMPLEMENTATION_ -c $(PLATFORM_GENERAL_INCLUDES) $< -o $@

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

$(BIN)/crossbase: $(JAVA_CLASSES) $(CPP_OBJECTS)
	...

Здесь мы видим зависимость от всех найденных файлов. То есть makefile написан таким образом, чтобы собирать все java и cpp файлы, предложенные ему в правильных папках.

Остальные существенные моменты:

  • -static-libgcc и -static-libstdc++ необходимы в mingw, чтобы собираемый файл содержал в себе стандартные библиотеки C и C++. В противном случае он будет слинкован с ними динамически и потребует таскать за собой пару DLL.
  • -mconsole нужен в системе Windows, чтобы система выдала программе консольный ввод-вывод при запуске. Этот параметр для GUI-приложения надо убрать.
  • Опция -rdynamic не поддерживается gcc под Windows в силу особенностей платформы.


Пробежимся вскользь по основному правилу сборки — $(BIN)/crossbase: $(JAVA_CLASSES) $(CPP_OBJECTS). Сперва мы распаковываем все объектные файлы из libavian.a, чтобы впоследствии передать их линковщику поименно. Поведение странное, но не бессмысленное. В Windows это решает какую-то странную проблему с линковкой (я не разобрался достаточно хорошо). Далее мы берем наш classpath.jar, добавляем к нему наши скомпилированные классы из bin/java и пакуем всё вместе в bin/boot.jar. Затем мы вызываем binaryToObject, который создает из нашего boot.jar объектный файл obj/boot.jar.o с символами _binary_boot_jar_start и _binary_boot_jar_end (которые мы импортировали в main.o). И, наконец, мы линкуем всё это безобразие вместе. И, наконец, выполняем волшебную команду strip, в параметрах которой, на этот раз, отличилась OS X, где они не такие, как в MinGW и в Linux. Назначение команды — выкинуть из исполняемого файла всякие левые символы. До ее отработки crossbase весит более 9 мегабайт, после — менее полутора.

3. Момент триумфа


Зайдя в папку crossbase/bin, запускаем из консоли наш crossbase, передав ему параметры.

> ./crossbase Привет Хабр!

This is a crossplatform monolith application with Java code inside. Freedom to Java apps!
args[0] = Привет
args[1] = Хабр!


Получившийся у нас проект лежит на моём GitHub-е.

4. Итоги и смысл


Мне трудно оценить пользу от этой статьи. Если я хотя бы получу за нее инвайт, это будет значить, что она, по крайней мере, не безынтересна. Скажу только, что при кажущейся сложности, этот метод прекрасно окупается по сравнению с написанием программы, скажем, на чистом C++. Java становится очень удобна при разрастании проекта хотя бы до пары десятков классов. Даже если быть предельно аккуратном при написании кода на C++, всё равно остаются лазейки для чудовищно сложновылавливаемых ошибок. Поэтому управляющий код (не требующий суперпроизводительности) я бы всем советовал писать на Java. Код же, требующий максимальной скорости, можно написать на C++, а затем очень легко и аккуратно обернуть C++ класс Java классом. Возможно я еще напишу, как сделать это красиво и не напороться на грабли.

Изначально я планировал сделать в статье главу, посвященную добавлению к этому «бутерброду» кроссплатформенного пользовательского интерфейса SWT (того, который используется в Eclipse), но потом решил, что она будет слишком уж длинной и увесистой. Если господам читателям интересно, напишу об этом отдельно. Благодарю за внимание!

P.S.
Получив от хабровчан много отзывов, я доработал статью и программу. Спасибо всем за советы и поправки.
Теги:
Хабы:
+136
Комментарии80

Публикации

Истории

Работа

Программист C++
128 вакансий
Java разработчик
358 вакансий
Программист С
49 вакансий
QT разработчик
7 вакансий

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн