Go в API для мобильного приложения. Создаем совместный список покупок с мгновенными уведомлениями

8fa05fc9fa2826edddf1973a2e44e867.png

В предыдущей статье мы рассмотрели использование Go для создания веб-приложений (с выполнением через Web Assembly). Но прежде всего Go интересен как язык для реализации высокопроизводительных и неблокирующих решений на стороне сервера и в этой статье мы изучим использование Go для backend на примере разработки API для мобильного приложения для совместного редактирования списка покупок. Приложение будет включать в себя механизмы авторизации, запроса и модификации объектов, а также мгновенные уведомления (через веб-сокеты и Push) и мониторинг доступность API. В качестве примера мы создадим минимальный API, для которого обеспечивается уведомление всех зарегистрированных пользователей об изменении списка, а также будут предусмотрена отправка пуш-уведомлений всем адресатам по запросу.

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

  • взаимодействия с базой данных (идеально было бы сделать с поддержкой объектно-реляционного отображения — ORM);

  • реализация REST-API для управления ресурсами системы;

  • авторизация пользователя и выдача токена доступа к API (JWT);

  • поддержка постоянного подключения с клиентскими приложениями через веб-сокеты;

  • отправка пуш-уведомлений зарегистрированным клиентам;

  • генерация документации по использования API;

  • мониторинг доступности сервиса.

Взаимодействие с базой данных

Go поддерживает низкоуровневые драйверы для большинства свободных и проприетарных баз данных (включая Oracle, MSSQL и MySQL, PostgreSQL, MongoDB, …) и позволяет выполнять и генерировать SQL-запросы с использованием определения схемы данных в тэгах Go. Одной из наиболее активно развивающихся и функциональных библиотек для объектно-реляционного отображения можно считать GORM, позволяющий описывать схему данных внутри структур, определять отношения между структурами, автоматически создавать миграции при изменении схемы данных, создавать запросы (в том числе, с поддержкой JOIN) и управлять транзакциями.

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

Наши объекты будут состоять из следующих полей:

  • Название (строка).

  • Отметка о выполнении (логическое значение).

  • Автор (ссылка на пользователя).

  • Исполнитель, завершивший задачу (ссылка на пользователя), может быть null.

  • Дата-время добавления (timestamp).

  • Дата-время выполнения (timestamp), может быть null.

Кроме списка продуктов будет необходимо создать таблицу с пользователями (мы будем сохранять e-mail для уникальной идентификации).

  • Первичный ключ (число, автоинкремент).

  • Адрес электронной почты (строка).

  • Хэш пароля (строка).

  • Дата-время последнего входа в систему (timestamp).

Начнем разработку приложения с установки библиотеки gorm и создания структур, описывающих модель данных:

go get -u gorm.io/gorm
go get -u gorm.io/driver/sqlite

Для корректной установки на компьютере должен быть установлен gcc (например, на Debian/Ubuntu он может быть добавлен через apt install build-essential, на Windows через Msys2 и pacman -Syu && pacman -S --needed base-devel mingw-w64-x86_64-toolchain).

Добавим в проект определение структур (в пакет models):

package models

type ShoppingListItem struct {
	gorm.Model
	Name      string
	Finished  bool
	Creator   User  `gorm:"foreignKey:Id"`
	Performer *User `gorm:"foreignKey:Id"`
	Added     int64
	Completed *int64
}

type User struct {
	gorm.Model
	Id           int32 `gorm:"PrimaryKey"`
	EMail        string
	PasswordHash string
	LastUpdate   int64
}

И выполним инициализацию базы данных в main:

import (
  "github.com/gin-gonic/gin"
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
  "shoppinglist/models"
)

func main() {
	db, err := gorm.Open(sqlite.Open("shoppinglist.db"), &gorm.Config{})
	if err != nil {
		panic("failed to connect database")
	}

	// Migrate the schema
	db.AutoMigrate(&models.User{}, &models.ShoppingListItem{})
}

После запуска приложения будет создан файл данных для SQLite, содержащий две таблицы в соответствии с описанием структур Go. Заполним несколькими тестовыми записями:

