Создание интерфейсов RESTful API в Golang

Часть 1, Часть 2, Часть 3

В предыдущей статье мы говорили о создании «идеальной» настройки для Golang проекта. Теперь пришла пора найти для неё реальное применение: будем создавать интерфейсы RESTful API. В этой части рассмотрим базы данных, модульное тестирование, тестирование API, пример приложения и в принципе всё, что вам нужно для создания реального проекта. Рекомендую ещё раз перечитать предыдущую статью. И вот теперь приступим, не теряя времени. Поехали!

Краткое изложение доступно в моём репозитории (в ветке rest-api) — https://github.com/MartinHeinz/go-project-blueprint/tree/rest-api

Платформы и библиотеки

Во-первых, что мы будем использовать?

  • Gin — каркас для разработки веб-приложений с применением HTTP-протокола. Это высокопроизводительная платформа на net/http с самыми необходимыми программными средствами, библиотеками и функциональными возможностями. К тому же у неё довольно аккуратный и развитый интерфейс.
  • GORM — библиотека средств объектно-реляционного отображения Golang, разработанная на database/sql. В неё включены такие функции, как предзагрузка, обратные вызовы, транзакции и другие. Здесь придётся потратить немного времени на освоение, и документация не так крута. Но если вы из тех людей, что предпочитают писать запросы на голом SQL, то вполне можете довольствоваться sqlx.
  • Viper — библиотека конфигураций Go, которая работает с разными форматами, параметрами командной строки, переменными среды и т.д. Всех интересующихся настройкой и использованием этой библиотеки направляем в предыдущую статью, где всё это подробно расписано.

Проект и структура пакетов

Перейдём теперь к отдельным пакетам проекта. Сначала рассмотрим пакеты, связанные с базами данных, потом — с запросами, и доберёмся до конечных точек 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. Примером используемой здесь логики может быть валидация модели или проверка на наличие ошибок.

API-интерфейсы

И, наконец, используя эти сервисы, дающие нам обработанные и валидные данные, мы можем предоставлять их нашим пользователям. Обратимся к коду:

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).