Разработка Web-приложений и микросервисов на Go с Gin, часть 3

Создаём веб-приложение с Go, Gin и React

Это продолжение материала, часть 1, часть 2

Вывод топика

У нас пока не работают ссылки на топики из общего списка. Сейчас мы добавим обработчики клика и шаблон для вывода топика.

Настройка роутов

Мы можем создать роут для обработки запросов для топика подобно роуту из предыдущей части. Однако, мы должны учитывать, что хотя обработчик для всех топиков будет один, URL каждого топика должен быть уникальным. Gin позволяет это сделать с помощью передачи параметров в роут:

router.GET("/article/view/:article_id", getArticle)

Этот роут будет обрабатывать соответствующие указанному пути запросы, а также хранить значение параметра, переданного в роут — article_id, который мы обработаем в обработчике роута. Обработчиком нашего роута будет функция getArticle.

Изменённый файл routes.go:


// routes.go

package main

func initializeRoutes() {

  // обработчик главного роута
  router.GET("/", showIndexPage)

  // Обработчик GET-запросов на /article/view/некоторый_article_id
  router.GET("/article/view/:article_id", getArticle)

}
Шаблоны

Для вывода топика нам нужно создать новый шаблон templates/article.html. Он будет создан так же, как шаблон index.html, но с небольшим отличием: вместо передачи в него переменной со списком топиков, мы будем передавать в него только один топик.

Посмотреть код шаблона article.html можно на Github.

Определяем требования к обработчику роутов юнит-тестами

Тест обработчика будет проверять выполнение следующих условий:

  1. Обработчик отвечает статус-кодом HTTP 200,
  2. Возвращаемый HTML содержит тег title, содержащий название полученного топика.

Код теста будет в функции TestArticleUnauthenticated в файле handlers.article_test.go. Вспомогательные функции мы разместим в файле common_test.go.

Создаём обработчик роута

Итак, что должен делать обработчик роута для топика — getArticle:

1. Получить ID топика для вывода

Для вывода нужного топика, мы должны получить его ID из контекста. Примерно так:

c.Param("article_id")

где c — это Контекст Gin, который передаётся параметром в любой обработчик при разработке с Gin.

2. Получить сам топик

Это можно сделать с помощью функции getArticleByID() из файла models.article.go:

article, err := getArticleByID(articleID)

Функция getArticleByID (в models.article.go) выглядит так:


func getArticleByID(id int) (*article, error) {
  for _, a := range articleList {
    if a.ID == id {
      return &a, nil
    }
  }
  return nil, errors.New("Article not found")
}

Эта функция считывает список топиков в цикле и возвращает топик, ID которого соответствует переданному ID. Если такого топика нет, она возвращает ошибку.

3. Обработать шаблон article.html, передав в него топик

Код ниже как раз делает это:


c.HTML(
    // Зададим HTTP статус 200 (OK)
    http.StatusOK,
    // Используем шаблон article.html
    "article.html",
    // Передадим данные в шаблон
    gin.H{
        "title":   article.Title,
        "payload": article,
    },
)

Обновлённый файл handlers.article.go будет таким:


// handlers.article.go

package main

import (
  "net/http"
  "strconv"

  "github.com/gin-gonic/gin"
)

func showIndexPage(c *gin.Context) {
  articles := getAllArticles()

  // Вызовем метод HTML из Контекста Gin для обработки шаблона
  c.HTML(
    // Зададим HTTP статус 200 (OK)
    http.StatusOK,
    // Используем шаблон index.html
    "index.html",
    // Передадим данные в шаблон
    gin.H{
      "title":   "Home Page",
      "payload": articles,
    },
  )

}

func getArticle(c *gin.Context) {
  // Проверим валидность ID
  if articleID, err := strconv.Atoi(c.Param("article_id")); err == nil {
    // Проверим существование топика
    if article, err := getArticleByID(articleID); err == nil {
      // Вызовем метод HTML из Контекста Gin для обработки шаблона
      c.HTML(
        // Зададим HTTP статус 200 (OK)
        http.StatusOK,
        // Используем шаблон index.html
        "article.html",
        // Передадим данные в шаблон
        gin.H{
          "title":   article.Title,
          "payload": article,
        },
      )

    } else {
      // Если топика нет, прервём с ошибкой
      c.AbortWithError(http.StatusNotFound, err)
    }

  } else {
    // При некорректном ID в URL, прервём с ошибкой
    c.AbortWithStatus(http.StatusNotFound)
  }
}

