Java序列化心得(二):自定义序列化

正如前文《Java序列化心得(一):序列化设计和默认序列化格式的问题》中所提到的,默认序列化方法存在各种各样的问题,出于效率或安全等方面的考虑,往往需要开发人员自定义序列化方法生成自定义序列化格式。当然,对应地也需要自定义反序列化方法,这里统称为“自定义序列化”(Custom Serialization)。

默认和自定义序列化方法的混合使用

大多数情况下,用户不需要完全重新实现序列化方法,只需要在原有默认方法上进行改进,本章将举例说明来默认和自定义序列化方法混合使用的情况。

首先我们看下面的例子:

public class Student implements Serializable {  
 
    private String firstName = null;      
    private String lastName = null;  
    private Integer age = null;  
    // unserializable field
    private transient School School= null;
    
    public Student () { }  

    public Student (String fname, String lname, Integer age, School school) {  
        this.firstName = fname;  
        this.lastName = lname;  
        this.age = age;  
        this.school = school;
    }
} 

public class School{  

    public String sName = null; 
    public String sId = null;

    public School(){
        this.sName = "";
        this.depId = "";
    }
    public School(String name, String id){
        this.sName = name;
        this.sId = id;
    }
} 

这里有两个类,Student类和School类,前者在类中引用了后者。虽然Student类已经声明“implements Serializable”,但是这个类不能顺利地被序列化,因为它引用的School类并不是可以序列化的。也许有的读者会说将School类声明为序列化不就好了嘛?前文《Java序列化心得(一):序列化设计和默认序列化格式的问题》中已经提到:“不要轻易的决定将一个类序列化”,所以在没有十分明确的需求下,不要轻易将School类改为“ implements Serializable”,否则School类如果以后被修改,将会影响到Student类序列化的格式,我们在设计类的序列化格式时最重要的原则就是保持其字节化格式的固定性,以降低维护它的代价。

那要如何序列化Student类为好呢?这里我们的序列化方案为:域school引用其他类,变化可能很大,所以采用自定义的方法序列化;其他域,如姓、名和年龄,反映的是Student类固有物理属性,且都为基本类型,形式固定,故而采用默认的序列化方法。具体序列化代码将在下面一个章节给出。

2. 自定义序列化的一般方法

细心的读者已经发现了,Student 类中school域前多了关键字transient, 其作用在于:

当某个字段被声明为transient后,默认序列化机制就会忽略该字.段

这样一来,默认的序列化机制不对school经行处理,我们才能开始实现自定义方法: writeObject( ) 与readObject( ) , 其中:

  • void writeObject(ObjectOutputStream out) throws IOException

用来实现序列化的机制:从流中读取字节数据,并转化为类对象;

  • void readObject(ObjectInputStream in)
    throws IOException,ClassNotFoundException

用来实反现序列化机制:将对象转化为字节数据,写入带流中。

具体实例代码如下:

private void writeObject(ObjectOutputStream out) throws IOException {  
        //invoke default serialization method
        out.defaultWriteObject(); 
 
        if(school == null)
            school = new School();
        out.writeObject(school.sName);  
        out.writeObject(school.sId);  
    }  
 
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {  
        //invoke default serialization method
        in.defaultReadObject();
  
        String name = (String) in.readObject();  
        String id = (String) in.readObject(); 
        school = new School(name, id);
    }  

我们以writeObject()为例进行说明:

  1. 首先writeObject()调用了默认的序列化方法defaultWriteObject()来处理非transient域firstNamelastNameage,将它们依次字节化写入流中;
  2. 接下来通过自定义的方法将school域写入流中:因为School类最主要的属性是学校的编号(Id)和名称(Name),我们也只关心这两个属性,而且这两个属性都是String类型,已经实现了“Serializable”接口,综上所述,我们就可以直接将它们写入流中,在反序列化过程中再根据这些属性来生成School类的对象。

readObject()中的内容也相似:

  1. 调用defaultReadObject()方法,将非transient域firstNamelastNameage从流中读出;
  2. 从流中读出和School类相关的内容(Id和Name),并根据这些内容生成新的School对象赋给school域;

这里需要说明下:即使所有的域都是transient的,也建议在writeObject() 与readObject() 中调用默认的序列化方法defaultWriteObject()和defaultReadObject())。这是从兼容性方面考虑的,如果以后类的结构发生了调整,增加非transient域,现有的序列化和反序列化机制也可以奏效的,因此强烈建议大家不要忘记调用默认的序列化方法,即使没有什么实际用处。

最后,为了保证序列化版本的一致性,要加上手动显示定义版本号serialVersionUID

