Effective Java 第三版—— 90.考虑序列化代理替代序列化实例

96
码匠安徒生
0.7 2019.04.04 11:45* 字数 1619

Tips
书中的源代码地址:https://github.com/jbloch/effective-java-3e-source-code
注意,书中的有些代码里方法是基于Java 9 API中的,所以JDK 最好下载 JDK 9以上的版本。

Effective Java, Third Edition

90. 考虑序列化代理替代序列化实例

正如在条目 85和 条目86中提到并贯穿本章的讨论,实现Serializable接口的决定,增加了出现bug和安全问题的可能性,因为它允许使用一种语言之外的机制来创建实例,而不是使用普通的构造方法。然而,有一种技术可以大大降低这些风险。这种技术称为序列化代理模式(serialization proxy pattern)。

序列化代理模式相当简单。首先,设计一个私有静态嵌套类,它简洁地表示外围类实例的逻辑状态。这个嵌套类称为外围类的序列化代理。它应该有一个构造方法,其参数类型是外围类。这个构造方法只是从它的参数拷贝数据:它不需要做任何一致性检查或防御性拷贝。按照设计,序列化代理的默认序列化形式是外围类的最好的序列化形式。外围类及其序列化代理都必须声明以实现Serializable。

例如,考虑在条目 50中编写的不可变Period类,并在条目 88中进行序列化。以下是该类的序列化代理。 Period非常简单,其序列化代理与该属性具有完全相同的属性:

// Serialization proxy for Period class
private static class SerializationProxy implements Serializable {
    private final Date start;

    private final Date end;

    SerializationProxy(Period p) {
        this.start = p.start;
        this.end = p.end;
    }

    private static final long serialVersionUID =
        234098243823485285L; // Any number will do (Item  87)
}

接下来,将以下writeReplace方法添加到外围类中。可以将此方法逐字复制到具有序列化代理的任何类中:

// writeReplace method for the serialization proxy pattern
private Object writeReplace() {
    return new SerializationProxy(this);
}

该方法在外围类上的存在,导致序列化系统发出SerializationProxy实例,而不是外围类的实例。换句话说,writeReplace方法在序列化之前将外围类的实例转换为它的序列化代理。

使用此writeReplace方法,序列化系统永远不会生成外围类的序列化实例,但攻击者可能会构造一个实例,试图违反类的不变性。 要确保此类攻击失败,只需把readObject方法添加到外围类中:

// readObject method for the serialization proxy pattern
private void readObject(ObjectInputStream stream)
        throws InvalidObjectException {
    throw new InvalidObjectException("Proxy required");
}

最后,在SerializationProxy类上提供一个readResolve方法,该方法返回外围类逻辑等效的实例。此方法的存在导致序列化系统在反序列化时把序列化代理转换回外围类的实例。

这个readResolve方法只使用其公共API创建了一个外围类的实例,这就是该模式的美妙之处。它在很大程度上消除了序列化的语言外特性,因为反序列化实例是使用与任何其他实例相同的构造方法、静态工厂和方法创建的。这使你不必单独确保反序列化的实例遵从类的不变量。如果类的静态工厂或构造方法确立了这些不变性,而它的实例方法维护它们,那么就确保了这些不变性也将通过序列化来维护。

以下是Period.SerializationProxyreadResolve方法:

// readResolve method for Period.SerializationProxy
private Object readResolve() {
    return new Period(start, end);    // Uses public constructor
}

与防御性拷贝方法(第357页)一样,序列化代理方法可以阻止伪造的字节流攻击(条目 88,第354页)和内部属性盗用攻击(条目 88, 第356页)。 与前两种方法不同,这一方法允许Period类的属性为final,这是Period类成为真正不可变所必需的(条目 17)。 与之前的两种方法不同,这个方法并没有涉及很多想法。 不你必弄清楚哪些属性可能会被狡猾的序列化攻击所破坏,也不必显示地进行有效性检查,作为反序列化的一部分。

还有另一种方法,序列化代理模式比readObject中的防御性拷贝更为强大。 序列化代理模式允许反序列化实例具有与最初序列化实例不同的类。 你可能认为这在实践中没有有用,但并非如此。

考虑EnumSet类的情况(条目 36)。 这个类没有公共构造方法,只有静态工厂。 从客户端的角度来看,它们返回EnumSet实例,但在当前的OpenJDK实现中,它们返回两个子类中的一个,具体取决于底层枚举类型的大小。 如果底层枚举类型包含64个或更少的元素,则静态工厂返回RegularEnumSet; 否则,他们返回一个JumboEnumSet

现在考虑,如果你序列化一个枚举集合,集合枚举类型有60个元素,然后将五个元素添加到这个枚举类型,再反序列化枚举集合。序列化时,这是一个RegularEnumSet实例,但一旦反序列化,最好是JumboEnumSet实例。事实上正是这样,因为EnumSet使用序列化代理模式。如果好奇,如下是EnumSet的序列化代理。其实很简单:

// EnumSet's serialization proxy
private static class SerializationProxy <E extends Enum<E>>
        implements Serializable {
    // The element type of this enum set.
    private final Class<E> elementType;

    // The elements contained in this enum set.
    private final Enum<?>[] elements;

    SerializationProxy(EnumSet<E> set) {
        elementType = set.elementType;
        elements = set.toArray(new Enum<?>[0]);
    }

    private Object readResolve() {
        EnumSet<E> result = EnumSet.noneOf(elementType);
        for (Enum<?> e : elements)
            result.add((E)e);
        return result;
    }

    private static final long serialVersionUID =
        362491234563181265L;
}

序列化代理模式有两个限制。它与用户可扩展的类不兼容(条目 19)。而且,它与一些对象图包含循环的类不兼容:如果试图从对象的序列化代理的readResolve方法中调用对象上的方法,得到一个ClassCastException异常,因为你还没有对象,只有该对象的序列化代理。

最后,序列化代理模式增强的功能和安全性并不是免费的。 在我的机器上,使用序列化代理序列化和反序列化Period实例,比使用防御性拷贝多出14%的昂贵开销。

总之,只要发现自己必须在不能由客户端扩展的类上编写readObject或writeObject方法时,请考虑序列化代理模式。 使用重要不变性来健壮序列化对象时,这种模式可能是最简单方法。

日记本
Gupao