Привет! Я Владислав Попов, автор курса «Go-разработчик с нуля» в Яндекс Практикуме. В серии статей я хочу помочь начинающим разработчикам упорядочить знания и написать приложение на Go с нуля: мы вместе пройдём каждый шаг и создадим API для получения информации о книгах и управления ими.
В прошлых частях мы создали сервер с тремя эндпоинтами, добавили базовую конфигурацию, логгер и обновили обработчики для возврата ответов в формате JSON. В этой статье будем продолжать развивать наш сервис и узнаем про более продвинутые способы работы с JSON.
Улучшение читаемости ответов
До сих пор, когда мы делали запросы с помощью curl
, мы получали в ответ JSON в виде одной строки без пробелов и переноса строк.
$ curl localhost:4000/v1/healthcheck
{"environment":"development","status":"available","version":"1.0.0"}
$ curl localhost:4000/v1/books/123
{"id":123,"title":"Effective Concurrency in Go","pages":532,"genres":["IT"],"edition":3}
Мы можем сделать вывод более читабельным, используя функцию json.MarshalIndent()
(подробнее о MarshalIndent — в документации JSON) вместо json.Marshal()
. Давайте обновим функцию writeJSON()
:
// cmd/api/helpers.go
package main
func (app *application) writeJSON(w http.ResponseWriter, status int, data interface{}, headers http.Header) error {
// Используем функцию json.MarshalIndent() для добавления переноса строк в закодированный JSON. Мы не используем префикс во втором параметре и используем табуляцию для отступов - в третьем.
js, err := json.MarshalIndent(data, "", "\t")
if err != nil {
return err
}
js = append(js, '\n')
for key, value := range headers {
w.Header()[key] = value
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(js)
return nil
}
Если перезапустить сервер и сделать несколько запросов из терминала, вы сможете увидеть, что ответы теперь читать проще.
$ curl localhost:4000/v1/healthcheck
{
"environment": "development",
"status": "available",
"version": "1.0.0"
}
$ curl localhost:4000/v1/books/123
{
"id": 123,
"title": "Effective Concurrency in Go",
"pages": 532,
"genres": [
"IT"
],
"edition": 3
}
Стоит обратить внимание, что, хотя читабельность ответов улучшилась, чем-то пришлось пожертвовать. Во-первых, ответы теперь стали немного больше по объему. Во-вторых, теперь Go выполняет дополнительную работу, чтобы расставить пробелы, что в свою очередь снижает производительность.
Давайте теперь обновим наши ответы, чтобы они были полноценным JSON-объектом. Вот так:
{
"book": {
"id": 123,
"title": "Effective Concurrency in Go",
"pages": 532,
"genres": [
"IT"
],
"edition": 3
}
}
Обратите внимание, что данные книги теперь вложены в ключ "book"
, а не являются объектом верхнего уровня.
Такое оформление ответа не является обязательным, но оно имеет ряд преимуществ:
Включение ключевого имени (например,
"book"
) на верхнем уровне JSON помогает сделать ответ более самодокументируемым. Людям, которые увидят ответ вне контекста, будет немного проще понять, к чему относятся данные.Это снижает риск ошибок на стороне клиента. Чтобы получить данные, клиент должен явно указать на них с помощью ключа
"book"
.Если мы всегда будем упаковывать данные, возвращаемые API, то устраним уязвимость в системе безопасности в старых браузерах, которая может возникнуть, если возвращать массив JSON в качестве ответа.
Есть несколько способов, которые мы могли бы использовать для упаковки ответов API, но будем придерживаться простоты и сделаем это с помощью мапы map[string]interface{}
.
Давайте обновим cmd/api/helpers.go
:
// cmd/api/helpers.go
package main
type envelope map[string]interface{}
func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error {
js, err := json.MarshalIndent(data, "", "\t")
if err != nil {
return err
}
js = append(js, '\n')
for key, value := range headers {
w.Header()[key] = value
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(js)
return nil
}
Теперь нужно обновить showBookHandler()
, чтобы создать экземпляр мапы, содержащей данные о книге, и передать его методу writeJSON()
, вместо передачи данных напрямую.
// cmd/api/books.go
func (app *application) showBookHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
book := data.Book{
ID: id,
CreateAt: time.Now(),
Title: "Effective Concurrency in Go",
Pages: 532,
Genres: []string{"IT"},
Edition: 3,
}
err = app.writeJSON(w, http.StatusOK, envelope{"book": book}, nil)
if err != nil {
app.logger.Println(err)
http.Error(w, "Сервер обнаружил проблему и не смог обработать ваш запрос", http.StatusInternalServerError)
}
}
Нам также нужно обновить код в healthcheckHandler()
, так как он тоже использует writeJSON()
.
func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
// Объявите мапу, содержащую данные для ответа. Обратите внимание, что способ, которым
// мы это сделали, означает, что данные о среде выполнения и версии теперь будут вложены
// в ключ system_info в ответе JSON.
env := envelope{
"status": "available",
"system_info": map[string]string{
"environment": app.config.env,
"version": version,
},
}
err := app.writeJSON(w, http.StatusOK, env, nil)
if err != nil {
app.logger.Panicln(err)
http.Error(w, "Сервер обнаружил проблему и не смог обработать ваш запрос", http.StatusInternalServerError)
}
}
Давайте проверим обновления. Перезапустите сервер и сделайте запрос:
$ curl localhost:4000/v1/books/123
{
"book": {
"id": 123,
"title": "Effective Concurrency in Go",
"pages": 532,
"genres": [
"IT"
],
"edition": 3
}
}
$ curl localhost:4000/v1/healthcheck
{
"status": "available",
"system_info": {
"environment": "development",
"version": "1.0.0"
}
}
Продвинутая кастомизация JSON
Мы уже смогли расширить возможности настройки JSON-ответов с помощью тегов структуры, красивого форматирования и оборачивания данных. Прежде чем продолжить, давайте сначала разберёмся, как Go кодирует JSON за кулисами.
Прежде чем кодировать определённый тип в JSON, Go проверяет, реализован ли для этого типа метод MarshalJSON()
. Если реализован, Go вызывает этот метод. Строго говоря, когда Go кодирует определённый тип в JSON, он проверяет, реализует ли этот тип интерфейс json.Marshaler
. Вот он:
type Marshaler interface {
MarshalJSON() ([]byte, error)
}
Если тип удовлетворяет интерфейсу, то Go вызывает для этого типа метод MarshalJSON()
и использует возвращаемый массив []byte
в качестве закодированного значения JSON.
Если у типа нет метода MarshalJSON()
, то Go пытается закодировать JSON на основе собственного набора правил.
Таким образом, если мы хотим изменить способ кодирования JSON для какого-либо типа, всё, что нам нужно, — это реализовать для него метод MarshalJSON()
, который возвращает слайс байт []byte
в JSON-представлении.
Давайте сделаем кастомизацию для поля Pages
в нашей структуре Book
.
Кастомизация поля Pages
Когда наша структура Book
кодируется в JSON, поле Pages
(типа int32
) кодируется как просто целое число. Давайте сделаем так, чтобы оно кодировалось как строка <количество> страниц
.
{
"book": {
"id": 123,
"title": "Effective Concurrency in Go",
"pages": "532 pages",
"genres": [
"IT"
],
"edition": 3
}
}
Есть несколько способов добиться этого, но самый простой — создать пользовательский тип для поля Pages
и реализовать для него метод MarshalJSON()
.
Давайте создадим новый файл internal/data/pages.go
для хранения логики типа Pages
:
package data
import (
"fmt"
"strconv"
)
// Объявляем пользовательский тип Pages, который имеет базовый тип int32
// такой же, как был у поля Pages структуры Book
type Pages int32
// Реализуем метод MarshalJSON(), чтобы тип Pages удовлетворял интерфейсу json.Marshaler
// Метод должен возвращать количество страниц в книге в кодировке JSON в таком виде:
// "<количество> страниц"
func (p Pages) MarshalJSON() ([]byte, error) {
// Создаем строку, которая содержит количество страниц в нужном нам формате
jsonValue := fmt.Sprintf("%d pages", p)
// Используем функцию strconv.Quote() для созданной строки, чтобы заключить её в
// двойные кавычки. Это нужно, так как строки в JSON заключены в кавычки.
quotedJSONValue := strconv.Quote(jsonValue)
// Преобразовываем строку в массив байтов и возвращаем её
return []byte(quotedJSONValue), nil
}
Следует обратить внимание на пару вещей:
1. Если метод MarshalJSON()
возвращает значение в виде строки JSON, как у нас, то перед тем как вернуть строку, нужно заключить её в двойные кавычки. Если этого не сделать, она не будет интерпретироваться как строка JSON, и вы получите ошибку.
2. Мы намеренно делаем приёмник, не указатель на тип, как func (p *Pages) MarshalJSON()
. Это даёт больше гибкости, так как в этом случае метод сможет работать как со значениями Pages
, так и с указателями на Pages
.
Теперь, когда метод MarshalJSON()
для нашего типа Pages
определён, откройте файл internal/data/books.go
и обновите структуру Book
:
package data
import "time"
type Book struct {
ID int64 `json:"id"`
CreateAt time.Time `json:"-"`
Title string `json:"title"`
Year int32 `json:"year,omitempty"`
// Используйте тип Pages вместо int32. Обратите внимание, что omitempty
// по-прежнему будет работать, и если поле Pages будет равно 0, то оно будет считаться пустым
// и не будет включено в JSON, а метод MarshalJSON(), который мы создали,
// вообще не будет вызван.
Pages Pages `json:"pages,omitempty"`
Genres []string `json:"genres,omitempty"`
Edition int32 `json:"edition"`
}
Давайте попробуем перезапустить сервер и сделать запрос:
$ curl localhost:4000/v1/books/123
{
"book": {
"id": 123,
"title": "Effective Concurrency in Go",
"pages": "532 pages",
"genres": [
"IT"
],
"edition": 3
}
}
Вы должны увидеть такой ответ. Обратите внимание на то, как представлены страницы в ответе.
В общем, это довольно удобный способ создания пользовательского JSON. Наш код лаконичен и понятен, и у нас есть собственный тип Pages
, который мы можем использовать везде и всегда, когда нам это нужно.
Но есть и обратная сторона. Важно знать, что использование пользовательских типов иногда может приводить к затруднениям при интеграции вашего кода с другими пакетами. Вам может потребоваться выполнить преобразование типов, чтобы изменить пользовательский тип на значение, которое понимают и принимают другие пакеты.
На этом третья часть закончена. В ней вы узнали о продвинутой работе с JSON и реализовали метод MarshalJSON()
, чтобы кастомизировать один из ключей JSON. На данный момент API отправляет хорошо отформатированные JSON-ответы на успешные запросы.
Если клиент отправляет неверный запрос или что-то идёт не так в нашем приложении, мы по-прежнему отправляем ему текстовое сообщение об ошибке с помощью функций http.Error()
и http.NotFound()
.
В следующей части мы исправим это и добавим несколько помощников для обработки ошибок и отправки соответствующих ответов клиентам.