Если сейчас собрать и запустить наше приложение, при открытии localhost:8080/article/view/1 в браузере оно будет выглядеть так:

Разработка Web-приложений и микросервисов на Go с Gin, часть 3

Новые файлы, добавленные в этом разделе:


└── templates
    └── article.html
Ответ в JSON/XML

В этом разделе мы немного перепишем приложение так, что оно, в зависимости от заголовков запроса, будет отвечать в формате HTML, JSON или XML.

Повторно используемые функции

До сих пор мы использовали метод HTML Контекста Gin для обработки шаблонов прямо из обработчика. Этот способ хорошо если мы всегда будем выводить только в формате HTML. Однако, если мы хотим менять формат ответа, к примеру, на основе какого-то параметра, мы должны переписать эту часть функции, чтобы она делала только валидацию данных и их получение, а выводом в шаблон будет заниматься другая функция в зависимости от формата вывода на основе заголовка Accept. Мы создадим эту функция в файле main.go и она будет общая для всех обработчиков.

В Gin в Контексте, переданном обработчику роута, есть поле Request. В этом поле есть Header, в котором содержатся все заголовки запроса. Для получения заголовка Accept мы можем использовать метод Get в Header, вот так:

// c - это Gin Context
c.Request.Header.Get("Accept")
  • Если заголовок: application/json, функция выводит JSON,
  • Если заголовок: application/xml, функция выводит XML, и
  • Если заголовок любой другой или вообще пустой, функция выводит HTML.

Полный код функции:


// Render one of HTML, JSON or CSV based on the 'Accept' header of the request
// If the header doesn't specify this, HTML is rendered, provided that
// the template name is present
func render(c *gin.Context, data gin.H, templateName string) {

  switch c.Request.Header.Get("Accept") {
  case "application/json":
    // Respond with JSON
    c.JSON(http.StatusOK, data["payload"])
  case "application/xml":
    // Respond with XML
    c.XML(http.StatusOK, data["payload"])
  default:
    // Respond with HTML
    c.HTML(http.StatusOK, templateName, data)
  }

}
Изменяем требования к обработчику роутов

Так как мы теперь должны проверить ответ в JSON и XML если задан специальный заголовок, нам нужно добавить тесты в файл handlers.article_test.go для проверки этих условий:

  1. Проверить, что приложение вернёт список топиков в формате JSON если заголовок Accept равен application/json
  2. Проверить, что приложение вернёт список топиков в формате XML если заголовок Accept равен application/xml

Мы добавим соответствующие функции TestArticleListJSON и TestArticleXML.

Обновляем обработчики

Обработчик у нас уже полностью готов, нам нужно только изменить метод обработки c.HTML на соответствующий требуемому формату метод вывода.

К примеру, обработчик роута showIndexPage будет изменён с такого:


func showIndexPage(c *gin.Context) {
  articles := getAllArticles()

  // Call the HTML method of the Context to render a template
  c.HTML(
    // Set the HTTP status to 200 (OK)
    http.StatusOK,
    // Use the index.html template
    "index.html",
    // Pass the data that the page uses
    gin.H{
      "title":   "Home Page",
      "payload": articles,
    },
  )

}

на такой:


func showIndexPage(c *gin.Context) {
  articles := getAllArticles()

  // Call the render function with the name of the template to render
  render(c, gin.H{
    "title":   "Home Page",
    "payload": articles}, "index.html")

}

Получаем список топиков в формате JSON

Чтобы увидеть приложение в работе, соберём его и запустим. Затем выполним следующую команду:

curl -X GET -H "Accept: application/json" http://localhost:8080/

Она должна вернуть следующее:

[{"id":1,"title":"Article 1","content":"Article 1 body"},{"id":2,"title":"Article 2","content":"Article 2 body"}]

Как вы видите, мы получили ответ в формате JSON, передав заголовок Accept как application/json.

Список топиков в формате XML

Теперь запросим детали конкретной статьи в формате XML. Для этого запустите приложение как написано выше и затем выполните команду:

curl -X GET -H "Accept: application/xml" http://localhost:8080/article/view/1

В ответ должно прийти следующее:


<article><ID>1</ID><Title>Article 1</Title><Content>Article 1 body</Content></article>
Тестирование приложения

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

go test -v
Результат должен быть примерно таким:

=== RUN TestShowIndexPageUnauthenticated
[GIN] 2016/06/14 - 19:07:26 | 200 | 183.315µs | | GET /
--- PASS: TestShowIndexPageUnauthenticated (0.00s)
=== RUN TestArticleUnauthenticated
[GIN] 2016/06/14 - 19:07:26 | 200 | 143.789µs | | GET /article/view/1
--- PASS: TestArticleUnauthenticated (0.00s)
=== RUN TestArticleListJSON
[GIN] 2016/06/14 - 19:07:26 | 200 | 51.087µs | | GET /
--- PASS: TestArticleListJSON (0.00s)
=== RUN TestArticleXML
[GIN] 2016/06/14 - 19:07:26 | 200 | 38.656µs | | GET /article/view/1
--- PASS: TestArticleXML (0.00s)
=== RUN TestGetAllArticles
--- PASS: TestGetAllArticles (0.00s)
=== RUN TestGetArticleByID
--- PASS: TestGetArticleByID (0.00s)
PASS
ok github.com/demo-apps/go-gin-app 0.084s

Как мы видим, эта команда запускает все написанные нами тесты и, в нашем случае, сообщает, что всё работает как положено. Если вы присмотритесь к выводу, то увидите, что Go также сделал и HTTP запросы для нас, проверив обработчики роутов.

Заключение

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

Код приложения можно скачать в этом Github репозитории.

Gin очень прост для того, чтобы начать писать веб-приложения — вкупе со встроенной функциональностью Go, он легко позволяет строить высококачественные, хорошо покрытые тестами веб-приложения и микросервисы.

По материалам Building Go Web Applications and Microservices Using Gin

Разработка Web-приложений и микросервисов на Go с Gin, часть 2

Создаём веб-приложение с Go, Gin и React

Это продолжение материала, часть 1.

Выводим список топиков

Сейчас мы добавим функционал для показа списка всех топиков на главной странице.

Настройка роута

В предыдущем разделе мы создали роут и его описание в файле main.go. С ростом размера приложения будет лучше перенести описания роутов в отдельный файл. Мы создадим функцию initializeRoutes() в файле routes.go и будем вызывать её из функции main() для настройки всех роутов. Вместо создания линейного обработчика роутов, мы сделаем роуты отдельными функциями.

После всего этого файл routes.go будет таким:

// routes.go

package main

func initializeRoutes() {

  // определение роута главной страницы
  router.GET("/", showIndexPage)
}

Так как мы выводим список топиков на главной странице, нам не нужно будет создавать больше никаких других роутов.

Файл main.go должен быть сейчас таким:

// main.go

package main

import "github.com/gin-gonic/gin"

var router *gin.Engine

func main() {

  // роутер по-умолчанию в Gin
  router = gin.Default()

  // Обработаем шаблоны вначале, так что их не нужно будет перечитывать
  // ещё раз. Из-за этого вывод HTML-страниц такой быстрый.
  router.LoadHTMLGlob("templates/*")

  // Инициализируем роуты
  initializeRoutes()

  // Запускаем приложение
  router.Run()

}
Проектирование модели топика

Сделаем структуру топика простой, всего с тремя полями — Id, Title (название) и Content (содержание). Её можно описать так:

type article struct {
  ID      int    `json:"id"`
  Title   string `json:"title"`
  Content string `json:"content"`
}

Большинство приложений используют базу данных для хранения данных. Чтобы не усложнять, мы будем хранить список топиков в памяти и заполнять его при создании двумя следующими топиками:

var articleList = []article{
  article{ID: 1, Title: "Article 1", Content: "Article 1 body"},
  article{ID: 2, Title: "Article 2", Content: "Article 2 body"},
}

Мы вставим этот кусок кода в новый файл models.article.go. Сейчас нам понадобится функция, возвращающая список всех топиков. Мы её назовём getAllArticles() и положим её в этот же файл. И создадим тест для неё. Мы назовём этот тест TestGetAllArticles и вставим его в файл models.article_test.go.

Создадим тест (TestGetAllArticles) для функции getAllArticles(). В результате файл models.article_test.go будет таким:

