CodeGym /コース /JAVA 25 SELF /シリアライズにおける互換性と後方互換性 (backward compatibility)

シリアライズにおける互換性と後方互換性 (backward compatibility)

JAVA 25 SELF
レベル 45 , レッスン 2
使用可能

1. 互換性の問題

想像してください。あなたはアプリの最初のバージョンをリリースし、ユーザーがデータ(たとえばユーザープロファイルや設定)を保存し始めました。1か月後、クラス UserProfileemail フィールドが足りないと気づいて追加しました。すべて順調……と思いきや、古いファイルを読み込もうとした瞬間に問題が起きます。うまくいけば新しいフィールドは空のままですが、最悪の場合は例外が発生し、ユーザーは不満を抱くでしょう。

シリアライズの互換性とは、プログラムが以前のバージョンのクラスでシリアライズされたデータを正しく読み込める能力であり、その逆も同様です。Java(特に Serializable によるバイナリシリアライズ)では、JVM がクラス構造の変更に非常に厳密であるため、このテーマはとりわけ重要です。

問題が発生しやすい典型的なシナリオ:

  • クラスに新しいフィールドを追加した。
  • 古いフィールドを削除した。
  • フィールドの型を変更した(例: intString に)。
  • クラス名を変更した、またはクラスを別のパッケージへ移動した。
  • オブジェクトをシリアライズするライブラリ/フレームワークを更新した。

これらの場合、古いシリアライズ済みデータは新しいプログラムのバージョンでは「読み込めない」状態になり得ます。

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. クラスを変更すると何が起きるか?

新しいフィールドの追加

古いシリアライズ済みオブジェクト → 追加フィールドを持つ新しいクラス

  • 新しいフィールドは既定値(null0false)になります。
  • それ以外は正しくデシリアライズされます。

例:

// 以前:
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; // 新しいフィールド
}

結果: 古いオブジェクトは読み込め、emailnull になります。

フィールドの削除

古いシリアライズ済みオブジェクトに含まれるフィールドが、新しいクラスには存在しない

  • そのフィールドはデシリアライズ時に単に無視されます。
  • 重要なのは serialVersionUID を変更しないことです。

フィールド型の変更

例として、以前は int age、それを String age に変更したとします。

  • これは非互換な変更です。デシリアライズを試みるとエラーが発生します(一般的には InvalidClassExceptionClassCastException)。
  • このような変更は避けるか、(下記の)カスタムシリアライズで互換性を担保しましょう。

クラスまたはパッケージの名前変更

ここは厳格です。クラス名やパッケージを変更すると、デシリアライズは通りません。シリアライズ済みストリームには完全修飾クラス名が保存されており、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 を変更しなければ、デシリアライズは成功し、emailnull になります。

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)にすると、古いデータにそのフィールドが存在しない場合に読み込みエラーになります。アノテーションやスキーマの設定には注意しましょう!

1
タスク
JAVA 25 SELF, レベル 45, レッスン 2
ロック未解除
蔵書カタログの進化: 新しいフィールドはどうなるか?
蔵書カタログの進化: 新しいフィールドはどうなるか?
1
タスク
JAVA 25 SELF, レベル 45, レッスン 2
ロック未解除
系譜ツリー: 年齢の自動移行
系譜ツリー: 年齢の自動移行
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION