Как стать автором
Обновить

Пишем Slack бота для Scrum покера на Go

Время на прочтение30 мин
Количество просмотров5.1K

Здравствуйте! Сегодня мы напишем 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%

Такой результат мне нравится больше :)

На этом пожалуй остановлюсь. Весь код можете найти здесь. Спасибо за внимание!

UPDATE: Думал, что наберется материала на две статьи, но не вышло, поэтому второй части не будет.

Теги:
Хабы:
Всего голосов 8: ↑6 и ↓2+8
Комментарии5

Публикации

Истории

Работа

Go разработчик
118 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань