少し、オブジェクトのシリアライズ(直列化)とその影響について、調べる必要がありまして。
これまで、あまりシリアライズを使う、特にクラスの互換性的な面はあまり考慮しなかった(というか、シリアライズされたオブジェクトの授受は避けていた)のですが、ちょっと気にする必要が出てきました。実際に使用するかどうかは別ですが。
Java オブジェクト直列化仕様
http://docs.oracle.com/javase/jp/6/platform/serialization/spec/serialTOC.html
JDK 7版(英語)
http://docs.oracle.com/javase/7/docs/platform/serialization/spec/serialTOC.html
で、気になるところは、主にここですね。
直列化に影響する型変更
http://docs.oracle.com/javase/jp/6/platform/serialization/spec/version.html#6678
どういう時に、互換性がなくなるんだろう?ということ。
仕様によると、まずはこういうことらしいです。
5.6.1 互換性のない変更
クラスに対する互換性のない変更とは、相互運用性の保証が維持できないような変更です。クラスの展開の過程で起こる互換性のない変更には、次のものがあります。
- フィールドを削除する
- 階層においてクラスを上方または下方に移動する
- 非 static フィールドを static に、または 非 transient フィールドを transient に変更する
- プリミティブフィールドの宣言された型を変更する
- writeObject メソッドまたは readObject メソッドを、デフォルトのフィールドデータの書き込みまたは読み込みを行わないように変更したり、前のバージョンが書き込みまたは読み込みを行わなかった場合にその書き込みまたは読み込みを行うように変更する
- クラスを Serializable から Externalizable に変更したり、その反対を行なったりする
- クラスを非 enum 型から enum 型に変更したり、その反対を行なったりする
- Serializable や Externalizable を取り除く
- writeReplace または readResolve メソッドをクラスに追加するときに、その動作がクラスの以前のバージョンと互換性がないオブジェクトを作成する
反対に、互換性がある変更は、
- フィールドの追加
- クラスの追加
- クラスの削除
- writeObject/readObject メソッドの追加
- writeObject/readObject メソッドの削除
- java.io.Serializable の追加
- フィールドへのアクセス修飾子を変更
- フィールドの static から非 static へ、または transient から非 transienst への変更
ということらしいです。とはいえ、これってクラスの形式の互換性の話で、実際のアプリケーションの互換性はどう?というのは、別問題ですよね。
で、とりあえず互換性がなくなるというもののうち、前のバージョンで読み込まれた時に問題になるものを試してみようと思います。対象は、「フィールドを削除する」、「非 static フィールドを static に、または 非 transient フィールドを transient に変更する」、「プリミティブフィールドの宣言された型を変更する」、「クラスを Serializable から Externalizable に変更したり、その反対を行なったりする」ですね。
writeObjectやreadObjectの実装変更もそうですが、これはいったん置いておきます。
シリアライズの確認対象として、こんなクラスを用意。
SerializeTarget.java
import java.lang.reflect.Field; import java.io.Serializable; public class SerializeTarget implements Serializable { private static final long serialVersionUID = 1L; public String stringField1 = "defaultValue1"; public String stringField2 = "defaultValue2"; public int intField = 10; public void method1() { } @Override public String toString() { StringBuilder builder = new StringBuilder(); for (Field f : getClass().getDeclaredFields()) { builder.append(" "); builder.append(f.getName()); builder.append(" = "); try { Object v = f.get(this); if (v != null) { builder.append(v.toString()); } else { builder.append("null"); } } catch (ReflectiveOperationException e) { throw new RuntimeException(e); } builder.append(System.lineSeparator()); } if (builder.length() > 0) { return builder .delete(builder.length() - System.lineSeparator().length(), builder.length()) .toString(); } else { return builder.toString(); } } }
これをコンパイルして
$ javac SerializeTarget.java
この時のクラスファイルを保存しておきます。
$ cp -p SerializeTarget.class SerializeTarget.class.org
そして、シリアライズ/デシリアライズの操作を行うクラス。
Exec.java
import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; public class Exec { public static void main(String[] args) { if (args.length == 0) { System.out.println("Argument, ser or des"); System.exit(1); } String fileName = "object.ser"; switch (args[0]) { case "ser": serialize(fileName); break; case "des": deserialize(fileName); break; default: System.out.println("Argument, ser or des"); System.exit(1); } } private static void serialize(String fileName) { SerializeTarget st = new SerializeTarget(); st.stringField1 = "stringField1"; st.stringField2 = "stringField2"; st.intField = 100; try (FileOutputStream fis = new FileOutputStream(fileName); BufferedOutputStream bos = new BufferedOutputStream(fis); ObjectOutputStream oos = new ObjectOutputStream(bos)) { oos.writeObject(st); } catch (IOException e) { e.printStackTrace(); } System.out.println("SerializeValue = "); System.out.println(st); } private static void deserialize(String fileName) { try (FileInputStream fis = new FileInputStream(fileName); BufferedInputStream bos = new BufferedInputStream(fis); ObjectInputStream ois = new ObjectInputStream(bos)) { SerializeTarget st = (SerializeTarget) ois.readObject(); System.out.println("DeserializedValue = "); System.out.println(st); } catch (ClassNotFoundException | IOException e) { e.printStackTrace(); } } }
こんな感じで使います。シリアライズする時は
$ java Exec ser SerializeValue = serialVersionUID = 1 stringField1 = stringField1 stringField2 = stringField2 intField = 100
デシリアライズする時は
$ java Exec des DeserializedValue = serialVersionUID = 1 stringField1 = stringField1 stringField2 = stringField2 intField = 100
共に、シリアライズ/デシリアライズした時の値をコンソールに出力します。
では、いってみましょう。
serialVersionUIDを変更する
いきなり、番外編。変えたことある人、多いのかな?と思いまして。
シリアライズ時に
private static final long serialVersionUID = 1L;
としていた値を、
private static final long serialVersionUID = 10L;
として、実行。
$ java Exec des java.io.InvalidClassException: SerializeTarget; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 10 at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:617) at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1620) at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1515) at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1769) at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1348) at java.io.ObjectInputStream.readObject(ObjectInputStream.java:370) at Exec.deserialize(Exec.java:52) at Exec.main(Exec.java:22)
というわけで、InvalidClassExceptionがスローされます。
この後、serialVersionUIDは1Lに戻しました。
フィールドを削除する
続いて、フィールドの削除。ひとつ、フィールドをコメントアウトします。
//public String stringField2 = "defaultValue2";
で、シリアライズして保存します。
$ java Exec ser SerializeValue = serialVersionUID = 1 stringField1 = stringField1 intField = 100
元のバージョンにクラスファイルを戻して
$ cp SerializeTarget.class.org SerializeTarget.class
デシリアライズしてみます。
$ java Exec des DeserializedValue = serialVersionUID = 1 stringField1 = stringField1 stringField2 = null intField = 100
…なくなったフィールドの値が、nullになりました。
フィールド削除の説明を読むと
フィールドを削除する - クラスのフィールドが削除されると、書き込まれたストリームにはその値がない。そのストリームが以前のクラスによって読み込まれると、ストリームに値がないため、そのフィールドの値はデフォルト値に設定される。しかし、このデフォルト値は、以前のバージョンがその規約を果たす能力を損なうことがある
とあるので、ここでの「デフォルト値」はフィールド宣言時の初期値ではなく、各宣言された型に応じた初期値であることがわかりますね。
非 static フィールドを static に、または 非 transient フィールドを transient に変更する
続いて、こう変更。
public transient String stringField1 = "defaultValue1"; public static String stringField2 = "defaultValue2";
$ java Exec ser SerializeValue = serialVersionUID = 1 stringField1 = stringField1 stringField2 = stringField2 intField = 100
初期バージョンのクラスに戻して
$ cp SerializeTarget.class.org SerializeTarget.class
デシリアライズ。
$ java Exec des DeserializedValue = serialVersionUID = 1 stringField1 = null stringField2 = null intField = 100
非 static フィールドを static に、または 非 transient フィールドを transient に変更する - デフォルトの直列化を前提としている場合、この変更は、フィールドをクラスから削除するのと同じことである。そのクラスのこのバージョンでは、そのデータはストリームに書き込まれないので、そのクラスの以前のバージョンで読むことはできない。フィールドの削除と同じように、以前のバージョンのフィールドはデフォルト値に初期化されるので、そのクラスは予期できないエラーとなることがある
なので、確かにフィールド削除の時と同じような動きをしていますね。
「Serializable や Externalizable を取り除く」というのも、おそらくシリアライズ対象のクラスのメンバーとして定義されているクラスからSerializableやExternalizableを除くという意味だと思うので、transientに変更するのとほぼ同じだと思われます。
プリミティブフィールドの宣言された型を変更する
intだったフィールドを、Integerに変更。
public Integer intField = 10;
$ java Exec ser SerializeValue = serialVersionUID = 1 stringField1 = stringField1 stringField2 = stringField2 intField = 100
クラスを戻して
$ cp SerializeTarget.class.org SerializeTarget.class
デシリアライズ。
$ java Exec des java.io.InvalidClassException: SerializeTarget; incompatible types for field intField at java.io.ObjectStreamClass.matchFields(ObjectStreamClass.java:2254) at java.io.ObjectStreamClass.getReflector(ObjectStreamClass.java:2149) at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:657) at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1620) at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1515) at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1769) at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1348) at java.io.ObjectInputStream.readObject(ObjectInputStream.java:370) at Exec.deserialize(Exec.java:52) at Exec.main(Exec.java:22)
これは、InvalidClassExceptionが投げられ、エラーとなります。
クラスを Serializable から Externalizable に変更したり、その反対を行なったりする
今度は、Externalizableを実装します。
public class SerializeTarget implements Externalizable {
readExternal/writeExternalメソッドも実装。
@Override public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { stringField1 = (String) in.readObject(); stringField2 = (String) in.readObject(); intField = in.readInt(); } @Override public void writeExternal(ObjectOutput out) throws IOException { out.writeObject(stringField1); out.writeObject(stringField2); out.writeInt(intField); }
$ java Exec ser SerializeValue = serialVersionUID = 1 stringField1 = stringField1 stringField2 = stringField2 intField = 100
クラスを元に戻して
$ cp SerializeTarget.class.org SerializeTarget.class
デシリアライズ。
$ java Exec des java.io.InvalidClassException: SerializeTarget; Serializable incompatible with Externalizable at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:634) at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1620) at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1515) at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1769) at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1348) at java.io.ObjectInputStream.readObject(ObjectInputStream.java:370) at Exec.deserialize(Exec.java:52) at Exec.main(Exec.java:22)
InvalidClassExceptionがスローされました。
というわけで、互換性のない変更をいくつか試してみましたが、例外になるものならないものがあります。ですが、例外が投げられないだけで、実際のアプリケーションが期待するデータがごそっとなくなったりするわけですので、シリアライズしてやり取りするクラスの互換性ってすごい大事ですよ、ということを考えさせられる気がするのですが。
世の中の人達、この手のクラスのアップグレードはどう対処してるんでしょ?