Привет, я Паша, руковожу эксплуатацией инфраструктуры крупного хайлоад-проекта. Хочу поделиться опытом разработки бота на Golang для различных мессенджеров.

Всё началось с того, что у нас был бот на python-telegram-bot, делал он простые вещи, умел послать случайную весёлую гифку из Интернета, кошечку, собачку, затем мы прикрутили к нему наш таск-трекер и бот стал создавать тикеты прямо из чата.

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

И-так, поехали.

Используемые модули

Следующие внешние модули были использованы при разработке

	github.com/andygrunwald/go-jira v1.13.0
	github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
	github.com/gorilla/mux v1.8.0
	github.com/urfave/cli/v2 v2.3.0
	go.mongodb.org/mongo-driver v1.6.0
	golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a

Входная точка

в качестве парсера аргументов мы использовали urfave/cli2 - мы используем и cobra, для создания утилит, но, по нашему мнению, cli2 для демонов подходит больше, там при описании аргумента из коробки доступно дефолтное значение + указание переменной окружения, можно задать required, алиасы и многое другое, вот, например, параметры текстового флага (аргумента утилиты)

type StringFlag struct {
	Name        string
	Aliases     []string
	Usage       string
	EnvVars     []string
	FilePath    string
	Required    bool
	Hidden      bool
	TakesFile   bool
	Value       string
	DefaultText string
	Destination *string
	HasBeenSet  bool
}

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

package main
import (
	"github.com/urfave/cli/v2"
)
func main() {
	app := &cli.App{
		Flags: []cli.Flag{
			/*
			приложение может работать в разных dev/production, поддержим это
			- можно вызывать будущую утилиту с аргументом --environment
			- либо указать это в переменной окружения ENVIRONMENT
			- по умолчанию будет использовано значение production
			*/
			&cli.StringFlag{Name: "environment", Value: "production", Usage: "environment name", EnvVars: []string{"ENVIRONMENT"}},
			
			//прочитаем логин и пароль от JIRA, пользуемся этим аналогично
			&cli.StringFlag{Name: "jira-login", Usage: "jira bot login", EnvVars: []string{"jira_login"}},
			&cli.StringFlag{Name: "jira-password", Usage: "jira bot password", EnvVars: []string{"jira_password"}},
			
			/*
			есть и числовые аргументы, если ввести там текст то будет что то такое:
			Incorrect Usage. invalid value "foo" for flag -jira-default-search-limit: parse error
			*/
			&cli.IntFlag{Name: "jira-default-search-limit", Value: 5, Usage: "jira default search limit"},
      
			/*
			список пользователей, которые могут писать боту, задаётя множественным перечислением аргумента --telegram-permit-users:
			--telegram-permit-users foo --telegram-permit-users bar
			*/
			&cli.StringSliceFlag{Name: "telegram-permit-users", Value: cli.NewStringSlice(
				"Paulstrong",
			), Usage: "telegram permitted users list"},
			
			//и так далее
			&cli.StringFlag{Name: "mongodb-host", Usage: "mongodb server host/ip", EnvVars: []string{"mongodb_host"}},
			&cli.StringFlag{Name: "mongodb-user", Usage: "mongodb server username", EnvVars: []string{"mongodb_user"}},
			&cli.StringFlag{Name: "mongodb-password", Usage: "mongodb server password", EnvVars: []string{"mongodb_password"}},
			&cli.StringFlag{Name: "mongodb-name", Usage: "mongodb server database name", EnvVars: []string{"mongodb_name"}},
			&cli.IntFlag{Name: "mongodb-port", Value: 27017, Usage: "mongodb server database name"},
		},
		Action: func(context *cli.Context) error {
			/*
			у нас у демона есть всего одна корневая "команда", эта функция отвечает за её обработку
			здесь мы создаём экземпляр будущего демона, передаём ему контекст с аргументами
			*/
			d := daemon.NewDaemon(context)
			//проводим инициализацию демона (позже рассмотрим, что там)
			if err := d.Init(); err != nil {
				log.Fatalln("daemon initialization error", err)
			}
			//и, наконец, запускаем демона
			return d.Run()
		},
	}
	//запускаем приложение cli2
	_ = app.Run(os.Args)
}

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

