Статьи

Безопасная загрузка различных версий собственной библиотеки в JNA (или JNI)

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

Выгрузка собственной библиотеки в Java считается небезопасной, поскольку она зависит от запускаемого сборщика мусора. Невозможно принудительно запустить GC (вы можете предложить только запуск GC в JVM), и нет никакой гарантии, что после запуска GC библиотека (или любой другой объект в памяти) была собрана сборщиком мусора. Тем не менее, концепция динамически загружаемых библиотек в C позволяет безопасно выгружать библиотеку и снова загружать ее или другую версию.

Библиотека примеров

Чтобы уточнить, как выгрузить библиотеку и загрузить другую версию, эта «библиотека» будет использоваться в качестве примера. Заголовочный файл version.h выглядит следующим образом

int getVersion(void);

Так что это будет очень простой кусок кода, возвращающий номер версии. Конечно, это не реальный пример. Соответствующий код C version.c

#include <stdio.h>
#include "version.h"

int getVersion(void) {
    return 1;
}

Чтобы использовать функцию getVersion () в C, можно использовать этот код

#include <stdlib.h>
#include <stdio.h>
#include "version/version.h"

int main(int argc, char **argv) {
    printf("%d\n", getVersion());
    return 0;
}

Чтобы собрать все это и отделить библиотеку с помощью функции getVersion () от кода, который ее использует, я создал каталог с именем «src» и в нем каталог с именем «версия». Итак, структура каталогов

  • ЦСИ

    • версия

      • version.c
      • version.h
    • test.c

Наконец, все должно быть построено, прежде чем мы сможем его выполнить. Я не гуру Make file, поэтому я создал скрипт bash:

#!/bin/bash
cd src/version
gcc -c -Wall -Werror -fpic version.c -o version.o && gcc -shared -o libVersion.so version.o
cd ..
gcc -I./version -L./version -Wall test.c -o test -lVersion
cd ..
export LD_LIBRARY_PATH=src/version:$LD_LIBRARY_PATH

echo "Calling library from C:"
./src/test

Запуск скрипта генерирует этот вывод:

$ ./compile.sh 
1
$

Вызов библиотеки из Java

Пожалуйста, обратитесь к документации JNA для получения дополнительной информации о JNA.

Чтобы вызвать функцию getVersion () в библиотеке libVersion.so из Java, я создал этот класс

package version;

import com.sun.jna.Library;
import com.sun.jna.Native;

public class VersionModule {

    private Version version;
    private static VersionModule vm;

    public static void main(String[] args) {
        vm = new VersionModule();
        vm.callVersion();
    }

    private void callVersion() {
        vm.init();
        System.out.println(vm.getVersion());
    }

    public void init() {
        version = (Version) Native.loadLibrary(Version.LIBRARY_NAME, Version.class);
    }

    public int getVersion() {
        return version.getVersion();
    }

    private interface Version extends Library {

        String LIBRARY_NAME = "Version";

        int getVersion();

    }

}

Я поместил Java-файл в каталог src / java / version, вот так

  • ЦСИ

    • Джава

      • версия


          VersionModule.java
    • версия

      • version.c
      • version.h
    • test.c

и я изменил скрипт bash complie.sh, чтобы также вызывать класс Java:

#!/bin/bash
cd src/version
gcc -c -Wall -Werror -fpic version.c -o version.o && gcc -shared -o libVersion.so version.o
cd ..
gcc -I./version -L./version -Wall test.c -o test -lVersion
cd ..
export LD_LIBRARY_PATH=src/version:$LD_LIBRARY_PATH

echo "Calling library from C:"
./src/test

cd src/java
javac -cp .:/usr/share/java/jna-3.2.7.jar version/VersionModule.java

echo "Calling library from Java:"
java -Djna.library.path=../version -cp .:/usr/share/java/jna-3.2.7.jar version.VersionModule

Запуск скрипта теперь генерирует этот вывод:

$ ./compile.sh 
Calling library from C:
1
Calling library from Java:
1

Использование двух версий библиотеки через прокси-библиотеку

Предположим теперь, что мы получили новую версию библиотеки. Заголовочный файл не изменился, но реализация функции getVersion () изменилась. Вот новая версия

#include <stdio.h>
#include "version.h"

int getVersion(void) {
    return 2;
}

Чтобы сохранить обе версии рядом друг с другом, я переименовал каталог, содержащий старый код, в version1 и поместил новый код в отдельную папку с именем version2 (из-за отсутствия более подходящих имен):

  • ЦСИ

    • Джава

      • версия


          VersionModule.java
    • version1

      • version.c
      • version.h
    • version2

      • version.c
      • version.h
    • test.c

Чтобы переключиться с одной версии на другую, мы представляем еще один файл кода C. Этот код будет действовать как прокси-библиотека и будет выполнять фактическую загрузку и выгрузку правильной версии библиотеки, а также вызывать соответствующие функции в загруженной библиотеке. Чтобы свести к минимуму изменения кода в файле test.c, коде Java и bash-скрипте compile.sh, файлы заголовка и кода помещаются в каталог src / version. Заголовочный файл version.h выглядит следующим образом

void set_library_path(char *_library_path);
int getVersion(void);

Код прокси C выглядит следующим образом

#include <stdlib.h>
#include <stdio.h>
#include <dlfcn.h>

void *handle;

void set_library_path(char *_library_path) {
    if (handle) {
        dlclose(handle);
    }
    handle = dlopen(_library_path, RTLD_NOW);
    if (!handle) {
        fputs (dlerror(), stderr);
    }
}

int getVersion(void) {
    int (*getVersion)(void);
    char *error;
    getVersion = dlsym(handle, "getVersion");
    if ((error = dlerror()) != NULL)  {
        fputs(error, stderr);
    }

    return (*getVersion)();
}

