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 — это 6 типов

Новичкам часто кажется, что 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. Здесь важна идея: декодирование идёт в переменную по адресу, потому что библиотека должна куда-то записать результат.

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, а скрывать поля нужно осознанно и по правилам модели, а не случайно.

1
Задача
Go SELF, 45 уровень, 0 лекция
Недоступна
Синхронизация задачи
Синхронизация задачи
1
Задача
Go SELF, 45 уровень, 0 лекция
Недоступна
Пакет тегов
Пакет тегов
1
Задача
Go SELF, 45 уровень, 0 лекция
Недоступна
Отчёт по задачам
Отчёт по задачам
1
Задача
Go SELF, 45 уровень, 0 лекция
Недоступна
Осторожный разбор
Осторожный разбор
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