CLOVER🍀

That was when it all began.

RocksDB JNIで遊ぶ

最近遊んだJavaライブラリがRocksDBのJNIバインディングを使っているのを見て、けっこう簡単に導入できるの?という感じを期待して
試してみることにしました。

RocksDBとは?

そもそも、RocksDBについて。

RocksDB | A persistent key-value store | RocksDB

Facebook、GoogleのLevelDBを採用したキーバリュー型ストア「RocksDB」公開。マルチコアと高速ストレージに最適化 - Publickey

Facebook、Key-Valueストア「RocksDB」をオープンソース化:C++ライブラリとして構築 - @IT

RocksDB と SSD の親和性調査のまとめ

GoogleのLevelDBを使い、CPUコア多数持つサーバーでのスケーラビリティ、高速なストレージの効率的な利用、IOバウンド、インメモリなどのワークロードを
サポートしたKey Value Storeだそうです。

そう、Key Value Store。

なので、オペレーション的にはKey&Valueなものが目立ちます。

Getting started | RocksDB

ドキュメントは、GitHubWikiを見るのが良さそうです。
Home · facebook/rocksdb Wiki · GitHub

基本的な操作はこちら。

Basic Operations · facebook/rocksdb Wiki · GitHub

RocksDB JNI

RocksDB自体はC++で書かれているのですが、これのJava向けのAPI(JNI使用)が提供されています。

https://github.com/facebook/rocksdb/tree/v5.9.2/java

RocksJava Basics · facebook/rocksdb Wiki · GitHub

あまり詳細なサンプルなどはないようですが、雰囲気を見つつ書いていってみます。

使ってみる

Maven依存関係。

        <dependency>
            <groupId>org.rocksdb</groupId>
            <artifactId>rocksdbjni</artifactId>
            <version>5.9.2</version>
        </dependency>

あと、テストライブラリも用意。

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.0.2</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.9.0</version>
        </dependency>

テストコードの雛形はこちら。
src/test/java/org/littlewings/rocksdbjni/RocksDBJniTest/RocksDBJniTest.java

package org.littlewings.rocksdbjni.RocksDBJniTest;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import org.junit.jupiter.api.Test;
import org.rocksdb.ColumnFamilyDescriptor;
import org.rocksdb.ColumnFamilyHandle;
import org.rocksdb.ColumnFamilyOptions;
import org.rocksdb.DBOptions;
import org.rocksdb.Options;
import org.rocksdb.RocksDB;
import org.rocksdb.RocksDBException;

import static org.assertj.core.api.Assertions.assertThat;

public class RocksDBJniTest {
    // ここに、テストを書く!
}

初めてのRocksDB

Wikiを見つつ、書いてみたのがこちら。

    @Test
    public void gettingStarted() {
        // RocksDB.loadLibrary();

        try (Options options = new Options().setCreateIfMissing(true);
             RocksDB rocks = RocksDB.open(options, "sample-db")) {
            // put
            rocks.put("key".getBytes(StandardCharsets.UTF_8), "value".getBytes(StandardCharsets.UTF_8));

            // get
            assertThat(rocks.get("key".getBytes(StandardCharsets.UTF_8))).isEqualTo("value".getBytes(StandardCharsets.UTF_8));
            assertThat(rocks.get("missing".getBytes(StandardCharsets.UTF_8))).isNull();

            // get(配列受け取り)
            byte[] valueBytes = new byte[5];
            assertThat(rocks.get("key".getBytes(StandardCharsets.UTF_8), valueBytes))
                    .isEqualTo(5);
            assertThat(valueBytes).isEqualTo("value".getBytes(StandardCharsets.UTF_8));

            // exist
            StringBuilder builder = new StringBuilder();
            assertThat(rocks.keyMayExist("key".getBytes(StandardCharsets.UTF_8), builder)).isTrue();
            assertThat(builder.toString()).isEqualTo("value");

            builder = new StringBuilder();
            assertThat(rocks.keyMayExist("missing".getBytes(StandardCharsets.UTF_8), builder)).isFalse();
            assertThat(builder.toString()).isEmpty();

            // delete
            rocks.delete("key".getBytes(StandardCharsets.UTF_8));
            assertThat(rocks.get("key".getBytes(StandardCharsets.UTF_8))).isNull();
        } catch (RocksDBException e) {
            throw new RuntimeException(e);
        }
    }

RocksDB#loadLibraryはネイティブライブラリのロードのためのものですが、この記述自体はRocksDBのstaticイニシャライザーにも実装されていますし、
その他の各種クラス利用時にも呼び出されていたりします。

