JavaRush /Курси /Go SELF /Відповідність JSON ↔ Go-типи:

Відповідність JSON ↔ Go-типи: struct, []T

Go SELF
Рівень 45 , Лекція 0
Відкрита

1. Навіщо взагалі думати про відповідність JSON ↔ Go-типів

Коли ви вперше бачите JSON, виникає спокуса сприймати його як «рядок із дужками». У маленьких прикладах це навіть працює. Але щойно JSON стає вхідними або вихідними даними вашого застосунку — файлом, мережею чи API — він перетворюється на контракт: форма даних має бути зрозумілою й стабільною, інакше програма зламається в найменш слушний момент. Зазвичай — просто на демо.

У Go є стандартний пакет encoding/json, який уміє перетворювати JSON на значення Go й назад. Але ключова думка така: один і той самий JSON можна «прочитати» різними Go-типами, і від обраного типу залежить, наскільки ваш код буде безпечним, зрозумілим і зручним для подальшої логіки.

Щоб не писати застосунок у стилі «а давайте все буде any, а там розберемося», сьогодні побудуємо «карту місцевості»: які форми JSON найкраще відповідають struct, []T і map[string]any, а також чим ці варіанти відрізняються.

JSON — це шість типів

Новачкам часто здається, що JSON — це «все підряд» і там може бути що завгодно. Насправді JSON дуже строгий: він складається лише з шести типів. Це приємна новина, бо якщо типів мало, то й правил зіставлення з Go-типами теж небагато. Решта — лише комбінації.

Ось компактна таблиця відповідностей. Вона не єдина можлива, але для старту — найпрактичніша:

JSON-тип Приклад Типовий Go-тип, коли форма відома Типовий Go-тип, коли форма невідома
object
{"id":1,"title":"A"}
struct{...}
map[string]any
array
[1,2,3]
[{"id":1}]
[]T
[]any
string
"hello"
string
any
number
10
3.14
int
float64
зазвичай це
float64
усередині
any
boolean
true
bool
any
null
null
часто
*T == nil
nil

Насамперед нас цікавитимуть три «контейнерні» випадки: JSON object → struct або map, JSON array → []T. Саме вони визначають архітектуру читання даних: житимете ви у світі типів чи у світі перевірок на кожному кроці.

2. JSON object → struct: коли форма даних відома

Коли JSON-об’єкт має зрозумілу структуру, наприклад задачу в трекері з полями id, title і done, найрозумніше читати його в struct. Це схоже на анкету: у ній поля відомі наперед. Якщо почати зберігати її як «мішок із ключів і значень», ви лише ускладните собі життя. struct дає компілятору шанс захистити вас від помилок.

Важливо пам’ятати про одну особливість рефлексії в Go: як правило, пакети кодування й декодування працюють лише з експортованими полями структури, тобто з тими, що починаються з великої літери. Цю ж ідею ви побачите й в інших стандартних пакетах серіалізації; наприклад, в описі encoding/gob окремо підкреслюється, що кодуються й декодуються лише експортовані поля. Для JSON діє те саме правило.

Почнімо розвивати наш навчальний застосунок «міні-трекер задач». Поки що без файлів і без HTTP: просто вчимося правильно представляти задачу в коді.

Міні-модель Task і читання JSON у структуру

Створімо структуру й спробуймо розпарсити вхідний JSON. Зверніть увагу: JSON пише id, title, done, а в Go ми використовуємо ID, Title, Done. Для базових випадків encoding/json уміє зіставляти імена досить гнучко, але зі складнішими назвами, на кшталт created_at, доведеться розбиратися окремо.


package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

Тепер функція, яка читає JSON у Task. Тут важлива ідея: у json.Unmarshal потрібно передавати змінну за адресою, бо бібліотеці треба кудись записати результат.

package main

import (
	"encoding/json"
	"fmt"
)

func parseTask(data []byte) (Task, error) {
	var t Task
	if err := json.Unmarshal(data, &t); err != nil {
		return Task{}, fmt.Errorf("parse task: %v", err)
	}
	return t, nil
}

І маленький приклад використання:

package main

import "fmt"

func main() {
	t, err := parseTask([]byte(`{"id":1,"title":"read docs","done":false}`))
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Printf("%+v\n", t) // {ID:1 Title:read docs Done:false}
}

Зверніть увагу на відчуття: після Unmarshal у вас на руках нормальна Go-структура, з якою можна працювати без «танців» навколо типів.

Вкладені об’єкти: struct усередині struct

У реальному JSON об’єкти часто бувають вкладеними: наприклад, у задачі може бути автор. У Go це зручно моделювати вкладеною структурою.

package main

type Author struct {
	Name string
}

type Task struct {
	ID     int
	Title  string
	Done   bool
	Author Author
}

Якщо вхідні дані такі:

{"id":2,"title":"write code","done":true,"author":{"name":"Sam"}}

