Java のオブジェクトグラフストレージエンジン MicroStream の使い方

f:id:Naotsugu:20220204211005p:plain


はじめに

MicroStream 5.0 からOSS化された、オブジェクトグラフストレージエンジンの使い方の紹介です。


MicroStream とは

MicroStream は Javaネイティブなオブジェクトグラフストレージエンジンです。 Java自体のシリアライズ機能と似ていますが、これをデータストアのソリューションとして利用するには様々な欠点があります。 MicroStream は快適かつ効率的なデータストアのソリューションとして利用できます。

  • オブジェクトグラフを部分的にオンデマンドで永続化・ロード・アップデートできる
  • サイズとパフォーマンスの両面で非常に効率的
  • クラス構造の変更に対応するソリューションが提供されている(内部ヒューリスティックによる暗黙的な方法と、ユーザ定義のマッピング戦略による明示的な方法)
  • Java構造を自動的に扱うことができる(ラムダ、プロキシ、スレッドなどのJVM内部と関連するインスタンスなどは除く)

MicroStream はあくまでもストレージエンジンであり、一般的な DBMS が提供する多くの機能は意図的に省かれています。 旧来の DBMS をアプリケーションで利用する場合、冗長なユーザー、接続、セッション管理というオーバーヘッドと問題、そして古いクエリーインターフェースの時代遅れの概念と制限をすべて受け止めるという代償を伴います。 MicroStream は、現代の技術に完全に適合し、冗長なオーバーヘッドや時代遅れの二次的なサーバープロセスの複雑さをもたらさない保存ライブラリとして利用できます。

ストレージは抽象化されているため、ローカルファイルシステムであったり、PostgreSQLであったり、AWS S3 であったり、Redis であったり、様々なファイルシステムが選択できます。


Hello World

組み込みのストレージエンジンを使った簡単な例を見てみましょう。

以下のような依存を定義します。

Gradle の場合

dependencies {
    implementation 'one.microstream:microstream-storage-embedded:06.01.00-MS-GA'
}

Gradle Kotlin DSLの場合

dependencies {
    implementation("one.microstream:microstream-storage-embedded:06.01.00-MS-GA")
}

Maven の場合

<dependencies>
    <dependency>
        <groupId>one.microstream</groupId>
        <artifactId>microstream-storage-embedded</artifactId>
        <version>06.01.00-MS-GA</version>
    </dependency>
</dependencies>


依存が定義できれば、以下のように Hello World! という文字列を保存できます。

public class App {
    public static void main(String[] args) {

        final EmbeddedStorageManager storageManager = EmbeddedStorage.start();
        storageManager.setRoot("Hello World!");
        storageManager.storeRoot();

        System.out.println(storageManager.root());

        storageManager.shutdown();
        System.exit(0);
    }
}

コンソールには Hello World! が出力されます。

少し詳しく見ていきましょう。

EmbeddedStorageManager storageManager = EmbeddedStorage.start();

上記はデフォルト構成で、ストレージマネージャを開始しています。デフォルトでは、プロジェクトルートの storage というディレクトリ内にデータベースが作成されます。 既存のストレージが見つかればそれを利用し、既存のストレージが見つからなければ新しいストレージを作成します。

storageManager.setRoot("Hello World!");
storageManager.storeRoot();

上記は、データベースのルートに Hello World! という文字列を設定し、storeRoot() で内容を永続化しています。 storageManager.storeRoot() ルートオブジェクトを保存する特殊なケースメソッドです。 ルート以外のオブジェクトを格納する場合は、storageManager.store(modifiedObject) を使います。

ここでは単純な文字列ですが、通常はアプリケーションに固有の明示的なルートオブジェクトを指定して設定します。 MicroStream のデータベースは、Java オブジェクトのオブジェクトグラフとなるため、データベースにはルートインスタンスを起点としてアクセスすることになります。

例えば以下のようなものをルートオブジェクトとして設定します。

public class Data {
    private final Books books = new Books();
    private final Shops shops = new Shops();
    private final Customers customers = new Customers();
    private final Purchases purchases = new Purchases();
    // ...
}

デフォルトでは、1つのインスタンスがエンティティグラフのルートとして登録でき、 EmbeddedStorage.root() を介してアクセスできます。 ルートオブジェクト指定して以下のように起動することもできます。

