التسلسل وإلغاء التسلسل
التسلسل (Serialization) هو عملية تحويل كائن Java حيّ إلى سلسلة من البايتات بهدف حفظه في ملف، أو إرساله عبر الشبكة، أو تخزينه في قاعدة بيانات. أما إلغاء التسلسل (Deserialization) فهو العكس: إعادة بناء الكائن من تلك البايتات. توفّر Java آلية مدمجة لهذا الغرض، لكنها تأتي مع مخاوف جوهرية يجب على كل مطوّر محترف أن يدركها.
الواجهة Serializable
لتفعيل التسلسل المدمج في Java لكائن ما، نفّذ الواجهة java.io.Serializable. هذه واجهة علامة (marker interface) لا تحتوي على أي توابع — هي مجرد إشارة للـ JVM بأن كائنات هذا الصنف قابلة للتسلسل.
import java.io.Serializable;
public class UserProfile implements Serializable {
// دائمًا صرّح serialVersionUID — الشرح أدناه
private static final long serialVersionUID = 1L;
private String username;
private int age;
private String email;
public UserProfile(String username, int age, String email) {
this.username = username;
this.age = age;
this.email = email;
}
@Override
public String toString() {
return "UserProfile{username='" + username + "', age=" + age + ", email='" + email + "'}";
}
}
لماذا نصرّح بـ serialVersionUID؟ عند تسلسل كائن، يُضمّن Java بصمة إصدار في تدفّق البايتات. إذا عدّلت الصنف لاحقًا (أضفت حقلًا أو غيّرت توقيع تابع) دون تصريح بـ serialVersionUID، أعاد Java حساب البصمة وأصبحت ملفاتك القديمة غير قابلة للقراءة. صرّح به صراحةً لتتحكّم أنت متى يحدث كسر التوافق.
كتابة الكائن: ObjectOutputStream
ObjectOutputStream تُغلّف أي OutputStream وتضيف إمكانية كتابة الكائنات كاملة. اجمعها مع FileOutputStream للحفظ على القرص، واستخدم دائمًا BufferedOutputStream لتحسين الأداء.
import java.io.*;
import java.nio.file.*;
public class SerializeDemo {
public static void main(String[] args) {
UserProfile user = new UserProfile("alice", 30, "alice@example.com");
Path file = Path.of("user.ser");
// try-with-resources يضمن إغلاق التدفّق حتى عند حدوث استثناء
try (var out = new ObjectOutputStream(
new BufferedOutputStream(Files.newOutputStream(file)))) {
out.writeObject(user);
System.out.println("تمّ التسلسل: " + user);
} catch (IOException e) {
System.err.println("فشل التسلسل: " + e.getMessage());
}
}
}
قراءة الكائن: ObjectInputStream
ObjectInputStream تعكس ObjectOutputStream. استدعِ readObject() وحوّل الناتج إلى النوع المتوقّع. يجب أن يكون تعريف الصنف موجودًا في classpath وقت إلغاء التسلسل.
import java.io.*;
import java.nio.file.*;
public class DeserializeDemo {
public static void main(String[] args) {
Path file = Path.of("user.ser");
try (var in = new ObjectInputStream(
new BufferedInputStream(Files.newInputStream(file)))) {
// readObject() تُعيد Object — أجرِ التحويل بحرص
UserProfile restored = (UserProfile) in.readObject();
System.out.println("تمّ إلغاء التسلسل: " + restored);
} catch (IOException | ClassNotFoundException e) {
System.err.println("فشل إلغاء التسلسل: " + e.getMessage());
}
}
}
اصطد كلا الاستثناءين: IOException وClassNotFoundException. تُطلق readObject() كليهما. يعني ClassNotFoundException أن البايتات أشارت إلى صنف غير موجود في بيئة التشغيل الحالية — شائع عند إلغاء تسلسل بيانات من إصدار مختلف للتطبيق.
الكلمة المفتاحية transient
ليس كل حقل يجب حفظه. كلمات المرور، ومقابض الملفات المفتوحة، والذاكرة التخزينية المؤقتة، والحقول التي يمكن إعادة احتسابها، كلّها تُعلَّم بـ transient. يتجاهل الـ JVM الحقول العابرة (transient) أثناء التسلسل؛ وتُعيَّن إلى قيمتها الافتراضية (null للكائنات، 0 للأعداد) عند إلغاء التسلسل.
import java.io.Serializable;
public class Session implements Serializable {
private static final long serialVersionUID = 1L;
private String sessionId;
private String userId;
// لا نريد أبدًا حفظ كلمة المرور الخام
private transient String password;
// حقل محسوب — عابر لأنه يُعاد بناؤه من userId
private transient String displayName;
public Session(String sessionId, String userId, String password) {
this.sessionId = sessionId;
this.userId = userId;
this.password = password;
this.displayName = "User-" + userId;
}
public String getPassword() { return password; }
public String getDisplayName() { return displayName; }
@Override
public String toString() {
return "Session{id=" + sessionId + ", userId=" + userId
+ ", password=" + password + ", displayName=" + displayName + "}";
}
}
بعد إلغاء تسلسل Session، تُعيد getPassword() القيمة null وكذلك getDisplayName() — كلاهما كان عابرًا. إذا احتجت لاستعادة الحالة المشتقّة بعد إلغاء التسلسل، نفّذ التابع الخاص private void readResolve() أو استخدم readObject() مخصّصًا.
تسلسل المجموعات والرسوم البيانية للكائنات
يُسلسل Java الرسم البياني الكامل للكائنات الموصولة بالكائن الجذر. إذا كان UserProfile يحتوي على List<Order>، يُسلسل كل Order في القائمة أيضًا — لكن كل صنف في هذا الرسم البياني يجب أن ينفّذ Serializable، وإلا ستحصل على NotSerializableException وقت التشغيل.
import java.io.Serializable;
import java.util.List;
import java.util.ArrayList;
public class Order implements Serializable {
private static final long serialVersionUID = 1L;
private String orderId;
private double total;
public Order(String orderId, double total) {
this.orderId = orderId;
this.total = total;
}
}
public class Customer implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private List<Order> orders = new ArrayList<>(); // ArrayList قابلة للتسلسل
public Customer(String name) { this.name = name; }
public void addOrder(Order o) { orders.add(o); }
}
المراجع الدائرية مُعالَجة، لكن انتبه. إذا كان الكائن A يحمل مرجعًا للكائن B والعكس، يتعامل Java مع ذلك بتتبّع كل كائن كتبه وإعادة استخدام المرجع. لكن الرسوم البيانية العميقة المتداخلة قد تنتج تدفقات بايت كبيرة بشكل مفاجئ. لأغراض الرسوم البيانية المعقّدة، يُفضَّل استخدام صيغة صريحة كـ JSON.
التحفّظات: لماذا التسلسل المدمج مثير للجدل
تراكمت على آلية التسلسل المدمجة في Java قائمة طويلة من المشكلات المعروفة. فهمها لا يقلّ أهمية عن فهم الـ API نفسه:
- خطر أمني. كان إلغاء تسلسل بايتات غير موثوقة مصدرًا لثغرات تنفيذ التعليمات البرمجية عن بُعد (RCE). يمكن استغلال سلاسل غير متوقّعة داخل مكتبات الـ classpath عبر تدفّق بايت مُصمَّم بعناية. لا تُلغِ تسلسل بيانات من مصادر غير موثوقة باستخدام الآلية المدمجة.
- هشاشة الإصدار. إضافة الحقول أو حذفها أو إعادة تسميتها قد يُفسد البيانات القديمة بصمت أو يُطلق استثناءات — حتى مع تصريح
serialVersionUID.
- لا مخطط (Schema). الصيغة الثنائية معتمة ولا يمكن فحص البيانات المخزّنة في ملفات
.ser أو ترحيلها بسهولة.
- الأداء. الآلية الافتراضية التي تعتمد كثيرًا على الانعكاس (Reflection) أبطأ من صيغ كـ JSON أو Protocol Buffers أو Kryo.
- Java فقط. الصيغة غير قابلة للتشغيل البيني مع لغات أخرى.
البدائل الحديثة. للتخزين الدائم أو الاتصال عبر الشبكة، فضّل JSON (Jackson / Gson) أو Protocol Buffers أو Avro — جميعها تمنحك مخططًا قابلًا للقراءة ومتعدد اللغات وقابلًا للتطوّر. استخدم تسلسل Java المدمج فقط لحالات الاستخدام الداخلية قصيرة العمر (كالتخزين المؤقت للجلسات في الذاكرة مع السيطرة الكاملة على الطرفين) ولا تعرّضه أبدًا لمدخلات خارجية.
الخلاصة
نفّذ Serializable وصرّح دائمًا بـ serialVersionUID. استخدم ObjectOutputStream وObjectInputStream داخل try-with-resources لكتابة الكائنات وقراءتها. علّم الحقول الحساسة أو القابلة لإعادة الاحتساب بـ transient. كل صنف في الرسم البياني للكائنات يجب أن يكون قابلًا للتسلسل. والأهم من كل ذلك: تعامل مع التسلسل المدمج في Java باعتباره أداة قديمة — افهمها جيدًا، لكن الجأ إلى JSON أو Protocol Buffers في كود الإنتاج الجديد.