Сериализация данных: тест производительности и описание применения

в 9:35, , рубрики: Externalizable, java, kotlin, serializable, сравнение, тест

Сериализация

Сериализация (Serialize, в последующем «сохранение») – это процесс сохранения данных объекта во внешнем хранилище. Эта операция работает в паре с обратной – восстановлением данных, называемой десереализацией (Deserealize, в последующем «восстановление»).

Операции сохранения и восстановления данных применяются очень часто. В классических языках программирования готовых механизмов для сохранения и восстановления данных объектов нет и, при возникновении такой необходимости, приходится создавать их самостоятельно. В Java такие готовые механизмы существуют и, даже, в количестве более одного. Давайте разберемся, какие механизмы есть и какие возможности они предоставляют для программ на Kotlin.

Само понятие сериализации никак не привязано к формату данных, в который будут сохранены данные, поэтому вне зависимости от того, какой результат будет получен – бинарный файл с собственной структурой, формат XML, JSON или даже текстовый файл – все это будет сериализацией.

Многие классы потоков, такие как Writer или PrintStream предоставляют готовые возможности для сохранения элементарных типов данных, но использовать эти так же неудобно, как и в классических языках программирования из-за очень большого числа описаний, которые необходимо проделывать.

Но, помимо работы с элементарными типами, в Java существует несколько разных типов готовых механизмов для сохранения данных классов и множество библиотек, реализующих работу с одними и теми же форматами, отличающихся друг от друга производительностью, объемом и предоставляемыми возможностями.

Ниже будут рассмотрены типовые способы сохранения данных: встроенные в стандартную библиотеку Java, а так же сохранение в формате XML и JSON.

Serializable

Простейшая возможность, существующая в стандартной библиотеке Java – это сохранение и восстановление данных в полностью автоматическом режиме в бинарной форме. Для реализации этой возможности необходимо всего лишь указать у всех классов, данные которых должны автоматически сохраняться и восстанавливаться, интерфейс Serializable в качестве реализуемого. Это интерфейс «маркер», который не требует реализации ни одного метода. Он используется просто для обозначения того, что данные этого класса должны сохраняться и восстанавливаться.

Пример использования

Использование этого класса элементарно – одной операцией и не требует написания ни одной лишней буквы.

Код программы

class DataClass(s : String) : Serializable {
  @JvmField var strField = ""
  @JvmField var intField = 0
  @JvmField var dbField = 0.0
  protected @JvmField var strProt = ""
  private var strPriv = ""
  @JvmField val valStr : String
  protected @JvmField val valProt : String

  init {
    valStr = s
    valProt = "prot=" + s

    strField = s + ":baseText"
    intField = s.hashCode()
    dbField = s.hashCode().toDouble() / 1000
    strProt = s+":prot"
    strPriv = s+":priv"
  }

  fun print() {
    outn("str = [%s]nint = [%d]ndb = [%f]", strField, intField, dbField)
    outn("prot = [%s]npriv = [%s]", strProt, strPriv) 
    outn("value = [%s]nprot value = [%s]", valStr, valProt)
  }
}

fun Action() {
  outn( "Simple object IO test" )

  val a = DataClass("dataA")
  outn("Saved contents:")
  a.print()

  Holder(ObjectOutputStream(File("out.bin").outputStream())).h.writeObject(a)
  val b = Holder(ObjectInputStream(File("out.bin").inputStream())).h.readObject()

  outn("Class: %s", b.javaClass.name)
  if (b is DataClass) {
    outn("Loaded contents:")
    b.print()
  }
}

В результате выполнения этого теста мы получим следующий вывод:

Результат

Simple object IO test
Saved contents:
str = [dataA:baseText]
int = [95356375]
db = [95356,375000]
prot = [dataA:prot]
priv = [dataA:priv]
value = [dataA]
prot value = [prot=dataA]
Class: app.test.Externalize.Test$DataClass
Loaded contents:
str = [dataA:baseText]
int = [95356375]
db = [95356,375000]
prot = [dataA:prot]
priv = [dataA:priv]
value = [dataA]
prot value = [prot=dataA]

Как уже видно из результата сохранение и восстановление объекта прошло успешно и, после восстановления, новый объект имеет точно такое же содержимое как сохраняемый.
При выполнении программы был создан файл «out.bin» размером в 244 байта в бинарном формате.
Описание формата можно найти во множестве источников, но, на мой взгляд, разбираться в нем не имеет никакого смысла, достаточно, чтобы его успешно понимали операции сохранения и восстановления.