Data root = new Data();
final EmbeddedStorageManager storageManager = EmbeddedStorage.start(root);


設定

ルートオブジェクトと、ストレージのパスを指定したストレージマネージャの生成は以下のように行います。

final DataRoot root = new DataRoot();
final EmbeddedStorageManager storageManager = EmbeddedStorage
    .start(root, Paths.get("data"));

また、以下の依存を追加することで、

dependencies {
    implementation("microstream-storage-embedded-configuration:06.01.00-MS-GA")
}

ビルダ形式で各種設定を行うことができます。

EmbeddedStorageManager storageManager = EmbeddedStorageConfiguration.Builder()
    .setStorageDirectoryInUserHome("data-dir")
    .setBackupDirectory("backup-dir")
    .setChannelCount(4)
    .createEmbeddedStorageFoundation()
    .createEmbeddedStorageManager();

外だしの設定ファイルを利用して以下のようにすることもできます。

EmbeddedStorageManager storageManager = EmbeddedStorageConfiguration
    .load("/META-INF/microstream/storage.xml")
    .createEmbeddedStorageFoundation()
    .createEmbeddedStorageManager();


オブジェクトの操作

新しく作成されたオブジェクトを格納するには、そのオブジェクトの「所有者」を格納します。

Customer customer = new Customer("Alice");
root.customers.add(customer);
storageManager.store(root.customers);

既に保存済みのオブジェクトに対する変更は、intのような値型の場合、フィールドをメンバーを保持するオブジェクトを格納します。

customer.setAge(24);
storageManager.store(customer);

String のような不変オブジェクトの場合、も同様にそのオブジェクトを保持するオブジェクトを格納する必要がある点に注意してください。

customer.setName("Bob");
storageManager.store(customer);

複数オブジェクトを格納する storageManager.storeAll() メソッドもあり、Iterable か可変長引数を渡すことができます。 データの削除は、データの変更と同じ扱いとなります。


マルチスレッド環境化では、以下のように同期してデータベースを操作する必要があります。

XThreads.executeSynchronized(() -> {
    root.changeData();
    storageManager.store(root);
});

これによりオブジェクトグラフへの変更はすべて同期され、他のすべてのスレッドが現在の値を見ることができます。


ここで見た永続化は、変更されたオブジェクトを明示的に store() する必要があるということになります。 MicroStream のデフォルトの保存方法(Lazy Storing)は、まだ保存されていないインスタンスのみ保存され、インスタンスが以前に保存されていた場合、たとえそれが変更されていたとしても、再び保存されません。 つまり深くネストしたインスタンスの変更は、変更されたインスタンスを都度store()する必要があります。

これに対して Eager Storing を利用した場合は、参照されるインスタンスは、たとえ以前に保存されていたとしても保存されます。これはパフォーマンスを犠牲にして、変更された子オブジェクトも保存する動きとなります。

Eager Storing を利用するには以下のようにします。

Storer storer = storage.createEagerStorer();
storer.store(myData);
storer.commit();


データのロード

データの読み込みには、Eager と Lazy の2つがあり、デフォルトはイーガーローディングです。 これは、ストアドオブジェクトグラフのすべてのオブジェクトが直ちにロードされることを意味します。

final EmbeddedStorageManager storage = EmbeddedStorage.start();
if (storage.root() == null) {
    System.out.println("No existing Database found, creating a new one:");
    MyRoot root = new MyRoot();
    storage.setRoot(root);
    storage.storeRoot();
} else {
    MyRoot root = (MyRoot) storage.root();
    root.myObjects.forEach(System.out::println);
}

既存のデータベースが見つかった場合、MicroStreamデータベースインスタンスの起動時に自動的にデータがロードされます。


大量のデータについては上記の動作はふさわしくないでしょう。その場合には Lazy を利用します。

public class BusinessYear {
    private Lazy<ArrayList<Turnover>> turnovers = Lazy.Reference(new ArrayList<>());
}

Lazy にてラップすれば、必要時までデータのロードが遅延します。 値の取得は以下のように get() を使います。

ArrayList<Turnover> turnovers = this.turnovers.get();