Конфиг

Пользоваться context от cli2 в приложении - неудобно, потому что обращаться к аргументам придётся по имени и это надо будет либо копипастить в коде, либо хранить в константах, например вот так выглядит получение имени окружения:

context.String("environment")

мы это дело обернули в структуру, в которую встроили контекст:

type UrfaveContext struct {
	*cli.Context
}

func NewUrfaveContext(context *cli.Context) *UrfaveContext {
	ret := &UrfaveContext{}
	ret.Context = context
	return ret
}

func (c *UrfaveContext) Environment() string {
	return c.Context.String("environment")
}

и далее мы подаём в инстанс демона уже готовый конфиг:

d := daemon.NewDaemon(NewUrfaveContext(context))

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

type Daemon struct {
	config    interfaces.IConfig
}

func NewDaemon(config interfaces.IConfig) *Daemon {
  return &Daemon{config}
}

func (d *Daemon) PrintEnv() {
  fmt.Println(d.config.Environment())
}

здесь мы видим интерфейс IConfig, мы его используем осознанно, т.к. в будущем может появиться что-то более прикольное, чем cli2, а также кто-то может задействовать cobra вместо cli2, поэтому мы просто реализуем методы, указанные в интерфейсе, а на чём именно - решать каждому самостоятельно:

type IConfig interface {
	Environment() string
}

Что дальше?

Дальше нужно поговорить о подкапотном пространстве демона. Он у нас имеет следующие атрибуты:

type Daemon struct {
	shutdown  chan struct{}
	engines []interfaces.IEngine
	config    interfaces.IConfig
	db        interfaces.IDatabase
}
  • shutdown - канал, в который шлётся struct{}{} при отлове сигнала завершения приложения, когда мы писали бота, мы еще не знали про то, как работать context.Context, поэтому писали как могли

  • engine - универсальный "движок", например telegram, viber, то есть менсенджеры, кроме того, движком может быть обычная горутина, которая что-то делает на фоне, а затем присылает в один из других движков результаты своей работы, в общем и целом, это - горутина, которая работает в фоне

  • config - мы уже знаем что это

  • db - объект базы данных, мы используем бд для хранения oauth2 авторизации от нашего корпоративного мессенджера, мы подаём этот объект в "конструктор" демона аналогично конфигу, это может быть монга, либо быть mysql, и др., главное, реализовать нужные методы:

Пользуемся этой структурой предсказуемо:

type IDaemon interface {
  Config() IConfig
}

func NewDaemon() IDaemon {
  var daemon *Daemon
  daemon.engines = append(daemon.engines, NewTelegram(daemon))
  daemon.engines = append(daemon.engines, NewOurMessenger(daemon))
  return daemon
}

daemon подаём в конструктор не зря, в будущем мы сможем обратиться, например, к конфигу

type OurMessenger struct {
  daemon IDaemon
}
func NewOurMessenger(daemon IDaemon) *OurMessenger {
  return &OurMessenger{daemon}
}
func (m *OurMessenger) GetUpdates() {
  token := m.daemon.Config()
  ...
}

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

type IDatabase interface {
	GetToken() (*oauth2.Token, error) //читаем токен из базы
	SetToken(t *oauth2.Token) error //кладём токен в базу
}

Интерфейс движка выглядит так

type IEngine interface {
	AddShutdownChan(ch chan struct{})
	Run(wg *sync.WaitGroup)
	Reply(update IUpdate)
	CheckAcl(update IUpdate, cmd ICommand) (result bool)
	SetManager(engine IManager) 
	GetManager() IManager
	SetName(name string) 
	GetName() string
	SetDaemon(d IDaemon) 
	GetDaemon() IDaemon
	GetConfig() IConfig
	GetDB() IDatabase
}

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

