Часть 1, Часть 2, Часть 3
В предыдущей статье мы говорили о создании «идеальной» настройки для Golang проекта. Теперь пришла пора найти для неё реальное применение: будем создавать интерфейсы RESTful API. В этой части рассмотрим базы данных, модульное тестирование, тестирование API, пример приложения и в принципе всё, что вам нужно для создания реального проекта. Рекомендую ещё раз перечитать предыдущую статью. И вот теперь приступим, не теряя времени. Поехали!
Краткое изложение доступно в моём репозитории (в ветке rest-api
) — https://github.com/MartinHeinz/go-project-blueprint/tree/rest-api
Во-первых, что мы будем использовать?
net/http
с самыми необходимыми программными средствами, библиотеками и функциональными возможностями. К тому же у неё довольно аккуратный и развитый интерфейс.database/sql
. В неё включены такие функции, как предзагрузка, обратные вызовы, транзакции и другие. Здесь придётся потратить немного времени на освоение, и документация не так крута. Но если вы из тех людей, что предпочитают писать запросы на голом SQL, то вполне можете довольствоваться sqlx
.Перейдём теперь к отдельным пакетам проекта. Сначала рассмотрим пакеты, связанные с базами данных, потом — с запросами, и доберёмся до конечных точек API. Кроме пакета main
, есть пакеты, каждый из которых следует принципу единственной ответственности:
Пакет моделей (models
) имеет один файл, который определяет типы, отражающие структуру таблицы базы данных. В примере из репозитория есть 2 типа struct
— Model
и User
:
type Model struct {
ID uint `gorm:"primary_key;column:id" json:"id"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deleted_at"`
}
type User struct {
Model
FirstName string `gorm:"column:first_name" json:"first_name"`
LastName string `gorm:"column:last_name" json:"last_name"`
Address string `gorm:"column:address" json:"address"`
Email string `gorm:"column:email" json:"email"`
}
Model
— это тот же тип, что и gorm.Model
, только с тегами json: так проще генерировать ответы JSON, содержащие его поля. User
описывает простого пользователя приложения с тегами GORM, указывающими на столбец, с которым связано поле. Есть также теги для индексов, типов, ассоциаций и т.д. Узнать о них больше можно здесь.
Дальше идёт пакет daos
, расшифровывается как Data Access Objects (DAOs). DAO — это объект, отвечающий за доступ к данным. Он выполняет SQL-запросы, используя GORM или голый SQL. Например, у нас есть простая функция, которая получает данные о пользователе с помощью ID и возвращает их в виде модели User
вместе с ошибкой, если ошибка имеется:
func (dao *UserDAO) Get(id uint) (*models.User, error) {
var user models.User
err := config.Config.DB.Where("id = ?", id). // Выполняем запрос
First(&user). // Делаем его скалярным
Error // получаем ошибку или null
return &user, err
}
Можно разделить объекты доступа к данным по какому-либо критерию, например по таблицам, к которым данные имеют доступ, либо по какой-то другой логике. Только не сваливайте всё в одну кучу, иначе будет неразбериха.
СервисыОтлично. Данные у нас аккуратно загружены в модели. Прежде чем их отдавать, можно использовать дополнительную логику для обработки данных. И здесь в дело вступают сервисы. Такой дополнительной логикой может быть фильтрация, агрегирование, изменение структуры или валидация данных. К тому же это позволяет отделять запросы к базе данных от логики предметной области, делая код намного более чистым, простым для сопровождения и — что лично для меня самое важное — легко тестируемым (мы ещё поговорим об этом дальше). Посмотрим на код:
type userDAO interface {
Get(id uint) (*models.User, error)
}
type UserService struct {
dao userDAO
}
// NewUserService создаёт новый UserService с пользователем DAO.
func NewUserService(dao userDAO) *UserService {
return &UserService{dao}
}
// Get просто получает пользователя с помощью пользователя DAO, здесь может быть дополнительная логика обработки данных, получаемых DAOs
func (s *UserService) Get(id uint) (*models.User, error) {
return s.dao.Get(id) // Без дополнительной логики, просто получаем результат запроса
}
Здесь мы сначала определяем интерфейс, который объединяет все ранее созданные функции DAO, в нашем случае просто Get(id uint)
из предыдущего кода. Потом определяем сервис User с нашим объектом доступа к данным и функцию, которая его создаёт, используя DAO в качестве параметра. Наконец, определяем функцию, которая может задействовать дополнительную логику и использовать DAO из UserService
. Здесь для простоты используем DAO при выполнении запроса в базу данных на пользователя и возвращаем DAO. Примером используемой здесь логики может быть валидация модели или проверка на наличие ошибок.
И, наконец, используя эти сервисы, дающие нам обработанные и валидные данные, мы можем предоставлять их нашим пользователям. Обратимся к коду:
func GetUser(c *gin.Context) {
s := services.NewUserService(daos.NewUserDAO()) // Создаём сервис
id, _ := strconv.ParseUint(c.Param("id"), 10, 32) // Парсим ID из URL
if user, err := s.Get(uint(id)); err != nil { // Пытаемся получить пользователя из базы данных
c.AbortWithStatus(http.StatusNotFound) // Завершаем, если данные не найдены
log.Println(err)
} else {
c.JSON(http.StatusOK, user) // Отправляем данные обратно
}
}
Здесь у нас функция, которую можно использовать для работы конечной точки API-интерфейса. Сначала создаём сервис с заданным пользователем DAO. Затем парсим ID, который мы ожидаем в URL (что-то вроде /users/{id}
), потом используем сервис для получения из БД данных о пользователе. Если данные будут найдены, возвращаем их в формате JSON с кодом состояния 200
.
Выглядит здорово, но сейчас нам надо всё это настроить в main
, чтобы Gin понимал, где работают наши API:
r := gin.New()
r.Use(gin.Logger())
r.Use(gin.Recovery())
v1 := r.Group("/api/v1")
{
v1.GET("/users/:id", apis.GetUser)
}
r.Run(fmt.Sprintf(":%v", config.Config.ServerPort))
Сначала нужно создать экземпляр Gin, потом привяжем к нему промежуточное ПО (logger или CORS). И самое важное — создаем набор конечных точек (все они будут начинаться с api/v1/
) и регистрируем нашу функцию GetUser
, чтобы она работала в /api/v1/users
конкретного пользователя (определяемого параметром ID). Вот и всё, теперь можно запускать наше приложение!
Возможно, вы подумали: «Зачем создавать все эти пакеты, отдельные файлы, функции в несколько слоёв и т.д. и т.п.?». Но если в вашем приложении всё будет свалено в кучу, то со временем, когда приложение станет достаточно большим, проблем с сопровождением кода будет не избежать. А самое важное, на мой взгляд: такое разделение необходимо для лучшей тестируемости, ведь гораздо легче тестировать каждый уровень — доступ к базе данных, управление данными и API — отдельно, чем всё в одном месте. Раз уж речь зашла о тестах, неплохо было бы попрактиковаться в их написании…
Обратите внимание: из фрагмента кода, приведённого выше, я убрал несколько строчек и комментариев (например, подключение к базе данных или загрузка конфигурации). Сделано это было для простоты и ясности. Все убранные строчки вместе с дополнительными пояснениями и комментариями можно найти в репозитории.
Вот и добрались до тестов, моей любимой части! Начнём с пакета test_data
. Здесь содержатся служебные функции, связанные с тестовой базой данных и тестовыми данными. Хотел бы обратить ваше внимание на функцию init
:
func init() {
err := config.LoadConfig("/config")
if err != nil {
panic(err)
}
config.Config.DB, config.Config.DBErr = gorm.Open("sqlite3", ":memory:")
config.Config.DB.Exec("PRAGMA foreign_keys = ON") // SQLite defaults to `foreign_keys = off'`
if config.Config.DBErr != nil {
panic(config.Config.DBErr)
}
config.Config.DB.AutoMigrate(&models.User{})
}
Эта функция особенная: Go выполняет её, когда пакет импортируется. Здесь можно выполнить настройку для тестов: сначала загружаем конфигурацию, потом создаём тестовую базу данных (SQLite в оперативной памяти), для которой мы активируем внешние ключи. Затем создаём таблицы базы данных, используя функцию GORM AutoMigrate
.
А здесь вы могли подумать: «Зачем использовать базу данных SQLite в оперативной памяти? Неужели так лучше?». Вообще-то да. Сам я использую для всех проектов PostgreSQL. Когда же дело доходит до тестов, нужно что-то понятное и предсказуемое, быстрое (в оперативной памяти) и независимое от хост-системы/сервера баз данных — всё это обеспечивает данная настройка.
Не будем переходить к оставшимся функциям пакета, а то вам надоест читать, к тому же они уже есть у нас здесь.
Помимо функции инициализации, в пакете у нас хранятся кое-какие данные. Например, файл db.sql
, в котором содержатся: а) инструкции вставок SQL, добавляющие значения в базу данных SQLite перед запуском тестов; б) тестовые случаи в формате JSON, используемые как ожидаемые результаты для конечных точек API.
Теперь, когда наша тестовая настройка готова, перейдём к тестам в каждом пакете:
func TestUserDAO_Get(t *testing.T) {
config.Config.DB = test_data.ResetDB()
dao := NewUserDAO()
user, err := dao.Get(1)
expected := map[string]string{"First Name": "John", "Last Name": "Doe", "Email": "[email protected]"}
assert.Nil(t, err)
assert.Equal(t, expected["First Name"], user.FirstName)
assert.Equal(t, expected["Last Name"], user.LastName)
assert.Equal(t, expected["Email"], user.Email)
}
Это тест объектов доступа к данным daos
, он очень простой: создаём DAO, вызываем тестируемую функцию (Get
) и проверяем на соответствие ожидаемым значениям, добавленным в базу данных SQLite во время настройки. Больше тут добавить нечего — переходим к services
:
func TestUserService_Get(t *testing.T) {
s := NewUserService(newMockUserDAO())
user, err := s.Get(2)
if assert.Nil(t, err) && assert.NotNil(t, user) {
assert.Equal(t, "Ben", user.FirstName)
assert.Equal(t, "Doe", user.LastName)
}
user, err = s.Get(100)
assert.NotNil(t, err)
}
func (m *mockUserDAO) Get(id uint) (*models.User, error) {
for _, record := range m.records {
if record.ID == id {
return &record, nil
}
}
return nil, errors.New("not found")
}
func newMockUserDAO() userDAO {
return &mockUserDAO{
records: []models.User{
{Model: models.Model{ID: 1}, FirstName: "John", LastName: "Smith", Email: "[email protected]", Address: "Dummy Value"},
{Model: models.Model{ID: 2}, FirstName: "Ben", LastName: "Doe", Email: "[email protected]", Address: "Dummy Value"},
},
}
}
type mockUserDAO struct {
records []models.User
}
Здесь кода побольше, пробежимся по нему снизу вверх. Первое, что нам нужно, — сымитировать DAO (mockUserDAO
), дабы не зависеть от реализации настоящего DAO. Чтобы эта имитация имела смысл, нужно заполнить её тестовыми данными, что и происходит в newMockUserDAO
. Дальше определяем версию Get
, которая имитирует настоящую: вместо выполнения запроса к базе данных, мы просматриваем ненастоящие записи и возвращаем, если найдём заданный ID.
Для самого теста создаём NewUserService
, но вместо настоящего DAO, используем нашу имитацию с предсказуемым поведением, так что мы можем изолировать проверяемую в тесте функцию от основного DAO. Осталось только выполнить простой тест: используем сымитированный метод Get
и проверяем на наличие ожидаемых значений, добавленных нами в имитацию.
Последним проверяем API, все тесты здесь длиной практически в одну строку, но немного подготовиться нам всё же придётся:
func newRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
config.Config.DB = test_data.ResetDB()
return router
}
func testAPI(router *gin.Engine, method string, urlToServe string, urlToHit string, function gin.HandlerFunc, body string) *httptest.ResponseRecorder {
router.Handle(method, urlToServe, function)
res := httptest.NewRecorder()
req, _ := http.NewRequest(method, urlToHit, bytes.NewBufferString(body))
router.ServeHTTP(res, req)
return res
}
func runAPITests(t *testing.T, tests []apiTestCase) {
for _, test := range tests {
router := newRouter()
res := testAPI(router, test.method, test.urlToServe, test.urlToHit, test.function, test.body)
assert.Equal(t, test.status, res.Code, test.tag)
if test.responseFilePath != "" {
response, _ := ioutil.ReadFile(test.responseFilePath)
assert.JSONEq(t, string(response), res.Body.String(), test.tag)
}
}
}
Для целей нашего тестирования здесь три функции имитируют HTTP-запрос. Первая функция создаёт Gin в тестовом режиме и возвращает базу данных в исходное состояние. Вторая наблюдает за URL и затем отправляет запрос в конкретную конечную точку API. Третья функция запускает список тестовых случаев и проверяет, не совпадают ли коды состояния, и дополнительно может проверить, не совпадают ли результаты в формате JSON. Рассмотрим примеры тестовых случаев:
func TestUser(t *testing.T) {
path := test_data.GetTestCaseFolder()
runAPITests(t, []apiTestCase{
{"t1 - get a User", "GET", "/users/:id", "/users/1", "", GetUser, http.StatusOK, path + "/user_t1.json"},
{"t2 - get a User not Present", "GET", "/users/:id", "/users/9999", "", GetUser, http.StatusNotFound, ""},
})
}
Параметров целая куча, но здесь всё довольно просто. Разберём каждый параметр:
"t1 - get a User"
— название тестового случая с номером для облегчения поиска при отладке;"GET"
— метод HTTP;"/users/:id"
— тестируемый URL;"/users/1"
— конкретный URL с включёнными параметрами;""
— тело запроса, в данном случае пустое;GetUser
— метод, прикреплённый к конечной точке;http.StatusOK
— ожидаемый код состояния, здесь 200
;path + "/user_t1.json"
— путь к ожидаемому результату в формате JSON, все хранятся в пакете test_data
.Вот и всё, что нужно для создания RESTful API в Golang. Надеюсь, хоть что-то из изложенного здесь будет кстати при создании вашего следующего проекта. Весь исходный код находится здесь. Если статья понравилась, переходите к следующей, в которой мы расскажем, как добавить крутую документацию к вашему проекту.
Перевод статьи Martin Heinz: Building RESTful APIs in Golang (впервые опубликована на martinheinz.dev).
Комментарии