Javaのシリアル化- Javaのシリアル化

Javaにおけるシリアル化はJDK 1.1で導入され、それはコアJavaの重要な機能の1つです。

Javaにおけるシリアル化

Javaにおけるシリアル化は、オブジェクトをストリームに変換してネットワーク経由で送信したり、ファイルとして保存したり、後で使用するためにDBに保存したりすることができます。逆シリアル化は、オブジェクトストリームを実際のJavaオブジェクトに変換するプロセスであり、プログラムで使用するために使用されます。Javaにおけるシリアル化は最初は非常に簡単に使用できるように見えますが、後半の部分で見ていくいくつかの些細なセキュリティおよび整合性の問題があります。このチュートリアルでは、以下のトピックを見ていきます。

  1. Javaでのシリアライズ可能
  2. シリアル化およびserialVersionUIDを使用したクラスのリファクタリング
  3. Java Externalizableインターフェース
  4. Javaシリアル化メソッド
  5. 継承を伴うシリアル化
  6. シリアル化プロキシパターン

Javaでのシリアライズ可能

クラスオブジェクトをシリアライズ可能にしたい場合、java.io.Serializableインターフェースを実装するだけです。Javaでのシリアライズ可能はマーカーインターフェースであり、実装するフィールドやメソッドはありません。これはクラスをシリアライズ可能にするためのオプトインプロセスのようなものです。JavaでのシリアライズはObjectInputStreamObjectOutputStreamによって実装されているため、ファイルに保存するか、ネットワークを介して送信するかをラップするだけです。Javaプログラムのシンプルなシリアライズの例を見てみましょう。

package com.journaldev.serialization;

import java.io.Serializable;

public class Employee implements Serializable {

//	private static final long serialVersionUID = -6470090944414208496L;
	
	private String name;
	private int id;
	transient private int salary;
//	private String password;
	
	@Override
	public String toString(){
		return "Employee{name="+name+",id="+id+",salary="+salary+"}";
	}
	
	//ゲッターとセッターメソッド
	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public int getSalary() {
		return salary;
	}

	public void setSalary(int salary) {
		this.salary = salary;
	}

//	public String getPassword() {
//		return password;
//	}
//
//	public void setPassword(String password) {
//		this.password = password;
//	}
	
}

これはいくつかのプロパティとゲッターセッターメソッドを持つシンプルなJavaビーンです。オブジェクトのプロパティをストリームにシリアライズしたくない場合は、私が給与変数で行ったようにtransientキーワードを使用できます。では、オブジェクトをファイルに書き込んでから、同じファイルからデシリアライズしたいとします。したがって、シリアライズの目的でObjectInputStreamObjectOutputStreamを使用するユーティリティメソッドが必要になります。

package com.journaldev.serialization;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;

/**
 * A simple class with generic serialize and deserialize method implementations
 * 
 * @author pankaj
 * 
 */
public class SerializationUtil {

	//指定されたファイルからオブジェクトに逆シリアル化
	public static Object deserialize(String fileName) throws IOException,
			ClassNotFoundException {
		FileInputStream fis = new FileInputStream(fileName);
		ObjectInputStream ois = new ObjectInputStream(fis);
		Object obj = ois.readObject();
		ois.close();
		return obj;
	}

	//指定されたオブジェクトをシリアル化してファイルに保存
	public static void serialize(Object obj, String fileName)
			throws IOException {
		FileOutputStream fos = new FileOutputStream(fileName);
		ObjectOutputStream oos = new ObjectOutputStream(fos);
		oos.writeObject(obj);

		fos.close();
	}

}

メソッドの引数が、任意のJavaオブジェクトの基本クラスで動作することに注意してください。これは、汎用的な性質を持たせるためにこのように書かれています。さて、Javaシリアル化を実行するテストプログラムを作成しましょう。

package com.journaldev.serialization;

import java.io.IOException;

public class SerializationTest {
	
