Статьи

Использование сериализации для поиска грязных полей в объекте

Допустим, вы разрабатываете платформу для автоматического сохранения объектов в базе данных. Вам необходимо обнаружить изменения, сделанные между двумя сохранениями, чтобы были сохранены только измененные поля. Как обнаружить грязные поля. Самый простой способ сделать это — просмотреть исходные данные и текущие данные и сравнить каждое поле отдельно. Код как ниже:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void getDirtyFields(Object obj, Object obj2, Class cls, Map<String, DiffFields> diff)
        throws Exception {
        Field[] flds = cls.getDeclaredFields();
        for (int i = 0; i < flds.length; i++) {
            flds[i].setAccessible(true);
            Object fobj = flds[i].get(obj);
            Object fobj2 = flds[i].get(obj2);
            if (fobj.equals(fobj2)) continue;
 
            if (checkPrimitive(flds[i].getType())) {
               <!-- add to dirty fields -->
                continue;
            }
 
            Map<String, DiffFields> fdiffs = new HashMap<String, DiffFields>();
            getDirtyFields(fobj, fobj2, fobj.getClass(), fdiffs);
            <!-- add to dirty fields -->
        }
 
        if (cls.getSuperclass() != null)
            getDirtyFields(obj, obj2, cls.getSuperclass(), diff);
    }

Приведенный выше код не обрабатывает множество условий, таких как значение null, поле, являющееся коллекцией, картой или массивом и т. Д. Тем не менее, это дает представление о том, что можно сделать. Хорошо работает, если объект маленький и не содержит много иерархии. Когда изменение в огромном иерархическом объекте очень мало, мы должны пройти весь путь до последнего объекта, чтобы узнать разницу. Более того, использование равных может быть неправильным подходом для обнаружения грязных полей. Равные, возможно, не были реализованы, или просто он может просто сравнить несколько полей, так что истинное обнаружение грязных полей не делается. Вам придется проходить через каждое поле независимо от того, равны ему или нет, пока не попадете в примитив, чтобы обнаружить грязные поля.

Здесь я хочу поговорить о другом подходе к обнаружению грязных полей. Вместо отражения мы можем использовать сериализацию для обнаружения грязных полей. Мы можем легко заменить «равно» в вышеприведенном коде, чтобы сериализовать объект, и только если байты различаются, продолжаем дальше. Но это не оптимально, так как мы будем сериализовать один и тот же объект несколько раз. Нам нужна логика, как показано ниже:

  • Сериализация двух сравниваемых объектов
  • Сравнивая два потока, определите сравниваемые поля.
  • Если значения байтов отличаются, сохраните поле как другое
  • Соберите все поля, которые отличаются, и верните их

Таким образом, один обход двух байтовых потоков может генерировать список полей, которые отличаются. Как мы реализуем эту логику? Можем ли мы пройти через сериализованный поток и иметь возможность распознавать поля в нем? Мы хотим написать код, как показано ниже:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void main(String[] args) throws Exception {
        ComplexTestObject obj = new ComplexTestObject();
        ComplexTestObject obj2 = new ComplexTestObject();
        obj2._simple._string = "changed";
 
        //serialize the first object and get the bytes
        ByteArrayOutputStream ostr = new ByteArrayOutputStream();
        CustomOutputStream str = new CustomOutputStream(ostr);
        str.writeObject(obj);
        str.close();
        byte[] bytes = ostr.toByteArray();
 
        //serialize the second object and get the bytes
        ostr = new ByteArrayOutputStream();
        str = new CustomOutputStream(ostr);
        str.writeObject(obj2);
        str.close();
        byte[] bytes1 = ostr.toByteArray();      
 
       //read and compare the bytes and get back a list of differing fields
        ReadSerializedStream check = new ReadSerializedStream(bytes, bytes1);
        Map diff = check.compare();
        System.out.println("Got difference: " + diff);
    }

Карта должна содержать _simple._string, чтобы мы могли напрямую перейти к _string и обработать ее.

Объяснение формата сериализации

Есть статьи, которые объясняют, как выглядит стандартный поток сериализации . Но мы будем использовать пользовательский формат. Хотя мы можем читать стандартный формат сериализации, он становится ненужным, когда структура классов уже определена нашими классами. Мы упростим его и изменим формат сериализации, чтобы записывать только тип полей. Тип полей необходим, поскольку объявления классов могут иметь ссылки на интерфейсы, суперклассы и т. Д., В то время как содержащееся значение может быть производным типом.

