Dynamically change JSON schema in Go with gob
Significantly change the marshalization of the structure in json is possible only through the MarshalJSON () method, writing there the full implementation of marshalization. How exactly? Go documentation does not give any answers or recommendations to this, providing, so to speak, complete freedom. And how to take advantage of this freedom so as not to pile up a bunch of crutches, transferring all the logic to MarshalJSON (), and then not overwrite this poor constantly growing function with the next json customizations?
The solution is actually simple:
- Be honest (honest).
(The second point will not be, the first is enough.)
It is this approach that will save a ton of govnokoda confusing code, a lot of alterations and a certain kind of fun before an important release. Let's look not at the example in the documentation, where json is customized for a simple int, and the whole model logic on several lines, but at our original task.
Do we really need to change the structure of our facility and cram a bunch of crutches? Is it really that the language rigor, which provides for a one-to-one correspondence between the json attributes and the structure itself, suddenly began to interfere with us?
The initial task is to get such JSON structures of some approved formats. In the original problem nothing is said about crutches. It is said about different data structures. And we use the same data type (struct) to store this data. Thus, our single entity must have several representations. So we got the correct interpretation of the problem.
We need to make several representations for our data type. Do not change the conversion to json for a specific case, but in principle have several views, one of which is the default view.
So, we have another entity - representation .
And let's get to the examples and, actually, to the code.
Suppose we have a bookstore that sells books. Everything is built on microservices, and one of them gives data on requests in json format. Books were first uploaded only to the storefront. Then we connect to various affiliate networks, and, for example, provide books for university students at a special price. And recently, our marketers suddenly decided to hold some kind of promotion actions and they also need their own price and some other text of their own. Let some other microservice be involved in the calculation of prices and the preparation of texts, which adds ready-made data to a database.
So, the evolution of our book model has reached such a disgrace:
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
}
The last attribute (view) is non-exported (private), it is not a part of the data, but is the storage location of the very view , which contains information in which json the object will be folded. In the simplest case, this is just interface {}
type BookView interface{}
We can also add some method to the interface of our view, for example Prepare (), which will be called in MarshalJSON () and somehow prepare, validate, or log the output structure.
Now let's describe our views and the function itself
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)
}
Sending and receiving data between structures occurs according to the principle of matching the names of the attributes, while the types do not have to match exactly, for example, you can send from int64, but accept it in int, but not in uint.
The last step is to marshal the installed data view using the full power of the standard description via json tags (`json:"promo,omitempty"`)
A very important requirement for applying this approach is the mandatory registration of model structures and mappings. To ensure that all structures are always guaranteed to be registered, add them to the init () function.
func init() {
gob.Register(Book{})
gob.Register(SiteBookView{})
gob.Register(Partner1BookView{})
gob.Register(Partner2BookView{})
gob.Register(PromoBookView{})
}
Full model code:
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)
}
The controller will have something like this code:
func GetBooksForPartner2(ctx *gin.Context) {
books := LoadBooksForPartner2()
for i := range books {
books[i].SetPartner2View()
}
ctx.JSON(http.StatusOK, books)
}
Now for a “one more” json change, just add another view and remember to register it in init ().