Pull to refresh

Как я пишу HTTP-сервисы спустя 13 лет работы с Go

Level of difficultyMedium
Reading time18 min
Views26K
Original author: Mat Ryer

Примерно шесть лет назад я написал пост о том, как пишу HTTP-сервисы на Go, и сегодня я снова хочу рассказать, как пишу HTTP-сервисы.

Тот пост оказался довольно популярным и вызвал обсуждения, повлиявшие на то, как я делаю это сегодня. И спустя годы ведения подкаста Go Time, обсуждения Go в X/Twitter и поддержки подобного кода я решил, что настало время обновить информацию.

(Если вы педант и скажете, что Go не совсем 13 лет, то я отвечу, что начал писать HTTP-сервисы на Go версии .r59.)

В этом посте рассматривается широкий спектр тем, связанных с созданием сервисов на Go:

  • Структурирование серверов и обработчиков с расчётом на максимальное удобство поддержки;

  • Советы и рекомендации по оптимизации сервисов под быстрый запуск и правильное отключение; 

  • Обработка стандартных задач, применимых ко множеству типов запросов;

  • Глубокое исследование правильного тестирования сервисов.

Эти практики прошли у меня проверку временем как в маленьких, так и в крупных проектах; надеюсь, так будет и в вашем случае.

Для кого предназначен этот пост?

Для вас. Для всех, кто планирует писать какой-то HTTP-сервис на Go. Также он может оказаться полезным, если вы изучаете Go, так как многие из примеров соответствуют рекомендуемым практикам. Опытные гоферы тоже могут найти здесь полезные паттерны.

Чтобы этот пост был максимально полезным, нужно знать основы Go. Если вы считаете, что у ваc их пока нет, то рекомендую прочитать Learn Go with tests Криса Джеймса. А если вы хотите узнать и о других материалах Криса, то можете послушать эпизод Go Time о файлах и папках проектов на Go , созданный мной совместно с Беном Джонсоном.

Конструктор NewServer

Давайте начнём с изучения фундамента любого сервиса на Go: сервера. Функция NewServer создаёт основной http.Handler. Обычно я использую по одному такому обработчику на сервис и полагаюсь на HTTP-роуты в том, чтобы они перенаправляли трафик к нужным обработчикам каждого сервиса, потому что:

  • NewServer — это большой конструктор, получающий в качестве аргументов все зависимости.

  • Если это возможно, он возвращает http.Handler, который может быть специализированным типом для особо сложных ситуаций.

  • Обычно он сам конфигурирует собственный мультиплексор (muxer) и вызовы к routes.go

Например, ваш код может выглядеть примерно так:

func NewServer(
	logger *Logger
	config *Config
	commentStore *commentStore
	anotherStore *anotherStore
) http.Handler {
	mux := http.NewServeMux()
	addRoutes(
		mux,
		Logger,
		Config,
		commentStore,
		anotherStore,
	)
	var handler http.Handler = mux
	handler = someMiddleware(handler)
	handler = someMiddleware2(handler)
	handler = someMiddleware3(handler)
	return handler
}

В тестовых случаях, не требующих всех зависимостей, я передаю nil.

Конструктор NewServer отвечает за всю высокоуровневую работу с HTTP, относящуюся ко всем конечным точкам, например, к CORS, вспомогательному коду аутентификации и логгингу:

var handler http.Handler = mux
handler = logging.NewLoggingMiddleware(logger, handler)
handler = logging.NewGoogleTraceIDMiddleware(logger, handler)
handler = checkAuthHeaders(handler)
return handler

Для настройки сервера обычно нужно раскрыть его при помощи встроенного пакета Go:

srv := NewServer(
	logger,
	config,
	tenantsStore,
	slackLinkStore,
	msteamsLinkStore,
	proxy,
)
httpServer := &http.Server{
	Addr:    net.JoinHostPort(config.Host, config.Port),
	Handler: srv,
}
go func() {
	log.Printf("listening on %s\n", httpServer.Addr)
	if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		fmt.Fprintf(os.Stderr, "error listening and serving: %s\n", err)
	}
}()
var wg sync.WaitGroup
wg.Add(1)
go func() {
	defer wg.Done()
	<-ctx.Done()
	if err := httpServer.Shutdown(ctx); err != nil {
		fmt.Fprintf(os.Stderr, "error shutting down http server: %s\n", err)
	}
}()
wg.Wait()
return nil

