הסידור ב-Java – סידור ב-Java

הרצפיה ב־Java נכנסה לשימוש ב־JDK 1.1 והיא אחת מהתכונות החשובות של Core Java.

הרצפיה ב־Java

הרצפיה ב־Java מאפשרת לנו להמיר אובייקט לזרם שאנו יכולים לשלוח ברשת או לשמור אותו כקובץ או לאחסן ב־DB לשימוש מאוחר יותר. הדיסריאליזציה היא התהליך של המרת זרם אובייקט לאובייקט ג'אווה אמיתי לשימוש בתוכנית שלנו. הרצפיה ב־Java נראית קלה מאוד לשימוש בהתחלה אך היא מגיעה עם כמה בעיות אבטחה ושלמות טריביאליות שנבחן אותן בחלק המאוחר יותר של מאמר זה. נבחן את הנושאים הבאים במדריך זה.

  1. Serializable ב־Java
  2. רפרקטורינג של קלאסים עם הרצפיה ו־serialVersionUID
  3. ממשק Externalizable של Java
  4. שיטות הרצפיה של Java
  5. הרצפיה עם ירושה
  6. תבנית הרצפיה של פרוקסי

Serializable ב-Java

אם ברצונך שאובייקט מחלקה יהיה ניתן לסריאליזציה, הכל שצריך לעשות הוא ליישם את ממשק java.io.Serializable. Serializable ב-Java הוא ממשק סימון ואין לו שדות או שיטות ליישום. זה דומה לתהליך Opt-In שבאמצעותו אנו מקנים למחלקות שלנו את היכולת לסריאליזציה. הסריאליזציה ב-Java מיושמת על ידי ObjectInputStream ו- ObjectOutputStream, כך שכל מה שנצטרך הוא כיסוי מעליהם כדי לשמור אותם לקובץ או לשלוח אותם ברשת. בואו נראה דוגמה פשוטה לתכנית סריאליזציה ב-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;
//	}
	
}

שימו לב שזו פשוטה פפולרית עם כמה מאפיינים ושיטות גטר-סטר. אם ברצונך שמאפיין של אובייקט לא יוסריאלז לזרם, תוכל להשתמש במילת המפתח transient כמו שעשיתי עם משתנה השכר. עכשיו נניח שברצוננו לכתוב את האובייקטים שלנו לקובץ ולאח"כ להשתמש בפעולת ההפעלה הקריאה באותו קובץ. לכן נזדקק לשיטות ייעוץ שישתמשו ב- ObjectInputStream ו- ObjectOutputStream לצורך סריאליזציה.

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);
		
	}
}

כעת בטלו את ההערה של המשתנה "סיסמה" ואת שיטות ה- getter-setter שלו ממחלקת העובד והפעילו את התוכנית. תקבלו את השגיאה למטה;

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 משתמשת במשתני מחלקה, שיטות, שם מחלקה, חבילה וכדומה כדי ליצור מספר ארוך ויחודי זה. אם אתה עובד עם כל סביבת פיתוח, תקבל אזהרה אוטומטית ש"המחלקה הניתנת לסדר Employee אינה מצהירה על שדה serialVersionUID סטטי סופי ומסוג long". ניתן להשתמש בכלי ה-Java "serialver" כדי ליצור את serialVersionUID של המחלקה, לדוג, עבור מחלקת העובד נוכל להפעיל אותו עם הפקודה הבאה.

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

שים לב שאין צורך שהגרסה הסדורה תופקד מתוך התוכנית עצמה, אפשר להקצות ערך לפי הצורך. המכפיל יכול להופיע רק כדי להודיע לתהליך ההתרסרות כי המחלקה החדשה היא הגרסה החדשה של אותה מחלקה וכדאי לסדר אותה באם ניתן.

ממשק Externalizable של 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}

אז איזו מתוך השיטות עדיפה לשימוש בסיריאליזציה בג'אווה. בעצם עדיף להשתמש בממשק Serializable ועד סוף המאמר, תדעו למה.

שיטות סיריאליזציה של ג'אווה

ראינו שהסידור ב-Java הוא אוטומטי וכל מה שנצטרך זה ליישם את הממשק Serializable. המימוש נמצא במחלקות ObjectInputStream ו-ObjectOutputStream. אך מה קורה אם נרצה לשנות את הדרך שבה אנו שומרים נתונים, לדוגמה אם יש לנו מידע רגיש באובייקט ולפני שמור/שימוש אנו רוצים להצפין/לפענח אותו. לכן יש ארבעה שיטות שאנו יכולים לספק במחלקה כדי לשנות את ההתנהגות של הסידור. אם השיטות הללו קיימות במחלקה, הן משמשות למטרות סידור.

  1. readObject(ObjectInputStream ois): אם השיטה הזו קיימת במחלקה, פעולת ObjectInputStream readObject() תשתמש בשיטה זו כדי לקרוא את האובייקט מהזרם.
  2. writeObject(ObjectOutputStream oos): אם השיטה הזו קיימת במחלקה, פעולת ObjectOutputStream writeObject() תשתמש בשיטה זו כדי לכתוב את האובייקט לזרם. אחת השימושים הנפוצים הוא להסתיר את משתני האובייקט כדי לשמור על שלמות הנתונים.
  3. Object writeReplace(): אם השיטה הזו קיימת, אז לאחר תהליך הסידור נקראת השיטה הזו והאובייקט שמוחזר מוסדר לזרם.
  4. Object readResolve(): אם השיטה הזו קיימת, אז לאחר תהליך ההשבתה, השיטה הזו נקראת כדי להחזיר את האובייקט הסופי לתוכנית הקוראת. אחד השימושים של השיטה הזו הוא ליישם את דפוס ה-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;
	}	
}

המחלקה האב היא שפיתור ג׳אווה פשוט, אך היא לא מממשת את ממשק 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();
		
		//שימו לב לסדר של הקריאה והכתיבה שצריכים להיות זהים
		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. באמצעות יישום של השיטה validateObject(), אנו יכולים להוסיף תקיפות עסקיות כדי לוודא כי איכות הנתונים אינה נפגעת. בואו נכתוב מחלקת בדיקה ונראה אם אנו יכולים לשחזר את מצב המחלקה האב מנתונים סדורים או לא.

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();
		}
	}
}

כשאנו מפעילים את המחלקה למעלה, אנו מקבלים את הפלט הבא.

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

לכן, בדרך זו, אנו יכולים לסדר את מצב המחלקה האב גם אם היא אינה מיישמת את ממשק Serializable. אסטרטגיה זו מועילה כאשר מדובר במחלקה האב שהיא מחלקה של צד שלישי שאין לנו אפשרות לשנות.

תבנית מניע סדרתי

הסדרתיות ב-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.
  • סוף סוף הממש readObject() במחלקת Data והשלך InvalidObjectException כדי למנוע התקפות האקרים שמנסים ליצור אובייקט זרם של Data ולנסות לפענח אותו.

בואו נכתוב מבחן קטן כדי לבדוק אם המימוש עובד או לא.

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

זהו כל מה שיש לנו על הסדרה ב-Java, זה נראה פשוט אך עדיף תמיד שלא להסתמך על המימוש הברירתי. הורד את הפרויקט מהקישור למעלה ושחק איתו כדי ללמוד עוד.

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