Използване на JNA за достъп до естествени динамични библиотеки

1. Общ преглед

В този урок ще видим как да използваме библиотеката на Java Native Access (накратко JNA) за достъп до родните библиотеки, без да пишем код на JNI (Java Native Interface).

2. Защо JNA?

В продължение на много години Java и други езици, базирани на JVM, до голяма степен са изпълнили мотото си „пиши веднъж, пусни навсякъде“. Понякога обаче трябва да използваме собствен код, за да приложим някои функционалности :

  • Повторно използване на стария код, написан на C / C ++ или друг език, способен да създаде собствен код
  • Достъп до специфична за системата функционалност, която не се предлага в стандартното изпълнение на Java
  • Оптимизиране на скоростта и / или използването на паметта за конкретни раздели на дадено приложение.

Първоначално този вид изискване означаваше, че ще трябва да прибегнем до JNI - Java Native Interface. Макар и ефективен, този подход има своите недостатъци и като цяло беше избегнат поради няколко проблема:

  • Изисква от разработчиците да напишат C / C ++ “лепилен код” за свързване на Java и родния код
  • Изисква пълна верига от инструменти за компилиране и свързване, достъпна за всяка целева система
  • Маршалирането и демаркирането на стойности към и от JVM е досадна и податлива на грешки задача
  • Правни проблеми и проблеми с поддръжката при смесване на Java и собствени библиотеки

JNA дойде да реши по-голямата част от сложността, свързана с използването на JNI. По-специално, няма нужда да създавате никакъв JNI код, за да използвате естествен код, разположен в динамични библиотеки, което улеснява целия процес.

Разбира се, има някои компромиси:

  • Не можем директно да използваме статични библиотеки
  • По-бавен в сравнение с ръчно изработения JNI код

За повечето приложения обаче ползите от простотата на JNA далеч надхвърлят тези недостатъци. Като такова е справедливо да се каже, че освен ако нямаме много специфични изисквания, JNA днес е може би най-добрият възможен избор за достъп до родния код от Java - или друг език, базиран на JVM, между другото.

3. Настройка на проект JNA

Първото нещо, което трябва да направим, за да използваме JNA, е да добавим нейните зависимости към pom.xml на нашия проект :

 net.java.dev.jna jna-platform 5.6.0  

Последната версия на jna-платформата може да бъде изтеглена от Maven Central.

4. Използване на JNA

Използването на JNA е процес от две стъпки:

  • Първо, ние създаваме интерфейс на Java, който разширява интерфейса на библиотеката на JNA, за да опише методите и типовете, използвани при извикване на целевия роден код
  • След това предаваме този интерфейс на JNA, който връща конкретна реализация на този интерфейс, който използваме за извикване на собствени методи

4.1. Методи за извикване от стандартната библиотека C

За нашия първи пример, нека използваме JNA, за да извикаме функцията cosh от стандартната библиотека C, която се предлага в повечето системи. Този метод взема двоен аргумент и изчислява неговия хиперболичен косинус. Програмата за променлив ток може да използва тази функция само като включи заглавен файл:

#include  #include  int main(int argc, char** argv) { double v = cosh(0.0); printf("Result: %f\n", v); }

Нека създадем Java интерфейса, необходим за извикване на този метод:

public interface CMath extends Library { double cosh(double value); } 

След това използваме родния клас на JNA, за да създадем конкретна реализация на този интерфейс, за да можем да извикаме нашия API:

CMath lib = Native.load(Platform.isWindows()?"msvcrt":"c", CMath.class); double result = lib.cosh(0); 

Най-интересното тук е част призива на натоварване () метод . Необходими са два аргумента: името на динамичната библиотека и Java интерфейс, описващ методите, които ще използваме. Той връща конкретна реализация на този интерфейс, което ни позволява да извикаме някой от неговите методи.

Сега имената на динамичните библиотеки обикновено зависят от системата и стандартната библиотека на C не е изключение: libc.so в повечето базирани на Linux системи, но msvcrt.dll в Windows. Ето защо използвахме помощния клас Platform , включен в JNA, за да проверим в коя платформа работим и да изберем правилното име на библиотеката.

Забележете, че не трябва да добавяме разширението .so или .dll , както се подразбира. Също така, за системи, базирани на Linux, не е необходимо да посочваме префикса „lib“, който е стандартен за споделените библиотеки.

Тъй като динамичните библиотеки се държат като Singletons от гледна точка на Java, обичайната практика е да се декларира поле INSTANCE като част от декларацията на интерфейса:

public interface CMath extends Library { CMath INSTANCE = Native.load(Platform.isWindows() ? "msvcrt" : "c", CMath.class); double cosh(double value); } 

4.2. Основни типове картографиране

В нашия първоначален пример извиканият метод използва само примитивни типове както като свой аргумент, така и като върната стойност. JNA обработва тези случаи автоматично, обикновено използвайки естествените си аналози на Java при картографиране от C типове:

  • char => байт
  • кратко => кратко
  • wchar_t => char
  • int => int
  • long => com.sun.jna.NativeLong
  • дълго дълго => дълго
  • плувка => плувка
  • двойно => двойно
  • char * => String

Картографирането, което може да изглежда странно, е това, което се използва за родния дълъг тип. Това е така, защото в C / C ++ типът long може да представлява 32- или 64-битова стойност, в зависимост от това дали работим на 32- или 64-битова система.

