Набор инструментов Go для работы с микросервисами

REST и gRPC: идеальное сочетание

Микросервисы обычно работают на фреймворках HTTP и RPC, таких как REST и gRPC.

REST построен на основе объектно-ориентированного проектирования  —  подхода, который представляет собой строительный блок HTTP-протокола.Операции CRUD(Create Read Update Delete  —  Создание, чтение, обновление, удаление) определяют набор поведений объекта. API REST задействуют поднабор методов HTTP, чтобы выполнять операции CRUD над элементом, который обычно представлен/сериализован в формате JSON.

gRPC  —  высокопроизводительный фреймворк RPC. С помощью API RPC можно получить доступ к распределенным процедурам или методам, которые синтаксически неотличимы от централизованных. Это позволяет скрыть сложность сериализации и передачи данных по сети. gRPC предлагает клиентскую, серверную и двунаправленную потоковую передачу.

Под капотом gRPC использует HTTP/2 для передачи и буферы протокола для эффективной сериализации, чтобы достичь невероятной производительности уровня REST+JSON. Он предлагает первоклассную поддержку генерации кода. Компилятор protobuf создает код как для клиента, так и для сервера, что ускоряет разработку приложений и сокращает уровень усилий, необходимый для доставки нового сервиса.

Сочетание REST и gRPC позволяет создавать распределенные высокопроизводительные сервисы с двухканальным режимом доступа, а также сохранить преимущества объектно-ориентированного проектирования.

Рассмотрим на примере. Определим сервис gRPC, который будет размещать заказы (order) объектно-ориентированным образом с помощью спецификации protobuf. Поскольку order является объектом, определенные методы RPC должны соответствовать операциям CRUD, которые будет поддерживать сервис. Мы также добавим List  —  дополнительный метод RPC, чтобы поддерживать списки и фильтрацию существующих заказов.

syntax = "proto3";
package orders;

import "google/protobuf/timestamp.proto";

// Сервис заказов с определениями rpc-метода CRUD + List
service OrderService {
  
  // Создает новый заказ
  rpc Create (CreateOrderRequest) returns (CreateOrderResponse);
  
  // Извлекает существующий заказ
  rpc Retrieve (RetrieveOrderRequest) returns (RetrieveOrderResponse);
  
  // Изменяет существующий заказ
  rpc Update (UpdateOrderRequest) returns (UpdateOrderResponse);
  
  // Отменяет существующий заказ
  rpc Delete (DeleteOrderRequest) returns (DeleteOrderResponse);
  
   // Выдает список текущих заказов
  rpc List (ListOrderRequest) returns (ListOrderResponse);
}

// Сообщение с деталями заказа (это и есть объект)
message Order {
  // Представляет различные состояния заказа
  enum Status {
    PENDING = 0;
    PAID = 1;
    SHIPPED = 2;
    DELIVERED = 3;
    CANCELLED = 4;
  }
  int64 order_id = 1;
  repeated Item items = 2;
  float total = 3;
  google.protobuf.Timestamp order_date = 5;
  Status status = 6;
}

// Сообщение с информацией об оплате
message PaymentMethod {
    enum Type {
    NOT_DEFINED = 0;
    VISA = 1;
    MASTERCARD = 2;
    PAYPAL = 3;
    APPLEPAY = 4;
  }
   Type payment_type = 1;
   string pre_authorization_token = 2; 
}

// Сообщение с подробной информацией о товаре, который может быть включен в заказ
message Item {
  string description = 1;
  float price = 2;
}

// Запрос на создание заказа
message CreateOrderRequest {
  repeated Item items = 1;
  PaymentMethod payment_method = 2;
}

// Ответ на создание заказа
message CreateOrderResponse {
  Order order = 1;
}

// Запрос на получение заказа
message RetrieveOrderRequest {
  int64 order_id = 1;
}

// Ответ на получение заказа
message RetrieveOrderResponse {
  Order order = 1;
}

// Запрос на изменение существующего заказа
message UpdateOrderRequest {
  int64 order_id = 1;
  repeated Item items = 2;
  PaymentMethod payment_method = 3;
}

// Запрос на обновление существующего заказа
message UpdateOrderResponse {
  Order order = 1;
}

