1. 問題:シリアライズ対象クラスを変更すると何が起きるのか?
実際のプロジェクトでは、オブジェクトはよくシリアライズされ、後で復元できるようにファイル・データベース・キャッシュに保存されます。ところが、クラスにフィールドの追加・削除や型の変更を加えた一方で、本番環境にはすでに古いシリアライズ済みオブジェクトが存在している場合、どうなるでしょうか?
例えば、本番環境に User クラスの保存済みオブジェクトを含むファイルがあるとします。アプリの新バージョンをリリースし、その User に新しいフィールドを追加したり、既存フィールドの型を変更したとします。プログラムが古いデータをデシリアライズしようとすると、多くの場合 InvalidClassException のようなエラーになるか、JVM の想定とオブジェクト構造が一致しないためにデータが失われます。
だからこそ、クラスとシリアライズ済みデータのバージョン間の互換性を事前に設計することが重要です。本番環境では単に古いファイルを「消す」ことはできません。後方互換性を維持するか、データ移行を実装して、新しいクラス版が既存の保存済みオブジェクトと正しく動作できるようにする必要があります。
2. serialVersionUID を用いた解決策
serialVersionUID とは?
これは、シリアライズ可能なクラスの「バージョン」を表す特別なフィールドです。
private static final long serialVersionUID = 1L;
- このフィールドを指定しない場合、Java はクラス構造に基づいて自動的に値を計算します。
- デシリアライズ時に、クラス側とシリアライズ済みデータ側の serialVersionUID が比較されます。
- 一致しなければ InvalidClassException が投げられます。
自動生成と手動管理
自動: 明示的に指定しない場合、コンパイラがクラス構造(名前、フィールド、メソッドなど)に基づいて値を計算します。
手動管理: 互換性を制御するため、シリアライズ可能なクラスでは常に serialVersionUID を明示指定することを推奨します。
例:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
// ...
}
いつ変更し、いつ据え置くべきか?
- 据え置くべき場合: 互換性を壊さない変更(例: デフォルト初期化できる新規フィールドの追加)。
- 変更すべき場合: フィールドの削除、フィールド型の変更、クラス階層の変更、その他の非互換な変更。
ルール:
- 新しいクラス版で古いシリアライズ済みオブジェクトを読めるようにしたい場合は、serialVersionUID を変更しない。
- 非互換が致命的(「壊れた」データになるくらいならエラーの方がよい)なら、serialVersionUID を増やす。
3. データ移行の戦略
便利なアプローチの1つが、いわゆる「遅延」移行です。すべての古いデータを一度に変換するのではなく、オブジェクトが初めて読み込まれたときに段階的に変換します。
例えば新しいフィールドを追加した場合、古いオブジェクトをデシリアライズすると、そのフィールドは型に応じて 0、null、または false などのデフォルト値を取ります。フィールドを削除した場合、そのフィールドは単に無視されます。JVM は名前と型でフィールドを対応付けるため、多くの変更は「そのまま通ります」。
より難しいのはフィールド型の変更です。以前は int で、今は String といった場合、標準のデシリアライズでは対処できません。解決策は、変換を手動で処理する readObject メソッドを実装することです:
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
ObjectInputStream.GetField fields = in.readFields();
// 旧フィールド: int age
int age = fields.get("age", -1);
// 新フィールド: String ageStr
this.ageStr = String.valueOf(age);
}
このように、古いオブジェクトは初回の読み込み時に新しいクラス版へ適切に適応されます。
「インプレース変換」パターン (in-place conversion)
この手法は遅延移行と異なり、すべてのデータを一括で変換します。考え方はシンプルで、ファイルやデータベースにある各シリアライズ済みオブジェクトを走査し、古いバージョンのクラスで読み取り、新しいバージョンのオブジェクトを作成して、更新された形式で書き戻します。
この方法は、遅延移行に頼れない場合に便利です。例えばデータ量が多い、あるいはオブジェクトがめったに読まれず、すべてのオブジェクトを新バージョンのアプリで即座に利用可能にしておきたい場合です。実務では専用のスクリプトやユーティリティで行うことが多く、プロセスは次のようになります:
// 簡単な in-place 変換の例
List<File> files = getSerializedFiles(); // 古いオブジェクトを含むファイルの一覧
for (File file : files) {
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))) {
OldUser oldUser = (OldUser) ois.readObject(); // 古いオブジェクトを読み込む
NewUser newUser = new NewUser(oldUser); // 古いオブジェクトから新しいオブジェクトを生成する
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file))) {
oos.writeObject(newUser); // 新しいバージョンでファイルを書き換える
}
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
この方法なら、すべてのオブジェクトをすぐに新しいバージョンへ揃え、本番環境で安全に利用できる状態にできます。
4. 古いバージョンへの対処:高度なテクニック
ObjectInputStream.readClassDescriptor() と readFields()
- readClassDescriptor() — クラスのメタデータ読み取り処理をフックし、必要に応じて差し替えてシリアライズ処理を「欺く」ことができます.
- readFields() — クラス構造が変わっていても、名前でフィールドを読み出せます。
例:
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
ObjectInputStream.GetField fields = in.readFields();
String name = (String) fields.get("name", "unknown");
int age = fields.defaulted("age") ? 0 : fields.get("age", 0);
// ... 新しいフィールドの初期化
}
5. 実践:クラスの2つのバージョン、シリアライズと移行
ステップ 1. 古いバージョンのクラス
// OldUser.java
import java.io.Serializable;
public class OldUser implements Serializable {
private static final long serialVersionUID = 1L;
public String name;
public int age;
public OldUser(String name, int age) {
this.name = name;
this.age = age;
}
}
ステップ 2. 古いバージョンのオブジェクトをシリアライズする
OldUser user = new OldUser("ヴァーシャ", 30);
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.dat"))) {
out.writeObject(user);
}
ステップ 3. 新しいバージョンのクラス(email フィールドを追加、age の型を変更)
// User.java
import java.io.*;
public class User implements Serializable {
private static final long serialVersionUID = 1L;
public String name;
public String age; // 型を変更!
public String email; // 新しいフィールド
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
ObjectInputStream.GetField fields = in.readFields();
this.name = (String) fields.get("name", "unknown");
// 旧フィールド age(int)を文字列に変換する
if (!fields.defaulted("age")) {
int oldAge = fields.get("age", 0);
this.age = String.valueOf(oldAge);
} else {
this.age = "unknown";
}
// 新しいフィールド email — 既定値は null
this.email = (String) fields.get("email", null);
}
}
ステップ 4. 古いオブジェクトを新しいクラスでデシリアライズする
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.dat"))) {
User user = (User) in.readObject();
System.out.println(user.name + ", " + user.age + ", " + user.email);
}
結果:
- 古いフィールド age は文字列に変換されます。
- 新しいフィールド email は null。
- InvalidClassException は発生しません。serialVersionUID が一致しており、型不一致を手動で処理しているためです。
不一致を処理しなかったらどうなる?
フィールドの型だけを変更して readObject を実装しない場合、デシリアライズ時に次のエラーになります:
java.io.InvalidClassException: User; incompatible types for field age
6. シリアライズ済みデータ移行の典型的な落とし穴
エラー 1: serialVersionUID を指定していない — クラスを少しでも変更すると、ささいな変更でも InvalidClassException を招く可能性があります。
エラー 2: フィールド型の変更を readObject で処理していない — 型不一致エラーになります。
エラー 3: フィールドを削除したのに古いデータには残っている — Java はそのフィールドを単に無視しますが、重要なフィールドだった場合はデータ損失になります。
エラー 4: テストせずに全データを手作業で移行しようとした — 一部の情報を失ったり、不整合なオブジェクトを生成する恐れがあります。
エラー 5: オブジェクトのシリアライズ/デシリアライズ箇所をすべて更新していない — 一部のコードは新バージョン、別の一部は旧バージョンを使い、「お化け」のようなバグが発生します。
エラー 6: 大量データ向けの移行戦略を用意していない — 「遅延」移行では、古いデータへ初回アクセス時にユーザーが思わぬエラーに遭遇することがあります。
エラー 7: 移行前にバックアップを取っていない — アップデート前にシリアライズ済みデータのバックアップを必ず作成しましょう!
GO TO FULL VERSION