	public static void main(String[] args) {
		String fileName="employee.ser";
		Employee emp = new Employee();
		emp.setId(100);
		emp.setName("Pankaj");
		emp.setSalary(5000);
		
		//ファイルにシリアル化
		try {
			SerializationUtil.serialize(emp, fileName);
		} catch (IOException e) {
			e.printStackTrace();
			return;
		}
		
		Employee empNew = null;
		try {
			empNew = (Employee) SerializationUtil.deserialize(fileName);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
		
		System.out.println("emp Object::"+emp);
		System.out.println("empNew Object::"+empNew);
	}
}

上記のシリアル化のテストプログラムをJavaで実行すると、以下の出力が得られます。

emp Object::Employee{name=Pankaj,id=100,salary=5000}
empNew Object::Employee{name=Pankaj,id=100,salary=0}

給与は一時変数なので、その値はファイルに保存されず、したがって新しいオブジェクトで取得されませんでした。同様に、静的変数の値もシリアル化されません。それらはクラスに属していてオブジェクトに属していないためです。

シリアル化とserialVersionUIDを使用したクラスのリファクタリング

Javaでのシリアル化では、無視できる場合にクラスの一部を変更することが許可されます。逆シリアル化プロセスに影響を与えないクラスの変更のいくつかは次のとおりです:

  • クラスに新しい変数を追加する
  • 変数を一時変数から非一時変数に変更する。シリアル化では、これは新しいフィールドを持つことと同じです。
  • 変数を静的から非静的に変更する。シリアル化では、これは新しいフィールドを持つことと同じです。

しかし、これらの変更がすべて機能するためには、javaクラスにはクラスに対してserialVersionUIDが定義されている必要があります。既にシリアライズされたファイルの逆テストクラスからの逆シリアライズのためのテストクラスを書きましょう。

package com.journaldev.serialization;

import java.io.IOException;

public class DeserializationTest {

	public static void main(String[] args) {

		String fileName="employee.ser";
		Employee empNew = null;
		
		try {
			empNew = (Employee) SerializationUtil.deserialize(fileName);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
		
		System.out.println("empNew Object::"+empNew);
		
	}
}

次に、Employeeクラスから「password」変数とそのゲッター・セッターメソッドのコメントを外して実行します。以下の例外が発生します。

java.io.InvalidClassException: com.journaldev.serialization.Employee; local class incompatible: stream classdesc serialVersionUID = -6470090944414208496, local class serialVersionUID = -6234198221249432383
	at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:604)
	at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1601)
	at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1514)
	at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1750)
	at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1347)
	at java.io.ObjectInputStream.readObject(ObjectInputStream.java:369)
	at com.journaldev.serialization.SerializationUtil.deserialize(SerializationUtil.java:22)
	at com.journaldev.serialization.DeserializationTest.main(DeserializationTest.java:13)
empNew Object::null

その理由は、前のクラスと新しいクラスのserialVersionUIDが異なるためです。実際、クラスがserialVersionUIDを定義しない場合、それは自動的に計算されてクラスに割り当てられます。Javaはこの一意の長い数値を生成するために、クラス変数、メソッド、クラス名、パッケージなどを使用します。IDEで作業している場合、クラス「Employee」は「long」型の静的なfinal serialVersionUIDフィールドを宣言していないという警告が自動的に表示されます。Javaユーティリティ「serialver」を使用してクラスのserialVersionUIDを生成できます。Employeeクラスの場合、以下のコマンドで実行できます。

SerializationExample/bin$serialver -classpath . com.journaldev.serialization.Employee

なお、このプログラム自体からシリアルバージョンを生成する必要はなく、必要に応じてこの値を割り当てることができます。ただし、逆シリアライズプロセスに新しいクラスが同じクラスの新しいバージョンであることを知らせるためには、この値が存在する必要があります。たとえば、EmployeeクラスからserialVersionUIDフィールドのコメントアウトを解除し、SerializationTestプログラムを実行します。次に、Employeeクラスからpasswordフィールドのコメントを解除し、DeserializationTestプログラムを実行すると、オブジェクトストリームが正常に逆シリアライズされることが確認できます。これは、Employeeクラスの変更がシリアライズプロセスと互換性があるためです。

Java Externalizable Interface

Javaのシリアル化プロセスに気づいた場合、それは自動的に行われます。時々、オブジェクトデータを不透明にしてその整合性を保つことが望ましい場合があります。これは、java.io.Externalizableインターフェースを実装し、writeExternal()readExternal()メソッドの実装を提供することで行うことができます。これらはシリアル化プロセスで使用されます。

package com.journaldev.externalization;