// Запрос на отмену существующего заказа
message DeleteOrderRequest {
  int64 order_id = 1;
}

// Ответ на отмену существующего заказа
message DeleteOrderResponse {
  Order order = 1;
}

// Запрос на выдачу списка текущих заказов
message ListOrderRequest {
  repeated int64 ids = 1;
  Order.Status statuses = 2;
}

// Ответ со списком заказов
message ListOrderResponse {
  repeated Order orders = 1;
}

Затем компилируем order.protoс помощью protoc с необходимыми опциями Go.

Компиляция order.proto

Команда выше создаст два файла: order.pb.go и order_grpc.pb.go. Первый содержит структуру для каждого типа protobuf message, определенного в order.proto.

Сгенерированный код структуры заказа

Файл order_grpc.pb.go предоставляет клиентский и серверный код для взаимодействия с сервисом заказов. Он содержит OrderServiceServer —  перевод интерфейса OrderService.

Сгенерированный код интерфейса OrderServiceServer

Чтобы запустить сервер gRPC, нужно реализовать интерфейс OrderServiceServer. Для этого можно применить UnimplementedOrderServiceServer (поверхностная реализация, представленная в сгенерированном коде).

Сгенерированный код UnimplementedOrderServiceServer

Метод RegisterOrderServiceServer принимает grpc.Server и интерфейс OrderServiceServer. Он оборачивает grpc.Server вокруг реализации интерфейса сервиса заказов и должен вызываться перед серверным методом Serve(). Пример представлен ниже.

import(
  "log"
  "net"
  "google.golang.org/grpc"
)

const (
  grpcPort = "50051"
)

func main() {
  grpcServer := grpc.NewServer()
  orderService := UnimplementedOrderServiceServer{}
  RegisterOrderServiceServer(grpcServer, &orderService)

  lis, err := net.Listen("tcp", ":" + grpcPort)
  if err != nil {
    log.Fatalf("failed to listen: %v", err)
  }

  if err := grpcServer.Serve(lis); err != nil {
    log.Fatalf("failed to start gRPC server: %v", err)
  }
}

На этом этапе сервис заказов gRPC запускается всего несколькими строками кода. Осталось только разработать сервер REST. Внедрив в него интерфейс OrderServiceServer, мы полностью объединим REST и gRPC.

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/golang/protobuf/jsonpb"
	"google.golang.org/grpc"
)

// RestServer реализует сервер REST для сервиса заказов
type RestServer struct {
	server       *http.Server
	orderService OrderServiceServer // Тот же сервис заказов, что и в сервере gRPC
}

// Функция NewRestServer отлично подходит для создания RestServer
func NewRestServer(orderService OrderServiceServer, port string) RestServer {
	rs := RestServer{
		server: &http.Server{
			Addr:    ":" + port,
			Handler: router,
		},
		orderService: orderService,
	}

	// Регистрация маршрутов
	router.POST("/order", rs.create)
	router.GET("/order/:id", rs.retrieve)
	router.PUT("/order", rs.update)
	router.DELETE("/order", rs.delete)
	router.GET("/order", rs.list)

	return rs
}

// Start запускает сервер
func (r RestServer) Start() error {
	return r.server.ListenAndServe()
}

// Функция-обработчик create создает заказ из запроса (тело JSON)
func (r RestServer) create(c *gin.Context) {
	var req CreateOrderRequest

	// Демаршализация запроса
	err := jsonpb.Unmarshal(c.Request.Body, &req)
	if err != nil {
		c.String(http.StatusInternalServerError, "error creating order request")
	}

	// Использует сервис заказов, чтобы создать заказ из запроса
	resp, err := r.orderService.Create(c.Request.Context(), &req)
	if err != nil {
		c.String(http.StatusInternalServerError, "error creating order")
	}
	m := &jsonpb.Marshaler{}
	if err := m.Marshal(c.Writer, resp); err != nil {
		c.String(http.StatusInternalServerError, "error sending order response")
	}
}

func (r RestServer) retrieve(c *gin.Context) {
	c.String(http.StatusNotImplemented, "not implemented yet")
}