func populateTestData(db *gorm.DB) {
  var newUser = models.User{
		EMail:        "test.shopping.list@gmail.com",
		PasswordHash: "098f6bcd4621d373cade4e832627b4f6",
		LastUpdate:   time.Now().Unix(),
	}
	db.Create(&newUser)
	db.Create(&models.ShoppingListItem{
		Name:      "Milk",
		Finished:  false,
		Creator:   newUser,
		Performer: nil,
		Added:     time.Now().Unix(),
		Completed: nil,
	})
	db.Create(&models.ShoppingListItem{
		Name:      "Coffee",
		Finished:  false,
		Creator:   newUser,
		Performer: nil,
		Added:     time.Now().Unix(),
		Completed: nil,
	})
}

Для упрощения отладки изменим конфигурацию логирования:

	newLogger := logger.New(
		log.New(os.Stdout, "\r\n", log.LstdFlags),
		logger.Config{
			SlowThreshold:             time.Second,
			LogLevel:                  logger.Info,
			IgnoreRecordNotFoundError: true,
			Colorful:                  true,
		},
	)

	db, err := gorm.Open(sqlite.Open("shoppinglist.db"), &gorm.Config{Logger: newLogger})

И добавим после заполнения данных диагностику по количеству записей в базах данных. Обратите внимание, что название базы данных (как и полей) отличается от исходных названий структур — camelcase заменяется на snakecase, в конце добавляется символ «s» (для множественного числа):

	var usersCount int64
	db.Table("users").Count(&usersCount)
	println("Users count is ", usersCount)

	var shoppingListItemCount int64
	db.Table("shopping_list_items").Count(&shoppingListItemCount)
	println("Shopping list items count is ", shoppingListItemCount)

Для выполнения запросов извлечения и модификации данных нам будут доступны методы:

  • db.Delete(объект) или db.Delete(&ShoppingListItem{}, 1) для удаления (соответственно для ранее полученного объекта или по типу данных и первичному ключу).

  • db.Save(объект) для обновления ранее полученного объекта.

  • db.First(&obj, 1) для извлечения объекта по ключу (таблица определяется по типу переменной obj).

  • db.Find(&obj, []int{1,2,3}) для извлечения группы объектов с заданными первичными ключами.

  • db.Find(&objs) для получения всех объектов из таблицы (определяется по типу элемента списка objs).

  • db.Table("shopping_list_item").Select("name").Rows() для извлечения названий всех продуктов из списка покупок. Дополнительно можно задать условия поиска (.Where), упорядочивание набора (.Order), ограничение количества (.Limit).

Эти методы мы будем использовать в контроллере для REST API. Например, для получения продуктов из списка покупок с упорядочиванием по времени в обратном хронологическом порядке можно использовать следующий код:

	rows, err := db.Table("shopping_list_items").Select("name").Order("created_at desc").Rows()
	for rows.Next() {
		var name string
		rows.Scan(&name)
		println(name)
	}

Мы создали заготовку кода для взаимодействия с базой данных и теперь можем перейти к созданию REST API. Выбор сетевых библиотек для Go чрезвычайно велик (начиная от встроенного net/http, высокопроизводительного fasthttp, маршрутизаторами gorilla/mux и до сложных фреймворков вроде gin-gonic или Echo), поэтому здесь сложнее выбрать предпочтительное решение, поэтому будем использовать один из наиболее известных и достаточно удобных в применении фреймворк gin-gonic. Выбор также определяется возможностями встраивания Middleware для обработки запроса (например, проверки аутентификации) и наличии инструментов генерации документации на основе зарегистрированных обработчиков адресов.

REST API-сервис на Gin

Начнем с установки модуля: go get -u github.com/gin-gonic/gin. Добавим новую функцию для инициализации сервера и настройки маршрутов.

func initAPI() {
	r := gin.Default()
	r.GET("/ping", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"status": "OK"})
	})
	r.Run()
}

В список импортов должно быть добавлено "github.com/gin-gonic/gin" (сам фреймворк) и "net/http" для поддержки констант состояния ответа (например, http.StatusOK).

