Использование конкурентности при создании API в Go

Когда в 2014 году я впервые начинал писать приложения на Golang, мое внимание сразу же привлекло самое необычное и интересное, что есть в этом языке: конкурентность и каналы. Провозившись с кучей строк кода с ошибками, едва поддающимися объяснению, я освоил кое-какие шаблоны. Эти шаблоны используют преимущества конкурентности, уменьшая при этом количество ошибок и значительно улучшая удобство кода с точки зрения его восприятия человеком.

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

Шаблон конкурентности API

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

Разработайте блокирующие операции, чтобы…

  1. Всегда запускаться асинхронно с помощью ключевого слова go для выполнения этих операций в многопоточном режиме.
  2. make (открывать) и close (закрывать) выделенные каналы для минимизации вероятности утечек памяти и взаимоблокировок.
  3. Использовать context.Context для остановки ожидающих запросов, которые больше не нужны.
  4. Использовать указатели для возвращения результатов вместо каналов. Так уменьшится количество каналов, которыми нужно управлять.
  5. Выдавать ошибки через <-chan error. Так вы дождетесь завершения блокирующих операций до возвращения ответов.

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

Пример кода

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

API-запросы

Этап 1 реализации этого шаблона  —  это асинхронная отправка всех API-запросов и блокирующих операций.

// Piece — это фрагмент результата
type Piece struct {
	ID uint `json:"id"`
}

// getPiece вызывает `GET /piece/:id`
func getPiece(ctx context.Context, id uint, piece *Piece) <-chan error {
	out := make(chan error)
	go func() {
		// Корректное управление памятью — всегда закрывайте... каналы
		defer close(out)

		// NewRequestWithContext немедленно отменит свой запрос, когда
		// источник вызова отменит контекст
		req, err := http.NewRequestWithContext(
			ctx, 
			"GET", 
			fmt.Sprintf("api.url.com/piece/%d", id), 
			nil,
		)
		if err != nil {
			out <- err
			return
		}

		// Отправляем запрос
		rsp, err := http.DefaultClient.Do(req)
		if err != nil {
			out <- err
			return
		} else if rsp.StatusCode != http.StatusOK {
			out <- fmt.Errorf("%d: %s", rsp.StatusCode, rsp.Status)
			return
		}

		// Выполняем парсинг ответа в piece
		defer rsp.Body.Close()
		if err := json.NewDecoder(rsp.Body).Decode(piece); err != nil {
			out <- err
			return
		}
	}()
	return out
}

Ответы от API

Этап 2 реализации шаблона  —  это объединение нескольких блокирующих операций и API-запросов в один ответ от API struct.

/ Результат — комбинация нескольких блокирующих операций, 
// которые будут получены одновременно
type Result struct {
	FirstPiece  *Piece `json:"firstPiece,omitempty"`
	SecondPiece *Piece `json:"secondPiece,omitempty"`
	ThirdPiece  *Piece `json:"thirdPiece,omitempty"`
}

// GetResult — это `http.HandleFunc`, который получает (с помощью GET) `Result`(результаты)
func GetResult(w http.ResponseWriter, r *http.Request) {
	// Выполняем парсинг и проверку вводимых данных...

	// getResult немедленно остановится, когда произойдет отмена http.Request
	var result Result
	if err := <-getResult(r.Context(), &result); err != nil {
		w.Write([]byte(err.Error()))
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	// Выполняем маршалинг ответа
	bs, err := json.Marshal(&result)
	if err != nil {
		w.Write([]byte(err.Error()))
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	// Успешно!
	w.Write(bs)
	w.WriteHeader(http.StatusOK)
}

// getResult возвращает результат многочисленных вызовов API с высокой степенью конкурентности
func getResult(ctx context.Context, result *Result) <-chan error {
	out := make(chan error)
	go func() {
		// Корректное управление памятью
		defer close(out)

		// Функция cancel позволит остановить все ожидающие запросы, когда один
		// завершится неуспешно
		ctx, cancel := context.WithCancel(ctx)

		// Merge позволяет получить все ошибки, возвращенные из всех
		// вызовов в `getPieces` в одном `<-chan error`.
		// Когда не вернется ни одной ошибки, Merge будет ждать, пока все 
		// `<-chan error` закроются, прежде чем продолжить
		for err := range util.Merge(
			getPiece(ctx, 1, result.FirstPiece),
			getPiece(ctx, 2, result.SecondPiece),
			getPiece(ctx, 3, result.ThirdPiece),
		) {
			if err != nil {

				// Отменяем все ожидающие запросы
				cancel()

				// Выдаем ошибку в источник вызова
				out <- err
				return
			}
		}
	}()
	return out
}

Функция Merge

Этап 3  —  это реализация единственной функции ‘слияния’ func. Даже если вы хорошо разбираетесь в Go, эта часть паззла наверняка будет самой сложной, а вероятность возникновения ошибок здесь  —  самой высокой. Рекомендую скопировать и вставить этот код прямо в пакет util или использовать тот, что есть в пакете конвейера Delivery Hero.

package util

import (
	"sync"
)

// Merge объединяет нескольких каналов ошибок в один
func Merge(errChans ...<-chan error) <-chan error {
	mergedChan := make(chan error)

	// Создаем WaitGroup, которая ожидает закрытия всех errChans
	var wg sync.WaitGroup
	wg.Add(len(errChans))
	go func() {
		// Когда все errChans будут закрыты, закрываем mergedChan
		wg.Wait()
		close(mergedChan)
	}()

	for i := range errChans {
		go func(errChan <-chan error) {
			// Ожидаем закрытия каждого errChan
			for err := range errChan {
				if err != nil {
					// Объединяем содержимое каждого errChan в mergedChan
					mergedChan <- err
				}
			}
			// Сообщаем WaitGroup, что один из errChans закрыт
			wg.Done()
		}(errChans[i])
	}

	return mergedChan
}

Подведем итоги

Существует много разных подходов в отношении к конкурентности в Go. Здесь мы изложили четкий и эффективный подход к конкурентности при создании API: он сохраняет код хорошо организованным и сводит к минимуму ошибки управления памятью.

Надеюсь, этот подход станет для вас полезным и применимым на практике примером конкурентности в Golang. И сэкономит немного времени и нервных клеток, избавляя от необходимости собирать по крупицам всю эту информацию самостоятельно.

Остаётся только попрактиковаться!

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Mark Salpeter: Concurrent API Patterns in Go

Предыдущая статьяКак писать лог-файлы, которые экономят время
Следующая статьяСоздаем расширение Chrome на Mint