func (r RestServer) update(c *gin.Context) {
	c.String(http.StatusNotImplemented, "not implemented yet")
}

func (r RestServer) delete(c *gin.Context) {
	c.String(http.StatusNotImplemented, "not implemented yet")
}

func (r RestServer) list(c *gin.Context) {
	c.String(http.StatusNotImplemented, "not implemented yet")
}

Обновляем метод main, и сочетание REST и gRPC будет готово.

Теперь сервера gRPC и REST запущены и работают с одной реализацией сервиса заказов. Обратите внимание, что в вышеизложенные фрагменты кода можно внести несколько оптимизаций относительно обработки ошибок, параллелизма, удобочитаемости и так далее.

Как было сказано выше, фреймворк gRPC предоставляет обширный набор инструментов для работы с protobuf. Он ускоряет разработку приложений и позволяет генерировать клиентский и серверный код, а также интерфейс сервиса, который можно использовать для объединения gRPC с REST и другими API HTTP.

Параллелизм  —  горутины и каналы

Goroutine  —  это функция, которая выполняется одновременно с другими функциями. Это некий фоновый процесс, который не блокирует текущий поток выполнения. За кадром эти легкие потоки мультиплексируются с один или несколькими (много:1) потоками ОС. Это позволяет программе Go справляться с миллионами горутин, где количество futures, которые может обрабатывать Java, будет ограничено число доступных потоком ОС (так как потоки Java соотносятся 1:1 с потоками ОС).

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

Channel  —  это канал с примитивными типами, который позволяет горутинам безопасно обмениваться данными без мьютексов (блокировок). Процесс чтения и записи канала блокирует текущий поток выполнения до тех пор, пока отправитель и получатель не будут готовы.

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

  • Задачи приложения: запуск веб-серверов, пулы соединения с базами данных, демоны, извлечение API, очереди обработки данных.
  • Запросы/события: обработка входящих HTTP-запросов, выполнение дорогостоящих подзадач (например, несколько сетевых вызовов), публикация новых сообщений в Kafka.
  • Задачи Fire & Forget: логирование, оповещение, метрики.

Веб-сервер  —  это процесс уровня приложения, который обычно включает метод start/serve, блокирующий текущий поток выполнения до тех пор, пока сервер не завершит обслуживание запросов. Если вам интересно, как HTTP-сервер Go обрабатывает запросы, загляните в исходный код (для каждого входящего HTTP-запроса создается горутина).

Так как grpcServer.Serve() и restServer.Start()  —  блокирующие вызовы, лишь один из них может быть запущен в основном (main) потоке выполнения. Другой должен работать в фоновом режиме. Методы start/serve серверов REST & gRPC также возвращают ошибки, требующие аккуратной обработки.

Совет: оберните каждый сервер в структуру, которая представляет канал ошибки. Вызовите метод start/serve в горутину, записывающую ошибку в канал. Это позволит использовать select, чтобы активировать ожидание завершения операций нескольких каналов.

Ниже показано, как оптимизировать серверы REST и gRPC для фоновой обработки и распространения ошибок по каналам.

RestServer:

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/golang/protobuf/jsonpb"
	"google.golang.org/grpc"
)

// RestServer реализует сервер REST для сервиса заказов
type RestServer struct {
	server       *http.Server
	orderService OrderServiceServer // Тот же сервис заказов, что и в сервере gRPC
}

// Функция NewRestServer отлично подходит для создания RestServer
func NewRestServer(orderService OrderServiceServer, port string) RestServer {
	rs := RestServer{
		server: &http.Server{
			Addr:    ":" + port,
			Handler: router,
		},
		orderService: orderService,
	}

	// Регистрация маршрутов
	router.POST("/order", rs.create)
	router.GET("/order/:id", rs.retrieve)
	router.PUT("/order", rs.update)
	router.DELETE("/order", rs.delete)
	router.GET("/order", rs.list)

	return rs
}

// Start запускает сервер
func (r RestServer) Start() error {
	return r.server.ListenAndServe()
}

