[Мессенджеры, Программирование, Go, Agile] Пишем Slack бота для Scrum покера на Go. Часть 1

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
04-Мар-2021 01:32

Здравствуйте! Сегодня мы напишем Slack бота для Scrum покера на языке Go. Писать будем по возможности без фреймворков и внешних библиотек, так как наша цель — разобраться с языком программирования Go и проверить, насколько этот язык удобен для разработки подобных проектов.
ДисклеймерЯ только познаю Go и многих вещей еще не знаю. Мой основной язык разработки Python. Поэтому часто буду отсылать к нему в тех местах, где по моему мнению в Python что-то сделано удобнее или проще. Цель этих отсылок в том, чтобы породить дискуссию, ведь вполне вероятно, что эти "удобные вещи" также присутствуют в Go, просто я их не нашел. Также, отмечу, что все что будет описано ниже, можно было бы сделать гораздо проще (без разделения на слои и так далее), но мне показалось интересным написать больше с целью обучения и практики в "чистой" архитектуре. Да и тестировать так проще.Хватит прелюдий, вперед в бой!Итоговый результатАнимация работы будущего бота
Для тех, кому читать код интересней, чем статью — прошу сюда.Структура приложенияРазобьем нашу программу на следующие слои. У нас предполагается слой взаимодействия (web), слой для рисования интерфейса средствами Slack UI Block Kit(ui), слой для сохранения / получения результатов (storage), а также место для хранения настроек (config). Давайте создадим следующие папки в проекте:
config/
storage/
ui/
web/
-- clients/
-- server/
main.go
СерверДля сервера будем использовать стандартный сервер из пакета http. Создадим структуру Server следующего вида в web -> server:server.go
package server
import (
  "context"
  "log"
  "net/http"
  "os"
  "os/signal"
  "sync/atomic"
  "time"
)
type Server struct {
  // Здесь мы будем определять все необходимые нам зависимости и передавать их на старте приложения в main.go
  healthy        int32
  logger         *log.Logger
}
func NewServer(logger *log.Logger) *Server {
  return &Server{
    logger: logger,
  }
}
Эта структура будет выступать хранилищем зависимостей для наших хэндлеров. Есть несколько подходов для организации работы с хэндлерами и их зависимостями. Например, можно объявлять и запускать все в main.go, там же где мы создаем экземпляры наших структур и интерфейсов. Но это плохой путь. Еще есть вариант использовать глобальные переменные и просто их импортировать. Но в таком случае становится сложно покрывать проект тестами. Дальше мы увидим плюсы выбранного мной подхода. Итак, нам нужно запустить наш сервер. Напишем метод:server.go
func (s *Server) setupRouter() http.Handler {  // TODO
  router := http.NewServeMux()
  return router
}
func (s *Server) Serve(address string) {
  server := &http.Server{
    Addr:         address,
    Handler:      s.setupRouter(),
    ErrorLog:     s.logger, // Наш логгер
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
    IdleTimeout:  15 * time.Second,
  }
  // Создаем каналы для корректного завершения процесса
  done := make(chan bool)
  quit := make(chan os.Signal, 1)
  // Настраиваем сигнал для корректного завершения процесса
  signal.Notify(quit, os.Interrupt)
  go func() {
    <-quit
    s.logger.Println("Server is shutting down...")
    // Эта переменная пригодится для healthcheck'а например
    atomic.StoreInt32(&s.healthy, 0)
    // Даем клиентам 30 секунд для завершения всех операций, прежде чем сервер будет остановлен
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    // Информируем сервер о том, что не нужно держать существующие коннекты
    server.SetKeepAlivesEnabled(false)
    // Выключаем сервер
    if err := server.Shutdown(ctx); err != nil {
      s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)
    }
    close(done)
  }()
  s.logger.Println("Server is ready to handle requests at", address)
  // Переменная для проверки того, что сервер запустился и все хорошо
  atomic.StoreInt32(&s.healthy, 1)
  // Запускаем сервер
  if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
    s.logger.Fatalf("Could not listen on %s: %v\n", address, err)
  }
  // Когда сервер остановлен и все хорошо, снова получаем управление и логируем результат
  <-done
  s.logger.Println("Server stopped")
}
Теперь давайте создадим первый хэндлер. Создадим папку в web -> server -> handlers:healthcheck.go
package handlers
import (
  "net/http"
)
func Healthcheck() http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    w.Write("OK")
  })
}
Добавим наш хэндлер в роутер:server.go
// Наш код выше
func (s *Server) setupRouter() http.Handler {
  router := http.NewServeMux()
  router.Handle(
    "/healthcheck",
    handlers.Healthcheck(),
  )
  return router
}
// Наш код ниже
Идем в main.go и пробуем запустить наш сервер:
package main
import (
  "log"
  "os"
  "go-scrum-poker-bot/web/server"
)
func main() {
  // Создаем логгер со стандартными флагами и префиксом "INFO:".
  // Писать он будет только в stdout
  logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
  app := server.NewServer(logger)
  app.Serve(":8000")
}
Пробуем запустить проект:
go run main.go
Если все хорошо, то сервер запустится на :8000 порту. Наш текущий подход к созданию хэндлеров позволяет передавать в них любые зависимости. Это нам еще пригодится, когда мы будем писать тесты. ;) Прежде чем идти дальше, нам нужно немного настроить нашу локальную среду, чтобы Slack смог с нами взаимодействовать.NGROKДля того, чтобы можно было локально проверять работу нашего бота, нам нужно установить себе туннель ngrok. Вообще можно любой другой, но этот вариант удобный и прост в использовании. Да и Slack его советует. В общем, когда все будет готово, запустите его командой:
ngrok http 8000
Если все хорошо, то вы увидите что-то вроде этого:
ngrok by @inconshreveable                                                                                                            (Ctrl+C to quit)
Session Status                online
Account                       Sayakhov Ilya (Plan: Free)
Version                       2.3.35
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://ffd3cfcc460c.ngrok.io -> http://localhost:8000
Forwarding                    https://ffd3cfcc460c.ngrok.io -> http://localhost:8000
Connections                   ttl     opn     rt1     rt5     p50     p90
                              0       0       0.00    0.00    0.00    0.00