// models.article_test.go

package main

import "testing"

// Test the function that fetches all articles
func TestGetAllArticles(t *testing.T) {
  alist := getAllArticles()

  // Check that the length of the list of articles returned is the
  // same as the length of the global variable holding the list
  if len(alist) != len(articleList) {
    t.Fail()
  }

  // Check that each member is identical
  for i, v := range alist {
    if v.Content != articleList[i].Content ||
      v.ID != articleList[i].ID ||
      v.Title != articleList[i].Title {

      t.Fail()
      break
    }
  }
}

В этом тесте используется функция getAllArticles() для получения списка всех топиков. Сперва этот тест проверяет, что эта функция получает список топиков и этот список идентичен списку, загруженному в глобальную переменную articleList. Затем он проходит в цикле по списку топиков для проверки уникальности каждого. Если хотя бы одна из этих проверок не удалась, тест возвращает неудачу.

После написания теста приступим к написанию кода модуля. Файл models.article.go будет содержать такой код:

// models.article.go

package main

type article struct {
  ID      int    `json:"id"`
  Title   string `json:"title"`
  Content string `json:"content"`
}

// For this demo, we're storing the article list in memory
// In a real application, this list will most likely be fetched
// from a database or from static files
var articleList = []article{
  article{ID: 1, Title: "Article 1", Content: "Article 1 body"},
  article{ID: 2, Title: "Article 2", Content: "Article 2 body"},
}

// Return a list of all the articles
func getAllArticles() []article {
  return articleList
}
Создание шаблона представления

Так как список топиков будет выводится на главной странице, нам не нужно создавать новый шаблон. Однако, нам нужно изменить шаблон index.html для вывода в него списка топиков.

Предположим, что список топиков будет передан в шаблон в переменной payload. Тогда следующий снипет выведет список всех топиков:

{{range .payload }}
    <!--Create the link for the article based on its ID-->
    <a href="/article/view/{{.ID}}">
      <!--Display the title of the article -->
      <h2>{{.Title}}</h2>
    </a>
    <!--Display the content of the article-->
    <p>{{.Content}}</p>
  {{end}}

Этот снипет пройдётся по всем элементам переменной payload и выведет название и текст каждого топика. Также этот снипет добавит ссылку в каждый топик. Однако, пока мы ещё не создали обработчик соответствующего роута, и эти ссылки на топики не будут работать.

Обновлённый index.html будет таким:

<!--index.html-->

<!--Embed the header.html template at this location-->
{{ template "header.html" .}}

  <!--Loop over the `payload` variable, which is the list of articles-->
  {{range .payload }}
    <!--Create the link for the article based on its ID-->
    <a href="/article/view/{{.ID}}">
      <!--Display the title of the article -->
      <h2>{{.Title}}</h2>
    </a>
    <!--Display the content of the article-->
    <p>{{.Content}}</p>
  {{end}}

<!--Embed the footer.html template at this location-->
{{ template "footer.html" .}}
Определяем требования к обработчику роута с помощью юнит-теста

Перед созданием обработчика роута главной страницы, мы создадим тест, чтобы определить поведение этого обработчика. Этот тест проверит следующие условия:

  1. Обработчик отвечает статус-кодом HTTP 200,
  2. Возвращаемый HTML содержит тег title с текстом «Home Page».

Код теста поместим в функцию TestShowIndexPageUnauthenticated в файл handlers.article_test.go. Вспомогательные функции, используемые в этом тесте, мы разместим в файле common_test.go.

Вот содержимое файла handlers.article_test.go:

// handlers.article_test.go

package main

import (
  "io/ioutil"
  "net/http"
  "net/http/httptest"
  "strings"
  "testing"
)

// Test that a GET request to the home page returns the home page with
// the HTTP code 200 for an unauthenticated user
func TestShowIndexPageUnauthenticated(t *testing.T) {
  r := getRouter(true)

  r.GET("/", showIndexPage)

  // Create a request to send to the above route
  req, _ := http.NewRequest("GET", "/", nil)

  testHTTPResponse(t, r, req, func(w *httptest.ResponseRecorder) bool {
    // Test that the http status code is 200
    statusOK := w.Code == http.StatusOK

    // Test that the page title is "Home Page"
    // You can carry out a lot more detailed tests using libraries that can
    // parse and process HTML pages
    p, err := ioutil.ReadAll(w.Body)
    pageOK := err == nil && strings.Index(string(p), "<title>Home Page</title>") > 0

    return statusOK && pageOK
  })
}

