2009年9月1日火曜日

#appengine JavaのLow-Level API入門 Relationship編

前回のエントリが結構好評だったのと、前回のエントリだけだと、JDOから入った人は「Relationshipどーすんの?」となりそぅな気がしたので、調子に乗ってlow-level APIとJDOでのRelationshipの比較等について書きます。

  • JDOの方はLLParentクラスがOneToOneでLLChildAを、OneToManyでLLChildBを保持するような構成です。
  • 一方low-level APIではJDOと同じ事をキーのみでEntityGroupを構成します。LLParentkindは子エンティティのためのpropertyを一切保持しないと言う事です。JDOの方でもキーのみでEntityGroupを構成する事ができますが、ListPropertyで保持する方が一般的というかORMっぽいかなぁと思いまして。

一見、データの保持の仕方が全く違うよね?と見えますが、実はどちらの方法で保存しても結局はDatastoreには全く同じ状態で格納されます。それを確認するために以下の組み合わせをテストケースで確認します。「JDOによる保存」「low-level APIによる保存」それぞれの結果に対して「JDOでクエリ」「low-level API」をそれぞれ試す、という4パターンを実際のコードで説明します。ばかみたいにコードをそのまま貼付けているので縦に長いです、ゴメンナサイ。

JDO用のエンティティクラス

@PersistenceCapable(identityType = IdentityType.APPLICATION, detachable="true")
@Version(strategy = VersionStrategy.VERSION_NUMBER, column = "version")
public class LLParent implements Serializable {
  @PrimaryKey
  @Persistent(valueStrategy = IdGeneratorStrategy.IDENTITY)
  private Key key;

  @Persistent
  private String property1;

  @Persistent(defaultFetchGroup = "true")
  private Text property2;

  @Persistent(defaultFetchGroup = "true")
  private LLChildA childA;

  @Persistent(defaultFetchGroup = "true")
  @Order(extensions = @Extension(vendorName="datanucleus", key="list-ordering", value="property1"))
  private List<LLChildB> childBList;

  ...以下アクセサメソッド
}

@PersistenceCapable(identityType = IdentityType.APPLICATION, detachable="true")
@Version(strategy = VersionStrategy.VERSION_NUMBER, column = "version")
public class LLChildA implements Serializable {
  @PrimaryKey
  @Persistent(valueStrategy = IdGeneratorStrategy.IDENTITY)
  private Key key;

  @Persistent  private String property1;

  ...以下アクセサメソッド
}

LLChildBクラスはLLChildAクラスとクラス名以外は全く同じです。

JDOによる保存

private Key saveByJDO() {
  LLParent parent = new LLParent();
  parent.setProperty1("parent:property1value");
  parent.setProperty2(new Text("parent:property2value"));

  LLChildA childA = new LLChildA();
  childA.setProperty1("childA:property1value");
  parent.setChildA(childA);

  List<LLChildB> childBList = new ArrayList<LLChildB>();
  LLChildB childB1 = new LLChildB();
  childB1.setProperty1("childB1:property1value");
  childBList.add(childB1);
  LLChildB childB2 = new LLChildB();
  childB2.setProperty1("childB2:property1value");
  childBList.add(childB2);
  parent.setChildBList(childBList);

  PersistenceManager pm = factory.getPersistenceManager();
  Transaction transaction = pm.currentTransaction();
  transaction.begin();
  parent = pm.makePersistent(parent);
  transaction.commit();
  pm.close();
  return parent.getKey();
}

特に変わった事はしてません。

low-level APIによる保存

private Key saveByLowLevelApi() {
  Entity parent = new Entity(LLParent.class.getSimpleName());
  parent.setProperty("property1", "parent:property1value");
  parent.setProperty("property2", new Text("parent:property2value"));

  DatastoreService service = DatastoreServiceFactory.getDatastoreService();
  com.google.appengine.api.datastore.Transaction transaction = service.beginTransaction();

  Key parentKey = service.put(transaction, parent);

  List<Entity> entities = new ArrayList<Entity>();
  Entity childA = new Entity(LLChildA.class.getSimpleName(), parentKey);
  childA.setProperty("property1", "childA:property1value");
  entities.add(childA);

  Entity childB1 = new Entity(LLChildB.class.getSimpleName(), parentKey);
  childB1.setProperty("property1", "childB1:property1value");
  entities.add(childB1);
  Entity childB2 = new Entity(LLChildB.class.getSimpleName(), parentKey);
  childB2.setProperty("property1", "childB2:property1value");
  entities.add(childB2);

  service.put(transaction, entities);
  transaction.commit();

  return parentKey;
}

こちらも特に変わった事はしてません。前回のエントリの最後にあったものとほぼ同じ。JDOによる保存とは違い、LLParentkindは子エンティティを保持するpropertyを持ちません。

JDOによる取得とassert