Длинные списки аргументов

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

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

Всё не так уж плохо, если отформатировать список, как вертикальный, который мне встречался в современном фронтенд‑коде:

srv := NewServer(
	logger,
	config,
	tenantsStore,
	commentsStore,
	conversationService,
	chatGPTService,
)

Отображаем всю поверхность API в routes.go

Этот файл — единственное место в вашем сервисе, где перечислены все роуты.

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

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

func addRoutes(
	mux                 *http.ServeMux,
	logger              *logging.Logger,
	config              Config,
	tenantsStore        *TenantsStore,
	commentsStore       *CommentsStore,
	conversationService *ConversationService,
	chatGPTService      *ChatGPTService,
	authProxy           *authProxy
) {
	mux.Handle("/api/v1/", handleTenantsGet(logger, tenantsStore))
	mux.Handle("/oauth2/", handleOAuth2Proxy(logger, authProxy))
	mux.HandleFunc("/healthz", handleHealthzPlease(logger))
	mux.Handle("/", http.NotFoundHandler())
}

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

func main() вызывает только run()

Функция run похожа на функцию main, но получает в качестве аргументов параметры операционной системы, а возвращает, как можно догадаться, ошибку.

Мне бы хотелось, чтобы func main() была func main() error. Или, как в C, где можно вернуть код выхода: func main() int. Но даже имея сверхпростую основную функцию, можно осуществить свои желания:

func run(ctx context.Context, w io.Writer, args []string) error {
	// ...
}

func main() {
	ctx := context.Background()
	ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
	defer cancel()
	if err := run(ctx, os.Stdout, os.Args); err != nil {
		fmt.Fprintf(os.Stderr, "%s\n", err)
		os.Exit(1)
	}
}

Показанный выше код создаёт контекст, отменяемый Ctrl+C или его аналогом, а также вызывает функцию run. Если run возвращает nil, то функция выполняет выход обычным образом. Если она возвращает ошибку, то мы записываем её в stderr и выполняем выход с ненулевым кодом. Если я пишу инструмент командной строки, для которого важны коды выхода, то должен возвращать и int, чтобы можно было писать тесты для проверки правильности возвращаемого кода.

Параметры операционной системы передаются run как аргументы. Например, можно передать os.Args, если она имеет поддержку флагов, и даже зависимости os.Stdinos.Stdoutos.Stderr. Это сильно упрощает тестирование программ, потому что тестовый код может вызывать run для исполнения вашей программы, управления аргументами и всеми потоками простой передачей разных аргументов.

В таблице ниже показаны примеры входных аргументов для функции run:

Значение

Тип

Описание

os.Args

[]string

Аргументы, передаваемые при исполнении вашей программы. Также используется для флагов парсинга.

os.Stdin

io.Reader

Для считывания ввода

os.Stdout

io.Writer

Для записи вывода

os.Stderr

io.Writer

Для записи логов ошибок

os.Getenv

func(string) string

Для чтения переменных окружения

os.Getwd

func() (string, error)

Получение рабочей папки

Если нужно не использовать глобальные данные, то обычно во многих местах достаточно применять t.Parallel() для ускорения тестовых наборов. Всё выполняется автономно, так что множественные вызовы run не влияют друг на друга.

Я часто пишу сигнатуры функции run

func run(
	ctx    context.Context,
	args   []string,
	getenv func(string) string,
	stdin  io.Reader,
	stdout, stderr io.Writer,
) error

Теперь, когда мы внутри функции run, можно вернуться к написанию обычного кода на Go, где допускается возвращать ошибки, как будто это никого не волнует. Мы, гоферы, любим возвращать ошибки, и чем скорее признаемся себе в этом, тем раньше люди в Интернете решат, что выиграли, и оставят нас в покое.

Правильное отключение

Если вы выполняете много тестов, то вашей программе важно останавливаться после завершения каждого из них. (Или же можно заставить выполнять все тесты один экземпляр, это решать вам.)

