Привет!
Недавно в рамках одного из проектов на стеке KMP, Ktor и Kotlin Serialization мы с командой решили провести эксперимент и определить возможность и целесобразность минификации тел запросов / ответов на Json.
Да, мы знаем про GraphQL, Protobuf и др., но в нашем случае имел место необузданный интерес наколхозить такое решение. И при всей его наивности удалось сократить средний размер итоговых джсонов (после всех внутренних оптимизаций) на 15–20%.
Вводные данные:
Большое приложение на KMP с таргетами iOS, Android, Web и Desktop;
Фронтенд и бэкенд написаны на Ktor и швыряются Json'ами по HTTP;
Монорепа и по сути один KMP проект.
Структура проекта:
frontend‑app — модуль с реализацией фронтенда на KMP;
backend‑app — модуль с реализацией бэкенда на Kotlin + Ktor.
http‑model — модуль с моделями, общими между бэкендом и фронтендом; чистый Kotlin и Kotlin Serialization.
Задача
У нас есть списочный Json, который весит 4 754 байт. По сути единственной сущностью списка является следующий объект:
{
"interestId": "f5092d67-1ba7-4e7a-8eed-75ba2726c242",
"title": "Антенны дальнего действия",
"imageUrl": null,
"category": {
"interestCategoryId": "6ac16b9f-9d2b-4bd4-b2aa-a5d35d727ecd",
"title": "Аналог",
"interestCategoryOrder": 0
},
"interestOrder": 0
}
Объект и его структура (включая ключи) дублируются n кол‑во раз, где n — размер списка, что логично для Json формата.
А вот наша модель Interest, общая между бэкендом и фронтендом, лежащая в модуле common‑model:
@Serializable
data class Interest(
val interestId: Uuid,
val title: String,
val imageUrl: String?,
val category: InterestCategory,
val interestOrder: Int
)
Повторюсь, модель общая, т. е. бэкенд и фронтенд сериализуют и десериализуют эту модель по идентичным правилам. Кроме того, в нашем кейсе модель даже находится в рамках одного проекта.
Рождается предположение: «А зачем нам сохранять читаемость Json? Для кого?»
Соответственно, обновленная модель:
@Serializable
data class Interest(
@SerialName("iid")
val interestId: Uuid,
@SerialName("t")
val title: String,
@SerialName("iu")
val imageUrl: String?,
@SerialName("c")
val category: InterestCategory, // В InterestCategory аналогично
@SerialName("io")
val interestOrder: Int
)
Обновленный объект Json:
{
"iid": "f5092d67-1ba7-4e7a-8eed-75ba2726c242",
"t": "Антенны дальнего действия",
"iu": null,
"c": {
"ici": "6ac16b9f-9d2b-4bd4-b2aa-a5d35d727ecd",
"t": "Аналог",
"ico": 0
},
"io": 0
}
Итоговый вес изначального Json'а с минифицированным объектом — 3 890 байт, т. е. ~ 80% от исходного. 20% веса банально занимал нейминг ключей. А ведь зависимость ~ O(nk), где n — размер списка, а k — вложенность объекта списка.
Какие могут быть проблемы?
Единственное за чем необходимо было следить, чтобы в рамках одной модели не было двух идентичных значений @SerialName.
При грамотном версионировании и пряморуком деливери, никаких других проблем не будет.
Использовали бы мы это в проде?
Уже использовали в рамках того же проекта. Работает хорошо, на метрики повлияло в позитивном ключе. В каких‑то местах менее существенно, в каких‑то ощутимо.
Для нас это был безболезненный и дешёвый вариант: на всё про всё ушла ~ 1 человеко‑ночь под пивом, в то время как банальная миграция на Protobuf или GraphQL была бы на порядок сложнее, запутаннее и менее привлекательной для заказчиков.
Внимание!
С нашим мультиплатформенным стеком это действительно было неплохим решеним, однако если, скажем, Ваш проект состоит из нескольких команд: веба на JS, отдельно нативных мобилок и отдельно бэка на, например, Java, нужно серьёзно задуматься.
Стоят ли эти 15–20% прироста производительности написания очень качественной документации и спецификации и постоянного синка нечитаемого нейминга в случае каких‑то изменений?
Моё мнение — не стоит. Зависит от масштаба проекта, команды и радикальности руководства.