概要
シリアライズ対象のクラスにパスワードやトークンなどの機密情報が含まれている場合、そのまま保存・転送してしまうとセキュリティ上の問題になります。transient 修飾子を付けたフィールドはシリアライズの対象外になるため、機密情報の除外に利用されます。一方、serialVersionUID はクラスのバージョン管理に使われる識別子で、省略すると予期しないタイミングで互換性が崩れます。この記事では、ユーザーアカウント情報のシリアライズを題材に、transient で除外すべきフィールドの判断基準、serialVersionUID を明示する理由、そして sealed interface と組み合わせた型安全なシリアライズ設計を Java 17 のコードで整理します。デシリアライズ後に transient フィールドがどうなるか、static フィールドはどう扱われるかなど、実際にテストで引っかかりやすいポイントも取り上げます。
使いどころ
ユーザーアカウント情報をセッションにシリアライズする際、パスワードや認証トークンを transient で除外する
クラスにフィールドを追加したときに serialVersionUID を管理し、過去にシリアライズ済みのデータとの互換性を維持する
管理者と一般ユーザーで異なるシリアライズ対象フィールドを sealed interface で設計し、型ごとに保護すべき情報を明確にする
コード例
import java.io.*;
public class TransientSerialVersionDemo {
// sealed interface でシリアライズ可能な型を限定
sealed interface Account permits UserAccount, AdminAccount {}
static final class UserAccount implements Account, Serializable {
private static final long serialVersionUID = 1L;
private final String userId;
private final String email;
// transient: パスワードはシリアライズしない
private transient String password;
UserAccount(String userId, String email, String password) {
this.userId = userId;
this.email = email;
this.password = password;
}
public String userId() { return userId; }
public String email() { return email; }
public String password() { return password; }
@Override
public String toString() {
return "UserAccount{userId='" + userId + "', email='" + email
+ "', password='" + password + "'}";
}
}
static final class AdminAccount implements Account, Serializable {
private static final long serialVersionUID = 1L;
private final String adminId;
// transient: 管理者トークンもシリアライズ対象外
private transient String secretToken;
AdminAccount(String adminId, String secretToken) {
this.adminId = adminId;
this.secretToken = secretToken;
}
public String adminId() { return adminId; }
public String secretToken() { return secretToken; }
@Override
public String toString() {
return "AdminAccount{adminId='" + adminId
+ "', secretToken='" + secretToken + "'}";
}
}
// instanceof パターンマッチングで型ごとの処理
static String describeAccount(Account acc) {
if (acc instanceof UserAccount u) {
return "ユーザー: " + u.userId();
} else if (acc instanceof AdminAccount a) {
return "管理者: " + a.adminId();
} else {
return "不明なアカウント";
}
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
var account = new UserAccount("U001", "[email protected]", "secret123");
System.out.println("シリアライズ前: " + account);
// シリアライズ
byte[] bytes;
try (var baos = new ByteArrayOutputStream();
var oos = new ObjectOutputStream(baos)) {
oos.writeObject(account);
bytes = baos.toByteArray();
}
System.out.println("シリアライズサイズ: " + bytes.length + " bytes");
// デシリアライズ
try (var bais = new ByteArrayInputStream(bytes);
var ois = new ObjectInputStream(bais)) {
var loaded = (UserAccount) ois.readObject();
// password は transient なので null
System.out.println("デシリアライズ後: " + loaded);
Account acc = loaded;
System.out.println("判定結果: " + describeAccount(acc));
}
}
}Version Coverage
sealed interface で型の集合を制限でき、instanceof パターンマッチング(Java 16+)で型判定とキャストを1行で書ける。
// Java 17: instanceof パターンマッチング
if (acc instanceof UserAccount u) {
System.out.println("ユーザー: " + u.userId());
} else if (acc instanceof AdminAccount a) {
System.out.println("管理者: " + a.adminId());
}Library Comparison
注意点
transient フィールドはデシリアライズ後にデフォルト値(参照型は null、int は 0、boolean は false)になる。復元後に必要な値の再設定を忘れるとNullPointerException の原因になる
static フィールドはインスタンスに属さないためシリアライズされない。transient を付けなくても除外されるが、意図を明示するために transient を付けるケースもある
serialVersionUID を途中から追加する場合、既存のシリアライズ済みデータと整合しない値を設定すると InvalidClassException になる。既存データがある場合は serialver コマンドで現在の値を確認してから設定すること
sealed interface の permits で列挙された型だけがシリアライズ対象になるため、型追加時に permits リストの更新漏れがあるとコンパイルエラーになる。これは安全側に倒れるので利点でもある
FAQ
readObject メソッドをカスタマイズして再設定するか、デシリアライズ後の初期化メソッドを呼び出します。Externalizable なら readExternal でデフォルト値を明示的に設定できます。
互換性を壊すフィールド削除や型変更のときに変更します。フィールド追加だけなら同じ UID でもデシリアライズは成功しますが、追加フィールドはデフォルト値になります。
permits で許可された型だけが存在することがコンパイル時に保証されるため、デシリアライズ後の型判定で漏れが起きにくくなります。switch の網羅性チェックと組み合わせると安全です。