Контекст передаётся сквозным образом. Он сбрасывается, если в программу поступает сигнал завершения, поэтому важно отслеживать его на каждом уровне. По крайней мере, следует передавать его зависимостям. В лучшем случае, проверяйте метод Err() в любом долго выполняемом или имеющем большое количество циклов коде, и если он возвращает ошибку, то прекращайте выполняемую работу и делайте возврат. Это поможет серверу выполнять отключение без ошибок. Если вы запускаете другие горутины, то контекст можно использовать и для того, чтобы решить, останавливать их или нет.

Управление окружением

Параметры args и getenv позволяет нам при помощи флагов и переменных окружения управлять поведением программы. Флаги обрабатываются через args (если только вы не используете версии флагов глобального пространства и не применяете flags.NewFlagSet внутри run), так что можно вызывать run с разными значениями:

args := []string{
	"myapp",
	"--out", outFile,
	"--fmt", "markdown",
}
go run(ctx, args, etc.)

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

getenv := func(key string) string {
	switch key {
	case "MYAPP_FORMAT":
		return "markdown"
	case "MYAPP_TIMEOUT":
		return "5s"
	default:
		return ""
}
go run(ctx, args, getenv)

На мой взгляд, использование методики с getenv удобнее t.SetEnv при управлении переменными окружения, потому что можно продолжать выполнять тесты, вызывая t.Parallel(), чего t.SetEnv не позволяет делать.

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

В функции main мы можем передавать настоящие данные:

func main() {
	ctx := context.Background()
	ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
	defer cancel()
	if err := run(ctx, os.Getenv, os.Stderr); err != nil {
		fmt.Fprintf(os.Stderr, "%s\n", err)
		os.Exit(1)
	}
}

func возвращают обработчик

Мои функции обработчиков не реализуют http.Handler или http.HandlerFunc напрямую, они их возвращают. В частности, они возвращают типы http.Handler.

// handleSomething обрабатывает веб-запрос
func handleSomething(logger *Logger) http.Handler {
	thing := prepareThing()
	return http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) {
			// используем нечто для обработки запроса
			logger.Info(r.Context(), "msg", "handleSomething")
		}
	)
}

Этот паттерн даёт каждому обработчику собственное окружение замыкания. В этом пространстве можно выполнять работу по инициализации и данные будут доступны обработчикам при их вызове.

Сделайте так, чтобы считывались только общие данные. Если обработчики что-то модифицируют, вам нужен мьютекс или что-то ещё для защиты.

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

Обработка декодинга/кодирования в одном месте

Каждому сервису требуется декодировать тела запросов и кодировать тела ответов. Это разумная абстракция, выдержавшая проверку временем.

Обычно я использую пару вспомогательных функций encode и decode. В примере ниже с использованием дженериков видно, что мы просто создаём обёртку поверх нескольких простых строк, чего я обычно не делаю; однако это становится полезно, когда нужно внести здесь изменения для всех API. (Например, если ваш начальник застрял в 1990-х и хочет добавить поддержку XML.)

func encode[T any](w http.ResponseWriter, r *http.Request, status int, v T) error {
	w.WriteHeader(status)
	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(v); err != nil {
		return fmt.Errorf("encode json: %w", err)
	}
	return nil
}

func decode[T any](r *http.Request) (T, error) {
	var v T
	if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
		return v, fmt.Errorf("decode json: %w", err)
	}
	return v, nil
}

Любопытно, что компилятор способен вывести тип из аргумента, так что вам не нужно передавать его при вызове encode:

err := encode(w, r, http.StatusOK, obj)

Но поскольку это возвращаемый аргумент в decode, нужно указать ожидаемый тип:

decoded, err := decode[CreateSomethingRequest](r)

Я старался не перегружать эти функции, но в прошлом мне очень понравился простой интерфейс валидации, который подходит к функции decode.

Валидация данных

Я люблю простые интерфейсы, даже обожаю. Интерфейсы отдельных методов очень легко реализовывать. Так что когда дело доходит до валидации объектов, мне нравится делать так:

// Validator - это объект, который можно валидировать.
type Validator interface {
  // Valid проверяет объект и возвращает любые
  // проблемы. Если len(problems) == 0, тогда
  // объект валиден.
  Valid(ctx context.Context) (problems map[string]string)
}