完整的Student类代码如下:

public class Student implements Serializable {  

    private final static long serialVersionUID = 1L
 
    private String firstName = null;      
    private String lastName = null;  
    private Integer age = null;  
    // unserializable field
    private transient School School= null;
    
    public Student () { }  

    public Student (String fname, String lname, Integer age, School school) {  
        this.firstName = fname;  
        this.lastName = lname;  
        this.age = age;  
        this.school = school;
    }
    
    private void writeObject(ObjectOutputStream out) throws IOException {  
        //invoke default serialization method
        out.defaultWriteObject();  

        if(school == null)
            school = new School();
        out.writeObject(school.sName);  
        out.writeObject(school.sId);  
    }  
 
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {  
        //invoke default serialization method
        in.defaultReadObject();
  
        String name = (String) in.readObject();  
        String id = (String) in.readObject(); 
        school = new School(name, id);
    }  
} 

将readObject() 方法看成是构造器

《Effective Java》中曾经提到,readObject()的作用相当于参数为ObjectInputStream类型的构造器,因此要像构造器一样,对于参数的有效性进行检查。

举给例子,Student类中的age域代表这个学生的年龄,很显然应该是非负整数,但是如果有人恶意伪造的输入流,并把age对应的值设为-1,上面的readObject()方法不能提供数字有效性的检查,age=-1情况就会发生,这显然是错误的。

因此,我们在设计readObject()方法时要考虑其生成实例的有效性,确保实例中各个域值都符合构造对象时的约束。

带有数据约束检查的readObject()实例如下:

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {  
        //invoke default serialization method
        in.defaultReadObject();
        
        //check the value of "age" field
        if(this.age < 0)
              throw new InvalidObjectException("invalid data: age < 0");
  
        String name = (String) in.readObject();  
        String id = (String) in.readObject(); 
        school = new School(name, id);
    }  

如果发现age域的数值小于0,则说明此数据有问题,readObject()将会抛出异常“InvalidObjectException”。

除此之外,要解决反序列化异常问题,还可以手动实现:

private void readObjectNoData() throws ObjectStreamException;

readObject()遇到诸如序列化版本不一致或者是输入流被篡改/损坏时, 异常被抛出后会自动调用该方法,给对象提供一个合理的默认值(比如整数型为0,Boolen型为fasle,类引用为null等等);

readResolve() 与单例模式

Serializable接口还有两个接口方法可以实现序列化对象的替换,即writeReplace()和readResolve()。这里我们先主要来谈谈readReplace()方法。

Object readResolve() throws ObjectStreamException;

readResolve()方法会在readObject()调用之后自动调用,顾名思义,其最主要就是将反序列化之后的对象替换掉:其返回类型Object,因此该函数虽然没有任何参数,但是可以通过this访问到反序列化的对象,将其替换成任何对象类型再返回,而这个返回值将作为反序列机制输入流的最终输入结果。

readResolve()最重要的应用场景就是保护性恢复单例模式的对象,这种类型全局中都应该保证只有一个实例,因此readResolve()可以和单例工厂结合,根据实际情况把对象替换掉。

比如在某种场景下,所有学生都是一个学校的,这时候School类就应该是单例的,序列化传入的数据有可能和当前实例并不一致,这时如果School类要实现序列化就需要readResolve()帮助,代码如下:

public class School{  

    public String sName = null; 
    public String sId = null;

    public static final School instance= new School();
    
    // Singleton pattern requires constructors to be private.
    private School(){
        this.sName = "";
        this.depId = "";
    }

    // Singleton pattern requires constructors to be private.
    private School(String name, String id){
        this.sName = name;
        this.spId = id;
    }
    
    private Object readResolve(){
        return instance;
    }

无论默认序列化机制传入什么样的数据,都会被替换为当前School类中保存的实例;

序列化代理和writeReplace()

正如前文《Java序列化心得(一):序列化设计和默认序列化格式的问题》中所提到的, 当一个类被实现序列化之后,就有可能增加Bug和安全隐患。要想解决这一问题,序列化代理模式就是可以利用手段。

一般而言,序列化代理类是作为内部嵌入类存在于主类中,主类和它内部的序列化代理类都要求声明“implement Serializable”。在结构上,序列化代理类的默认序列化格式就应该是主类序列化格式的完美体现。例如Student类的序列化代理可以设计为

private class SerializationProxy4Student implements Serializable {  

    private final static long serialVersionUID = 1L // Any number will do
 
    private String firstName = null;      
    private String lastName = null;  
    private Integer age = null;  
    private String schoolName = null;
    private String schoolId = null;
    
