Як парсити різнотипний JSON в Go
Привіт, тему статті простіше буде одразу пояснити на прикладах коду.
Чи просто в Go розпарсити JSON {"name":"golang/go", "stars":84300}
? Так.
А [{"name":"golang/go", "stars":84300}]
? Так.
А ["golang/go", 84300]
? Складніше.
А [["golang/go", 84300], ["rust-lang/rust", 53700]]
? Ще складніше.
Тема парсингу JSON розрахована на гоферів, які перекваліфікувались з JavaScript, PHP, Python чи Ruby. Буде корисна досвідченим гоферам, щоб знати, що детальне пояснення вже є та рекомендувати новеньким в команді. У статті буде багато прикладів коду та детальні пояснення з посиланнями на популярні opensource-рішення.
Буденність JSON-у
Працюючи з PHP та JavaScript, я звик, що можу легко розпарсити будь-який валідний JSON. Працюючи з Go легко розпарсити JSON, який відповідає статичнотипізованому формату:
{ "name": "json-unmarshal-research", "version": "1.0.0" }
package main import ( "encoding/json" "testing" "github.com/stretchr/testify/require" ) func TestPackageUnmarshalJSON(t *testing.T) { const ( // language=JSON content = `{ "name": "json-unmarshal-research", "version": "1.0.0" }` ) type Package struct { Name string `json:"name"` Version string `json:"version"` } var ( actual Package expected = Package{ Name: "json-unmarshal-research", Version: "1.0.0", } ) var err = json.Unmarshal([]byte(content), &actual) require.NoError(t, err) require.Equal(t, expected, actual) }
Або більший приклад теж легко розпарсити в Go:
[ { "id": 724712, "name": "rust", "full_name": "rust-lang/rust", "description": "Empowering everyone to build reliable and efficient software.", "created_at": "2010-06-16T20:39:03Z", "updated_at": "2021-04-15T14:54:00Z", "homepage": "https://www.rust-lang.org", "size": 616241, "stargazers_count": 53889, "language": "Rust", "has_wiki": false, "archived": false, "forks": 7753, "open_issues": 7120, "watchers": 53889, "default_branch": "master", "organization": { "login": "rust-lang", "id": 5430905 }, "network_count": 7753, "subscribers_count": 1497 }, { "id": 23096959, "name": "go", "full_name": "golang/go", "description": "The Go programming language", "created_at": "2014-08-19T04:33:40Z", "updated_at": "2021-04-15T14:59:43Z", "homepage": "https://golang.org", "size": 257437, "stargazers_count": 84507, "language": "Go", "has_wiki": true, "archived": false, "forks": 12312, "open_issues": 7045, "watchers": 84507, "default_branch": "master", "organization": { "login": "golang", "id": 4314092 }, "network_count": 12312, "subscribers_count": 3530 }, { "id": 44838949, "name": "swift", "full_name": "apple/swift", "description": "The Swift Programming Language", "created_at": "2015-10-23T21:15:07Z", "updated_at": "2021-04-15T15:00:43Z", "homepage": "https://swift.org", "size": 661779, "stargazers_count": 55730, "language": "C++", "has_wiki": false, "archived": false, "forks": 8947, "open_issues": 352, "watchers": 55730, "default_branch": "main", "organization": { "login": "apple", "id": 10639145 }, "network_count": 8947, "subscribers_count": 2589 } ]
Для цього вставте JSON в онлайн-інструмент JSON to Go, який робить рутинну роботу за нас:
type AutoGenerated []struct { ID int `json:"id"` Name string `json:"name"` FullName string `json:"full_name"` Description string `json:"description"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Homepage string `json:"homepage"` Size int `json:"size"` StargazersCount int `json:"stargazers_count"` Language string `json:"language"` HasWiki bool `json:"has_wiki"` Archived bool `json:"archived"` Forks int `json:"forks"` OpenIssues int `json:"open_issues"` Watchers int `json:"watchers"` DefaultBranch string `json:"default_branch"` Organization Organization `json:"organization"` NetworkCount int `json:"network_count"` SubscribersCount int `json:"subscribers_count"` } type Organization struct { Login string `json:"login"` ID int `json:"id"` }
type Repository struct { ID int `json:"id"` // ... Organization Organization `json:"organization"` // .. } var repositories []Repository var unmarshalErr = json.Unmarshal([]byte(content), &repositories) require.NoError(t, unmarshalErr) require.Equal(t, 3, len(repositories)) t.Log(repositories)
А от складності будемо розглядати в контексті задач.
Різнотипні повідомлення в вебсокетах
Є великий, але простий приклад вебсокету, зверніть увагу на змінну message, яка містить масив байтів.
package main import ( "fmt" "net/http" "github.com/gorilla/websocket" ) func main() { var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, } http.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) { conn, upgradeErr := upgrader.Upgrade(w, r, nil) if upgradeErr != nil { fmt.Printf("[ERROR] WebSocket upgrade %+v\n", upgradeErr) // NOP return } for { var ( messageType int message []byte readMessageErr error ) // Read message from browser messageType, message, readMessageErr = conn.ReadMessage() if readMessageErr != nil { fmt.Printf("[ERROR] WebSocket read message %+v\n", readMessageErr) // NOP return } // Print the message to the console fmt.Printf("[INFO] WebSocket %s sent: %s\n", conn.RemoteAddr(), message) // Write message back to browser writeMessageErr := conn.WriteMessage(messageType, message) if writeMessageErr != nil { fmt.Printf("[ERROR] WebSocket write message %+v\n", writeMessageErr) // NOP return } } }) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "index.html") }) fmt.Println("[INFO] start server on port 8080") var serveErr = http.ListenAndServe(":8080", nil) if serveErr != nil { fmt.Printf("[ERROR] serve %+v\n", serveErr) } }
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Go WebSocket JSON</title> </head> <body> <div> <input id="js-input" type="text"/> <button onclick="send()">Send</button> </div> <pre id="js-output"></pre> <script> const $input = document.getElementById("js-input"); const $output = document.getElementById("js-output"); const socket = new WebSocket("ws://localhost:8080/echo"); socket.onopen = function () { $output.innerHTML += "Status: Connected\n"; }; socket.onmessage = function (e) { $output.innerHTML += "Server: " + e.data + "\n"; }; function send() { socket.send($input.value); $input.value = ""; } </script> </body> </html>
Тепер будемо в message передавати різнотипний JSON щоб розпарсити в Go:
const socket = new WebSocket("ws://localhost:8080/echo"); socket.onopen = function () { socket.send(JSON.stringify({ "command_name": "employer_add_company", "data": { "name": "Example", "urls": ["https://example.com"] } })); socket.send(JSON.stringify({ "command_name": "employer_add_project", "data": { "company_id": 1, "name": "Example", "urls": ["https://example.com"], "domains": ["EdTech", "HealthTech", "FoodTech"] } })); socket.send(JSON.stringify({ "command_name": "employer_add_vacancy", "data": { "company_id": 1, "name": "Senior Rust Developer", "description": "Example description", "parking": { "exists": false, "free": false, }, "remote": true } })); }
Розглянемо різні варіанти парсингу від простого до складного, альтернативний варіант, а також латки.
Найзрозуміліший варіант — це розпарсити назву команди та в залежності від назви все повідомлення.
Парсинг відбувається в методі Dispatch:
type Company struct { Name string `json:"name"` Urls []string `json:"urls"` } type Project struct { CompanyID int `json:"company_id"` Name string `json:"name"` Urls []string `json:"urls"` Domains []string `json:"domains"` } type Vacancy struct { CompanyID int `json:"company_id"` Name string `json:"name"` Description string `json:"description"` Parking Parking `json:"parking"` Remote bool `json:"remote"` } type Parking struct { Exists bool `json:"exists"` Free bool `json:"free"` } type Command struct { Name string `json:"command_name"` } type EmployerAddCompanyCommand struct { CommandName string `json:"command_name"` // unused, @TODO delete Data Company `json:"data"` } type EmployerAddProjectCommand struct { CommandName string `json:"command_name"` // unused, @TODO delete Data Project `json:"data"` } type EmployerAddVacancyCommand struct { CommandName string `json:"command_name"` // unused, @TODO delete Data Vacancy `json:"data"` }
import ( "encoding/json" "errors" "fmt" ) type Session struct { companies []Company projects []Project vacancies []Vacancy } func (s *Session) Dispatch(raw []byte) error { var command Command var nameErr = json.Unmarshal(raw, &command) if nameErr != nil { return nameErr } switch command.Name { case "employer_add_company": var wrapper EmployerAddCompanyCommand var dataErr = json.Unmarshal(raw, &wrapper) if dataErr != nil { return dataErr } s.DispatchCompany(wrapper.Data) return nil case "employer_add_project": var wrapper EmployerAddProjectCommand var dataErr = json.Unmarshal(raw, &wrapper) if dataErr != nil { return dataErr } s.DispatchProject(wrapper.Data) return nil case "employer_add_vacancy": var wrapper EmployerAddVacancyCommand var dataErr = json.Unmarshal(raw, &wrapper) if dataErr != nil { return dataErr } s.DispatchVacancy(wrapper.Data) return nil } return errors.New(fmt.Sprintf("undefined command %s", command.Name)) } func (s *Session) DispatchCompany(c Company) { s.companies = append(s.companies, c) } func (s *Session) DispatchProject(c Project) { s.projects = append(s.projects, c) } func (s *Session) DispatchVacancy(v Vacancy) { s.vacancies = append(s.vacancies, v) }
import ( "testing" "github.com/stretchr/testify/require" ) func TestSession_Dispatch(t *testing.T) { const ( // language=JSON addCompanyJSON = `{ "command_name": "employer_add_company", "data": { "name": "Example", "urls": ["https://example.com"] } }` // language=JSON addProjectJSON = `{ "command_name": "employer_add_project", "data": { "company_id": 1, "name": "Example", "urls": ["https://example.com"], "domains": ["EdTech", "HealthTech", "FoodTech"] } }` // language=JSON addVacancyJSON = `{ "command_name": "employer_add_vacancy", "data": { "company_id": 1, "name": "Senior Rust Developer", "description": "Example description", "parking": { "exists": false, "free": false }, "remote": true } }` ) { var session Session var err = session.Dispatch([]byte(addCompanyJSON)) require.NoError(t, err) require.Equal(t, Session{ companies: []Company{ { Name: "Example", Urls: []string{"https://example.com"}, }, }, }, session) } { var session Session var err = session.Dispatch([]byte(addProjectJSON)) require.NoError(t, err) require.Equal(t, Session{ projects: []Project{ { CompanyID: 1, Name: "Example", Urls: []string{"https://example.com"}, Domains: []string{"EdTech", "HealthTech", "FoodTech"}, }, }, }, session) } { var session Session var err = session.Dispatch([]byte(addVacancyJSON)) require.NoError(t, err) require.Equal(t, Session{ vacancies: []Vacancy{ { CompanyID: 1, Name: "Senior Rust Developer", Description: "Example description", Parking: Parking{ Exists: false, Free: false, }, Remote: true, }, }, }, session) } }
Схожий варіант — це розпарсити поле command_name, використовуючи github.com/valyala/fastjson:
func (s *Session) Dispatch(raw []byte) error { var commandName = fastjson.GetString(raw, "command_name") switch commandName { case "employer_add_company": // as before example Company // ... return nil case "employer_add_project": // as before example Project // ... return nil case "employer_add_vacancy": // as before example Vacancy // ... return nil } return errors.New(fmt.Sprintf("undefined command %s", commandName)) }
І третій варіант — використовувати json.RawMessage зі стандартної бібліотеки encoding/json для відкладеного парсингу (або відкладеної серіалізації):
// RawMessage is a raw encoded JSON value. // It implements Marshaler and Unmarshaler and can // be used to delay JSON decoding or precompute a JSON encoding. type RawMessage []byte // MarshalJSON returns m as the JSON encoding of m. func (m RawMessage) MarshalJSON() ([]byte, error) { if m == nil { return []byte("null"), nil } return m, nil } // UnmarshalJSON sets *m to a copy of data. func (m *RawMessage) UnmarshalJSON(data []byte) error { if m == nil { return errors.New("json.RawMessage: UnmarshalJSON on nil pointer") } *m = append((*m)[0:0], data...) return nil }
type CommandRawData struct { Name string `json:"command_name"` Data json.RawMessage `json:"data"` }
func (s *Session) Dispatch(raw []byte) error { var command CommandRawData var nameErr = json.Unmarshal(raw, &command) if nameErr != nil { return nameErr } switch command.Name { case "employer_add_company": var company Company var dataErr = json.Unmarshal(command.Data, &company) if dataErr != nil { return dataErr } s.DispatchCompany(company) return nil case "employer_add_project": var project Project var dataErr = json.Unmarshal(command.Data, &project) if dataErr != nil { return dataErr } s.DispatchProject(project) return nil case "employer_add_vacancy": var vacancy Vacancy var dataErr = json.Unmarshal(command.Data, &vacancy) if dataErr != nil { return dataErr } s.DispatchVacancy(vacancy) return nil } return errors.New(fmt.Sprintf("undefined command %s", command.Name)) }
Ось додатковий приклад, щоб краще зрозуміти, як працює json.RawMessage:
import ( "encoding/json" "testing" "github.com/stretchr/testify/require" ) func TestSession_RawMessageUnmarshal(t *testing.T) { type CommandRawData struct { Name string `json:"command_name"` Data json.RawMessage `json:"data"` } const ( // language=JSON addCompanyJSON = `{"command_name":"any","data":{"name":"Example","urls":["https://example.com"]}}` ) var ( actual CommandRawData expected = CommandRawData{ Name: "any", Data: json.RawMessage([]byte(`{"name":"Example","urls":["https://example.com"]}`)), } ) var err = json.Unmarshal([]byte(addCompanyJSON), &actual) require.NoError(t, err) require.Equal(t, expected, actual) }
Основні варіанти розглянули, ще залишився альтернативний варіант та латки.
Парсинг JSON-у в месенджері Tinode
Tinode — це месенджер, бекенд якого написаний на чистому Go, а код є у відкритому доступі github.com/tinode/chat.
Tinode також використовує вебсокети, а тому цікаво глянути, як парсять повідомлення:
// ClientComMessage is a wrapper for client messages. type ClientComMessage struct { Hi *MsgClientHi `json:"hi"` Acc *MsgClientAcc `json:"acc"` Login *MsgClientLogin `json:"login"` Sub *MsgClientSub `json:"sub"` Leave *MsgClientLeave `json:"leave"` Pub *MsgClientPub `json:"pub"` Get *MsgClientGet `json:"get"` Set *MsgClientSet `json:"set"` Del *MsgClientDel `json:"del"` Note *MsgClientNote `json:"note"` // ... }
func (s *Session) dispatchRaw(raw []byte) { var msg ClientComMessage if err := json.Unmarshal(raw, &msg); err != nil { // ... return } s.dispatch(&msg) }
func (s *Session) dispatch(msg *ClientComMessage) { switch { case msg.Pub != nil: // ... case msg.Sub != nil: // ... case msg.Leave != nil: // ... case msg.Hi != nil: // ... case msg.Login != nil: // ... case msg.Get != nil: // ... case msg.Set != nil: // ... case msg.Del != nil: // ... case msg.Acc != nil: // ... case msg.Note != nil: // ... default: // ... return } }
Для кожного повідомлення власний ключ.
Будемо вважати за четвертий варіант парсингу різнотипного JSON-у, комфортний для малого числа типів повідомлень. У Tinode їх всього 10.
Універсальна подвійна серіалізація
У мережі мало статей з прикладами, як парсити різнотипний JSON, а тому початківці в Go знаходять найпростіший варіант: додатково серіалізувати повідомлення на стороні клієнта (щоб простіше було парсити на сервері):
const socket = new WebSocket("ws://localhost:8080/echo"); socket.onopen = function () { socket.send(JSON.stringify({ "command_name": "employer_add_company", "data": JSON.stringify({ "name": "Example", "urls": ["https://example.com"] }) })); socket.send(JSON.stringify({ "command_name": "employer_add_project", "data": JSON.stringify({ "company_id": 1, "name": "Example", "urls": ["https://example.com"], "domains": ["EdTech", "HealthTech", "FoodTech"] }) })); socket.send(JSON.stringify({ "command_name": "employer_add_vacancy", "data": JSON.stringify({ "company_id": 1, "name": "Senior Rust Developer", "description": "Example description", "parking": { "exists": false, "free": false }, "remote": true }) })); }
type CommandStringData struct { Name string `json:"command_name"` Data string `json:"data"` }
func (s *Session) Dispatch(raw []byte) error { var command CommandStringData var nameErr = json.Unmarshal(raw, &command) if nameErr != nil { return nameErr } switch command.Name { case "employer_add_company": var company Company var dataErr = json.Unmarshal([]byte(command.Data), &company) if dataErr != nil { return dataErr } s.DispatchCompany(company) return nil case "employer_add_project": var project Project var dataErr = json.Unmarshal([]byte(command.Data), &project) if dataErr != nil { return dataErr } s.DispatchProject(project) return nil case "employer_add_vacancy": var vacancy Vacancy var dataErr = json.Unmarshal([]byte(command.Data), &vacancy) if dataErr != nil { return dataErr } s.DispatchVacancy(vacancy) return nil } return errors.New(fmt.Sprintf("undefined command %s", command.Name)) }
func TestSession_Dispatch(t *testing.T) { const ( // language=JSON addCompanyJSON = `{ "command_name": "employer_add_company", "data": "{\"name\":\"Example\",\"urls\":[\"https://example.com\"]}" }` ) { var session Session var err = session.DispatchStringData([]byte(addCompanyJSON)) require.NoError(t, err) require.Equal(t, Session{ companies: []Company{ { Name: "Example", Urls: []string{"https://example.com"}, }, }, }, session) } }
Варіант робочий, але через подвійну серіалізацію складніше читати у веб-консолі, попередні варіанти кращі.
Безключовий JSON у вебсокет повідомленнях
Ще один формат, який зустрічається у вебсокетах, економний-безключовий:
const socket = new WebSocket("ws://localhost:8080/echo"); socket.onopen = function () { socket.send(JSON.stringify([ "employer_add_company", { "name": "Example", "urls": ["https://example.com"] } ])); socket.send(JSON.stringify([ "employer_add_project", { "company_id": 1, "name": "Example", "urls": ["https://example.com"], "domains": ["EdTech", "HealthTech", "FoodTech"] } ])); socket.send(JSON.stringify([ "employer_add_vacancy", { "company_id": 1, "name": "Senior Rust Developer", "description": "Example description", "parking": { "exists": false, "free": false, }, "remote": true } ])); }
Для парсингу різнотипного масиву підходить []json.RawMessage:
func (s *Session) Dispatch(raw []byte) error { var command [2]json.RawMessage var err = json.Unmarshal(raw, &command) if err != nil { return err } switch string(command[0]) { case `"employer_add_company"`: var company Company var dataErr = json.Unmarshal(command[1], &company) if dataErr != nil { return dataErr } s.DispatchCompany(company) return nil case `"employer_add_project"`: var project Project var dataErr = json.Unmarshal(command[1], &project) if dataErr != nil { return dataErr } s.DispatchProject(project) return nil case `"employer_add_vacancy"`: var vacancy Vacancy var dataErr = json.Unmarshal(command[1], &vacancy) if dataErr != nil { return dataErr } s.DispatchVacancy(vacancy) return nil } return errors.New(fmt.Sprintf("undefined command %s", string(command[0]))) }
func TestSession_Dispatch(t *testing.T) { const ( // language=JSON addCompanyJSON = `[ "employer_add_company", { "name": "Example", "urls": ["https://example.com"] } ]` // language=JSON addCompanyThirdKeyJSON = `[ "employer_add_company", { "name": "Example", "urls": ["https://example.com"] }, "test third key" ]` ) var expected = Session{ companies: []Company{ { Name: "Example", Urls: []string{"https://example.com"}, }, }, } { var session Session var err = session.Dispatch([]byte(addCompanyJSON)) require.NoError(t, err) require.Equal(t, expected, session) } { var session Session var err = session.Dispatch([]byte(addCompanyThirdKeyJSON)) require.NoError(t, err) require.Equal(t, expected, session) } }
Зверніть увагу, раніше в switch порівнював з employer_add_company, а зараз з " "employer_add_company"
.
Я використав [2]json.RawMessage, а якщо використовувати слайс []json.RawMessage, то треба додатково перевіряти розмір.
func (s *Session) Dispatch(raw []byte) error { var command []json.RawMessage var err = json.Unmarshal(raw, &command) if err != nil { return err } if len(command) < 2 { return errors.New(fmt.Sprintf("invalid json format")) } var ( commandName string nameErr = json.Unmarshal(command[0], &commandName) ) if nameErr != nil { return nameErr } switch commandName { case "employer_add_company": // as before example // ... return nil case "employer_add_project": // as before example // ... return nil case "employer_add_vacancy": // as before example // ... return nil } return errors.New(fmt.Sprintf("undefined command %s", commandName)) }
Епілог та далі буде
У статті я описав основні ідеї, які допоможуть парсити різнотипні формати JSON-у. У процесі написання статті розписав заголовки, про що хочу написати, як інтерфейс. Ще залишилось сім заголовків, а тому у теми парсингу JSON-у буде продовження.
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
25 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів