シリアライズされるクラス名を固定にする

2018年3月7日

============================
この情報は古いので、シリアライズ・デシリアライズされるクラスを自由に変更するを見ること。
============================

シリアライズされたオブジェクトを別パッケージ・別クラスに復帰するで書いたことは、何も考えずにシリアライゼーションを行ってしまった後でシリアライズされたクラスのパッケージやクラス名を変更する方法であったが、はじめからシリアライズされるクラスを、ある特定の名称にすることはできるだろうか?

これを行いたい理由というのは、

  • proguardで製品を難読化するが、しかしバージョンアップし再度難読化するごとに基本的にはパッケージ名・クラス名は変更されてしまう(難読化の際には、フィールド名は難読化を逃れるものとする)。
  • バージョンアップのたびに一度だけではなく自由にパッケージを移動し、クラス名称を変更したい。
  • JSONによるシリアライゼーションに変更しようと思ったが、JSONではそのオブジェクトツリー構造の中に一つのオブジェクトが複数回出現するような構造を作ることができない。

シリアライゼーションの方針

以上の要求を元にしたシリアライゼーションの方針としては、例えばシリアライズされるクラスに何らかの一意な名前をつけ、パッケージの変更、クラス名の変更、難読化に際しても決してこの一意名は変更されないようにする。何らかのアノテーションによるものが最も簡単と思われる。

検証

これが実際に可能かを検証してみる。以下は非常に簡単な例で、要するにシリアライズされるのは、Foo1,Foo2だが、ObjectOutputStreamによるシリアライズ書き込み時にこれを別の名前に変更してしまう。この例では、ObjectInputStreamはいじらずにBar1,Bar2として復帰させるのだが、この部分を変更すれば、固定された一意名にすることが可能なはず。

このコードのキモは、ObjectOutputStreamのwriteClassDescriptorを変更してしまうことであるが、ただし、まともな方法では所望の動作をさせられない。どうしてもリフレクションを使う必要がある。また、ObjectOutputStreamクラスの多くのメソッドがprivateになっており、動作変更できそうな、publicあるいはprotectedとしては、writeClassDescriptorメソッドしか見つからなかった。

package sample;

import java.io.*;
import java.lang.reflect.*;
import java.nio.file.*;

public class Example {

  public static class Foo1 implements Serializable {
    private static final long serialVersionUID = 1L;
    public int a = 123;
  }
  public static class Foo2 extends Foo1 {
    private static final long serialVersionUID = 1L;
    public int b = 456;
    public Foo1 c;
    public Foo1 d;
  }
  public static class Bar1 implements Serializable {
    private static final long serialVersionUID = 1L;
    public int a;
    public String toString() { return "" + a; }
  }
  public static class Bar2 extends Bar1 {
    private static final long serialVersionUID = 1L;
    public int b;
    public Bar1 c;
    public Bar1 d;
    public String toString() {
      return super.toString() + "," + b +"," + c + "," + d;
    }
  }

  public static void main(String[]args) throws Exception {   

    Field nameField = ObjectStreamClass.class.getDeclaredField("name");
    nameField.setAccessible(true);

    ByteArrayOutputStream bytesOut = new ByteArrayOutputStream();
    try (ObjectOutputStream output = new ObjectOutputStream(bytesOut) {
      @Override
      protected void writeClassDescriptor(ObjectStreamClass desc) throws IOException {
        try {
        String name = desc.getName();
        if (name.equals("sample.Example$Foo1")) {
          nameField.set(desc,  "sample.Example$Bar1");
        }
        if (name.equals("sample.Example$Foo2")) {
          nameField.set(desc,  "sample.Example$Bar2");
        }
        super.writeClassDescriptor(desc);
        nameField.set(desc, name);
        } catch (Exception ex) {
          ex.printStackTrace();
        }
      }
    }) {
      Foo2 foo2 = new Foo2();
      Foo1 foo1 = new Foo1();
      foo1.a = 55555;
      foo2.c = foo2.d = foo1;
      output.writeObject(foo2);
    }

    Files.write(new File("test.bin").toPath(), bytesOut.toByteArray());

    try (ObjectInputStream input = new ObjectInputStream(new ByteArrayInputStream(bytesOut.toByteArray()))) {
      Bar2 result = (Bar2)input.readObject();
      System.out.println(result);
    }
  }
}

実行結果は以下になる。

123,456,55555,55555

シリアライズストリームの内容

シリアライズされたストリームをダンプすると以下になっている。

ここでは、シリアライズされたクラスが、sample.Example$Bar1, sample.Example$Bar2であることが示されている。ただし、sample/Example$Foo1という文字列も現れてしまっている、これはFoo2クラスのフィールド名の型が表現されているだけである。ここは変更しなくとも支障はなかった。