За да се справи с този проблем, JNA предоставя типа NativeLong , който използва правилния тип в зависимост от архитектурата на системата.

4.3. Структури и съюзи

Друг често срещан сценарий се занимава с родния код APIs, които очакват указател към някои структура или обединение тип . Когато създавате Java интерфейс за достъп до него, съответният аргумент или върната стойност трябва да бъде тип Java, който разширява съответно Structure или Union .

For instance, given this C struct:

struct foo_t { int field1; int field2; char *field3; };

Its Java peer class would be:

@FieldOrder({"field1","field2","field3"}) public class FooType extends Structure { int field1; int field2; String field3; };

JNA requires the @FieldOrder annotation so it can properly serialize data into a memory buffer before using it as an argument to the target method.

Alternatively, we can override the getFieldOrder() method for the same effect. When targeting a single architecture/platform, the former method is generally good enough. We can use the latter to deal with alignment issues across platforms, that sometimes require adding some extra padding fields.

Unions work similarly, except for a few points:

  • No need to use a @FieldOrder annotation or implement getFieldOrder()
  • We have to call setType() before calling the native method

Let's see how to do it with a simple example:

public class MyUnion extends Union { public String foo; public double bar; }; 

Now, let's use MyUnion with a hypothetical library:

MyUnion u = new MyUnion(); u.foo = "test"; u.setType(String.class); lib.some_method(u); 

If both foo and bar where of the same type, we'd have to use the field's name instead:

u.foo = "test"; u.setType("foo"); lib.some_method(u);

4.4. Using Pointers

JNA offers a Pointer abstraction that helps to deal with APIs declared with untyped pointer – typically a void *. This class offers methods that allow read and write access to the underlying native memory buffer, which has obvious risks.

Before start using this class, we must be sure we clearly understand who “owns” the referenced memory at each time. Failing to do so will likely produce hard to debug errors related to memory leaks and/or invalid accesses.

Assuming we know what we're doing (as always), let's see how we can use the well-known malloc() and free() functions with JNA, used to allocate and release a memory buffer. First, let's again create our wrapper interface:

public interface StdC extends Library { StdC INSTANCE = // ... instance creation omitted Pointer malloc(long n); void free(Pointer p); } 

Now, let's use it to allocate a buffer and play with it:

StdC lib = StdC.INSTANCE; Pointer p = lib.malloc(1024); p.setMemory(0l, 1024l, (byte) 0); lib.free(p); 

The setMemory() method just fills the underlying buffer with a constant byte value (zero, in this case). Notice that the Pointer instance has no idea to what it is pointing to, much less its size. This means that we can quite easily corrupt our heap using its methods.

We'll see later how we can mitigate such errors using JNA's crash protection feature.

4.5. Handling Errors

Old versions of the standard C library used the global errno variable to store the reason a particular call failed. For instance, this is how a typical open() call would use this global variable in C:

int fd = open("some path", O_RDONLY); if (fd < 0) { printf("Open failed: errno=%d\n", errno); exit(1); }

Of course, in modern multi-threaded programs this code would not work, right? Well, thanks to C's preprocessor, developers can still write code like this and it will work just fine. It turns out that nowadays, errno is a macro that expands to a function call:

// ... excerpt from bits/errno.h on Linux #define errno (*__errno_location ()) // ... excerpt from  from Visual Studio #define errno (*_errno())

Now, this approach works fine when compiling source code, but there's no such thing when using JNA. We could declare the expanded function in our wrapper interface and call it explicitly, but JNA offers a better alternative: LastErrorException.

Any method declared in wrapper interfaces with throws LastErrorException will automatically include a check for an error after a native call. If it reports an error, JNA will throw a LastErrorException, which includes the original error code.

Let's add a couple of methods to the StdC wrapper interface we've used before to show this feature in action:

public interface StdC extends Library { // ... other methods omitted int open(String path, int flags) throws LastErrorException; int close(int fd) throws LastErrorException; } 

Now, we can use open() in a try/catch clause:

StdC lib = StdC.INSTANCE; int fd = 0; try { fd = lib.open("/some/path",0); // ... use fd } catch (LastErrorException err) { // ... error handling } finally { if (fd > 0) { lib.close(fd); } } 

In the catch block, we can use LastErrorException.getErrorCode() to get the original errno value and use it as part of the error handling logic.

4.6. Handling Access Violations

As mentioned before, JNA does not protect us from misusing a given API, especially when dealing with memory buffers passed back and forth native code. In normal situations, such errors result in an access violation and terminate the JVM.

JNA supports, to some extent, a method that allows Java code to handle access violation errors. There are two ways to activate it:

  • Setting the jna.protected system property to true
  • Calling Native.setProtected(true)

След като активираме този защитен режим, JNA ще улови грешки при нарушаване на достъпа, които обикновено водят до срив, и извежда изключение java.lang.Error . Можем да проверим дали това работи, като използваме указател, инициализиран с невалиден адрес и се опитваме да напишем някои данни в него:

Native.setProtected(true); Pointer p = new Pointer(0l); try { p.setMemory(0, 100*1024, (byte) 0); } catch (Error err) { // ... error handling omitted } 

Както обаче се посочва в документацията, тази функция трябва да се използва само за отстраняване на грешки / разработка.

5. Заключение

В тази статия показахме как да използваме JNA за лесен достъп до родния код в сравнение с JNI.

Както обикновено, целият код е достъпен в GitHub.