Менеджер

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

type OurMessenger struct {
	name         string
	daemon       interfaces.IDaemon
	manager      interfaces.IManager
}

func New(d interfaces.IDaemon, name string) interfaces.IEngine {
	e := &OurMessenger{daemon: d, name: name}
	e.manager = NewManager(e)
	return a
}

Интерфейс менеджера выглядит следующим образом

type IManager interface {
  /*
  метод для сохранения ссылки на исходный контроллер, 
  который начнёт обрабатывать сообщение, полученное от пользователя
  */
	SetInitialController(c IInitialController) 
  /*
  после инициализации структуры, реализующей IUpdate,
  она будет передана в функцию Route, 
  которая отправит это дело движку, который связан с менеджером
  */
	Route(update IUpdate)
	SetEngine(e IEngine) 
	GetEngine() IEngine
	GetConfig() IConfig
	GetDB() IDatabase
}

type Manager struct {
  engine IEngine
}

func NewManager(e IEngine) IManager {
  return &Manager{e}
}

Как обрабатываются сообщения

Итак, мы научили принимать сообщения от наших пользователей, детали реализации тут раскрывать не буду, у каждого может быть своя реализации, смысл тут в том, что у нас есть набор байт от сообщения, id чата, имя пользователя, под это дело создали интерфейс

type IUpdate interface {
	Reply(text string)
	CheckAcl(cmd ICommand) (result bool)
	GetText() (result string)
	GetEngine() (result IEngine)
	GetChatID() (result string)
	GetMessageID() (result string)
	GetReplyText() (result string)
	GetUserName() (result string)
}

приняв сообщение от пользователя, мы упаковываем его атрибуты в объект и отдаём менеджеру на роутинг

		upd := update.NewUpdate(ourMsgObj, text, msgId, chanId, directId)
    ourMsgObj.manager.Route(upd)

теперь наше сообщение передано менеджеру на обработку, здесь мы можем проверить правомерность отправки сообщения (acl), например, Telegram публичный мессенджер, и некоторые команды закрыты на определенных пользователей, а дальше мы отправляем наше сообщение в обработчика (контроллер)

интерфейс контроллера выглядит следующим образом

type IController interface {
	Call(cmd ICommand) (result string, err error) //входная точка
	CanAnswer(c ICommand) (result bool) //метод для проверки возможности ответа контроллером для заданного текста
	GetName() (result string) //отдаём имя контроллера
	Validate(c ICommand) (err error) //проверяем правильность заполнения команды контроллера
	SetManager(m IManager)
	GetManager() IManager
	GetConfig() IConfig
	GetDatabase() IDatabase
}

type IInitialController interface {
	IController
	ThrowManager() 
}

Контроллер - это объект, который парсит строку, выделяя в ней заголовочную часть (Entity) от данных (Args), и далее передаёт данные на рендеринг в модельную часть, для такого разделения у нас есть интерфейс ICommand

type ICommand interface {
	GetEntity() (result string) //отдать заголовок команды
	SetEntity(val string) //записать заголовок команды
	GetArgs() (result string) //отдать тело команды
	SetArgs(val string) //записать тело команды
	GetText() (result string) //отдать исходный текст команды
	SetText(val string) //записать исходный текст команды
	Parse() (entity string, args string, err error) //парсим команду
}

Чтобы понять, как это у нас используется, нужно показать какие команды мы умеем обрабаывать:

/task create foo тестируем создание тикета
это текстовый тикет, не нужно ничего делать
мы просто хотим убедиться, что бот успешно его создал

Что мы видим?

Во первых, мы договорились, что сообщения для бота будут начинаться со слеша, сегодня это выглядит немного странновато, т.к. бота можно отметить через @, но это история, нас это устраивает, мы это не трогаем. Может быть в будущем поддержим как-то иначе.