Особенности

Если рассмотреть приведенный выше пример подробнее, то можно увидеть следующие особенности.

  • Были сохранены и восстановлены абсолютно все поля, даже те у которых указан тип доступа «private» и «protected».

  • Обработаны были и поля, указанные как «val», т.е. неизменяемые по стандартам Kotlin.

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

В результате понятно, что состояние объекта сохраняется и восстанавливается минуя все синтаксические ограничения, которые указаны в тексте программы. Иногда такая особенность реализации является плюсом, но, иногда, она может являться и принципиальным ограничением на ее использование.

Для сохранения данных используется специальный поток ObjectOutputStream и аналог для загрузки.
Этот поток умеет работать с любыми типами данных и, в том числе, с объектами целиком, чем мы и воспользовались.
Формируемые этим потоком данные содержат независимый набор блоков информации, поэтому никаких ограничений на его использование нет.
Можно сохранять в один поток сколько угодно объектов или элементарных типов, главное, при восстановлении, прочитать их в том же порядке.

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

Классы для сохранения и восстановления данных сами являются потоками и их содержимое можно обернуть каким угодно классом, обрабатывающим потоки.
Можно сжать сохраняемые данные, зашифровать, передавать по сети, сохранять в память, архив или любой внутренний контейнер.

Возможности

Несмотря на кажущуюся простоту этого метода, он предоставляет очень мощный механизм, которым крайне легко и просто пользоваться. У него есть свои недостатки, которые будут описаны ниже, но, часто, его возможностей вполне достаточно для всего, что может понадобиться программисту.

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

  • При сохранении объектов автоматически отслеживаются их ссылки друг на друга и, при восстановлении, аналогичные объекты будут ссылаться на те же объекты. Т.е. если вы сохраняете объект «А» и объект «В» и при этом одним из полей объекта «В» является ссылка на сохраняемый объект «А», то будет сохранено не две различные копии класса «А», а только одна. При восстановлении полей новый объект «В» будет по-прежнему ссылаться на объект «А» восстановленный из этого же потока, т.е. будет восстановлена связь меду объектами.
    Эта особенность позволяет абсолютно прозрачно сохранять связную иерархию объектов, ссылающихся друг на друга без разрушения связей и дублирования данных.

  • Поддерживается сохранение классов типа «enum» с корректным их восстановлением.

  • Поддерживается сохранение и восстановление любых коллекций, основанных на интерфейсах List, Tree и Map.
    Т.е. для того чтобы сохранить и восстановить все элементы списка или даже дерева не нужно писать никакого дополнительного кода, достаточно сохранить его как объект.

  • Автоматически сохраняются и восстанавливаются данные всех предков и, при наследовании от этого класса, так же будут сохраняться все данные текущего.
    Никаких дополнительных действий для обеспечения сохранения и восстановления всей цепочки наследования предпринимать не нужно.

Для более точного управления процессом сохранения и восстановления данных можно использовать дополнительные механизмы.

Контроль версии

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

Такой механизм контроля совместимости существует. При сохранении библиотека автоматически вычисляет код для используемого класса, который описывает его состояние и, при восстановлении, проверяет совместим ли объект с теми данными, которые были сохранены для него ранее. Если к восстанавливаемому классу были добавлены поля или изменен их порядок, то восстановить данные из сохраненной копии можно, но если поля были удалены или у них изменился тип, то восстановить такие данные уже будет невозможно и, при попытке чтения такого объекта, будет выброшена ошибка.

Код описывающий состояние класса может быть вычислен автоматически, при сохранении объекта, но, если класс не планируется изменять в дальнейшем или хочется отключить этот механизм проверки, то можно использовать специальное поле класса.

class DataClass : Serializable {
 companion object {
    const private val serialVersionUID = 1L
  }
}

Это поле должно быть статической константой типа Long, описанной в классе. В случае Kotlin эта константа обязана быть описана с использованием аннотации @JvmStatic или модификатора const, иначе библиотека загрузки его не увидит.

При восстановлении данных проверяется значение кода из потока с тем, которое вычислено или записано константой у нужного класса в момент сохранения и, если эти значения не совпадают, будет выброшено исключение с ошибкой.

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

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

Для вычисления значения состояния класса можно воспользоваться утилитой «serialver» из поставки Java, но использовать ее неудобно, поэтому гораздо проще получить это значение программным путем.

Для этого нужно в программе, которая использует нужный класс, вызвать метод для вычисления его состояния и полученное значение установить в поле serialVersionUID.

