2009年12月28日月曜日

App Engine JDO Tips

Google App Engineのデータストアを利用する場合、公式にはJDOおよびJPAが提供されています。 過去にJDOからSlim3 Datastoreに乗り換えるようなことも書いていますが、最近のJDOについて少し書いてみたいと思います。

今回は、Google App Engine Blog: JPA/JDO Java Persistence Tips - The Year In Reviewで紹介されている内容をもとに話を進めます。JDOの使い方自体に詳しくなくてもある程度読めるように書いたつもりですが、何かあればコメントください。

相互参照する one-to-many の関係 (episode 1, 3)

JDOを利用すると、相互参照を行うone-to-manyの関係を簡単に記述できます。

下記はParentクラスのオブジェクトが、複数のChildクラスのオブジェクトを保有する例です。

package com.example;

import java.util.*;
import javax.jdo.annotations.*;

@PersistenceCapable(identityType = IdentityType.APPLICATION)
public class Parent {

    @PrimaryKey
    private String keyName;

    // Childクラスのparentプロパティが自身への参照を持つ
    @Persistent(mappedBy = "parent")
    @Element(dependent = "true")
    private List<Child> children = new ArrayList<Child>();

    // ...
}
package com.example;

import javax.jdo.annotations.*;
import com.google.appengine.api.datastore.Key;

@PersistenceCapable(identityType = IdentityType.APPLICATION)
public class Child {

    @PrimaryKey
    @Persistent(valueStrategy = IdGeneratorStrategy.IDENTITY)
    private Key key;

    private String value;

    // Parentへの参照を保持して相互参照させる
    private Parent parent;

    // ...
}

上記のように書いた場合、ParentクラスのchildrenにChildクラスのオブジェクトを設定するだけで、自動的にそれぞれのChildクラスのparentフィールドにはParentクラスのオブジェクトが設定されます。

また、上記のような owned-relationship と呼ばれる関係は、ParentをルートとしたEntity Groupを自動的に構成します。このため、Childクラスのオブジェクトを持つParentはトランザクション処理による変更が可能です。

このEntity GroupはKeyのパスで表現されます。Childクラスのエンティティは親を持つことになるので、@PrimaryKeyの指定を、(String, Longなどではなく)Key型のフィールドに対してする必要があります。

なお現在の実装では、上記のようなParentを保存する際にchildrenというプロパティはデータストア上に作成されません。そのかわり、Childを保存する際にchildren_INTEGER_IDXというプロパティがデータストア上に作成され、Parentから見たChildの位置(children内のインデックス)が保持されます。

このため、過去のParentと同じキーを持つParentオブジェクトを生成し、childrenを操作してしまうと過去のchildrenの内容がマージされるという残念な状況になります。つまり、2つのChildオブジェクトを持つParentを2回作成すると、あとでParentを取得した際に4つのChildオブジェクトを持っていたりします。

そのため、上書きする場合でも一度取得したオブジェクトを利用します。

PersistentManager pm = ...;

Parent parent;
try {
    // すでに存在したら再利用
    parent = pm.getObjectById(Parent.class, "parent");
}
catch (JDOObjectNotFoundException e) {
    // 存在しなければ新しく作る
    parent = new Parent();
    parent.setKeyName("parent");
    pm.makePersistent(parent);
}

Child child1 = new Child();
Child child2 = new Child();

child1.setValue("child-1");
child2.setValue("child-2");

// ChildオブジェクトをParent.childrenに追加
List<Child> children = parent.getChildren();
children.add(child1);
children.add(child2);

JDO(とJPA)はtransparent persistence (透過的な永続化)を提供するためのライブラリです。上記のように新しく作成したオブジェクトはすぐにPersistentManager.makePersistent()によって永続化対象に指定し、そのオブジェクトを使い続けるスタイルのほうがやりやすいかもしれません。

キーのみを取得するクエリ (episode 4)

キーのみを取得するクエリが利用可能になっています。 やりかたは、JDOQLを書く際に"SELECT <キーを保持するフィールド名> FROM ..."のように、@PrimaryKeyの指定をしたフィールドを指定するだけです。

PersistentManager pm = ...;