// Функция-обработчик create создает заказ из запроса (тело JSON)
func (r RestServer) create(c *gin.Context) {
	var req CreateOrderRequest

	// Демаршализация запроса
	err := jsonpb.Unmarshal(c.Request.Body, &req)
	if err != nil {
		c.String(http.StatusInternalServerError, "error creating order request")
	}

	// Использует сервис заказов, чтобы создать заказ из запроса
	resp, err := r.orderService.Create(c.Request.Context(), &req)
	if err != nil {
		c.String(http.StatusInternalServerError, "error creating order")
	}
	m := &jsonpb.Marshaler{}
	if err := m.Marshal(c.Writer, resp); err != nil {
		c.String(http.StatusInternalServerError, "error sending order response")
	}
}

func (r RestServer) retrieve(c *gin.Context) {
	c.String(http.StatusNotImplemented, "not implemented yet")
}

func (r RestServer) update(c *gin.Context) {
	c.String(http.StatusNotImplemented, "not implemented yet")
}

func (r RestServer) delete(c *gin.Context) {
	c.String(http.StatusNotImplemented, "not implemented yet")
}

func (r RestServer) list(c *gin.Context) {
	c.String(http.StatusNotImplemented, "not implemented yet")
}

GrpcServer:

import (
	"net"

	"google.golang.org/grpc"
)

// GrpcServer реализует сервер gRPC для сервиса заказов
type GrpcServer struct {
	server   *grpc.Server
	errCh    chan error
	listener net.Listener
}

// Функция NewRestServer отлично подходит для создания RestServer
func NewGrpcServer(service OrderServiceServer, port string) (GrpcServer, error) {
	lis, err := net.Listen("tcp", ":"+port)
	if err != nil {
		return GrpcServer{}, err
	}
	server := grpc.NewServer()
	RegisterOrderServiceServer(server, service)

	return GrpcServer{
		server:   server,
		listener: lis,
		errCh:    make(chan error),
	}, nil
}

// Start запускает сервер REST в фоновом режиме, отправляя ошибку в канал ошибок
func (g GrpcServer) Start() {
	go func() {
		g.errCh <- g.server.Serve(g.listener)
	}()
}

// Stop останавливает сервер
func (g GrpcServer) Stop() {
	g.server.GracefulStop()
}

// Error возвращает канал ошибок сервера
func (g GrpcServer) Error() chan error {
	return g.errCh
}

Не забывайте о том, что приложение Go стоит рассматривать как единую структуру. Разработчики часто пишут надежный код на уровне сервиса, а затем засоряют методы main множеством условных выражений log.Fatal() и другой сложной логикой.

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

Соответственно возвращать значения из функции init не получится. Если в процессе инициализации переменной возникает ошибка, возможно, вам потребуется выйти из приложения или написать логику recover.

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

Ниже представлена оптимизированная версия main, которая создает структуру приложения, использует select для прослушивания ошибок серверов REST & gRPC, а также обрабатывает запуск и остановку, включая сигналы завершения работы ОС.

import (
	"log"
	"os"
	"os/signal"
	"syscall"
)

const (
	grpcPort = "50051"
	restPort = "8080"
)

// Оболочка app отлично подходит для всех элементов, необходимых для запуска
// и завершения работы микросервиса Order
type app struct {
	restServer RestServer
	grpcServer GrpcServer
	/* Listens for an application termination signal
	   Ex. (Ctrl X, Docker container shutdown, etc) */
	shutdownCh chan os.Signal
}

// start запускает сервера REST и gRPC в фоновом режиме
func (a app) start() {
	a.restServer.Start() // non blocking now
	a.grpcServer.Start() // also non blocking :-)
}

// stop останавливает сервера
func (a app) shutdown() error {
	a.grpcServer.Stop()
	return a.restServer.Stop()
}

// newApp создает новое приложение с серверами REST и gRPC
// Эта функция выполняет все необходимые для приложения инициализации
func newApp() (app, error) {
	orderService := UnimplementedOrderServiceServer{}

	gs, err := NewGrpcServer(orderService, grpcPort)	
	if err != nil {
		return app{}, err
	}

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
	
	return app{
		restServer: NewRestServer(orderService, restPort),
		grpcServer: gs,
		shutdownCh: quit,
	}, nil
}