private void assertByJDO(Key parentKey) {
  PersistenceManager pm = factory.getPersistenceManager();
  LLParent entity = pm.getObjectById(LLParent.class, parentKey);
  assertThat(entity.getKey(), is(equalTo(parentKey)));
  assertThat(entity.getProperty1(), is(equalTo("parent:property1value")));
  assertThat(entity.getProperty2().getValue(), is(equalTo("parent:property2value")));
  assertThat(entity.getChildA(), is(not(nullValue())));
  assertThat(entity.getChildA().getProperty1(), is(equalTo("childA:property1value")));
  assertThat(entity.getChildBList(), is(not(nullValue())));
  assertThat(entity.getChildBList().get(0).getProperty1(), is(equalTo("childB1:property1value")));
  assertThat(entity.getChildBList().get(1).getProperty1(), is(equalTo("childB2:property1value")));
  pm.close();
}

low-level APIによる取得とassert

private void assertByLowLevelApi(Key parentKey) throws EntityNotFoundException {
  DatastoreService service = DatastoreServiceFactory.getDatastoreService();
  Entity entity = service.get(parentKey);
  assertThat(entity.getKey(), is(equalTo(parentKey)));
  assertThat((String) entity.getProperty("property1"), is(equalTo("parent:property1value")));
  assertThat(((Text) entity.getProperty("property2")).getValue(), is(equalTo("parent:property2value")));
  // 以下のふたつは自動的に取得されない…といぅか、属性すら存在しない。
  assertThat(entity.hasProperty("childA"), is(equalTo(false)));
  assertThat(entity.hasProperty("childBList"), is(equalTo(false)));
  // OneToOneのChildAを取得する。
  Query queryA = new Query(LLChildA.class.getSimpleName(), entity.getKey());
  Entity childA = service.prepare(queryA).asSingleEntity();
  assertThat((String) childA.getProperty("property1"), is(equalTo("childA:property1value")));
  // OneToManyのChildBを取得する。
  Query queryB = new Query(LLChildB.class.getSimpleName(), entity.getKey());
  List<Entity> childBList = service.prepare(queryB).asList(FetchOptions.Builder.withOffset(0));
  assertThat((String) childBList.get(0).getProperty("property1"), is(equalTo("childB1:property1value")));
  assertThat((String) childBList.get(1).getProperty("property1"), is(equalTo("childB2:property1value")));
}

これがlow-level APIでのRelationshipのクエリのサンプルです。low-level APIの保存の時にLLParentkindは子エンティティを保持するpropertyを持たない、と書きましたが、Entity#hasProperty()メソッドを使ってそれを検証しています。

検証する為のテストメソッド

@Test
public void JDOで保存してJDOで読む() {
  Key parentKey = saveByJDO();
  assertByJDO(parentKey);
}

@Test
public void JDOで保存して低レベルAPIで読む() throws EntityNotFoundException {
  Key parentKey = saveByJDO();
  assertByLowLevelApi(parentKey);
}

@Test
public void 低レベルAPIで保存して低レベルAPIで読む() {
  Key parentKey = saveByLowLevelApi();
  assertByJDO(parentKey);
}

@Test
public void 低レベルAPIで保存してJDOで読む() throws EntityNotFoundException {
  Key parentKey = saveByLowLevelApi();
  assertByLowLevelApi(parentKey);
}

これにより、以下の事が確認できます。

「JDOで色んなRelationshipの方法があるが、結局「EntityGroupはキーだけで構成される」という事がわかります。JDOで保存してlow-level APIで読み出すと、子エンティティを保持するpropertyが親エンティティからは完全に消滅しています。逆に、子エンティティを保持しない状態でlow-level APIで保存しても、JDOで読み出すとしっかり子エンティティが親エンティティに保持されています。これらはJDOが裏で色々とやってくれている、という事です。

ちょっとだけ裏を覗いてみたり

Datastoreサービスが実行されたタイミングをフックして、Datastoreサービスどのメソッド(機能)が実行されたかを標準出力に表示すると、次のような事がわかります。フックを行ったりするのに使用したソースはエントリの最後尾にあります。

JDOによる保存

makeSyncCall:datastore_v3:BeginTransaction
makeSyncCall:datastore_v3:Put
makeSyncCall:datastore_v3:Put
makeSyncCall:datastore_v3:Put
makeSyncCall:datastore_v3:Put
makeSyncCall:datastore_v3:Commit

プログラムからはPersistenceManager#makePersistent()を一回実行しているだけですが、実際はPUTメソッドが4回呼ばれています。

low-level APIによる保存
makeSyncCall:datastore_v3:BeginTransaction
makeSyncCall:datastore_v3:Put
makeSyncCall:datastore_v3:Put
makeSyncCall:datastore_v3:Commit

JDOと比べて、DatastoreService#put(List entities)の存在でPUTメソッドの呼び出し回数が2回で済んでいます。ちなみに、ひがさんが「次のバージョンの隠れた目玉機能としてparalel putが実装される」とおっしゃってたので、次期バージョンではJDOも同じ回数になると思われます。

読み出し