Далее мы видим инструкцию task, под это дело у нас есть type TaskController

в контексте ICommand, task - является Entity, всё остальное - Args, аргументы, который мы начинаем "проваливать" дальше по цепочке контроллеров.

Далее идёт create, под это у нас есть CreateController (всё, что связано с task, вынесено в отдельный package, чтобы не вводить дополнительно кучу префиксом в именах типов)

Далее идёт имя очереди в JIRA, в данном случае, для примера, foo

Дальше у нас идёт тема тикета (тестируем создание тикета)

И, наконец, идёт текст тикета.

Если наложить это на ICommand, то получится следующее:

type Command struct {
	entity          string //task
	args            string //create foo .............
	text            string //тут полный текст команды
}

//немного реализации
func NewCommand(text string) (cmd interfaces.ICommand, err error) {
	cmd = &Command{text: text}
  //отделяем заголовок команды от данных
	entity, args, err := cmd.Parse()
	if err != nil {
		return cmd, err
	}
	cmd.SetEntity(entity)
	cmd.SetArgs(args)
	return cmd, err
}

func (c *Command) Parse() (entity string, args string, err error) {
	text := c.text
	lines := strings.Split(text, "\n")
	//для определения entity нам нужна только первая строчка
	if len(lines) > 1 {
		text = lines[0]
	}
	//нас интересует регулярка
	re := regexp.MustCompile(`^(\S+)(.*)$`)
	//ищем по регулярке
	items := re.FindStringSubmatch(text)
	//если паттерн не подошёл
	if len(items) < 2 {
		//возвращаем наверх соответствующую ошибку
		return entity, args, errors.New(controllers.EParseCommandError)
	}
	/*не смог вспомнить зачем это
	наверное как то связано с телеграмом и вызовом бота через @
	*/
	entity = strings.Split(items[1], "@")[0]
	//далее инициализируем args от второго элемента регулярки
	args = items[2]
	/*и идём по всем строчкам и пристёгиваем их к args
	соглашусь, тут scanner.Scanner и bytes.Buffer выглядит гораздо интересней
	писали давно, еще почти ничего не знали про golang
	*/
	if len(lines) > 1 {
		for index, line := range lines {
			if index == 0 {
				continue
			}
			args += "\n" + line
		}
	}
	//отдаём результат разбора команды на Entity и Args
	return strings.TrimSpace(entity), strings.TrimSpace(args), err
}

//далее реализация интерфейса
func (c *Command) GetEntity() (result string) {
	return c.entity
}

func (c *Command) GetArgs() (result string) {
	return c.args
}

func (c *Command) GetText() (result string) {
	return c.text
}

func (c *Command) SetEntity(val string) {
	c.entity = val
}

func (c *Command) SetArgs(val string) {
	c.args = val
}

func (c *Command) SetText(val string) {
	c.text = val
}

Чтобы начать обрабатывать сообщение, у нас есть InitialController, который находится в вершине всей иерархии

type InitialController struct {
	controllers []interfaces.IController
	name        string
	manager     interfaces.IManager
}

func NewInitialController(manager interfaces.IManager) interfaces.IInitialController {
	ctrl := &InitialController{}
	ctrl.name = "initial"
	ctrl.AddController(task_controller.NewTaskController(manager))
	return ctrl
}

/* 
метод для прокидывания менеджера все контроллеры
вызывается после добавления всех контроллеров
*/
func (ctrl *InitialController) ThrowManager() {
	for _, c := range ctrl.controllers {
		c.SetManager(ctrl.manager)
	}
}

/*
метод для поиска контроллера, который готов ответить на нашу команду
*/
func (ctrl *InitialController) FindController(command interfaces.ICommand) (result interfaces.IController, err error) {
	for _, ctl := range ctrl.controllers {
		if ctl.CanAnswer(command) {
			result = ctl
		}
	}
	if result == nil {
		result = NewDummyController(ctrl.manager)
	}
	return result, err
}

