java.io.Serializable 実装クラスに対してどのようなテストを行うべきなのかを考えるシリーズの3回目です。
これまで2回にわたり、Serializable 実装クラスに対して必要なテストについて実例を交えて見てきました。
今回のテーマは「ケース3:そのクラス、本当に Serializable ですか?- 最低限、シリアライズ/デシリアライズしてみる」です。
#Serializable を implements しただけではシリアライズ/デシリアライズ可能にならない場合がある
Serializable を implements しさえすれば自動的にシリアライズ/デシリアライズ可能になるというのは、よくある大きな誤解です。実例を見てみましょう。
##実例1
次の Odd クラスは、奇数値を保持するだけの単純な int のラッパーです。
public class Odd {
private int n;
public Odd(int n) {
set(n);
}
public final void set(int n) {
if (n < 1 || n % 2 == 0) {
throw new IllegalArgumentException("n must be an odd. n==" + n);
}
this.n = n;
}
public int get() {
return n;
}
}
このままではあまり役に立たないので、Odd を継承して ExtendedOdd クラスを作りました。値が何回参照されたかカウントする機能を追加するとともに、それをファイルに保存できるよう、Serializable を実装しています。
public class ExtendedOdd extends Odd implements Serializable {
private AtomicInteger counter = new AtomicInteger();
public ExtendedOdd(int n) {
super(n);
}
@Override
public int get() {
counter.incrementAndGet();
return super.get();
}
public int getCount() {
return counter.get();
}
}
さて、ExtendedOdd クラスはシリアライズ/デシリアライズ可能でしょうか? いいえ、このクラスのオブジェクトをデシリアライズすることはできません。
実際にやってみましょう。
public class ExtendedOddTest {
@Test
public void test1() throws ClassNotFoundException, IOException {
ExtendedOdd eOdd = new ExtendedOdd(7);
byte[] bytes = TestUtil.serialize(eOdd);
Object obj = TestUtil.deserialize(bytes);
assertThat(obj, instanceOf(ExtendedOdd.class));
}
}
このテストを実行すると、Object obj = TestUtil.deserialize(bytes);
の行で次の例外が発生して失敗します。
java.io.InvalidClassException: mypkg.ExtendedOdd; no valid constructor
原因は、Odd が引数なしのコンストラクタを持たないことです。
Odd 自体は Serializable ではないので、インスタンス化するためにはコンストラクタを用いるほかありません1。ExtendedOdd をデシリアライズする過程で、デシリアライズ機構は引数なしのコンストラクタを用いて Odd 部分をインスタンスに復元しようとします。このため失敗するわけです。
Odd 自体を Serializable にすることでこの問題は解消します2。
##実例2
次の例はオセロ盤クラスです。
盤上の位置を (i, j) で指定できるようにしつつ、内部では Position オブジェクトに変換することにより、データを HashMap で管理できるようにしています。
(え? 2次元配列で実装すりゃ良いって? いやいやこれは例ですから...)
public class OthelloBoard implements Serializable {
private final Map<Position, Color> map = new HashMap<>();
public void put(int i, int j, Color color) {
// 紙幅の関係でパラメータチェックは省略(以下同様)
map.put(new Position(i, j), color);
}
public Color get(int i, int j) {
return map.get(new Position(i, j));
}
private static class Position {
private final int i;
private final int j;
private Position(int i, int j) {
this.i = i;
this.j = j;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof Position) {
Position p = (Position) obj;
return i == p.i && j == p.j;
}
return false;
}
@Override
public int hashCode() {
return 8 * i + j;
}
}
}
OthelloBoard は正しく動きますが、シリアライズできません。
実際にやってみましょう。
public class OthelloBoardTest {
@Test
public void test1() throws IOException, ClassNotFoundException {
byte[] bytes = TestUtil.serialize(new OthelloBoard());
Object obj = TestUtil.deserialize(bytes);
assertThat(obj, instanceOf(OthelloBoard.class));
}
@Test
public void test2() throws IOException, ClassNotFoundException {
OthelloBoard board = new OthelloBoard();
board.put(3, 4, Color.BLACK);
board.put(4, 3, Color.BLACK);
board.put(3, 3, Color.WHITE);
board.put(4, 4, Color.WHITE);
byte[] bytes = TestUtil.serialize(board);
Object obj = TestUtil.deserialize(bytes);
assertThat(obj, instanceOf(OthelloBoard.class));
}
}
test1 には合格しますが、test2 は byte[] bytes = TestUtil.serialize(board);
の行で次の例外が発生して失敗します。
java.io.NotSerializableException: mypkg.OthelloBoard$Position
原因は内部クラス Position が Serializable でないことです。
OthelloBoard クラスのインスタンスフィールド map は HashMap クラスのオブジェクトであり、これは Serializable です。(そのため、test1 には合格しました。)
しかし、map の内部から参照している Position オブジェクトが Serializable ではないため、test2 に失敗しました。
このように、シリアライズ機構は基本的に、シリアライズ対象のオブジェクトだけでなく、そのオブジェクトの内部から参照している他のオブジェクトを芋づる式にすべてシリアライズしようとします。
そして、そのオブジェクトグラフの中にひとつでも Serializable でないクラスのオブジェクトがあると、元のオブジェクトのシリアライズ自体が失敗するというわけです。
#ケース3:そのクラス、本当に Serializable ですか?- 最低限、シリアライズ/デシリアライズしてみる
2つの例を見てきましたが、これらから得られる教訓は単純です。
Serializable を実装したのであれば、実際にシリアライズ/デシリアライズしてみるべきであるということです。
シリアライズ/デシリアライズに関する実装の妥当性について、コンパイラは殆ど何も警告してくれません。何らの警告なしにビルドが成功し、プログラムとして動作もし、そして実際にシリアライズ/デシリアライズしてみて初めて実装の間違いに気づくのです。
やはり最低限、実際にシリアライズ/デシリアライズしてみるというテストを行うべきでしょう。
さて 次稿 では、これまで3回にわたり見てきた内容をまとめます。
#おまけ
OthelloBoard のもう少しマシな例として、以前の投稿『リバーシで遊んで覚える Java 8.』で紹介したソースの中の GitHub: StrictBoardクラスへのリンク を貼っておきます。
ソースへの突っ込みなどなど、お待ちしております。
~ ~ ~
しかし投稿者自ら言うのもナンですが、シリアライズ/デシリアライズのテストって try/catch だとか IOException だとか、とにかく面倒な印象しかないんですよねぇ。。。これをもっとカンタンに、省力化できればと。
これについては、稿を改めたいと思います。⇒ 投稿しました:『JUnit4でのシリアライズ/デシリアライズ検証を楽チンにする!』
#参考
本稿で使用したソースを掲載します。
なお、java のバージョンは java 8 です。
####実例1
package mypkg;
public class Odd {
private int n;
public Odd(int n) {
set(n);
}
public final void set(int n) {
if (n < 1 || n % 2 == 0) {
throw new IllegalArgumentException("n must be an odd. n==" + n);
}
this.n = n;
}
public int get() {
return n;
}
}
package mypkg;
import java.io.Serializable;
import java.util.concurrent.atomic.AtomicInteger;
public class ExtendedOdd extends Odd implements Serializable {
private AtomicInteger counter = new AtomicInteger();
public ExtendedOdd(int n) {
super(n);
}
@Override
public int get() {
counter.incrementAndGet();
return super.get();
}
public int getCount() {
return counter.get();
}
}
package mypkg;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import java.io.IOException;
import org.junit.Test;
public class ExtendedOddTest {
@Test
public void test1() throws ClassNotFoundException, IOException {
ExtendedOdd eOdd = new ExtendedOdd(7);
byte[] bytes = TestUtil.serialize(eOdd);
Object obj = TestUtil.deserialize(bytes);
assertThat(obj, instanceOf(ExtendedOdd.class));
}
}
####実例2
package mypkg;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
public class OthelloBoard implements Serializable {
private final Map<Position, Color> map = new HashMap<>();
public void put(int i, int j, Color color) {
// 紙幅の関係でパラメータチェックは省略(以下同様)
map.put(new Position(i, j), color);
}
public Color get(int i, int j) {
return map.get(new Position(i, j));
}
private static class Position {
private final int i;
private final int j;
private Position(int i, int j) {
this.i = i;
this.j = j;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof Position) {
Position p = (Position) obj;
return i == p.i && j == p.j;
}
return false;
}
@Override
public int hashCode() {
return 8 * i + j;
}
}
}
package mypkg;
public enum Color {
BLACK,
WHITE;
}
package mypkg;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import java.io.IOException;
import org.junit.Test;
public class OthelloBoardTest {
@Test
public void test1() throws IOException, ClassNotFoundException {
byte[] bytes = TestUtil.serialize(new OthelloBoard());
Object obj = TestUtil.deserialize(bytes);
assertThat(obj, instanceOf(OthelloBoard.class));
}
@Test
public void test2() throws IOException, ClassNotFoundException {
OthelloBoard board = new OthelloBoard();
board.put(3, 4, Color.BLACK);
board.put(4, 3, Color.BLACK);
board.put(3, 3, Color.WHITE);
board.put(4, 4, Color.WHITE);
byte[] bytes = TestUtil.serialize(board);
Object obj = TestUtil.deserialize(bytes);
assertThat(obj, instanceOf(OthelloBoard.class));
}
}
####共通
package mypkg;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.Objects;
public class TestUtil {
public static byte[] serialize(Object obj) throws IOException {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos)) {
oos.writeObject(obj);
return bos.toByteArray();
}
}
public static Object deserialize(byte[] bytes)
throws ClassNotFoundException, IOException {
Objects.requireNonNull(bytes);
try (ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bis)) {
return ois.readObject();
}
}
@SuppressWarnings("unchecked")
public static <T> T writeAndRead(T obj)
throws IOException, ClassNotFoundException {
byte[] bytes = serialize(obj);
return (T) deserialize(bytes);
}
private TestUtil() {
}
}
#注釈
-
一方、Serializable なクラスがデシリアライズされる際には、デシリアライズ機能によりインスタンスが直接生成され、コンストラクタが迂回されます。このことは、ExtendedOdd クラスのコンストラクタにデバッグログ出力コードを仕込むことにより容易に確認できます。 ↩
-
ただし、これはあまりよい解決策と言えません。Odd を継承している、あるいはこれから継承する他の全てのクラスに影響が及ぶためです。詳細な議論は Amazon:『Effective Java 第2版』-「項目74: Serializable を注意して実装する」を参照してください。 ↩