Как построить масштабируемый API на Go с помощью Gin

Помимо TypeScript, я еще работаю с Go, языком программирования от Google, вышедшем в 2012 году. Это очень эффективный язык, который становится все популярнее.

Я считаю, что его стоит осваивать, поэтому в текущей статье приведу краткое руководство по созданию простого, но одновременно и масштабируемого API на этом языке с помощью Gin и GORM. Из соображений простоты Docker здесь использоваться не будет.

Прежде чем начать, сразу поделюсь GitHub-репозиторием этого проекта.

Что такое Gin?

Gin  —  это самый популярный высокопроизводительный фреймворк для Go (Golang), с помощью которого можно создавать веб-приложения. Если вы знакомы с ExpressJS, то Gin очень на него похож, и работать вам с ним будет довольно удобно.

Что мы будем создавать?

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

Что необходимо?

Вам потребуется базовое понимание Go. Лично я в качестве редактора кода использую Visual Studio Code, вы же вольны выбирать на свое усмотрение. Только имейте ввиду, что в статье вам встретится команда code .  —  это собственная команда VSCode, которая открывает в редакторе текущий каталог.

Помимо этого, вам нужно будет установить на локальную машину Go и PostgreSQL.

Создание базы данных

Для начала нужно создать базу данных. Я знаю, что все делают это по-своему. Некоторые используют GUI, но мы будем работать из терминала. Напомню, что у вас должна быть установлена PostgreSQL. В этом случае нижеприведенные команды будут работать в системах Linux, Mac и Windows:

$ psql postgres
$ CREATE DATABASE go_medium_api;
$ \l
$ \q
  • psql postgres открывает командную строку psql под пользователем postgres.
  • CREATE DATABASE go_medium_api; cоздает нужную нам базу данных.
  • \l выводит список всех баз данных.
  • \q закрывает командную строку.

Ниже показан мой терминал после выполнения всех этих команд. Как видите, была создана база данных go_api_medium.

Настройка проекта

Далее мы инициируем проект и устанавливаем все необходимые модули.

Внимание: замените YOUR_USERNAME на свое имя пользователя Github.

$ mkdir go-gin-api-medium
$ cd go-gin-api-medium
$ code .
$ go mod init github.com/YOUR_USERNAME/go-gin-api-medium

Теперь установим Gin, GORM и Viper. С помощью Viper мы будем управлять переменными среды.

$ go get github.com/spf13/viper
$ go get github.com/gin-gonic/gin
$ go get gorm.io/gorm
$ go get gorm.io/driver/postgres

Определим итоговую структуру проекта:

$ mkdir -p cmd pkg/books pkg/common/db pkg/common/envs pkg/common/models

И добавим некоторые файлы:

$ touch Makefile cmd/main.go pkg/books/add_book.go pkg/books/controller.go pkg/books/delete_book.go pkg/books/get_book.go pkg/books/get_books.go pkg/books/update_book.go pkg/common/db/db.go pkg/common/envs/.env pkg/common/models/book.go

Итак, после создания проекта файловая структура должна быть такой:

А теперь пора заняться кодом!

Переменные среды

Для начала нужно добавить переменные среды, в которых мы будем хранить порт приложения и URL базы данных. Не забудьте заменить DB_USER, DB_PASSWORD, DB_HOST и DB_PORT данными из вашей БД.

Добавляем в pkg/common/envs/.env:

PORT=:3000
DB_URL=postgres://DB_USER:DB_PASSWORD@DB_HOST:DB_PORT/go_api_medium

К примеру, на моей машине это выглядит так:

PORT=:3000
DB_URL=postgres://kevin:root@localhost:5432/go_api_medium

Конфигурация

Добавляем в pkg/common/config/config.go:

package config

import "github.com/spf13/viper"

type Config struct {
Port string `mapstructure:"PORT"`
DBUrl string `mapstructure:"DB_URL"`
}

func LoadConfig() (c Config, err error) {
viper.AddConfigPath("./pkg/common/config/envs")
viper.SetConfigName("dev")
viper.SetConfigType("env")

viper.AutomaticEnv()

err = viper.ReadInConfig()

if err != nil {
return
}

err = viper.Unmarshal(&c)

return
}

Модели Book

Здесь мы создадим модель/сущность Book. Инструкция gorm.Model добавит ей свойства ID, CreatedAt, UpdatedAt и DeletedAt.

В дополнение к этому мы добавим 3 строковых свойства. Тег json в конце сообщает GORM информацию об именах каждого столбца в нашей базе данных Postgres. 

Далее добавим код в pkg/common/models/book.go:

package models

import "gorm.io/gorm"

type Book struct {
gorm.Model // adds ID, created_at etc.
Title string `json:"title"`
Author string `json:"author"`
Description string `json:"description"`
}

Инициализация базы данных

С моделью book мы закончили. Теперь пора настроить GORM и автоматически перенести эту модель. Функция AutoMigrate при запуске приложения создаст таблицу books.