/*
входная точка в обработку сообщения контроллером
*/
func (ctrl *InitialController) Call(cmd interfaces.ICommand) (result string, err error) {

	controller, ctlErr := ctrl.FindController(cmd)
	/*
	если мы не нашли контроллера для ответа на initial-команду
	значит эта команда нам неизвестна в принципе, кидаем это дело наверх
	*/
	if ctlErr != nil {
		return result, ctlErr
	}
	/*
	если мы тут, значит контроллер всё таки нашёлся
	но надо проверить синтаксис команды
	*/
	if err := controller.Validate(cmd); err != nil {
		//и если что, кинуть ошибку наверх
		return result, errors.New(controllers.EInvalidCommand + ": " + err.Error())
	}
	/*
	если мы тут, значит команда нам известна (контроллер найден)
	можем вызвать его и отдать результат наверх
	*/
	return controller.Call(cmd)
}

Код будет идти по списку контроллеров и опрашивать их "ты можешь ответить на команды task?", и один из контроллеров ответить true

type TaskController struct {
	name        string
	controllers []interfaces.IController
	manager     interfaces.IManager
}

func NewTaskController(m interfaces.IManager) *TaskController {
	task := &TaskController{manager: m}
	task.name = "task"
	task.AddController(NewCreateController(m))
	return task
}

/*
проверяем возможность ответить на команду
*/
func (ctrl *TaskController) CanAnswer(cmd interfaces.ICommand) (result bool) {
	return cmd.GetEntity() == ctrl.name
}

/*
входная точка контроллера
*/
func (ctrl *TaskController) Call(cmd interfaces.ICommand) (result string, err error) {
	if err := ctrl.Validate(cmd); err != nil {
		return result, errors.New(controllers.EInvalidCommand + ": " + err.Error())
	}
	taskCmd, taskCmdErr := commands.NewCommand(cmd.GetArgs())
	if taskCmdErr != nil {
		return result, errors.New(controllers.EInvalidTaskCommand)
	}
	//идём по списку контроллеров и спрашиваем кто может ответить
	controller, ctlErr := ctrl.FindController(taskCmd)
	if ctlErr != nil {
		return result, ctlErr
	}
	if err := controller.Validate(taskCmd); err != nil {
		return result, errors.New(controllers.EInvalidTaskCommand + ": " + err.Error())
	}

	return controller.Call(taskCmd)
}

у него добавлены разные контроллеры, в т.ч. CreateController, здесь всё аналогично, код будет идти по списку контроллеров и спрашивать "ты можешь ответить на команду create?", и указанный контроллер ответит true

type CreateController struct {
	args    string
	name    string
	manager interfaces.IManager
}

func NewCreateController(m interfaces.IManager) *CreateController {
	ret := &CreateController{name: "create", manager: m}
	return ret
}

func (ctrl *CreateController) CanAnswer(cmd interfaces.ICommand) (result bool) {
	return cmd.GetEntity() == ctrl.name
}

И так далее, мы постепенно разбираем команду, отправляя команды во вложенные контроллеры. В какой-то момент мы придём в конечную точку, из которой мы передадим конкретную команду в модель на обработку.

func (ctrl *CreateController) Call(cmd interfaces.ICommand) (result string, err error) {
	createCmd, _ := commands.NewTaskCreateCommand(cmd.GetText())
	parseRes, parseErr := createCmd.Parse()
	if parseErr != nil {
		return result, parseErr
	}
	return task.Create(parseRes.Project, parseRes.Title, parseRes.Content, parseRes.Assignee, ctrl.GetConfig(), parseRes.Link)
}

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

Если какой-то из контроллеров считает что он может CanAnswer, но после этого у него не проходит Validate (отдаёт err != nil), то мы возвращаем ошибку наверх, пристёгивая детали, таким образом пользователь получит понятное описание ошибки, что конкретно не так он ввёл в своей команде боту.

Заключение

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

Всем peace :)

Комментарии (0)