Для маршрута указывается функция, принимающая контекст запроса (gin.Context), который используется для получения информации о запросе (извлечение значения именованных параметров, десериализация JSON в структуру Go), а также для формирования ответа (может быть отправлен как плоский текст, сериализация структуры Go в Json, либо двоичный файл). Для описания JSON-полей можно использовать встроенные договоренности по описанию схемы JSON-сериализации (и их можно добавить непосредственно в поля структуры, которая описывает модель данных для объединения DAO и DTO). Создадим контроллер для управления списком покупок (пока без авторизации):

Прежде всего добавим описание json-полей в структуру ShoppingListItem:

type ShoppingListItem struct {
	gorm.Model
	Name      string `json:"name"`
	Finished  bool   `json:"finished"`
	Creator   User   `gorm:"foreignKey:Id" json:"creator"`
	Performer *User  `gorm:"foreignKey:Id" json:"performer"`
	Added     int64  `json:"added"`
	Completed *int64 `json:"completed"`
}

Создадим теперь контроллер для реализации REST-методов со списком покупок и отдельный пакет для работы с базой данных:

package data

import (
	"gorm.io/gorm"
	"shoppinglist/models"
)

type Data struct {
	Db *gorm.DB
}

func (data *Data) GetItems() []models.ShoppingListItem {
	var items = make([]models.ShoppingListItem, 0, 0)
	data.Db.Find(&items)
	return items
}

func (data *Data) GetItem(id int) *models.ShoppingListItem {
	var result *models.ShoppingListItem
	data.Db.Find(&result, id)
	return result
}

func (data *Data) DeleteItem(id int) bool {
	err := data.Db.Delete(&models.ShoppingListItem{}, id)
	return err == nil
}

func (data *Data) CreateItem(item models.ShoppingListItem) uint {
	data.Db.Create(&item)
	return item.ID
}

func (data *Data) UpdateItem(item models.ShoppingListItem) {
	data.Db.Save(item)
}

Контроллер будет выполнять проксирование HTTP-запросов в вызовы методов для взаимодействия с базой данных:

type Controller struct {
	Data data.Data
}

func (controller *Controller) GetItems(c *gin.Context) {
	c.JSON(http.StatusOK, controller.Data.GetItems())
}

func (controller Controller) GetItem(c *gin.Context) {
	ids := c.Param("id")
	id, err := strconv.Atoi(ids)
	if err != nil {
		c.AbortWithError(http.StatusBadRequest, err)
		return
	}
	c.JSON(http.StatusOK, controller.Data.GetItem(id))
}

func (controller Controller) DeleteItem(c *gin.Context) {
	ids := c.Param("id")
	id, err := strconv.Atoi(ids)
	if err != nil {
		c.AbortWithError(http.StatusBadRequest, err)
		return
	}
	if controller.Data.DeleteItem(id) {
		c.JSON(http.StatusOK, gin.H{})
	} else {
		c.JSON(http.StatusNotFound, gin.H{})
	}
}

func (controller Controller) CreateItem(c *gin.Context) {
	var item models.ShoppingListItem
	err := c.ShouldBindJSON(&item)
	if err != nil {
		c.AbortWithError(http.StatusBadRequest, err)
		return
	} else {
		id := controller.Data.CreateItem(item)
		c.JSON(http.StatusOK, id)
	}
}

func (controller Controller) UpdateItem(c *gin.Context) {
	var item models.ShoppingListItem
	err := c.ShouldBindJSON(&item)
	if err != nil {
		c.AbortWithError(http.StatusBadRequest, err)
		return
	} else {
		controller.Data.UpdateItem(item)
		c.JSON(http.StatusOK, gin.H{"status": "updated"})
	}
}

И добавим соответствующие маршруты в инициализацию Gin:

r.GET("/item", controller.GetItems)
r.GET("/item/:id", controller.GetItem)
r.UPDATE("/item/:id", controller.UpdateItem)
r.DELETE("/item/:id", controller.DeleteItem)
r.POST("/item", controller.CreateItem)

Здесь мы используем возможности разбора JSON-тела запроса (ShouldBindJSON), который возвращает ошибку при несоответствии схемы данных. Также через контекст могут быть получены фрагменты пути : name, параметры GET-запроса, присоединенные файлы и информация об агенте (браузере или приложении). Для формирования ответа используется метод контекста JSON, который определяет код ответа (используется библиотека net/http), а также его содержание в виде сериализуемой map (через gin.H) или структуру с описанием json-полей (в этом случае она будет автоматически преобразована в JSON-ответ).

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