// run запускает приложение, обрабатывая любые ошибки серверов REST и gRPC
// и сигналы завершения работы
func run() error {
	app, err := newApp()
	if err != nil {
		return err
	}

	app.start()
	defer app.shutdown()

	select {
	case restErr := <-app.restServer.Error():
		return restErr
	case grpcErr := <-app.grpcServer.Error():
		return grpcErr
	case <-app.shutdownCh:
		return nil
	}
}

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}

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

Группы ожидания (Waitgroup) позволяют запускать набор горутин и ожидать завершения их работы. Однако при их использовании также нужно управлять счетчиком waitGroup. ErrGroup отлично подходит для выполнения набора подзадач. Этот элемент состоит из коллекции горутин, которые реализуют подзадачи и обрабатывают распространение ошибок. errGroup ожидает (блокирует) до тех пор, пока все подзадачи не будут завершены.

Применяйте Context для входящих и исходящих серверных запросов. Он позволяет распространять ограниченные запросом значения, сроки и сигналы отмены между клиентами и серверами. В Context есть канал Done(), с помощью которого горутины могут получать уведомления о его отмене. Так они могут раньше выполнить выход и освободить ресурсы системы. Когда используется errgroup.WithContext(), полученный контекст отменяется при первой обнаруженной ошибке подзадачи или при возвращении wait().

В примере ниже validateOrder создает errGroup, которая порождает две параллельные подзадачи: preAuthorizePayment для авторизации способа оплаты и checkInventory для проверки наличия товаров. Функции, вызываемые в обеих подзадачах, принимают Context и могут вернуться раньше в случае его отмены (или задержки запроса).

import (
	"context"
	"errors"
	"time"

	"golang.org/x/sync/errgroup"
)
var (
	ErrPreAuthorizationTimeout = errors.New("pre-authorization request timeout")
	ErrInventoryRequestTimeout = errors.New("check inventory request timeout")
	ErrItemOutOfStock = errors.New("sorry one or more items in your order is out of stock")
)

// preAuthorizePayment выполняет предварительную авторизацию метода оплаты
// и возвращает ошибку. nil возвращается при успешной предварительной авторизации
func preAuthorizePayment(ctx context.Context, payment *PaymentMethod, orderAmount float32) error {

	// Здесь выполняется дорогостоящая логика авторизации - для этого примера задействуем режим сна :-)
	// и вернем nil, чтобы указать успешную авторизацию
	timer := time.NewTimer(3 * time.Second)

	select {
	case <-timer.C:
		return nil
	case <-ctx.Done():
		return ErrPreAuthorizationTimeout
	}
}

// checkInventory возвращает логическое значение и ошибку, указывающую,
// есть ли все товары на складе. (true, nil) возвращается, если
// все товары есть на складе, и не возникло никаких ошибок
func checkInventory(ctx context.Context, items []*Item) (bool, error) {

	// Здесь выполняется дорогостоящая логика инвентаризации - для этого примера задействуем режим сна :-)
	timer := time.NewTimer(2 * time.Second)

	select {
	case <-timer.C:
		return true, nil
	case <-ctx.Done():
		return false, ErrInventoryRequestTimeout
	}
}

// getOrderTotal высчитывает общую сумму заказа
func getOrderTotal(items []*Item) float32 {
	var total float32

	for _, item := range items {
		total += item.Price
	}

	return total
}

func validateOrder(ctx context.Context, items []*Item, payment *PaymentMethod) error {
	g, errCtx := errgroup.WithContext(ctx)

	g.Go(func() error {
		return preAuthorizePayment(errCtx, payment, getOrderTotal(items))
	})

	g.Go(func() error {
		itemsInStock, err := checkInventory(errCtx, items)
		if err != nil {
			return err
		}
		if !itemsInStock {
			return ErrItemOutOfStock
		}
		return nil
	})

	return g.Wait()
}

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

import (
	"fmt"
	"sync"
	"time"
)

// OrderDispatcher - это процесс-демон, который создает набор обработчиков с помощью sync.waitGroup, чтобы параллельно
// обрабатывать и отправлять заказы
type OrderDispatcher struct {
	ordersCh   chan *Order
	orderLimit int // maximum number of orders the pool will process concurrently
}

