IT戦記

プログラミング、起業などについて書いているプログラマーのブログです😚

Effective Java 読書会 14 日目 「シリアライズ!シリアライズ!」

お前をシリアルにしてやろうか!


this photo is licensed by Horia Varlan

はじめに

いよいよ最後のページになりました!!!
はりきっていきましょう!!

今回の範囲

279 ページ 〜 305 ページ

シリアライズって何?

シリアライズとは、構造を持ったデータ(Java では、オブジェクトやプリミティブ)を、バイト列にすること。
たとえば、

  • オブジェクトをファイルに保存したい!
  • 通信先にこのオブジェクトを送りたい!
  • (具体的には、)ゲームデータをセーブしたい!(とか)

などなどの用途で使えます。

Javaシリアライズ

ObjectOutputStream にオブジェクトを書き込むと、 ObjectOutputStream がラップしているオブジェクトがバイト列に変換される。
たとえば、以下のようなことになる

package jp.amachang;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.util.Arrays;
import java.util.Date;

public class App {
    public static void main( String[] args ) throws IOException {

        // バイト配列ストリームを作る
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        
        // バイト配列ストリームをオブジェクトストリームでラップ
        ObjectOutputStream oout = new ObjectOutputStream(bout);
 
        // Date オブジェクトをシリアライズ
        oout.writeObject(new Date());
        
        oout.close();
        bout.close();
        
        // バイト配列ストリームに書き込まれたバイト列を表示
        System.out.println(Arrays.toString(bout.toByteArray()));
        // => [-84, -19, 0, 5, 115, 114, 0, 14, 106, 97, 118, 97, 46, 117, 116, 105, 108, 46, 68, 97, 116, 101, 104, 106, -127, 1, 75, 89, 116, 25, 3, 0, 0, 120, 112, 119, 8, 0, 0, 1, 39, -104, 127, 40, 91, 120]
    }
}

ちゃんと、 Date オブジェクトがバイト列になりました!!すごい!
もちろん ByteArrayOutputStream を FileOutputStream にすればファイルに保存できるし、 Socket と一緒に使えば RPC 的に使える!

シリアライズって何?

シリアライズは、シリアライズの逆でシリアライズで生成されたバイト列をオブジェクトに戻すことを言う。
バイト列に出来るだけじゃ意味ないですからね!

Java のデシリアライズ

Java のデシリアライズは ObjectInputStream で InputStream をラップして、 readObject すれば出来る。
以下の例を見てください。

package jp.amachang;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.util.Date;

public class App {
    public static void main( String[] args ) throws IOException, ClassNotFoundException {
        
        // さっきシリアライズした new Date() のバイト列
        byte[] bytes = {-84, -19, 0, 5, 115, 114, 0, 14, 106, 97, 118, 97, 46, 117, 116, 105, 108, 46, 68, 97, 116, 101, 104, 106, -127, 1, 75, 89, 116, 25, 3, 0, 0, 120, 112, 119, 8, 0, 0, 1, 39, -104, 127, 40, 91, 120};

        // バイト配列をストリームにする
        ByteArrayInputStream bin = new ByteArrayInputStream(bytes);
        
        // オブジェクトストリームでラップ
        ObjectInputStream oin = new ObjectInputStream(bin);
        
        // デシリアライズする
        Date date = (Date)oin.readObject();
        
        // 日付を表示
        System.out.println(date);
        // => Fri Mar 26 12:23:42 JST 2010
    }
}

自分の作ったクラスをシリアライズできるようにする

まず単純に

以下のようにしてみると

package jp.amachang;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;

// 自分の作ったオブジェクト
class Foo {
    int bar = 1;
    String baz = "foo";
}

public class App {
    public static void main( String[] args ) throws IOException {
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        ObjectOutputStream oout = new ObjectOutputStream(bout);
 
        // オブジェクトをシリアライズ
        oout.writeObject(new Foo());
        // ↑ ここで以下のようなエラーが発生してしまう
        // 
        // Exception in thread "main" java.io.NotSerializableException: jp.amachang.Foo
        //      at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1156)
        //      at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:326)
        //      at jp.amachang.App.main(App.java:19)
    }
}

このようにエラーになってしまう。

マーカーインタフェース Serializable を実装してあげる

Serializable でマーク付けすれば、シリアライズできるようになる。

package jp.amachang;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.Arrays;

// Serializable を実装してやる
class Foo implements Serializable {
    int bar = 1;
    String baz = "foo";
}