Файл common_test.go:

package main

import (
  "net/http"
  "net/http/httptest"
  "os"
  "testing"

  "github.com/gin-gonic/gin"
)

var tmpArticleList []article

// This function is used for setup before executing the test functions
func TestMain(m *testing.M) {
  //Set Gin to Test Mode
  gin.SetMode(gin.TestMode)

  // Run the other tests
  os.Exit(m.Run())
}

// Helper function to create a router during testing
func getRouter(withTemplates bool) *gin.Engine {
  r := gin.Default()
  if withTemplates {
    r.LoadHTMLGlob("templates/*")
  }
  return r
}

// Helper function to process a request and test its response
func testHTTPResponse(t *testing.T, r *gin.Engine, req *http.Request, f func(w *httptest.ResponseRecorder) bool) {

  // Create a response recorder
  w := httptest.NewRecorder()

  // Create the service and process the above request.
  r.ServeHTTP(w, req)

  if !f(w) {
    t.Fail()
  }
}

// This function is used to store the main lists into the temporary one
// for testing
func saveLists() {
  tmpArticleList = articleList
}

// This function is used to restore the main lists from the temporary one
func restoreLists() {
  articleList = tmpArticleList
}

Для написания теста мы написали несколько вспомогательных функций. Они в дальнейшем помогут нам уменьшить количество кода при написании похожих тестов.

Функция TestMain переводит Gin в тестовый режим и вызывает функции тестирования. Функция getRouter создаёт и возвращает роутер. Функция saveLists() помещает список топиков во временную переменную. Она используется в функции restoreLists() для восстановления списка топиков до первоначального состояния после выполнения юнит-теста.

И, наконец, функция testHTTPResponse выполняет переданную ей функцию для проверки — возвращает ли она булево значение true — показывая успешность теста, или нет. Эта функция помогает нам избежать дублирования кода, необходимого для тестирования ответа на HTTP-запрос.

Для проверки HTTP-кода и возвращаемого HTML, сделаем следующее:

  1. Создадим новый роутер,
  2. Определим роуту тот же обработчик, что используется в главном приложении (showIndexPage),
  3. Создадим новый запрос для доступа к роуту,
  4. Создадим функцию, обрабатывающую ответ, для тестирования HTTP-кода и HTML, и
  5. Вызовем testHTTPResponse() из новой функции для завершения теста.
Создание обработчика роута

Мы будет создавать все обработчики роутов, относящихся к топикам, в файле handlers.article.go. Обработчик главной страницы, showIndexPage выполняет следующие задачи:

1. Получает список топиков

Это делается с помощью функции getAllArticles, созданной ранее:

articles := getAllArticles()

2. Обрабатывает шаблон index.html, передавая ему список топиков

Это делается с помощью кода ниже:

c.HTML(
    // Set the HTTP status to 200 (OK)
    http.StatusOK,
    // Use the index.html template
    "index.html",
    // Pass the data that the page uses
    gin.H{
        "title":   "Home Page",
        "payload": articles,
    },
)

Разница с кодом из предыдущего раздела только в том, что мы передаём список топиков, который можно прочитать в шаблоне в переменной payload.

Файл handlers.article.go должен быть таким:

// handlers.article.go

package main

import (
  "net/http"

  "github.com/gin-gonic/gin"
)

func showIndexPage(c *gin.Context) {
  articles := getAllArticles()

  // Call the HTML method of the Context to render a template
  c.HTML(
    // Set the HTTP status to 200 (OK)
    http.StatusOK,
    // Use the index.html template
    "index.html",
    // Pass the data that the page uses
    gin.H{
      "title":   "Home Page",
      "payload": articles,
    },
  )

}

Если сейчас собрать и запустить приложение, открыть в браузере http://localhost:8080, оно будет выглядеть так:

Разработка Web-приложений и микросервисов на Go с Gin, часть 2

Новые файлы, добавленные в этом разделе:

├── common_test.go
├── handlers.article.go
├── handlers.article_test.go
├── models.article.go
├── models.article_test.go
└── routes.go

Окончание