シリアライズ・デシリアライズされるクラスを自由に変更する

2018年5月21日

※2018・5・19 これを行うライブラリを作成した。シリアライゼーションクラスを自由に変更するライブラリrelocSerialの紹介を見てほしい。

この問題の背景

かなり以前に作成したJavaプログラムにおいて、何も考えずにシリアライズを行ってしまっており、ユーザ側データベースにシリアライズされたオブジェクトが格納されているのだが、以下の不都合がある。

  • シリアライズクラスの名称変更(パッケージ変更を含む)が一切できない。このため、プログラムコードをリファクタリングしようにも、その部分のクラスは変更することができない。
  • 難読化をする際、フィールドは仕方が無いとしても、クラス名も難読化を解除しなければならない。このため、本来不要なはずのクラス名がリバースエンジニアリングされてしまうし、そのクラスにいたるまでのパッケージ名称も見えてしまう。

上記代替策もあるが、いずれも問題点がある。

  • JSONによるシリアライゼーション
    • 変更するにしても、少なくともシリアライズ済のオブジェクトは読み込めなければならない。
    • JSONによるものでは、プログラム上では単一のオブジェクトをあちこちで参照している状態であっても、シリアライズされた場合は複数オブジェクトになってしまう。
  • Javaシリアライゼーションの代替システム
    • FST、kryoなどというものがあるが、いずれもバイナリ互換は無いようで、既存のJavaシリアライゼーションストリームは読み込めないようだ。

Long time ago, we’ve created Java program which serialize/deserialize certain classes using Java Serialization. Now we’re suffering from the problem that those classes can’t be changed anyway like changing package or changing class names. This is a big problem when we refactor that proglem. How can we change these things while preserving data in user side ?

What we want is Java Serialization System which does not depend package names or class names. Also we want to read old user data after refactoring the program.

  • Please refer to this page for the open source library which handles this problem.

解決策のデモ

ということで、Javaシリアライゼーションを改造して、上記問題を解決することにした。コード例では以下をデモしている。

  • 任意のクラスがシリアライズされるときに、そのクラスを別のクラス名に変更してしまう。
  • デシリアライズする場合に、そのクラス名を別のクラス名に変更してしまう。

これらの機能によって、既存のシリアライゼーションストリームに対し、以下の操作ができる。

  • 古いクラス名のストリームを読み込むときに、常にそれに対応する新たなクラス名に変換する。
  • 新たなクラス名で書き込みを行うときに、その名前を将来的に変更されない統一名として書き込む。

We’ve got the solution, the following code demonstrates how to do that.

  • When serializing some object, that class name will be replaced.
  • When deserializing from serialization stream, class names in the stream will be replaced.
package sample;

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

public class Serial {

  static Field oscNameField;
  static {
    try {
      oscNameField = ObjectStreamClass.class.getDeclaredField("name");
      oscNameField.setAccessible(true);
    } catch (Exception ex) {
      throw new RuntimeException(ex);
    }
  }

  public static void main(String[]args) throws Exception {
    Sample[]samples = bytesToObject(objectToBytes(new Foo[] {
        new Foo(123),
        new Foo(456)
    }));
    for (Sample sample: samples) System.out.println(sample);

    samples = bytesToObject(objectToBytes(new Bar[] {
        new Bar(321),
        new Bar(654)
    }));
    for (Sample sample: samples) System.out.println(sample);
  }

  static byte[] objectToBytes(Object object) throws IOException {
    ByteArrayOutputStream bytes = new ByteArrayOutputStream();
    try(ObjectOutputStream out = new NewOutput(bytes)) {      
      out.writeObject(object);
    }
    return bytes.toByteArray();
  }

  @SuppressWarnings("unchecked")
  static <T> T bytesToObject(byte[]bytes)  throws Exception {
    try (ObjectInputStream in = new NewInput(new ByteArrayInputStream(bytes))) {
      return (T)in.readObject();
    }
  } 

  static class NewOutput extends ObjectOutputStream {
    NewOutput(OutputStream out) throws IOException {
      super(out);
    }    
    @Override
    protected void writeClassDescriptor(ObjectStreamClass desc) throws IOException {      
      TypeCodeReplacer replacer = new TypeCodeReplacer(desc.getName());
      if (!replacer.getElement().equals("sample.Serial$Foo")) {
        super.writeClassDescriptor(desc);
        return;
      }
      try {
        try {                  
          oscNameField.set(desc, replacer.getReplaced(("sample.Serial$Bar")));                
          super.writeClassDescriptor(desc);
        } finally { 
          oscNameField.set(desc, replacer.getOriginal());
        }
      } catch (Exception ex) {
        ex.printStackTrace();
      }
    }
  }

  static class NewInput extends ObjectInputStream {    
    protected NewInput(InputStream in) throws IOException {
      super(in);
    }    
    @Override
    protected ObjectStreamClass readClassDescriptor() throws IOException, ClassNotFoundException {
      ObjectStreamClass osc = super.readClassDescriptor();
      TypeCodeReplacer replacer = new TypeCodeReplacer(osc.getName());
      if (!replacer.getElement().equals("sample.Serial$Bar")) return osc;
      try {
        oscNameField.set(osc, replacer.getReplaced("sample.Serial$Sample"));
      } catch (Exception ex) {
        throw new RuntimeException(ex);
      }      
      return osc;
    }
  }

  public static class Foo implements Serializable {
    private static final long serialVersionUID = 1L;
    public int a;
    Foo(int a) {
      this.a = a;
    }
  }

  public static class Bar implements Serializable {
    private static final long serialVersionUID = 1L;
    public int a;
    Bar(int a) {
      this.a = a;
    }
  }

  public static class Sample implements Serializable {
    private static final long serialVersionUID = 1L;
    public int a;
    public int b;
    @Override
    public String toString() {
      return "" + a;
    }
  }


  /**
   * いわゆるJavaのタイプコード中のクラス要素を変換する。
   * 配列以外の場合には、"int", "java.lang.Object"という文字列で表現された型コードになるが、
   * 配列の場合には、"[I", "[Ljava.lang.Object;"などとなる。
   * このクラスは、その要素を抜き出し、これを置換できるようにする。
   */
  static class TypeCodeReplacer {

    private String prec = "";
    private String element;
    private String succ = "";
    private boolean replaceable;

    public TypeCodeReplacer(String name) {
      this.element = name;

      // '['が途切れるまでindexをすすめる
      int index = 0;
      while (name.charAt(index) == '[')
        index++;

      // 最初から'['ではない。つまり配列ではない。
      if (index == 0) {
        replaceable = true;
        return;
      }

      // 配列だが、要素はクラスではない。この場合は置き換え不可
      if (name.charAt(index) != 'L') return;

      // クラスの場合
      replaceable = true;
      prec = name.substring(0, index + 1);
      element = name.substring(index + 1, name.length() - 1);
      succ = name.substring(name.length() - 1);
    }

    /** 要素名を取得する */
    public String getElement() {
      return element;
    }

    /** 要素を置換したタイプコードを返す。ただし、置換できるのは、配列でないか、あるいはオブジェクトの配列のみ */
    public String getReplaced(String name) {
      if (!replaceable) throw new RuntimeException("not replaceable");    
      return prec + name + succ;
    }

    /** 元のタイプコードを返す */
    public String getOriginal() {
      return prec + element + succ;
    }

    @Override
    public String toString() {
      throw new RuntimeException();
    }
  }
}