import java.io.Externalizable;
import java.io.IOException;
import java.io.ObjectInput;
import java.io.ObjectOutput;

public class Person implements Externalizable{

	private int id;
	private String name;
	private String gender;
	
	@Override
	public void writeExternal(ObjectOutput out) throws IOException {
		out.writeInt(id);
		out.writeObject(name+"xyz");
		out.writeObject("abc"+gender);
	}

	@Override
	public void readExternal(ObjectInput in) throws IOException,
			ClassNotFoundException {
		id=in.readInt();
		//書かれた順序で読み込みます
		name=(String) in.readObject();
		if(!name.endsWith("xyz")) throw new IOException("corrupted data");
		name=name.substring(0, name.length()-3);
		gender=(String) in.readObject();
		if(!gender.startsWith("abc")) throw new IOException("corrupted data");
		gender=gender.substring(3);
	}

	@Override
	public String toString(){
		return "Person{id="+id+",name="+name+",gender="+gender+"}";
	}
	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getGender() {
		return gender;
	}

	public void setGender(String gender) {
		this.gender = gender;
	}

}

ストリームに変換する前にフィールド値を変更し、その後、変更を元に戻すことに注意してください。この方法で、ある種のデータ整合性を維持できます。ストリームデータを読み込んだ後、整合性チェックが失敗した場合は例外をスローできます。動作を確認するためにテストプログラムを作成しましょう。

package com.journaldev.externalization;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;

public class ExternalizationTest {

	public static void main(String[] args) {
		
		String fileName = "person.ser";
		Person person = new Person();
		person.setId(1);
		person.setName("Pankaj");
		person.setGender("Male");
		
		try {
			FileOutputStream fos = new FileOutputStream(fileName);
			ObjectOutputStream oos = new ObjectOutputStream(fos);
		    oos.writeObject(person);
		    oos.close();
		} catch (IOException e) {
			// TODO 自動生成された例外キャッチブロック
			e.printStackTrace();
		}
		
		FileInputStream fis;
		try {
			fis = new FileInputStream(fileName);
			ObjectInputStream ois = new ObjectInputStream(fis);
		    Person p = (Person)ois.readObject();
		    ois.close();
		    System.out.println("Person Object Read="+p);
		} catch (IOException | ClassNotFoundException e) {
			e.printStackTrace();
		}
	    
	}
}

上記のプログラムを実行すると、次の出力が得られます。

Person Object Read=Person{id=1,name=Pankaj,gender=Male}

では、Javaでシリアル化に使用するのがより良い方法はどちらでしょうか。実際には、Serializableインターフェースを使用する方が良いです。記事の最後まで読み進めると、その理由がわかります。

Javaシリアル化メソッド

私たちは、Javaでのシリアル化が自動的であり、実装する必要があるのはSerializableインターフェースだけであることを見てきました。実装はObjectInputStreamクラスとObjectOutputStreamクラスにあります。ただし、データの保存方法を変更したい場合、例えばオブジェクトに機密情報が含まれており、保存/取得する前にそれを暗号化/復号化したい場合はどうなりますか。そのため、シリアル化動作を変更するための4つのメソッドがクラスに提供できます。これらのメソッドがクラスに存在する場合、それらはシリアル化の目的で使用されます。`

  1. ` `readObject(ObjectInputStream ois)`: このメソッドがクラスに存在する場合、ObjectInputStreamの`readObject()`メソッドはストリームからオブジェクトを読み取るためにこのメソッドを使用します。`
  2. ` `writeObject(ObjectOutputStream oos)`: このメソッドがクラスに存在する場合、ObjectOutputStreamの`writeObject()`メソッドはストリームにオブジェクトを書き込むためにこのメソッドを使用します。一般的な使用法の1つは、オブジェクト変数を不明瞭にしてデータの整合性を維持することです。`
  3. ` `Object writeReplace()`: このメソッドが存在する場合、シリアル化プロセスの後にこのメソッドが呼び出され、返されたオブジェクトがストリームにシリアル化されます。`
  4. ` `Object readResolve()`: このメソッドが存在する場合、逆シリアル化プロセスの後、このメソッドが呼び出されて最終的なオブジェクトが呼び出し元プログラムに返されます。このメソッドの1つの使用法は、シリアル化されたクラスでシングルトンパターンを実装することです。詳細は、``Serialization and Singleton``を参照してください。

