Menggunakan JNA untuk Mengakses Pustaka Dinamis Asli

1. Ikhtisar

Dalam tutorial ini, kita akan melihat bagaimana menggunakan perpustakaan Java Native Access (disingkat JNA) untuk mengakses perpustakaan asli tanpa menulis kode JNI (Java Native Interface).

2. Mengapa JNA?

Selama bertahun-tahun, Java dan bahasa berbasis JVM lainnya sebagian besar telah memenuhi motto "tulis sekali, jalankan di mana-mana". Namun, terkadang kita perlu menggunakan kode asli untuk mengimplementasikan beberapa fungsi :

  • Menggunakan kembali kode lama yang ditulis dalam C / C ++ atau bahasa lain yang dapat membuat kode asli
  • Mengakses fungsionalitas khusus sistem tidak tersedia di runtime Java standar
  • Mengoptimalkan kecepatan dan / atau penggunaan memori untuk bagian tertentu dari aplikasi tertentu.

Awalnya, persyaratan semacam ini berarti kami harus menggunakan JNI - Java Native Interface. Meskipun efektif, pendekatan ini memiliki kekurangan dan umumnya dihindari karena beberapa masalah:

  • Mewajibkan pengembang untuk menulis "kode perekat" C / C ++ untuk menjembatani Java dan kode asli
  • Membutuhkan kompilasi lengkap dan tautan alat yang tersedia untuk setiap sistem target
  • Nilai marsal dan unmarshalling ke dan dari JVM adalah tugas yang membosankan dan rawan kesalahan
  • Masalah hukum dan dukungan saat mencampur Java dan pustaka asli

JNA datang untuk menyelesaikan sebagian besar kerumitan yang terkait dengan penggunaan JNI. Secara khusus, tidak perlu membuat kode JNI untuk menggunakan kode asli yang terletak di pustaka dinamis, yang membuat keseluruhan proses menjadi lebih mudah.

Tentu saja, ada beberapa trade-off:

  • Kami tidak dapat langsung menggunakan pustaka statis
  • Lebih lambat jika dibandingkan dengan kode JNI buatan tangan

Untuk sebagian besar aplikasi, manfaat kesederhanaan JNA jauh lebih besar daripada kerugiannya. Dengan demikian, wajar untuk mengatakan bahwa, kecuali kita memiliki persyaratan yang sangat spesifik, JNA saat ini mungkin adalah pilihan terbaik yang tersedia untuk mengakses kode asli dari Java - atau bahasa berbasis JVM lainnya.

3. Pengaturan Proyek JNA

Hal pertama yang harus kita lakukan untuk menggunakan JNA adalah menambahkan dependensinya ke pom.xml proyek kita :

 net.java.dev.jna jna-platform 5.6.0  

Versi terbaru jna-platform dapat diunduh dari Maven Central.

4. Menggunakan JNA

Menggunakan JNA adalah proses dua langkah:

  • Pertama, kita membuat antarmuka Java yang memperluas antarmuka Library JNA untuk mendeskripsikan metode dan tipe yang digunakan saat memanggil kode native target.
  • Selanjutnya, kami meneruskan antarmuka ini ke JNA yang mengembalikan implementasi konkret antarmuka ini yang kami gunakan untuk memanggil metode asli

4.1. Metode Panggilan dari C Standard Library

Untuk contoh pertama kita, mari gunakan JNA untuk memanggil fungsi cosh dari pustaka C standar, yang tersedia di sebagian besar sistem. Metode ini mengambil argumen ganda dan menghitung kosinus hiperboliknya. Program AC dapat menggunakan fungsi ini hanya dengan memasukkan file header:

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

Mari buat antarmuka Java yang diperlukan untuk memanggil metode ini:

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

Selanjutnya, kami menggunakan kelas Native JNA untuk membuat implementasi konkret dari antarmuka ini sehingga kami dapat memanggil API kami:

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

Bagian yang benar-benar menarik di sini adalah panggilan ke beban () metode . Dibutuhkan dua argumen: nama pustaka dinamis dan antarmuka Java yang menjelaskan metode yang akan kita gunakan. Ini mengembalikan implementasi konkret dari antarmuka ini, memungkinkan kita untuk memanggil salah satu metodenya.

Sekarang, nama pustaka dinamis biasanya bergantung pada sistem, dan pustaka standar C tidak terkecuali: libc.so di sebagian besar sistem berbasis Linux, tetapi pnidui.dll di Windows. Inilah mengapa kami menggunakan kelas pembantu Platform , termasuk dalam JNA, untuk memeriksa platform mana yang kami jalankan dan memilih nama perpustakaan yang tepat.

Perhatikan bahwa kita tidak perlu menambahkan ekstensi .so atau .dll , seperti yang tersirat. Selain itu, untuk sistem berbasis Linux, kami tidak perlu menentukan awalan "lib" yang merupakan standar untuk pustaka bersama.

Karena perpustakaan dinamis berperilaku seperti Singletons dari perspektif Java, praktik umum adalah mendeklarasikan bidang INSTANCE sebagai bagian dari deklarasi antarmuka:

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

4.2. Pemetaan Jenis Dasar

Dalam contoh awal kita, metode yang dipanggil hanya menggunakan tipe primitif sebagai argumen dan nilai kembaliannya. JNA menangani kasus tersebut secara otomatis, biasanya menggunakan rekan Java alami mereka saat memetakan dari tipe C:

  • char => byte
  • pendek => pendek
  • wchar_t => char
  • int => int
  • long => com.sun.jna.NativeLong
  • panjang panjang => panjang
  • float => float
  • ganda => ganda
  • char * => String

Pemetaan yang mungkin terlihat aneh adalah yang digunakan untuk tipe panjang asli . Ini karena, dalam C / C ++, tipe panjang mungkin mewakili nilai 32 atau 64 bit, bergantung pada apakah kita menjalankan sistem 32 atau 64 bit.

Untuk mengatasi masalah ini, JNA menyediakan tipe NativeLong , yang menggunakan tipe yang tepat bergantung pada arsitektur sistem.

4.3. Struktur dan Serikat Pekerja

Skenario umum lainnya adalah berurusan dengan API kode native yang mengharapkan pointer ke beberapa tipe struct atau union . Saat membuat antarmuka Java untuk mengaksesnya, argumen yang sesuai atau nilai kembalian harus berupa tipe Java yang masing-masing memperluas Structure atau 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)

Setelah kami mengaktifkan mode terlindung ini, JNA akan menangkap kesalahan pelanggaran akses yang biasanya mengakibatkan crash dan memunculkan pengecualian java.lang.Error . Kami dapat memverifikasi bahwa ini berfungsi menggunakan Pointer yang diinisialisasi dengan alamat tidak valid dan mencoba menulis beberapa data ke sana:

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

Namun, seperti yang dinyatakan dalam dokumentasi, fitur ini hanya boleh digunakan untuk tujuan debugging / pengembangan.

5. Kesimpulan

Dalam artikel ini, kami telah menunjukkan cara menggunakan JNA untuk mengakses kode native dengan mudah jika dibandingkan dengan JNI.

Seperti biasa, semua kode tersedia di GitHub.