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 из урока про загрузку нужен и здесь.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему поле с маленькой буквы не попадает в JSON?
- Что случится с опечаткой в имени поля при обычном разборе?
- Почему
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 у нас нет — а с ней пришлось бы решать, как её защищать.
Ответы
Показать ответы
- Потому что
encoding/jsonвидит только экспортируемые поля — те же правила, что и у любого другого пакета. Поле с маленькой буквы для него не существует, и отдать его наружу случайно нельзя. - Ничего: обычный разбор молча пропускает поля, которых нет в структуре. Отправитель будет уверен, что прислал заголовок, а вы получите пустую строку. Лечится
DisallowUnknownFields. - Потому что
WriteHeaderотправляет статус и заголовки одним куском. После него заголовки менять поздно — Go проигнорирует, а читатель получит ответ безContent-Type.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.