RocksDB.loadLibrary();

https://github.com/facebook/rocksdb/blob/v5.9.2/java/src/main/java/org/rocksdb/RocksDB.java#L34-L36
https://github.com/facebook/rocksdb/blob/v5.9.2/java/src/main/java/org/rocksdb/Options.java#L24-L26

なので、明示的な呼び出しは不要?ライブラリロード先のパスをデフォルト以外のものにする場合は、パスを指定可能なオーバーロードされたバージョンを
使用するとよいです。

なお、このRockdsDBのJARに含まれているネイティブライブラリは、こちらになります。

$ jar -tvf ~/.m2/repository/org/rocksdb/rocksdbjni/5.9.2/rocksdbjni-5.9.2.jar  | grep -E 'dll|so'
8304735 Tue Dec 19 10:26:10 JST 2017 librocksdbjni-linux32.so
8162410 Tue Dec 19 10:14:02 JST 2017 librocksdbjni-linux64.so
9137088 Wed Jan 03 11:58:08 JST 2018 librocksdbjni-linux-ppc64le.so
6688256 Wed Jan 03 19:46:16 JST 2018 librocksdbjni-win64.dll

RocksDBインスタンスの取得は、Optionを作成後にRocksDB#openで行います。

        try (Options options = new Options().setCreateIfMissing(true);
             RocksDB rocks = RocksDB.open(options, "sample-db")) {

RocksDB#openに渡すパスは、RocksDBのファイルを配置するためのディレクトリです。

今回の指定だと、カレントディレクトリにこういうのができあがります。

$ find sample-db -type f
sample-db/LOG
sample-db/MANIFEST-000001
sample-db/CURRENT
sample-db/OPTIONS-000005
sample-db/LOCK
sample-db/000003.log
sample-db/IDENTITY

WikiにはOptions#setCreateIfMissingの指定は書いてありますが、Options指定なしのRocksDB#openを呼び出すと、暗黙的に指定してくれていたりします。
https://github.com/facebook/rocksdb/blob/v5.9.2/java/src/main/java/org/rocksdb/RocksDB.java#L155-L161

操作は、キーも値もbyte配列で行います。

            // put
            rocks.put("key".getBytes(StandardCharsets.UTF_8), "value".getBytes(StandardCharsets.UTF_8));

            // get
            assertThat(rocks.get("key".getBytes(StandardCharsets.UTF_8))).isEqualTo("value".getBytes(StandardCharsets.UTF_8));
            assertThat(rocks.get("missing".getBytes(StandardCharsets.UTF_8))).isNull();

get時に、byte配列を渡してその中身を詰めてもらうというやり方もできます。

            // get(配列受け取り)
            byte[] valueBytes = new byte[5];
            assertThat(rocks.get("key".getBytes(StandardCharsets.UTF_8), valueBytes))
                    .isEqualTo(5);
            assertThat(valueBytes).isEqualTo("value".getBytes(StandardCharsets.UTF_8));

キーの存在確認…の割に、値も同時に取れたりします。しかもStringBuilderで。

            // exist
            StringBuilder builder = new StringBuilder();
            assertThat(rocks.keyMayExist("key".getBytes(StandardCharsets.UTF_8), builder)).isTrue();
            assertThat(builder.toString()).isEqualTo("value");

            builder = new StringBuilder();
            assertThat(rocks.keyMayExist("missing".getBytes(StandardCharsets.UTF_8), builder)).isFalse();
            assertThat(builder.toString()).isEmpty();

delete。

            // delete
            rocks.delete("key".getBytes(StandardCharsets.UTF_8));
            assertThat(rocks.get("key".getBytes(StandardCharsets.UTF_8))).isNull();

そして、最後にcloseします。今回は、try-with-resources文でクローズしています。
Memory Management

Wikiにも記載がありますが、AbstractNativeReference(RocksDBなどの親クラス)#finalizeでもcloseを呼び出すように実装されてはいますが、
それに頼るべきでない旨も合わせて書いてあります。

実際、deprecateみたいですし、そもそもちゃんとcloseすべきですよね。
https://github.com/facebook/rocksdb/blob/v5.9.2/java/src/main/java/org/rocksdb/AbstractNativeReference.java#L67-L75

その他、merge、multiGet、deleteRange、iterator、snapshotなどなどあるようですが、今回はパス…。

Column Family

WikiJava APIの部分にちょっとだけ載っていた割には、サンプルも特になかったのでチャレンジという意味でColumn Familyも触ってみることにしました。
RocksJava Basics · facebook/rocksdb Wiki · GitHub

Column Familyを使うと、類似のキー、値をグループ化して管理することができます。

Column Families · facebook/rocksdb Wiki · GitHub

Column Familyを指定せずにアクセスした場合は、「デフォルトの」Column Familyとして扱われているようです。

では、Javaコードで書いてみましょう。

    @Test
    public void withColumnFamily() {
        try (ColumnFamilyOptions columnFamilyOptions = new ColumnFamilyOptions().optimizeUniversalStyleCompaction()) {
            List<ColumnFamilyDescriptor> columnFamilyDescriptors = Arrays.asList(
                    new ColumnFamilyDescriptor(RocksDB.DEFAULT_COLUMN_FAMILY, columnFamilyOptions),
                    new ColumnFamilyDescriptor("column-family1".getBytes(), columnFamilyOptions),
                    new ColumnFamilyDescriptor("column-family2".getBytes(), columnFamilyOptions)
            );

            List<ColumnFamilyHandle> columnFamilyHandles = new ArrayList<>();

            try (DBOptions options = new DBOptions().setCreateIfMissing(true).setCreateMissingColumnFamilies(true);
                 RocksDB rocks = RocksDB.open(options, "column-family-db", columnFamilyDescriptors, columnFamilyHandles)) {
                // column handle
                ColumnFamilyHandle defaultColumnFamilyHandle = columnFamilyHandles.get(0);
                ColumnFamilyHandle columnFamily1Handle = columnFamilyHandles.get(1);
                ColumnFamilyHandle columnFamily2Handle = columnFamilyHandles.get(2);

                // put
                rocks.put(defaultColumnFamilyHandle,
                        "key".getBytes(StandardCharsets.UTF_8), "value-default".getBytes(StandardCharsets.UTF_8));
                rocks.put(columnFamily1Handle,
                        "key".getBytes(StandardCharsets.UTF_8), "value-column-family1".getBytes(StandardCharsets.UTF_8));
                rocks.put(columnFamily2Handle,
                        "key".getBytes(StandardCharsets.UTF_8), "value-column-family2".getBytes(StandardCharsets.UTF_8));

                // get
                assertThat(rocks.get(defaultColumnFamilyHandle, "key".getBytes(StandardCharsets.UTF_8))).isEqualTo("value-default".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get(columnFamily1Handle, "key".getBytes(StandardCharsets.UTF_8))).isEqualTo("value-column-family1".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get(columnFamily2Handle, "key".getBytes(StandardCharsets.UTF_8))).isEqualTo("value-column-family2".getBytes(StandardCharsets.UTF_8));

                // delete
                rocks.delete(defaultColumnFamilyHandle, "key".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get(defaultColumnFamilyHandle, "key".getBytes(StandardCharsets.UTF_8)))
                        .isNull();
                assertThat(rocks.get(columnFamily1Handle, "key".getBytes(StandardCharsets.UTF_8)))
                        .isEqualTo("value-column-family1".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get(columnFamily2Handle, "key".getBytes(StandardCharsets.UTF_8)))
                        .isEqualTo("value-column-family2".getBytes(StandardCharsets.UTF_8));

                // default column family
                rocks.put(defaultColumnFamilyHandle,
                        "key1".getBytes(StandardCharsets.UTF_8), "value1".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get("key1".getBytes(StandardCharsets.UTF_8))).isEqualTo("value1".getBytes(StandardCharsets.UTF_8));

                rocks.put("key2".getBytes(StandardCharsets.UTF_8), "value2".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get(defaultColumnFamilyHandle, "key2".getBytes(StandardCharsets.UTF_8))).isEqualTo("value2".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get("key2".getBytes(StandardCharsets.UTF_8))).isEqualTo("value2".getBytes(StandardCharsets.UTF_8));
            } catch (RocksDBException e) {
                throw new RuntimeException(e);
            }
        }
    }

Column Familyを使用する場合は、まずColumn Familyの定義を行うところから始めます。

        try (ColumnFamilyOptions columnFamilyOptions = new ColumnFamilyOptions().optimizeUniversalStyleCompaction()) {
            List<ColumnFamilyDescriptor> columnFamilyDescriptors = Arrays.asList(
                    new ColumnFamilyDescriptor(RocksDB.DEFAULT_COLUMN_FAMILY, columnFamilyOptions),
                    new ColumnFamilyDescriptor("column-family1".getBytes(), columnFamilyOptions),
                    new ColumnFamilyDescriptor("column-family2".getBytes(), columnFamilyOptions)
            );

今回は3カラムを用意。ここでも、Column Family名の指定はbyte配列となります。

RocksDB#DEFAULT_COLUMN_FAMILYというのは、「default」という名前のColumn Familyです。
https://github.com/facebook/rocksdb/blob/v5.9.2/java/src/main/java/org/rocksdb/RocksDB.java#L22

空のListを用意していますが、これはRocksDB#open時に中身を詰めてもらうことになります。

            List<ColumnFamilyHandle> columnFamilyHandles = new ArrayList<>();

Column Familyを使用する場合、RocksDB#open時にはDBOptionsを渡す必要があります。この時に、Column Familyの定義(Descriptor)と先ほど作成した
空のListを渡しておきます。

            try (DBOptions options = new DBOptions().setCreateIfMissing(true).setCreateMissingColumnFamilies(true);
                 RocksDB rocks = RocksDB.open(options, "column-family-db", columnFamilyDescriptors, columnFamilyHandles)) {

すると、RocksDBのインスタンスが取得できるとともに、空のListにColumn FamilyのHandleが詰められています。

                // column handle
                ColumnFamilyHandle defaultColumnFamilyHandle = columnFamilyHandles.get(0);
                ColumnFamilyHandle columnFamily1Handle = columnFamilyHandles.get(1);
                ColumnFamilyHandle columnFamily2Handle = columnFamilyHandles.get(2);

Handleという変数名でRocksDB中のソースコード中も出てくるのですが、なんかC系っぽいですね。

以降は、このHandleを使って操作を行います。

put、get時に、このHandleを第1引数に指定してアクセスを行います。

                // put
                rocks.put(defaultColumnFamilyHandle,
                        "key".getBytes(StandardCharsets.UTF_8), "value-default".getBytes(StandardCharsets.UTF_8));
                rocks.put(columnFamily1Handle,
                        "key".getBytes(StandardCharsets.UTF_8), "value-column-family1".getBytes(StandardCharsets.UTF_8));
                rocks.put(columnFamily2Handle,
                        "key".getBytes(StandardCharsets.UTF_8), "value-column-family2".getBytes(StandardCharsets.UTF_8));

                // get
                assertThat(rocks.get(defaultColumnFamilyHandle, "key".getBytes(StandardCharsets.UTF_8))).isEqualTo("value-default".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get(columnFamily1Handle, "key".getBytes(StandardCharsets.UTF_8))).isEqualTo("value-column-family1".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get(columnFamily2Handle, "key".getBytes(StandardCharsets.UTF_8))).isEqualTo("value-column-family2".getBytes(StandardCharsets.UTF_8));

Column Familyを指定する場合は、同じキーであってもColumn Family単位で管理されることになるため、別々に扱われることになります。

なので、あるColumn Familyに紐づく値を削除しても、他のColumn Familyは特に変わりません。

                // delete
                rocks.delete(defaultColumnFamilyHandle, "key".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get(defaultColumnFamilyHandle, "key".getBytes(StandardCharsets.UTF_8)))
                        .isNull();
                assertThat(rocks.get(columnFamily1Handle, "key".getBytes(StandardCharsets.UTF_8)))
                        .isEqualTo("value-column-family1".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get(columnFamily2Handle, "key".getBytes(StandardCharsets.UTF_8)))
                        .isEqualTo("value-column-family2".getBytes(StandardCharsets.UTF_8));

また、Column Familyを指定せずに単純にput、getなどを行った場合は、「default」のColumn Familyを使用していると見なされます。

                // default column family
                rocks.put(defaultColumnFamilyHandle,
                        "key1".getBytes(StandardCharsets.UTF_8), "value1".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get("key1".getBytes(StandardCharsets.UTF_8))).isEqualTo("value1".getBytes(StandardCharsets.UTF_8));

                rocks.put("key2".getBytes(StandardCharsets.UTF_8), "value2".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get(defaultColumnFamilyHandle, "key2".getBytes(StandardCharsets.UTF_8))).isEqualTo("value2".getBytes(StandardCharsets.UTF_8));
                assertThat(rocks.get("key2".getBytes(StandardCharsets.UTF_8))).isEqualTo("value2".getBytes(StandardCharsets.UTF_8));

なんとなく、雰囲気はわかりましたね。

まとめ

RocksDBのJNIバイディングを試してみました。

RocksDB自体、初めて使ったのですが、けっこう簡単に使えて良かったですね。データベースとまではいかなくても、永続化したい&Key Value Store的なもので
アクセスできればよいくらいのものについては、使ってみてもいいかなと思います。