    SerializationProxy4Student (Student s) {
        this.firstname = s.firstname;
        this.lastname = s.lastname;
        this. age = s.age;
        this.schoolName = s.school.sName;
        this.schoolId = s.school.sId;
     }  
} 

正如上面说提到的,SerializationProxy4Student的默认序列化格式就是上面我们自定义序列化中所体现的。我们只需要在每次序列化中将Student类对象转化为SerializationProxy4Student类对象写入流中即可,那如何替换呢?这就需要writeReplace() 函数的帮助。

Object writeReplace() throws ObjectStreamException;

writeReplace()方法被实现后,序列化机制会先调用writeReplace()方法将当前对象替换成另一个对象(该方法会返回替换后的对象)并将其写入流中,这便是序列化代理需要的功能。在实现writeReplace()时要注意一下几点:

  1. 实现writeReplace()方法之后,在不再需要writeObject()和readObject(),因为writeReplace()的返回值会被自动调用默认序列化机制写入输出流中,同时因为对象类型已经被替换,并且是不可逆的,所以readObject()的调用也不是需要,甚至还会给攻击者伪造输入流提供机会来,所以不建议使用。可以在writeObject()和readObject()中抛出异常,来保证这一点,例如:
private void readObject( ObjectInputStream stream) throw InvaildObjectException{
    throw new InvaildObjectException("Proxy Pattern Required");
}
  1. 因为writeReplace()返回值将自动序列化,所以其返回类型必须是可序列化的,这也是就是要求序列化代理类中的域都是可序列化的;

  2. readResolve()并不是用来恢复writeReplace()的,二者并不是成对出现的,也没有必然联系,切记。

下面我们回到序列化代理模式中,定义好序列化代理类,在主类中就可以调用writeReplace()方法替换序列化类型,例如:

private Object writeReplace ( ) {
    return new SerializationProxy4Student(this); // this:Student instance
}

使用序列化代理的最大好处就在于:将序列化的内容和类的结构分离开。无论主类(比如Student类)如何修改和被继承,其序列化的格式都是代理类(SerializationProxy4Student类)的序列化格式,这是固定的,所以接受该类序列化的其他代码也不用担心主类的变化,他们只是专注于处理代理类,代理类已经包含了所有被需要的信息。

如果主类有着十分巨大的版本变化,新旧版本的序列化的格式还都在被使用中,这种情况下可以构造多个序列化代理类,就可以根据情况支持多种序列化格式,而不必修改原有的接口,代码也有更好的兼容性和可扩展性。

Externalizable接口:强制自定义序列化

上文中关于序列化的自定义方法的介绍越来越复杂,自定义的程度也越来越深,那有没有完全定制的序列化方法吗?这就是** Externalizable**接口。

Externalizable接口继承于Serializable,当使用该接口时,强制要求序列化的细节都由开发人员去完成,即实现writeExternal()与readExternal()方法。

另外,使用Externalizable进行序列化时,当读取对象时,会调用被序列化类的无参构造器去创建一个新的对象,然后再将被保存对象的字段的值分别填充到新对象中。这边是前文《Java序列化心得(一):序列化设计和默认序列化格式的问题》中所提到的要保留无参数构造器的原因。

比较而言,Externalizable更为高效, 但Serializable更加灵活,其最重要的特色就是可以自动序列化,因此使用广泛。所以一般只有在对效率要求较高的情况下才会考虑Externalizable,但通常情况下Serializable使用的更多。
关于性能比较可以参考http://www.tuicool.com/articles/2Q3M73

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,198评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,663评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,985评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,673评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,994评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,399评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,717评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,407评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,112评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,371评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,891评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,255评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,881评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,010评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,764评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,412评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,299评论 2 260

推荐阅读更多精彩内容

  • JAVA序列化机制的深入研究 对象序列化的最主要的用处就是在传递,和保存对象(object)的时候,保证对象的完整...
    时待吾阅读 10,734评论 0 24
  • 将一个对象编码成字节流称作将该对象「序列化」。相反,从字节流编码中重新构建对象被称作「反序列化」。一旦对象被「序列...
    Alent阅读 755评论 0 1
  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,292评论 18 399
  • 一、 序列化和反序列化概念 Serialization(序列化)是一种将对象以一连串的字节描述的过程;反序列化de...
    步积阅读 1,398评论 0 10
  • 想说说最近的日子 那天做完心理疏导 我找了地方坐了一会 心里回荡着老师的那句话 你看起来好多了 或许糟糕透了 就很...
    82fa6c830610阅读 246评论 0 0