public class App {
    public static void main( String[] args ) throws IOException {
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        ObjectOutputStream oout = new ObjectOutputStream(bout);
 
        // オブジェクトをシリアライズ
        oout.writeObject(new Foo());

        oout.close();
        bout.close();
        
        // ちゃんとシリアライズされている
        System.out.println(Arrays.toString(bout.toByteArray()));
        // => [-84, -19, 0, 5, 115, 114, 0, 15, 106, 112, 46, 97, 109, 97, 99, 104, 97, 110, 103, 46, 70, 111, 111, -104, -48, -77, 46, -35, 69, 25, 40, 2, 0, 2, 73, 0, 3, 98, 97, 114, 76, 0, 3, 98, 97, 122, 116, 0, 18, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 120, 112, 0, 0, 0, 1, 116, 0, 3, 102, 111, 111]
    }
}
シリアライズできるかな?
package jp.amachang;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;

class Foo implements Serializable {
    int bar = 1;
    String baz = "foo";
}

public class App {
    public static void main( String[] args ) throws IOException, ClassNotFoundException {

        // さっき、シリアライズしたバイト列を
        byte[] bytes = {-84, -19, 0, 5, 115, 114, 0, 15, 106, 112, 46, 97, 109, 97, 99, 104, 97, 110, 103, 46, 70, 111, 111, -104, -48, -77, 46, -35, 69, 25, 40, 2, 0, 2, 73, 0, 3, 98, 97, 114, 76, 0, 3, 98, 97, 122, 116, 0, 18, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 120, 112, 0, 0, 0, 1, 116, 0, 3, 102, 111, 111};

        ByteArrayInputStream bin = new ByteArrayInputStream(bytes);
        ObjectInputStream oin = new ObjectInputStream(bin);
        
        // デシリアライズ
        Foo foo = (Foo) oin.readObject();
        
        // ちゃんと出来てるかな?
        System.out.println(foo.bar); // => 1
        System.out.println(foo.baz); // => foo
        // やったね!!
    }
}

こんな感じで、 Serializable を実装するだけで、シリアライズできるようになるんですね!

シリアライズした時とデシリアライズした時で、オブジェクトの形が変わっちゃったらどうなるん?

package jp.amachang;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.util.Date;

// さっきのオブジェクトを拡張
class Foo implements Serializable {
    int bar = 1;
    String baz = "foo";
    
    // このフィールドが追加された
    Date createdAt;
}

public class App {
    public static void main( String[] args ) throws IOException, ClassNotFoundException {

        // さっき、シリアライズしたバイト列を
        byte[] bytes = {-84, -19, 0, 5, 115, 114, 0, 15, 106, 112, 46, 97, 109, 97, 99, 104, 97, 110, 103, 46, 70, 111, 111, -104, -48, -77, 46, -35, 69, 25, 40, 2, 0, 2, 73, 0, 3, 98, 97, 114, 76, 0, 3, 98, 97, 122, 116, 0, 18, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 120, 112, 0, 0, 0, 1, 116, 0, 3, 102, 111, 111};

        ByteArrayInputStream bin = new ByteArrayInputStream(bytes);
        ObjectInputStream oin = new ObjectInputStream(bin);
        
        // デシリアライズ
        Foo foo = (Foo) oin.readObject();
        // ↑ ここで以下のようなエラーが発生!!!!
        // Exception in thread "main" java.io.InvalidClassException: jp.amachang.Foo; local class incompatible: stream classdesc serialVersionUID = -7435245970926528216, local class serialVersionUID = 911504535012214353
        //      at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:562)
        //      at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1583)
        //      at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1496)
        //      at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1732)
        //      at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1329)
        //      at java.io.ObjectInputStream.readObject(ObjectInputStream.java:351)
        //      at jp.amachang.App.main(App.java:28)
    }
}

節子「…にいちゃん…なんで、オブジェクト…死んでしまうん…?」
修造「大丈夫大丈夫!死んでない!死んでない!Serial Version UID で出来る出来る!出来る!」

というわけで、シリアライズするときに Sereal Version UID をつけましょう

シリアライズ
package jp.amachang;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.Arrays;

class Foo implements Serializable {

    // Serial Version UID を付ける
    private static final long serialVersionUID = 1L;

    int bar = 1;
    String baz = "foo";
}

