Как построить масштабируемый API на Go с помощью Gin
Помимо TypeScript, я еще работаю с Go, языком программирования от Google, вышедшем в 2012 году. Это очень эффективный язык, который становится все популярнее.
Я считаю, что его стоит осваивать, поэтому в текущей статье приведу краткое руководство по созданию простого, но одновременно и масштабируемого API на этом языке с помощью Gin и GORM. Из соображений простоты Docker здесь использоваться не будет.
Прежде чем начать, сразу поделюсь GitHub-репозиторием этого проекта.
https://t.me/golang_interview – подготовься к Golang собеседованию
Что такое 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.