Javaにおけるシリアライゼーション – Javaシリアライゼーション

Javaのシリアライゼーションは、JDK 1.1で導入され、Core Javaの重要な機能の一つです。

Javaでのシリアライゼーション

Javaでのシリアライゼーションは、オブジェクトをストリームに変換し、ネットワーク上で送信したり、ファイルとして保存したり、後で使用するためにDBに保存することができます。デシリアライゼーションは、オブジェクトのストリームを実際のJavaオブジェクトに変換するプロセスであり、プログラム内で使用するために行われます。Javaでのシリアライゼーションは最初は非常に簡単に使用できますが、後半の記事で見ていくようないくつかの取るに足らないセキュリティと完全性の問題もあります。このチュートリアルでは以下のトピックを扱います。

    1. Javaでのシリアライズ

 

    1. シリアライズとserialVersionUIDを使用したクラスのリファクタリング

 

    1. JavaのExternalizableインターフェース

 

    1. Javaのシリアライズメソッド

 

    1. 継承を使ったシリアライズ

 

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

Javaでシリアライズ可能

クラスオブジェクトをシリアライズ可能にしたい場合は、単にjava.io.Serializableインターフェースを実装すればよいです。JavaでのSerializableは、マーカーインターフェースであり、実装すべきフィールドやメソッドはありません。クラスをシリアライズ可能にするためのオプトインプロセスのようなものです。JavaでのシリアライズはObjectInputStreamとObjectOutputStreamによって実装されているため、ファイルに保存したり、ネットワーク上に送信するためには、それらの上にラッパーが必要です。以下に、シンプルなJavaプログラム例を示します。

package com.scdev.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+"}";
	}
	
	//getter and setter methods
	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 Beanであり、いくつかのプロパティとゲッターセッターメソッドがあることに注意してください。オブジェクトのプロパティをストリームにシリアル化しない場合は、salary変数と同様にtransientキーワードを使用することができます。次に、オブジェクトをファイルに書き込み、同じファイルから逆シリアル化する必要があるとします。そのために、オブジェクトのシリアル化にObjectInputStreamとObjectOutputStreamを使用するユーティリティメソッドが必要です。

package com.scdev.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 scdev
 * 
 */
public class SerializationUtil {

	// deserialize to Object from given file
	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;
	}

	// serialize the given object and save it to file
	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 オブジェクトの基本クラスである Object と連携するメソッド引数に注目してください。このように書かれているのは汎用性を持たせるためです。では、Java シリアル化を確認するためのテストプログラムを作成しましょう。

package com.scdev.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);
		
		//serialize to file
		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クラスの一部を変更することを許可します。デシリアライズプロセスに影響しない、クラスの変更の一部として次のようなものがあります:

  • Adding new variables to the class
  • Changing the variables from transient to non-transient, for serialization it’s like having a new field.
  • Changing the variable from static to non-static, for serialization it’s like having a new field.

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

package com.scdev.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.scdev.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.scdev.serialization.SerializationUtil.deserialize(SerializationUtil.java:22)
	at com.scdev.serialization.DeserializationTest.main(DeserializationTest.java:13)
empNew Object::null

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

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

次のように言い換えます:

このプログラム自体から直接シリアルバージョンを生成する必要はないことに注意してください。私たちはこの値を任意に割り当てることができます。ただ、シリアル化プロセスが新しいクラスが同じクラスの新しいバージョンであることを知るために、その値は存在する必要があります。たとえば、EmployeeクラスからserialVersionUIDフィールドのコメントを外してSerializationTestプログラムを実行します。次に、Employeeクラスからpasswordフィールドのコメントを外してDeserializationTestプログラムを実行します。その結果、オブジェクトストリームは正常にデシリアライズされます。なぜなら、Employeeクラスの変更がシリアル化プロセスと互換性があるからです。

JavaのExternalizableインターフェース

もしJavaのシリアライゼーションプロセスに気づいた場合、それは自動的に行われます。時にはオブジェクトデータを秘匿してその完全性を保ちたい場合があります。そのためには、java.io.Externalizableインターフェースを実装し、シリアライゼーションプロセスで使用されるwriteExternal()メソッドとreadExternal()メソッドの実装を提供することができます。

