Официальные сайты с инструкциями и документациями: beego , gorm

При написании backend на Go обычно используют готовые библиотеки, одни из популярных сейчас - Chi , Gin и Gorilla Mux . В данной статье мы рассмотрим менее популярную библиотеку - BeeGo . Напишем RESTful API, соберем документацию в Swagger, добавим тесты и свяжем с PostgreSQL базой при помощи популярной Gorm. Написанный микро-сервис будет использоваться для авторизации пользователей в системе по средством кода из SMS сообщения.

Beego представляет из себя мощный набор инструментов. При создании проекта Beego создает структуру дирректорий (описана ниже) и даже файлы примеров. Таким образом, при создании проекта, мы сразу получаем готовую модель API сервиса с рабочим примером, который можно исправить под свои нужды. Структура созданного проекта в Beego имеет MVC шаблон разделения данных.

MVC - Model-View-Controller («Модель-Представление-Контроллер», «Модель-Вид-Контроллер») — схема разделения данных приложения и управляющей логики на три отдельных компонента: модель, представление и контроллер — таким образом, что модификация каждого компонента может осуществляться независимо. Модель (Model) предоставляет данные и реагирует на команды контроллера, изменяя своё состояние. Представление (View) отвечает за отображение данных модели пользователю, реагируя на изменения модели. Контроллер (Controller) интерпретирует действия пользователя, оповещая модель о необходимости изменений.

  • Gorm - это реализация доступа к базе данных на языке Go
  • ORM - объектно-реляционное отображение, или преобразование — технология программирования, которая связывает базы данных с концепциями объектно-ориентированных языков программирования.
  • Beego - Свободно распостаняемый фреймворк для разработки и сборки ваших приложений на языке Go
  • Swagger - Позволяет автоматически создавать клиентские библиотеки API

Установка

Beego

go get -u github.com/beego/beego/v2@latest

Для импорта используется:

beego "github.com/beego/beego/v2/server/web"

Beego tools

go get -u github.com/beego/bee

Теперь необходимо добавить $GOPATH/bin в переменную окружения PATH:

export PATH=$GOPATH/bin:$PATH

Если команда bee version возвращает ошибку, что bee не найден, тогда выполните установку:

go install github.com/beego/bee

Gorm

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

Создаем API структуру

bee api sms

В результате получаем готовую структуру из дирректорий с файлами примеров.

Описание базовых и дополнительных директорий и файлов

Автоматически созданные директории

  • routers
  • controllers
  • swagger
  • models
  • tests

Создаются дополнительно

  • conf
  • db
  • lib/sms_service

Описание структуры

├── conf
│   └── app.conf - конфигурационный файл, содержащий в основном переменные для подключения к БД
├── controllers
│   └── sms.go - контроллер, содержит код для автоматической генерации документации в формате swagger и описание точек входа.
├── db
│   ├── conn.go - подключение и взаимодействия с базой данных.
│   ├── migration.go - автоматический перенос схемы, для поддержания в актуальном состоянии.
│   └── serializer.go - JSON serializer интерфейс и функции его реализации.
├── lib
│   └── sms_service
│       └── sms_service.go - осуществляет взаимодействие запросов ендпоинтов с базой данных.
├── models
│   ├── entity
│   │   ├── common.go - содержит структуру базовых полей для БД (ID, время создания\изменения\удаления)
│   │   └── sms.go - базовая структура с укаазанием размера полей и кодом, который отобразится как пример в swagger-документации.
│   ├── request
│   │   └── sendsms.go - обработка входящих запросов и функция для валидации.
│   └── response
│       └── sms.go - формирование ответа.
├── routers
│   └── router.go - содержит структуру HTTP-путей.
├── swagger - дерриктория содержащая swagger, внутри нам необходимо исправить только 1 файл.
└── tests
    └── sms_test.go - содержит тесты для точек входа.

Прописываем базовый путь до контроллера в router.go