то після Unmarshal ви отримаєте Task{Author: Author{Name:"Sam"}}. Це одна з причин, чому struct — ваш найкращий друг, коли структура вхідних даних відома.

Часткове читання: забрати лише потрібні поля

Іноді потрібна лише частина даних. Наприклад, ви хочете швидко перевірити, що id є, а решта поки неважлива. Тоді можна зробити структуру-приймач із мінімальним набором полів.

package main

import "encoding/json"

type TaskIDOnly struct {
	ID int
}

func parseTaskID(data []byte) (int, error) {
	var x TaskIDOnly
	if err := json.Unmarshal(data, &x); err != nil {
		return 0, err
	}
	return x.ID, nil
}

Це дуже практичний прийом: маленька структура під конкретну задачу робить код читабельнішим, ніж читання всього в map[string]any і ручні перевірки.

3. JSON array → []T: коли даних багато й вони однотипні

JSON-масив — це послідовність елементів. У Go природна форма для послідовності — зріз []T. І тут правило просте: ви обираєте тип Tі тим самим задаєте, як має читатися кожен елемент масиву. Якщо T — структура, то кожен елемент масиву має бути JSON-об’єктом відповідної форми.

Це схоже на коробку з однаковими деталями: якщо ви очікуєте «болти M6», то не хочете, щоб раптом там опинилася «бананова шкірка» — хоча JSON теоретично може підсунути й таке. З []T ви просите декодер перевірити форму за вас.

Читаємо масив задач: []Task

Припустімо, ми отримали список задач:

[
  {"id":1,"title":"read docs","done":false},
  {"id":2,"title":"write code","done":true}
]

У Go це читається як []Task:

package main

import (
	"encoding/json"
	"fmt"
)

func parseTaskList(data []byte) ([]Task, error) {
	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, fmt.Errorf("parse task list: %v", err)
	}
	return tasks, nil
}

І використання:

package main

import "fmt"

func main() {
	data := []byte(`[{"id":1,"title":"A","done":false},{"id":2,"title":"B","done":true}]`)
	tasks, err := parseTaskList(data)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(len(tasks)) // 2
}

Масив простих значень: []string, []int

Якщо JSON-масив складається з рядків, його зручно читати в []string. Наприклад, теги задачі:

["go","json","practice"]
package main

import "encoding/json"

func parseTags(data []byte) ([]string, error) {
	var tags []string
	err := json.Unmarshal(data, &tags)
	return tags, err
}

Цей приклад виглядає майже надто простим, але в цьому й перевага: правильний вибір типу робить код коротшим, а помилки — помітнішими раніше.

nil-зріз і порожній зріз

У зрізів є два «порожні» стани: nil і []T{} — порожній, але не nil. У повсякденній логіці це зазвичай неважливо, але під час серіалізації та порівняння різниця може проявлятися. Новачкові достатньо пам’ятати: якщо функція повертає []T, часто зручно повертати nil і err у разі помилки, а в разі успіху — нормальний зріз, можливо порожній.

Якщо ви лише починаєте, не намагайтеся вигадувати складні правила. Важливо лише послідовно дотримуватися обраного контракту: або «порожній список = порожній зріз», або «порожній список може бути nil». Головне — не змішувати: в одному місці так, в іншому — інакше.

4. JSON object → map[string]any: коли форма невідома

Іноді структура JSON заздалегідь невідома. Наприклад, ви робите налагоджувальну утиліту, яка приймає «будь-який JSON» і друкує, що всередині. Або пишете код, який має пропускати крізь себе «користувацькі поля», не фіксуючи список ключів наперед.

Тоді ви обираєте map[string]any (у старих версіях часто писали map[string]interface{} — за змістом це те саме). Але за гнучкість ви платите: усередині з’являються значення типу any, а це означає, що далі доведеться вручну робити перевірки й приводити типи.

І ще один залізний факт: ключі в JSON-об’єкті — завжди рядки, тому map[int]... тут не підходить за визначенням.

Декодуємо JSON у map[string]any і дивимося типи

Візьмемо JSON:

{"id":1,"title":"A","done":true}

Декодуємо:

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	var m map[string]any
	_ = json.Unmarshal([]byte(`{"id":1,"title":"A","done":true}`), &m)

	fmt.Printf("%T\n", m["title"]) // string
	fmt.Printf("%T\n", m["done"])  // bool
	fmt.Printf("%T\n", m["id"])    // float64
}

Неочікувано: id став float64. Це не помилка й не «знущання над студентами», а наслідок того, що JSON-число саме по собі не вказує, це int чи float. Коли ви декодуєте в «динамічну» структуру (any), пакет обирає стандартне представлення числа — float64.

Тому правило просте: map[string]any годиться, коли ви готові жити у світі ручних перевірок.

Дістаємо значення з map[string]any безпечно

Якщо ви все ж обрали цей шлях, діставайте значення обережно: спершу перевірте, чи є ключ, а потім — тип.

package main

import "fmt"