public class App {
    public static void main( String[] args ) throws IOException, ClassNotFoundException {
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        ObjectOutputStream oout = new ObjectOutputStream(bout);
        oout.writeObject(new Foo());

        oout.close();
        bout.close();
        
        // ちゃんとシリアライズされる(バージョン番号(1L)が埋め込まれている)
        System.out.println(Arrays.toString(bout.toByteArray()));
        // => [-84, -19, 0, 5, 115, 114, 0, 15, 106, 112, 46, 97, 109, 97, 99, 104, 97, 110, 103, 46, 70, 111, 111, 0, 0, 0, 0, 0, 0, 0, 1, 2, 0, 2, 73, 0, 3, 98, 97, 114, 76, 0, 3, 98, 97, 122, 116, 0, 18, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 120, 112, 0, 0, 0, 1, 116, 0, 3, 102, 111, 111]
    }
}
シリアライズ
package jp.amachang;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.util.Date;

class Foo implements Serializable {

    // Serial Version UID は同じ
    private static final long serialVersionUID = 1L;

    int bar = 1;
    String baz = "foo";
    
    // 新しくフィールドを追加
    Date createdAt = new Date();
}

public class App {
    public static void main( String[] args ) throws IOException, ClassNotFoundException {

        // さっきシリアライズしたバイト列
        byte[] bytes = {-84, -19, 0, 5, 115, 114, 0, 15, 106, 112, 46, 97, 109, 97, 99, 104, 97, 110, 103, 46, 70, 111, 111, 0, 0, 0, 0, 0, 0, 0, 1, 2, 0, 2, 73, 0, 3, 98, 97, 114, 76, 0, 3, 98, 97, 122, 116, 0, 18, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 120, 112, 0, 0, 0, 1, 116, 0, 3, 102, 111, 111};

        ByteArrayInputStream bin = new ByteArrayInputStream(bytes);
        ObjectInputStream oin = new ObjectInputStream(bin);
        
        // Serial Version UID が同じならちゃんとデシリアライズできる
        Foo foo = (Foo)oin.readObject();
        
        System.out.println(foo.bar); // => 1
        System.out.println(foo.baz); // => foo
        
        // 新しく追加されたフィールドは null になっている
        System.out.println(foo.createdAt); // => null
    }
}

やったね!

Serial Version UID を付けない場合は

メソッドの追加などにも影響を受けるので、自分でちゃんと付けたほうがいいです。

シリアライズのとき、コンストラクタは呼ばれません!!!!

先ほどの例で、 foo.createdAt が null になっていたことからも分かるように、シリアライズ、デシリアライズは完全に言語外の仕組み、コンストラクタによって初期化が行われないことに注意しましょう!

シリアライズ形式は公開 API

シリアライズ形式とは、シリアライズされるバイト列の形式のこと。
シリアライズ形式は、一度決まるといたるところで使われ、それを変更するのは難しい。
たとえば、ソフトウェアがアップデートされても、前のバージョンで保存されたデータが PC に残っているかもしれない。
よって、シリアライズ形式は公開 API と認識すべし。

自分でシリアライズ形式をカスタマイズする

シリアライズ形式を自分で制御することもできる。

package jp.amachang;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

class Foo implements Serializable {

    private static final long serialVersionUID = 1L;

    int bar = 1;
    String baz = "foo";

    // 自分でシリアライズ
    private void writeObject(ObjectOutputStream out) throws IOException {
        out.writeInt(bar);
        out.writeObject(baz);
    }

    // 自分でデシリアライズ
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        bar = in.readInt();
        baz = (String) in.readObject();
    }
}

public class App {
    public static void main( String[] args ) throws IOException, ClassNotFoundException {
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        ObjectOutputStream oout = new ObjectOutputStream(bout);
        
        // シリアライズ
        oout.writeObject(new Foo());
        
        oout.close();
        bout.close();
        
        ByteArrayInputStream bin = new ByteArrayInputStream(bout.toByteArray());
        ObjectInputStream oin = new ObjectInputStream(bin);
        
        // デシリアライズ
        Foo foo = (Foo) oin.readObject();
        
        // ちゃんと出来てる???
        System.out.println(foo.bar); // => 1
        System.out.println(foo.baz); // => foo
    }
}

transient と default(Read|Write)Object を使う

また、一部だけカスタマイズした場合は、 transient と default(Read|Write)Object を使うといい

class Foo implements Serializable {

    private static final long serialVersionUID = 1L;

    // baz のシリアライズの仕方だけカスタマイズする
    int bar = 1;
    transient String baz = "foo";

    private void writeObject(ObjectOutputStream out) throws IOException {
        out.defaultWriteObject(); // transient が付いていないフィールドを全部シリアライズ
        out.writeObject(baz); // baz だけ手動でシリアライズ
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        out.defaultReadObject(); // transient が付いていないフィールドを全部デシリアライズ
        baz = (String) in.readObject(); // baz を手動でデシリアライズ
    }
}

バイナリ互換性とセマンティック互換性

