Приложение на 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 и немного — в отправке сообщений об ошибках.