null チェックのついた Lazy.get() も用意されています。

return Lazy.get(this.turnovers);

具体的には以下のような実装になるでしょう。

public class BusinessYear {
    private Lazy<List<Turnover>> turnovers;

    private List<Turnover> getTurnovers() {
        return Lazy.get(this.turnovers);
    }

    public void addTurnover(final Turnover turnover) {
        List<Turnover> turnovers = this.getTurnovers();
        if (turnovers == null) {
            this.turnovers = Lazy.Reference(turnovers = new ArrayList<>());
        }
        turnovers.add(turnover);
    }
}

Lazy は最後のアクセスから一定時間後に自動的にクリアされます。 Lazy.clear(Lazy) で明示的にクリアすることも可能です。


クエリ

オブジェクトグラフの永続化は MicroStream エンジンが担っています。 クエリを実行する際、MicroStream が保存しているデータに対して実行されるのではなく、ローカルシステムのメモリにあるデータに対して実行するだけです。SQLのようなクエリ言語を使用する必要はなく、すべての操作は、プレーンなJavaで行います。

標準的なJavaのコレクションを使うのであれば、Stream API を使って以下のようにできるでしょう。

public List<Article> getUnAvailableArticles() {
    return shop.getArticles().stream()
        .filter(a -> !a.available())
        .collect(Collectors.toList());
}

フィールドに Map でキー付けしたものを書けば、キーによる検索もプレーンなJavaの世界で実現できます。

private final Map<String, Book> isbn13ToBook = new HashMap<>();


ObjectCopier

MicroStream では、MicroStreamによって保存および管理されているデータのディープコピーを簡単に作成できるユーティリティが提供されています。

DBから取得し、変更し、同期して保存するといった一般的なアプリケーションのユースケースで利用できます。

ObjectCopier でコピーを取得して、そのコピーに対して操作をするだけです。

ObjectCopier objectCopier = ObjectCopier.New();

Customer customer = root.getCustomer(id);

Customer customerCopy = objectCopier.copy(customer);
customerCopy.addPurchase(purchase);

XThreads.executeSynchronized(() -> {
    root.setCustomer(id, customerCopy);
    storage.store(root.getcusomers());
}


バックエンドにデータベースを使う

なんでも良いのですが、Mariadb をバックエンドのストレージとして使ってみます。

以下の依存を追加します。

implementation("one.microstream:microstream-afs-sql:06.01.00-MS-GA")
implementation("org.mariadb.jdbc:mariadb-java-client:2.7.1")

Mariadb は Docker で以下のように起動しておけば良いでしょう。

$ docker run --name mariadb --env MARIADB_USER=example-user --env MARIADB_PASSWORD=my_cool_secret --env MARIADB_ROOT_PASSWORD=my-secret-pw --env MYSQL_DATABASE=mariadb -p 3306:3306 mariadb:latest


以下のようにすることで、Mariadb サーバをストレージとして利用することができます。

public static void main(String[] args) throws Exception {

    MariaDbDataSource dataSource = new MariaDbDataSource();
    dataSource.setUrl("jdbc:mysql://localhost:3306/mariadb");
    dataSource.setUser("example-user");
    dataSource.setPassword("my_cool_secret");

    SqlFileSystem fileSystem = SqlFileSystem.New(
            SqlConnector.Caching(SqlProviderMariaDb.New(dataSource)));
    EmbeddedStorageManager storage = EmbeddedStorage.start(
            fileSystem.ensureDirectoryPath("microstream_storage"));

    if (storage.root() == null) {
        System.out.println("No existing Database found, creating a new one:");
        Data root = new Data();
        root.setName("foo");
        storage.setRoot(root);
        storage.storeRoot();
    } else {
        Data root = (Data) storage.root();
        System.out.printf(root.getName());
    }

    storage.shutdown();
    System.exit(0);
}


まとめ

マイクロサービス界隈で話題?の OSS 化された MicroStream の簡単な利用方法について説明しました。 非常に割り切った設計で、ORMにありがちな黒魔術のような所も無く、シンプルで使いやすいプロダクトだなと感じました。

ここで説明していないこともあるので、興味の有る方は以下の公式マニュアルを一読してみるのも良いのではないでしょうか。