func getTitle(m map[string]any) (string, error) {
	v, ok := m["title"]
	if !ok {
		return "", fmt.Errorf("title is missing")
	}
	s, ok := v.(string)
	if !ok {
		return "", fmt.Errorf("title must be string")
	}
	return s, nil
}

Так, це довше, ніж t.Title у структурі. Зате це чесно: ви явно формулюєте правила вхідних даних і отримуєте зрозумілі помилки.

Вкладені об’єкти й масиви в map[string]any

Якщо JSON містить вкладені об’єкти та масиви, у динамічному вигляді ви отримаєте рекурсивну структуру з:

  • map[string]any для об’єктів,
  • []any для масивів,
  • string, bool, float64, nil для примітивів.

У певному сенсі це «дерево з вузлами any», а ваш код перетворюється на обхід дерева з купою перевірок. Це нормально для налагоджувальних і «універсальних» інструментів, але зазвичай не те, що хочеться бачити в бізнес-логіці.

5. Як обрати: struct чи map, і де тут місце []T

У реальному проєкті вибір типу — це не «смак», а інженерне рішення. Вам важливо розуміти, чого ви хочете: строгості й читабельності чи гнучкості з принципом «на вході приймемо що завгодно».

Уявіть, що JSON — це посилка, а Go-тип — форма приймання на складі. Якщо ви знаєте, що вам привозять смартфони, ви робите форму з полями «серійний номер», «модель» і «колір». Якщо не знаєте, що саме привезуть, ви пишете «прийнято: коробка, вміст уточнюється» — а далі розбиратися значно складніше.

Ось зручна порівняльна таблиця:

Критерій
struct
map[string]any
Типобезпечність висока низька: усе через перевірки
Читабельність коду зазвичай чудова швидко стає шумною
Гнучкість щодо ключів форма фіксована можна будь-які ключі
Коли помилки виявляються часто раніше пізніше, під час виконання
Ідеально для API-контрактів, DTO, моделей налагодження, «плагінних» даних, невідомої схеми

А []T — це просто багато однакових сутностей. Тому найчастіше ви обираєте або []Task, або []map[string]any, залежно від того, чи відома форма елемента.

Помилки декодування: некоректний JSON — це нормально

Поки ви навчаєтеся, дуже хочеться написати _ = json.Unmarshal(...) і «не думати про погане». Але зовнішній JSON майже завжди може виявитися неправильним: користувач помилився, файл обрізався, мережа чхнула, або хтось надіслав вам не JSON, а любовного листа у форматі «майже JSON».

У encoding/json є конкретні типи помилок для деяких випадків. Наприклад, синтаксичні проблеми парсингу виражаються помилкою типу json.SyntaxError, і в неї є поле Offset, де вказано місце в байтах, де саме все зламалося. Цю ідею — і сам тип — часто використовують, щоб робити повідомлення про помилки змістовнішими.

Нам тут не потрібно заглиблюватися в обчислення line/column — важливо лише запам’ятати звичку: помилка декодування — це не «ганьба», а штатна гілка коду. Ви перевіряєте err і дієте далі: або повідомляєте користувачу, або припиняєте обробку.

6. Типові помилки під час вибору й використання Go-типів для JSON

Помилка № 1: читати відомий JSON-об’єкт у map[string]any «про всяк випадок».
Таке рішення здається гнучким, але на практиці швидко перетворює код на болото з перевірок, приведень типів і незрозумілих помилок. Якщо структура відома й стабільна, struct майже завжди простіший, коротший і надійніший: поля вже типізовані, і компілятор допомагає вам не переплутати string із bool.

Помилка № 2: очікувати, що map[string]any збереже числа як int.
Під час динамічного декодування числа зазвичай стають float64, і це ламає наївне припущення на кшталт m["id"].(int). Треба або читати в структуру з int, або свідомо приводити float64int з перевірками (і пам’ятати, що 3.14 у id — це вже привід насторожитися).

Помилка № 3: використовувати map[int]any для JSON object.
У JSON ключі об’єкта — рядки. Завжди. Навіть якщо «візуально» ключ виглядає як число ({"1":"a"}), це все одно рядок "1". Тому правильний контейнер для об’єкта — map[string]....

Помилка № 4: забути передати адресу в Unmarshal.
json.Unmarshal(data, t) не зможе заповнити t, бо бібліотеці потрібне місце, куди записати результат. Правильно: json.Unmarshal(data, &t). Якщо ви бачите помилку або дивну поведінку, насамперед перевірте, чи не забули &.

Помилка № 5: зробити поля структури неекспортованими й дивуватися, чому вони не заповнюються.
Якщо ви написали type Task struct { title string }, то під час декодування поле title не заповнюватиметься, бо воно неекспортоване. У підсумку в вас буде «вічно порожній title», і ви почнете підозрювати змову JSON. Насправді достатньо зробити Title string, а приховувати поля потрібно свідомо й за правилами моделі, а не випадково.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