Як парсити різнотипний JSON в Go
Повна версія
Як парсити різнотипний JSON в Go
Підписуйтеся наTelegram-канал «DOU #tech», щоб не пропустити нові технічні статті.
Привіт, тему статті простіше буде одразу пояснити на прикладах коду.
Чи просто в 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"
"github.com/stretchr/testify/require"
"testing"
)
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"
"github.com/gorilla/websocket"
"net/http"
)
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 (
"github.com/stretchr/testify/require"
"testing"
)
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"
"github.com/stretchr/testify/require"
"testing"
)
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-у буде продовження.