fun Action() {
  outn( "ID: %dn", ObjectStreamClass.lookup(DataClass::class.java).serialVersionUID )
  //…
}

Вывод:

ID: 991989581060349712

Управление сохраняемыми данными

Часто сохранять и восстанавливать нужно не все существующие у объекта данные, но только часть из них. При развитии объекта, иногда, требуется возможность восстанавливать его содержимое, даже если формат объекта уже полностью изменился. Для этого нужно уметь читать поля, которых в классе уже нет, у которых изменилось имя или тип.

Возможность такой фильтрации сохраняемых данных существует. В Java можно пометить поле, которое не нужно сохранять, специальным модификатором transient, но у Kotlin такой модификатор отсутствует. Более гибким механизмом фильтрации полей является описание статической константы с именем serialPersistentFields.

Код программы

open class DataClass(s : String) : Serializable {
  @JvmField var strField = ""
  @JvmField var intField = 0
  @JvmField var dbField = 0.0

  companion object {
    const private val serialVersionUID1 = 1L
    @JvmStatic val serialPersistentFields = arrayOf(
      ObjectStreamField("strField",String::class.java),
      ObjectStreamField("intField",Int::class.java)
    )
  }

  init {
    strField = s + ":baseText"
    intField = s.hashCode()
    dbField = s.hashCode().toDouble() / 1000
  }

  fun print() =
    outn("str = [%s]nint = [%d]ndb = [%f]", strField, intField, dbField)
}

fun Action() {
  val a = DataClass("dataA")
  outn("Saved contents:")
  a.print()

  Holder(ObjectOutputStream(File("out.bin").outputStream())).h.writeObject(a)
  val b = Holder(ObjectInputStream(File("out.bin").inputStream())).h.readObject()

  if (b is DataClass) {
    outn("Loaded contents:")
    b.print()
  }
}

Теперь наш пример сохраняет только два поля из трех доступных.

Saved contents:
str = [dataA:baseText]
int = [95356375]
db = [95356,375000]
Loaded contents:
str = [dataA:baseText]
int = [95356375]
db = [0,000000]

Ручное управление сохраняемыми данными

Иногда, описать структуру или изменения в ней так, чтобы автоматизированные средства работали без ошибок не удается. В таком случае нужно сохранять или восстанавливать значения полей класса вручную, но при этом не хотелось бы лишаться всех преимуществ, предоставляемых автоматизированными средствами.

Предположим, что в нашем объекте у одного из полей изменилось имя и при этом нужно обеспечить сохранение и загрузку данных и с новым и со старым именем.

Средства автоматизации справиться с таким изменением неспособны, но можно оперировать полями объекта вручную.

У сериализуемого объекта можно описать функции writeObject и readObject, которые будут вызваны для загрузки и сохранения его содержимого.

Код программы

open class DataClass(s : String) : Serializable {
  @JvmField var strField = ""
  @JvmField var intFieldChanged = 0
  @JvmField var dbField = 0.0

  companion object {
    const private val serialVersionUID1 = 1L
    @JvmStatic val serialPersistentFields = arrayOf(
      ObjectStreamField("strField", String::class.java),
      ObjectStreamField("intField", Int::class.java)
    )
  }

  init {
    strField = s + ":baseText"
    intFieldChanged = s.hashCode()
    dbField = s.hashCode().toDouble() / 1000
  }

  fun print() =
    outn("str = [%s]nint = [%d]ndb = [%f]", strField, intFieldChanged, dbField)

  private fun readObject(s : ObjectInputStream) {
    val fields = s.readFields()
    strField = fields.get("strField", "" as Any?) as String
    intFieldChanged = fields.get("intField", 0)
  }

  private fun writeObject(s : ObjectOutputStream) {
    val fields = s.putFields()
    fields.put("strField", strField as Any?)
    fields.put("intField", intFieldChanged)
    s.writeFields()
  }
}

В этом примере поле класса теперь называется intFieldChanged, но в сохраняемых данных его имя по прежнему будет фигурировать как intField, что позволит загружать данные сохраненные со старым именем и сохранять их в таком виде, что старый класс будет способен их загрузить.

В тексте функций writeObject и readObject можно реализовать произвольную логику сохранения и загрузки данных.

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

Правда в последнем случае будет сложно обеспечить преемственность сохраняемой структуры, как это реализовано в примере, но, часто, такой необходимость нет.

В случае, ручного оперирования данными нужно обеспечить, чтобы данные восстанавливались в том же порядке, в котором они были сохранены.