Чтобы настроить сериализацию, мы создаем наш собственный ObjectOutputStream и переопределяем функцию writeClassDescriptor. Наш ObjectOutputStream теперь выглядит следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
public class CustomOutputStream extends ObjectOutputStream {
    public CustomOutputStream(OutputStream str)
        throws IOException  {
        super(str);
    }
    @Override
    protected void writeClassDescriptor(ObjectStreamClass desc)
        throws IOException  {
        <b>String name = desc.forClass().getName();
        writeObject(name);</b>
        String ldr = "system";
        ClassLoader l = desc.forClass().getClassLoader();
        if (l != null)  ldr = l.toString();
        if (ldr == null)  ldr = "system";
        writeObject(ldr);
    }
}

Давайте напишем простой объект для сериализации и посмотрим, как выглядит поток байтов:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public class SimpleTestObject implements java.io.Serializable {
    int _integer;
    String _string;
    public SimpleTestObject(int b)  {
        _integer = 10;
        _string = "TestData" + b;
    }
    public static void main(String[] args) throws Exception  {
        SimpleTestObject obj = new SimpleTestObject(0);
        FileOutputStream ostr = new FileOutputStream("simple.txt");
        CustomOutputStream str = new CustomOutputStream(ostr);
        str.writeObject(obj);
        str.close(); ostr.close();
    }
}

После запуска этого класса, вызвав «hexdump -C simple.txt», вы получите следующий вывод:

1
2
3
4
5
6
7
00000000  ac ed 00 05 73 72 74 00  10 53 69 6d 70 6c 65 54  |....srt..SimpleT|
00000010  65 73 74 4f 62 6a 65 63   74 74 00 27 73 75 6e 2e  |estObjectt.'sun.|
00000020  6d 69 73 63 2e 4c 61 75  6e 63 68 65 72 24 41 70  |misc.Launcher$Ap|
00000030  70 43 6c 61 73 73 4c 6f   61 64 65 72 40 33 35 63  |pClassLoader@35c|
00000040  65 33 36 78 70 00 00 00  0a 74 00 09 54 65 73 74  |e36xp....t..Test|
00000050  44 61 74 61 30                                                          |Data0|
00000055

Следуя формату в этой статье, мы можем отследить байты как:

  • AC ED: STREAM_MAGIC. Указывает, что это протокол сериализации.
  • 00 05: STREAM_VERSION. Версия сериализации.
  • 0 × 73: TC_OBJECT. Указывает, что это новый объект.

Теперь нам нужно прочитать дескриптор класса.

  • 0 × 72: TC_CLASSDESC. Указывает, что это новый класс.

Дескриптор класса написан нами, поэтому мы знаем формат. Он прочитал две строки.

  • 0 × 74: TC_STRING. Определяет тип объекта.
  • 0 × 00 0 × 10: длина строки, за которой следуют 16 символов типа объекта, т.е. SimpleTestObject
  • 0 × 74: TC_STRING. Определяет загрузчик классов
  • 0 × 00 0 × 27: длина строки, за которой следует имя загрузчика классов
  • 0 × 78: TC_ENDBLOCKDATA, конец необязательных данных блока для объекта.
  • 0 × 70: TC_NULL, следует за конечным блоком и представляет тот факт, что нет суперклассов

После этого записываются значения различных полей в классе. В нашем классе есть два поля: _integer и _string. поэтому у нас есть 4 байта значения _integer, т. е. 0 × 00, 0 × 00, 0 × 00, 0x0A, за которым следует строка, которая имеет формат

  • 0 × 74: TC_STRING
  • 0 × 00 0 × 09: длина строки
  • 9 байтов строковых данных

Сравнение потоков и обнаружение грязных полей

Теперь, когда мы понимаем и упростили формат сериализации, мы можем начать писать анализатор для потока и сравнивать их. Сначала мы пишем стандартные функции чтения для примитивных полей. Например, getInt записывается, как показано ниже, для чтения целых чисел (другие присутствуют в примере кода ):

1
2
3
4
static int getInt(byte[] b, int off) {
        return ((b[off + 3] & 0xFF) << 0) +  ((b[off + 2] & 0xFF) << 8) +
               ((b[off + 1] & 0xFF) << 16) + ((b[off + 0]) << 24);
    }