通常、上記のメソッドを実装する際は、サブクラスがそれらをオーバーライドできないようにプライベートに保持されます。これらは主に直列化の目的であり、プライベートに保つことでセキュリティの問題を回避できます。

継承を使用した直列化

時折、Serializable インターフェースを実装していないクラスを拡張する必要があります。自動直列化の動作に依存し、かつスーパークラスにいくつかの状態がある場合、これらはストリームに変換されず、後で取得できません。ここで、readObject() および writeObject() メソッドが本当に役立ちます。これらの実装を提供することで、スーパークラスの状態をストリームに保存し、後で取得することができます。これを実際に見てみましょう。

package com.journaldev.serialization.inheritance;

public class SuperClass {

	private int id;
	private String value;
	
	public int getId() {
		return id;
	}
	public void setId(int id) {
		this.id = id;
	}
	public String getValue() {
		return value;
	}
	public void setValue(String value) {
		this.value = value;
	}	
}

SuperClass はシンプルな Java ビーンですが、Serializable インターフェースを実装していません。

package com.journaldev.serialization.inheritance;

import java.io.IOException;
import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.ObjectInputValidation;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class SubClass extends SuperClass implements Serializable, ObjectInputValidation{

	private static final long serialVersionUID = -1322322139926390329L;

	private String name;

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}
	
	@Override
	public String toString(){
		return "SubClass{id="+getId()+",value="+getValue()+",name="+getName()+"}";
	}
	
	//直列化のためのヘルパーメソッドを追加して、スーパークラスの状態を保存/初期化します
	private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException{
		ois.defaultReadObject();
		
		//read および write の順序に注意してください
		setId(ois.readInt());
		setValue((String) ois.readObject());	
	}
	
	private void writeObject(ObjectOutputStream oos) throws IOException{
		oos.defaultWriteObject();
		
		oos.writeInt(getId());
		oos.writeObject(getValue());
	}

	@Override
	public void validateObject() throws InvalidObjectException {
		//オブジェクトをここで検証
		if(name == null || "".equals(name)) throw new InvalidObjectException("name can't be null or empty");
		if(getId() <=0) throw new InvalidObjectException("ID can't be negative or zero");
	}	
}

書き込みと読み込みの順序が同じであることに注意してください。データの安全性を確保するために、データの読み書きにロジックを組み込むことができます。また、クラスはObjectInputValidationインターフェースを実装していることにも注意してください。

package com.journaldev.serialization.inheritance;

import java.io.IOException;

import com.journaldev.serialization.SerializationUtil;

public class InheritanceSerializationTest {

	public static void main(String[] args) {
		String fileName = "subclass.ser";
		
		SubClass subClass = new SubClass();
		subClass.setId(10);
		subClass.setValue("Data");
		subClass.setName("Pankaj");
		
		try {
			SerializationUtil.serialize(subClass, fileName);
		} catch (IOException e) {
			e.printStackTrace();
			return;
		}
		
		try {
			SubClass subNew = (SubClass) SerializationUtil.deserialize(fileName);
			System.out.println("SubClass read = "+subNew);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
	}
}

validateObject()メソッドを実装することで、ビジネスの妥当性検証を行い、データの整合性が損なわれないようにすることができます。テストクラスを作成し、シリアライズされたデータからスーパークラスの状態を取得できるかどうか確認してみましょう。

SubClass read = SubClass{id=10,value=Data,name=Pankaj}

上記のクラスを実行すると、以下の出力が得られます。

このように、Serializableインターフェースを実装していない場合でも、スーパークラスの状態をシリアライズすることができます。この戦略は、変更できないサードパーティクラスがスーパークラスである場合に便利です。

シリアライズプロキシパターン

