Приложение на Go шаг за шагом. Часть 3: форматирование и обёртывание ответов JSON

от автора

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/927524/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *