Shanraq.org Shanraq.org
JSON и своё API: блог читают не только браузером
IT

Go: с нуля до своего блога Урок 44 из 50

JSON и своё API: блог читают не только браузером

Сорок четвёртый урок курса по Go. Теги структуры решают, как поле называется снаружи и что скрыто совсем; лишнее поле в чужом JSON молчит, если не попросить обратного; число без типа становится float64. И ответ обработчика, у которого ошибка — тоже JSON.

Зачем это нужно

Блог отдаёт страницы, и это правильно ровно до того дня, когда его содержимое понадобится не человеку.

Мобильному приложению нужны те же статьи, но без разметки. Соседнему сайту — список заголовков. Вам самим — скрипт, который проверяет, что всё на месте. Всем им отдавать <h1> бессмысленно: они разбирают ответ программой, и им нужен формат, который программе понятен.

Такой формат — JSON, и в Go он лежит в стандартной библиотеке.

Сразу целиком

Новая папка, go mod init sabaq41:

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"
)

// Article — то, что уходит наружу. Теги решают, как поле называется в JSON;
// omitempty убирает пустое, а дефис прячет поле совсем.
type Article struct {
	Slug      string    `json:"slug"`
	Title     string    `json:"title"`
	Tags      []string  `json:"tags,omitempty"`
	UpdatedAt time.Time `json:"updated_at,omitzero"`
	Draft     bool      `json:"-"`
	secret    string    // с маленькой буквы — наружу не выйдет никогда
}

func main() {
	fmt.Println("== что уходит наружу")
	a := Article{
		Slug: "dala", Title: "О степи", Draft: true, secret: "не для чужих глаз",
		UpdatedAt: time.Date(2026, time.September, 6, 12, 0, 0, 0, time.UTC),
	}
	out, _ := json.MarshalIndent(a, "", "  ")
	fmt.Println(string(out))

	fmt.Println()
	fmt.Println("== пустые поля")
	empty, _ := json.Marshal(Article{Slug: "salem", Title: "Привет"})
	fmt.Println(string(empty))

	fmt.Println()
	fmt.Println("== что приходит снаружи")
	body := `{"slug":"koktem","title":"Весна","tags":["степь"],"лишнее":1}`
	var got Article
	fmt.Println("обычный разбор: ", json.Unmarshal([]byte(body), &got), "->", got.Slug, got.Tags)

	strict := json.NewDecoder(strings.NewReader(body))
	strict.DisallowUnknownFields()
	fmt.Println("строгий разбор: ", strict.Decode(&Article{}))

	fmt.Println()
	fmt.Println("== число без типа")
	var any1 any
	json.Unmarshal([]byte(`{"n":7}`), &any1)
	n := any1.(map[string]any)["n"]
	fmt.Printf("%v — это %T\n", n, n)

	fmt.Println()
	fmt.Println("== ответ обработчика")
	srv := httptest.NewServer(routes())
	defer srv.Close()
	show(srv.URL + "/api/articles/dala")
	show(srv.URL + "/api/articles/joq")
}

func routes() http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /api/articles/{slug}", func(w http.ResponseWriter, r *http.Request) {
		if r.PathValue("slug") != "dala" {
			// Ошибка тоже JSON: тот, кто читает API, разбирает ответ, а не
			// смотрит на страницу с картинкой.
			writeJSON(w, http.StatusNotFound, map[string]string{"error": "нет такой статьи"})
			return
		}
		writeJSON(w, http.StatusOK, Article{Slug: "dala", Title: "О степи", Tags: []string{"степь"}})
	})

	return mux
}

// writeJSON пишет заголовок раньше кода ответа: после WriteHeader заголовки
// уже уехали читателю, и менять их поздно.
func writeJSON(w http.ResponseWriter, code int, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(code)
	if err := json.NewEncoder(w).Encode(v); err != nil {
		// Заголовок уже ушёл — остаётся только записать в журнал.
		fmt.Println("не удалось записать ответ:", err)
	}
}

show из второго файла просто печатает ответ. Вывод:

== что уходит наружу
{
  "slug": "dala",
  "title": "О степи",
  "updated_at": "2026-09-06T12:00:00Z"
}

== пустые поля
{"slug":"salem","title":"Привет"}

== что приходит снаружи
обычный разбор:  <nil> -> koktem [степь]
строгий разбор:  json: unknown field "лишнее"

== число без типа
7 — это float64

== ответ обработчика
200 application/json; charset=utf-8 -> {"slug":"dala","title":"О степи","tags":["степь"]}
404 application/json; charset=utf-8 -> {"error":"нет такой статьи"}

Разбор

Наружу выходит только то, что с большой буквы

Первый блок вывода — три поля, а в структуре их шесть. Куда делись остальные?

secret написан с маленькой буквы, то есть невидим за пределами пакета — и для encoding/json тоже. Это то же правило из урока про пакеты, и здесь у него приятное следствие: случайно отдать наружу внутреннее поле нельзя.

Draft пропал по другой причине: у него тег json:"-", а это значит «не выводить никогда». Так прячут то, что снаружи знать не нужно: черновик, внутренние пометки, служебные поля.

Образ. Витрина магазина. На неё выставляют не всё, что есть на складе, и решает это не склад, а тот, кто оформляет витрину.

Теги: имя, omitempty и omitzero