Код программы

open class DataClass(s : String) : Serializable {
  @JvmField var strField = ""
  @JvmField var intField = 0

  companion object {
    const private val serialVersionUID1 = 1L
  }

  init {
    strField = s + ":baseText"
    intField = s.hashCode()
  }

  fun print() =
    outn("str = [%s]nint = [%d]", strField, intField)

  private fun readObject(s : ObjectInputStream) {
    strField = s.readUTF()
    intField = s.readInt()
  }

  private fun writeObject(s : ObjectOutputStream) {
    s.writeUTF(strField)
    s.writeInt(intField)
  }
}

Восстановление синглетонов

При восстановлении данных библиотека автоматически восстановит ссылки на объекты, но для работы с объектами, которые существуют в единственном экземпляре этого недостаточно т.к. при загрузке будет создан один, но новый объект, тогда как нужно, чтобы загруженные элементы ссылались на уже существующий в программе.

Это поведение, так же, можно обеспечить. Для этого достаточно у класса, который должен обеспечивать уникальность, создать метод readResolve.

Этот метод будет вызван после загрузки любого объекта этого класса и позволяет заменить его другим.

Код программы

class LinkedData private constructor(@JvmField val value : Int) : Serializable {
  companion object {
    @JvmField val ZERO = LinkedData(0)
    @JvmField val NONZERO = LinkedData(1)
    @JvmStatic fun make(v : Int) = if (v == 0) ZERO else NONZERO
  }

  private fun readResolve() : Any = if ( value == 0 ) ZERO else NONZERO
}

open class DataClass(v : Int) : Serializable {
  @JvmField val link = LinkedData.make(v)
  @JvmField var intField = v

  companion object {
    const private val serialVersionUID1 = 1L
  }

  fun print() =
    outn("int = [%d]nlink = [%s]",
         intField,
         if (link == LinkedData.ZERO) "ZERO" else
         if (link == LinkedData.NONZERO) "NONZERO" else
           "OTHER!" )
}

Результат работы программы.

Saved contents:
int = [100]
link = [NONZERO]
Loaded contents:
int = [100]
link = [NONZERO]

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

В нашем примере код вернет один из уже существующих объектов этого класса, обеспечив его уникальность и идентичность.

Сохранение нестандартных классов, прокси

В случае, если необходимо реализовать прозрачную работу с собственными классами, например с коллекциями в собственном формате или обеспечить подмену интерфейса класса (проксирование), то это тоже можно реализовать.

Для этого нужно создать наследника для классов ObjectInputStream и ObjectOutputStream переопределив у них методы annotateClass или annotateProxyClass.

Первый предназначен для обеспечения загрузки и сохранения неизвестных классов,
а второй для проксирования интерфейса классов.

Недостатки

Библиотека, реализующая функционал интерфейса Serializable очень могучая, но она имеет ряд серьезных недостатков, которые в некоторых случаях могут являться принципиальным ограничением для ее использования.

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

По моим измерениям, скорость работы этой библиотеки сопоставима практически со скоростью сохранения и восстановления данных в формате XML.

Библиотека Serializable быстрее, но значительно уступает в скорости работы практически всем другим способам сохранения и восстановления данных.

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

В среднем, создаваемые этой библиотекой файлы оказываются незначительно меньше файлов в формате JSON, в которые сохраняются те же самые данные.

Формат сохраняемых данных – бинарный. Если когда-то может возникнуть задача, получить данные из такого файла на другом языке программирования или обеспечить возможность просмотра или редактирования его человеком, то такая задача может оказаться довольно сложно реализуемой.

Serializable работает только с полями!

Самый важный недостаток этого метода заключается в том, что он способен работать только с полями.
Если вы реализуете интерфейс, где значение поля «эмулируется» парой функций для установки и получения значения, если в ваших класса используются делегаты и прочие способы описания свойств, которые не имеют соответствующего поля в классе, то этот механизм окажется абсолютно бесполезен.
Он способен сохранить и восстановить только данные, описанные в классе в виде полей, а для всех остальных придется реализовывать их сохранение и загрузку вручную.

Externalizable

Второй метод, реализуемый штатными средствами Java – это интерфейс Externalizable. Все объекты реализующие этот интерфейс и их наследники могут быть сохранены в поток теми же классами ObjectInput и ObjectOutput что и реализующие интерфейс Serializable, но, в отличии от последнего, сохранение и восстановление данных объектов происходит полностью в ручном режиме.