Добавим в pkg/common/db/db.go:

package db

import (
"log"

"github.com/YOUR_USERNAME/go-gin-api-medium/pkg/common/models"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)

func Init(url string) *gorm.DB {
db, err := gorm.Open(postgres.Open(url), &gorm.Config{})

if err != nil {
log.Fatalln(err)
}

db.AutoMigrate(&models.Book{})

return db
}

Файл main

Это файл начальной загрузки, в котором мы выполняем множество процессов:

  • инициализируем Viper для обработки переменных среды;
  • инициализируем базу данных на основе GORM;
  • добавляем простой маршрут /;
  • запускаем приложение.

Немного позже мы этот файл изменим. 

А пока что добавим в cmd/main.go следующий код:

package main

import (
"github.com/gin-gonic/gin"
"github.com/YOUR_USERNAME/go-gin-api-medium/pkg/common/db"
"github.com/spf13/viper"
)

func main() {
viper.SetConfigFile("./pkg/common/envs/.env")
viper.ReadInConfig()

port := viper.Get("PORT").(string)
dbUrl := viper.Get("DB_URL").(string)

r := gin.Default()
db.Init(dbUrl)

r.GET("/", func(c *gin.Context) {
c.JSON(200, gin.H{
"port": port,
"dbUrl": dbUrl,
})
})

r.Run(port)
}

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

$ go run cmd/main

Вывод в консоль. Здесь особенно важна последняя строка.

Перейдем на http://localhost:3000.

Обработчики книг

Отлично, все работает! Не волнуйтесь, текущий вывод мы заменим. Теперь добавим в API обработчики.

Контроллер

Обработчики/маршруты книг будут основываться на так называемых получателях указателей, для чего нам нужно определить их структуру. Эта структура будет получать информацию базы данных, поэтому при каждом вызове обработчика/маршрута книги мы будем получать доступ к GORM. Позже мы этот файл изменим.

Добавим код в pkg/books/controller.go:

package books

import (
"gorm.io/gorm"
)

type handler struct {
DB *gorm.DB
}

Добавление книг

Этот файл очень интересен. После импорта мы определяем структуру тела запроса. В строке 16 можно видеть получатель указателей, определенный на предыдущем шаге. В строке 31 мы используем этот получатель, названный просто h

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

Добавим код в pkg/books/add_book.go:

package books

import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/YOUR_USERNAME/go-gin-api-medium/pkg/common/models"
)

type AddBookRequestBody struct {
Title string `json:"title"`
Author string `json:"author"`
Description string `json:"description"`
}

func (h handler) AddBook(c *gin.Context) {
body := AddBookRequestBody{}

// получаем тело запроса
if err := c.BindJSON(&body); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}

var book models.Book

book.Title = body.Title
book.Author = body.Author
book.Description = body.Description

if result := h.DB.Create(&book); result.Error != nil {
c.AbortWithError(http.StatusNotFound, result.Error)
return
}

c.JSON(http.StatusCreated, &book)
}

Получение книг

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

Добавим код в pkg/books/get_books.go:

package books

import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/YOUR_USERNAME/go-gin-api-medium/pkg/common/models"
)

func (h handler) GetBooks(c *gin.Context) {
var books []models.Book

if result := h.DB.Find(&books); result.Error != nil {
c.AbortWithError(http.StatusNotFound, result.Error)
return
}

c.JSON(http.StatusOK, &books)
}

Получение книги

Здесь мы возвращаем всего одну книгу на основе переданного параметра ID.

Добавляем в pkg/books/get_book.go:

package books

import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/YOUR_USERNAME/go-gin-api-medium/pkg/common/models"
)

func (h handler) GetBook(c *gin.Context) {
id := c.Param("id")

var book models.Book

if result := h.DB.First(&book, id); result.Error != nil {
c.AbortWithError(http.StatusNotFound, result.Error)
return
}

c.JSON(http.StatusOK, &book)
}

Обновление книги

Если мы добавляем книги, то у нас должна быть возможность и обновлять их. Этот маршрут аналогичен прописанному ранее AddBook.

Добавляем в pkg/books/update_book.go:

package books

import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/YOUR_USERNAME/go-gin-api-medium/pkg/common/models"
)

type UpdateBookRequestBody struct {
Title string `json:"title"`
Author string `json:"author"`
Description string `json:"description"`
}

func (h handler) UpdateBook(c *gin.Context) {
id := c.Param("id")
body := UpdateBookRequestBody{}

// получаем тело запроса
if err := c.BindJSON(&body); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}

var book models.Book

if result := h.DB.First(&book, id); result.Error != nil {
c.AbortWithError(http.StatusNotFound, result.Error)
return
}

book.Title = body.Title
book.Author = body.Author
book.Description = body.Description

h.DB.Save(&book)

c.JSON(http.StatusOK, &book)
}

Удаление книги