シリアライズ形式が偶然同じでも、フィールドが持つ意味(セマンティック)が変わっているかもしれない。
フィールドの意味も考えて、シリアライズ、デシリアライズを考えないといけない!

Serializable じゃなくて、デフォルトコンストラクタを持たないクラスを継承した場合、デシリアライズできない

package jp.amachang;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

// デフォルトコンストラクタを持たず、
// Serializable な親を持つ場合は
abstract class Bar {
    Bar(int i) {} 
}

class Foo extends Bar implements Serializable {

    private static final long serialVersionUID = 1L;

    Foo() {
        super(1);
    }
    
    int bar = 1;
    String baz = "foo";
}

public class App {
    public static void main( String[] args ) throws IOException, ClassNotFoundException {
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        ObjectOutputStream oout = new ObjectOutputStream(bout);
        
        oout.writeObject(new Foo());
        
        oout.close();
        bout.close();
        
        ByteArrayInputStream bin = new ByteArrayInputStream(bout.toByteArray());
        ObjectInputStream oin = new ObjectInputStream(bin);
        
        // デシリアライズ
        Foo foo = (Foo) oin.readObject();
        // ここで例外が発生
        // Exception in thread "main" java.io.InvalidClassException: jp.amachang.Foo; jp.amachang.Foo; no valid
        // constructor
        // at java.io.ObjectStreamClass.checkDeserialize(ObjectStreamClass.java:713)
        // at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1733)
        // at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1329)
        // at java.io.ObjectInputStream.readObject(ObjectInputStream.java:351)
        // at jp.amachang.App.main(App.java:42)
        // Caused by: java.io.InvalidClassException: jp.amachang.Foo; no valid constructor
        // at java.io.ObjectStreamClass.<init>(ObjectStreamClass.java:471)
        // at java.io.ObjectStreamClass.lookup(ObjectStreamClass.java:310)
        // at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1106)
        // at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:326)
        // at jp.amachang.App.main(App.java:33)
    }
}

原因は、デシリアライズ時にデシリアライズされるオブジェクトのもっとも近い Serializable じゃない親のデフォルトコンストラクタが呼ばれるから。
そのオブジェクトのコンストラクタは決して呼ばれないので注意せよ!!!
なので、 abstract クラスにはなるべくデフォルトコンストラクタを用意しましょう
デフォルトコンストラクタ以外の処理がある場合、コンストラクタの処理を protected な初期化メソッドとして切り出して置いて、 readObject の中で呼び出すといいよ!

enum は、難しいこと考えなくてもシリアライズ可能

自前でのシングルトンやインスタンス制御より enum のほうが楽ちん!

readObject を呼び出すときの注意

オーバーライドされた可能性のあるメソッドを呼び出さない!(サブクラスで、親クラスに存在しないフィールドを参照する可能性があるため)
コンストラクタで防御的なコピーをしているなら、 readObject でもやる!

シリアライズ・プロキシーパターン

意味的にシリアライズすべき、フィールドだけを持ったプロキシークラスを生成して、そのクラスがシリアライズされる

public class Cereal implements Serializable {
    private String  name;
    private int     calorie;
   
    public Cereal(String name, int calorie) {
        this.name = name;
        this.calorie = calorie;
    }
   
    /**
     *  このクラスの代わりにシリアライズする代替オブジェクトを返す
     */
    private Object writeReplace() {
        //  Cereal の代わりに CerealProxy をシリアライズする
        return new CerealProxy(this);
    }
     
    /**
     *  このクラスのデシリアライズの実装。
     */
    private void readObject(ObjectInputStream in) throws InvalidObjectException {
        //  このクラスはデシリアライズさせない!
        throw new InvalidObjectException("Proxy required.");
    }
     
    /**
     *  Cereal のシリアライズプロキシクラス。
     *  Cereal とは論理的に等価
     */
    private static class CerealProxy implements Serializable {
        private static final long serialVersionUID = 1L;
   
        private final String    name;
        private final int       calorie;
         
        public CerealProxy(Cereal cereal) {
            this.name = cereal.name;
            this.calorie = cereal.calorie;
        }
         
        /**
         *  このクラスの代わりにデシリアライズする代替オブジェクトを返す
         */
        private Object readResolve() {
            //  CerealProxy の代わりに Cereal をデシリアライズする
            return new Cereal(name, calorie);
        }
    }
}

シリアライズロキシーパターンのコードは、同僚の K.Nishina (Twitter とかブログやってないのかな><)さんが書いてくれたものを使わせてもらいました!
シリアル!

まとめ

これで、すべてのページが終了しました!!!
やったぜええええええええええ!