Динамическое изменение схемы JSON в Go с помощью gob

Значимо изменить маршализацию структуры в json можно только через метод MarshalJSON(), написав там полную реализацию маршализации. Как именно? На это документация Go ни ответов, ни рекомендаций не даёт, предоставляя, так сказать, полную свободу. А как воспользоваться этой свободой так, чтобы не нагородить кучу костылей, перенеся всю логику в MarshalJSON(), и потом не перепиливать эту бедную постоянно разрастающуюся функцию при очередных кастомизациях json?


Решение на самом деле простое:


  1. Будь честен (честна).

(Второго пункта не будет, первого хватит.)


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


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


Исходная задача — получить такие-то JSON структуры каких-то утверждённых форматов. В исходной задаче про костыли ничего не сказано. Сказано про разные структуры данных. А у нас для хранения этих данных используется один и тот же тип данных (struct). Таким образом наша единая сущность должна иметь несколько представлений. Вот мы и получили правильную интерпретацию задачи.


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


Итак, у нас появляется ещё одна сущность — представление.


И давайте уже к примерам и, собственно, к коду.


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


Итак, эволюция нашей модели книги дошла до такого безобразия:


type Book struct {
	Id               int64
	Title            string
	Description      string
	Partner2Title    string
	Price            int64
	PromoPrice       int64
	PromoDescription string
	Partner1Price    int64
	Partner2Price    int64
	UpdatedAt        time.Time
	CreatedAt        time.Time
	view             BookView
}

Последний атрибут (view) — неэкспортируемый (приватный), он не является частью данных, а является местом хранения того самого представления, в котором и содержится информация, в какой json сворачиваться объекту. В простейшем случае это просто interface{}


type BookView interface{}

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


Теперь давайте опишем наши представления и саму функцию


type SiteBookView struct {
	Id          int64  `json:"sku"`
	Title       string `json:"title"`
	Description string `json:"description"`
	Price       int64  `json:"price"`
}

type Partner1BookView struct {
	Id            int64  `json:"bid"`
	Title         string `json:"title"`
	Partner1Price int64  `json:"price"`
}

type Partner2BookView struct {
	Id            int64  `json:"id"`
	Partner2Title string `json:"title"`
	Description   string `json:"description"`
	Partner2Price int64  `json:"price"`
}

type PromoBookView struct {
	Id               int64  `json:"ref"`
	Title            string `json:"title"`
	Description      string `json:"description"`
	PromoPrice       int64  `json:"price"`
	PromoDescription string `json:"promo,omitempty"`
}

func (b Book) MarshalJSON() (data []byte, err error) {
	//сначала проверяем, установлено ли представление
	if b.view == nil {
		//если нет, то устанавливаем представление по умолчанию
		b.SetDefaultView()
	}
	//затем создаём буфер для перегона значений в представление
	var buff bytes.Buffer
	// создаём отправителя данных, который будет кодировать в некий бинарный формат и складывать в буфер
	enc := gob.NewEncoder(&buff)
	//создаём приёмник данных, который будет декодировать из бинарные данные, взятые из буфера
	dec := gob.NewDecoder(&buff)
	//отправляем данные из базовой структуры
	err = enc.Encode(b)
	if err != nil {
		return
	}
	//принимаем их в наше отображение
	err = dec.Decode(b.view)
	if err != nil {
		return
	}
	//маршализуем отображение стандартным способом
	return json.Marshal(b.view)
}

Отправка и приём данных между структурами происходит по принципу совпадения названий атрибутов, при этом типы не обязательно должны точно совпадать, можно, например, отправлять из int64, а принимать в int, но не в uint.


Последним шагом делаем маршализацию установленного представления с данными, используя всю мощь стандартного описания через теги json (`json:"promo,omitempty"`)


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


func init() {
	gob.Register(Book{})
	gob.Register(SiteBookView{})
	gob.Register(Partner1BookView{})
	gob.Register(Partner2BookView{})
	gob.Register(PromoBookView{})
}

Полный код модели:


