1. Зачем думать о соответствии JSON ↔ Go-типы
Когда вы впервые видите JSON, возникает соблазн относиться к нему как к «строке со скобочками». В маленьких примерах это даже работает. Но как только JSON становится входом/выходом вашего приложения (файл, сеть, API), он превращается в контракт: форма данных должна быть понятна и стабильна, иначе программа будет ломаться в самый неподходящий момент — обычно на демо.
В Go есть стандартный пакет encoding/json, который умеет превращать JSON в значения Go и обратно. Но ключевая мысль такая: один и тот же JSON можно «прочитать» разными Go-типами, и от выбранного типа будет зависеть, насколько ваш код получится безопасным, понятным и удобным для дальнейшей логики.
Чтобы не писать приложение в стиле «а давайте всё будет any, а там разберёмся», мы сегодня построим «карту местности»: какие формы JSON лучше всего соответствуют struct, []T и map[string]any, и чем эти выборы отличаются.
JSON — это 6 типов
Новичкам часто кажется, что JSON — это «всё подряд», и там может быть что угодно. На самом деле JSON очень строгий: он состоит всего из шести типов. Это приятная новость, потому что если типов мало, то и правил сопоставления с Go-типами тоже мало. Остальное — просто комбинации.
Вот компактная таблица соответствий (не единственно возможная, но самая практичная для старта):
| JSON-тип | Пример | Типичный Go-тип (когда форма известна) | Типичный Go-тип (когда форма неизвестна) |
|---|---|---|---|
| object | |
|
|
| array | |
|
|
| string | |
|
|
| number | |
|
чаще всего будет внутри |
| boolean | |
|
|
| null | |
часто |
|
Сейчас мы будем в первую очередь разбирать три «контейнерных» случая: 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. Здесь важна идея: декодирование идёт в переменную по адресу, потому что библиотека должна куда-то записать результат.
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-тип — это форма приёмки на складе. Если вы знаете, что вам привозят смартфоны, вы делаете форму со строками «серийный номер», «модель», «цвет». Если вы не знаете, что привезут, вы пишете «принято: коробка, содержимое уточняется» — и дальше разбирать сложнее.
Вот удобная сравнительная таблица:
| Критерий | |
|
|---|---|---|
| Типобезопасность | высокая | низкая (всё через проверки) |
| Читаемость кода | обычно отличная | быстро становится шумной |
| Гибкость по ключам | фиксированная форма | можно любые ключи |
| Ошибки | часто ловятся раньше | ловятся позже, в рантайме |
| Идеально для | 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, либо осознанно приводить float64 → int с проверками (и помнить, что 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, а скрывать поля нужно осознанно и по правилам модели, а не случайно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