Это будет последний маршрут. В нем мы удаляем книгу на основе ее ID, но только если нужная сущность существует в базе данных. В ответ возвращается лишь HTTP-код состояния.

Добавляем в pkg/books/delete_book.go:

package books

import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/YOUR_USERNAME/go-gin-api-medium/pkg/common/models"
)

func (h handler) DeleteBook(c *gin.Context) {
id := c.Param("id")

var book models.Book

if result := h.DB.First(&book, id); result.Error != nil {
c.AbortWithError(http.StatusNotFound, result.Error)
return
}

h.DB.Delete(&book)

c.Status(http.StatusOK)
}

Снова контроллер

С маршрутами разобрались. Теперь нужно изменить файл контроллера. На этот раз мы создаем функцию RegisterRoutes, имя которой говорит само за себя.

Помните получатель указателей? Здесь мы используем его для маршрутов/обработчиков.

Изменяем файл pkg/books/controller.go из:

package books

import (
"gorm.io/gorm"
)

type handler struct {
DB *gorm.DB
}

в:

package books

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

"gorm.io/gorm"
)

type handler struct {
DB *gorm.DB
}

func RegisterRoutes(r *gin.Engine, db *gorm.DB) {
h := &handler{
DB: db,
}

routes := r.Group("/books")
routes.POST("/", h.AddBook)
routes.GET("/", h.GetBooks)
routes.GET("/:id", h.GetBook)
routes.PUT("/:id", h.UpdateBook)
routes.DELETE("/:id", h.DeleteBook)
}

Снова файл main

Нам также нужно изменить файл main.go. Ранее мы просто инициализировали в нем базу данных. На этот же раз мы получаем ее ответ и регистрируем маршруты/обработчики.

Изменяем cmd/main.go из:

package main

import (
"github.com/gin-gonic/gin"
"github.com/YOUR_USERNAME/go-gin-api-medium/pkg/common/db"
"github.com/spf13/viper"
)

func main() {
viper.SetConfigFile("./pkg/common/envs/.env")
viper.ReadInConfig()

port := viper.Get("PORT").(string)
dbUrl := viper.Get("DB_URL").(string)

r := gin.Default()
db.Init(dbUrl)

r.GET("/", func(c *gin.Context) {
c.JSON(200, gin.H{
"port": port,
"dbUrl": dbUrl,
})
})

r.Run(port)
}

в:

package main

import (
"github.com/gin-gonic/gin"
"github.com/YOUR_USERNAME/go-gin-api-medium/pkg/books"
"github.com/YOUR_USERNAME/go-gin-api-medium/pkg/common/db"
"github.com/spf13/viper"
)

func main() {
viper.SetConfigFile("./pkg/common/envs/.env")
viper.ReadInConfig()

port := viper.Get("PORT").(string)
dbUrl := viper.Get("DB_URL").(string)

r := gin.Default()
h := db.Init(dbUrl)

books.RegisterRoutes(r, h)
// здесь регистрируются дополнительные маршруты

r.Run(port)
}

Makefile

Хоть это и не обязательно, но в этом файле можно настроить дополнительные скрипты для упрощения команд. В качестве примера мы определим скрипт server для запуска приложения, что позволит запускать его не через go run cmd/main, а с помощью make server. Это не очень хороший пример, поскольку изначальная команда и так довольно коротка. Но представьте, что работаете с более длинными инструкциями.

Добавим код в Makefile внутри корневого каталога:

server:
go run cmd/main.go

Запуск приложения

Все готово! Больше никакого кода. Пора запускать приложение:

$ make server

или:

$ go run cmd/main.go

Ниже показан вывод. Помимо предупреждений, мы видим, что все маршруты настроены, и приложение работает на порту 3000.

Тестирование конечных точек

Теперь мы протестируем два созданных нами маршрута. Для этого можно использовать ПО вроде Postman, Insomnia или просто выполнить команды CURL.

POST: добавление новой книги

$ curl --request POST \
--url http://localhost:3000/books/ \
--header 'Content-Type: application/json' \
--data '{
"title": "Book A",
"author": "Kevin Vogel",
"description": "Some cool description"
}'

GET: получение всех книг

Не забывайте, что можете выполнять команды GET и в браузере.

$ curl --request GET --url http://localhost:3000/books/

GET: получение книги по ID

$ curl --request GET --url http://localhost:3000/books/1/

PUT: обновление книги по ID

$ curl --request PUT \
--url http://localhost:3000/books/1/ \
--header 'Content-Type: application/json' \
--data '{
"title": "Updated Book Name",
"author": "Kevin Vogel",
"description": "Updated description"
}'

DELETE: удаление книги по ID

$ curl --request DELETE --url http://localhost:3000/books/1/

Вот и все! Напомню, что загрузил этот проект на GitHub.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Kevin Vogel: Build a Scalable API in Go with Gin (2022)

Предыдущая статьяПереключатель темного режима в веб-приложении
Следующая статья5 библиотек ведения логов для Node.js