Контроль доступа к ресурсам

Существует два метода для проверки прав доступа и отслеживания пользователя. Первый из них — создание сессии (чаще используется при создании серверной стороны веб-приложений, где пользователь получает сгенерированные страницы), для этого можно использовать Middleware из github.com/gin-gonic/contrib/sessions:

var secret = []byte("secret")
r.Use(sessions.Sessions("mysession", sessions.NewCookieStore(secret)))

Вызов Use регистрирует middleware для управления сессиями и далее оно отслеживает текущего пользователя (в этом случае — с использование cookie, но могут быть и иные варианты). Middleware представляет из себя функцию, с заголовком идентичным обработчику запроса (принимает *gin.Context), которая может выполнить изменение запроса или создать ответ до вызова основного обработчика из маршрутизатора. Например, для ограничения доступности некоторых маршрутов можно обернуть часть вызовов в Use с контролем авторизации пользователя через сессию (для этого удобно использовать возможность создания групп запросов с общим префиксом) и реализовать методы для авторизации и выхода из системы, которые будут управлять значением в хранилище сессии с ключом username.

private := r.Group("/user")
private.Use(AuthRequired)
{
	private.GET("/me", me)
	private.GET("/status", status)
}
func AuthRequired(c *gin.Context) {
  session := sessions.Default(c)
	user := session.Get("username")
	if user == nil {
		c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
		return
	}
	c.Next()
}

func Login(c *gin.Context) {
  session := sessions.Default(c)
	username := c.PostForm("username")
	password := c.PostForm("password")
  if username=="user" && password=="password" {
    session.Set("username", "user")
    session.Save()
  } else {
    c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
  }
}

