GAE/JでSerializableなクラスを永続化してみる(落とし穴注意)

ここにあるように、GAE/JではSeriarizableなクラスをBLOBとして永続化することができます。
JDO準拠なので、POJOなSerializableを永続化できる! スゲェ! Google様一生ついて行きます! って思ってたんですが、要注意物件でした。

まず、Serializableなフィールドに@Persistentのアノテーションをつけるときに、defaultFetchGroup = "true"とする必要があります。

@Persistent(serialized = "true", defaultFetchGroup = "true")
public HashMap<String, Serializable> prop = new HashMap<String, Serializable>();

これをつけないと、PersistenceManager#getObjectByIdでオブジェクトを取得したときに、Serializableなフィールドがnullのままになってしまいます(c.f.
getObjectById Does Not Load Serialized Classes )。

次に、Serializableなフィールドは更新ができません。一旦永続化したら、Serializableフィールドを更新するためには全く新しいインスタンスをフィールドに割り当てる必要があります。
例えば、以下のようなクラスがあったとして、

class EntityData {
	@Persistent(valueStrategy = IdGeneratorStrategy.IDENTITY)
	@PrimaryKey
	public Long id;
	@Persistent(serialized = "true", defaultFetchGroup = "true")
	public HashMap<String, Serializable> prop = new HashMap<String, Serializable>();

	public void setProp(HashMap<String, Serializable> prop) {
		this.prop = prop;
	}

	public HashMap<String, Serializable> getProp() {
		return prop;
	}
}

以下のように一旦作って永続化します。

PersistenceManager pm = ....;
EntityData ed = new EntityData();
ed.setProp("nyara", "cat");
pm.makePersistent(ed);
pm.close();

このとき、以下のようにして更新をかけても無効です。

PersistenceManager pm = ....;
EntityData ed = (getExtentやQuery等、何らかの手段で先ほど永続したインスタンスを入手);
ed.getProp().put("nyara", "is my cat");
pm.close();

以下のようにして、Serializableインスタンスに新しいインスタンスを割り当てる必要有りです。

PersistenceManager pm = ....;
EntityData ed = (getExtentやQuery等、何らかの手段で先ほど永続したインスタンスを入手);
ed.
ed.setProp(new HashMap());
ed.getProp().put("nyara", "is my cat");
pm.close();

まあ考えてみれば当然かも知れません。直接フィールドを書き換えられたら、永続化し直すべきか否か判断できないですよね。
安全のためには、PersistenceMangerで永続化するSerializableクラスのインスタンスは、不変オブジェクトにしておくのが良いと思いますです。
そういや、ネイティブにサポートされてる永続化可能な型は全部不変なクラスだなあ……。

Joel本の記述を思い出してしまった、そんな一週間でした。抽象化怖い!

以下、検証用のテストケースです。

package jp.ne.hatena.matsuza.entities;

import java.io.File;
import java.io.Serializable;
import java.util.HashMap;

import javax.jdo.Extent;
import javax.jdo.PersistenceManager;
import javax.jdo.annotations.IdGeneratorStrategy;
import javax.jdo.annotations.IdentityType;
import javax.jdo.annotations.PersistenceCapable;
import javax.jdo.annotations.Persistent;
import javax.jdo.annotations.PrimaryKey;

import jp.ne.hatena.matsuza.PMF;
import jp.ne.hatena.matsuza.TestEnvironment;
import junit.framework.TestCase;

import org.junit.Test;

import com.google.appengine.api.datastore.dev.LocalDatastoreService;
import com.google.appengine.tools.development.ApiProxyLocalImpl;
import com.google.apphosting.api.ApiProxy;

@PersistenceCapable(identityType = IdentityType.APPLICATION)
class EntityData {
	@Persistent(valueStrategy = IdGeneratorStrategy.IDENTITY)
	@PrimaryKey
	public Long id;
	@Persistent(serialized = "true", defaultFetchGroup = "true")
	public HashMap<String, Serializable> prop = new HashMap<String, Serializable>();

	public void setProp(HashMap<String, Serializable> prop) {
		this.prop = prop;
	}

	public HashMap<String, Serializable> getProp() {
		return prop;
	}
}

public class SerializableTest extends TestCase {
	@Override
	protected void setUp() throws Exception {
		super.setUp();
		ApiProxy.setEnvironmentForCurrentThread(new TestEnvironment());
		ApiProxy.setDelegate(new ApiProxyLocalImpl(new File(".")) {
		});
		ApiProxyLocalImpl proxy = (ApiProxyLocalImpl) ApiProxy.getDelegate();
		proxy.setProperty(LocalDatastoreService.NO_STORAGE_PROPERTY,
				Boolean.TRUE.toString());
	}

	@Override
	protected void tearDown() throws Exception {
		ApiProxyLocalImpl proxy = (ApiProxyLocalImpl) ApiProxy.getDelegate();
		LocalDatastoreService datastoreService = (LocalDatastoreService) proxy
				.getService("datastore_v3");
		datastoreService.clearProfiles();

		super.tearDown();
	}

	@SuppressWarnings("unchecked")
	@Test
	public void testPersistent() {
		{
			PersistenceManager pm = PMF.get().getPersistenceManager();
			EntityData e = new EntityData();
			// Serializableにデータを入れて、保存
			e.getProp().put("key", "1");
			pm.makePersistent(e);
			pm.close();
		}
		{
			PersistenceManager pm = PMF.get().getPersistenceManager();
			Extent<EntityData> ext = pm.getExtent(EntityData.class);
			EntityData e = ext.iterator().next();
			// Serializableを取り出す。入れたデータが入っていることが確認できる。
			assertEquals("1", e.getProp().get("key"));
			// Serializableインスタンスの中身を変更して保存
			e.getProp().put("key", "2");
			pm.close();
		}
		{
			PersistenceManager pm = PMF.get().getPersistenceManager();
			Extent<EntityData> ext = pm.getExtent(EntityData.class);
			EntityData e = ext.iterator().next();
			// 変更内容が保存されていない
			// assertEquals("2", e.getProp().get("key"));
			assertEquals("1", e.getProp().get("key"));
			e.getProp().put("key", "3");
			// setterに明示的にアクセスすれば永続化されるのかな?
			// 保存していたSerializableインスタンスをsetterに渡す。
			e.setProp(e.getProp());
			pm.close();
		}
		{
			PersistenceManager pm = PMF.get().getPersistenceManager();
			Extent<EntityData> ext = pm.getExtent(EntityData.class);
			EntityData e = ext.iterator().next();
			// 変更内容が保存されていない
			// 多分、オブジェクトIDが等しいものがsetterでセットされても、永続化対象にならないのだろう
			// assertEquals("3", e.getProp().get("key"));
			assertEquals("1", e.getProp().get("key"));
			// Serializableインスタンスをclone()して、cloneされた新インスタンスを保存
			HashMap<String, Serializable> newMap = (HashMap<String, Serializable>) e
					.getProp().clone();
			newMap.put("key", "4");
			e.setProp(newMap);
			pm.close();
		}
		{
			PersistenceManager pm = PMF.get().getPersistenceManager();
			Extent<EntityData> ext = pm.getExtent(EntityData.class);
			EntityData e = ext.iterator().next();
			// 永続化できた!
			assertEquals("4", e.getProp().get("key"));
		}
	}
}