func init() {
	ns := beego.NewNamespace("/v1",
		beego.NSNamespace("/sms",
			beego.NSInclude(
				&controllers.SMSController{},
			),
		),
	)
	beego.AddNamespace(ns)
}

Указываем путь до нашего swagger.json

Открываем swagger/index.html и для url указыввем путь к swagger.json:

const ui = SwaggerUIBundle({url: "./swagger.json", ....

Задаем переменные в конфигурационном файле

appname = sms
httpport = 8080
dbUser = ${DB_USER||postgres}
dbPass = ${DB_PASSWORD||postgres}
dbHost = ${DB_HOST||localhost}
dbName = ${DB_NAME||sms}

Написание обработчиков

Создаем основные структуры в models/entity

common.go - базовая модель для БД. Первичный ключ и общие поля.

type BaseModel struct {
	ID        string     `gorm:"primary_key;type:uuid;default:uuid_generate_v4()"`
	CreatedAt time.Time  `json:"-"`
	UpdatedAt time.Time  `json:"-"`
	DeletedAt *time.Time `sql:"index" json:"-"`
}

sms.go - описываем основную структуру для SMS с описанием для документации свагера

type SMS struct {
	BaseModel
	Phone    string `gorm:"column:phone" json:"phone" example:"+79991122233"`                // Телефон пользователя
	Username string `gorm:"size:255; column:username" json:"username" example:"test_user"`   // username пользователя
	FIO      string `gorm:"size:255; column:fio" json:"fio" example:"Ivanov Ivan Ivanovich"` // ФИО пользователя
	Message  string `gorm:"size:255; column:message" json:"message" example:"232-876"`       // Код для входа
}

Создаем структуры для запросов в models/request/sendsms.go

type SendSMS struct {
	Phone    string `json:"phone" example:"+79995553311"`        // Телефон пользователя
	Username string `json:"username" example:"test_user"`        // username пользователя
	FIO      string `json:"fio" example:"Ivanov Ivan Ivanovich"` // ФИО пользователя
	Message  string `json:"message" example:"232-876"`           // Код для входа
}

Так же добавим методы для конвертации (паттерн адаптер )

func (r SendSMS) ConvertToEntity() entity.SMS {
	return entity.SMS{
		Phone:    r.Phone,
		Username: r.Username,
		FIO:      r.FIO,
		Message:  r.Message,
	}
}

Добавляем валидацию входящего запроса в models/request/sendsms.go

Для провекрки будем использовать уже встроенные инструмент Beego - validation , а также regexp для проверки мобильного номера телефона (опционально, так как номер телефона тоже можно проверить через validation).

Required - проверяем, что переменная не пустая. MaxSize - проверяем, что максимальный размер не более 255 символов. Для номера телефона - сравниваем полученные данные с регулярным выражением (находится в конфиге).

func (sms SendSMS) Validate() error {
	valid := validation.Validation{}
	valid.Required(sms.Username, "username")
	valid.MaxSize(sms.Username, 255, "usernameMax")
	valid.Required(sms.FIO, "FIO")
	valid.MaxSize(sms.FIO, 255, "FIOMax")
	valid.Required(sms.Message, "message")
	valid.MaxSize(sms.Message, 255, "messageMax")
	if valid.HasErrors() {
		for _, err := range valid.Errors {
			return errors.New(err.Key + err.Message)
		}
	}
	phoneRegexp, _ := beego.AppConfig.String("phoneRegexp")
	re := regexp.MustCompile(phoneRegexp)
	if ok := re.MatchString(sms.Phone); !ok {
		return errors.New("invalid phone number format or length")
	}
	return nil
}

Создаем структуры для ответов в models/response/sms.go

type SMSes []SMS

type SMS struct {
	ID       string `json:"ID" example:"1f507148-c71d-4d64-b376-b9207370f3c6"` //ID поля
	Phone    string `json:"phone" example:"+79991122233"`                      // Телефон пользователя
	Username string `json:"username" example:"test_user"`                      // username пользователя
	FIO      string `json:"fio" example:"Ivanov Ivan Ivanovich"`               // ФИО пользователя
	Message  string `json:"message" example:"232-876"`                         // Код для входа
}

И адаптеры для конвертации - дальнейшая работа осуществляется с entity.SMS

func ConvertSMSToModel(dbSMS entity.SMS) SMS {
	return SMS{
		ID:       dbSMS.ID,
		Phone:    dbSMS.Phone,
		Username: dbSMS.Username,
		FIO:      dbSMS.FIO,
		Message:  dbSMS.Message,
	}
}

func ConvertSMSesToModel(dbSMSes []entity.SMS) SMSes {
	var SMSesResponse SMSes
	for _, dbSMS := range dbSMSes {
		SMSesResponse = append(SMSesResponse, ConvertSMSToModel(dbSMS))
	}
	return SMSesResponse
}

Пишем обработчики в controllers/sms.go

Объявляем структуру для beego контроллера:

type SMSController struct {
	beego.Controller
}

Для каждой uri должны быть комментарии, которые автоматически собираются beego в swagger в документацию. Пример:

// @Title SendSMS
// @Description send SMS
// @Param body body	request.SendSMS	true "body for SMS content"
// @Success 201 created
// @Failure 400 bad request
// @Failure 500 internal server error
// @router / [post]

Обработка запросов

Принимаем JSON запрос и парсим в request.SendSMS, проверяем (функция валидации выше в models/request/sendsms.go), выполняем и возвращаем HTTP статус ответ и, если требуется, тело или ошибку.

func (c *SMSController) SendSMS() {
	var sms request.SendSMS
	err := json.Unmarshal(c.Ctx.Input.RequestBody, &sms)
	if err != nil {
		c.Ctx.Output.SetStatus(http.StatusBadRequest)
		c.Ctx.Output.Body([]byte(err.Error()))
		return
	}
	err = sms.Validate()
	if err != nil {
		c.Ctx.Output.SetStatus(http.StatusBadRequest)
		c.Ctx.Output.Body([]byte(err.Error()))
		return
	}
	err = smsservice.Instance.SendSMS(sms)
	if err != nil {
		c.Ctx.Output.SetStatus(http.StatusInternalServerError)
		c.Ctx.Output.Body([]byte(err.Error()))
		return
	}
	c.Ctx.Output.SetStatus(http.StatusCreated)
}

Функция для записи готова. Теперь напишем функцию для вывода всех существующих записей. Используем функцию GetSMSes который объявляли в sms_service.go. Читаем все записи в слайс и формируе ответ используя функцию ConvertSMSesToModel из /response/sms.go

// @Title GetSMSes
// @Description Получить SMS
// @Success 200 {object} response.SMSes
// @Failure 404 not found
// @Failure 500 internal server error
// @router / [get]
func (c *SMSController) GetSMSes() {
	sms, err := smsservice.Instance.GetSMSes()
	if err != nil {
		c.Ctx.Output.SetStatus(http.StatusInternalServerError)
		c.Ctx.Output.Body([]byte(err.Error()))
		return
	}
	if len(sms) == 0 {
		c.Ctx.Output.SetStatus(http.StatusNotFound)
		c.Ctx.Output.Body([]byte(err.Error()))
		return
	}
	c.Ctx.Output.SetStatus(http.StatusOK)
	c.Data["json"] = sms
	c.ServeJSON()
}

Напишем функцию для вывода одной записи по ID

// @Title GetSMSByID
// @Description Получить sms по id
// @Param id path string true "ID sms"
// @Success 200 {object} response.SMS
// @Failure 404 not found
// @Failure 500 internal server error
// @router /:id [get]
func (c *SMSController) GetSMSByID(id string) {
	sms, err := smsservice.Instance.GetSMSByID(id)

	if errors.Is(err, gorm.ErrRecordNotFound) {
		c.Ctx.Output.SetStatus(http.StatusNotFound)
		return
	}

	if err != nil {
		c.Ctx.Output.SetStatus(http.StatusInternalServerError)
		c.Ctx.Output.Body([]byte(err.Error()))
		return
	}
	c.Ctx.Output.SetStatus(http.StatusOK)
	c.Data["json"] = sms
	c.ServeJSON()
}

И функцию для удаления записи по ID

// @Title DeleteUser
// @Description Удалить sms
// @Param id path string true "ID sms"
// @Success 204
// @Failure 500 internal server error
// @router /:id [delete]
func (c *SMSController) DeleteSMS(id string) {
	err := smsservice.Instance.DeleteSMS(id)
	if err != nil {
		c.Ctx.Output.SetStatus(http.StatusInternalServerError)
		c.Ctx.Output.Body([]byte(err.Error()))
		return
	}
	c.Ctx.Output.SetStatus(http.StatusOK)
}

Пишем сервис для взаимодействий с БД lib/sms_service/sms_service.go

Объявляем интерфейс для взаимодействия с backend (в нашем случае - PostgreSQL)

type Service interface {
	GetSMSes() (response.SMSes, error)
	GetSMSByID(smsID string) (response.SMS, error)
	SendSMS(sms request.SendSMS) error
	DeleteSMS(smsID string) error
}

Создаем переменную типа интерфейс Service, который объявляли ранее и присваиваем структуру smsService, которая содержит поле с указателем на структуру базы данных и добавляем поле с функцией db.GetBD() для подключения к нашей БД.

var Instance Service = smsService{
	db: db.GetDB(),
}

type smsService struct {
	db *gorm.DB
}

Пишем функцию для записи в базу данных. Функция пишет принятые данные в сообтветствующие поля базы данных.

func (s smsService) SendSMS(sms request.SendSMS) error {
	dbSMS := sms.ConvertToEntity()
	err := s.db.Create(&dbSMS).Error
	if err != nil {
		return errors.Wrapf(err, "Ошибка создания SMS %s", sms.Username)
	}

	return nil
}

Пишем функцию для чтения всех существующих записей в БД

func (u smsService) SendSMS(sms request.SendSMS) error {
	dbSMS := sms.ConvertToEntity()
	err := u.db.Create(&dbSMS).Error
	if err != nil {
		return errors.Wrapf(err, "Ошибка создания SMS %s", sms.Username)
	}

	return nil
}

Функция для чтения одной записи, запись находится по ID

func (u smsService) GetSMSByID(smsID string) (response.SMS, error) {
	sms := entity.SMS{BaseModel: entity.BaseModel{ID: smsID}}
	err := u.db.Debug().First(&sms).Error
	if err != nil {
		return response.SMS{}, errors.Wrapf(err, "Ошибка чтения SMS %v из базы", smsID)
	}
	return response.ConvertSMSToModel(sms), nil
}

Функция для удаления записи из backend’а по ID

func (u smsService) DeleteSMS(smsID string) error {
	err := u.db.Delete(&entity.SMS{}, "id = ?", smsID).Error
	if err != nil {
		return errors.Wrapf(err, "Ошибка удаления SMS %s", smsID)
	}

	return nil
}

Тестирование

Опишем тесты в tests/sms_test.go

Необходимо создать тесты, по одному на проверку каждой функции в API - отправка СМС, получения всех СМС, получение одной СМС по ID, удаление СМС и проверка возврата ошибки в случае получения пустой СМС.

func init() {
	_, file, _, _ := runtime.Caller(0)
	apppath, _ := filepath.Abs(filepath.Dir(filepath.Join(file, ".."+string(filepath.Separator))))
	beego.TestBeegoInit(apppath)
}

var id string
var expectedSMS string = `{"fio": "Ivanov Ivan Ivanovich","message": "232-876","phone": "+79991122233","username": "test_user"}`

// TestSendSMS send SMS
func TestSendSMS(t *testing.T) {
	body := bytes.NewReader([]byte(expectedSMS))
	r, _ := http.NewRequest("POST", "/v1/sms", body)
	w := httptest.NewRecorder()
	beego.BeeApp.Handlers.ServeHTTP(w, r)

	logs.Info("testing", "TestSendSMS", fmt.Sprintf("Code[%d]\n", w.Code))

	Convey("Subject: Test Station Endpoint\n", t, func() {
		Convey("Status Code Should Be 201", func() {
			So(w.Code, ShouldEqual, 201)
		})
		Convey("The Result Should Be Empty", func() {
			So(w.Body.Len(), ShouldEqual, 0)
		})
	})
}

// TestGetSMSes get SMS list
func TestGetSMSes(t *testing.T) {
	r, _ := http.NewRequest("GET", "/v1/sms", nil)
	w := httptest.NewRecorder()
	beego.BeeApp.Handlers.ServeHTTP(w, r)

	logs.Info("testing", "TestGetSMSes", fmt.Sprintf("Code[%d]\n", w.Code))

	Convey("Subject: Test Station Endpoint\n", t, func() {
		Convey("Status Code Should Be 200", func() {
			So(w.Code, ShouldEqual, 200)
		})
		Convey("The Result Should Not Be Empty", func() {
			So(w.Body.Len(), ShouldBeGreaterThan, 0)
		})
	})

	var sms response.SMSes
	err := json.Unmarshal(w.Body.Bytes(), &sms)
	if err != nil {
		t.Fatal(err)
	}
	id = sms[0].ID
}

func TestGetSMSByID(t *testing.T) {
	r, _ := http.NewRequest("GET", "/v1/sms/"+id, nil)
	w := httptest.NewRecorder()
	beego.BeeApp.Handlers.ServeHTTP(w, r)

	logs.Info("testing", "TestGetSMSByID", fmt.Sprintf("Code[%d]\n", w.Code))

	Convey("Subject: Test Station Endpoint\n", t, func() {
		Convey("Status Code Should Be 200", func() {
			So(w.Code, ShouldEqual, 200)
		})
		Convey("The Result Should Not Be Empty", func() {
			So(w.Body.Len(), ShouldBeGreaterThan, 0)
		})
	})

	var sms response.SMS
	var expected response.SMS = response.SMS{
		ID: id,
	}
	err := json.Unmarshal(w.Body.Bytes(), &sms)
	if err != nil {
		t.Fatal(err)
	}

	if expected.ID != sms.ID {
		t.Fatalf("TestGetSMSByID: ID mismatch; got %s; want %s", sms.ID, expected.ID)
	}
}

// TestDeleteSMS Delete SMS by ID
func TestDeleteSMS(t *testing.T) {
	r, _ := http.NewRequest("DELETE", "/v1/sms/"+id, nil)
	w := httptest.NewRecorder()
	beego.BeeApp.Handlers.ServeHTTP(w, r)

	logs.Info("testing", "TestDeleteSMS", fmt.Sprintf("Code[%d]\n", w.Code))

	Convey("Subject: Test Station Endpoint\n", t, func() {
		Convey("Status Code Should Be 204", func() {
			So(w.Code, ShouldEqual, 204)
		})
		Convey("The Result Should Be Empty", func() {
			So(w.Body.Len(), ShouldEqual, 0)
		})
	})
}

// TestGetEmptySMSes get empty SMS list
func TestGetEmptySMSes(t *testing.T) {
	r, _ := http.NewRequest("GET", "/v1/sms", nil)
	w := httptest.NewRecorder()
	beego.BeeApp.Handlers.ServeHTTP(w, r)

	logs.Info("testing", "TestGetEmptySMSes", fmt.Sprintf("Code[%d]\n", w.Code))

	Convey("Subject: Test Station Endpoint\n", t, func() {
		Convey("Status Code Should Be 404", func() {
			So(w.Code, ShouldEqual, 404)
		})
		Convey("The Result Should Not Be Empty", func() {
			So(w.Body.Len(), ShouldBeGreaterThan, 0)
		})
	})
}

Запуск тестов

go test tests/sms_test.go