У середовищі розробників часто існує думка, що протокол серіалізації protobuf і його реалізація - це особлива, видатна технологія, здатна вирішити всі реальні та потенційні проблеми з продуктивність одним фактом свого застосування в проекті. Можливо на таке сприйняття впливає простота застосування цієї технології і авторитет самої компанії Google.
На жаль, на одному з проектів мені довелося впритул зіткнутися з деякими особливостями, які ніяк не згадуються в рекламній документації, проте сильно впливають на технічні характеристики проекту.
Все подальше викладення стосується тільки реалізації protobuf на платформі Java. Також в основному описана версія 2.6.1, хоча в вже випущеній версії 3.0.0 принципових змін я також не побачив.
Також звертаю факт, що стаття не претендує на повноту огляду. Про хороші сторони технології (наприклад, це мультимовність і відмінна документація) можна почитати на офіційному сайті. Ця стаття розповідає тільки про проблеми і, можливо, дозволить прийняти більш зважене рішення. Одна частина проблем відноситься до самого формату, інша частина проблем відноситься до реалізації. Також потрібно уточнити, що більшість згаданих тут проблем проявляються за певних умов.
maven-проект з уже підключеними залежностями для самостійного дослідження можна взяти на github.
0. Необхідність препроцесингу
Це найменша проблема, навіть не хотів включати її до переліку, але для повноти нехай буде згадана. Для того щоб отримати java-код необхідно запустити компілятор protoc. Деяка проблема є в тому, що цей компілятор являє собою нативний додаток і на кожній з платформ виконуваний файл буде своїм, тому обійтися простим підключенням maven-плагіна не вийде. Як мінімум потрібна змінна оточення на машинах розробників і на CI-сервері, яка буде вказувати на виконуваний файл, і після цього його вже можна запускати з maven/ant сценарію.
Як варіант, можна зробити maven-pluging, який тримає в ресурсах всі бінарники і розпаковує з себе потрібний під поточну платформу в тимчасову папку, звідки і запускає його. Не знаю, може такий хтось вже і зробив.
Загалом, невеликий гріх, тому пробачимо.
1. Непрактичний код
На жаль, для платформи Java генератор protoc виробляє дуже непрактичний код. Замість того, щоб згенерувати чистенькі anemic-контейнери і окремо серіалізатори до них, генератор упихає все в один великий клас з підкласами. Генеровані біни не можна ні впровадити в свою ієрархію, ні навіть банально позикоментувати інтерфейс java.util.Serializable для спихання бінів на куди-небудь бік. Загалом вони годяться тільки в якості вузькоспеціалізованих DTO. Якщо вас це влаштовує - то це і не проблема зовсім, тільки не заглядайте всередину.
2. Зайве копіювання - низька продуктивність
Власне ось тут у мене почалися вже абсолютно об'єктивні проблеми. Генерований код для кожної описуваної сутності (назвемо її «Bean») створює два класи (і один інтерфейс, але він не важливий в даному контексті). Перший клас - це immutable Bean який являє собою read-only зліпок даних, другий клас - це mutable Bean.Builder, який вже можна правити і встановлювати значення.
Навіщо так зроблено, залишилося незрозумілим. Хтось каже, що автори входять до секти адептів ФП; хтось стверджує що так вони намагалися позбутися циклічних залежностей при серіалізації (як це їм допомогло?); хтось каже, що protobuf першої версії працював тільки з mutable-класами, а дурні люди стріляли при цьому собі в ноги.
Можна було б сказати, що на смак і колір архітектури різні, але при такому дизайні для того щоб отримати байтове уявлення вам потрібно створити Bean.Builder, заповнити його, потім викликати метод build (). Для того щоб змінити бін, потрібно створити його білдер через метод toBuilder (), змінити значення і потім викликати build ().
І все нічого, тільки при кожному виклику build () і toBuilder () відбувається копіювання всіх полів з екземпляра одного класу в екземпляр іншого класу. Якщо все що вам потрібно - це отримати байтовий масив для серіалізації або змінити пару полів, то це копіювання сильно заважає. Крім того, в цьому методі схоже (я зараз з'ясовую) присутня багаторічна проблема, яка призводить до того, що копіюються навіть ті поля, значення яких навіть не були встановлені в білдері.
Ви навряд чи помітите це, якщо у вас дрібні біни з невеликою кількістю полів. Однак мені у спадок дісталася ціла бібліотека, кількість полів в окремих бінах якої сягала трьох сотень. Виклик методу build () для такого біна займає близько 50мкс в моєму випадку, що дозволяє обробити не більше 20000 бінів в секунду.
Іронія в тому, що в моєму випадку інші тести показують, що збереження подібного біна через Jackson/JSON в два-три рази швидше (у разі якщо проініціалізовані не всі поля і більшу частину полів можна не серіалізувати).
3. Втрата посилання
Якщо у вас є графоподібна структура, в якій біни посилаються один на одного, то у мене для вас погана новина - protobuf не підходить для серіалізації таких структур. Він зберігає біни за значенням, не відстежуючи факт того, що цей бін вже був серіалізований.
Іншими словами якщо у вас є bean1 і bean2, які посилаються один на одного, то при серіалізації-десеріалізації ви отримаєте bean1, який посилається на бін bean3; а також bean2, який посилається на бін bean4.
Упевнений, що в переважній більшості випадків така функціональність не потрібна і навіть протипоказана в простих DTO. Однак ця проблема проявляється і в більш природних випадках. Наприклад, якщо ви додасте один і той же бін в збірку 100 разів, він буде збережений всі 100 разів, а не самотні. Або ви серіалізуєте список лотів (товарів). Кожен з лотів являє собою дрібний бін з описом (кількість, ціна, дата), а також з посиланням на розвісистий опис продукту. Якщо зберігати в лоб, то опис продукту буде серіалізовано стільки разів, скільки існує лотів, навіть якщо всі лоти вказують на один і той же продукт. Вирішенням цієї проблеми буде окреме збереження продуктів у вигляді словника, але це вже додаткові дії - і при серіалізації, і при десереалізації.
Описана поведінка є абсолютно очікуваною і природною для текстових форматів типу JSON/XML. Але ось від бінарного формату очікуєш дещо іншого, тим більше, що штатна серіалізація Java в цьому плані працює рівно так, як і очікується.
4. Компактність під питанням
Існує думка, що protobuf є суперкомпактним форматом. Насправді компактність серіалізації забезпечується всього кількома факторами:
- Реалізовані і використовуються за замовчуванням типи var-int і var-long - як знакові, так і для беззнакові. Поля таких типів дозволяють заощадити місце, у разі якщо реальні значення в цих полях невеликі. Іншими словами, якщо розподіл по всьому діапазону значень нерівномірно і основна маса значень сконцентрована близько нуля. Наприклад, при збереженні значення 23L воно займе всього лише один байт замість восьми. Але з іншого боку, якщо ви збережете Long.MAX_VALUE, то таке значення займе вже всі десять байт.
- Замість повних метаданих (імен полів) зберігаються лише числові ідентифікатори полів. Власне заради цього ми і вказуємо ідентифікатори в proto-файлах і саме тому вони повинні бути унікальними і незмінними. Ідентифікатори зберігаються в полях типу var-int, тому є сенс починати їх саме з 1.
- Не зберігаються поля, для яких не було параметрів значень через сіттери. Для цього protobuf при встановленні значень через сетери також встановлює в окремій бітовій масці відповідний полю біт. Тут не обійшлося без проблем, оскільки при встановленні значення 0L такий біт все одно взводиться, хоча очевидно, що зберігати таке поле немає необхідності, оскільки в більшості мов 0 - це значення за замовчуванням. Наприклад, Jackson при серіалізації, коли вирішує серіалізувати це поле чи ні, дивиться на безпосереднє значення поля.
І все це чудово, але ось тільки якщо ми подивимося на байтове уявлення DTO середнього (але за всіх говорити не буду) сучасного сервісу, то побачимо, що більшу частину місця будуть займати рядки, а не примітиви. Це логіни, імена, назви, описи, коментарі, URI ресурсів, причому часто в декількох варіантах (дозволах для картинок). Що робить protobuf з рядками? Загалом нічого особливого - просто зберігає їх у потік у вигляді UTF-8. При цьому пам'ятаємо, що національні символи в UTF-8 займають по два, а то й по три байти.
Припустимо, додаток генерує такі дані, що в процентному співвідношенні в байтовому поданні рядки займають 75%, а примітиви займають 25%. У такому випадку, навіть якщо наш алгоритм оптимізації примітивів скоротить необхідне для їх зберігання місце до нуля, ми отримаємо економію всього в 1/4.
У деяких випадках компактність серіалізація є досить критичною, наприклад для мобільних додатків в умовах поганого/дорогого зв'язку. У таких випадках без додаткової компресії поверх protobuf не обійтися, інакше ми будемо даремно ганяти надлишкові дані в рядках. Але тоді раптом з'ясовується, що аналогічний комплект [JSON + GZIP] при серіалізації дає несильно більший розмір порівняно з [PROTOBUF + ZIP]. Звичайно, варіант [JSON + GZIP] буде також споживати більше ресурсів CPU при роботі, але в той же час, він часто також є ще й більш зручним.
protoc v3
У protobuf третьої версії з'явився новий режим генерації «Java Nano». Його ще немає в документації, а runtime цього режиму ще в стадії alpha, але користуватися ним можна вже зараз за допомогою перемикача «» --javanano _ out «».
У цьому режимі генератор створює анемічні біни з публічними полями (без сетерів і без гетерів) і з простими методами серіалізації. Зайвого копіювання немає, тому проблема # 2 вирішена. Інші проблеми залишилися, більше того за наявності циклічних посилань серіалізатор випадає в StackOverflowError.
Прийняття рішення про серіалізацію кожного поля проводиться на підставі його поточного значення, а не окремої бітової маски, що дещо спрощує самі біни.
protostuff
Альтернативна реалізація протоколу protobuf. У бою не відчував, але на перший погляд виглядає дуже добротно. Не потребує proto-файлів (однак вміє з ними працювати, якщо це необхідно), тому вирішено проблеми # 0, # 1 і # 2. Крім цього вміє зберігати у свій власний формат, а також у JSON, XML і YAML. Також цікавою є можливість переганяти дані з одного формату в інший потоком, без необхідності повної десеріалізації в проміжний бін.
На жаль, якщо віддати на серіалізацію звичайний POJO без схеми, анотацій і без proto-файлів (так теж можна), protostuff буде зберігати всі поля об'єкта поспіль, в незалежності від того були вони проініціалізовані значенням чи ні, а це знову сильно б'є по компактності у випадку, коли заповнені не всі поля. Але наскільки я бачу, таку поведінку при бажанні можна підправити, перевизначивши пару класів.
