Привет! Я Владислав Попов, автор курса «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()

В следующей части мы исправим это и добавим несколько помощников для обработки ошибок и отправки соответствующих ответов клиентам.

Комментарии (0)