Тег — это строка в обратных кавычках после поля, и она говорит encoding/json, что делать:

Slug      string    `json:"slug"`
Tags      []string  `json:"tags,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitzero"`

Без тега поле выйдет с тем же именем, что и в Go, — Slug с большой буквы. Снаружи так не принято: в JSON пишут slug или updated_at, и тег приводит имя к общему виду.

omitempty убирает поле, если значение пустое: пустая строка, ноль, пустой срез. Во втором блоке вывода это видно — у статьи без тегов поля tags просто нет.

Для time.Time omitempty не работает: это структура, а структура никогда не «пуста» в смысле omitempty. Отсюда omitzero — он смотрит на нулевое значение типа, и в выводе нулевое время исчезло вместе с ним.

Разбор чужого JSON: лишнее молчит

Третий блок — про то, что приходит снаружи. В теле есть поле лишнее, которого нет в структуре, и обычный разбор не сказал ни слова: <nil>, статья разобралась.

Так задумано, и это удобно, пока не станет опасно: опечатка в имени поля превращается в молчаливо пропущенное значение. Читатель отправил titel вместо title — вы приняли статью без заголовка и не узнали об этом.

Поэтому там, где принимают чужой JSON, включают строгость:

dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()

Тогда лишнее поле становится ошибкой: json: unknown field "лишнее" — и отправитель узнаёт о своей опечатке сразу, а не через неделю.

Число без типа становится float64

Четвёртый блок — короткий, но за него платят часами отладки.

Если разбирать в any (или в map[string]any), все числа станут float64. Не int, каким оно выглядит в тексте, а именно float64 — в JSON нет отдельного целого типа, и Go выбирает самое общее.

Отсюда паника interface conversion: interface {} is float64, not int у тех, кто разбирал в any. Лечение простое: разбирайте в структуру. Тогда int останется int, а лишние поля отвалятся ещё и по типу.

Заголовок ставят раньше кода

writeJSON делает три вещи в строгом порядке:

w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)

Порядок не случаен. WriteHeader отправляет читателю строку статуса и все заголовки разом; после него w.Header().Set уже ничего не меняет — Go молча проигнорирует. Так теряют Content-Type, а браузер потом гадает, что ему прислали.

То же и с телом: первая же запись в w без WriteHeader означает 200. Хотите другой код — поставьте его до того, как напишете хоть байт.

Ошибка — тоже JSON

Последние две строки вывода: и 200, и 404 пришли с application/json.

Это правило, а не мелочь. Тот, кто читает API, разбирает ответ программой: если на ошибку прилетит HTML-страница с картинкой, разбор упадёт на первом же символе, и в журнале будет «неожиданный <» вместо «нет такой статьи».

Поэтому у API один формат на все ответы. Код состояния говорит, что произошло, тело — подробности: {"error": "нет такой статьи"}.

Поток вместо строки: Encoder и Decoder

json.Marshal собирает весь ответ в память, а json.NewEncoder(w).Encode(v) пишет сразу в поток. Для одной статьи разницы нет; для списка из десяти тысяч — это десятки мегабайт, которые не нужно держать целиком.

С разбором так же: json.NewDecoder(r.Body).Decode(&v) читает прямо из тела запроса, не собирая его в строку. И только у декодера есть DisallowUnknownFields, ради которого мы его и завели.

Не забудьте про предел: тело запроса приходит снаружи, а значит, http.MaxBytesReader из урока про загрузку нужен и здесь.

Карта урока

Карта урока: структура, теги и поток

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему поле с маленькой буквы не попадает в JSON?
  2. Что случится с опечаткой в имени поля при обычном разборе?
  3. Почему Content-Type ставят до WriteHeader?

Задание

Обязательное. Добавьте блогу API: GET /api/articles отдаёт список статей, GET /api/articles/{slug} — одну. Ответы и ошибки — JSON с правильным заголовком, черновики и внутренние поля наружу не выходят.

Всё это сделано в step-29 — сверьтесь после того, как напишете сами.

По желанию.

  • Сделайте POST /api/articles, который принимает статью строгим декодером и отвечает 201 с адресом созданной.
  • Добавьте в ответ поле reading_time — метод типа в JSON не попадёт, придётся считать при сборке ответа.
  • Проверьте своё API из терминала: curl -s localhost:8090/api/articles | jq.

Куда это встанет в блоге

У блога появляется второй способ читать его содержимое — и первый, который не зависит от разметки. С этого момента страницу можно переписать, не сломав тех, кто читает данными.

Долги. API отдаёт всё сразу, без постраничности. Оно ничем не ограничено: сто запросов в секунду от одного скрипта никто не остановит. И записи через API у нас нет — а с ней пришлось бы решать, как её защищать.

Ответы

Показать ответы
  1. Потому что encoding/json видит только экспортируемые поля — те же правила, что и у любого другого пакета. Поле с маленькой буквы для него не существует, и отдать его наружу случайно нельзя.
  2. Ничего: обычный разбор молча пропускает поля, которых нет в структуре. Отправитель будет уверен, что прислал заголовок, а вы получите пустую строку. Лечится DisallowUnknownFields.
  3. Потому что WriteHeader отправляет статус и заголовки одним куском. После него заголовки менять поздно — Go проигнорирует, а читатель получит ответ без Content-Type.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.