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

简介: 正如前文《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

相关文章
|
1月前
|
Java Spring 容器
【Java】Spring如何扫描自定义的注解?
【Java】Spring如何扫描自定义的注解?
35 0
|
1月前
|
存储 Java 数据库
|
1月前
|
消息中间件 存储 负载均衡
Kafka【付诸实践 01】生产者发送消息的过程描述及设计+创建生产者并发送消息(同步、异步)+自定义分区器+自定义序列化器+生产者其他属性说明(实例源码粘贴可用)【一篇学会使用Kafka生产者】
【2月更文挑战第21天】Kafka【付诸实践 01】生产者发送消息的过程描述及设计+创建生产者并发送消息(同步、异步)+自定义分区器+自定义序列化器+生产者其他属性说明(实例源码粘贴可用)【一篇学会使用Kafka生产者】
136 4
|
6天前
|
存储 Java
Java输入输出:解释一下序列化和反序列化。
Java中的序列化和反序列化是将对象转换为字节流和反之的过程。ObjectOutputStream用于序列化,ObjectInputStream则用于反序列化。示例展示了如何创建一个实现Serializable接口的Person类,并将其序列化到文件,然后从文件反序列化回Person对象。
15 5
|
7天前
|
Java
Java配置大揭秘:读取自定义配置文件的绝佳指南
Java配置大揭秘:读取自定义配置文件的绝佳指南
11 0
Java配置大揭秘:读取自定义配置文件的绝佳指南
|
11天前
|
NoSQL Java Redis
Java自定义线程池的使用
Java自定义线程池的使用
|
30天前
|
Java
java 自定义注解 实现限流
java 自定义注解 实现限流
10 1
|
1月前
|
消息中间件 分布式计算 Kafka
硬核!Apache Hudi中自定义序列化和数据写入逻辑
硬核!Apache Hudi中自定义序列化和数据写入逻辑
30 1
|
1月前
|
存储 缓存 JSON
什么是Java序列化,它有哪些重要性
什么是Java序列化,它有哪些重要性
|
1月前
|
Java 网络安全 开发者
【Docker】5、Dockerfile 自定义镜像(镜像结构、Dockerfile 语法、把 Java 项目弄成镜像)
【Docker】5、Dockerfile 自定义镜像(镜像结构、Dockerfile 语法、把 Java 项目弄成镜像)
41 0