Скрытый текст
import (
	"bytes"
	"encoding/gob"
	"encoding/json"
	"time"
)

func init() {
	gob.Register(Book{})
	gob.Register(SiteBookView{})
	gob.Register(Partner1BookView{})
	gob.Register(Partner2BookView{})
	gob.Register(PromoBookView{})
}

type BookView interface{}

type Book struct {
	Id               int64
	Title            string
	Description      string
	Partner2Title    string
	Price            int64
	PromoPrice       int64
	PromoDescription string
	Partner1Price    int64
	Partner2Price    int64
	UpdatedAt        time.Time
	CreatedAt        time.Time
	view             BookView
}

type SiteBookView struct {
	Id          int64  `json:"sku"`
	Title       string `json:"title"`
	Description string `json:"description"`
	Price       int64  `json:"price"`
}

type Partner1BookView struct {
	Id            int64  `json:"bid"`
	Title         string `json:"title"`
	Partner1Price int64  `json:"price"`
}

type Partner2BookView struct {
	Id            int64  `json:"id"`
	Partner2Title string `json:"title"`
	Description   string `json:"description"`
	Partner2Price int64  `json:"price"`
}

type PromoBookView struct {
	Id               int64  `json:"ref"`
	Title            string `json:"title"`
	Description      string `json:"description"`
	PromoPrice       int64  `json:"price"`
	PromoDescription string `json:"promo,omitempty"`
}

func (b *Book) SetDefaultView() {
	b.SetSiteView()
}

func (b *Book) SetSiteView() {
	b.view = &SiteBookView{}
}

func (b *Book) SetPartner1View() {
	b.view = &Partner1BookView{}
}

func (b *Book) SetPartner2View() {
	b.view = &Partner2BookView{}
}

func (b *Book) SetPromoView() {
	b.view = &PromoBookView{}
}

func (b Book) MarshalJSON() (data []byte, err error) {
	if b.view == nil {
		b.SetDefaultView()
	}
	var buff bytes.Buffer
	enc := gob.NewEncoder(&buff)
	dec := gob.NewDecoder(&buff)
	err = enc.Encode(b)
	if err != nil {
		return
	}
	err = dec.Decode(b.view)
	if err != nil {
		return
	}
	return json.Marshal(b.view)
}


В контролере будет примерно такой код:


func GetBooksForPartner2(ctx *gin.Context) {
    books := LoadBooksForPartner2()

    for i := range books {
        books[i].SetPartner2View()
    }

    ctx.JSON(http.StatusOK, books)
}

Теперь для «ещё одного» изменения json достаточно просто добавить ещё одно представление и не забыть зарегистрировать его в init().