// SELECT <キーを保持するフィールド名> FROM ... でキーのみを取得
Query query = pm.newQuery("SELECT key FROM " + Hoge.class.getName());
List<Key> keys = (List<Key>) query.execute();
...

App Engineのクエリは、(1)キーの一覧を取得、(2)キーからエンティティを取得、の順で結果を取得しているようなので、キーのみを取り出す場合には上記のように書くことで、(2)の処理をスキップすることができます。

エンティティの取得とモデルへのマッピングはそれなりにコストが高い操作ですので、キーだけが必要である場合には便利です。特に検索のみに利用するエンティティなどは、キーに検索結果としての有効な情報を凝縮することで、不要な情報をロードしなくて済みます。

シリアライズしたオブジェクトの格納 (episode 5)

App Engineのデータストアに利用できるデータの種類は限られていますが、JDOを利用すると自動的にオブジェクトのシリアライズを行ってBlobとして永続化できます (Blob StoreのBlobとは別物です)。

package com.example;

import java.util.Map;

import javax.jdo.annotations.*;

@PersistenceCapable(identityType = IdentityType.APPLICATION, detachable = "true")
public class ObjectHolder {

    @PrimaryKey
    private String keyName;

    // MapはJDOで扱えないので、serializedを指定する
    @Persistent(serialized = "true")
    private Map<String, String> map;

    // ...
}

@Versionを利用したLong Running Transaction (episode 6)

App Engineのデータストアにはトランザクション処理を行うための仕組みがあらかじめ組み込まれていますが、リクエストごとにコミットまたはロールバックしなければならないため、画面をまたぐような長期間の並行性制御(Long Running Transaction)はソフトウェアで指示する必要があります。 JDOにはこれを自動的に行う仕組みが備わっています。

まず、JDO向けのモデルクラスに@Versionというアノテーションを付与します。

package com.example;

import java.io.Serializable;

import javax.jdo.annotations.*;

@PersistenceCapable(identityType = IdentityType.APPLICATION, detachable = "true")
@Version
public class Versioned implements Serializable {

    private static final long serialVersionUID = 1L;

    @PrimaryKey
    @Persistent(valueStrategy = IdGeneratorStrategy.IDENTITY)
    private Long id;

    private String value;

    // ...
}

このとき、detachable = "true"という指定と、implements Serializableを併せて指定しておきました。前者はオブジェクトをJDOの管轄から外してスナップショットを作成する(デタッチ)のための指定で、後者はセッションに同オブジェクトを保持しておくための指定です。

Long Running Transactionを開始する場合には、下記のように@Versionを付与したモデルにPersistenceManager.detachCopy()を実行して記憶しておきます。

PersistenceManager pm = ...;
Versioned data = ...;
data.setValue("Hello!");
pm.makePersistent(data);
// 現在の状態のスナップショットを作成
Versioned snapshot = pm.detachCopy(data);
// snapshotをセッションなどに保存

それぞれの画面で行われた変更は、全て上記のデタッチしたsnapshotに対して行います。 全ての操作が終わったら、最後にデータストアに書き戻します。

PersistenceManager pm = ...;
Versioned snapshot = ...;
pm.makePersistent(snapshot);

上記のように、JDOの@Versionを利用したLong Running Transactionでは、明示的にJDOのトランザクションを開始する必要はありません。というより、明示的にJDOのトランザクションを利用すると、@Versionによる並行性制御が無視されます

これはそれなりに有名なことらしいのですが、知らないと非常に不可解な動きをします。このエントリを書いてる最中に同じようにはまって、様々な方に助けていただきました(いつもありがとうございます)。

このLong Running Transactionは、PersistenceManager.close()を実行した際に実際の反映が行われます。もし、スナップショットを書き戻すまでに外部から(本体の)モデルが変更されている場合、close()が実行されるタイミングでJDOExceptionがスローされていました。

このPersistenceManager.close()の中では、おそらく下記のような操作が実行されています。

