Effective Java (序列化)
七十五、考虑使用自定义的序列化形式:
设计一个类的序列化形式和设计该类的API同样重要,因此在没有认真考虑好默认的序列化形式是否合适之前,不要贸然使用默认的序列化行为。在作出决定之前,你需要从灵活性、性能和正确性多个角度对这种编码形式进行考察。一般来讲,只有当你自行设计的自定义序列化形式与默认的形式基本相同时,才能接受默认的序列化形式。比如,当一个对象的物理表示法等同于它的逻辑内容,可能就适合于使用默认的序列化形式。见如下代码示例:
1 public class Name implements Serializable {
2 private final String lastName;
3 private final String firstName;
4 private final String middleName;
5 ... ...
6 }
从逻辑角度而言,该类的三个域字段精确的反应出它的逻辑内容。然而有的时候,即便默认的序列化形式是合适的,通常还必须提供一个readObject方法以保证约束关系和安全性,如上例代码中,firstName和lastName不能为null等。
下面我们再看一个极端的例子:
1 public final class StringList implements Serializable {
2 private int size = 0;
3 private Entry head = null;
4 private static class Entry implements Serializable {
5 String data;
6 Entry next;
7 Entry previous;
8 }
9 }
对于上面的示例代码,如果采用默认形式的序列化,将会导致双向链表中的每一个节点的数据以及前后关系都会被序列化。因此这种物理表示法与它的逻辑数据内容有实质性的区别时,使用默认序列化形式会有以下几个缺点:
1. 它使这个类的导出API永远的束缚在该类的内部表示法上,即使今后找到更好的的实现方式,也无法摆脱原有的实现方式。
2. 它会消耗过多的空间。事实上对于上面的示例代码,我们只需要序列化数据部分,可以完全忽略链表节点之间的关系。
3. 它会消耗过多的时间。
4. 它会引起栈溢出。
根据以上四点,我们修订了StringList类的序列化实现方式,见如下代码:
1 public final class StringList implements Serializable {
2 private transient int size = 0;
3 private transient Entry head = null;
4 private static class Entry {
5 String data;
6 Entry next;
7 Entry previous;
8 }
9 private void writeObject(ObjectOutputStream s) throws IOException {
10 s.defaultWriteObject();
11 s.writeInt(size);
12 for (Entry e = head; e != null; e = e.next)
13 s.writeObject(e.data);
14 }
15 private void readObject(ObjectInputStream s)
16 throws IOException, ClassNotFoundException {
17 s.defaultReadObject();
18 int numElemnet = s.readInt();
19 for (int i = 0; i < numElements; i++)
20 add((String)s.readObject());
21 }
22 public final void add(String s) { ... }
23 ... ...
24 }
在修订代码中,所有的域字段都是transient,但writeObject和readObject方法的首要任务仍然是先调用defaultWriteObject和defaultReadObject方法,即便这对于缺省序列化形式并不是必须的。因为在今后的修改中,很有可能会为该类添加非transient域字段,一旦忘记同步修改writeObject或readObject方法,将会导致序列化和反序列化的数据处理方式不一致。
对于默认序列化还需要进一步说明的是,当一个或多个域字段被标记为transient时,如果要进行反序列化,这些域字段都将被初始化为其类型默认值,如对象引用域被置为null,数值基本域的默认值为0,boolean域的默认值为false。如果这些值不能被任何transient域所接受,你就必须提供一个readObject方法。它首先调用defaultReadObject,然后再把这些transient域恢复为可接受的值。
最后需要说明的是,无论你是否使用默认的序列化形式,如果在读取整个对象状态的任何其他方法上强制任何同步,则也必须在对象序列化上强制这种同步,见如下代码:
1 private synchronized void writeObject(ObjectOutputStream s) throws IOException {
2 s.defaultWriteObject();
3 }
七十六、保护性的编写readObject方法:
在条目39中介绍了一个不可变的日期范围类,它包含可变的私有Date域。该类通过在其构造器和访问方法中保护性的拷贝Date对象,极力的维护其约束条件和不可变性。见如下代码:
1 public final class Period {
2 private final Date start;
3 private final Date end;
4 public Period(Date start, Date end) {
5 this.start = new Date(start.getTime());
6 this.end = new Date(end.getTime());
7 if (this.start.compareTo(this.end) > 0)
8 throw new IllegalArgumentException();
9 }
10 public Date start() {
11 return new Date(start.getTime());
12 }
13 public Date end() {
14 return new Date(end.getTime());
15 }
16 public String toString() {
17 return start + " - " + end;
18 }
19 ... ...
20 }
这个对象的物理表示法和其逻辑表示法完全匹配,所以我们可以使用默认的序列化形式。因此在声明该类的地方增加" implements Serializable "。然而,如果你真是这样做了,那么这个类将不再保证他的关键约束了。
问题在于,如果反序列化的数据源来自于该类实例的正常序列化,那么将不会引发任何问题。如果恰恰相反,反序列化的数据源来自于一组伪造的数据流,事实上,反序列化的机制就是从一组有规则的数据流中实例化指定对象,那么我们将不得不面对Period实例对象的内部约束被破坏的危险,见如下代码:
1 public class BogusPeriod {
2 private static final byte[] serializedForm = new byte[] {
3 ... ... //这里是一组伪造的字节流数据
4 };
5 public static void main(String[] args) [
6 Period p = (Period)deserialize(serializedForm);
7 System.out.println(p);
8 }
9 private static Object deserialize(byte[] sf) {
10 try {
11 InputStream is = new ByteArrayInputStream(sf);
12 ObjectInputStream ois = new ObjectInputStream(is);
13 return ois.readObject();
14 } catch (Exception e) {
15 throw new IllegalArgumentException(e);
16 }
17 }
18 }
如果执行上面的代码就会发现Period的约束被打破了,end的日期早于start。为了修正这个问题,可以为Period提供一个readObject方法,该方法首先调用defaultReadObject,然后检查被反序列化之后的对象的有效性。如果检查失败,则抛出InvalidObjectException异常,使反序列化过程不能成功地完成。
1 private void readObject(ObjectInputStream s)
2 throws IOException,ClassNotFoundException {
3 s.defaultReadObject();
4 if (start.compareTo(end) > 0)
5 throw new InvalidObjectException(start + " after " + end);
6 }
如果执行上面的代码就会发现Period的约束被打破了,end的日期早于start。为了修正这个问题,可以为Period提供一个readObject方法,该方法首先调用defaultReadObject,然后检查被反序列化之后的对象的有效性。如果检查失败,则抛出InvalidObjectException异常,使反序列化过程不能成功地完成。
1 private void readObject(ObjectInputStream s)
2 throws IOException,ClassNotFoundException {
3 s.defaultReadObject();
4 if (start.compareTo(end) > 0)
5 throw new InvalidObjectException(start + " after " + end);
6 }
除了上面的攻击方式之外,还存在着另外一种更为隐匿的攻击方式,它也是通过伪造序列化数据流的方式来骗取反序列化方法的信任。它在伪造数据时,将私有域字段的引用在外部保存起来,这样当对象实例反序列化成功后,由于外部仍然可以操作其内部数据,因此危险仍然存在。如何避免该风险呢?见如下修订后的readObject方法:
1 private void readObject(ObjectInputStream s)
2 throws IOException,ClassNotFoundException {
3 s.defaultReadObject();
4 //执行保护性copy
5 start = new Date(start.getTime());
6 end = new Date(end.getTime());
7 if (start.compareTo(end) > 0)
8 throw new InvalidObjectException(start + " after " + end);
9 }
注意,保护性copy一定要在有效性检查之前进行。
这里给出一个基本的规则,可以用来帮助确定默认的readObject方法是否可以被接受。规则是增加一个公有的构造器,其参数对应于该对象中每个非transient域,并且无论参数的值是什么,都是不进行检查就可以保存到相应的域中的。对于这样的做法如果仍然可以接受,那么默认的readObject就是合理,否则就需要提供一个显式的readObject方法。
对于非final的可序列化类,在readObject方法和构造器之间还有其他类似的地方,readObject方法不可以调用可被覆盖的方法,无论是直接调用还是间接调都不可以。如果违反了该规则,并且覆盖了该方法,被覆盖的方法将在子类的状态被反序列化之前先运行。程序很可能会失败。