Серіалізація даних: тест продуктивності і опис застосування

Серіалізація
Серіалізація (
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: %d\n", 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 make fun(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 make fun(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 Мб
У цій таблиці наведено результати тестування.
  • У колонці
    «місце»
    не вказано місце, яке посів тест від швидкого до повільного.
  • У колонці
    «ім'я»
    вказаний псевдонім тесту.
  • У колонках
    «запис»
    ,
    «читання»
    та
    «всього»
    зазначено час, в секундах, яке було витрачено тестом на виконання відповідної операції.
  • У колонці
    «кращий роботодавець»
    та
    «гірший»
    зазначено відміну,
    «разах»
    від кращого і гіршого представника у відповідній категорії.
  • Осередками без даних позначений найкращий і найгірший результат в категорії.
  • У колонці
    «файл»
    вказаний розмір файлу, який був згенерований для одного набору даних.
<habracut/>
Коментарі

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
т. к. розбирати завантажене дерево завжди простіше ніж залежати від того порядку, в якому елементи виявляться у вихідних даних.
Використовувати цей спосіб для великих вихідних даних, ні для операцій, де важлива продуктивність, не можна. Він працює повільно і споживає багато пам'яті.
Посилання
Текст утиліти тестування можна скачати по цим посиланням.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.