func Logout(c *gin.Context  {
  session := sessions.Default(c)
  session.Delete("username")
}

Однако для REST API предполагается, что каждый запрос является полностью независимым и не зависит от предыдущего состояния сервера. В большинстве случаев для авторизации сетевых запросов используют постоянные или временные токены, которые отправляются в заголовке Authorization. Де-факто во многих сервисах используется механизм выдачи и обновления токенов JWT, так что добавим себе в приложение поддержку этого способа определения источника запроса.

JWT-токены выдаются со стороны сервера после выполнения аутентификации любым другим способом (например, по парольной паре) и предполагают периодическое обновление для исключения потенциальной утечки. Для корректного функционирования JWT публикует три точки подключения — авторизация, обновление токена и инвалидация (удаление токена). Для интеграции с Gin Framework существует несколько библиотек для поддержки JWT, мы рассмотрим добавление авторизации на примере Gin-JWT.

Выполним установку:

go get github.com/appleboy/gin-jwt/v2

И добавим необходимый импорт"github.com/appleboy/gin-jwt/v2" .

Далее необходимо сконфигурировать middleware, это включает в себя как описание временных параметров ключей (время жизни, периодичность обновления), так и включение кода для проверки корректности учетных данных пользователя при авторизации и реакцию на некорректный доступ.

type login struct {
	Username string `form:"username" json:"username" binding:"required"`
	Password string `form:"password" json:"password" binding:"required"`
}

type User struct {
	UserName  string
	FirstName string
	LastName  string
}

func attachLogin(r *gin.Engine) {
	authMiddleware, err := jwt.New(&jwt.GinJWTMiddleware{
		Realm:       "Shopping List API",
		Key:         []byte("myverysecretkey"),
		Timeout:     time.Hour,   //время до истечения ключа
		MaxRefresh:  time.Hour,   //требуемая переодичность обновления
		IdentityKey: identityKey, //атрибут для хранения идентификатора
		PayloadFunc: func(data interface{}) jwt.MapClaims { //создание информации о пользователе
			if v, ok := data.(*User); ok {
				return jwt.MapClaims{
					identityKey: v.UserName,
				}
			}
			return jwt.MapClaims{}
		},
		IdentityHandler: func(c *gin.Context) interface{} { //создание объекта с описанием пользователя
			claims := jwt.ExtractClaims(c)
			return &User{
				UserName: claims[identityKey].(string),
			}
		},
		Authenticator: func(c *gin.Context) (interface{}, error) { //выполнение авторизации и выдача ключа
			var loginVals login
			if err := c.ShouldBind(&loginVals); err != nil {
				return "", jwt.ErrMissingLoginValues
			}
			userID := loginVals.Username
			password := loginVals.Password

			if userID == "admin" && password == "admin" {
				return &User{
					UserName:  userID,
					LastName:  "Admin",
					FirstName: "Admin",
				}, nil
			}

			return nil, jwt.ErrFailedAuthentication
		},
		Authorizator: func(data interface{}, c *gin.Context) bool { //функция авторизации
			if v, ok := data.(*User); ok && v.UserName == "admin" {
				return true
			}

			return false
		},
		Unauthorized: func(c *gin.Context, code int, message string) { //ответ при неавторизованном доступе
			c.JSON(code, gin.H{
				"code":    code,
				"message": message,
			})
		},
		TokenLookup:   "header: Authorization, query: token, cookie: jwt", //где будем искать токен авторизации
		TokenHeadName: "Bearer",                                           //тип токена (по умолчанию Bearer)
		TimeFunc:      time.Now,                                           //функция для генерации времени
	})
	if err != nil {
		log.Fatal("JWT Error:" + err.Error())
	}
	errInit := authMiddleware.MiddlewareInit()
	if errInit != nil {
		log.Fatal("authMiddleware.MiddlewareInit() Error:" + errInit.Error())
	}
	r.POST("/login", authMiddleware.LoginHandler)
  r.GET("/logout", authMiddleware.LogoutHandler)
	auth := r.Group("/auth")
	auth.GET("/refresh_token", authMiddleware.RefreshHandler)
	auth.Use(authMiddleware.MiddlewareFunc())
	{
		auth.GET("/me", meHandler)
	}
}

Добавим вызов attachLogin в инициализацию Gin, после чего получим возможность выполнять вход в систему (POST-запрос на адрес /login с полями формы username и password), в ответ будет возвращен токены доступа/обновление и срок действия, выполнить обновление токена (GET запрос /refresh_token с передачей текущего токена обновления), а также выполнять запросы в защищенной области (в этом коде это /auth/me, но аналогично можно обернуть все запросы к API для управления списком покупок с использованием authMiddleware.MiddlewareFunc). Для доступа к информации об авторизованном пользователе будет использоваться контекст:


func meHandler(c *gin.Context) {
	claims := jwt.ExtractClaims(c)
	user, _ := c.Get(identityKey)
	c.JSON(200, gin.H{
		"userID":   claims[identityKey],
		"userName": user.(*User).UserName,
	})
}

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

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

Поддержка веб-сокетов для отправки уведомлений

При создании мобильных приложений с совместным доступом к данным очень важно обеспечить мгновенную отправку уведомлений об изменениях. Существует два альтернативных подхода — с использованием веб-сокетов (постоянно существующих подключений, требует запущенного приложения в фоновом или полноэкранном режиме), либо с применением push-уведомлений платформы (в этом случае они могут быть получены и обработаны без необходимости запуска приложения). Рассмотрим первый вариант реализации.

Для включения поддержки веб-сокетов в gin можно использовать библиотеку Melody, предоставляющая возможность обработки запросов через web socket непосредственно внутри обработчиков подключений в gin.

Начнем с установки библиотеки:

go get gopkg.in/olahol/melody.v1

и добавим импорт "gopkg.in/olahol/melody.v1". Затем, вместе с gin мы должны будем инициализировать Melody и привязать ее обработчики к одному из адресов в маршрутизаторе gin. Для веб-сокетов реализуется несколько обработчиков — при открытии сессии (HandleConnect), получении сообщения (HandleMessage) и отключении клиента (HandleDisconnect). Например, в нашем случае при подключении нового клиента мы добавим ссылку на его сессию в список получателей, при отключении будем исключать его из списка. Обработка входящих сообщений не является в этом приложении обязательной, поскольку запросы модификации списка поступают через REST.

sessions := make(map[]melody.Session)

m := melody.New()
m.HandleConnect(func(session *melody.Session) {
		println("Connected")
		id, _ := uuid.NewV4()
    //добавить новую сессию
		session.Set("uuid", id.String())
    sessions = append(sessions, session)
	})
	m.HandleDisconnect(func(session *melody.Session) {
		println("Disconnected")
		//удалить завершенную сессию
		my_uuid, _ := session.Get("uuid")
	  for i := 0; i < len(sessions); i++ {
		  uuid, _ := sessions[i].Get("uuid")
			if my_uuid.(string) == uuid.(string) {
				sessions = append(sessions[:i], sessions[i+1:]...)
				break
			}
    }
	})
	m.HandleMessage(func(session *melody.Session, msg []byte) {
		var message models.Message
		json.Unmarshal(msg, &message)
  }
  r.GET("/ws", func(c *gin.Context) {
		m.HandleRequest(c.Writer, c.Request)
  })

Для подключения по протоколу Web Socket в этом примере будет использоваться путь /ws. При создании подключения генерируется уникальный идентификатор сессии, который в дальнейшем может использоваться для определения связанного пользователя (в сочетании с токеном авторизации). При отключении клиента зарегистрированная в списке сессия удаляется. Список сессий можно использовать, например, для отправки уведомления всем получателям (при добавлении нового элемента в списке покупок или отметке о выполнении на одном из существующих).

var message models.Message
var msg []byte
json.Marshal(msg, &msg)

for _, session := range sessions {
  session.Broadcast(msg)
}

Использование пуш-уведомлений

В отличии от веб-сокетов платформенные пуш-уведомления не определяются едиными стандартами и требуют поддержки со стороны библиотеки варианта реализации конкретного поставщика. Наиболее популярными сервисами для отправки пуш-уведомлений являются Firebase Cloud Messaging (Google FCM) и Apple Push Notification Services (APN), а также вендорные решения Huawei Notification. Наиболее универсальным решением для отправки пуш-уведомлений сейчас можно назвать сервер рассылки уведомлений gorush, который может взаимодействовать с Google FCM, APN, Huawei Messaging Service и запускается как отдельный сервис (из контейнера appleboy/gorush) и может работать как через утилиту командной строки gorush, так и программно через протокол gRPC. Также можно использовать платформенные библиотеки go-gcm и apns2.

Прежде всего для настройки рассылки пуш-уведомлений необходимо получить токены авторизации в соответствующих сервисах для разработки. Далее ключи (или сертификаты) передаются в yaml-конфигурации контейнера (подробнее в официальной документации). Для доступа к запущенному сервису можно использовать gRPC подключение и описание протокола gorush.

import (
  "github.com/appleboy/gorush/rpc/proto"
  structpb "github.com/golang/protobuf/ptypes/struct"
  "google.golang.org/grpc"
)

const (
  address = "gorush:9000"
)

func main() {
  conn, err := grpc.Dial(address, grpc.WithInsecure())  //подключение к серверу
  if err != nil {
    log.Fatalf("did not connect: %v", err)
  }
  defer conn.Close()
  c := proto.NewGorushClient(conn)

  //отправка уведомления
  r, err := c.Send(context.Background(), &proto.NotificationRequest{
    Platform: 2,
    Tokens:   []string{"1"},
    Message:  "В список покупок добавлены помидоры",		//текст сообщения
    Badge:    1,
    Category: "shopping",						//категория сообщения
    Sound:    "default",
    Priority: proto.NotificationRequest_HIGH,
    Alert: &proto.Alert{
      Title:    "Добавлен продукт",
      Body:     "В список покупок добавлены помидоры",
      Subtitle: "1 кг",
    },
    //дополнительные данные
    Data: &structpb.Struct{
      Fields: map[string]*structpb.Value{
        "shopping_id": {
          Kind: &structpb.Value_StringValue{StringValue: "welcome"},
        },
        "key2": {
          Kind: &structpb.Value_NumberValue{NumberValue: 2},
        },
      },
    },
  })
  if err != nil {
    log.Println("could not send notification: ", err)
  }
}

Генерация документации по API

Для автоматического создания документации по доступным точкам подключения можно использовать препроцессор исходных текстов gin-swagger, анализирующий блок комментариев перед функцией обработчиком маршрута и создающий описание в виде swagger yaml-файла, который может быть опубликован непосредственно на этом же сервере.

	import (
    swaggerFiles "github.com/swaggo/files"
	  ginSwagger "github.com/swaggo/gin-swagger"
  )

Аннотация // @BasePath /api/v1 интерпретируется как определение общего префикса для API. Для корректной интерпретации схемы входных и выходных данных к запросу перед методом контроллера необходимо добавить блок комментариев. Например, для метода получения информации о элементе списка комментарий может выглядеть следующим образом:


// Получение информации о элементе списка покупок
// @Summary Получение информации о элементе списка покупок
// @Description Возвращает подробное описание элемента списка покупок
// @Param id path string true "Идентификатор элемента"
// @Produce json
// @Success 200 {object} []models.ShoppingListItem "Информация о элементе"
// @Failure 400 {object} httputil.HTTPError
// @Router /item/{id} [GET]
func (controller Controller) GetItem(c *gin.Context) {
	ids := c.Param("id")
	id, err := strconv.Atoi(ids)
	if err != nil {
		c.AbortWithError(http.StatusBadRequest, err)
		return
	}
	c.JSON(http.StatusOK, controller.Data.GetItem(id))

Здесь указывается как общее описание метода (Summary / Description), так и схема входных данных (@Param), тип принимаемых данных (@Accept), тип возвращаемого результата (@Produce) и возможные статусы успешного (@Success) и неуспешного (@Failure) выполнения. Для генерации документации в сценарий сборки необходимо включить запуск консольной утилиты препроцессора:

go get -u github.com/swaggo/swag/cmd/swag
$GOPATH/bin/swag init

Для публикации созданного swagger-файла и интерфейса для его просмотра в маршрутизатор Gin необходимо добавить следующие действия:

url := ginSwagger.URL("http://localhost:8080/swagger/doc.json") // The url pointing to API definition
.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url))

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

В простом случае проверка корректности функционирования сервиса может быть выполнена как отдельная функция с проверками доступности необходимых подсистем, присоединенная на известный маршрут (например, GET /status). Также существует несколько middleware для автоматической проверки доступности маршрутизатора (например, gin-health-check) и инструментов для проверки доступности внешних систем и создания JSON-ответа по текущему состоянию. Например, можно использовать библиотеку health, предоставляющую возможность регистрировать проверки доступности внешних сервисов.

Установим библиотеку:

go get -u github.com/alexliesenfeld/health

Добавим импорт health «github.com/alexliesenfeld/health» и зарегистрируем проверку доступности SQLite3:

	checker := health.NewChecker(
		health.WithCacheDuration(1*time.Second),
		health.WithTimeout(10*time.Second),
		health.WithPeriodicCheck(15*time.Second, 3*time.Second, health.Check{
			Name:    "database",      // название проверки
			Timeout: 2 * time.Second, // таймаут проверки
			Check:   db.PingContext,
		}),
    //перехват изменения состояния
		health.WithStatusListener(func(ctx context.Context, state health.CheckerState) {
			log.Println(fmt.Sprintf("health status changed to %s", state.Status))
		}),
	)

	http.Handle("/health", health.NewHandler(checker))
	go http.ListenAndServe(":3000", nil)

Проверка доступности будет публиковаться на другой порт (3000), чтобы исключить ситуацию пропуска ошибки из-за недоступности основного маршрутизатора Gin.

Таким образом мы создали полноценный API с автоматической проверкой состояния доступности, генерацией документации, поддержкой REST-методов для взаимодействия с мобильным приложениям, а также возможностью мгновенной передачи уведомлений через постоянно открытые web-сокеты и платформенные пуш уведомления. Также для реальных задач бывает необходимо настроить политики CORS, ограничивать количество запросов (для исключения атак), выполнять отладку исполнения запросов и собирать аналитику по времени их обработку. Для многих из этих задач существует готовые middleware (например, можно посмотреть в подборку gin-contrib), но архитектура решения позволяет без особых затруднения создавать собственную сложную логику обработки запросов.

Полный текст API доступен в Github.

Ну и по традиции всех, кто дочитал до конца, хочу пригласить на бесплатный урок по теме «Функции и методы в Golang». Урок пройдет уже 25 мая. Регистрация доступна по ссылке ниже.

© Habrahabr.ru