Метод Valid получает контекст (который опционален, но пригождался мне в прошлом) и возвращает map. Если с полем возникли какие-то проблемы, то его имя используется в качестве ключа, а человекочитаемое объяснение проблемы задаётся в качестве значения.

Метод может выполнять всё необходимое для валидации полей struct. Например, он может проверять следующее:

  • Что обязательные поля не пусты;

  • Что строки определённого формата (например, адрес электронной почты) корректны;

  • Что числа находятся в приемлемом интервале.

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

Затем я применяю утверждение о типе, чтобы проверить, реализует ли объект интерфейс. Или при работе с дженериками я могу более явно указать явным образом происходящее, изменив метод decode так, чтобы он настаивал на реализации интерфейса.

func decodeValid[T Validator](r *http.Request) (T, map[string]string, error) {
	var v T
	if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
		return v, nil, fmt.Errorf("decode json: %w", err)
	}
	if problems := v.Valid(r.Context()); len(problems) > 0 {
		return v, problems, fmt.Errorf("invalid %T: %d problems", v, len(problems))
	}
	return v, nil, nil
}

В этом коде T обязан реализовывать интерфейс Validator, а метод Valid должен возвращать ноль проблем, чтобы объект считался успешно декодированным.

Вполне можно возвращать nil в качестве значения проблем, потому что мы будем проверять len(problems), которая будет нулём для map nil, но не приведёт к панике.

Шаблон «Адаптер» для промежуточного слоя

Функции промежуточного слоя получают http.Handler и возвращают новый обработчик, способный выполнять код до и/или после вызова исходного обработчика, или же может вообще решить не вызывать исходный обработчик.

Вот пример проверки того, что пользователь не администратор:

func adminOnly(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !currentUser(r).IsAdmin {
			http.NotFound(w, r)
			return
		}
		h(w, r)
	})
}

Логика внутри обработчика может опционально решать, нужно ли вызывать исходный обработчик. Если в показанном выше примере IsAdmin ложно, то обработчик вернёт HTTP 404 Not Found и выполнит возврат (или аварийное завершение); обратите внимание, что обработчик h не вызывается. Если IsAdmin истинно, то пользователю предоставляется доступ к роуту, поэтому исполнение передаётся обработчику h.

Обычно я составляю список промежуточного слоя в файле routes.go:

package app

func addRoutes(mux *http.ServeMux) {
	mux.HandleFunc("/api/", handleAPI())
	mux.HandleFunc("/about", handleAbout())
	mux.HandleFunc("/", handleIndex())
	mux.HandleFunc("/admin", adminOnly(handleAdminIndex()))
}

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

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

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

Проблема в том, что код получался таким:

