diff --git a/jnigen_db_native_interop/.gitignore b/jnigen_db_native_interop/.gitignore new file mode 100644 index 0000000..58ee391 --- /dev/null +++ b/jnigen_db_native_interop/.gitignore @@ -0,0 +1,5 @@ +/mvn_java +/.dart_tool +.DS_Store +/.idea +/build diff --git a/jnigen_db_native_interop/LICENSE b/jnigen_db_native_interop/LICENSE new file mode 100644 index 0000000..9748873 --- /dev/null +++ b/jnigen_db_native_interop/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2025, James Williams + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/jnigen_db_native_interop/README.md b/jnigen_db_native_interop/README.md new file mode 100644 index 0000000..ea7b30a --- /dev/null +++ b/jnigen_db_native_interop/README.md @@ -0,0 +1,116 @@ +# jni_leveldb + +A sample command-line application demonstrating how to create an idiomatic Dart wrapper around a Java implementation of LevelDB, a fast key-value storage library. This project showcases the evolution of a LevelDB wrapper from raw JNI bindings to a safe, clean, and Dart-native API using `jnigen`. + +## Prerequisites + +- Java Development Kit (JDK) version 1.8 or later must be installed, and the `JAVA_HOME` environment variable must be set to its location. + +## Setup and Running + +1. **Generate Bindings and Build JNI code:** + + Run `jnigen` to download the required Java libraries, generate Dart bindings, and build the JNI glue code. + + ```bash + dart run jni:setup + dart run jnigen:setup + dart run jnigen --config jnigen.yaml + ``` + +2. **Run the application:** + + ```bash + dart run bin/jni_leveldb.dart + ``` + +## From Raw Bindings to an Idiomatic Dart API + +The initial output of `jnigen` provides a low-level, direct mapping of the Java API. Using this directly in application code can be verbose, unsafe, and unidiomatic. This project demonstrates how to build a better wrapper by addressing common pitfalls. + +### 1. Resource Management + +JNI objects are handles to resources in the JVM. Failing to release them causes memory leaks. + +**The Problem:** Forgetting to call `release()` on JNI objects. + +**The Solution:** The best practice is to use the `using(Arena arena)` block, which automatically manages releasing all objects allocated within it, making your code safer and cleaner. For objects that live longer, you must manually call `release()`. + +*Example from the wrapper:* +```dart +void putBytes(Uint8List key, Uint8List value) { + using((arena) { + final jKey = JByteArray.from(key)..releasedBy(arena); + final jValue = JByteArray.from(value)..releasedBy(arena); + _db.put(jKey, jValue); + }); +} +``` + +### 2. Idiomatic API Design and Type Handling + +Raw bindings expose Java's conventions and require manual, repetitive type conversions. A wrapper class should expose a clean, Dart-like API. + +**The Problem:** Java method names (`createIfMissing$1`) and types (`JString`, `JByteArray`) are exposed directly to the application code. + +**The Solution:** Create a wrapper class that exposes methods with named parameters and standard Dart types (`String`, `Uint8List`), handling all the JNI conversions internally. + +*The Improved API:* +```dart +static LevelDB open(String path, {bool createIfMissing = true}) { ... } +void put(String key, String value) { ... } +String? get(String key) { ... } +``` + +This allows for clean, simple iteration in the application code: +```dart +for (var entry in db.entries) { + print('${entry.key}, ${entry.value}'); +} +``` + +### 3. JVM Initialization + +The JVM is a process-level resource and should be initialized only once when the application starts. + +**The Problem:** Calling `Jni.spawn()` inside library code. + +**The Solution:** `Jni.spawn()` belongs in a locatio where it will be called once, like your application's `main()` function, not in the library. In this example, the library code should assume the JVM is already running. + +*Correct Usage in `bin/jni_leveldb.dart`:* +```dart +void main(List arguments) { + // ... find JARs ... + Jni.spawn(classPath: jars); // Spawn the JVM once. + db(); // Run the application logic. +} +``` + +### The Final Result + +By applying these principles, the application logic becomes simple, readable, and free of JNI-specific details. + +*Final Application Code:* +```dart +import 'package:jni_leveldb/src/leveldb.dart'; + +void db() { + final db = LevelDB.open('example.db'); + try { + db.put('Akron', 'Ohio'); + db.put('Tampa', 'Florida'); + db.put('Cleveland', 'Ohio'); + + print('Tampa is in ${db.get('Tampa')}'); + + db.delete('Akron'); + + print('\nEntries in database:'); + for (var entry in db.entries) { + print('${entry.key}, ${entry.value}'); + } + } finally { + db.close(); + } +} +``` \ No newline at end of file diff --git a/jnigen_db_native_interop/bin/jni_leveldb.dart b/jnigen_db_native_interop/bin/jni_leveldb.dart new file mode 100644 index 0000000..fd0bf38 --- /dev/null +++ b/jnigen_db_native_interop/bin/jni_leveldb.dart @@ -0,0 +1,30 @@ +import 'package:jni_leveldb/jni_leveldb.dart' as jni_leveldb; +import 'dart:io'; +import 'package:jni/jni.dart'; + +import 'package:path/path.dart'; + +const jarError = ''; + +void main(List arguments) { + +const jarDir = './mvn_jar/'; + List jars; + try { + jars = Directory(jarDir) + .listSync() + .map((e) => e.path) + .where((path) => path.endsWith('.jar')) + .toList(); + } on OSError catch (_) { + stderr.writeln(jarError); + return; + } + if (jars.isEmpty) { + stderr.writeln(jarError); + return; + } + Jni.spawn(classPath: jars); + + jni_leveldb.db(); +} diff --git a/jnigen_db_native_interop/jnigen.yaml b/jnigen_db_native_interop/jnigen.yaml new file mode 100644 index 0000000..741d27a --- /dev/null +++ b/jnigen_db_native_interop/jnigen.yaml @@ -0,0 +1,15 @@ +output: + dart: + path: 'lib/leveldb/' + +classes: + - 'org.iq80.leveldb.DB' + - 'org.iq80.leveldb.Options' + - 'org.iq80.leveldb.DBIterator' + - 'org.iq80.leveldb.impl.Iq80DBFactory' + - 'org.iq80.leveldb.impl.SeekingIteratorAdapter' + - 'java.io.File' + +maven_downloads: + source_deps: + - 'org.iq80.leveldb:leveldb:0.12' diff --git a/jnigen_db_native_interop/lib/jni_leveldb.dart b/jnigen_db_native_interop/lib/jni_leveldb.dart new file mode 100644 index 0000000..a00fef8 --- /dev/null +++ b/jnigen_db_native_interop/lib/jni_leveldb.dart @@ -0,0 +1,22 @@ +import 'package:jni_leveldb/src/leveldb.dart'; + +void db() { + final db = LevelDB.open('example.db'); + try { + db.put('Akron', 'Ohio'); + db.put('Tampa', 'Florida'); + db.put('Cleveland', 'Ohio'); + db.put('Sunnyvale', 'California'); + + print('Tampa is in ${db.get('Tampa')}'); + + db.delete('Akron'); + + print('\nEntries in database:'); + for (var entry in db.entries) { + print('${entry.key}, ${entry.value}'); + } + } finally { + db.close(); + } +} \ No newline at end of file diff --git a/jnigen_db_native_interop/lib/src/leveldb.dart b/jnigen_db_native_interop/lib/src/leveldb.dart new file mode 100644 index 0000000..f4caafe --- /dev/null +++ b/jnigen_db_native_interop/lib/src/leveldb.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:jni/jni.dart'; +import 'package:jni_leveldb/leveldb/java/io/File.dart' as java; +import 'package:jni_leveldb/leveldb/org/iq80/leveldb/DB.dart'; +import 'package:jni_leveldb/leveldb/org/iq80/leveldb/Options.dart'; +import 'package:jni_leveldb/leveldb/org/iq80/leveldb/impl/Iq80DBFactory.dart'; +import 'package:jni_leveldb/leveldb/org/iq80/leveldb/impl/SeekingIteratorAdapter.dart'; + +class LevelDB { + final DB _db; + + LevelDB._(this._db); + + static LevelDB open(String path, {bool createIfMissing = true}) { + final options = Options()..createIfMissing$1(createIfMissing); + final file = java.File(path.toJString()); + final db = Iq80DBFactory.factory!.open(file, options); + if (db == null) { + throw Exception('Failed to open database at $path'); + } + return LevelDB._(db); + } + + void put(String key, String value) { + putBytes(utf8.encode(key), utf8.encode(value)); + } + + void putBytes(Uint8List key, Uint8List value) { + using((arena) { + final jKey = JByteArray.from(key)..releasedBy(arena); + final jValue = JByteArray.from(value)..releasedBy(arena); + _db.put(jKey, jValue); + }); + } + + String? get(String key) { + final value = getBytes(utf8.encode(key)); + if (value == null) { + return null; + } + return utf8.decode(value); + } + + Uint8List? getBytes(Uint8List key) { + return using((arena) { + final jKey = JByteArray.from(key)..releasedBy(arena); + final value = _db.get(jKey); + if (value == null) { + return null; + } + final bytes = value.toList(); + value.release(); + return Uint8List.fromList(bytes); + }); + } + + void delete(String key) { + deleteBytes(utf8.encode(key)); + } + + void deleteBytes(Uint8List key) { + using((arena) { + final jKey = JByteArray.from(key)..releasedBy(arena); + _db.delete(jKey); + }); + } + + void close() { + _db.release(); + } + + Iterable> get entries sync* { + final iterator = _db.iterator()?.as(SeekingIteratorAdapter.type); + if (iterator == null) return; + try { + iterator.seekToFirst(); + while (iterator.hasNext()) { + final entry = iterator.next(); + if (entry == null) continue; + + final keyBytes = entry.getKey(); + final valueBytes = entry.getValue(); + + if (keyBytes == null || valueBytes == null) { + keyBytes?.release(); + valueBytes?.release(); + entry.release(); + continue; + } + + final key = utf8.decode(keyBytes.toList()); + final value = utf8.decode(valueBytes.toList()); + + keyBytes.release(); + valueBytes.release(); + entry.release(); + + yield MapEntry(key, value); + } + } finally { + iterator.release(); + } + } +} diff --git a/jnigen_db_native_interop/pubspec.yaml b/jnigen_db_native_interop/pubspec.yaml new file mode 100644 index 0000000..618b643 --- /dev/null +++ b/jnigen_db_native_interop/pubspec.yaml @@ -0,0 +1,19 @@ +name: jni_leveldb +description: A sample command-line application. +version: 1.0.0 +# repository: https://github.com/my_org/my_repo + +publish_to: none + +environment: + sdk: ^3.6.2 + +# Add regular dependencies here. +dependencies: + jnigen: + path: ../native-non-fork/pkgs/jnigen + jni: ^0.14.1 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.24.0