package com.scdev.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();
		//read in the same order as written
		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.scdev.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 Auto-generated catch block
			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()メソッドはこのメソッドを使用してオブジェクトをストリームから読み取ります。

 

    1. writeObject(ObjectOutputStream oos)メソッド: このメソッドがクラスに存在する場合、ObjectOutputStreamのwriteObject()メソッドはこのメソッドを使用してオブジェクトをストリームに書き込みます。一般的な使用法の1つは、データの整合性を維持するためにオブジェクト変数を曖昧にすることです。

 

    1. Object writeReplace()メソッド: このメソッドが存在する場合、シリアル化プロセスの後にこのメソッドが呼び出され、返されたオブジェクトがストリームにシリアル化されます。

 

    Object readResolve()メソッド: このメソッドが存在する場合、逆シリアル化プロセスの後にこのメソッドが呼び出され、最終的なオブジェクトが呼び出し元のプログラムに返されます。このメソッドの使用法の1つは、シリアル化されたクラスでシングルトンパターンを実装することです。詳しくは、「Serialization and Singleton」をご覧ください。

通常、上記の方法を実装する際には、サブクラスがそれらをオーバーライドできないようにプライベートに保持されます。これらの方法は、シリアライズの目的のためにのみ使用され、プライベートに保持することでセキュリティ問題を回避します。

継承を伴ったシリアライゼーション

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

package com.scdev.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;
	}	
}

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

package com.scdev.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()+"}";
	}
	
	//adding helper method for serialization to save/initialize super class state
	private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException{
		ois.defaultReadObject();
		
		//notice the order of read and write should be same
		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 {
		//validate the object here
		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インターフェースを実装していることにも注意してください。validateObject()メソッドを実装することで、データの整合性を確保するためのビジネスバリデーションを追加できます。テストクラスを作成し、シリアル化されたデータからスーパークラスの状態を取得できるかどうかを確認しましょう。

package com.scdev.serialization.inheritance;

import java.io.IOException;

import com.scdev.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();
		}
	}
}

上記のクラスを実行すると、以下の出力が得られます。 (Jōki no kurasu o jikkō suru to, ika no shutsuryoku ga eraremasu.)

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

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

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

Javaにおけるシリアル化には、いくつかの深刻な危険性が存在します。

  • The class structure can’t be changed a lot without breaking the java serialization process. So even though we don’t need some variables later on, we need to keep them just for backward compatibility.
  • Serialization causes huge security risks, an attacker can change the stream sequence and cause harm to the system. For example, user role is serialized and an attacker change the stream value to make it admin and run malicious code.

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

package com.scdev.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+"}";
	}
	
	//serialization proxy class
	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){
			//obscuring data for security
			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");
		}
		
	}
	
	//replacing serialized object to DataProxy object
	private Object writeReplace(){
		return new DataProxy(this);
	}
	
	private void readObject(ObjectInputStream ois) throws InvalidObjectException{
		throw new InvalidObjectException("Proxy is not used, something fishy");
	}
}
  • Both Data and DataProxy class should implement Serializable interface.
  • DataProxy should be able to maintain the state of Data object.
  • DataProxy is inner private static class, so that other classes can’t access it.
  • DataProxy should have a single constructor that takes Data as argument.
  • Data class should provide writeReplace() method returning DataProxy instance. So when Data object is serialized, the returned stream is of DataProxy class. However DataProxy class is not visible outside, so it can’t be used directly.
  • DataProxy class should implement readResolve() method returning Data object. So when Data class is deserialized, internally DataProxy is deserialized and when it’s readResolve() method is called, we get Data object.
  • Finally implement readObject() method in Data class and throw InvalidObjectException to avoid hackers attack trying to fabricate Data object stream and parse it.

実装が正しく機能しているかを確かめるために、小さなテストを書きましょう。 (Jitsugyou ga tadashiku kinou shite iru ka o tashikameru tame ni, chiisana tesuto o kakimashou.)

package com.scdev.serialization.proxy;

import java.io.IOException;

import com.scdev.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のシリアル化プロジェクトをダウンロードしてください。

Javaでのシリアライゼーションに関しては以上です。それは見た目はシンプルですが、慎重に使用するべきであり、デフォルトの実装に依存しない方が常に良いです。上記のリンクからプロジェクトをダウンロードして、それを使って試してみることで、さらに学ぶことができます。

コメントを残す 0

Your email address will not be published. Required fields are marked *