Интерфейс реализуется с помощью методов readExternal и writeExternal, на совести которых лежит сохранение и восстановление всех элементов класса.

В отличии от Serializable, при реализации Externalizable, ответственность за загрузку данных
предков класса лежит на программисте. Библиотека не будет читать и писать ничего автоматически, в поток будет сохранено только то, что явно вызвано в методах сохранения.

Т.к. у реализаций Serializable и Externalizable общая основа, то при реализации последнего можно использовать практически все возможности первого.

Можно сохранять и восстанавливать объекты целиком. В таком случае будет использоваться механизм сохранения и восстановления ссылок. У объектов можно реализовать функцию readResolve, которая будет играть точно ту же роль, можно сохранять те же самые коллекции с возможностью их автоматически загрузить.

Принципиальное отличие работы кода Externalizable в следующем:

  • Не будут автоматически сохраняться данные предков объекта и, для их сохранения нужно явно описать действия по их сохранению.

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

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

  • Никакой дополнительной информации, кроме сохраняемой каждым элементарным методом, в поток записано не будет.

  • Создание объектов производится через вызов его конструктора, поэтому каждый сохраняемый объект обязан иметь конструктор без параметров.

Код программы

class LinkedData private constructor(@JvmField val value : Int) {
  companion object {
    @JvmField val ZERO = LinkedData(0)
    @JvmField val NONZERO = LinkedData(1)
    @JvmStatic fun make(v : Int) = if (v == 0) ZERO else NONZERO
  }
}

open class DataClass : Externalizable {
  @JvmField var link : LinkedData
  @JvmField var intField : Int

  constructor() { link = LinkedData.ZERO; intField = 0 }
  constructor(v : Int) { link = LinkedData.make(v); intField = v }

  fun print() = outn("int = [%d]nlink = [%s]", intField,
                     if (link == LinkedData.ZERO) "ZERO" else
                       if (link == LinkedData.NONZERO) "NONZERO" else
                         "OTHER!")

  override fun readExternal(s : ObjectInput) {
    link = if ( s.readByte().toInt() == 0 ) LinkedData.ZERO else LinkedData.NONZERO
    intField = s.readInt()
  }

  override fun writeExternal(s : ObjectOutput) {
    s.writeByte(if(link == LinkedData.ZERO) 0 else 1)
    s.writeInt(intField)
  }
}

В этом коде пришлось изменить описание поля link на изменяемое т.к. его приходится устанавливать вручную при загрузке и описать конструктор для создания загружаемого объекта. С целью иллюстрации преимуществ такого способа сохранения данных вместо объекта link сохраняется только описание состояния.

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

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

Особенности

Основная особенность реализации интерфейса Externalizable заключается в том, что полный контроль над сохраняемыми данными, их форматом и порядком их следования находится в руках программиста.
Этот метод сериализации по удобству и эффективности находится между прямой записью примитивов в поток и полной автоматизацией, предоставляемой библиотекой Serializable.

То, в какую сторону будет смещаться удобство или эффективность кода зависит от тех методов, которые будут использованы в в программе. Чем больше используется автоматизации, тем больше будет сохранено служебной информации, и тем медленнее будет работать код, но тем меньшее число нюансов сохранения и восстановления данных придется реализовывать вручную.

В общем, интерфейс Externalizable предоставляет довольно удобный интерфейс для тех, кто хочет реализовать значительно более эффективное сохранение и восстановление данных, но, при этом, хочет пользоваться какими-то из возможностей автоматизации этого процесса.

Здесь стоит заметить, что использовать этот метод стоит только тогда, когда, с одной стороны, не все данные будут сохраняться вручную и, с другой стороны, большая их часть будет сохранена самостоятельно.

В случае перекосов в какую-либо из сторон, получившийся код может стать значительно менее эффективным чем полная автоматизации или полный отказ от нее.

К примеру, если сохранять абсолютно все данные вручную, то использование классов ObjectStream теряет всякий смысл, а связанные с их использованием накладные расходы и ограничения могут только мешать.

Гораздо проще использовать возможности записи и чтения данных, предоставляемые любым бинарным потоком.

Если же, реализуя этот интерфейс, абсолютно все данные сохранять с использованием только механизмов автоматизации, то получившийся код окажется заметно менее эффективным, а создаваемые им файлы больше, чем при использовании возможностей Serialilzable и, при этом, придется реализовывать самостоятельно очень много нюансов для обеспечения целостности данных после их загрузки.