PersistenceManager pm = ...;
SomeModel newData = ...;
...
Transaction tx = pm.currentTransaction();
try {
  // 現在のトランザクションを利用して、同じIDのオブジェクトを取り出す
  SomeModel oldData = pm.getObjectById(SomeModel.class, newData.getId());
  // サーバから取り出したバージョンと、手元のバージョンを比較
  // 他の人が変更していたらサーバから取り出したバージョンがずれているはず
  if (oldData.getVersion() != newData.getVersion()) {
    // 実際にはJDOExceptionがスローされる
    throw new ConcurrentModificationException();
  }
  // バージョンチェックと書き込みは同一トランザクションで
  // 処理するのでアトミックに行われる
  pm.makePersistent(newData);
  tx.commit();
}
catch () {
  if (tx.isActive()) {
    tx.rollback();
  }
}

より詳しい考察は、App Engineでバージョンによる楽観的排他制御 - ひがやすを blogなどでも行われています。

はまった点

@Versionの調査をしていてひどくはまっていた点を紹介します。

PersistenceManager pm = ...;
Versioned data = ...;
// (a) 先にmakePersistent()で永続化の対象に指定
pm.makePersistent(data);
// (b) そのあとにモデルを変更
data.setValue("Hello!");

// (c) 現在の状態のスナップショットを作成
Versioned snapshot = pm.detachCopy(data);
...

// (d) 最後にclose()
pm.close()

上記のように、最初にPersistenceManager.makePersistent()を実行(a)し、それ以降にモデルオブジェクトを変更した(b)場合、最後のPersistenceManager.close()の瞬間(d)にモデルオブジェクトのバージョンが変更されます。このため、(b)と(d)の間で行われたスナップショットの作成(c)は、(d)で変更されるバージョンの情報を反映していません(aの時点のバージョンを利用します)。

このようなスナップショットの書き戻しは常に失敗します。これは(d)の時点で変更されたバージョンが「Long Runnning Transactionの外部で変更が行われた」というように見なされてしまうためです。

makePersistent()detachCopy()を同一リクエストで行う場合、最初に紹介したように「すべて変更してからmakePersistent()を実行する」というようにした方がよさそうです。

PersistenceManager pm = ...;
Versioned data = ...;
// 先に変更してから
data.setValue("Hello!");
// makePersistent()で永続化の対象に指定
pm.makePersistent(data);
// すると、detachCopy()はmakePersistent()時点のバージョンと一致
Versioned snapshot = pm.detachCopy(data);

transparent persistence (透過的な永続化)とさっき書いたばかりなのですが、App Engineではどうもそう簡単にいかないようです。

インデックスを作成しないプロパティ (episode 7)

データストアにエンティティを格納した場合、基本的にはそれぞれのプロパティに対するインデックスが作成されます(TextやBlobなどは対象外)。しかしこのインデックス作成はCPUやデータストア領域などのリソースを消費するため、不要の場合には抑制させることもできます。

具体的には、インデックスの作成を抑制したいプロパティに対して、@Extension(vendorName="datanucleus", key="gae.unindexed", value="true")という長い記述の注釈を付与するだけです。

package com.example;

import javax.jdo.annotations.*;

import com.google.appengine.api.datastore.Link;

@PersistenceCapable(identityType = IdentityType.APPLICATION, detachable = "true")
public class BlogEntry {

    @PrimaryKey
    private String keyName;

    // title はインデックスを作る
    private String title;

    // permalink はインデックスを作らない
    @Persistent
    @Extension(vendorName = "datanucleus", key = "gae.unindexed", value="true")
    private Link permalink;

    // ...
}

上記のモデルに対してpermalinkをフィルタに指定するクエリを実行した場合、クエリは失敗するのではなく単に結果を何も返しません

なお、@Extensionにはのほかにorg.datanucleus.jpa.annotations.Extensionという注釈型も存在します。後者はJPA用なので注意が必要です (Enhanceの際にエラーが表示されます)。

インデックスの作成については、How Entities and Indexes are Stored - Google App Engine - Google Code (英語)が参考になると思います。

はまった点

今回のはまりは下記のような感じです。

package com.example;

import javax.jdo.annotations.*;

import com.google.appengine.api.datastore.Link;

@PersistenceCapable(identityType = IdentityType.APPLICATION)
public class BlogEntry {

    @PrimaryKey
    private String keyName;

    private String title;

