Приложение на Go шаг за шагом. Часть 2: отправка ответов в формате JSON
- пятница, 28 февраля 2025 г. в 00:00:11
Привет! Я Владислав Попов, автор курса «Go-разработчик с нуля» в Яндекс Практикуме. В серии статей я хочу помочь начинающим разработчикам упорядочить знания и написать приложение на Go с нуля: мы вместе пройдём каждый шаг и создадим API для получения информации о книгах и управления ими.
В прошлой статье мы уже создали сервер и добавили обработчики для трёх эндпоинтов. Также добавили простенькую конфигурацию и логгер, которые впоследствии будем развивать. В этой части статьи обновим наши обработчики, чтобы они возвращали ответы в формате JSON вместо обычного текста.
JSON (аббревиатура от JavaScript Object Notation) — это читаемый человеком текстовый формат, который можно использовать для представления структурированных данных. Например, в нашем проекте книгу можно представить в виде следующего JSON-кода:
{
"id": 12,
"title": "Dune",
"genre": [
"fantastic",
"drama"
],
"pages": 1739
}
Краткий обзор синтаксиса JSON:
Фигурные скобки {}
определяют объект JSON, который состоит из пар «ключ — значение», разделённых запятыми.
Ключи должны быть строками, а значения могут быть строками, числами, логическими значениями, другими объектами JSON или массивами любого из этих типов. Они заключены в квадратные скобки []
.
Строки должны быть заключены в двойные кавычки "
, а не в одинарные '
. Логические значения могут быть либо true
, либо false
.
Вне строк пробелы в JSON не имеют никакого значения, поэтому мы могли бы представить ту же книгу так
{"id":12,"title":"Dune","genre":["fantastic","drama"],"pages":1739}
В этой статье вы узнаете:
Как отправлять ответы в формате JSON из вашего REST API, включая ответы на ошибки.
Как закодировать объекты Go в JSON с помощью пакета encoding/json
.
Как настроить кодирование объектов Go в JSON с помощью тегов структур и с помощью интерфейса json.Marshal
.
Как создать многократно используемый помощник для отправки ответов в формате JSON, который обеспечит разумную и последовательную структуру всех ваших ответов API.
Итак, начинаем!
Давайте начнём с обновления хендлера healthcheckHandler()
, который будет обновлять сформированный ответ JSON следующего вида:
{"status": "available", "environment": "development", "version": "1.0.0"}
Следует отметить, что JSON — это просто текст, хотя в нём и присутствуют некоторые управляющие символы, которые придают тексту структуру и определённый смысл. По сути, это обычный текст.
Так как JSON — это текст, мы можем отправлять JSON-ответ из обработчиков Go так же, как и любой другой текстовый ответ: с помощью функций w.Write()
, io.WriteString()
или одной из функций fmt.Fprint()
.
Фактически единственное, что нужно сделать, — это установить заголовок Content-Type: application/json
в ответе, чтобы клиент знал, что получает JSON, и мог его интерпретировать соответственно.
Откроем файл cmd/api/healthcheck.go
и обновим его, как показано ниже:
package main
import (
"fmt"
"net/http"
)
func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
// Создаем жёстко закодированную строку JSON. Обратите внимание, что мы используем
// неформатируемый строковый литерал, заключённый в обратные кавычки, чтобы при включении
// в JSON двойных кавычек нам не пришлось их экранировать. Мы также используем
// спецификатор `%q`, чтобы заключить значения переменных в двойные кавычки.
js := `{"status": "available", "environment": %q, "version": %q}`
js = fmt.Sprintf(js, app.config.env, version)
// Устанавливаем "Content-Type: application/json" в заголовок ответа.
// Если вы этого не сделаете, будет отправлен ответ с заголовком по умолчанию
// "Content-Type: text/plain; charset=utf-8"
w.Header().Set("Content-Type", "application/json")
// Записываем JSON в тело ответа
w.Write([]byte(js))
}
Сохраним изменения, перезапустим сервер и проверим, как работает конечная точка GET /v1/healthcheck
, с помощью curl
. Ответ должен быть таким:
HTTP/1.1 200 OK
Content-Type: application/json
Date: Mon, 13 Jan 2025 18:32:18 GMT
Content-Length: 73
{"status": "available", "environment": "development", "version": "1.0.0"}
Конечно, использование статического JSON-ответа — это достаточно простой приём, но он вполне допустим. Он может быть полезен для конечных точек API, которые всегда возвращают один и тот же JSON.
Давайте перейдём к чему-то более интересному и посмотрим, как кодировать собственные объекты. Для сериализации JSON используем пакет encoding/json
. У нас есть два варианта: можем либо вызвать функцию json.Marshal()
, либо объявить и использовать тип json.Encoder
.
Принцип работы json.Marshal()
довольно прост: мы передаём ему в качестве параметра собственный объект Go, и он возвращает JSON-представление этого объекта в виде массива []byte
. Сигнатура функции выглядит так:
func Marshal(v interface{}) ([]byte, error)
Параметр v
типа interface{}
(известен как «пустой интерфейс») означает, что мы можем передать любой тип Go в json.Marshal()
в качестве аргумента.
Давайте обновим наш обработчик healthcheckHandler()
, чтобы он использовал json.Marshal()
для создания ответа JSON из мапы:
// cmd/api/healthcheck.go
package main
import (
"encoding/json"
"net/http"
)
func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
// Создаем мапу, которая хранит информацию, которую мы хотим отправить в ответе
data := map[string]string {
"status": "available",
"environment": app.config.env,
"version": version,
}
// Передаём мапу в `json.Marshal()`, которая возвращает слайс []byte
// содержащий JSON. Если при сериализации произойдет ошибка, мы залогируем её и
// отправим клиенту сообщение об ошибке.
js, err := json.Marshal(data)
if err != nil {
app.logger.Println(err)
http.Error(w, "The server could not process your request", http.StatusInternalServerError)
return
}
// Добавим в наш JSON символ новой строки, чтобы было удобно его просматривать
// в терминале.
js = append(js, '\n')
// На этом этапе мы знаем, что кодирование данных прошло без проблем, поэтому мы
// можем спокойно устанавливать любые необходимые HTTP-заголовки для успешного ответа.
w.Header().Set("Content-Type", "application/json")
// Используем w.Write() для отправки фрагмента []byte, содержащего JSON, в качестве тела ответа.
w.Write([]byte(js))
}
Если перезапустим сервер и откроем в браузере ссылку localhost:4000/v1/healthcheck
, мы должны получить ответ, похожий на этот:
{"environment":"development","status":"available","version":"1.0.0"}
Мапа была преобразована в объект JSON с парами «ключ — значение» в алфавитном порядке.
По мере роста API мы будем отправлять много ответов в формате JSON, поэтому имеет смысл перенести часть этой логики в отдельный вспомогательный метод writeJSON()
.
Откроем файл cmd/api/helpers.go
и добавим метод writeJSON()
:
// cmd/api/helpers.go
// writeJSON вспомогательная функция для отправки ответов. Она принимает в качестве аргументов
// http.ResponseWriter, код ответа HTTP, данные для кодирования и заголовки, которые мы хотим
// включить в ответ.
func (app *application) writeJSON(w http.ResponseWriter, status int, data interface{}, headers http.Header) error {
// Сериализуем данные в JSON. Возвращаем ошибку, если она возникает.
js, err := json.Marshal(data)
if err != nil {
return err
}
// Добавим в наш JSON символ новой строки, чтобы было удобно его просматривать
// в терминале.
js = append(js, '\n')
// На этом этапе мы знаем, что не столкнёмся с другими ошибками до отправки ответа,
// поэтому можно безопасно добавлять любые заголовки.
// Проходимся по мапе заголовков и добавляем каждый в http.ResponseWriter.
for key, value := range headers {
w.Header()[key] = value
}
// Добавим заголовок "Content-Type: application/json", затем укажем код состояния и
// JSON-ответ.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(js)
return nil
}
Теперь, когда у нас есть помощник writeJSON()
, мы можем значительно упростить наш обработчик healthcheckHandler()
.
package main
import (
"net/http"
)
func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]string{
"status": "available",
"environment": app.config.env,
"version": version,
}
err := app.writeJSON(w, http.StatusOK, data, nil)
if err != nil {
app.logger.Println(err)
http.Error(w, "The server could not process your request", http.StatusInternalServerError)
}
}
Если перезапустим сервер и откроем в браузере ссылку localhost:4000/v1/healthcheck
, мы должны получить тот же ответ, что и раньше.
Кроме json.Marshal()
для сериализации данных можно использовать json.Encoder
. Например, его можно использовать так:
func (app *application) exampleHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]string{
"hello": "world",
}
// Устанавливаем заголовок ответа "Content-Type: application/json"
w.Header().Set("Content-Type", "application/json")
// Используем функцию json.NewEncoder() для инициализации экземпляра json.Encoder,
// который записывает данные в http.ResponseWriter. Затем вызываем метод Encode(),
// передавая данные, которые хотим преобразовать в JSON (в данном случае это
// приведённая выше мапа). Если данные успешно преобразованы в JSON,
// они будут записаны в http.ResponseWriter.
err := json.NewEncoder(w).Encode(data)
if err != nil {
app.logger.Println(err)
http.Error(w, "The server could not process your request", http.StatusInternalServerError)
}
}
Этот способ элегантен, но если присмотреться, можно увидеть некоторую проблему.
Когда мы вызываем json.NewEncoder(w).Encode(data)
, JSON-документ создаётся и записывается в http.ResponseWriter
за один шаг. Это означает отсутствие возможности устанавливать заголовки HTTP-ответа в зависимости от того, возвращает ли метод Encode() ошибку.
Например, если нам нужно добавить заголовок Cache-Control
при успешном ответе, при успешной отправке ответа и не передавать его при возникновении ошибки, сделать это с помощью json.Encoder
будет непросто. Придётся установить заголовок заранее и удалить его в случае ошибки. Довольно сомнительный способ.
Другой вариант — записать JSON во временный буфер bytes.Buffer
, а не напрямую в http.ResponseWriter
. Это позволит проверить на наличие ошибок, прежде чем устанавливать заголовок Cache-Control
и копировать JSON из bytes.Buffer
в http.ResponseWriter
. Но как только вы начнёте это делать, вы поймёте, что будет проще и понятнее (а также немного быстрее) использовать json.Marshal()
.
Теперь давайте вернёмся к нашему обработчику showBookHandler()
и обновим его, чтобы он возвращал ответ в формате JSON, который будет представлять одну книгу в нашей системе:
{
"id": 123,
"title": "Effective Concurrency in Go",
"author": "Burak Serdar",
"year": 2023,
"tags": [
"go",
"programming",
"concurrency"
],
"pages": 195
}
Теперь вместо сериализации мапы, как мы делали, будем сериализовать структуру Book
.
Сначала нам нужно определить структуру Book
. Мы сделаем это в новом пакете, который впоследствии будет содержать все пользовательские типы данных для нашего проекта, а также логику взаимодействия с базой данных.
Создадим новую директорию /internal/data
, а в ней файл books.go
:
$ mkdir internal/data
$ touch internal/data/books.go
И в этом файле определим структуру Book
:
// internal/data/books.go
package data
import "time"
type Book struct {
ID int64 // уникальный ID книги
CreatedAt time.Time // время, когда книга была добавлена в базу
Author string // автор
Title string // название книги
Year int32 // год публикации
Tags []string // теги, по которым можно найти книгу
Pages int32 // количество страниц
}
Теперь давайте обновим наш обработчик showBookHandler()
, в котором мы инициализируем экземпляр структуры Book
, а затем отправим JSON с помощью нашего помощника writeJSON()
.
// cmd/api/books.go
package main
import (
"fmt"
"net/http"
"time"
"internal/data"
)
func (app *application) showBookHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
// Создаём новый экземпляр структуры Book, которая содержит идентификатор, взятый из
// URL-адреса. Обратите внимание, мы намеренно не установили значение поля Year.
book := data.Book{
ID: id,
CreatedAt: time.Now(),
Title: "Effective Concurrency in Go",
Author: "Burak Serdar",
Tags: []string{"go", "programming", "concurrency"},
Pages: 195,
}
// Сериализуем структуру в JSON и отправляем как HTTP-ответ.
err = app.writeJSON(w, http.StatusOK, book, nil)
if err != nil {
app.logger.Println(err)
http.Error(w, "The server could not process your request", http.StatusInternalServerError)
}
}
Давайте проверим, как всё работает. Перезапустим сервер и откроем в браузере http://localhost:4000/v1/books/123
. Мы должны увидеть такой JSON-ответ:
{"ID":123,"CreatedAt":"2025-02-03T02:02:51.064136366+03:00","Title":"Effective Concurrency in Go","Author":"Burak Serdar","Year":0,"Tags":["go","programming","concurrency"],"Pages":195}
Обратите внимание, по умолчанию имена ключей в JSON-объекте имеют те же имена, что и имена полей структуры. Это можно изменить с помощью структурных тегов.
Одна из приятных особенностей сериализации структур — это возможность настроить JSON, добавив к полям структуры «структурные теги». Чаще всего структурные теги используются для изменения имён ключей, которые отображаются в объекте JSON. Это может быть полезно, если имена полей структуры не подходят для общедоступных ответов или вы хотите использовать альтернативный стиль написания в выводе JSON.
Давайте добавим в нашу структуру Book
структурные теги, чтобы имена ключей были в стиле snake_case
:
// internal/data/books.go
package data
// Добавляем структурные теги к полям структуры
type Book struct {
ID int64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Title string `json:"title"`
Author string `json:"author"`
Year int32 `json:"year"`
Tags []string `json:"tags"`
Pages int32 `json:"pages"`
}
И если перезапустить сервер и перейти на http://localhost:4000/v1/books/123
, то вывод теперь будет следующим:
{"id":123,"created_at":"2025-02-03T02:25:00.884851042+03:00","title":"Effective Concurrency in Go","author":"Burak Serdar","year":0,"tags":["go","programming","concurrency"],"pages":195}
Иногда нужно, чтобы определённые поля структуры не были представлены в JSON-ответе. Такие поля можно скрыть.
Видимостью полей структуры можно управлять с помощью директив omitempty
и -
.
Директиву -
можно использовать, если вы не хотите, чтобы определённое поле структуры отображалось в JSON. Это полезно для полей, содержащих внутреннюю системную информацию, которая не имеет отношения к вашим пользователям, или конфиденциальную информацию, которую вы не хотите раскрывать (например, хэш пароля).
Директива omitempty
скрывает поле, если значение поля структуры пусто:
0
для чисел,
""
для строк,
false
для bool
,
пустой слайс, массив или мапа.
Чтобы продемонстрировать, как использовать эти директивы, давайте внесём ещё пару изменений в нашу структуру Book
. Поле CreatedAt
не имеет значения для конечных пользователей, поэтому давайте всегда скрывать его в выводе с помощью директивы -
. Мы также будем использовать директиву omitempty
, чтобы скрывать поля Year
и Tags
в выводе, если они пусты.
// internal/data/books.go
package data
// Добавляем структурные теги к полям структуры
type Book struct {
ID int64 `json:"id"`
CreatedAt time.Time `json:"-"`
Title string `json:"title"`
Author string `json:"author"`
Year int32 `json:"year,omitempty"`
Tags []string `json:"tags,omitempty"`
Pages int32 `json:"pages"`
}
Перезапустим сервер, вывод должен быть похож на этот:
{"id":123,"title":"Effective Concurrency in Go","author":"Burak Serdar","tags":["go","programming","concurrency"],"pages":195}
Как видим, поле CreatedAt
больше не отображается в JSON. Поле Year
также не отображается благодаря директиве omitempty
и значению 0. Другое поле Tags
с директивой omitempty
отображается, так как не пустое.
На этом вторая часть закончена. Мы познакомились с сериализацией и десериализацией JSON. Узнали, что сериализовать данные в JSON можно двумя способами, и выяснили, какой способ в нашем случае предпочтительнее и почему. Мы также добавили вспомогательный метод writeJSON)()
к своему проекту, который отправляет ответы в формате JSON.
В следующей статье разберёмся с десериализацией JSON и немного — в отправке сообщений об ошибках.