Это объясняется тем, что при сохранении и восстановлении данных библиотека проделывает очень много работы, которая, с одной стороны, упрощает ее использование и, с другой, значительно оптимизирует сохраняемые данные за счет записи только ссылок на уже сохраненные объекты.
Реализовывать все это вручную не имеет никакого смысла, проще использовать уже готовый код целиком.

Тестирование, сравнения

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

Однако, будет полезным привести результаты тестирования, которые я получил при выборе механизма для сохранения данных.

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

Формат данных

Автоматически генерируемые случайные данные выглядят следующим образом:

  0) <noname>       add.t_ForOrGetPut       org.sun.NotNotEmptyGetEach( SetEmptyCombineSplit )
  1) fNotRandom                     add.For(void, add.t_HasEmpty Has, add.t_Combine GetCombineHas )
  2) fOrSet                     app.PutHasForGet( app.t_Set HasCombineAdd, app.t_ForNotOrAdd Combine )
  3) fHasSplit                      org.sun.Set(  Set, sec.sun.t_JoinEmptyHasCombineCombine EmptyCombineOr )
  4) fJoinEach      sec.sun.t_OrForEmptySet     sec.sun.EachPutOrNot( org.sun.t_Combine SetHasSplitJoinEmpty, void )
  5) fEachSet       org.sun.t_RandomSplit       app.OrIsFor( sec.sun.t_Set CombineGetRandom, void )
  6) <noname>       app.t_NotSetForForGet       sun.NotHasForSplitAdd( org.sun.t_IsRandomOrHas Each, void)
  7) fNotSplit      add.t_NotAdd            sec.sun.IsHasNot( app.t_HasForSplitHas ForGet, void )
  8) <noname>                       sun.SetForSplitSet( PutCombine, void, void )
  9) fCombineNot    sun.t_SplitRandomGetRandom  sun.AddAdd( void, org.sun.t_NotRandomHasEmpty AddPutNotSplit )
 10) <noname>       add.t_ForIs         sun.EachIs( NotFor, void, void,  PutSplitAddNot )
 11) fSetOr                     app.HasJoin( OrOr, void, void, add.t_NotAddHas Each )

Имена методов, типов и исходных файлов генерируются из набора случайных имен. Имя исходного файла может отсутствовать, а текст <noname> и void обозначает синглетоны, обозначающие уникальные значения для имени файла или параметра.

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

Используемые средства

При тестировании использовались следующие способы сохранения и восстановления данных:

  • SerialFull — Полностью автоматическая работа интерфейса Serializable.

  • Extern+Ser — Реализация интерфейса Externalizable с кодом, в котором смешано ручное и автоматизированное сохранение данных.

  • ExternFull — Реализация интерфейса Externalizable с полностью ручным сохранением данных.

  • JsJsonMini — Библиотека «minimal-json», сохраняющая данные в формате JSON.
    В тесте использовалась библиотека minimal-json-0.9.4.jar, домашняя страница этого проекта находится тут: https://github.com/ralfstx/minimal-json.

  • Библиотека fasterXML-jackson так же сохраняющая данные в формате JSON.
    В тесте использовалась библиотека версии 2.0.4, домашняя страница этого проекта находится тут: https://github.com/FasterXML/jackson.
    С использованием этой библиотеки было реализовано два алгоритма работы с данными.
    Первый из них (JsJackAnn), полностью автоматический, управлялся только аннотациями, который называется в этой библиотеке annotations-databind подходом.
    Во втором (JsJackSream) было реализовано полностью ручной разбор дерева,
    называемого в этой библиотеке stream подходом.

  • Реализация штатного механизма Java для сохранения XML данных на основании
    классов «org.w3c.dom.Document и org.w3c.dom.Element.

В таблице ниже приведены данные о каждом использованном средстве.

Название Версия Источник Дополнительные библиотеки
XML, Serializable, Externalizable Java 1.8 Штатная реализация Java Не требуются, входят в комплект поставки Java.
minimal-json 0.9.4 https://github.com/ralfstx/minimal-json minimal-json-0.9.4.jar – 30Кб
fasterXML-jackson 2.0.4 https://github.com/FasterXML/jackson jackson-core-2.0.4.jar – 194Кб, jackson-databind-2.0.4.jar – 847Кб, jackson-annotations-2.0.4.jar – 34Кб

Процедура тестирования

Утилита тестирования

Для тестирования была реализована утилита со следующим интерфейсом:

USAGE: SerializableTest.jar [-opts]