Комментарии 17

  • НЛО прилетело и опубликовало эту надпись здесь
      +3

      Ну про что может быть первый комментарий к статье про Go? Правильно! Про Rust!

        +1

        Можно через interface{}, но код будет выглядеть более громоздко из-за type assertion..

        • НЛО прилетело и опубликовало эту надпись здесь
            0
            Что вы называете «суммированим типов»?

            В Go вы просто преобразовываете intrface{} (он по сути объединяет вообще все типы языка) в тип который ожидаете и проверяете ошибку этого преобразования. Если без ошибки — значит все ок — пользуйтесь полученным типом данных. Ну а ошибка — думайте как обработать.

            Именно из за обработки ошибок преобразований код такого решения довольно сильно разрастается, но иначе и нельзя.
            • НЛО прилетело и опубликовало эту надпись здесь
                0
                Ну а какая разница сумма-тип упадет при «натягивании на ненатягуемое» или assertion ошибку даст, когда вы из интерфейса попробуете «получить неполучаемое»?
                • НЛО прилетело и опубликовало эту надпись здесь
            0
            лучше сразу явно в map[string]interface{}
              0
              А можно пример кода, как именно предлагается сделать?
                0
                n0madic дал ссылку с примером, вместо interface{} я бы посоветовал явно использовать map[string]interface{}, чтобы избавиться от type assertion, конечно это не будет работать в общем случае. Вообще, натягивание на interface{} или map[string]interface{} или []map[string]interface{} в обычном коде это imho антипаттерн и ведет к уменьшению надежности кода вообще. Подойдет для промежуточного хранения данных перед например общей json schema валидацией. Но доставать данные с type assertion и их использование это сомнительное удовольствие.

                b := []byte(`{
                   "k1" : "v1", 
                   "k3" : 10,
                   result:["v4",12.3,{"k11" : "v11", "k22" : "v22"}]
                }`)
                var f map[string]interface{}
                err := json.Unmarshal(b, &f)
                  0
                  Здесь обратная задача, то есть, не в json, а обратно. При этом в условиях задано, что структура неизвестная.
                  map[string]interface{} — это просто общий интерфейс объекта json. Естественно, что в него декодируется любой объект из json строки.
                  И да, конечно, переменную типа map[string]interface{} можно создать в коде и потом её кодировать в json, и это было бы решением прямой задачи. Собственно, его код я и спрашивал. Просто интересно, чем конкретно оно лучше. Те решения, что я встречал (через map[string]interface{}), лучшими бы я не назвал.
          0

          А что со скоростью?

            0
            Спасибо, красивое решение… хотя и пришлось вчитаться несколько раз чтобы понять суть задачи и способ ее решения. По сути gob тут простой пересборщик из большой структуры в меньшую. И должен по идее работать быстро.
            Но было бы здорово попробовать решить эту задачу «как-то неправильно» и бенчами прогнать в паре с этим решением.
              0
              В чем проблема взять и явно конвертировать исходную обобщённую структуру в нужный набор урезанных и не мудрить с аттрибутами?
              В том месте, где вы принимаете решение какое представление внедрить почему просто не вернуть признак желаемого представления и не сделать фабричный метод который вернет нужный вам JSON? Сериализовать в gob, чтобы десериализовать из gob, чтобы сериализовать в JSON… Как-то такие решения должны настораживать их авторов
                0
                Если я правильно понял, то Вы предлагаете использовать дополнительный приватный атрибут типа
                toJson           func() (data []byte, err error)

                И именно в него положить всю логику преобразования в json.
                При этом для каждого отображения нужно иметь свою функцию. Теперь вопрос: а что будет в тех функциях? типа такого:
                
                        return json.Marshal(&struct {
                            Id               int64  `json:"ref"`
                            Title            string `json:"title"`
                            Description      string `json:"description"`
                            PromoPrice       int64  `json:"price"`
                            PromoDescription string `json:"promo,omitempty"`
                            Links            Links  `json:"links"`
                        }{
                            Id:               b.Id,
                            Title:            b.Title,
                            Description:      b.Description,
                            PromoPrice:       b.PromoPrice,
                            PromoDescription: b.PromoDescription,
                            Links:            b.Links,
                        })
                

                Если как по мне, то читабельности и разделения кода становится на порядок меньше, хотя как показывают замеры скорости, это будет быстрее.
                Или имелось в виду совсем другое решение?
                0
                Про скорость и бенчмарки. (в ответ TonyLorencio, Sly_tom_cat и, кончено, всем, кому интересно).
                Главный вопрос тут, конечно, про целесообразность использования gob, и дополнительного кодирования/декодирования, поэтому давайте рассмотрим именно этот момент.
                Вообще, решение конечно более архитектурное, направленное на разделение кода, чтобы при сливании git веток меньше приходилось править конфликты, которые бы были неизбежны, если бы всё решение было внутри функции MarshalJSON.
                Итак, давайте сделаем тоже самое, но без использования gob, а с прямым заполнением структуры. В коде есть закомментированный текст — о нём будет далее.
                book1.go
                package models
                
                import (
                    "encoding/json"
                    "fmt"
                    "time"
                )
                
                const (
                    SiteBook1View = iota
                    Partner1Book1View
                    Partner2Book1View
                    PromoBook1View
                )
                
                func (b *Book1) SetDefaultView() {
                    b.SetSiteView()
                }
                
                func (b *Book1) SetSiteView() {
                    b.view = SiteBook1View
                }
                
                func (b *Book1) SetPartner1View() {
                    b.view = Partner1Book1View
                }
                
                func (b *Book1) SetPartner2View() {
                    b.view = Partner2Book1View
                }
                
                func (b *Book1) SetPromoView() {
                    b.view = PromoBook1View
                }
                
                type Book1 struct {
                    Id               int64
                    Title            string
                    Description      string
                    Partner2Title    string
                    Price            int64
                    PromoPrice       int64
                    PromoDescription string
                    Partner1Price    int64
                    Partner2Price    int64
                    // Links            Links
                    UpdatedAt time.Time
                    CreatedAt time.Time
                    view      int
                }
                
                func (b Book1) MarshalJSON() (data []byte, err error) {
                    if b.view == 0 {
                        b.SetDefaultView()
                    }
                    switch b.view {
                    case SiteBook1View:
                        return json.Marshal(SiteBookView{
                            Id:          b.Id,
                            Title:       b.Title,
                            Description: b.Description,
                            Price:       b.Price,
                            // Links:       b.Links,
                        })
                    case Partner1Book1View:
                        return json.Marshal(Partner1BookView{
                            Id:            b.Id,
                            Title:         b.Title,
                            Partner1Price: b.Partner1Price,
                            // Links:         b.Links,
                        })
                    case Partner2Book1View:
                        return json.Marshal(Partner2BookView{
                            Id:            b.Id,
                            Partner2Title: b.Partner2Title,
                            Description:   b.Description,
                            Partner2Price: b.Partner2Price,
                            // Links:         b.Links,
                        })
                    case PromoBook1View:
                        return json.Marshal(PromoBookView{
                            Id:               b.Id,
                            Title:            b.Title,
                            Description:      b.Description,
                            PromoPrice:       b.PromoPrice,
                            PromoDescription: b.PromoDescription,
                            // Links:            b.Links,
                        })
                    default:
                        err = fmt.Errorf("undefined view")
                        return
                    }
                    return
                }
                
                



                Тест создаёт список из элементов и потом получает json строку. Результаты:
                BenchmarkRun0-8 20000 68701 ns/op
                BenchmarkRun1-8 200000 7177 ns/op

                То есть, использование gob даёт замедление в 10 раз. Да, это несколько больше, чем ожидалось.
                Но это простые типы данных: int64 и string. А что будет, если будет что-то посложнее? Давайте добавим список литературы.
                Код LInks
                type Link struct {
                	Title       string
                	Description string
                	Rate        float64
                }
                
                type Links []Link
                
                type Book struct {
                	Id               int64
                	Title            string
                	Description      string
                	Partner2Title    string
                	Price            int64
                	PromoPrice       int64
                	PromoDescription string
                	Partner1Price    int64
                	Partner2Price    int64
                	Links            Links
                	UpdatedAt time.Time
                	CreatedAt time.Time
                	view      BookView
                }
                type SiteBookView struct {
                	Id          int64  `json:"sku"`
                	Title       string `json:"title"`
                	Description string `json:"description"`
                	Price       int64  `json:"price"`
                	Links       Links  `json:"links"`
                }
                //к остальным также добавим Links с каким-то json всевдонимом
                


                Пусть всегда будет по списку литературу в 100 позиций. Результаты уже не столь плачевны:
                BenchmarkRun0-8 3000 493611 ns/op
                BenchmarkRun1-8 5000 322898 ns/op
                Замедление уже составляет 52%. При более сложных данных может быть и меньше.
                Если вам нужно выводить до 100 элементов, при этом их структура достаточно сложная, то замедление будет не столь критичным. Если это тонны информации, и каждая миллисекунда на счету, то, возможно, то данное решение может быть не очень эффективным.
                Да, это плата за разделение кода, уменьшение риска конфликтов при слиянии git веток, наглядность моделей. Возможно, этого всего можно добиться и без gob, или с ним, но другим, более эффективным решением. Если найду — обязательно поделюсь.

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

                Самое читаемое