CLOVER🍀

That was when it all began.

Javaとシリアライズと互換性

少し、オブジェクトのシリアライズ(直列化)とその影響について、調べる必要がありまして。

これまで、あまりシリアライズを使う、特にクラスの互換性的な面はあまり考慮しなかった(というか、シリアライズされたオブジェクトの授受は避けていた)のですが、ちょっと気にする必要が出てきました。実際に使用するかどうかは別ですが。

Javaのシリアライズの仕様は、こちらに記載があります。

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がスローされました。

というわけで、互換性のない変更をいくつか試してみましたが、例外になるものならないものがあります。ですが、例外が投げられないだけで、実際のアプリケーションが期待するデータがごそっとなくなったりするわけですので、シリアライズしてやり取りするクラスの互換性ってすごい大事ですよ、ということを考えさせられる気がするのですが。

世の中の人達、この手のクラスのアップグレードはどう対処してるんでしょ?