// NewOrderDispatcher создает новый OrderDispatcher
func NewOrderDispatcher(orderLimit int, bufferSize int) OrderDispatcher {
	return OrderDispatcher{
		ordersCh:   make(chan *Order, bufferSize), // initiliaze as a buffered channel
		orderLimit: orderLimit,
	}
}

// SubmitOrder отправляет заказ на обработку
func (d OrderDispatcher) SubmitOrder(order *Order) {
	go func() {
		d.ordersCh <- order
	}()
}

// Start запускает диспетчера в фоновом режиме
func (d OrderDispatcher) Start() {
	go d.processOrders()
}

// Shutdown отключает OrderDispatcher путем закрытия канала заказов
// Примечание: эта функция должна выполняться только после того, как последний заказ
// попадет в канал заказов. Отправка заказа в закрытый канал вызовет панику.
func (d OrderDispatcher) Shutdown() {
	close(d.ordersCh)
}

// processOrders обрабатывает все входящие заказы в фоновом режиме с помощью
// for-range и sync.waitGroup
func (d OrderDispatcher) processOrders() {
	limiter := make(chan struct{}, d.orderLimit)
	var wg sync.WaitGroup

	// Непрерывная обработка заказов, полученных из канала заказов
	// Этот цикл завершится после закрытия канала
	for order := range d.ordersCh {
		limiter <- struct{}{}
		wg.Add(1)

		go func(order *Order) {
			// Что нужно сделать: запустить процесс выполнения, чтобы собрать заказ в пакет и отправить
			// Пока используем спящий режим и печать
			time.Sleep(50 * time.Millisecond)
			fmt.Printf("Order (%v) has shipped \n", order)
			<-limiter
			wg.Done()
		}(order)
	}
	wg.Wait()
}

func main() {
	dispatcher := NewOrderDispatcher(3, 100)
	dispatcher.Start()
	defer dispatcher.Shutdown()

	dispatcher.SubmitOrder(&Order{Items: []*Item{{Description: "iPhone Screen Protector", Price: 9.99}}})
	dispatcher.SubmitOrder(&Order{Items: []*Item{{Description: "iPhone Case", Price: 19.99}}})
	dispatcher.SubmitOrder(&Order{Items: []*Item{{Description: "Pixel Case", Price: 14.99}}})
	dispatcher.SubmitOrder(&Order{Items: []*Item{{Description: "Bluetooth Speaker", Price: 29.99}}})
	dispatcher.SubmitOrder(&Order{Items: []*Item{{Description: "4K Monitor", Price: 159.99}}})
	dispatcher.SubmitOrder(&Order{Items: []*Item{{Description: "Inkjet Printer", Price: 79.99}}})
	
	time.Sleep(5 * time.Second) // just for testing
}

Эффективное модульное тестирование

Несколько советов по модульному тестированию в Go.

  • Используйте чистые функции, а не методы. Это одна из самых простых единиц кода в плане тестирования. Чистая функция детерминирована и не требует инициализации для проверки. Метод  —  это функция, определенная по типу (type), например структуре. Чтобы его протестировать, нужно инициализировать его родительский тип. Пример показан ниже.
// Избегайте этого
type OrderTotaler struct {         
  items []*Item
}

// Это метод. Привязка его к структуре не приносит никакой пользы
// Структура должна быть инициализирована перед тестированием
// этого метода
func (t OrderTotaler) getOrderTotal() float32 {
	var total float32

	for _, item := range t.items {
		total += item.Price
	}

	return total
}

// Лучше попробуйте этот вариант. Это чистая функция
func getOrderTotal(items []*Item) float32 {
	var total float32

	for _, item := range items {
		total += item.Price
	}

	return total
}
  • Создавайте функциональные зависимости.Любую внешнюю зависимость (база данных, вызов веб-сервиса, производитель событий и прочие), которая позволяет функции выполнить задачу, можно внедрить в нее в качестве параметра. Но такие функции сложно тестировать. Обычно это обходится путем использования фреймворка тестирования, который способен изменять (имитировать) значения внешних зависимостей во время выполнения (с помощью отражения). Посмотрите еще раз на функцию validateOrder в одном из фрагментов кода выше и вы обнаружите, что внешние зависимости preAuthorizePayment и verifyInventory встроены. Протестировать ее будет непросто. Поскольку Go поддерживает функции первого класса, исправить это можно, превратив validateOrder в функцию более высокого порядка.