Нас интересует строчка https://ffd3cfcc460c.ngrok.io. Она нам понадобится дальше.Slash commandsСоздадим наше приложение в Slack. Для этого нужно перейти сюда -> Create New App. Далее указываем имя GoScrumPokerBot и добавляем его в свой Workspace. Далее, нам нужно дать нашему боту права. Для этого идем в OAuth & Permissions -> Scopes и добавляем следующие права: chat:write, commands. Первый набор прав нужен, чтобы бот мог писать в каналы, а второй для slash команд. И наконец нажимаем на Reinstall to Workspace. Готово! Теперь идем в раздел Slash commands и добавляем нашу команду /poker . В Request URL нужно вписать адрес из пункта выше + путь. Пусть будет так: https://ffd3cfcc460c.ngrok.io/play-poker.Slash command handlerТеперь создадим хэндлер для обработки событий на только созданную команду. Идем в web -> server -> handlers и создаем файл play_poker.go:
func PlayPokerCommand() http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"response_type": "ephemeral", "text": "Hello world!"}`))
  })
}
Добавляем наш хэндлер в роутер:server.go
func (s *Server) setupRouter() http.Handler {
  router := http.NewServeMux()
  router.Handle(
    "/healthcheck",
    handlers.Healthcheck(),
  )
  router.Handle(
    "/play-poker",
    handlers.PlayPokerCommand(),
  )
  return router
}
Идем в Slack и пробуем выполнить эту команду: /poker. В ответ вы должны получить что-то вроде этого:
Но это не единственный вариант взаимодействия со Slack. Мы также можем слать сообщения в канал. Этот вариант мне понравился больше и плюс у него больше возможностей в сравнении с ответом на команду. Например вы можете послать сообщение в фоне (если оно требует долгих вычислений). Давайте напишем наш http клиента. Идем в web -> clients. Создаем файл client.go:client.go
package clients
// Создадим новый тип для наших хэндлеров
type Handler func(request *Request) *Response
// Создадим новый тип для middleware (о них чуть позже)
type Middleware func(handler Handler, request *Request) Handler
// Создадим интерфейс http клиента
type Client interface {
  Make(request *Request) *Response
}
// Наша реализация клиента
type BasicClient struct {
  client     *http.Client
  middleware []Middleware
}
func NewBasicClient(client *http.Client, middleware []Middleware) Client {
  return &BasicClient{client: client, middleware: middleware}
}
// Приватный метод для всей грязной работы
func (c *BasicClient) makeRequest(request *Request) *Response {
  payload, err := request.ToBytes() // TODO
  if err != nil {
    return &Response{Error: err}
  }
  // Создаем новый request, передаем в него данные
  req, err := http.NewRequest(request.Method, request.URL, bytes.NewBuffer(payload))
  if err != nil {
    return &Response{Error: err}
  }
  // Применяем заголовки
  for name, value := range request.Headers {
    req.Header.Add(name, value)
  }
  // Выполняем запрос
  resp, err := c.client.Do(req)
  if err != nil {
    return &Response{Error: err}
  }
  defer resp.Body.Close()
  // Читаем тело ответа
  body, err := ioutil.ReadAll(resp.Body)
  if err != nil {
    return &Response{Error: err}
  }
  err = nil
  // Если вернулось что-то отличное выше или ниже 20x, то ошибка
  if resp.StatusCode > http.StatusIMUsed || resp.StatusCode < http.StatusOK {
    err = fmt.Errorf("Bad response. Status: %d, Body: %s", resp.StatusCode, string(body))
  }
  return &Response{
    Status:  resp.StatusCode,
    Body:    body,
    Headers: resp.Header,
    Error:   err,
  }
}
// Наш публичный метод для запросов
func (c *BasicClient) Make(request *Request) *Response {
  if request.Headers == nil {
    request.Headers = make(map[string]string)
  }
  // Применяем middleware
  handler := c.makeRequest
  for _, middleware := range c.middleware {
    handler = middleware(handler, request)
  }
  return handler(request)
}
Теперь создадим файл web -> clients:request.go
package clients
import "encoding/json"
type Request struct {
  URL     string
  Method  string
  Headers map[string]string
  Json    interface{}
}
func (r *Request) ToBytes() ([]byte, error) {
  if r.Json != nil {
    result, err := json.Marshal(r.Json)
    if err != nil {
      return []byte{}, err
    }
    return result, nil
  }
  return []byte{}, nil
}
Сразу напишем тесты к методу ToBytes(). Для тестов я взял testify/assert, так как без нее была бы куча if'ов, а меня они напрягают :) . К тому же, я привык к pytest и его assert, да и как-то глазу приятнее:request_test.go
package clients_test
import (
  "encoding/json"
  "go-scrum-poker-bot/web/clients"
  "reflect"
  "testing"
  "github.com/stretchr/testify/assert"
)
func TestRequestToBytes(t *testing.T) {
  // Здесь мы делаем что-то вроде pytest.parametrize (жаль, что в Go нет сахара для декораторов, это было бы удобнее)
  testCases := []struct {
    json interface{}
    data []byte
    err  error
  }{
    {map[string]string{"test_key": "test_value"}, []byte("{"test_key":"test_value"}"), nil},
    {nil, []byte{}, nil},
    {make(chan int), []byte{}, &json.UnsupportedTypeError{Type: reflect.TypeOf(make(chan int))}},
  }
  // Проходимся по нашим тест кейсам
  for _, testCase := range testCases {
    request := clients.Request{
      URL:     "https://example.com",
      Method:  "GET",
      Headers: nil,
      Json:    testCase.json,
    }
    actual, err := request.ToBytes()
    // Проверяем результаты
    assert.Equal(t, testCase.err, err)
    assert.Equal(t, testCase.data, actual)
  }
}
И нам нужен web -> clients:response.go
package clients
import "encoding/json"
type Response struct {
  Status  int
  Headers map[string][]string
  Body    []byte
  Error   error
}
// Я намеренно сделал универсальный метод, чтобы можно было привезти любой ответ к нужному и не писать каждый раз эти богомерзкие if err != nil
func (r *Response) Json(to interface{}) error {
  if r.Error != nil {
    return r.Error
  }
  return json.Unmarshal(r.Body, to)
}
И также, напишем тесты для метода Json(to interface{}):response_test.go
package clients_test
import (
  "errors"
  "go-scrum-poker-bot/web/clients"
  "testing"
  "github.com/stretchr/testify/assert"
)
// Один тест на позитивный кейс
func TestResponseJson(t *testing.T) {
  to := struct {
    TestKey string `json:"test_key"`
  }{}
  response := clients.Response{
    Status:  200,
    Headers: nil,
    Body:    []byte(`{"test_key": "test_value"}`),
    Error:   nil,
  }
  err := response.Json(&to)
  assert.Equal(t, nil, err)
  assert.Equal(t, "test_value", to.TestKey)
}
// Один тест на ошибку
func TestResponseJsonError(t *testing.T) {
  expectedErr := errors.New("Error!")
  response := clients.Response{
    Status:  200,
    Headers: nil,
    Body:    nil,
    Error:   expectedErr,
  }
  err := response.Json(map[string]string{})
  assert.Equal(t, expectedErr, err)
}
Теперь, когда у нас есть все необходимое, нам нужно написать тесты для клиента. Есть несколько вариантов написания тестов для http клиента. Я выбрал вариант с подменой http транспорта. Однако есть и другие варианты, но этот мне показался удобнее:client_test.go
package clients_test
import (
  "bytes"
  "go-scrum-poker-bot/web/clients"
  "io/ioutil"
  "net/http"
  "testing"
  "github.com/stretchr/testify/assert"
)
// Для удобства объявим новый тип
type RoundTripFunc func(request *http.Request) *http.Response
func (f RoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
  return f(request), nil
}
// Создание mock тестового клиента
func NewTestClient(fn RoundTripFunc) *http.Client {
  return &http.Client{
    Transport: RoundTripFunc(fn),
  }
}
// Валидный тест
func TestMakeRequest(t *testing.T) {
  url := "https://example.com/ok"
  // Создаем mock клиента и пишем нужный нам ответ
  httpClient := NewTestClient(func(req *http.Request) *http.Response {
    assert.Equal(t, req.URL.String(), url)
    return &http.Response{
      StatusCode: http.StatusOK,
      Body:       ioutil.NopCloser(bytes.NewBufferString("OK")),
      Header:     make(http.Header),
    }
  })
  // Создаем нашего http клиента с замоканным http клиентом
  webClient := clients.NewBasicClient(httpClient, nil)
  response := webClient.Make(&clients.Request{
    URL:     url,
    Method:  "GET",
    Headers: map[string]string{"Content-Type": "application/json"},
    Json:    nil,
  })
  assert.Equal(t, http.StatusOK, response.Status)
}
// Тест на ошибочный response
func TestMakeRequestError(t *testing.T) {
  url := "https://example.com/error"
  httpClient := NewTestClient(func(req *http.Request) *http.Response {
    assert.Equal(t, req.URL.String(), url)
    return &http.Response{
      StatusCode: http.StatusBadGateway,
      Body:       ioutil.NopCloser(bytes.NewBufferString("Bad gateway")),
      Header:     make(http.Header),
    }
  })
  webClient := clients.NewBasicClient(httpClient, nil)
  response := webClient.Make(&clients.Request{
    URL:     url,
    Method:  "GET",
    Headers: map[string]string{"Content-Type": "application/json"},
    Json:    nil,
  })
  assert.Equal(t, http.StatusBadGateway, response.Status)
}
Отлично! Теперь давайте напишем middleware. Я привык для каждой, даже самой маленькой задачи, писать отдельную маленькую middleware. Так можно легко переиспользовать такой код в разных проектах / для разных API с разными требованиями к заголовкам / авторизации и так далее. Slack требует при отправке сообщений в канал указывать Authorization заголовок с токеном, который вы сможете найти в разделе OAuth & Permissions. Создаем в web -> clients -> middleware:auth.go
package middleware
import (
  "fmt"
  "go-scrum-poker-bot/web/clients"
)
// Токен будем передавать при определении middleware на этапе инициализации клиента
func Auth(token string) clients.Middleware {
  return func(handler clients.Handler, request *clients.Request) clients.Handler {
    return func(request *clients.Request) *clients.Response {
      request.Headers["Authorization"] = fmt.Sprintf("Bearer %s", token)
      return handler(request)
    }
  }
}
И напишем тест к ней:auth_test.go
package middleware_test
import (
  "fmt"
  "go-scrum-poker-bot/web/clients"
  "go-scrum-poker-bot/web/clients/middleware"
  "testing"
  "github.com/stretchr/testify/assert"
)
func TestAuthMiddleware(t *testing.T) {
  token := "test"
  request := &clients.Request{
    Headers: map[string]string{},
  }
  handler := middleware.Auth(token)(
    func(request *clients.Request) *clients.Response {
      return &clients.Response{}
    },
    request,
  )
  handler(request)
  assert.Equal(t, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", token)}, request.Headers)
}
Также в репозитории вы сможете найти middleware для логирования и установки Content-Type: application/json. Здесь я не буду приводить этот код в целях экономии времени и места :). Давайте перепишем наш PlayPoker хэндлер:play_poker.go
package handlers
import (
  "errors"
  "go-scrum-poker-bot/ui"
  "go-scrum-poker-bot/web/clients"
  "go-scrum-poker-bot/web/server/models"
  "net/http"
  "github.com/google/uuid"
)
func PlayPokerCommand(webClient clients.Client, uiBuilder *ui.Builder) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    // Добавим проверку, что нам пришли данные из POST Form с текстом и ID канала
    if r.PostFormValue("channel_id") == "" || r.PostFormValue("text") == "" {
      w.Write(models.ResponseError(errors.New("Please write correct subject"))) // TODO
      return
    }
    resp := webClient.Make(&clients.Request{
      URL:    "https://slack.com/api/chat.postMessage",
      Method: "POST",
      Json: uiBuilder.Build( // TODO: Напишем builder позже
        r.PostFormValue("channel_id"),
        uuid.New().String(),
        r.PostFormValue("text"),
        nil,
        false,
      ),
    })
    if resp.Error != nil {
      w.Write(models.ResponseError(resp.Error)) // TODO
      return
    }
  })
}
И создадим в web -> server -> models . Файл errors.go для быстрого формирования ошибок:errors.go
package models
import (
  "encoding/json"
  "fmt"
)
type SlackError struct {
  ResponseType string `json:"response_type"`
  Text         string `json:"text"`
}
func ResponseError(err error) []byte {
  resp, err := json.Marshal(
    SlackError{
      ResponseType: "ephemeral",
      Text:         fmt.Sprintf("Sorry, there is some error happened. Error: %s", err.Error()),
    },
  )
  if err != nil {
    return []byte("Sorry. Some error happened")
  }
  return resp
}
Напишем тесты для хэндлера:play_poker_test.go
package handlers_test
import (
  "errors"
  "go-scrum-poker-bot/config"
  "go-scrum-poker-bot/ui"
  "go-scrum-poker-bot/web/server/handlers"
  "go-scrum-poker-bot/web/server/models"
  "net/http"
  "net/http/httptest"
  "net/url"
  "strings"
  "testing"
  "github.com/stretchr/testify/assert"
)
func TestPlayPokerHandler(t *testing.T) {
  config := config.NewConfig() // TODO
  mockClient := &MockClient{}
  uiBuilder := ui.NewBuilder(config) // TODO
  responseRec := httptest.NewRecorder()
  router := http.NewServeMux()
  router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))
  payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode()
  request, err := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))
  request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  router.ServeHTTP(responseRec, request)
  assert.Nil(t, err)
  assert.Equal(t, http.StatusOK, responseRec.Code)
  assert.Empty(t, responseRec.Body.String())
  assert.Equal(t, true, mockClient.Called)
}
func TestPlayPokerHandlerEmptyBodyError(t *testing.T) {
  config := config.NewConfig()
  mockClient := &MockClient{}
  uiBuilder := ui.NewBuilder(config)
  responseRec := httptest.NewRecorder()
  router := http.NewServeMux()
  router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))
  payload := url.Values{}.Encode()
  request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))
  request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  router.ServeHTTP(responseRec, request)
  expected := string(models.ResponseError(errors.New("Please write correct subject")))
  assert.Equal(t, http.StatusOK, responseRec.Code)
  assert.Equal(t, expected, responseRec.Body.String())
  assert.Equal(t, false, mockClient.Called)
}
func TestPlayPokerHandlerRequestError(t *testing.T) {
  errMsg := "Error msg"
  config := config.NewConfig() // TODO
  mockClient := &MockClient{Error: errMsg}
  uiBuilder := ui.NewBuilder(config) // TODO
  responseRec := httptest.NewRecorder()
  router := http.NewServeMux()
  router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))
  payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode()
  request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))
  request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  router.ServeHTTP(responseRec, request)
  expected := string(models.ResponseError(errors.New(errMsg)))
  assert.Equal(t, http.StatusOK, responseRec.Code)
  assert.Equal(t, expected, responseRec.Body.String())
  assert.Equal(t, true, mockClient.Called)
}
Теперь нам нужно написать mock для нашего http клиента:common_test.go
package handlers_test
import (
  "errors"
  "go-scrum-poker-bot/web/clients"
)
type MockClient struct {
  Called bool
  Error  string
}
func (c *MockClient) Make(request *clients.Request) *clients.Response {
  c.Called = true
  var err error = nil
  if c.Error != "" {
    err = errors.New(c.Error)
  }
  return &clients.Response{Error: err}
}
Как видите, код хэндлера PlayPoker аккуратный и его просто покрывать тестами и не страшно в случае чего изменять.Теперь можно приступить к написанию UI строителя интерфейсов для Slack UI Block Kit. Там все довольно просто, но много однотипного кода. Отмечу лишь, что Slack API мне не очень понравился и было тяжело с ним работать. Сам UI Builder можно глянуть в папке ui здесь. А здесь, в целях экономии времени, я не буду на нем заострять внимания. Отмечу лишь, что в качестве якоря для понимания того, событие от какого сообщения пришло и какой был текст для голосования (его мы не будем сохранять у себя, а будем брать непосредственно из события) будем использовать block_id. А для определения типа события будем смотреть на action_id.Давайте создадим конфиг для нашего приложения. Идем в config и создаем:config.go
package config
type Config struct {
  App   *App
  Slack *Slack
  Redis *Redis
}
func NewConfig() *Config {
  return &Config{
    App: &App{
      ServerAddress: getStrEnv("WEB_SERVER_ADDRESS", ":8000"),
      PokerRanks:    getListStrEnv("POKER_RANKS", "?,0,0.5,1,2,3,5,8,13,20,40,100"),
    },
    Slack: &Slack{
      Token: getStrEnv("SLACK_TOKEN", "FILL_ME"),
    },
    // Скоро понадобится
    Redis: &Redis{
      Host: getStrEnv("REDIS_HOST", "0.0.0.0"),
      Port: getIntEnv("REDIS_PORT", "6379"),
      DB:   getIntEnv("REDIS_DB", "0"),
    },
  }
}
// Получаем значение из env или выставляем default
func getStrEnv(key string, defaultValue string) string {
  if value, ok := os.LookupEnv(key); ok {
    return value
  }
  return defaultValue
}
// Получаем int значение из env или выставляем default
func getIntEnv(key string, defaultValue string) int {
  value, err := strconv.Atoi(getStrEnv(key, defaultValue))
  if err != nil {
    panic(fmt.Sprintf("Incorrect env value for %s", key))
  }
  return value
}
// Получаем список (e.g. 0,1,2,3,4,5) из env или выставляем default
func getListStrEnv(key string, defaultValue string) []string {
  value := []string{}
  for _, item := range strings.Split(getStrEnv(key, defaultValue), ",") {
    value = append(value, strings.TrimSpace(item))
  }
  return value
}
И напишем тесты к нему. Будем тестировать только публичные методы:config_test.go
package config_test
import (
    "go-scrum-poker-bot/config"
    "os"
    "testing"
    "github.com/stretchr/testify/assert"
)
func TestNewConfig(t *testing.T) {
    c := config.NewConfig()
    assert.Equal(t, "0.0.0.0", c.Redis.Host)
    assert.Equal(t, 6379, c.Redis.Port)
    assert.Equal(t, 0, c.Redis.DB)
    assert.Equal(t, []string{"?", "0", "0.5", "1", "2", "3", "5", "8", "13", "20", "40", "100"}, c.App.PokerRanks)
}
func TestNewConfigIncorrectIntFromEnv(t *testing.T) {
    os.Setenv("REDIS_PORT", "-")
    assert.Panics(t, func() { config.NewConfig() })
}
Я намеренно сделал обязательность выставления значений по умолчанию, хотя это не самый правильный путь. Изменим main.go:main.go
package main
import (
  "fmt"
  "go-scrum-poker-bot/config"
  "go-scrum-poker-bot/ui"
  "go-scrum-poker-bot/web/clients"
  clients_middleware "go-scrum-poker-bot/web/clients/middleware"
  "go-scrum-poker-bot/web/server"
  "log"
  "net/http"
  "os"
  "time"
)
func main() {
  logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
  config := config.NewConfig()
  builder := ui.NewBuilder(config)
  webClient := clients.NewBasicClient(
    &http.Client{
      Timeout: 5 * time.Second,
    },
    []clients.Middleware{ // Наши middleware
      clients_middleware.Auth(config.Slack.Token),
      clients_middleware.JsonContentType,
      clients_middleware.Log(logger),
    },
  )
  app := server.NewServer(
    logger,
    webClient,
    builder,
  )
  app.Serve(config.App.ServerAddress)
}
Теперь при запуске команды /poker мы в ответ получим наш симпатичный минималистичный интерфейс. Slack InteractivityДавайте научимся реагировать на события при взаимодействии пользователя с ним. Зайдем Your apps -> Наш бот -> Interactivity & Shortcuts. В Request URL введем:
https://ffd3cfcc460c.ngrok.io/interactivity
Создадим еще один хэндлер InteractionCallback в web -> server -> handlers:interaction_callback.go
package handlers
import (
  "go-scrum-poker-bot/storage"
  "go-scrum-poker-bot/ui"
  "go-scrum-poker-bot/ui/blocks"
  "go-scrum-poker-bot/web/clients"
  "go-scrum-poker-bot/web/server/models"
  "net/http"
)
func InteractionCallback(
  userStorage storage.UserStorage,
  sessionStorage storage.SessionStorage,
  uiBuilder *ui.Builder,
  webClient clients.Client,
) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    var callback models.Callback
    // Об этом ниже
    data, err := callback.SerializedData([]byte(r.PostFormValue("payload")))
    if err != nil {
      http.Error(w, err.Error(), http.StatusBadRequest)
      return
    }
    // TODO: Скоро доберемся до них
    users := userStorage.All(data.SessionID)
    visible := sessionStorage.GetVisibility(data.SessionID)
    err = nil
    // Определяем какое событие к нам поступило и реализуем немного логики исходя из него
    switch data.Action.ActionID {
    case ui.VOTE_ACTION_ID:
      users[callback.User.Username] = data.Action.SelectedOption.Value
      err = userStorage.Save(data.SessionID, callback.User.Username, data.Action.SelectedOption.Value)
    case ui.RESULTS_VISIBILITY_ACTION_ID:
      visible = !visible
      err = sessionStorage.SetVisibility(data.SessionID, visible)
    }
    if err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
    }
    // Шлем ответ перерисовывая интерфейс сообщения через response URL. Для пользователя все пройдет незаметно
    resp := webClient.Make(&clients.Request{
      URL:    callback.ResponseURL,
      Method: "POST",
      Json: &blocks.Interactive{
        ReplaceOriginal: true,
        Blocks:          uiBuilder.BuildBlocks(data.Subject, users, data.SessionID, visible),
        LinkNames:       true,
      },
    })
    if resp.Error != nil {
      http.Error(w, resp.Error.Error(), http.StatusInternalServerError)
      return
    }
  })
}
Мы пока не определили наше хранилище. Давайте определим их интерфейсы и напишем тест на этот хэндлер. Идем в storage:storage.go
package storage
type UserStorage interface {
  All(sessionID string) map[string]string
  Save(sessionID string, username string, value string) error
}
type SessionStorage interface {
  GetVisibility(sessionID string) bool
  SetVisibility(sessionID string, state bool) error
}
Я намеренно разбил логику на два хранилища, поскольку так удобнее тестировать и если будет нужно, то легко можно будет перевести например хранение голосов пользователей в базу данных, а настройки сессии оставить в Redis (как пример).Теперь нужно создать модель Callback. Идем в web -> server -> models:callback.go
package models
import (
  "encoding/json"
  "errors"
  "go-scrum-poker-bot/ui"
)
type User struct {
  Username string `json:"username"`
}
type Text struct {
  Type string `json:"type"`
  Text string `json:"text"`
}
type Block struct {
  Type    string `json:"type"`
  BlockID string `json:"block_id"`
  Text    *Text  `json:"text,omitempty"`
}
type Message struct {
  Blocks []*Block `json:"blocks,omitempty"`
}
type SelectedOption struct {
  Value string `json:"value"`
}
type Action struct {
  BlockID        string          `json:"block_id"`
  ActionID       string          `json:"action_id"`
  Value          string          `json:"value,omitempty"`
  SelectedOption *SelectedOption `json:"selected_option,omitempty"`
}
type SerializedData struct {
  SessionID string
  Subject   string
  Action    *Action
}
type Callback struct {
  ResponseURL string    `json:"response_url"`
  User        *User     `json:"user"`
  Actions     []*Action `json:"actions"`
  Message     *Message  `json:"message,omitempty"`
}
// Грязно достаем ID сессии, но другого способа я не смог придумать
func (c *Callback) getSessionID() (string, error) {
  for _, action := range c.Actions {
    if action.BlockID != "" {
      return action.BlockID, nil
    }
  }
  return "", errors.New("Invalid session ID")
}
// Текст для голосования
func (c *Callback) getSubject() (string, error) {
  for _, block := range c.Message.Blocks {
    if block.BlockID == ui.SUBJECT_BLOCK_ID && block.Text != nil {
      return block.Text.Text, nil
    }
  }
  return "", errors.New("Invalid subject")
}
// Какое событие к нам пришло
func (c *Callback) getAction() (*Action, error) {
  for _, action := range c.Actions {
    if action.ActionID == ui.VOTE_ACTION_ID || action.ActionID == ui.RESULTS_VISIBILITY_ACTION_ID {
      return action, nil
    }
  }
  return nil, errors.New("Invalid action")
}
func (c *Callback) SerializedData(data []byte) (*SerializedData, error) {
  err := json.Unmarshal(data, c)
  if err != nil {
    return nil, err
  }
  sessionID, err := c.getSessionID()
  if err != nil {
    return nil, err
  }
  subject, err := c.getSubject()
  if err != nil {
    return nil, err
  }
  action, err := c.getAction()
  if err != nil {
    return nil, err
  }
  return &SerializedData{
    SessionID: sessionID,
    Subject:   subject,
    Action:    action,
  }, nil
}
Давайте напишем тест на наш хэндлер:interaction_callback_test.go
package handlers_test
import (
  "encoding/json"
  "go-scrum-poker-bot/config"
  "go-scrum-poker-bot/ui"
  "go-scrum-poker-bot/web/server/handlers"
  "go-scrum-poker-bot/web/server/models"
  "net/http"
  "net/http/httptest"
  "net/url"
  "strings"
  "testing"
  "github.com/stretchr/testify/assert"
)
func TestInteractionCallbackHandlerActions(t *testing.T) {
  config := config.NewConfig()
  mockClient := &MockClient{}
  mockUserStorage := &MockUserStorage{}
  mockSessionStorage := &MockSessionStorage{}
  uiBuilder := ui.NewBuilder(config)
  router := http.NewServeMux()
  router.Handle(
    "/interactivity",
    handlers.InteractionCallback(mockUserStorage, mockSessionStorage, uiBuilder, mockClient),
  )
  actions := []*models.Action{
    {
      BlockID:        "test",
      ActionID:       ui.RESULTS_VISIBILITY_ACTION_ID,
      Value:          "test",
      SelectedOption: nil,
    },
    {
      BlockID:        "test",
      ActionID:       ui.VOTE_ACTION_ID,
      Value:          "test",
      SelectedOption: &models.SelectedOption{Value: "1"},
    },
  }
  // Проверяем на двух разных типах событий
  for _, action := range actions {
    responseRec := httptest.NewRecorder()
    data, _ := json.Marshal(models.Callback{
      ResponseURL: "test",
      User:        &models.User{Username: "test"},
      Actions:     []*models.Action{action},
      Message: &models.Message{
        Blocks: []*models.Block{
          {
            Type:    "test",
            BlockID: ui.SUBJECT_BLOCK_ID,
            Text:    &models.Text{Type: "test", Text: "test"},
          },
        },
      },
    })
    payload := url.Values{"payload": {string(data)}}.Encode()
    request, err := http.NewRequest("POST", "/interactivity", strings.NewReader(payload))
    request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    router.ServeHTTP(responseRec, request)
    assert.Nil(t, err)
    assert.Equal(t, http.StatusOK, responseRec.Code)
    assert.Empty(t, responseRec.Body.String())
    assert.Equal(t, true, mockClient.Called)
  }
}
Осталось определить mock для наших хранилищ. Обновим файл common_test.go:common_test.go
// Существующий код
type MockUserStorage struct{}
func (s *MockUserStorage) All(sessionID string) map[string]string {
  return map[string]string{"user": "1"}
}
func (s *MockUserStorage) Save(sessionID string, username string, value string) error {
  return nil
}
type MockSessionStorage struct{}
func (s *MockSessionStorage) GetVisibility(sessionID string) bool {
  return true
}
func (s *MockSessionStorage) SetVisibility(sessionID string, state bool) error {
  return nil
}
Добавив в роутер новый хэндлер:server.go
// Существующий код
func (s *Server) setupRouter() http.Handler {
  router := http.NewServeMux()
  router.Handle(
    "/healthcheck",
    handlers.Healthcheck(),
  )
  router.Handle(
    "/play-poker",
    handlers.PlayPokerCommand(s.webClient, s.uiBuilder),
  )
  router.Handle(
    "/interactivity",
    handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient),
  )
  return router
}
// Существующий код
Все хорошо, но наш сервер никак не уведомляет нас о том, что к нему поступил запрос + если мы где-то поймаем панику, то сервер может упасть. Давайте это исправим через middleware. Создаем папку web -> server -> middleware:log.go
package middleware
import (
  "log"
  "net/http"
)
func Log(logger *log.Logger) func(http.Handler) http.Handler {
  return func(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      defer func() {
        logger.Printf(
          "Handle request: [%s]: %s - %s - %s",
          r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent(),
        )
      }()
      next.ServeHTTP(w, r)
    })
  }
}
И напишем для нее тест:log_test.go
package middleware_test
import (
  "bytes"
  "go-scrum-poker-bot/web/server/middleware"
  "log"
  "net/http"
  "net/http/httptest"
  "os"
  "strings"
  "testing"
  "github.com/stretchr/testify/assert"
)
type logHandler struct{}
func (h *logHandler) ServeHTTP(http.ResponseWriter, *http.Request) {}
func TestLogMiddleware(t *testing.T) {
  var buf bytes.Buffer
  logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
  // Выставляем для логгера output наш буффер, чтобы все писалось в него
  logger.SetOutput(&buf)
  handler := &logHandler{}
  // Берем mock recorder из стандартной библиотеки Go
  responseRec := httptest.NewRecorder()
  router := http.NewServeMux()
  router.Handle("/test", middleware.Log(logger)(handler))
  request, err := http.NewRequest("GET", "/test", strings.NewReader(""))
  router.ServeHTTP(responseRec, request)
  assert.Nil(t, err)
  assert.Equal(t, http.StatusOK, responseRec.Code)
  // Проверяем, что в буффер что-то пришло. Этого нам достаточно, чтобы понять, что middleware успешно отработала
  assert.NotEmpty(t, buf.String())
}
Остальные middleware можете найти здесь.Ну и наконец слой хранения данных. Я решил взять Redis, так как это проще, да и не нужно для такого рода задач что-то большее, как мне кажется. Воспользуемся библиотекой go-redis и там же возьмем redismock для тестов.Для начала научимся сохранять и получать всех пользователей переданной Scrum Poker сессии. Идем в storage:users.go
package storage
import (
  "context"
  "fmt"
  "github.com/go-redis/redis/v8"
)
// Шаблоны ключей
const SESSION_USERS_TPL = "SESSION:%s:USERS"
const USER_VOTE_TPL = "SESSION:%s:USERNAME:%s:VOTE"
type UserRedisStorage struct {
  redis   *redis.Client
  context context.Context
}
func NewUserRedisStorage(redisClient *redis.Client) *UserRedisStorage {
  return &UserRedisStorage{
    redis:   redisClient,
    context: context.Background(),
  }
}
func (s *UserRedisStorage) All(sessionID string) map[string]string {
  users := make(map[string]string)
  // Пользователей будем хранить в set, так как сортировка для нас не принципиальна.
  // Заодно избавимся от необходимости искать дубликаты
  for _, username := range s.redis.SMembers(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID)).Val() {
    users[username] = s.redis.Get(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username)).Val()
  }
  return users
}
func (s *UserRedisStorage) Save(sessionID string, username string, value string) error {
  err := s.redis.SAdd(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID), username).Err()
  if err != nil {
    return err
  }
  // Голоса пользователей будем хранить в обычных ключах.
  // Я сделал вечное хранение, но это легко можно поменять, изменив -1 на нужное значение
  err = s.redis.Set(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username), value, -1).Err()
  if err != nil {
    return err
  }
  return nil
}
Напишем тесты:users_test.go
package storage_test
import (
  "errors"
  "fmt"
  "go-scrum-poker-bot/storage"
  "testing"
  "github.com/go-redis/redismock/v8"
  "github.com/stretchr/testify/assert"
)
func TestAll(t *testing.T) {
  sessionID, username, value := "test", "user", "1"
  redisClient, mock := redismock.NewClientMock()
  usersStorage := storage.NewUserRedisStorage(redisClient)
  // Redis mock требует обязательного указания всех ожидаемых команд и результаты их выполнения
  mock.ExpectSMembers(
    fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
  ).SetVal([]string{username})
  mock.ExpectGet(
    fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),
  ).SetVal(value)
  assert.Equal(t, map[string]string{username: value}, usersStorage.All(sessionID))
}
func TestSave(t *testing.T) {
  sessionID, username, value := "test", "user", "1"
  redisClient, mock := redismock.NewClientMock()
  usersStorage := storage.NewUserRedisStorage(redisClient)
  mock.ExpectSAdd(
    fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
    username,
  ).SetVal(1)
  mock.ExpectSet(
    fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),
    value,
    -1,
  ).SetVal(value)
  assert.Equal(t, nil, usersStorage.Save(sessionID, username, value))
}
func TestSaveSAddErr(t *testing.T) {
  sessionID, username, value, err := "test", "user", "1", errors.New("ERROR")
  redisClient, mock := redismock.NewClientMock()
  usersStorage := storage.NewUserRedisStorage(redisClient)
  mock.ExpectSAdd(
    fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
    username,
  ).SetErr(err)
  assert.Equal(t, err, usersStorage.Save(sessionID, username, value))
}
func TestSaveSetErr(t *testing.T) {
  sessionID, username, value, err := "test", "user", "1", errors.New("ERROR")
  redisClient, mock := redismock.NewClientMock()
  usersStorage := storage.NewUserRedisStorage(redisClient)
  mock.ExpectSAdd(
    fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
    username,
  ).SetVal(1)
  mock.ExpectSet(
    fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),
    value,
    -1,
  ).SetErr(err)
  assert.Equal(t, err, usersStorage.Save(sessionID, username, value))
}
Теперь определим хранилище для "покерной" сессии. Пока там будет лежать статус видимости голосов:sessions.go
package storage
import (
  "context"
  "fmt"
  "strconv"
  "github.com/go-redis/redis/v8"
)
// Шаблон для ключей
const SESSION_VOTES_HIDDEN_TPL = "SESSION:%s:VOTES_HIDDEN"
type SessionRedisStorage struct {
  redis   *redis.Client
  context context.Context
}
func NewSessionRedisStorage(redisClient *redis.Client) *SessionRedisStorage {
  return &SessionRedisStorage{
    redis:   redisClient,
    context: context.Background(),
  }
}
func (s *SessionRedisStorage) GetVisibility(sessionID string) bool {
  value, _ := strconv.ParseBool(
    s.redis.Get(s.context, fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID)).Val(),
  )
  return value
}
func (s *SessionRedisStorage) SetVisibility(sessionID string, state bool) error {
  return s.redis.Set(
    s.context,
    fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID),
    strconv.FormatBool(state),
    -1,
  ).Err()
}
И сразу напишем тесты для только что созданных методов:sessions_test.go
package storage_test
import (
  "errors"
  "fmt"
  "go-scrum-poker-bot/storage"
  "strconv"
  "testing"
  "github.com/go-redis/redismock/v8"
  "github.com/stretchr/testify/assert"
)
func TestGetVisibility(t *testing.T) {
  sessionID, state := "test", true
  redisClient, mock := redismock.NewClientMock()
  mock.ExpectGet(
    fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),
  ).SetVal(strconv.FormatBool(state))
  sessionStorage := storage.NewSessionRedisStorage(redisClient)
  assert.Equal(t, state, sessionStorage.GetVisibility(sessionID))
}
func TestSetVisibility(t *testing.T) {
  sessionID, state := "test", true
  redisClient, mock := redismock.NewClientMock()
  mock.ExpectSet(
    fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),
    strconv.FormatBool(state),
    -1,
  ).SetVal("1")
  sessionStorage := storage.NewSessionRedisStorage(redisClient)
  assert.Equal(t, nil, sessionStorage.SetVisibility(sessionID, state))
}
func TestSetVisibilityErr(t *testing.T) {
  sessionID, state, err := "test", true, errors.New("ERROR")
  redisClient, mock := redismock.NewClientMock()
  mock.ExpectSet(
    fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),
    strconv.FormatBool(state),
    -1,
  ).SetErr(err)
  sessionStorage := storage.NewSessionRedisStorage(redisClient)
  assert.Equal(t, err, sessionStorage.SetVisibility(sessionID, state))
}
Отлично! Осталось изменить main.go и server.go:server.go
package server
import (
  "context"
  "go-scrum-poker-bot/storage"
  "go-scrum-poker-bot/ui"
  "go-scrum-poker-bot/web/clients"
  "go-scrum-poker-bot/web/server/handlers"
  "log"
  "net/http"
  "os"
  "os/signal"
  "sync/atomic"
  "time"
)
// Новый тип для middleware
type Middleware func(next http.Handler) http.Handler
// Все зависимости здесь
type Server struct {
  healthy        int32
  middleware     []Middleware
  logger         *log.Logger
  webClient      clients.Client
  uiBuilder      *ui.Builder
  userStorage    storage.UserStorage
  sessionStorage storage.SessionStorage
}
// Добавляем их при инициализации сервера
func NewServer(
  logger *log.Logger,
  webClient clients.Client,
  uiBuilder *ui.Builder,
  userStorage storage.UserStorage,
  sessionStorage storage.SessionStorage,
  middleware []Middleware,
) *Server {
  return &Server{
    logger:         logger,
    webClient:      webClient,
    uiBuilder:      uiBuilder,
    userStorage:    userStorage,
    sessionStorage: sessionStorage,
    middleware:     middleware,
  }
}
func (s *Server) setupRouter() http.Handler {
  router := http.NewServeMux()
  router.Handle(
    "/healthcheck",
    handlers.Healthcheck(),
  )
  router.Handle(
    "/play-poker",
    handlers.PlayPokerCommand(s.webClient, s.uiBuilder),
  )
  router.Handle(
    "/interactivity",
    handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient),
  )
  return router
}
func (s *Server) setupMiddleware(router http.Handler) http.Handler {
  handler := router
  for _, middleware := range s.middleware {
    handler = middleware(handler)
  }
  return handler
}
func (s *Server) Serve(address string) {
  server := &http.Server{
    Addr:         address,
    Handler:      s.setupMiddleware(s.setupRouter()),
    ErrorLog:     s.logger,
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
    IdleTimeout:  15 * time.Second,
  }
  done := make(chan bool)
  quit := make(chan os.Signal, 1)
  signal.Notify(quit, os.Interrupt)
  go func() {
    <-quit
    s.logger.Println("Server is shutting down...")
    atomic.StoreInt32(&s.healthy, 0)
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    server.SetKeepAlivesEnabled(false)
    if err := server.Shutdown(ctx); err != nil {
      s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)
    }
    close(done)
  }()
  s.logger.Println("Server is ready to handle requests at", address)
  atomic.StoreInt32(&s.healthy, 1)
  if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
    s.logger.Fatalf("Could not listen on %s: %v\n", address, err)
  }
  <-done
  s.logger.Println("Server stopped")
}
main.go
package main
import (
  "fmt"
  "go-scrum-poker-bot/config"
  "go-scrum-poker-bot/storage"
  "go-scrum-poker-bot/ui"
  "go-scrum-poker-bot/web/clients"
  clients_middleware "go-scrum-poker-bot/web/clients/middleware"
  "go-scrum-poker-bot/web/server"
  server_middleware "go-scrum-poker-bot/web/server/middleware"
  "log"
  "net/http"
  "os"
  "time"
  "github.com/go-redis/redis/v8"
)
func main() {
  logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
  config := config.NewConfig()
  // Объявляем Redis клиент
  redisCLI := redis.NewClient(&redis.Options{
    Addr: fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port),
    DB:   config.Redis.DB,
  })
  // Наш users storage
  userStorage := storage.NewUserRedisStorage(redisCLI)
  // Наш sessions storage
  sessionStorage := storage.NewSessionRedisStorage(redisCLI)
  builder := ui.NewBuilder(config)
  webClient := clients.NewBasicClient(
    &http.Client{
      Timeout: 5 * time.Second,
    },
    []clients.Middleware{
      clients_middleware.Auth(config.Slack.Token),
      clients_middleware.JsonContentType,
      clients_middleware.Log(logger),
    },
  )
  // В Server теперь есть middleware
  app := server.NewServer(
    logger,
    webClient,
    builder,
    userStorage,
    sessionStorage,
    []server.Middleware{server_middleware.Recover(logger), server_middleware.Log(logger), server_middleware.Json},
  )
  app.Serve(config.App.ServerAddress)
}
Запустим тесты:
go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic
Результат:go tool cover -func coverage.txt
$ go tool cover -func coverage.txt
go-scrum-poker-bot/config/config.go:9:                                  NewConfig               100.0%
go-scrum-poker-bot/config/helpers.go:10:                                getStrEnv               100.0%
go-scrum-poker-bot/config/helpers.go:17:                                getIntEnv               100.0%
go-scrum-poker-bot/config/helpers.go:26:                                getListStrEnv           100.0%
go-scrum-poker-bot/main.go:22:                                          main                    0.0%
go-scrum-poker-bot/storage/sessions.go:18:                              NewSessionRedisStorage  100.0%
go-scrum-poker-bot/storage/sessions.go:25:                              GetVisibility           100.0%
go-scrum-poker-bot/storage/sessions.go:33:                              SetVisibility           100.0%
go-scrum-poker-bot/storage/users.go:18:                                 NewUserRedisStorage     100.0%
go-scrum-poker-bot/storage/users.go:25:                                 All                     100.0%
go-scrum-poker-bot/storage/users.go:34:                                 Save                    100.0%
go-scrum-poker-bot/ui/blocks/action.go:9:                               BlockType               100.0%
go-scrum-poker-bot/ui/blocks/button.go:11:                              BlockType               100.0%
go-scrum-poker-bot/ui/blocks/context.go:9:                              BlockType               100.0%
go-scrum-poker-bot/ui/blocks/section.go:9:                              BlockType               100.0%
go-scrum-poker-bot/ui/blocks/select.go:10:                              BlockType               100.0%
go-scrum-poker-bot/ui/builder.go:14:                                    NewBuilder              100.0%
go-scrum-poker-bot/ui/builder.go:18:                                    getGetResultsText       100.0%
go-scrum-poker-bot/ui/builder.go:26:                                    getResults              100.0%
go-scrum-poker-bot/ui/builder.go:41:                                    getOptions              100.0%
go-scrum-poker-bot/ui/builder.go:50:                                    BuildBlocks             100.0%
go-scrum-poker-bot/ui/builder.go:100:                                   Build                   100.0%
go-scrum-poker-bot/web/clients/client.go:22:                            NewBasicClient          100.0%
go-scrum-poker-bot/web/clients/client.go:26:                            makeRequest             78.9%
go-scrum-poker-bot/web/clients/client.go:65:                            Make                    66.7%
go-scrum-poker-bot/web/clients/middleware/auth.go:8:                    Auth                    100.0%
go-scrum-poker-bot/web/clients/middleware/json.go:5:                    JsonContentType         100.0%
go-scrum-poker-bot/web/clients/middleware/log.go:8:                     Log                     87.5%
go-scrum-poker-bot/web/clients/request.go:12:                           ToBytes                 100.0%
go-scrum-poker-bot/web/clients/response.go:12:                          Json                    100.0%
go-scrum-poker-bot/web/server/handlers/healthcheck.go:10:               Healthcheck             66.7%
go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12:      InteractionCallback     71.4%
go-scrum-poker-bot/web/server/handlers/play_poker.go:13:                PlayPokerCommand        100.0%
go-scrum-poker-bot/web/server/middleware/json.go:5:                     Json                    100.0%
go-scrum-poker-bot/web/server/middleware/log.go:8:                      Log                     100.0%
go-scrum-poker-bot/web/server/middleware/recover.go:9:                  Recover                 100.0%
go-scrum-poker-bot/web/server/models/callback.go:52:                    getSessionID            100.0%
go-scrum-poker-bot/web/server/models/callback.go:62:                    getSubject              100.0%
go-scrum-poker-bot/web/server/models/callback.go:72:                    getAction               100.0%
go-scrum-poker-bot/web/server/models/callback.go:82:                    SerializedData          92.3%
go-scrum-poker-bot/web/server/models/errors.go:13:                      ResponseError           75.0%
go-scrum-poker-bot/web/server/server.go:31:                             NewServer               0.0%
go-scrum-poker-bot/web/server/server.go:49:                             setupRouter             0.0%
go-scrum-poker-bot/web/server/server.go:67:                             setupMiddleware         0.0%
go-scrum-poker-bot/web/server/server.go:76:                             Serve                   0.0%
total:                                                                  (statements)            75.1%
Неплохо, но нам не нужно учитывать в coverage main.go (мое мнение) и server.go (здесь можно поспорить), поэтому есть хак :). Нужно добавить в начало файлов, которые мы хотим исключить из оценки следующую строчку с тегами:
//+build !test
Перезапустим с тегом:
go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic -tags=test
Результат:go tool cover -func coverage.txt
$ go tool cover -func coverage.txt
go-scrum-poker-bot/config/config.go:9:                                  NewConfig               100.0%
go-scrum-poker-bot/config/helpers.go:10:                                getStrEnv               100.0%
go-scrum-poker-bot/config/helpers.go:17:                                getIntEnv               100.0%
go-scrum-poker-bot/config/helpers.go:26:                                getListStrEnv           100.0%
go-scrum-poker-bot/storage/sessions.go:18:                              NewSessionRedisStorage  100.0%
go-scrum-poker-bot/storage/sessions.go:25:                              GetVisibility           100.0%
go-scrum-poker-bot/storage/sessions.go:33:                              SetVisibility           100.0%
go-scrum-poker-bot/storage/users.go:18:                                 NewUserRedisStorage     100.0%
go-scrum-poker-bot/storage/users.go:25:                                 All                     100.0%
go-scrum-poker-bot/storage/users.go:34:                                 Save                    100.0%
go-scrum-poker-bot/ui/blocks/action.go:9:                               BlockType               100.0%
go-scrum-poker-bot/ui/blocks/button.go:11:                              BlockType               100.0%
go-scrum-poker-bot/ui/blocks/context.go:9:                              BlockType               100.0%
go-scrum-poker-bot/ui/blocks/section.go:9:                              BlockType               100.0%
go-scrum-poker-bot/ui/blocks/select.go:10:                              BlockType               100.0%
go-scrum-poker-bot/ui/builder.go:14:                                    NewBuilder              100.0%
go-scrum-poker-bot/ui/builder.go:18:                                    getGetResultsText       100.0%
go-scrum-poker-bot/ui/builder.go:26:                                    getResults              100.0%
go-scrum-poker-bot/ui/builder.go:41:                                    getOptions              100.0%
go-scrum-poker-bot/ui/builder.go:50:                                    BuildBlocks             100.0%
go-scrum-poker-bot/ui/builder.go:100:                                   Build                   100.0%
go-scrum-poker-bot/web/clients/client.go:22:                            NewBasicClient          100.0%
go-scrum-poker-bot/web/clients/client.go:26:                            makeRequest             78.9%
go-scrum-poker-bot/web/clients/client.go:65:                            Make                    66.7%
go-scrum-poker-bot/web/clients/middleware/auth.go:8:                    Auth                    100.0%
go-scrum-poker-bot/web/clients/middleware/json.go:5:                    JsonContentType         100.0%
go-scrum-poker-bot/web/clients/middleware/log.go:8:                     Log                     87.5%
go-scrum-poker-bot/web/clients/request.go:12:                           ToBytes                 100.0%
go-scrum-poker-bot/web/clients/response.go:12:                          Json                    100.0%
go-scrum-poker-bot/web/server/handlers/healthcheck.go:10:               Healthcheck             66.7%
go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12:      InteractionCallback     71.4%
go-scrum-poker-bot/web/server/handlers/play_poker.go:13:                PlayPokerCommand        100.0%
go-scrum-poker-bot/web/server/middleware/json.go:5:                     Json                    100.0%
go-scrum-poker-bot/web/server/middleware/log.go:8:                      Log                     100.0%
go-scrum-poker-bot/web/server/middleware/recover.go:9:                  Recover                 100.0%
go-scrum-poker-bot/web/server/models/callback.go:52:                    getSessionID            100.0%
go-scrum-poker-bot/web/server/models/callback.go:62:                    getSubject              100.0%
go-scrum-poker-bot/web/server/models/callback.go:72:                    getAction               100.0%
go-scrum-poker-bot/web/server/models/callback.go:82:                    SerializedData          92.3%
go-scrum-poker-bot/web/server/models/errors.go:13:                      ResponseError           75.0%
total:                                                                  (statements)            90.9%
Такой результат мне нравится больше :) На этом пожалуй остановлюсь. Весь код можете найти здесь. Спасибо за внимание!
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_messendzhery (Мессенджеры), #_programmirovanie (Программирование), #_go, #_agile, #_go, #_slack, #_bot, #_golang, #_tutorial, #_scrum, #_agile, #_messendzhery (
Мессенджеры
)
, #_programmirovanie (
Программирование
)
, #_go, #_agile
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 02:48
Часовой пояс: UTC + 5