Serialization trong Java (Tuần tự hoá trong java) được giới thiệu từ JDK 1.1 và là một trong những tính năng quan trọng của Core Java.
Serialization trong Java
Serialization trong Java cho phép chúng ta chuyển một Object thành một luồng (stream), có thể gửi qua mạng, lưu vào file hoặc lưu trong cơ sở dữ liệu để sử dụng sau này. Deserialization là quá trình chuyển đổi luồng Object trở lại thành Java Object thực tế để sử dụng trong chương trình. Serialization trong Java thoạt nhìn có vẻ đơn giản, nhưng đi kèm với đó là một số vấn đề về bảo mật và tính toàn vẹn dữ liệu, sẽ được đề cập ở phần sau của bài viết này. Chúng ta sẽ xem xét các chủ đề sau trong hướng dẫn này.
- Serializable trong Java
- Tái cấu trúc class với Serialization và serialVersionUID
- Java Externalizable Interface
- Các phương thức Serialization trong Java
- Serialization kết hợp với kế thừa
- Mẫu thiết kế Serialization Proxy
Serializable trong Java
Nếu bạn muốn một class có thể được tuần tự hóa (serializable), bạn chỉ cần triển khai interface java.io.Serializable
. Serializable
trong Java là một marker interface, không chứa bất kỳ trường (field) hay phương thức nào cần cài đặt. Nó giống như một quá trình opt-in (tự nguyện tham gia)) để chỉ định rằng class có thể serializable. Việc serialization trong Java được thực hiện thông qua các class ObjectInputStream
và ObjectOutputStream
, do đó chúng ta chỉ cần xây dựng một lớp bọc (wrapper) để lưu file hoặc gửi dữ liệu qua mạng. Hãy cùng xem một ví dụ đơn giản về Serialization trong 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+"}";
}
//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;
// }
}
Lưu ý rằng đây là một Java Bean đơn giản với một vài thuộc tính và phương thức getter-setter. Nếu bạn muốn một thuộc tính không bị tuần tự hóa, hãy sử dụng từ khóa transient như đã dùng với biến salary. Giả sử bây giờ chúng ta muốn ghi object xuống file và sau đó đọc lại từ file đó. Chúng ta cần những phương thức tiện ích để sử dụng ObjectInputStream
và ObjectOutputStream
cho việc serialization.
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 {
// 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();
}
}
Lưu ý rằng các tham số của phương thức sử dụng kiểu Object, là lớp cha (superclass) của mọi object. Việc viết như vậy giúp phương thức mang tính tổng quát. Bây giờ hãy viết một chương trình kiểm tra để xem quá trình Serialization trong Java hoạt động như thế nào.
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);
//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);
}
}
Khi chúng ta chạy chương trình kiểm thử ở trên, chúng ta sẽ nhận được kết quả sau.
emp Object::Employee{name=Pankaj,id=100,salary=5000}
empNew Object::Employee{name=Pankaj,id=100,salary=0}
Vì salary là một biến tạm thời, nên giá trị của nó không được lưu vào file và do đó không được khôi phục trong object mới. Tương tự, các biến static cũng không được serialize vì chúng thuộc về class chứ không phải object.
Tái cấu trúc class với Serialization và serialVersionUID
Serialization trong Java cho phép một số thay đổi nhất định trong class mà không làm hỏng quá trình deserialization. Một số thay đổi không ảnh hưởng đến deserialization bao gồm:
- Thêm biến mới vào class
- Thay đổi biến từ transient sang non-transient (trong quá trình Serialization, điều này giống như thêm một trường mới).
- Chuyển biến từ static sang không static, cũng được coi là một field mới.
Tuy nhiên, để những thay đổi này hoạt động mà không gây lỗi, class cần khai báo serialVersionUID. Hãy viết một class kiểm thử chỉ để deserialization file đã được serialize từ class kiểm thử trước đó.
ackage 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);
}
}
Bây giờ hãy bỏ chú thích biến “password “và các phương thức getter-setter tương ứng trong class Employee, sau đó chạy lại chương trình. Bạn sẽ nhận được ngoại lệ như sau;
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
Nguyên nhân là do serialVersionUID của class cũ và mới không trùng khớp. Thực tế nếu class không tự định nghĩa serialVersionUID, thì Java sẽ tự động tính toán và gán một giá trị duy nhất. Java dùng tên class, package, các biến, phương thức,… để tạo ra một số nguyên duy nhất kiểu long
. Nếu bạn dùng một IDE, bạn sẽ thấy cảnh báo kiểu: “The serializable class Employee does not declare a static final serialVersionUID field of type long”
(“Lớp Employee có thể tuần tự hóa nhưng không khai báo trường là static final
kiểu long
”). Chúng ta có thể dùng công cụ serialver của Java để tạo serialVersionUID, ví dụ chạy lệnh sau cho class Employee.
SerializationExample/bin$serialver -classpath . com.journaldev.serialization.Employee
Lưu ý rằng không nhất thiết phải sinh giá trị serialVersionUID từ chương trình – bạn có thể tự gán một giá trị bất kỳ. Miễn là nó có mặt, Java sẽ hiểu rằng class hiện tại là phiên bản mới của class cũ và sẽ cho phép deserialize nếu có thể. Ví dụ, chỉ bỏ comment field serialVersionUID trong class Employee
, sau đó chạy lại chương trình SerializationTest
. Tiếp theo, bỏ comment field password và chạy lại DeserializationTest
, bạn sẽ thấy luồng object được deserialize thành công vì sự thay đổi là tương thích.
Java Externalizable Interface
Thông thường quá trình serialization trong Java diễn ra tự động. Tuy nhiên đôi khi chúng ta muốn làm mờ dữ liệu của object để bảo vệ tính toàn vẹn. Chúng ta có thể làm điều đó bằng cách implement interface java.io.Externalizable
và cung cấp cài đặt cho 2 phương thức: writeExternal() và 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();
//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;
}
}
Lưu ý rằng tôi đã thay đổi giá trị của các field trước khi chuyển thành stream, và khi đọc lại thì đảo ngược các thay đổi. Bằng cách này, chúng ta có thể bảo toàn dữ liệu ở mức độ nhất định. Nếu dữ liệu sau khi đọc không hợp lệ, ta có thể ném exception. Hãy viết một chương trình kiểm thử để xem cơ chế này hoạt động như thế nào.
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 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();
}
}
}
Khi chạy chương trình trên, ta được kết quả như sau.
Person Object Read=Person{id=1,name=Pankaj,gender=Male}
Vậy nên dùng cách nào cho serialization trong Java? Thực tế, tốt nhất là sử dụng interface Serializable, và đến cuối bài viết, bạn sẽ hiểu vì sao.
Các phương thức Serialization trong Java
Chúng ta đã thấy serialization trong Java diễn ra tự động khi triển khai interface Serializable, với việc thực thi do ObjectInputStream và ObjectOutputStream đảm nhận. Tuy nhiên nếu muốn tùy biến cách lưu dữ liệu, ví dụ như mã hóa/giải mã dữ liệu nhạy cảm trước khi lưu/đọc. Đó là lý do tại sao chúng ta có thể định nghĩa bốn phương thức trong class để thay đổi cách hoạt động của quá trình serialization. Nếu các phương thức này tồn tại trong class, chúng sẽ được sử dụng cho mục đích serialization.
- readObject(ObjectInputStream ois): Nếu phương thức này tồn tại trong class, ObjectInputStream.readObject() sẽ dùng nó để đọc object từ stream.
- writeObject(ObjectOutputStream oos): Nếu phương thức này tồn tại trong class, ObjectOutputStream.writeObject() sẽ dùng nó để ghi object vào stream. Thường được dùng để làm mờ dữ liệu nhằm bảo toàn tính toàn vẹn.
- Object writeReplace(): Nếu có phương thức này, sau quá trình serialization sẽ gọi phương thức này để lấy object cần ghi vào stream.
- Object readResolve(): Nếu có phương thức này, sau quá trình deserialization sẽ gọi phương thức này để trả về object cuối cùng cho chương trình. Một ứng dụng của phương thức này là triển khai Singleton pattern với class được serialize.
Thông thường, khi triển khai các phương thức trên, chúng được khai báo là riêng tư để các lớp con không thể ghi đè (override). Các phương thức này chỉ phục vụ cho mục đích serialization, và việc giữ chúng ở mức truy cập riêng tư sẽ giúp tránh các vấn đề bảo mật.
Serialization kết hợp với kế thừa
Đôi khi chúng ta cần kế thừa một class không triển khai Serializable. Nếu dựa vào serialization tự động thì trạng thái của superclass sẽ không được ghi vào stream và không thể khôi phục. Trong trường hợp này, việc cài đặt readObject() và writeObject() sẽ rất hữu ích. Bằng cách này, ta có thể lưu lại trạng thái của superclass vào stream và đọc lại sau đó. Hãy cùng xem điều này hoạt động như thế nào.
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 là một Java Bean đơn giản nhưng không implement 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()+"}";
}
//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");
}
}
Lưu ý rằng thứ tự ghi và đọc dữ liệu bổ sung vào stream phải giống nhau. Bạn có thể thêm logic để bảo mật dữ liệu khi ghi/đọc. Class cũng triển khai ObjectInputValidation
, nhờ đó chúng ta có thể định nghĩa logic kiểm tra tính hợp lệ bằng phương thức validateObject() để bảo vệ tính toàn vẹn dữ liệu. Hãy viết một class kiểm thử để xem có thể khôi phục state của superclass từ dữ liệu đã serialize hay không.
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();
}
}
}
Khi chúng ta chạy class ở trên, sẽ nhận được kết quả như sau.
SubClass read = SubClass{id=10,value=Data,name=Pankaj}
Vậy là chúng ta có thể serialize state của superclass dù nó không implement Serializable. Cách này rất hữu ích nếu superclass thuộc thư viện bên thứ ba mà bạn không thể thay đổi.
Mẫu thiết kế Serialization Proxy
Serialization trong Java có những rủi ro nghiêm trọng, ví dụ:
- Không thể thay đổi cấu trúc class nhiều mà không phá vỡ quá trình deserialization. Dù sau này chúng ta không còn cần đến một số biến nữa, vẫn phải giữ chúng lại để đảm bảo khả năng tương thích ngược (backward compatibility).
- Serialization tiềm ẩn nhiều rủi ro bảo mật nghiêm trọng, kẻ tấn công có thể thay đổi thứ tự dữ liệu trong stream và gây hại cho hệ thống. Ví dụ, nếu vai trò người dùng (user role) được tuần tự hóa, kẻ tấn công có thể chỉnh sửa giá trị trong stream thành “admin” và thực thi mã độc.
Java Serialization Proxy Pattern là một cách giúp bảo mật hơn khi sử dụng Serialization. Mô hình này sử dụng một class nội bộ (inner private static class) như một proxy cho quá trình serialization. Class này được thiết kế để lưu trữ state của class chính. Mô hình này được triển khai đúng cách nhờ việc override phương thức readResolve() và writeReplace(). Trước tiên, chúng ta hãy cùng viết một class sử dụng Serialization Proxy Pattern, sau đó phân tích để hiểu rõ hơn.
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+"}";
}
//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");
}
}
- Cả class
Data
vàDataProxy
phải triển khai interface Serializable. DataProxy
phải lưu được trạng thái của object Data.DataProxy
là inner class private static để các class khác không thể truy cập.DataProxy
có một constructor nhận đối tượng Data làm tham số.- Class
Data
phải có phương thức writeReplace() trả về instance củaDataProxy
. Vì vậy, khi đối tượng Data được serialize, stream trả về thực chất là của class DataProxy. Tuy nhiên, class DataProxy là private và không thể truy cập từ bên ngoài, nên không thể sử dụng trực tiếp. DataProxy
có phương thức readResolve() trả về objectData
. Vì vậy, khi class Data được deserialize, bên trong thực tế là đối tượng DataProxy được deserialize. Sau đó, khi phương thức readResolve() của nó được gọi, ta sẽ nhận được đối tượng Data.- Cuối cùng, hãy triển khai phương thức readObject() trong class Data và ném ra
InvalidObjectException
để ngăn chặn các cuộc tấn công từ hacker cố tình giả mạo stream của đối tượng Data và phân tích nó.
Viết một chương trình kiểm thử nhỏ để kiểm tra việc triển khai có hoạt động hay không.
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();
}
}
}
Khi chạy chương trình trên, bạn sẽ thấy kết quả hiển thị trên console.
Data{data=Pankaj}
Nếu bạn mở file data.ser, bạn sẽ thấy object DataProxy đã được lưu vào stream trong file.
Tải xuống dự án Java Serialization
Trên đây là toàn bộ nội dung về Serialization trong Java. Nghe có vẻ đơn giản, nhưng bạn nên sử dụng một cách thận trọng và tốt hơn là không nên quá phụ thuộc vào cách triển khai mặc định. Hãy tải project từ đường dẫn ở trên và tự trải nghiệm để hiểu rõ hơn.