1. 互換性の問題
想像してください。あなたはアプリの最初のバージョンをリリースし、ユーザーがデータ(たとえばユーザープロファイルや設定)を保存し始めました。1か月後、クラス UserProfile に email フィールドが足りないと気づいて追加しました。すべて順調……と思いきや、古いファイルを読み込もうとした瞬間に問題が起きます。うまくいけば新しいフィールドは空のままですが、最悪の場合は例外が発生し、ユーザーは不満を抱くでしょう。
シリアライズの互換性とは、プログラムが以前のバージョンのクラスでシリアライズされたデータを正しく読み込める能力であり、その逆も同様です。Java(特に Serializable によるバイナリシリアライズ)では、JVM がクラス構造の変更に非常に厳密であるため、このテーマはとりわけ重要です。
問題が発生しやすい典型的なシナリオ:
- クラスに新しいフィールドを追加した。
- 古いフィールドを削除した。
- フィールドの型を変更した(例: int を String に)。
- クラス名を変更した、またはクラスを別のパッケージへ移動した。
- オブジェクトをシリアライズするライブラリ/フレームワークを更新した。
これらの場合、古いシリアライズ済みデータは新しいプログラムのバージョンでは「読み込めない」状態になり得ます。
2. serialVersionUID: シリアライズ可能クラスのパスポート
Java では、(インターフェース Serializable を実装する)すべてのシリアライズ可能クラスに、バージョンを表す一意の識別子 serialVersionUID が存在します。これは、対象のクラスでオブジェクトをデシリアライズできるかどうかを JVM が確認するために使われます。識別子が一致しない場合は InvalidClassException が発生します。
private static final long serialVersionUID = 1L;
このフィールドを明示的に宣言していない場合、Java はクラスの構造(フィールド、メソッド、修飾子など)に基づいて自動生成します。しかし、のちにクラスを(たとえ些細にでも)変更すると、自動生成された serialVersionUID は変わり、古いデータとの互換性が失われます。
検証はどう動くのか?
オブジェクトをシリアライズするとき、そのデータと一緒に serialVersionUID の値もストリームへ書き込まれます。デシリアライズ時には、JVM がその識別子と現在のクラスに記載された識別子を照合します。一致すればオブジェクトは問題なく復元されますが、一致しない場合は処理が直ちにエラーで中断されます。JVM は、クラスが古いデータに適合しないほど変更されたと判断するためです。
なぜ serialVersionUID を明示的に宣言するのか?
自分で serialVersionUID を設定すれば、どの変更を「許容」するかをコントロールできます。たとえば新しいフィールドを追加しても、古いオブジェクトを読み込めるようにしたい場合は、識別子を以前のままにします。そうすればデシリアライズは問題なく進みます。自動生成に頼ると、コードのわずかな変更でも serialVersionUID が変わり、古い保存データを開けなくなるという不幸が起こり得ます。
例:
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
// ... ゲッターとセッター
}
これで、(必須でない限り)新しいフィールドを安心して追加しても、古いオブジェクトのデシリアライズは壊れません。
3. クラスを変更すると何が起きるか?
新しいフィールドの追加
古いシリアライズ済みオブジェクト → 追加フィールドを持つ新しいクラス
- 新しいフィールドは既定値(null、0、false)になります。
- それ以外は正しくデシリアライズされます。
例:
// 以前:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
}
// 変更後:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private String email; // 新しいフィールド
}
結果: 古いオブジェクトは読み込め、email は null になります。
フィールドの削除
古いシリアライズ済みオブジェクトに含まれるフィールドが、新しいクラスには存在しない
- そのフィールドはデシリアライズ時に単に無視されます。
- 重要なのは serialVersionUID を変更しないことです。
フィールド型の変更
例として、以前は int age、それを String age に変更したとします。
- これは非互換な変更です。デシリアライズを試みるとエラーが発生します(一般的には InvalidClassException や ClassCastException)。
- このような変更は避けるか、(下記の)カスタムシリアライズで互換性を担保しましょう。
クラスまたはパッケージの名前変更
ここは厳格です。クラス名やパッケージを変更すると、デシリアライズは通りません。シリアライズ済みストリームには完全修飾クラス名が保存されており、JVM はそれと完全に一致する名前を期待します。そのため、あらゆる改名は重大な変更とみなされます。プロジェクト構造を変更する必要がある場合は、手動によるデータ移行が避けられません。
4. transient と static: 何がシリアライズされ、何がされないか?
- static フィールドはそもそもシリアライズされません。これはクラスに属し、オブジェクトに属さないためです。
- transient フィールドは、一時的でシリアライズすべきでないデータ(例: キャッシュ、一時的なトークン)としてマークします。
例:
public class Session implements Serializable {
private static final long serialVersionUID = 1L;
private String user;
private transient String sessionToken; // シリアライズされない
}
デシリアライズ時、sessionToken は、シリアライズ前に値が入っていたとしても null になります。
5. カスタムシリアライズ: writeObject/readObject
より複雑な互換性ロジック(例: 古いフィールドを新形式に変換、型変更への対応)が必要な場合は、次の特別なメソッドを実装できます。
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
// 必要であれば追加ロジック
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
// 追加ロジック(例: 既存の値から新しいフィールドを補完する)
}
進化の例:
public class User implements Serializable {
private static final long serialVersionUID = 2L;
private String name;
private int age; // 以前は String birthYear だった
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
// birthYear があれば、age に変換する
// (birthYear を transient として保持していた場合のサンプル)
}
}
6. XML と JSON における互換性: テキストフォーマットの柔軟性
バイナリシリアライズと異なり、XML や JSON といったフォーマットはクラス構造の変更に対してはるかに寛容です。
XML (JAXB) と JSON (Jackson, Gson)
バイナリシリアライズとは異なり、XML や JSON を使う場合のデシリアライズはかなり寛容です。データにあなたのクラスに存在しないフィールドが含まれていても、それは単に無視されます。逆に、クラスに新しいフィールドがあっても、元データにそれがない場合は既定値になります——通常、オブジェクトは null、数値は 0 です。要素の順序は意味を持たないため、タグやキーを入れ替えても正しくパースされます。
アノテーションにより完全に制御できます。ファイルで使用する名前、必須か任意か、フォーマットなどを指定できます。たとえば JAXB では、クラス User は次のように記述できます:
public class User {
@XmlElement(required = true)
private String name;
@XmlElement
private String email; // 新しいフィールド、必須ではない
}
JSON(Jackson または Gson)の場合は概ね次のとおりです:
public class User {
@JsonProperty("name")
private String name;
@JsonProperty("email")
private String email; // 新しいフィールド
}
結果は良好です。古い JSON や XML ファイルは問題なく読み込まれ、新しいフィールドは単に null を受け取り、データ側に余計なフィールドがあっても無視されます。クラス構造を安心して変更でき、古い保存データを壊す心配が減ります。
いつ厳密な制御が必要か?
フィールドを必須にする場合は特に注意が必要です。古いデータにそのフィールドがないと、デシリアライズはエラーになります。型変更も同様で、以前は文字列だったフィールドを数値にした場合、古いデータがパースに失敗することがあります。こうした変更の前には、既存の保存データへの影響を確認し、必要に応じて移行や既定値の設定を用意しましょう。
7. 互換性確保の戦略
- 明示的に宣言する: serialVersionUID。バイナリシリアライズにおける互換性管理の要です。
- 追加するのは「必須ではない」フィールドのみ。 新しいフィールドは null か、既定値を持たせましょう。
- 使用する: transient (一時的または重要でないデータに)。 これらのフィールドはシリアライズされず、クラス進化時の問題を避けられます。
- クラスの変更を文書化する。 クラスコメントに、どのフィールドをいつ追加/削除したかを記載しましょう。
- 複雑なケースでは writeObject/readObject。読み込み時にデータ移行を実装できます。
- スキーマを利用する(XML Schema, JSON Schema)重要データ向けに。 データ構造を明示でき、読み込み時の検証にも役立ちます。
8. 実践: 非互換と進化のデモ
serialVersionUID 不一致のエラーのデモ
// まず、あるクラスバージョンでオブジェクトをシリアライズする
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
}
// 次に serialVersionUID を変更(例: 2L)、コンパイルして古いファイルを読み込もうとする
public class User implements Serializable {
private static final long serialVersionUID = 2L;
private String name;
}
結果:
java.io.InvalidClassException: User; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2
クラス進化の成功例
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
// 新しいフィールド
private String email;
}
古いオブジェクト(email なし)をシリアライズしてからフィールドを追加し、serialVersionUID を変更しなければ、デシリアライズは成功し、email は null になります。
9. シリアライズ互換性でよくあるミス
ミス1: serialVersionUID を宣言していない。 serialVersionUID を明示的に宣言しない場合、JVM は自動生成します。クラスのごく小さな変更(例: メソッド追加やフィールド修飾子の変更)でも serialVersionUID が変わり、結果として古いデータをデシリアライズできなくなります。これは backward compatibility を「壊す」典型例です。
ミス2: フィールド型の変更。 フィールドの型を変更すると(例: int から String)、例外や不正なデータにつながります。この種の変更は特別な注意が必要で、最善策は writeObject/readObject による手動移行です。
ミス3: クラス/パッケージの削除や改名。 クラス名の変更やパッケージの移動は、古いオブジェクトをデシリアライズできなくします。クラス名とパッケージはシリアライズ済みストリームに保存され、JVM はそれらを一致させられません。
ミス4: transient の乱用。 重要なフィールドを transient にすると(例: ユーザーの id)、シリアライズされず、オブジェクト復元時に値が失われます。
ミス5: コレクションの不整合な変更。 新しいコレクションフィールドの追加やコレクション型の変更(例: List から Set)により、古いデータのデシリアライズが不正確になったりエラーになったりします。
ミス6: XML/JSON の制約が厳しすぎる。 XML/JSON スキーマでフィールドを必須(required = true)にすると、古いデータにそのフィールドが存在しない場合に読み込みエラーになります。アノテーションやスキーマの設定には注意しましょう!
GO TO FULL VERSION