読み出しはJDOもlow-level APIも変わりません。JDOではPersistenceManager#get()を一回実行しているだけですが、実際にはGET、OneToOneをフェッチした時のRunQuery、OneToManyをフェッチした時のRunQuery、とlow-level APIと全く同じになります。

makeSyncCall:datastore_v3:Get
makeSyncCall:datastore_v3:RunQuery
makeSyncCall:datastore_v3:RunQuery

テストケースのsetup/teawdown部分

最後にこのテストケースで使用したDatastoreの初期化やら何やらの部分を書いておきます。ApiProxy#setDelegate()でちょっとだけ工夫をしています。

static PersistenceManagerFactory factory;

@BeforeClass
public static void setUpBeforeClass() {
  factory = JDOHelper.getPersistenceManagerFactory("transactions-optional");
}

@Before
public void setUp() {
  ApiProxy.setEnvironmentForCurrentThread(new ApiProxy.Environment() {
    public String getAppId() { return "unit test"; }
    public Map<String, Object> getAttributes() { return new HashMap<String, Object>(); }
    public String getAuthDomain() { return "gmail.com"; }
    public String getEmail() { return "hoge@gmail.com"; }
    public String getRequestNamespace() { return ""; }
    public String getVersionId() { return "1"; }
    public boolean isAdmin() { return false; }
    public boolean isLoggedIn() { return true; }
  });
  ApiProxy.setDelegate(new MyDelegate(new File("target/lowLevelApiTest")) {});
  ApiProxyLocalImpl proxy = (ApiProxyLocalImpl) ApiProxy.getDelegate();
  proxy.setProperty(LocalDatastoreService.NO_STORAGE_PROPERTY, Boolean.TRUE.toString());
}

@After
public void tearDown() {
  ApiProxyLocalImpl proxy = (ApiProxyLocalImpl) ApiProxy.getDelegate();
  LocalDatastoreService datastoreService = (LocalDatastoreService) proxy.getService("datastore_v3");
  datastoreService.clearProfiles();
  datastoreService.stop();
  ApiProxy.setDelegate(null);
  ApiProxy.setEnvironmentForCurrentThread(null);
}

static class MyDelegate extends ApiProxyLocalImpl {

  MyDelegate(File folder) { super(folder); }

  public byte[] makeSyncCall(Environment environment, String packageName, String methodName,
      byte[] request) throws ApiProxyException {
    if (packageName.equals("datastore_v3")) {
      System.out.println("makeSyncCall:" + packageName + ":" + methodName);
    }
    return super.makeSyncCall(environment, packageName, methodName, request);
  }

  public void log(Environment environment, LogRecord logRecord) {
    super.log(environment, record);
  }
}

余談(ここまでの話と全然関係がありません)

ここまで読んでしまうような変態な皆さんは、フックする為に使用しているMyDelegate#makeSyncCall()メソッドが気になって仕方無いですよね。特に第四引数であるbyte[] requestとかいうヤツ。「…何が入っているんだろう…というか全部が入っているんだよね?何でも取得できるよね」という予測ですよね!ズバリその通りです、ここに全部入っています。ただ、この中身は実行されるサービスによって変わる、ProtocolBufferによるシリアライズの結果です。例えば今回使ったDatastoreサービスのPUTメソッドであればcom.google.apphosting.api.DatastorePb.PutRequestオブジェクトに組み立てられます。Mailサービスならcom.google.appengine.api.mail.MailServicePb.MailMessage、といったような具合です。

説明すると長くなるのでここでは説明しませんが、これをうまく活用する事でローカル環境での自動テストが随分やりやすくなったり、テストの範囲が広がります。GAE/Jの中でも最も面白い部分かもしれません。

さらに余談の余談

SDKの中にはcom.google.apphosting.utils.remoteapi.RemoteApiServletといぅサーブレットが存在しています。こいつは、POSTメソッドの実装の内側でPostされたバイト配列を組み立ててサーバ側でmakeSyncCall()する仕組みになっています。…面白いですよね?…面白いはずなんですが、twitterでいっくらつぶやいても誰からも反応が無くて寂しかったりw

追記

「low-level APIによる取得とassert」のソースでChildBListのSort順についてコメントを書いていた箇所を削除しました。別の調査でJDOの@Orderアノテーションの動作との比較等を確認しようとしていたコードが残ってしまってました。

2 件のコメント:

Unknown さんのコメント...

Googleの人が言っていたJDOからLow level APIのparallel putを使うようにするという意味が、makePersistenceAll()だけの話ならちょっと悲しいかも。

前に話に出た、transactionつきのputを呼び出しているかどうかは、
org.datanucleus.store.appengine.DatastorePersistenceHandlerの125行目でtransactionありならtransactionつきのputを呼び出しています。

sss さんのコメント...

コメントありがとうございます。
確かにmakePersistenceAll()だけはツライですね…。

第一引数にtransactionについてはありがとうございます、教えてもらったポイントをエントリポイントとして関係ありそうなソースをもう少しよく読んでおきます。