Значимо изменить маршализацию структуры в 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().

Комментарии (11)


  1. epishman
    23.04.2019 09:35
    +1

    Кстати, давно хотел гошников спросить — если ли у них возможность распарсить JSON в универсальную динамическую структуру, как это можно сделать в Rust:

    use serde_json::{Result, Value};
    
    let data = r#"
            {
                "name": "John Doe",
                "age": 43,
                "phones": [
                    "+44 1234567",
                    "+44 2345678"
                ]
            }"#;
    
    // Parse the string of data into serde_json::Value.
    let v: Value = serde_json::from_str(data)?;
    
    // Access parts of the data by indexing with square brackets.
    println!("Please call {} at the number {}", v["name"], v["phones"][0]);
    


    1. Shtucer
      23.04.2019 10:16

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


    1. n0madic
      23.04.2019 10:41

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


      1. epishman
        23.04.2019 12:10

        Спасибо! Получается, что в Go нет суммирования типов, например в Rust используется вполне себе статический тип, покрывающий все варианты значений JSON, и это фича языка:

        enum Value {
            Null,
            Bool(bool),
            Number(Number),
            String(String),
            Array(Vec<Value>),
            Object(Map<String, Value>),
        }


        1. Sly_tom_cat
          23.04.2019 12:25

          Что вы называете «суммированим типов»?

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

          Именно из за обработки ошибок преобразований код такого решения довольно сильно разрастается, но иначе и нельзя.


          1. epishman
            23.04.2019 12:56

            Сумма-типом я называю enum со значениями внутри. Простейший вариант реализации nullable-значений это enum Option {None, Some(T)}, то есть в одной и той же переменной может лежать или безличное None, или контейнер Some, внутри которого лежит значение определенного типа. Киллер-фича для прикладного языка. Интерфейсы в Go тоже хорошо, но тут уже полная динамика, совсем без возможности контроля.
            PS
            А сахар в том, что кастовать в коде необязательно, если не боишься паники при ошибке преобразования — компилятор все сделает сам.


            1. Sly_tom_cat
              23.04.2019 13:39

              Ну а какая разница сумма-тип упадет при «натягивании на ненатягуемое» или assertion ошибку даст, когда вы из интерфейса попробуете «получить неполучаемое»?


              1. epishman
                23.04.2019 14:07

                Никакой, проверять все равно надо — либо упадет, либо будет вечно возвращать null, как в приведенном примере.


  1. TonyLorencio
    23.04.2019 10:36

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


  1. Sly_tom_cat
    23.04.2019 12:20

    Спасибо, красивое решение… хотя и пришлось вчитаться несколько раз чтобы понять суть задачи и способ ее решения. По сути gob тут простой пересборщик из большой структуры в меньшую. И должен по идее работать быстро.
    Но было бы здорово попробовать решить эту задачу «как-то неправильно» и бенчами прогнать в паре с этим решением.


  1. wtask
    25.04.2019 01:50

    В чем проблема взять и явно конвертировать исходную обобщённую структуру в нужный набор урезанных и не мудрить с аттрибутами?
    В том месте, где вы принимаете решение какое представление внедрить почему просто не вернуть признак желаемого представления и не сделать фабричный метод который вернет нужный вам JSON? Сериализовать в gob, чтобы десериализовать из gob, чтобы сериализовать в JSON… Как-то такие решения должны настораживать их авторов