    // @Persistent を省略
    @Extension(vendorName = "datanucleus", key = "gae.unindexed", value="true")
    private Link permalink;

    // ...
}

プロパティに@Persistentを省略した場合、永続化対象にはなるけど"unindexed"の対象にならないという残念な挙動をします。このエントリでは基本的に@Persistentを省略していますが、可能な限り明記したほうがいいのかもしれません。

IN, != 演算子の追加 (episode 9)

SDK 1.2.8 Release Notesで語られなかったことでも書きましたが、SDK 1.2.8からはIN, !=という演算子が利用できるようになりました。下記のようにJDOQLの中で指定します。

Query query = pm.newQuery(Employee.class);
// 年齢が20歳でない
query.setFilter("age != 20");
List<Employee> results = (List<Employee>) query.execute();

...
// 特別な年齢の一覧
List<Integer> anniversary = Arrays.asList(20, 40, 60);
Query query = pm.newQuery(Employee.class);
// 年齢が「特別な年齢の一覧」に含まれる
query.setFilter(":anniversary.contains(age)");
List<Employee> results = (List<Employee>) query.execute(anniversary);

...

その他、気付いたこと

Google App Engine Blog: JPA/JDO Java Persistence Tips - The Year In Reviewで紹介されなかったものの、App Engine for Javaのリリース当初から変わっていたことについて、いくつか紹介します。

PersistenceManagerFactoryの初期化が高速化

Slim3 Datastoreに乗り換えるという記事では、JDOをやめた理由の一つに「初期化が遅い」ということを挙げました。 しかし、SDK 1.2.8より利用可能になった<precompilation-enabled>true</precompilation-enabled>という設定により、この初期化のコストをかなり削減できるようです。

このプリコンパイル設定をonにしたりoffにしたりして、それぞれの初回アクセス時の負荷を測定してみました。いずれもPersistenceManagerFactoryを初期化し、pmf.getPersistenceManager()を起動するだけの簡単なプログラムです。

プリコンパイル設定なし プリコンパイル設定あり 削減された時間
1回目 4433cpu_msec 2450cpu_msec 1983cpu_msec
2回目 4394cpu_msec 2741cpu_msec 1653cpu_msec
3回目 4588cpu_msec 2585cpu_msec 2003cpu_msec
4回目 4725cpu_msec 2741cpu_msec 1984cpu_msec
5回目 4510cpu_msec 2605cpu_msec 1905cpu_msec
平均 4530cpu_msec 2624cpu_msec 1906cpu_msec

上記のように測定回数は5回と多少心もとないですが、いずれも2000cpu_msec近い時間がプリコンパイル設定を有効にすることで削減されています。 体感的にも応答に5秒かかっていたものが3秒くらいになっている感じがしますので、上記のプリコンパイル設定は有効にする価値がありそうです。

コアデータタイプがデフォルトフェッチグループに追加

過去のJDOでは、次のようなモデルクラスを作成した際に、linkフィールドやmailsフィールドにはデフォルトフェッチグループの指定が必要でした。 デフォルトフェッチグループとは、特別な指定をせずにエンティティをデータストアから読み出した際に、自動的にフェッチされるプロパティ群のことです。 プリミティブ型、String型、Date型などはJDOの定義でデフォルトフェッチグループに含まれていましたが、App Engine特有のクラス(Key, Link, Email)などはそれらに含まれていなかったため、わざわざフェッチグループの指定をしないとデータストアからロードした際にnullのまま放置されていました。

package com.example;

import java.util.List;
import javax.jdo.annotations.*;
import com.google.appengine.api.datastore.*;

@PersistenceCapable(identityType = IdentityType.APPLICATION, detachable = "true")
public class CoreDataType {

    @PrimaryKey
    private String keyName;

    private Link link;

    private List<Email> mails;

    // ...
}

SDK 1.2.8 (datanucleus-appengine 1.0.4)からは com.google.appengine.api.datastore.* にある組み込みデータ型や、それらのコレクションについてもについてもデフォルトフェッチグループに追加されるようになったので、上記のモデルクラスはフェッチグループの指定なしに利用できます。

0 件のコメント:

コメントを投稿