読者です 読者をやめる 読者になる 読者になる

【Effective Java】項目78:シリアライズされたインスタンスの代わりに、シリアライズ・プロキシを検討する

Effective Java Java

Serializable を実装すると、バグやセキュリティ上の問題が発生する可能性が高くなります。 コンストラクタ以外でインスタンスが生成されるようになるからです。

これらの可能性を大幅に減らす技法が、シリアライズ・プロキシ・パターン(Serialization Proxy Pattern)です。

シリアライズ・プロキシ・パターン

シリアライズ・プロキシ・パターンの実装について、項目39項目76で利用した Period クラスを例にして説明します。

ネストしたクラスを実装

プロキシパターンを実装するにはまず、対象となるクラス内にシリアライズ可能な private static ネストクラスを追加します。

ネストしたクラスには、シリアライズ対象のクラスのシリアライズに必要な論理的な情報を保持させます。 対象のクラスのキャッシュプロパティなどはこれに含めないでください。

このネストしたクラスは、パラメータがシリアライズ対象クラスであるコンストラクタを持つべきです。 この際、防御的コピーを用いる必要はありません。

そして、単に Serializable を実装するだけでかまいません。すなわち、デフォルトのシリアライズ形式となります。

これらを実装したものは次のコードになります。

// ネストしたクラス。シリアライズ用の専用。
private static class SerializationProxy implements Serializable {
    // フィールドは final にできる
    private final Date start;
    private final Date end;

    SerializationProxy(Period period) {
        // 防御的コピーは必要はない
        this.start = period.start;
        this.end = period.end;
    }

    // UID を設定(項目75)
    private static final long serialVersionUID = 234098243823485285L;
}

writeReplace() の実装

次に対象のクラスに writeReplace() メソッドを追加します。 writeReplace() で返したインスタンスが、実際にストリームに書き込む際のインスタンスになります。

シリアライズ・プロキシ・パターンでは writeReplace() で、さきほど宣言したネストしたクラスのインスタンスを返します。 シリアライズ時には writeReplace() が呼び出され、ネストしたクラスのインスタンスシリアライズ化されます。 すなわち、シリアライズ対象クラスが自体がシリアライズされることはなくなります。

しかし、攻撃者によって改ざんされたストリームを防ぐ必要があります。 そのため、シリアライズ対象クラスに readObject メソッドを実装し、例外が発生するようにしておきます。

// シリアライズ対象クラスの readObject メソッド
private void readObject(ObjectInputStream stream) throw InvalidObjectException {
    throw new InvalidObjectException("Proxy required");
}

readResolve() の実装

最後に、readResolve() メソッド(項目77)を実装し、コンストラクタを使ってシリアライズ対象クラスのインスタンスを返します。 これでデシリアライズの時には、シリアライズ対象クラスが正しく返却されるようになります。

シリアライズプロキシパターンの利点はまさにここにあります。 この readResolve() メソッドでは、言語外のインスタンス生成機能を利用していません。 通常のコンストラクタ(または static ファクトリーメソッド)を利用して、インスタンスを生成しています。 これによって、通常のコンストラクタに実装されている不変式チェックを利用することができます。

実際の readResolve メソッドのコードは次のようになります。

private Object readResolve() {
    return new Period(start, end);
}

シリアライズ・プロキシ・パターンは防御的方法(項目76)と同様に偽りのバイトストリーム攻撃を防ぐことができます。 項目77で紹介したような内部フィールドの Steal 攻撃も防ぎます。

また、この方法を用いると他の手法と異なり、Period クラスを不変にすることができます。 これはとても重要な違いです。

EnumSet の実装例

このパターンの利点はもう一つあります。 これは、デシリアライズされた際のインスタンスとは異なるクラスのインスタンスを生成することができる点です。

EnumSet (項目32)では、Enum の要素の数によって異なるクラスのインスタンスが生成されます。 要素の数が 64 以下の場合には RegularEnumSet のインスタンスが生成され、64 を超過した場合には JumboEnumSet のインスタンスが生成されます。

もし、シリアライズ・プロクシ・パターンを使わないとすると、RegularEnumSet としてシリアライズされたストリームは、たとえ要素が増えても RegularEnumSet としてしかデシリアライズできません。 しかし、プロクシ・パーターンを使えば readResolve メソッド内で通常の static ファクトリーメソッドを利用できるため、要素に応じて異なるインスタンスを生成することができます。

実際、Java の EnumSet は次のように実装されています。

public abstract class EnumSet<E extends Enum<E>> extends AbstractSet<E>
    implements Cloneable, java.io.Serializable

...(省略)...

    // シリアライズプロキシ
    private static class SerializationProxy <E extends Enum<E>>
        implements java.io.Serializable
    {
        private final Class<E> elementType;

        private final Enum<?>[] elements;

        SerializationProxy(EnumSet<E> set) {
            elementType = set.elementType;
            elements = set.toArray(ZERO_LENGTH_ENUM_ARRAY);
        }

        @SuppressWarnings("unchecked")
        private Object readResolve() {
            // elementType の Enum の数に応じて noneOf は異なるインスタンスを返す
            EnumSet<E> result = EnumSet.noneOf(elementType);
            for (Enum<?> e : elements)
                result.add((E)e);
            return result;
        }

        private static final long serialVersionUID = 362491234563181265L;
    }

    Object writeReplace() {
        // 書き込む際には SerializationProxy を書き込む
        return new SerializationProxy<>(this);
    }

    // Enum 自体はデシリアライズさせない。
    private void readObject(java.io.ObjectInputStream stream)
        throws java.io.InvalidObjectException {
        throw new java.io.InvalidObjectException("Proxy required");
    }
}

制限

シリアライズ・プロキシ・パターンも万能ではありません。

まず、クライアントによって拡張可能なクラスとは互換性がありません。

また、オブジェクトグラフが循環しているようなクラスでは利用できません。 シリアライズプロキシの readResolve からオブジェクトのメソッドを呼び出そうとしても、シリアライズ対象のクラスのインスタンスが復元できていないので、メソッドを呼び出すことができません。

また、通常のシリアライズよりもパフォーマンスが劣化することに注意してください。

感想

ついに終わった…。

最初の記事が 2015 年の 7 月。

hjm333.hatenablog.com

1年5ヶ月かかった。

Effective Java を解説しているブログはけっこうあるけれど、最後までたどり着いているブログはほとんど見受けられないので、そういう意味ではがんばったほうかな。

次の課題図書は何にしようかな~。