Where OPTS are:
  -Count=<number>        - set number of items to generate
  -Retry=<number>        - set number of iterations for each test
  -Out=<file>            - set file name to output
  -Nout                  - disable items output
  -gc                    - run gc after every test

Эта утилита последовательно производит следующие действия:

  • Создает случайный набор данных указанного объема.
  • Запускает все тесты по очереди.
  • Каждый тест заключается в том, что данные сохраняются на диск в формате теста, загружаются в память с созданием новых объектов и сравниваются с оригинальным набором для проверки правильности выполнения операций.
  • Процедура тестирования для всех классов выполняется указанное число раз.
  • Замеряет время, которое ушло на операции сохранения и загрузки данных и выводит таблицу с результатами.

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

Результаты

В зависимости от количества данных и выделяемой JVM памяти результаты значительно отличаются в абсолютных цифрах, однако, их соотношение сохраняется практически в любых условиях.

Вывод ктилиты

>sb -n "-c=100000" "-r=10"
Output file       : test_out
Number of elements: 100000
Number of retries : 10
Tests complete in 0:01:28.050 sec :: Save 0:00:31.903, Load 0:00:30.149, Total 0:01:02.103, Waste 0:00:25.947

В этом тесте создавалось 100.000 случайных элементов, которые были записаны на диск и прочитаны с него 10 раз каждым из тестов.

На тест было затрачено 1мин 28сек, из которых 25.9сек на накладные расходы утилиты тестирования.

Место Имя Запись Лучш Худш Загрузка Лучш Худш Всего Лучш Худш Файл
6 SerialFull 0:00:07.599 2,34 0:00:04.217 1,05 1,45 0:00:11.826 1,56 0,41 18Мб
1 ExternFull 0:00:02.550 0,12 1,98 0:00:02.061 4,02 0:00:04.616 2,60 16Мб
5 Extern+Ser 0:00:05.744 1,52 0,32 0:00:04.112 1,00 1,51 0:00:09.862 1,14 0,69 22.5Мб
7 XMLw3c 0:00:06.278 1,76 0,21 0:00:10.337 4,02 0:00:16.620 2,60 32Мб
4 JsJsonMini 0:00:04.678 1,05 0,62 0:00:04.614 1,24 1,24 0:00:09.302 1,02 0,79 25.9Мб
3 JsJackAnn 0:00:02.776 0,22 1,74 0:00:02.431 0,18 3,25 0:00:05.215 0,13 2,19 25.9Мб
2 JsJackSream 0:00:02.278 2,34 0:00:02.377 0,15 3,35 0:00:04.662 0,01 2,56 25.9Мб

В этой таблице приведены результаты тестирования.

  • В колонке «место» указано место, которое занял тест от быстрого к медленному.
  • В колонке «имя» указан псевдоним теста.
  • В колонках «запись», «чтение» и «всего» указано время, в секундах, которое было потрачено тестом на выполнение соответствующей операции.
  • В колонке «лучш» и «худш» указано отличие, в «разах» от лучшего и худшего представителя в соответствующей категории.
  • Ячейками без данных обозначен лучший и худший результат в категории.
  • В колонке «файл» указан размер файла, который был сгенерирован для одного набора данных.

Комментарии

Serializable

Из теста видно, что этот механизм очень медленный. При этом при загрузке и сохранении большого числа объектов он использует довольно много памяти. Это наиболее простой из всех участников теста в реализации способ, для использования которого нужно писать минимальное число текста и, если бы в данных не использовались синглетоны, то писать не нужно было бы вообще ничего.

Благодаря своей простоте, этот способ вполне годится для использования в случаях, когда
производительность кода не играет никакой роли — при сохранении своих данных или настроек.

В случае если важна производительность, рассматривать реализацию алгоритма с использованием такого подхода нужно в последнюю очередь.

Externalizable

Этот способ предсказуемо оказался самым скоростным при работе и генерирующим минимальные файлы данных.

Однако скоростные и объемные показатели этого теста становятся заметными только при полном отказе от автоматизации для всех, часто используемых операций. Как только автоматизация используется более широко (тест Extern+Ser) производительность программы стремительно падает, а объем файла данных растет.

Причины этого явления описаны в главе ранее.

Этот способ крайне многословен при использовании и не очень надежен в результатах т.к. в случае, если код автоматизации окажется в часто использующемся месте, то это сведет на нет все его преимущества.

Дополнительным минусом, который стоит отметить, является трудоемкость поиска ошибок рассогласования данных.

