golang

Приложение на Go шаг за шагом. Часть 2: отправка ответов в формате JSON

  • пятница, 28 февраля 2025 г. в 00:00:11
https://habr.com/ru/companies/yandex_praktikum/articles/885946/

Привет! Я Владислав Попов, автор курса «Go-разработчик с нуля» в Яндекс Практикуме. В серии статей я хочу помочь начинающим разработчикам упорядочить знания и написать приложение на Go с нуля: мы вместе пройдём каждый шаг и создадим API для получения информации о книгах и управления ими.

В прошлой статье мы уже создали сервер и добавили обработчики для трёх эндпоинтов. Также добавили простенькую конфигурацию и логгер, которые впоследствии будем развивать. В этой части статьи обновим наши обработчики, чтобы они возвращали ответы в формате JSON вместо обычного текста.


Что такое 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.

Итак, начинаем!

Фиксированный ответ JSON

Давайте начнём с обновления хендлера 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

Давайте перейдём к чему-то более интересному и посмотрим, как кодировать собственные объекты. Для сериализации JSON используем пакет encoding/json. У нас есть два варианта: можем либо вызвать функцию json.Marshal(), либо объявить и использовать тип json.Encoder

Функция json.Marshal()

Принцип работы 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 с парами «ключ — значение» в алфавитном порядке.

Вспомогательный метод writeJSON()

По мере роста 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.Encoder

Кроме 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-ответе. Такие поля можно скрыть.

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