Версионирование моделей сериализованных в JSON и Protobuf
Сериализовать модели в JSON и сохранять их в базе данных — это довольно распространённая практика. Она позволяет легко сохранять сложные объекты, не заботясь о структуре таблиц и связях между ними. Однако, когда приходит время изменять структуру модели, возникает вопрос: как сохранить совместимость с уже сохранёнными данными? В этой статье я расскажу об особенностях при работе с JSON-сериализованными моделями на примере кода на Go.
Явное версионирование
Наверное самым простым и очевидным способом будет ввести в вашей системе некую версию модели и на основе нее явно выбирать структуру в которую десериализовывать модель. И такой прямолинейный подход будет работать, однако довольно быстро приведет к засорению кода лишними проверками и условиями. Поэтому хотелось бы обойтись (пока это возможно) эволюцией одной структуры.
Неявное версионирование
Допустим, у нас есть простая модель User:
// BadUserV1 — старая версия структуры без поля Email.
// При чтении JSON с дополнительными полями они игнорируются (теряются).
type BadUserV1 struct {
Name string `json:"name"`
Age int `json:"age"`
}
На практике такие модели часто сериализуют в JSON и сохраняют в базу данных — например, в колонку типа JsonDocument
в YDB или в jsonb в PostgreSQL. Для наглядности, в примерах ниже мы будем сохранять данные в файл, но проблема
и решения одинаково применимы и к базам данных.
Сериализация и десериализация тривиальны:
func save[T any](user T) error {
data, err := json.Marshal(user)
if err != nil {
return fmt.Errorf("marshal user: %w", err)
}
return os.WriteFile(filename, data, 0644)
}
func read[T any]() (T, error) {
data, err := os.ReadFile(filename)
if err != nil {
var zero T
return zero, fmt.Errorf("read file: %w", err)
}
var user T
if err := json.Unmarshal(data, &user); err != nil {
var zero T
return zero, fmt.Errorf("unmarshal user: %w", err)
}
return user, nil
}
Изменение модели
Самое интересное начинается, когда нам нужно изменить структуру модели. Например, мы решили добавить новое поле Email.
Для наглядности, давайте создадим новую версию модели UserV2, которая будет содержать новое поле Email:
// UserV2 — новая версия структуры с добавленным полем Email.
type UserV2 struct {
Name string `json:"name"`
Age int `json:"age"`
Email string `json:"email"` // новое поле
}
Если наш новый код будет учитывать, что поле Email может отсутствовать, то проблем в новой версии быть не должно.
Мы также можем написать тесты, которые проверят, что в новой версии приложения всё будет работать отлично.
Проблема может возникнуть, если у нас более одного экземпляра приложения и мы для обеспечения непрерывной работы
хотим обновлять экземпляры последовательно. В этом случае, может возникнуть ситуация, когда в одном запросе пользователь
добавил новое поле Email, а в другом запросе, который обрабатывает старый экземпляр приложения, это поле отсутствует в модели.
Чтобы симулировать эту ситуацию, можно написать следующий код:
user := UserV2{
Name: "Alice",
Age: 30,
Email: "alice@example.com",
}
// Сохраняем V2 (содержит все поля: name, age, email).
save(user)
fmt.Println("1. UserV2 сохранён в файл (с email)")
// Читаем как V1 (BadUserV1 игнорирует неизвестное поле email).
uV1, _ := read[BadUserV1]()
fmt.Printf("2. Чтение как V1 (BadUserV1): %+v\n", uV1)
// Проверяем, что данные на диске полные (содержат email).
uV2, _ := read[UserV2]()
fmt.Printf("3. На диске всё ещё: %+v\n", uV2)
// Сохраняем V1 обратно (без email) — перезаписываем файл.
save(uV1)
fmt.Println("4. Сохранили V1 обратно в файл (без email)")
// Читаем снова как V2 — email больше не восстановится.
uV2, _ = read[UserV2]()
fmt.Printf("5. Чтение как V2 после сохранения V1: %+v\n", uV2)
И результат будет следующим:
1. UserV2 сохранён в файл (с email)
2. Чтение как V1 (BadUserV1): {Name:Alice Age:30}
3. На диске всё ещё: {Name:Alice Age:30 Email:alice@example.com}
4. Сохранили V1 обратно в файл (без email)
5. Чтение как V2 после сохранения V1: {Name:Alice Age:30 Email:}
Email потерян НАВСЕГДА! Данные не восстановить.
Как видно из вывода, при чтении данных с помощью старой версии модели BadUserV1, поле Email отсутствует, и его
значение по умолчанию — пустая строка. А значит, при сохранении данных с помощью старой версии модели BadUserV1,
поле Email теряется, и при последующем чтении с помощью новой версии модели UserV2, поле Email снова будет пустой
строкой.
JSON v2
Для решения этой проблемы можно использовать пакет encoding/json/v2, который был представлен в Go 1.25 в качестве
экспериментальной версии. Этот пакет поддерживает более гибкую десериализацию JSON, которая позволяет сохранять неизвестные поля в виде
поля jsontext.Value. Это позволяет нам сохранять все поля, даже если они не определены в структуре модели.
Вот как можно изменить структуру модели BadUserV1, чтобы использовать новый пакет encoding/json/v2:
// GoodUserV1 — улучшенная версия структуры.
// Использует поле Unknown с тегом `json:",unknown"` для сохранения неизвестных полей.
type GoodUserV1 struct {
Name string `json:"name"`
Age int `json:"age"`
Unknown jsontext.Value `json:",unknown"` // для сохранения неизвестных полей
}
Теперь, при десериализации JSON, все неизвестные поля будут сохраняться в поле Unknown, и мы сможем их использовать
при сохранении данных обратно. Таким образом, мы можем обеспечить совместимость между разными версиями модели и
избежать потери данных при изменении структуры модели.
// Снова сохраняем свежие V2 данные.
save(user)
fmt.Println("1. Пользователь V2 сохранён в файл (с email)")
// Читаем как GoodUserV1, которая сохраняет неизвестные поля в поле Unknown.
uV1WithUnknown, _ := read[GoodUserV1]()
fmt.Printf("2. Чтение как V1 (GoodUserV1): %+v\n", uV1WithUnknown)
// Изменяем Age и сохраняем обратно.
uV1WithUnknown.Age = 31
save(uV1WithUnknown)
fmt.Println("3. Сохранили V1 обратно в файл (изменили Age на 31)")
// Читаем как V2 — email восстановился из Unknown полей!
uV2, _ := read[UserV2]()
fmt.Printf("4. Чтение как V2 после сохранения V1: %+v\n", uV2)
Результат:
GOEXPERIMENT=jsonv2 go run ./main.go
1. Пользователь V2 сохранён в файл (с email)
2. Чтение как V1 (GoodUserV1): {Name:Alice Age:30 Unknown:{"email":"alice@example.com"}}
✓ Email сохранён в поле Unknown!
3. Сохранили V1 обратно в файл (изменили Age на 31)
Неизвестные поля сохранены в файл!
4. Чтение как V2 после сохранения V1: {Name:Alice Age:31 Email:alice@example.com}
✓ Email на месте! Age обновлён! Все данные сохранены.
На текущий момент encoding/json/jsontext находится в экспериментальной стадии и доступен только при использовании
переменной окружения GOEXPERIMENT=jsonv2. Однако, в будущем, когда этот пакет будет стабилизирован, он может стать
стандартным инструментом для работы с JSON в Go и значительно упростить процесс версионирования моделей.
Protobuf и Buf
Ручное описание моделей и их сериализация в JSON может быть трудоемким и подверженным ошибкам процессом. Например, если
мы переименуем поле Name в FullName, то при десериализации JSON, мы потеряем данные, которые были
сохранены в поле Name. Чтобы избежать таких проблем, можно использовать более формальный способ описания моделей,
например, с помощью Protobuf. Protobuf позволяет нам описывать структуру данных в виде схемы, которая может быть
скомпилирована в код на различных языках программирования. Это обеспечивает более строгую типизацию и позволяет легко
управлять версиями моделей.
Для работы с Protobuf в Go, можно использовать инструмент Buf, который позволяет нам описывать схемы Protobuf и генерировать код на Go. С помощью Buf, мы можем легко управлять версиями моделей и обеспечивать совместимость между ними.
К сожалению, Protobuf не поддерживает сохранение неизвестных полей, при сериализации и десериализации данных в JSON. Поэтому, чтобы обеспечить совместимость между разными версиями модели, нам нужно использовать нативный Protobuf формат для хранения данных, а не JSON. Это позволит нам сохранять все поля, даже если они не определены в структуре модели, и обеспечивать совместимость между разными версиями модели без потери данных.
В базе данных для хранения данных в бинарном формате Protobuf можно использовать колонку типа Bytes:
CREATE TABLE users (
id Uint64,
user_properties Bytes,
PRIMARY KEY (id)
);
Для того чтобы установить Buf, можно использовать следующую команду:
brew install buf
После установки Buf, нам нужно создать файл buf.yaml, который будет содержать настройки для нашего проекта:
version: v2
modules:
- path: .
name: buf.build/nikthespirit/serialization_example
Для того чтобы сгенерировать код на Go из схемы Protobuf, нам нужно создать файл buf.gen.yaml, который будет содержать
настройки для генерации кода:
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: gen
opt: paths=source_relative
Затем, мы можем описать нашу модель User в файле user.proto:
syntax = "proto3";
package models.v1;
option go_package = "serialization_example/models/v1";
message User {
string name = 1;
int32 age = 2;
// string email = 3;
}
Теперь, мы можем сгенерировать код на Go из схемы Protobuf, используя следующую команду:
buf generate
После генерации кода, мы можем использовать сгенерированные структуры для сериализации и десериализации данных в Protobuf формате, что обеспечит совместимость между разными версиями модели без потери данных.
Записав данные в бинарном формате Protobuf с полем Email, и прочитав их с помощью старой версии кода, мы увидим, что поле Email
будет сохранено в виде неизвестного поля в структуре Protobuf.
read user: {Name:Alice Age:30 unknownFields:[26 17 97 108 105 99 101 64 101 120 97 109 112 108 101 46 99 111 109]}
Мы можем даже обновить одно из полей в данных, например Age на 31, и сохранить данные обратно, не потеряв при этом значение поля Email:
uV1, _ := readUser(filename)
fmt.Printf("read user: %+v\n", uV1)
uV1.Age = 31
saveUser(filename, uV1)
fmt.Println("user updated successfully")
uV1, _ = readUser(filename)
fmt.Printf("read user after update: %+v\n", uV1)
read user: name:"Alice" age:30 3:"alice@example.com"
user updated successfully
read user after update: name:"Alice" age:31 3:"alice@example.com"
Детектирование нарушения совместимости
Buf предоставляет инструмент buf breaking, который позволяет нам проверять совместимость между разными версиями схем Protobuf.
Мы можем использовать этот инструмент для того, чтобы убедиться, что наши изменения в модели не нарушают совместимость с
уже сохранёнными данными.
Например, если мы добавим новое поле email в схему Protobuf, и запустим buf breaking, то мы ничего не увидим, так
как добавление нового поля не нарушает совместимость:
Однако, если мы удалим поле age из схемы Protobuf, и запустим buf breaking, то мы увидим, что это изменение нарушает
совместимость:
models/v1/user.proto:7:1:Previously present field "2" with name "age" on message "User" was deleted.
Переименование поля name на full_name также нарушит совместимость, так как при десериализации данных из JSON формата,
мы потеряем данные, которые были сохранены в поле name:
models/v1/user.proto:8:5:Field "1" with name "full_name" on message "User" changed option "json_name" from "name" to "fullName".
models/v1/user.proto:8:12:Field "1" on message "User" changed name from "name" to "full_name".
Buf определяет несколько уровней совместимости для проверки изменений в схемах Protobuf. Эти уровни в порядке строгости, от самого строгого:
FILE: Уровень по умолчанию. Этот уровень проверяет изменения, которые могут нарушить сгенерированный код на уровне файлов. Например, в некоторых языках программирования, таких как Python, перемещение кода между файлами может нарушить совместимость, тогда как в других языках, таких как Go, это может быть безопасно.PACKAGE: Этот уровень проверяет изменения, которые могут нарушить сгенерированный код на уровне пакетов. Он обнаруживает изменения, которые нарушают сгенерированные заглушки, но только учитывая изменения на уровне пакетов.WIRE_JSON: Этот уровень проверяет изменения, которые могут повлиять на сериализацию данных в JSON формате. Поскольку JSON является широко используемым форматом, рекомендуется использовать этот уровень в качестве минимального уровня проверки совместимости.WIRE: Этот уровень проверяет только изменения, которые могут повлиять на сериализацию данных в бинарном формате.
FILE и PACKAGE
Категории FILE и PACKAGE защищают совместимость в сгенерированном коде. Например, удаление перечисления (enum) или
сообщения часто приводит к удалению соответствующего типа в сгенерированном коде. Любой код, который ссылается на это
enum или сообщение, затем перестает компилироваться.
В качестве примера представьте, что у вас есть перечисление Arena и вы помечаете ARENA_FOO как устаревшее:
enum Arena {
ARENA_UNSPECIFIED = 0;
ARENA_FOO = 1 [deprecated = true];
ARENA_BAR = 2;
}
Позже вы удаляете это поле, потому что оно больше не поддерживается сервером:
enum Arena {
ARENA_UNSPECIFIED = 0;
ARENA_BAR = 2;
}
Это изменение полностью совместимо на уровне протокола (wire compatible), но весь код, который ссылался на ARENA_FOO,
перестанет компилироваться:
resp, err := service.Visit(
ctx,
&visitv1.VisitRequest{
Arena: visitv1.Arena_ARENA_FOO, // !!!
},
)
В некоторых случаях это желательно, но чаще всего вы делитесь своими .proto файлами или сгенерированным кодом с
клиентами, которых вы не контролируете. Вы должны выбрать проверку на наличие breaking changes уровней
FILE или PACKAGE, если хотите знать, когда вы сломаете код вашего клиента.
Хотя эти правила зависят от генератора кода, вам следует использовать FILE для защиты всех генерируемых языков.
FILE абсолютно необходим для C++ и Python.
Вы можете использовать PACKAGE для защиты языков, которые менее чувствительны к перемещению типов между файлами
внутри одного пакета, таких как Go.
WIRE и WIRE_JSON
WIRE и WIRE_JSON обнаруживают поломку закодированных сообщений. Например:
- Изменение опционального поля на обязательное. Старые сообщения, в которых не закодировано это поле, не смогут быть прочитаны с новым определением.
- Резервирование удаленных типов, повторное использование которых в будущем может вызвать несовместимость на уровне протокола.
WIRE и WIRE_JSON не проверяют поломку в сгенерированном исходном коде. Это выгодно, когда:
- Вы контролируете всех клиентов вашего сервиса. Вы все равно это исправите, если что-то сломается.
- Вы хотите, чтобы сборка вашего клиента ломалась, вместо того чтобы получать ошибки во время выполнения. (Это предполагает, что ваши клиенты одинаково рады немедленно бросить свои дела, чтобы починить ваш сервис.)
- Все ваши клиенты находятся в монорепозитории. Вы хотите определить, кто зависит от устаревших функций, с помощью сломанной сборки, а не во время выполнения.
- Вы сами являетесь своим клиентом. Например, вы пытаетесь обнаружить проблемы с чтением закодированных в Protobuf сообщений из старых версий вашей программы, которые были сохранены на диске или в другом энергонезависимом хранилище.
Использование WIRE_JSON вместо WIRE безопаснее, потому что JSON-кодировка Protobuf ломается при изменении имен полей.
Используйте WIRE_JSON, если вы используете Connect, gRPC-Gateway или gRPC JSON.
Используйте менее строгий WIRE, когда вы можете гарантировать, что декодируются только бинарно закодированные сообщения.
Например, как в нашем примере с базой данных, где мы сохраняем данные в бинарном формате Protobuf, а не в JSON.
Подробнее посмотреть какие из правил относятся к каждому уровню можно в официальной документации Buf.
Заключение
Хранение JSON-сериализованных моделей в базе данных — удобный подход, но он требует осторожности при изменении структуры данных. Основные выводы:
- Стандартный
encoding/jsonне сохраняет неизвестные поля, что может привести к потере данных при rolling-обновлениях сервиса. - Экспериментальный
encoding/json/v2решает эту проблему с помощью тегаjson:",unknown", позволяя старой версии кода прозрачно сохранять поля, которые она не знает. - Protobuf изначально поддерживает сохранение неизвестных полей в бинарном формате, что делает его надёжным выбором для версионирования моделей. При этом важно хранить данные именно в бинарном формате Protobuf, а не в JSON, чтобы воспользоваться этим преимуществом.
- Buf помогает формализовать процесс управления версиями схем: инструмент
buf breakingзаранее предупредит о несовместимых изменениях на нужном уровне — будь то совместимость на уровне протокола (WIRE,WIRE_JSON) или на уровне сгенерированного кода (FILE,PACKAGE).
Выбор подхода зависит от требований проекта. Если вы только начинаете и хотите минимальных усилий — следите за совместимостью JSON-модели вручную и рассмотрите переход на encoding/json/v2, когда он станет стабильным. Если надёжность и строгость важнее простоты — Protobuf с Buf станет отличным выбором, обеспечивая как сохранность данных, так и автоматический контроль совместимости схем.