Библиотека Java контролирует границы объектов и, если где-то происходит рассогласование процесса записи и чтения (например, поле сохраняется, но код для его загрузки описать забыли), то найти место проблемы очень сложно. Единственная ошибка, которую генерирует код Java – это EOF, что обозначает как достижение конца файла при чтении, так и попытку прочитать данные другого типа.

Использовать такой способ не имеет особого смысла т.к. он крайне многословен и не имеет особых преимуществ в объеме данных или скорости ни перед библиотеками JSON, ни перед ручнй реализаций собственного формата.

minimal-json

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

При загрузке данных все они грузятся в память, поэтому эта библиотека занимает сравнительно много места в памяти. Никаких способов кроме полностью ручного формирования дерева для записи и его разбора при загрузке в библиотеке нет.

Маленький размер – это, по сути, единственное достоинство этой библиотеки.

Она не имеет никаких средств автоматизации для уменьшения кода и при этом не демонстрирует никаких выдающихся результатов. Использовать именно эту библиотеку имеет смысл того в том случае, если объем приложения играет решающую роль и при этом нужен именно формат JSON.

Во всех остальных случаях проще либо реализовать бинарный формат, который будет работать в разы быстрее, либо свой формат данных для использования человеком, либо использовать другую библиотеку.

fasterXML-jackson databind

Использовать эту библиотеку в режиме автоматизации можно только в том случае, когда формат
сохраняемых данных прост. При усложнении взаимосвязи объектов или использовании синглетонов различного типа, код стремительно обрастает различными аннотациями и костылями, которые обеспечивают требуемую функциональность. Автоматический режим этой библиотеки несравнимо сложнее в использовании, чем вариант с Serializable, а код становится более запутанным и сложным, чем даже при ручном формировании дерева объектов.

Реализация этого теста потребовала написания самого большого объема кода, который не относится к выполняемой задаче напрямую. Некоторые вещи в ней даже невозможно реализовать без ручного управления.

К примеру, загрузку коллекции, в элементах которой могут быть синглетоны так, чтобы после загрузки они ссылались на уже существующие объекты.

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

Этот способ сохранения и восстановления данных является очень быстрым, не принципиально отличаясь от лидеров, но библиотека реализующая функционал просто огромна. Предоставляемый ею функционал очень велик, но и времени, на освоение всего этого функционала уйдет настолько же много. А вот понадобится ли он весь потом – это уже вопрос, на который каждый может ответить только сам.

Использовать эту библиотеку имеет смысл в случаях, если:

  • Существует множество несложных классов и часть сохраняемых свойств не существует в виде полей.

  • Требуется быстрый сериализатор в формат JSON, а количество описаний в исходных текстах оказывается не очень большим.

  • Размер используемых библиотек не играет никакой роли.

На мой взгляд, если в проекте вообще можно использовать библиотеки размером около мегабайта, то этот способ является наиболее предпочтительным т.к. он позволяет работать как с полями так и с методами и при этом не требует большого количества описаний в тексте программы.

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

fasterXML-jackson stream

Самый быстрый способ сохранить и загрузить данные в формате JSON и при этом не имеющий особых сложностей в реализации.

Особенностью этого способа является то, что при сохранении данных дополнительное дерево для хранения объектов JSON не создается, а сразу формируются данные формата. Аналогично при загрузке, происходит одновременное чтение JSON и его разбор.

Таким образом, этот способ имеет минимальные требования к объему памяти.

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

Для использования этого способа достаточно библиотеки jackson-core, которая занимает 200Кб, что в 4 раз меньше объема для использования databind подхода.

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

XML

Этот способ является самым медленным и предъявляет самые большие требования к объему памяти.

Дерево для всех объектов файла дынных не просто строится при загрузке данных, но при этом потребляет просто неприличный ее объем.

При тестировании этот тест не смог выполниться с количеством элементов более 400.000 штук из-за того, что 5Гб выделенной для JVM памяти оказалось недостаточно.

При реализации этот способ абсолютно ничем не отличается от любого другого, где нужно сформировать дерево перед сохранением и разобрать его при загрузке. Небольшое отличие при использовании было только в сравнении с библиотекой fasterXML-jackson в режиме stream т.к. разбирать загруженное дерево всегда проще чем зависеть от того порядка, в каком элементы окажутся в исходных данных.

Использовать этот способ ни для больших исходных данных, ни для операций, где важна производительность, нельзя. Он работает медленно и потребляет много памяти.

Ссылки

Текст утилиты тестирования можно скачать по этой ссылке.

Автор: JouriM

Источник

Поделиться

* - обязательные к заполнению поля