Дескриптор класса может быть прочитан с кодом, как показано ниже.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
byte desc = _reading[_readIndex++]; //read TC_CLASSDESC
        byte cdesc = _compareTo[_compareIndex++];
        switch (desc) {
        case TC_CLASSDESC: {
                byte what = _reading[_readIndex++];  byte cwhat = _compareTo[_compareIndex++]; //read the type written TC_STRING
                if (what == TC_STRING) {
                    String[] clsname = readString(); //read the field Type
                    if (_reading[_readIndex] == TC_STRING) {
                        what = _reading[_readIndex++];  cwhat = _compareTo[_compareIndex++];
                        String[] ldrname = readString(); //read the classloader name
                    }
                    ret.add(clsname[0]);
                    cret.add(clsname[1]);
                }
                byte end = _reading[_readIndex++]; byte cend = _compareTo[_compareIndex++]; //read 0x78 TC_ENDBLOCKDATA
                //we read again so that if there are super classes, their descriptors are also read
                //if we hit a TC_NULL, then the descriptor is read
                readOneClassDesc();
            }
            break;
        case TC_NULL:
            //ignore all subsequent nulls
            while (_reading[_readIndex] == TC_NULL) desc = _reading[_readIndex++];
            while (_compareTo[_compareIndex] == TC_NULL) cdesc = _compareTo[_compareIndex++];
            break;
        }

Здесь мы читаем первый байт, если это TC_CLASSDESC, мы читаем две строки. Затем мы продолжаем читать, пока не достигнем TC_NULL. Есть и другие условия, которые нужно обработать, например TC_REFERENCE, которая является ссылкой на ранее объявленное значение. Это можно найти в примере кода .

Примечание: функции читают оба потока байтов одновременно (_reading и _compareTo). Следовательно, оба они всегда указывают на точку, где сравнение должно начаться следующим. Байты считываются как блок, это гарантирует, что мы всегда начнем с правильной позиции, даже если есть различия в значениях. Например, строковый блок имеет длину, указывающую, где читать, дескриптор класса имеет конечный блок, указывающий, где читать, и так далее.

Мы не написали последовательность полей. Как мы узнаем, какие поля читать? Для этого мы можем сделать следующее:

1
2
3
Class cls = Class.forName(clsname, false, this.getClass().getClassLoader());
        ObjectStreamClass ostr = ObjectStreamClass.lookup(cls);
        ObjectStreamField[] flds = ostr.getFields();

Это дает нам поля в том порядке, в котором они были сериализованы. Если мы проведем итерацию по полям, это будет в том порядке, в котором были записаны данные. Итак, мы можем выполнить итерацию, как показано ниже:

01
02
03
04
05
06
07
08
09
10
11
12
13
Map diffs = new HashMap();
for (int i = 0; i < flds.length; i++) {
    DiffFields dfld = new DiffFields(flds[i].getName());
    if (flds[i].isPrimitive()) { //read primitives
    Object[] read = readPrimitive(flds[i]);
    if (!read[0].equals(read[1])) diffs.put(flds[i].getName(), dfld); //Value is not the same so add as different
    }
    else if (flds[i].getType().equals(String.class)) { //read strings
    byte nxtread = _reading[_readIndex++]; byte nxtcompare = _compareTo[_compareIndex++];
    String[] rstr = readString();
    if (!rstr[0].equals(rstr[1])) diffs.put(flds[i].getName(), dfld); //String not same so add as difference
    }
}

Здесь я только объяснил, как примитивные поля в классе могут быть проверены на различия. Однако логику можно распространить на подклассы, рекурсивно вызывая одни и те же функции для типов полей объекта.

Здесь можно найти пример кода для этого блога, в котором есть логика для сравнения подклассов и суперклассов. Более аккуратная реализация может быть найдена здесь .

Слово предостережения. У этого метода есть несколько недостатков:

  • Этот метод может использовать только сериализуемые объекты и поля. Переходные процессы и статические поля не сравниваются на предмет различий.
  • Если writeObject переопределяет сериализацию по умолчанию, то ObjectStreamClass не отражает правильно сериализованные поля. Для этого нам придется либо жестко закодировать чтение таких классов. Например, в примере кода есть такое чтение для ArrayList или использование и анализ стандартного формата сериализации.