mux.Handle("/route1", middleware(logger, db, slackClient, rroll []byte, handleSomething(handlerSpecificDeps))
mux.Handle("/route2", middleware(logger, db, slackClient, rroll []byte, handleSomething2(handlerSpecificDeps))
mux.Handle("/route3", middleware(logger, db, slackClient, rroll []byte, handleSomething3(handlerSpecificDeps))
mux.Handle("/route4", middleware(logger, db, slackClient, rroll []byte, handleSomething4(handlerSpecificDeps))

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

func newMiddleware(
	logger Logger,
	db *DB,
	slackClient *slack.Client,
	rroll []byte,
) func(h http.Handler) http.Handler

Возвращаемый тип func(h http.Handler) http.Handler — это функция, которую мы вызываем при настраивании роутов.

middleware := newMiddleware(logger, db, slackClient, rroll)
mux.Handle("/route1", middleware(handleSomething(handlerSpecificDeps))
mux.Handle("/route2", middleware(handleSomething2(handlerSpecificDeps))
mux.Handle("/route3", middleware(handleSomething3(handlerSpecificDeps))
mux.Handle("/route4", middleware(handleSomething4(handlerSpecificDeps))

Некоторые люди (не я) любят формализовывать этот тип функции следующим образом:

// middleware - это функция, обёртывающая http.Handlers,
// обеспечивая функциональность до и после исполнения
// обработчика h.
type middleware func(h http.Handler) http.Handler

Это нормально. Делайте так, как вам нравится. Я не буду поджидать вас после работы, идти за вами, а потом класть вам руку на плечо и задавать вопрос: довольны ли вы собой.

Лично я не делаю этого, потому что это добавляет лишний слой косвенности. Посмотрев на сигнатуру функции newMiddleware, можно легко понять, что происходит. Если возвращаемый тип — это middleware, то придётся проделывать дополнительную работу. По сути, я оптимизирую чтение кода, а не его написание.

Возможность спрятать типы запросов/ответов

Если конечная точка имеет собственные типы запросов и ответов, то обычно они полезны только для одного конкретного обработчика.

Если это так, то их можно определить внутри функции.

func handleSomething() http.HandlerFunc {
	type request struct {
		Name string
	}
	type response struct {
		Greeting string `json:"greeting"`
	}
	return func(w http.ResponseWriter, r *http.Request) {
		...
	}
}

Это позволяет поддерживать чистоту глобального пространства, а также препятствует зависимости других обработчиков от данных, которые вы можете считать нестабильными.

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

Использование вложенных типов запросов/ответов для дополнительной информации в тестах

Если типы запросов/ответов скрыты внутри обработчика, то можно просто объявить новые типы в тестовом коде.

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

Допустим, у нас в коде есть тип Person, и мы многократно используем его во множестве конечных точек. Если бы у нас была конечная точка /greet, то нас бы волновало только их имя, что можно выразить в следующем тестовом коде:

func TestGreet(t *testing.T) {
	is := is.New(t)
	person := struct {
		Name string `json:"name"`
	}{
		Name: "Mat Ryer",
	}
	var buf bytes.Buffer
	err := json.NewEncoder(&buf).Encode(person)
	is.NoErr(err) // json.NewEncoder
	req, err := http.NewRequest(http.MethodPost, "/greet", &buf)
	is.NoErr(err)
	//... другой тестовый код

Из этого кода понятно, что нам интересно только поле Name.

sync.Once для откладывания настройки

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

Это снижает время запуска приложения.

func handleTemplate(files string...) http.HandlerFunc {
	var (
		init    sync.Once
		tpl     *template.Template
		tplerr  error
	)
	return func(w http.ResponseWriter, r *http.Request) {
		init.Do(func(){
			tpl, tplerr = template.ParseFiles(files...)
		})
		if tplerr != nil {
		http.Error(w, tplerr.Error(), http.StatusInternalServerError)
			return
		}
		// используем tpl
	}
}

sync.Once гарантирует, что код будет исполняться только один раз, и что другие вызовы (другие люди, выполняющие тот же запрос) будут заблокированы, пока он не будет завершён.

  • Проверка на ошибки находится вне функции  init, поэтому если что-то пойдёт не так, мы выявим ошибку и она не потеряется в логах;

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

Помните, что делая это, вы перемещаете время инициализации с момента запуска на время исполнения (когда выполняется первый доступ к конечной точке). Я часто использую Google App Engine, так что это удобно для меня, но ваша ситуация может отличаться, так что стоит подумать о том, где и когда использовать sync.Once подобным образом.

Проектирование с учётом необходимости тестирования

Частично эти паттерны приняли такой вид из-за простоты тестирования кода. Функция run — это удобный способ запуска программы прямо из тестового кода.

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

  • Насколько просто понять, что делает твоя программа, глядя на тесты?

  • Насколько просто менять твой код, не волнуясь о возможности что-то поломать?

  • Если все тесты завершаются успешно, можно ли пушить код в продакшен, или нужно проверить что-то ещё?

Что такое «юнит» при юнит-тестировании? 

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

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

  1. Вызвать функцию, чтобы получить http.Handler — необходимо передать все требуемые зависимости (и это фича).

  2. Вызвать метод ServeHTTP для полученного http.Handler, используя реальные http.Request и ResponseRecorder из пакета httptest (см. https://pkg.go.dev/net/http/httptest#ResponseRecorder)

  3. Сделать утверждения (assertion) об ответе (проверить код статуса, декодировать тело и убедиться, что всё правильно, проверить все важные заголовки и так далее)

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

Я лучше вызову функцию run для исполнения всей программы, чтобы она выполнялась максимально близко к тому, как будет работать в продакшене. Она будет парсить все аргументы, подключаться ко всем зависимостям, выполнять миграцию базы данных и всё остальное, что она делает в реальном окружении, и в конечном итоге запустит сервер. Затем когда я вызову API из своего тестового кода, то пройду по всем слоям и даже буду взаимодействовать с реальной базой данных. Одновременно я также тестирую routes.go.

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

Тем не менее, я могу при необходимости пользоваться юнит-тестами. Если я использую TDD (что бывает у меня часто), то у меня всё равно выполняется много тестов, которые я с удовольствием поддерживаю. Но я возвращаюсь назад и удаляю тесты, если они повторяют то же, что и сквозное тестирование.

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

Тестирование при помощи функции run

Мне нравится вызывать из каждого теста функцию run. Каждый тест получает собственный автономный экземпляр программы. В каждом тесте я могу передавать разные аргументы, помечать значения, стандартные входные и выходные pipe и даже переменные окружения.

Так как функция run принимает context.Context, и поскольку весь наш код учитывает контекст (правда ведь?), мы можем получить функцию отмены, вызвав context.WithCancel. Благодаря откладыванию функции cancel при выполнении возврата тестовой функции (то есть когда выполнение тестов завершилось) контекст будет отменён и программа правильно завершится. В Go 1.14 добавили метод t.Cleanup, ставший заменой ключевого слова defer; если вы хотите узнать, для чего это было сделано, то прочитайте эту issue

Всё это можно организовать, написав на удивление небольшой объём кода. Разумеется, нужно всегда проверять ctx.Err или ctx.Done:

func Test(t *testing.T) {
	ctx := context.Background()
	ctx, cancel := context.WithCancel(ctx)
	t.Cleanup(cancel)
	go run(ctx)
	// здесь будет тестовый код

Ожидание готовности

Так как функция run выполняется в горутине, мы не знаем точно, когда она запустится. Если мы начнём отправлять запросы API, как реальные пользователи, то нам нужно знать, когда она будет готова.

Можно подготовить способ сигнализации о готовности, например канал или что-то подобное, но я предпочитаю запустить на сервере конечную точку /healthz или /readyz. Доказательством работы будут настоящие запросы HTTP.

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

Чтобы ожидать готовности сервиса достаточно написать цикл: 

// waitForReady вызывает указанную конечную точку, пока не получит 
// ответ 200, или пока не будет отменён контекст, или пока не будет достигнут
// таймаут.
func waitForReady(
	ctx context.Context, 
	timeout time.Duration, 
	endpoint string,
) error {
	client := http.Client{}
	startTime := time.Now()
	for {
		req, err := http.NewRequestWithContext(
			ctx, 
			http.MethodGet, 
			endpoint, 
			nil,
		)
		if err != nil {
			return fmt.Errorf("failed to create request: %w", err)
		}

		resp, err := client.Do(req)
		if err != nil {
			fmt.Printf("Error making request: %s\n", err.Error())
			continue
		}
		if resp.StatusCode == http.StatusOK {
			fmt.Println("Endpoint is ready!")
			resp.Body.Close()
			return nil
		}
		resp.Body.Close()

		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
			if time.Since(startTime) >= timeout {
				return fmt.Errorf("timeout reached while waiting for endpoint")
			}
			// немного ждём между проверками
			time.Sleep(250 * time.Millisecond)
		}
	}
}

Реализуем всё это на практике

Взаимодействие с простыми API при помощи описанных в статье методик — мой любимый способ работы. Он соответствует моим стремлениям к обеспечению высокого уровня поддерживаемости с удобочитаемым и легко расширяемым кодом при помощи копирования паттернов; с ним легко работать новым людям, его легко менять и реализовывать явным образом без всякой «магии». Это справедливо даже в случаях, когда я использую фреймворк генерации кода наподобие моего собственного пакета Oto для написания бойлерплейта на основании настраиваемых мной шаблонов.

В больших проектах и в крупных организациях, особенно наподобие Grafana Labs, в которой я работаю, вы часто сталкиваетесь с выбором конкретных технологий, влияющих на подобные решения. Хороший пример этого — gRPC. В случаях, когда существуют установленные паттерны и имеется наработанный опыт, или же широко используются другие инструменты или абстракции, вам часто приходится делать прагматичный выбор и «плыть по течению», хотя я всё равно подозреваю, что в этом посте вы найдёте что‑то полезное для себя.

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 37: ↑37 and ↓0+37
Comments20

Articles