var (
  	ErrPreAuthorizationTimeout = errors.New("pre-authorization request timeout")
	ErrInventoryRequestTimeout = errors.New("check inventory request timeout")
	ErrItemOutOfStock = errors.New("sorry one or more items in your order is out of stock")
)

// Создает псевдонимы для внешних зависимостей
type preAuthorizePaymentFunc func(context.Context, *PaymentMethod, float32) error
type checkInventoryFunc func (context.Context, []*Item) (bool, error)


// Внедряет зависимости в качестве параметров в validateOrder
func validateOrder(ctx context.Context, items []*Item, payment *PaymentMethod,
     preAuthorizePayment preAuthorizePaymentFunc, checkInventory checkInventoryFunc) error {
	g, errCtx := errgroup.WithContext(ctx)

	g.Go(func() error {
		return preAuthorizePayment(errCtx, payment, getOrderTotal(items))
	})

	g.Go(func() error {
		itemsInStock, err := checkInventory(errCtx, items)
		if err != nil {
			return err
		}
		if !itemsInStock {
			return ErrItemOutOfStock
		}
		return nil
	})

	return g.Wait()
}

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

import (
	"context"
	"errors"
	"testing"
)

func TestVerifyOrder(t *testing.T) {
	ctx := context.Background()
	iphoneScreenProtector := Item{Description: "iPhone Screen Protector", Price: 9.99}
	iphoneCase := Item{Description: "iPhone Case", Price: 19.99}

	// mock-функция внешней зависимости #1
	preAuth := func(ctx context.Context, payment *PaymentMethod, amount float32) error {
		if amount <= 0 || payment.PaymentType == PaymentMethod_UNDEFINED {
			return errors.New("invalid pre authorization request")
		}
		return nil
	}

	// mock-функция внешней зависимости #2
	checkInv := func(ctx context.Context, items []*Item) (bool, error) {
		if len(items) == 0 {
			return false, errors.New("no items to check")
		}
		if len(items) == 1 && items[0] == &iphoneScreenProtector {
			return true, nil
		}
		return false, nil
	}

	t.Run("payment pre-authorization and inventory checks are successful", func(t *testing.T) {
		visaPayment := PaymentMethod{
			PaymentType:           PaymentMethod_VISA,
			PreAuthorizationToken: "fooBarToken"}

		// Здесь не нужны мок-фреймворки
		if err := validateOrder(ctx, []*Item{&iphoneScreenProtector}, &visaPayment, preAuth, checkInv); err != nil {
			t.Error("Expected nil, got ", err)
		}
	})

	t.Run("error during payment pre-authorization", func(t *testing.T) {
		invalidPayment := PaymentMethod{
			PaymentType:           PaymentMethod_UNDEFINED,
			PreAuthorizationToken: "fooBarToken"}

		if err := validateOrder(ctx, []*Item{&iphoneScreenProtector}, &invalidPayment, preAuth, checkInv); err == nil {
			t.Error("Expected error, got nil")
		}
	})

	t.Run("item is out of stock", func(t *testing.T) {
		visaPayment := PaymentMethod{
			PaymentType:           PaymentMethod_VISA,
			PreAuthorizationToken: "fooBarToken"}

		if err := validateOrder(ctx, []*Item{&iphoneCase}, &visaPayment, preAuth, checkInv); err == nil {
			t.Error("Expected error, got nil")
		}
	})

	// Определите другие тестовые примеры и напишите их :-)
}
  • Мок-фреймворки полезны, если использовать их как инструмент, а не основу. Несмотря на то, что внешние зависимости можно имитировать без сторонних библиотек, эти фреймворки все же значительно помогают в процессе модульного тестирования, например при выполнении тестовых утверждений.
  • Пишите чистый код. Учитывайте, что его будут читать другие члены команды. Чистый код легко писать, понимать и тестировать. Как говорил Роб Пайк: “Чистый лучше, чем умный”.

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

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


Перевод статьиGeorge Francis Jr: The Go Microservice Toolkit