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

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

Это продолжение материала, часть 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

Окончание

Leave a Comment