Создание интерфейсов 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).


Поделиться статьей:


Вернуться к статьям

Комментарии

    Ничего не найдено.