  • Javaにおけるシリアライズは、いくつかの深刻な問題があります。たとえば、Javaのシリアライズプロセスを壊さずにクラス構造を大幅に変更することはできません。そのため、後で必要なくなった変数を保持する必要があります。
  • シリアライズは、重大なセキュリティリスクを引き起こします。攻撃者はストリームのシーケンスを変更してシステムに損害を与えることができます。たとえば、ユーザーの役割がシリアライズされ、攻撃者がストリームの値を変更して管理者にすると、悪意のあるコードを実行できます。

Javaシリアライゼーションプロキシパターンは、シリアライゼーションにおいてより高いセキュリティを実現する方法です。このパターンでは、内部のプライベートな静的クラスがシリアライゼーションの目的でプロキシクラスとして使用されます。このクラスは、メインクラスの状態を維持するように設計されています。このパターンは、readResolve()およびwriteReplace()メソッドを適切に実装することで実現されます。まず、シリアライゼーションプロキシパターンを実装したクラスを作成し、それをより良く理解するために分析します。

package com.journaldev.serialization.proxy;

import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class Data implements Serializable{

	private static final long serialVersionUID = 2087368867376448459L;

	private String data;
	
	public Data(String d){
		this.data=d;
	}

	public String getData() {
		return data;
	}

	public void setData(String data) {
		this.data = data;
	}
	
	@Override
	public String toString(){
		return "Data{data="+data+"}";
	}
	
	//シリアライゼーションプロキシクラス
	private static class DataProxy implements Serializable{
	
		private static final long serialVersionUID = 8333905273185436744L;
		
		private String dataProxy;
		private static final String PREFIX = "ABC";
		private static final String SUFFIX = "DEFG";
		
		public DataProxy(Data d){
			//セキュリティのためにデータを隠す
			this.dataProxy = PREFIX + d.data + SUFFIX;
		}
		
		private Object readResolve() throws InvalidObjectException {
			if(dataProxy.startsWith(PREFIX) && dataProxy.endsWith(SUFFIX)){
			return new Data(dataProxy.substring(3, dataProxy.length() -4));
			}else throw new InvalidObjectException("data corrupted");
		}
		
	}
	
	//シリアライズされたオブジェクトをDataProxyオブジェクトに置き換える
	private Object writeReplace(){
		return new DataProxy(this);
	}
	
	private void readObject(ObjectInputStream ois) throws InvalidObjectException{
		throw new InvalidObjectException("Proxy is not used, something fishy");
	}
}
  • DataクラスとDataProxyクラスは両方ともSerializableインターフェースを実装する必要があります。
  • DataProxyはDataオブジェクトの状態を維持できるようにする必要があります。
  • DataProxyは内部のプライベートな静的クラスであるため、他のクラスからアクセスできません。
  • DataProxyはDataを引数とする単一のコンストラクタを持つ必要があります。
  • DataクラスはwriteReplace()メソッドを提供し、DataProxyインスタンスを返す必要があります。したがって、Dataオブジェクトがシリアライズされると、返されるストリームはDataProxyクラスのものです。ただし、DataProxyクラスは外部からは見えないため、直接使用できません。
  • DataProxyクラスはreadResolve()メソッドを実装し、Dataオブジェクトを返さなければなりません。したがって、Dataクラスがデシリアライズされると、内部的にDataProxyがデシリアライズされ、そのreadResolve()メソッドが呼び出されると、Dataオブジェクトが取得されます。
  • 最後に、DataクラスにreadObject()メソッドを実装し、ハッカーがDataオブジェクトストリームを偽造し解析しようとする攻撃を防ぐためにInvalidObjectExceptionをスローします。

実装が機能するかどうかを確認するために、小さなテストを書いてみましょう。

package com.journaldev.serialization.proxy;

import java.io.IOException;

import com.journaldev.serialization.SerializationUtil;

public class SerializationProxyTest {

	public static void main(String[] args) {
		String fileName = "data.ser";
		
		Data data = new Data("Pankaj");
		
		try {
			SerializationUtil.serialize(data, fileName);
		} catch (IOException e) {
			e.printStackTrace();
		}
		
		try {
			Data newData = (Data) SerializationUtil.deserialize(fileName);
			System.out.println(newData);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
	}

}

上記のクラスを実行すると、コンソールに以下の出力が表示されます。

Data{data=Pankaj}

data.serファイルを開くと、DataProxyオブジェクトがファイル内にストリームとして保存されていることがわかります。

Java Serialization Projectをダウンロード

これでJavaでのシリアル化についての説明は終わりです。シンプルに見えますが、慎重に使用する必要があり、デフォルトの実装に依存しない方が常に良いです。上記のリンクからプロジェクトをダウンロードして、さらに学習してみてください。

Source:
https://www.digitalocean.com/community/tutorials/serialization-in-java