Функция set_library_path загружает запрошенную версию библиотеки, используя функции из библиотеки dlfcn (функция динамической библиотеки). Затем каждую функцию (в API библиотеки, для которой существуют разные версии) необходимо сопоставить с прокси-функцией, которая пытается выполнить требуемую функцию способом, аналогичным отражению в Java. Может быть трудно получить правильное сопоставление, в зависимости от определения функций, для которых вам нужен прокси. Подробнее о динамически загружаемых библиотеках можно узнать здесь .

Исходное дерево теперь выглядит так

  • ЦСИ

    • Джава

      • версия


          VersionModule.java
    • версия

      • version.c
      • version.h
    • version1

      • version.c
      • version.h
    • version2

      • version.c
      • version.h
    • test.c

Обратите внимание, что для того, чтобы код Java мог загружать прокси-библиотеку, эта библиотека должна называться libVersion.so, иначе JNA не сможет загрузить библиотеку! Код test.c для использования старой библиотеки, а затем новой

#include <stdlib.h>
#include <stdio.h>
#include "version/version.h"

int main(int argc, char **argv) {
    set_library_path("src/version1/libVersion.so");
    printf("%d\n", getVersion());
    set_library_path("src/version2/libVersion.so");
    printf("%d\n", getVersion());
    return 0;
}

Вот сценарий bash compile.sh для компиляции кода и выполнения тестовой программы на C:

#!/bin/bash
cd src/version1
gcc -c -Wall -Werror -fpic version.c -o version.o && gcc -shared -o libVersion.so version.o
cd ../version2
gcc -c -Wall -Werror -fpic version.c -o version.o && gcc -shared -o libVersion.so version.o
cd ../version
gcc -c -Wall -Werror -fpic -rdynamic version.c -o version.o && gcc -shared -o libVersion.so version.o
cd ..
gcc -I./version -L./version -Wall test.c -o test -lVersion -ldl
cd ..
export LD_LIBRARY_PATH=src/version:$LD_LIBRARY_PATH

echo "Calling library from C:"
./src/test

The script now generates this output:

$ ./compile.sh 
Calling library from C:
1
2

Calling the proxy library from Java

With this new proxy library, the code changes to call the different versions from Java are quite small as well. Here is the new code

package version;

import com.sun.jna.Library;
import com.sun.jna.Native;

public class VersionModule {

    private Version version;
    private static VersionModule vm;

    public static void main(String[] args) {
        vm = new VersionModule();
        vm.callVersion();
    }

    private void callVersion() {
        String pwd = System.getProperty("user.dir");
        vm.init("../version1/libVersion.so");
        System.out.println(vm.getVersion());
        vm.init("../version2/libVersion.so");
        System.out.println(vm.getVersion());
    }

    public void init(String path) {
        version = (Version) Native.loadLibrary(Version.LIBRARY_NAME, Version.class);
        version.set_library_path(path);
    }

    public int getVersion() {
        return version.getVersion();
    }

    private interface Version extends Library {

        String LIBRARY_NAME = "Version";

        int getVersion();
        void set_library_path(String _library_path);

    }

}

and the new compile.sh bash script:

#!/bin/bash
cd src/version1
gcc -c -Wall -Werror -fpic version.c -o version.o && gcc -shared -o libVersion.so version.o
cd ../version2
gcc -c -Wall -Werror -fpic version.c -o version.o && gcc -shared -o libVersion.so version.o
cd ../version
gcc -c -Wall -Werror -fpic -rdynamic version.c -o version.o && gcc -shared -o libVersion.so version.o
cd ..
gcc -I./version -L./version -Wall test.c -o test -lVersion -ldl
cd ..
export LD_LIBRARY_PATH=src/version:$LD_LIBRARY_PATH

echo "Calling library from C:"
./src/test

cd src/java
javac -cp .:/usr/share/java/jna-3.2.7.jar version/VersionModule.java

echo "Calling library from Java:"
java -Djna.library.path=../version -cp .:/usr/share/java/jna-3.2.7.jar version.VersionModule

Running the script produces this output

$ ./compile.sh 
Calling library from C:
1
2
Calling library from Java:
1
2

Performance impact

The 3rd party native library that is used by my project required me to create proxy functions for no less than 81 functions! Clearly this raises concerns about the performance of the proxy library w.r.t. the performance of direct use of one version of the library, even though 72 of these functions are getters and setters. A simple test, which was to call the whole processing sequence several times in a row with the old library and then the same with the proxy library calling the old library, The test shows there is no measurable overhead.

Caveats

There are a few caveats for this method to be aware of. The most important one is what to do when the API changes. A function in the new version may have the same signature but with different arguments or different argument types. Functions may exist in one version of the library and not the other. From the JNA point of view, all that matters is that the methods in the interface extending Library map one on one to functions in the proxy library. It is perfectly ok to construct the proxy library in such a way that its functions call functions in the loaded library with an entirely different name. As long as those functions get called with the proper arguments, it will work fine. If you do such a thing, then of course make sure to document this well, either in inline comments or in a technical design document, or both!

By the way, it is very tempting to delegate determining the location of the libraries to load and which functions can be called and which not to the end user. But this should be avoided as much as possible. You know about the internals of your Java code and proxy library so it is your responsibility to make sure that end users do not need to go to the trouble of making sure it all works fine.

Then of course it is a very bad practise to hard code the path to the libraries in source code. It is much better to determine the location of the library to load at runtime. This can be done via properties, command line flags, values in a database or whatever way you prefer. And again, make sure to document this well in your software user manual!

Finally, loading and unloading libraries takes time so it is important to minimize the need for that as much as possible. My project, for example, needs to deal with mixed content of data so I have made sure that I gather together all data of the old format and all data of the new format and then process them sequentially.