Работа с JSON
Учимся превращать данные Go в JSON и обратно, читать JSON из файлов и отправлять по сети — фундамент любого реального приложения
Практически каждое современное приложение работает с JSON. Когда вы открываете прогноз погоды в телефоне, когда бот в Telegram отвечает на ваше сообщение, когда сайт загружает список товаров — данные передаются именно в формате JSON. В Go есть мощный пакет encoding/json, который позволяет легко превращать структуры Go в JSON-строку (маршалинг) и разбирать JSON обратно в структуры (анмаршалинг). В этом уроке мы разберём обе операции, научимся читать JSON из файлов, а в конце напишем полноценную мини-программу для сохранения и загрузки данных. Это критически важный навык для нашего финального проекта — CLI-утилиты.
📄 Что такое JSON и зачем он нужен
JSON (JavaScript Object Notation) — это текстовый формат для хранения и передачи данных. Он выглядит как набор пар «ключ-значение» и читается одинаково хорошо и человеком, и компьютером. Вот простой пример JSON:
{
"name": "Алексей",
"age": 25,
"is_student": true,
"hobbies": ["Go", "музыка", "шахматы"]
}
Обратите внимание: ключи всегда в двойных кавычках, строки — тоже в двойных кавычках, числа и true/false — без кавычек. Массивы — в квадратных скобках. Вложенные объекты — в фигурных скобках.
Где используется JSON: API веб-сервисов (Twitter, YouTube, погодные сервисы), конфигурационные файлы, обмен данными между микросервисами, хранение настроек приложений, базы данных (MongoDB, CouchDB).
В Go для работы с JSON используется стандартный пакет encoding/json. Никаких сторонних библиотек устанавливать не нужно — всё идёт «из коробки». Две главные операции:
Marshal (маршалинг) — превращение данных Go (структур, map, слайсов) в JSON-строку.
Unmarshal (анмаршалинг) — разбор JSON-строки обратно в данные Go.
🔧 Marshal — превращаем данные Go в JSON
Представьте, что у вас есть структура с информацией о пользователе. Вы хотите отправить эти данные на сервер или сохранить в файл. Для этого нужно превратить структуру в JSON-строку. Именно это делает функция json.Marshal.
package main
import (
"encoding/json"
"fmt"
)
type User struct {
Name string
Age int
IsAdmin bool
}
func main() {
user := User{
Name: "Алексей",
Age: 25,
IsAdmin: false,
}
// Превращаем структуру в JSON ([]byte)
data, err := json.Marshal(user)
if err != nil {
fmt.Println("Ошибка:", err)
return
}
// Преобразуем []byte в строку для вывода
fmt.Println(string(data))
}
// Вывод: {"Name":"Алексей","Age":25,"IsAdmin":false}
Функция json.Marshal возвращает два значения: срез байтов ([]byte) с JSON-данными и ошибку. Если ошибки нет, err равен nil. Обратите внимание, что ключи в JSON совпадают с именами полей структуры: Name, Age, IsAdmin.
Теги структур — управляем именами полей
В реальных API ключи JSON обычно пишутся в нижнем регистре: name, age, is_admin. А поля структур Go должны начинаться с заглавной буквы (иначе они не экспортируются и json.Marshal их не увидит). Как быть? Go решает эту проблему с помощью тегов структур (struct tags):
type User struct {
Name string `json:"name"`
Age int `json:"age"`
IsAdmin bool `json:"is_admin"`
}
func main() {
user := User{
Name: "Алексей",
Age: 25,
IsAdmin: false,
}
data, err := json.Marshal(user)
if err != nil {
fmt.Println("Ошибка:", err)
return
}
fmt.Println(string(data))
}
// Вывод: {"name":"Алексей","age":25,"is_admin":false}
Тег пишется после типа поля в обратных кавычках: `json:"имя_в_json"`. Теперь в JSON будут именно те ключи, которые мы указали.
💡 Важно: Поля структуры с маленькой буквы (name, age) являются неэкспортируемыми. Пакет encoding/json работает через рефлексию и видит только экспортируемые поля (с большой буквы). Поэтому всегда пишите поля с заглавной буквы и используйте теги для настройки имён.
Полезные опции тегов
Теги поддерживают дополнительные опции через запятую:
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Secret string `json:"-"` // Поле полностью игнорируется
Discount float64 `json:"discount,omitempty"` // Пропускается, если значение нулевое
}
func main() {
p := Product{
ID: 1,
Name: "Ноутбук",
Price: 59990.0,
Secret: "секретные данные",
Discount: 0, // нулевое значение
}
data, _ := json.Marshal(p)
fmt.Println(string(data))
}
// Вывод: {"id":1,"name":"Ноутбук","price":59990}
// Secret — отсутствует (тег "-")
// Discount — отсутствует (omitempty + нулевое значение)
json:"-" — поле полностью исключается из JSON. Удобно для паролей, токенов и внутренних данных.
omitempty — если значение равно нулю (0, "", false, nil), поле не включается в JSON. Удобно для необязательных полей.
MarshalIndent — красивый JSON
Функция json.Marshal выдаёт JSON в одну строку. Для отладки и записи в файлы удобнее форматированный JSON с отступами. Для этого есть json.MarshalIndent:
data, err := json.MarshalIndent(user, "", " ")
if err != nil {
fmt.Println("Ошибка:", err)
return
}
fmt.Println(string(data))
// Вывод:
// {
// "name": "Алексей",
// "age": 25,
// "is_admin": false
// }
Второй аргумент — префикс (обычно пустая строка), третий — символ отступа (обычно 4 пробела или табуляция "\t").
📥 Unmarshal — разбираем JSON в структуры Go
Обратная операция — когда у вас есть JSON-строка (пришла из файла, из сети, из базы данных) и нужно превратить её в структуру Go. Для этого используется json.Unmarshal:
package main
import (
"encoding/json"
"fmt"
)
type User struct {
Name string `json:"name"`
Age int `json:"age"`
IsAdmin bool `json:"is_admin"`
}
func main() {
// JSON-строка (обычно приходит из файла или сети)
jsonStr := `{"name": "Мария", "age": 30, "is_admin": true}`
// Создаём переменную, куда запишутся данные
var user User
// Разбираем JSON в структуру
err := json.Unmarshal([]byte(jsonStr), &user)
if err != nil {
fmt.Println("Ошибка разбора JSON:", err)
return
}
fmt.Println("Имя:", user.Name) // Имя: Мария
fmt.Println("Возраст:", user.Age) // Возраст: 30
fmt.Println("Админ:", user.IsAdmin) // Админ: true
}
Обратите внимание на несколько ключевых моментов:
1. json.Unmarshal принимает []byte, поэтому строку нужно преобразовать: []byte(jsonStr).
2. Второй аргумент — указатель на переменную (&user). Функция должна записать данные внутрь этой переменной, поэтому нужен именно указатель.
3. Если JSON содержит поля, которых нет в структуре, они просто игнорируются. Если в структуре есть поля, которых нет в JSON, они останутся со значениями по умолчанию.
💡 Мягкий разбор: Go не ругается, если в JSON есть лишние поля или если каких-то полей не хватает. Это очень удобно: вы описываете в структуре только те поля, которые вам нужны, а остальные просто пропускаются.
Разбор массива JSON
JSON часто содержит массив объектов. В Go ему соответствует слайс структур:
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
func main() {
jsonData := `[
{"id": 1, "title": "Купить молоко", "done": false},
{"id": 2, "title": "Написать код", "done": true},
{"id": 3, "title": "Позвонить другу", "done": false}
]`
var tasks []Task
err := json.Unmarshal([]byte(jsonData), &tasks)
if err != nil {
fmt.Println("Ошибка:", err)
return
}
for _, t := range tasks {
status := "❌"
if t.Done {
status = "✅"
}
fmt.Printf("%s %s (ID: %d)\n", status, t.Title, t.ID)
}
}
// Вывод:
// ❌ Купить молоко (ID: 1)
// ✅ Написать код (ID: 2)
// ❌ Позвонить другу (ID: 3)
Разбор в map (когда структура неизвестна)
Иногда вы не знаете заранее, какие поля придут в JSON. Тогда можно разобрать данные в map[string]interface{} (или map[string]any в Go 1.18+):
package main
import (
"encoding/json"
"fmt"
)
func main() {
jsonStr := `{"name": "Алексей", "age": 25, "scores": [90, 85, 95]}`
var result map[string]any
err := json.Unmarshal([]byte(jsonStr), &result)
if err != nil {
fmt.Println("Ошибка:", err)
return
}
fmt.Println("Имя:", result["name"]) // Имя: Алексей
fmt.Println("Возраст:", result["age"]) // Возраст: 25
fmt.Println("Оценки:", result["scores"]) // Оценки: [90 85 95]
// Внимание: числа из JSON всегда приходят как float64!
age := result["age"].(float64)
fmt.Printf("Возраст (int): %d\n", int(age)) // Возраст (int): 25
}
💡 Ловушка с числами: При разборе в map[string]any все числа из JSON приходят как float64, даже если в JSON написано 25 без точки. Если нужен int, преобразуйте явно: int(value.(float64)). Это одна из причин, почему лучше использовать структуры — там типы определяются полем.
🏗️ Вложенные структуры и сложный JSON
Реальный JSON часто содержит вложенные объекты. В Go им соответствуют вложенные структуры:
package main
import (
"encoding/json"
"fmt"
)
type Address struct {
City string `json:"city"`
Street string `json:"street"`
}
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
Address Address `json:"address"`
}
func main() {
jsonStr := `{
"name": "Иван",
"age": 28,
"address": {
"city": "Москва",
"street": "ул. Пушкина, д. 10"
}
}`
var person Person
err := json.Unmarshal([]byte(jsonStr), &person)
if err != nil {
fmt.Println("Ошибка:", err)
return
}
fmt.Println("Имя:", person.Name)
fmt.Println("Город:", person.Address.City)
fmt.Println("Улица:", person.Address.Street)
}
// Вывод:
// Имя: Иван
// Город: Москва
// Улица: ул. Пушкина, д. 10
Вложенность может быть любой глубины. Главное — структура Go должна повторять структуру JSON. Поле Address в Person имеет тип Address — это отдельная структура со своими полями и тегами.
📂 Чтение JSON из файла
В реальных приложениях JSON почти всегда хранится в файлах, а не в строковых литералах. Есть два способа прочитать JSON из файла: загрузить весь файл в память и использовать json.Unmarshal, или использовать потоковый декодер json.NewDecoder.
Способ 1: os.ReadFile + json.Unmarshal
Самый простой и понятный способ. Сначала читаем весь файл в память, потом разбираем:
package main
import (
"encoding/json"
"fmt"
"os"
)
type Config struct {
Host string `json:"host"`
Port int `json:"port"`
Debug bool `json:"debug"`
}
func main() {
// Читаем весь файл в []byte
data, err := os.ReadFile("config.json")
if err != nil {
fmt.Println("Не удалось прочитать файл:", err)
return
}
// Разбираем JSON
var config Config
err = json.Unmarshal(data, &config)
if err != nil {
fmt.Println("Ошибка разбора JSON:", err)
return
}
fmt.Printf("Сервер: %s:%d (debug: %v)\n", config.Host, config.Port, config.Debug)
}
Допустим, файл config.json содержит:
{
"host": "localhost",
"port": 8080,
"debug": true
}
Тогда программа выведет: Сервер: localhost:8080 (debug: true).
Способ 2: json.NewDecoder (потоковый)
Декодер работает с io.Reader — читает данные потоком, не загружая весь файл в память. Это эффективнее для больших файлов и необходимо при работе с HTTP-ответами:
package main
import (
"encoding/json"
"fmt"
"os"
)
type Config struct {
Host string `json:"host"`
Port int `json:"port"`
Debug bool `json:"debug"`
}
func main() {
// Открываем файл
file, err := os.Open("config.json")
if err != nil {
fmt.Println("Не удалось открыть файл:", err)
return
}
defer file.Close()
// Создаём декодер и читаем
var config Config
decoder := json.NewDecoder(file)
err = decoder.Decode(&config)
if err != nil {
fmt.Println("Ошибка декодирования:", err)
return
}
fmt.Printf("Сервер: %s:%d (debug: %v)\n", config.Host, config.Port, config.Debug)
}
os.ReadFile + Unmarshal — простой и понятный, хорош для небольших файлов.
json.NewDecoder — работает с потоками (io.Reader), не грузит весь файл в память. Используйте для HTTP-ответов и больших файлов.
💾 Запись JSON в файл
Обратная задача — сохранить данные из программы в JSON-файл. Тоже два способа:
Способ 1: json.MarshalIndent + os.WriteFile
package main
import (
"encoding/json"
"fmt"
"os"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
func main() {
tasks := []Task{
{ID: 1, Title: "Изучить JSON в Go", Done: true},
{ID: 2, Title: "Написать CLI-утилиту", Done: false},
{ID: 3, Title: "Сдать проект", Done: false},
}
// Превращаем в красивый JSON
data, err := json.MarshalIndent(tasks, "", " ")
if err != nil {
fmt.Println("Ошибка маршалинга:", err)
return
}
// Записываем в файл (0644 — права на чтение/запись)
err = os.WriteFile("tasks.json", data, 0644)
if err != nil {
fmt.Println("Ошибка записи:", err)
return
}
fmt.Println("Задачи сохранены в tasks.json!")
}
После запуска появится файл tasks.json:
[
{
"id": 1,
"title": "Изучить JSON в Go",
"done": true
},
{
"id": 2,
"title": "Написать CLI-утилиту",
"done": false
},
{
"id": 3,
"title": "Сдать проект",
"done": false
}
]
Способ 2: json.NewEncoder (потоковый)
Аналогично декодеру, есть энкодер, который пишет JSON прямо в io.Writer:
package main
import (
"encoding/json"
"fmt"
"os"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
func main() {
tasks := []Task{
{ID: 1, Title: "Изучить JSON в Go", Done: true},
{ID: 2, Title: "Написать CLI-утилиту", Done: false},
}
file, err := os.Create("tasks.json")
if err != nil {
fmt.Println("Ошибка создания файла:", err)
return
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ") // Красивое форматирование
err = encoder.Encode(tasks)
if err != nil {
fmt.Println("Ошибка кодирования:", err)
return
}
fmt.Println("Задачи сохранены!")
}
💡 Подсказка: json.NewEncoder удобен тем, что вам не нужно хранить весь JSON в памяти — данные пишутся напрямую в файл (или HTTP-ответ, или любой другой io.Writer). Метод SetIndent добавляет отступы.
🌐 Декодирование JSON из сети
Одна из самых частых задач — получить JSON по HTTP и разобрать его. В Go это делается элегантно, потому что http.Response.Body реализует io.Reader, а json.NewDecoder принимает именно io.Reader:
package main
import (
"encoding/json"
"fmt"
"net/http"
)
// Структура для ответа API
type Post struct {
UserID int `json:"userId"`
ID int `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
}
func main() {
// Делаем GET-запрос к тестовому API
resp, err := http.Get("https://jsonplaceholder.typicode.com/posts/1")
if err != nil {
fmt.Println("Ошибка запроса:", err)
return
}
defer resp.Body.Close()
// Декодируем JSON прямо из тела ответа
var post Post
err = json.NewDecoder(resp.Body).Decode(&post)
if err != nil {
fmt.Println("Ошибка декодирования:", err)
return
}
fmt.Printf("Пост #%d\n", post.ID)
fmt.Printf("Автор (UserID): %d\n", post.UserID)
fmt.Printf("Заголовок: %s\n", post.Title)
fmt.Printf("Текст: %s\n", post.Body)
}
Разберём по шагам:
1. http.Get отправляет GET-запрос и возвращает ответ. resp.Body — это io.ReadCloser (умеет читать и закрываться).
2. defer resp.Body.Close() — обязательно закрываем тело ответа, чтобы не было утечки ресурсов.
3. json.NewDecoder(resp.Body).Decode(&post) — создаём декодер из тела ответа и сразу разбираем JSON в структуру.
Для получения массива объектов из API логика такая же, только переменная будет слайсом:
// Получаем список постов
resp, err := http.Get("https://jsonplaceholder.typicode.com/posts")
if err != nil {
fmt.Println("Ошибка:", err)
return
}
defer resp.Body.Close()
var posts []Post
err = json.NewDecoder(resp.Body).Decode(&posts)
if err != nil {
fmt.Println("Ошибка:", err)
return
}
fmt.Printf("Получено постов: %d\n", len(posts))
for _, p := range posts[:5] { // Выводим первые 5
fmt.Printf(" #%d: %s\n", p.ID, p.Title)
}
💡 Запомните паттерн: Для HTTP-ответов всегда используйте json.NewDecoder(resp.Body), а не io.ReadAll + json.Unmarshal. Декодер эффективнее по памяти и элегантнее по коду. Подробнее о HTTP-запросах — в следующем уроке 5.3.
⚠️ Обработка ошибок при работе с JSON
JSON может быть невалидным, содержать неожиданные типы или не соответствовать ожидаемой структуре. Всегда проверяйте ошибки:
package main
import (
"encoding/json"
"fmt"
)
type User struct {
Name string `json:"name"`
Age int `json:"age"`
}
func main() {
// Невалидный JSON — отсутствует закрывающая скобка
badJSON := `{"name": "Алексей", "age": 25`
var user User
err := json.Unmarshal([]byte(badJSON), &user)
if err != nil {
fmt.Println("Ошибка:", err)
// Вывод: Ошибка: unexpected end of JSON input
return
}
// Неправильный тип данных
wrongType := `{"name": "Алексей", "age": "двадцать пять"}`
err = json.Unmarshal([]byte(wrongType), &user)
if err != nil {
fmt.Println("Ошибка типа:", err)
// Вывод: Ошибка типа: json: cannot unmarshal string
// into Go struct field User.age of type int
return
}
}
Go сообщает точно, что пошло не так: невалидный JSON, неправильный тип поля, неожиданный конец данных. Это помогает быстро найти и исправить проблему.
Типичные ошибки при работе с JSON:
• Невалидный JSON (пропущена скобка, запятая, кавычка)
• Несовпадение типов (строка вместо числа и наоборот)
• Передача значения вместо указателя в Unmarshal
• Неэкспортируемые поля структуры (с маленькой буквы)
🚀 Практика: менеджер задач с сохранением в JSON
Давайте соберём всё воедино и напишем полноценную мини-программу. Это будет менеджер задач, который сохраняет данные в JSON-файл и загружает их при запуске. Именно такой паттерн мы будем использовать в финальном проекте.
package main
import (
"encoding/json"
"fmt"
"os"
)
// Структура задачи
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
const tasksFile = "tasks.json"
// loadTasks загружает задачи из файла
func loadTasks() ([]Task, error) {
// Проверяем, существует ли файл
data, err := os.ReadFile(tasksFile)
if err != nil {
// Если файла нет — возвращаем пустой список
if os.IsNotExist(err) {
return []Task{}, nil
}
return nil, fmt.Errorf("ошибка чтения файла: %w", err)
}
var tasks []Task
err = json.Unmarshal(data, &tasks)
if err != nil {
return nil, fmt.Errorf("ошибка разбора JSON: %w", err)
}
return tasks, nil
}
// saveTasks сохраняет задачи в файл
func saveTasks(tasks []Task) error {
data, err := json.MarshalIndent(tasks, "", " ")
if err != nil {
return fmt.Errorf("ошибка маршалинга: %w", err)
}
err = os.WriteFile(tasksFile, data, 0644)
if err != nil {
return fmt.Errorf("ошибка записи файла: %w", err)
}
return nil
}
// addTask добавляет новую задачу
func addTask(tasks []Task, title string) []Task {
// Определяем ID: максимальный + 1
maxID := 0
for _, t := range tasks {
if t.ID > maxID {
maxID = t.ID
}
}
task := Task{
ID: maxID + 1,
Title: title,
Done: false,
}
return append(tasks, task)
}
// completeTask отмечает задачу выполненной
func completeTask(tasks []Task, id int) bool {
for i := range tasks {
if tasks[i].ID == id {
tasks[i].Done = true
return true
}
}
return false
}
// printTasks выводит все задачи
func printTasks(tasks []Task) {
if len(tasks) == 0 {
fmt.Println("Список задач пуст.")
return
}
fmt.Println("=== Список задач ===")
for _, t := range tasks {
status := "[ ]"
if t.Done {
status = "[x]"
}
fmt.Printf(" %s #%d: %s\n", status, t.ID, t.Title)
}
fmt.Println()
}
func main() {
// 1. Загружаем задачи из файла
tasks, err := loadTasks()
if err != nil {
fmt.Println("Ошибка загрузки:", err)
return
}
fmt.Println("Задачи загружены из файла.")
printTasks(tasks)
// 2. Добавляем новые задачи
tasks = addTask(tasks, "Изучить encoding/json")
tasks = addTask(tasks, "Прочитать про HTTP в Go")
tasks = addTask(tasks, "Написать финальный проект")
// 3. Отмечаем первую задачу выполненной
if completeTask(tasks, 1) {
fmt.Println("Задача #1 выполнена!")
}
printTasks(tasks)
// 4. Сохраняем в файл
err = saveTasks(tasks)
if err != nil {
fmt.Println("Ошибка сохранения:", err)
return
}
fmt.Println("Задачи сохранены в", tasksFile)
}
Запустите эту программу, и она создаст файл tasks.json. Запустите второй раз — она загрузит существующие задачи и добавит новые. Это базовый паттерн персистентного хранения данных:
Паттерн «Загрузить → Изменить → Сохранить»:
1. При запуске: loadTasks() — читаем JSON из файла в слайс структур
2. Во время работы: добавляем, изменяем, удаляем элементы слайса
3. При завершении: saveTasks() — записываем слайс обратно в файл как JSON
Обратите внимание на функцию loadTasks. Она проверяет, существует ли файл, с помощью os.IsNotExist(err). Если файла нет — это не ошибка, просто возвращаем пустой слайс. Это делает программу устойчивой при первом запуске.
💡 Совет для финального проекта: Этот паттерн загрузки/сохранения JSON — основа нашей CLI-утилиты. Выделяйте функции чтения и записи в отдельные файлы (storage.go) и используйте их как «слой хранения». Тогда основная логика программы не будет знать, как именно хранятся данные — в JSON, в базе данных или в облаке.
📎 Шпаргалка: основные функции encoding/json
json.Marshal(v) — структура/map/слайс → []byte (JSON в одну строку)
json.MarshalIndent(v, prefix, indent) — то же, но с отступами (для файлов и отладки)
json.Unmarshal(data, &v) — []byte (JSON) → структура/map/слайс
json.NewDecoder(r).Decode(&v) — потоковое чтение из io.Reader (файл, HTTP)
json.NewEncoder(w).Encode(v) — потоковая запись в io.Writer (файл, HTTP)
Теги структур:
`json:"name"` — задать имя поля в JSON
`json:"name,omitempty"` — пропустить, если значение нулевое
`json:"-"` — полностью исключить поле из JSON
📋 Итоги урока
- JSON — текстовый формат для хранения и передачи данных, стандарт де-факто в веб-разработке
json.Marshalпревращает данные Go в JSON,json.Unmarshal— разбирает JSON обратно- Теги структур (
`json:"name"`) задают имена полей,omitemptyпропускает нулевые,"-"исключает полностью - Для файлов и HTTP:
json.NewDecoder/json.NewEncoderработают с потоками os.ReadFile+json.Unmarshal— простейший способ прочитать JSON из файлаjson.MarshalIndent+os.WriteFile— простейший способ записать JSON в файл- При разборе в
map[string]anyвсе числа приходят какfloat64— помните об этом - Паттерн «Загрузить → Изменить → Сохранить» — основа хранения данных в нашем финальном проекте
- Всегда проверяйте ошибки при маршалинге и анмаршалинге
В следующем уроке 5.3 мы изучим HTTP-запросы в Go: научимся делать GET и POST запросы к реальным API, отправлять заголовки и тело запроса, обрабатывать ответы. Мы подключим нашу CLI-утилиту к внешнему API и объединим знания JSON и HTTP в единый рабочий процесс.