Go-контексты и микросервисы. Как решить проблему с соединениями к базе при помощи контекстов

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Всем привет! В статье расскажу:

  • Как и почему у нас возникла необычная проблема, вызвавшая поток 400-ых ошибок. 

  • Как реализовали полноценную поддержку отмены операций в микросервисе.

  • Как реализовали свой пул подключений к базе для переиспользования подключений к базе в рамках запроса к сервису.

  • Как применили контексты в микросервисе и что от этого получили.

Немного о себе: меня зовут Вадим Макеров, я разработчик в команде iSpring Tech. Занимаюсь разработкой микросервисов на Golang и получаю от этого удовольствие.

Транзакции и блокировки, изучение непонятных ошибок

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

Мы используем DDD, и в доменной модели нашего сервиса лежит агрегат, у которого есть несколько листьев. Каждый запрос на изменение этого агрегата происходит под:

  • Полной блокировкой этого агрегата (изменения агрегата должны быть консистентны). 

  • Транзакцией (агрегат сохраняется в несколько шагов, и мы хотим соблюдать атомарность сохранения агрегата). 

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

  • UnitOfWork — единица работы — абстракция на транзакцией.

  • Lock — абстракция над блокировкой через базу.

// Фабрика, через которую можно создавать unitOfWork
type unitOfWorkFactory struct {
	// TransactionalClient это просто наша прослойка над объектом sql.DB 
	// полностью повторяющая его интерфейс, с небольшими изменениями для удобства
	client TransactionalClient
}

func (factory *unitOfWorkFactory) NewUnitOfWork() (UnitOfWork, error) {
	transaction, err := factory.client.BeginTransaction()
	// Обработка ошибки

	return &unitOfWork{transaction: transaction}, nil
}

type unitOfWork struct {
	transaction Transaction
	lock        *Lock
}

func (u *unitOfWork) Complete(err error) error {
	// Завершение транзакции(Commit, если err == nil или Rollback, если err != nil)
}

// Пригодится в будущем
func (u *unitOfWork) Client() Client {
	return u.transaction
}

Lock

// Создание именованного лока в базе
func NewLock(client Client, lockName string) Lock {
	return Lock{
		client:   client,
		lockName: lockName,
	}
}

type Lock struct {
	client   Client
	lockName string
}

// Применение лока в базе данных
func (l *Lock) Acquire() error {}

// Снятие лока в базе данных
func (l *Lock) Release() error {}

В большинстве операций нужны блокировка и транзакция вместе. Мы объединили их вместе под единой сущностью LockableUnitOfWork: под капотом сначала вызывали транзакцию, а потом уже блокировку.

Схема чтения незакоммиченных изменений
Схема чтения незакоммиченных изменений

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

Переместив транзакцию внутри блокировки, мы разрешили проблему чтения.

Теперь читаются только закоммиченные изменения
Теперь читаются только закоммиченные изменения

Возникла новая проблема. Стали нужны два соединения с базой: одно — на Lock, другое — на транзакцию.

Причина крылась в том, как изначально был реализован LockableUnitOfWork. До изменений он выглядел так:

type lockableUnitOfWorkFactory struct {
	// unitOfWorkFactory, что был выше реализует этот интерфейс для реализации паттерна декоратор
	factory UnitOfWorkFactory
}

type clientProvider interface {
	Client() Client
}

func (decorator *lockableUnitOfWorkFactory) NewUnitOfWork(lockName string) (service.UnitOfWork, error) {
	unitOfWork, err := decorator.factory.NewUnitOfWork()
	// Обработка ошибки

	var lock *Lock
	if lockName != "" {
		// Приведение unitOfWork к clientProvider,
		// чтобы получить соединение, на котором создана транзакция
		l := NewLock(unitOfWork.(clientProvider), lockName)
		lock = &l
		err = lock.Lock()
		// Обработка ошибки
	}

	return &lockableunitOfWork{unitOfWork: unitOfWork, lock: lock}, nil
}

func (u *lockableunitOfWork) Complete(err error) error {
	// Закрытие лока, если u.Lock != nil
	// Завершение транзакции(Commit, если err == nil или Rollback, если err != nil)
}

Здесь видно, что применение лока происходит после старта транзакции

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

Код LockableUnitOfWork после изменений:

type lockableUnitOfWorkFactory struct {
	factory UnitOfWorkFactory
	client  Client
}

type clientProvider interface {
	Client() Client
}

func (decorator *lockableUnitOfWorkFactory) NewUnitOfWork(lockName string) (service.UnitOfWork, error) {
	// Теперь Lock применяется до транзакции
	var lock *Lock
	if lockName != "" {
		// Для Lock-а используется отдельное соединение
		l := NewLock(decorator.client, lockName)
		lock = &l
		err = lock.Lock()
		// Обработка ошибки
	}
	
	// Здесь по-прежнему обычный unitOfWorkFactory создаёт транзакцию
	unitOfWork, err := decorator.factory.NewUnitOfWork()
	// Обработка ошибки

	return &lockableunitOfWork{unitOfWork: unitOfWork, lock: lock}, nil
}

func (u *lockableunitOfWork) Complete(err error) error {
	// Завершение транзакции(Commit, если err == nil или Rollback, если err != nil)
	// Закрытие лока, если u.Lock != nil
}

В пуле соединений с базой данных хранится максимум N соединений. Значит, количество одновременных запросов к сервису, которые работают через эти абстракции, уменьшится вдвое — до N/2. Такое положение дел нужно исправлять, но важно держать в голове несколько моментов:

  • Нельзя отказаться от этих абстракций: они универсальны и подходят для использования на Application уровне.

  • Нужно иметь возможность использовать их отдельно друг от друга или попеременно: например, применить одну общую блокировку и под ней запустить ещё одну блокировку и транзакцию.

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

Мы решили шарить соединение между UnitOfWork и Lock явным способом. Тут на помощь приходит объект Connection из стандартной библиотеки SQL. Он инкапсулирует под собой работу с соединением к базе данных: через него можно напрямую выполнять SQL-запросы, а также начинать транзакцию.

Шаринг соединения через Context

Самое логичное решение — шарить объект: нам нужно соединение, и мы должны шарить объект соединения. Простой способ поделить объект между другими объектами — вынести ответственность за обмен на третий объект. Поэтому мы завели ConnectionProvider, у которого только одна задача: выдавать объект соединения из внутреннего пула соединений.

Примечание: в Go в стандартной библиотеке SQL уже пулятся соединения к базе данных, но не получается контролировать, на каком соединении будет выполнен запрос. Поэтому нам нужен свой пул поверх пула из стандартной либы.

Мы организовали шаринг через простой пул с помощью map и расположили по соседству мьютекс. У map кто-то должен выступать в качестве ключа: нужна уникальная для каждого запроса в сервис сущность, которая ходит по всему микросервису. Мы решили, что этой сущностью будет Context: он уникален для каждого запроса в сервис.

Context идеально подходит для этой роли:

  • Context потокобезопасен: если хотите изменить контекст, вы всегда должны создать новый.

  • Context — это как раз та сущность, которая может ходить по всему сервису.

В итоге получился ConnectionProvider: он позволяет шарить соединение через Context.

type connectionProvider struct {
	client         TransactionalClient
	mu             sync.Mutex
	connectionPool map[context.Context]*connectionPoolEntry
}

type connectionPoolEntry struct {
	connection *sharedConnection
	count      uint
}

// Получает соединение из локального пула по контексту
// Если соединения не было, то создаёт новое и записывает в пул.
func (provider *connectionProvider) Connection(ctx context.Context) (conn TransactionalConnection, err error) {
	provider.withLock(func() {
		entry, ok := provider.connectionPool[ctx]
		if !ok {
			return
		}

		conn = entry.connection
		entry.count++
	})

	if conn == nil {
		conn, err = provider.client.Connection(ctx)
		if err != nil {
			return
		}

		sharedConn := &sharedConnection{
			TransactionalConnection: conn,
			ctx:                     ctx,
			releaseCallback:         provider.releaseConnection,
		}

		conn = sharedConn

		provider.withLock(func() {
			provider.connectionPool[ctx] = &connectionPoolEntry{
				connection: sharedConn,
				count:      1,
			}
		})
	}

	return conn, err
}

// Высвобождает соединение из пула
// Если это последнее освобождение из пула и больше соединение никто не использует,
// то соединение закрывается и удаляется из пула.
// Иначе - декременитрует счётчик использований соединения
func (provider *connectionProvider) releaseConnection(ctx context.Context) (err error) {
	provider.withLock(func() {
		entry, ok := provider.connectionPool[ctx]
		if !ok {
			return
		}

		if entry.count == 1 {
			err = entry.connection.close()
			delete(provider.connectionPool, ctx)
			return
		}
		entry.count--
	})
	return
}

func (provider *connectionProvider) withLock(f func()) {
	provider.mu.Lock()
	defer provider.mu.Unlock()
	f()
}

// Реализует интерфейс TransactionalConnection
// В него зашивается текущий контекст, чтобы освобождение соединения могло происходить независимо от контекста
type sharedConnection struct {
	TransactionalConnection

	ctx             context.Context
	releaseCallback func(ctx context.Context) error
}

// Переопределение публичного метода для вызова callback-а в пул
func (conn *sharedConnection) Close() error {
	return conn.releaseCallback(conn.ctx)
}

// Приватный метод для закрытия соединения
// Вызывается из пула
func (conn *sharedConnection) close() error {
	return conn.TransactionalConnection.Close()
}

LockableUnitOfWork

type connectionProvider struct {
	client         TransactionalClient
	mu             sync.Mutex
	connectionPool map[context.Context]*connectionPoolEntry
}

type connectionPoolEntry struct {
	connection *sharedConnection
	count      uint
}

// Получает соединение из локального пула по контексту
// Если соединения не было, то создаёт новое и записывает в пул.
func (provider *connectionProvider) Connection(ctx context.Context) (conn TransactionalConnection, err error) {
	provider.withLock(func() {
		entry, ok := provider.connectionPool[ctx]
		if !ok {
			return
		}

		conn = entry.connection
		entry.count++
	})

	if conn == nil {
		conn, err = provider.client.Connection(ctx)
		if err != nil {
			return
		}

		sharedConn := &sharedConnection{
			TransactionalConnection: conn,
			ctx:                     ctx,
			releaseCallback:         provider.releaseConnection,
		}

		conn = sharedConn

		provider.withLock(func() {
			provider.connectionPool[ctx] = &connectionPoolEntry{
				connection: sharedConn,
				count:      1,
			}
		})
	}

	return conn, err
}

// Высвобождает соединение из пула
// Если это последнее освобождение из пула и больше соединение никто не использует,
// то соединение закрывается и удаляется из пула.
// Иначе - декременитрует счётчик использований соединения
func (provider *connectionProvider) releaseConnection(ctx context.Context) (err error) {
	provider.withLock(func() {
		entry, ok := provider.connectionPool[ctx]
		if !ok {
			return
		}

		if entry.count == 1 {
			err = entry.connection.close()
			delete(provider.connectionPool, ctx)
			return
		}
		entry.count--
	})
	return
}

func (provider *connectionProvider) withLock(f func()) {
	provider.mu.Lock()
	defer provider.mu.Unlock()
	f()
}

// Реализует интерфейс TransactionalConnection
// В него зашивается текущий контекст, чтобы освобождение соединения могло происходить независимо от контекста
type sharedConnection struct {
	TransactionalConnection

	ctx             context.Context
	releaseCallback func(ctx context.Context) error
}

// Переопределение публичного метода для вызова callback-а в пул
func (conn *sharedConnection) Close() error {
	return conn.releaseCallback(conn.ctx)
}

// Приватный метод для закрытия соединения
// Вызывается из пула
func (conn *sharedConnection) close() error {
	return conn.TransactionalConnection.Close()
}

UnitOfWork и Lock — вышестоящие абстракции, которые отвечают за атомарность операции и блокировки. Инфраструктурный код, которому нужно текущее соединение с базой, может просто использовать ConnectionProvider и Context, чтобы работать с текущим соединением.

Для интеграции этого подхода решили использовать контексты по всему микросервису.

Применение контекстов в микросервисах

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

Контексты в микросервисах служат как для передачи request-scope данных (к примеру, RequestID), так и для отмены операций. Пользователь отменил запрос к сервису — нет смысла ходить в базу, чтобы извлечь информацию, которая уже никому не нужна. Можно завершить работу.

Контексты — немного теории для освежения знаний

Context — это сущность, переносящая в себе сроки выполнения запроса и прочие request-scope данные.

// A Context carries a deadline, a cancellation signal, and other values across
// API boundaries.
//
// Context's methods may be called by multiple goroutines simultaneously.
type Context interface {
	Deadline() (deadline time.Time, ok bool)

	Done() <-chan struct{}

	Err() error

	Value(key interface{}) interface{}
}

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

Что такое отмена контекста

Любой контекст несёт в себе функцию cancellation token, и любой контекст может быть отменён.

Создать контекст с отменой можно через:

// A Context carries a deadline, a cancellation signal, and other values across
// API boundaries.
//
// Context's methods may be called by multiple goroutines simultaneously.
type Context interface {
	Deadline() (deadline time.Time, ok bool)

	Done() <-chan struct{}

	Err() error

	Value(key interface{}) interface{}
}

В WithCancel передаётся родительский контекст, возвращается контекст и функция отмены. Вызов функции отмены как раз и есть сигнал всем подписчикам канала context.Done(), что контекст был отменён.

Зачем нужно подписываться на отмену контекста

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

Пример

// В go по соглашению контекст должен всегда идти первым параметром
func proceedDataAsynchroneulsyFromChannel(ctx context.Context, dataChannel chan <- Data) error {
	for {
		// Выбирает первый из двух каналов, в который пришло сообщение
		select {
			case <-ctx.Done():
				return ctx.Err()
			case someData <- dataChannel:
				// Длительная обработка данных
		}
	}
}

Как вы помните, context.Done() — это канал. При закрытии канала планировщик отпаузит все горутины, ожидающие <-ctx.Done(), и они смогут почистить свои ресурсы.

Также, помимо принудительной, отмена контекста может быть по таймауту или при достижении определенного времени.

context.WithTimeout автоматически отменяет контекст по таймауту.

// В go по соглашению контекст должен всегда идти первым параметром
func proceedDataAsynchroneulsyFromChannel(ctx context.Context, dataChannel chan <- Data) error {
	for {
		// Выбирает первый из двух каналов, в который пришло сообщение
		select {
			case <-ctx.Done():
				return ctx.Err()
			case someData <- dataChannel:
				// Длительная обработка данных
		}
	}
}

context.WithDeadline делает то же самое, только ему нужно передать время, а не Duration.

func proceedData(ctx context.Context, dataChannel chan <- Data) error {
	// Так же как и в предыдущем примере контекст отменяется спустя 5 секунд
	ctx, cancel := context.Deadline(txt, time.Now().Add(5 * time.Second))
	defer cancel()

	for {
		// По-прежнему выбирает первый из двух каналов, в который пришло сообщение
		select {
			case <-ctx.Done():
				return ctx.Err()
			case someData <- dataChannel:
				// Длительная обработка данных
		}
	}
}

Отмена контекста происходит по дереву: от родителя до всех детей

Можно завести контекст в main приложения и все следующие контексты «наследовать» от него. Отмена контекста будет происходить при получении сигналов SigTerm или SigInt от системы. Тогда будут отменяться все контексты. 

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

func callProceedData() error {
	ctx := context.Background()
	ctx, cancel := context.WithTimeout(5 * time.Second)
	defer cancel()

	// длительная операция и получение канала dataChannel

	// Если контекст отменен, то такая конструкция выполнит выход c ctx.Err()
	// иначе выполнение продолжится дальше
	select {
			case <-ctx.Done():
				return ctx.Err()
			default:
	}

	return proceedData(ctx, dataChannel)
}

func proceedData(ctx context.Context, dataChannel chan <- Data) error {
	// Так же как и в предыдущем примере контекст отменяется спустя 5 секунд
	ctx, cancel := context.WithTimeout(ctx, time.Second)
	defer cancel()

	for {
		// По-прежнему выбирает первый из двух каналов, в который пришло сообщение
		select {
			case <-ctx.Done():
				return ctx.Err()
			case data <- dataChannel:
				// Длительная обработка данных
		}
	}
}

Создание контекстов: Background и TODO

Получить «новый» контекст можно через функции context.Background и context.TODO: они обе возвращают пустой контекст, но при этом не создают контексты.

type emptyCtx int

// имплементация методов контекста для emptytx

var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)

// Каждый раз возвращает ранее созданный background
func Background() Context {
	return background
}

// Каждый раз возвращает ранее созданный todo
func TODO() Context {
	return todo
}

context.Background и context.TODO всегда возвращают один и тот же контекст при вызове.

Когда использовать Background, а когда TODO

context.TODO предпочтительно использовать, когда:

  • уже понятно, что в функцию нужно передать контекст, 

  • но пока неизвестно, откуда контекст должен прийти. 

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

Также некоторые линтеры могут отлавливать context.TODO и писать предупреждения.

context.Background нужно использовать только в самом верху: например, в функции main при старте приложения.

«Прочие request-scope данные»

Есть ещё один повод использовать контекст — передавать в нём request-scope данные через context.WithValue.

Этот метод создаёт новый контекст и зашивает в него значение под определенным ключом, которое можно получить через Context.Value. Если под таким ключом ничего нет, вернётся nil. К примеру, в контексте можно передавать RequestID.

type requestIDKey struct {}

func handleRequest (w http.ResponseWriter, req *http.Request) {
	requestID := retriveRequestIDFromHeaders(req)
	// Используется контекст из объекта Request
	ctx := context.WithValue(req.Context(), requestIDKey{}, requestID)
}

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

Рекомендую не хранить в контекстах сервисы или объекты, за временем жизни которых нужно следить. В остальном — решать вам.

Давайте посмотрим реализацию.

Запросы к базам данных через стандартную библиотеку SQL

В стандартной библиотеке Go у объекта базы данных есть методы Exec, Query, QueryRow. Почти у всех методов есть аналоги, которые используют контекст ExecContext, QueryContext, QueryRowContext и так далее.

type queryService struct {
	db *sql.DB
}

func (service *queryService) GetObject(ctx context.Context) (Object,error) {
	// sql, чтобы извлечь из базы объект
	const selectSQL = `SELECT * FROM ...`

	err := service.db.QueryContext(ctx, selectSql, ...)
	// ...
}

Объекты Transaction и Connection имеют всё те же контекстные методы, что и sql.DB.

type queryService struct {
	db *sql.DB
}

func (service *queryService) GetObject(ctx context.Context) (Object,error) {
	// sql, чтобы извлечь из базы объект
	const selectSQL = `SELECT * FROM ...`

	err := service.db.QueryContext(ctx, selectSql, ...)
	// ...
}

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

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

К Redis — через go-redis

type queryService struct {
	db *sql.DB
}

func (service *queryService) GetObject(ctx context.Context) (Object,error) {
	// sql, чтобы извлечь из базы объект
	const selectSQL = `SELECT * FROM ...`

	err := service.db.QueryContext(ctx, selectSql, ...)
	// ...
}

Запросы в другие сервисы

Для межсервисного взаимодействия мы используем gRPC: там можно передать контекст при вызове метода другого сервиса.

type queryService struct {
	db *sql.DB
}

func (service *queryService) GetObject(ctx context.Context) (Object,error) {
	// sql, чтобы извлечь из базы объект
	const selectSQL = `SELECT * FROM ...`

	err := service.db.QueryContext(ctx, selectSql, ...)
	// ...
}

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

К примеру, используется API Gateway.  В нём выставлен таймаут на запросы. API Gateway идёт к сервису A. Сервис А — к сервису B. Если сервис B будет долго обрабатывать запрос и у API Gateway выйдет таймаут, вся цепочка запроса прекратится. У сервисов A и B будет ошибка «context cancelled».

Работу с контекстами поддерживает и обычный http.Client.

func makeRequest(ctx context.Context) error {
	// Создаём объект request с контекстом
	req, err := http.NewRequestWithContext(ctx, "GET", "/ready", nil)
	if err != nil {
		return err
	}

	// Не используйте DefaultClient в production
	_, err := http.DefaultClient.Do(req)
}

Как вписать контексты в архитектуру

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

Со слоями Application и Infrastructure всё очень просто: во всех методах, сервисах и объектах мы стали принимать контекст.

Контекст и домен сервиса

Пускать контекст в домен не хотели. Объект контекста всё же слишком непредсказуемый с его context.Value, а мы хотим, чтобы наш домен зависел от предсказуемых сущностей.

Но контекст нужен после — на уровне инфраструктуры. К примеру, чтобы реализовать TransactionalOutbox, мы в одном из обработчиков доменных событий должны записать доменное событие в базу на той же транзакции. 

Это противоречие мы разрешили, реализовав проксю над доменным ивент-диспатчером.

// Уровень Domain
package domain

// Интерфейсы доменных хендлера и диспатчера
type EventHandler interface {
	Handle(event Event) error
}

type EventDispatcher interface {
	Dispatch(event Event) error
}

// Уровень Application
package app

// Интерфейс для хендлера, который хочет работать с контекстом
type EventHandler interface {
	Handle(ctx context.Context, event Event) error
}

// Передаёт контекст обработчикам, реализующим интерфейс EventHandler уровня Application
type domainEventDispatherProxy struct {
	ctx      context.Context
	handlers []EventHandler
}

func (proxy *domainEventDispatherProxy) Handle(e domain.Event) error {
	for _, handler := range proxy.handlers {
		err := hadler.Handle(porxy.ctx, e)
		if err != nil {
			return err
		}
	}
	return nil
}

Прокси передаёт событие вместе с контекстом в другой хендлер. Так в домен ничего не попадает, зато попадёт в инфраструктуру. Там контекст будет нужен, чтобы через ConnectionProvider получить соединение, на котором записать доменное событие в базу.

Решение проблемы с пересозданием сервисов на каждый запрос

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

В конструктор обработчика доменного события передавался UnitOfWorkFactory. Через него переиспользовался текущий UnitOfWork — он сохранялся в поле структуры. 

Было

type handler struct {
	// Это не совсем обычный UnitOfWorkFactory,
	// это декоратор над ним, сохраняющий текущий UnitOfWork в поле
	unitOfWorkFactory UnitOfWorkFactory
}

func (handler *handler) Handle(e domain.Event) err error {
	// Если unitOfWork был ранее создан, то вернёт текущий, иначе - создаст новый
	unitOfWork, err := hanler.unitOfWorkFactory.NewUnitOfWork()
	if err != nil {
		return err
	}

	// Magic
	client := unitOfWork.(clientProvider)
	// Работа с клиентом на текущем соединении
}

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

Благодаря ConnectionProvider мы смогли избавится от этого. Теперь обработчику можно передать в конструктор ConnectionProvider и принять context в методах. Причин пересоздавать сервисы больше нет: конструирование происходит ровно один раз.

Стало

type handler struct {
	connectionProvider ConnectionProvider
}

func (handler *handler) Handle(ctx context.Context, e domain.Event) err error {
 	client, err := handler.connectionProvider.Connection(ctx)
	if err != nil {
		return err
	}

	// Работа с клиентом на текущем соединении
}

Стало чище и более явно.

Казалось бы —  мелочь. Но именно конструирование этих обработчиков в самом начале сборки зависимостей почти каждого сервиса вынуждало пересобирать многие сущности.

___

Контесты — важная особенность языка Go, пришедшая из стандартной библиотеки x/net. При разработке сервиса они помогут сэкономить ресурсы, работать с операциями отмены более прозрачно и единым образом.

Мы же благодаря контекстам:

  • Решили проблему с соединениями к базе.

  • Перешли на использование контекстов в микросервисе.

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

Источник: https://habr.com/ru/company/ispring/blog/650941/


Интересные статьи

Интересные статьи

В 90% случаев, при обращении к провайдеру ip телефонии о проблемах качества связи, техподдержка сообщает, что у них все хорошо и нужно разбираться с нашей стороны. В итог...
В каких ситуациях Process Mining может принести организации пользу? Мы уверены, что практически в любой! Я, Иван Лазаревский, руководитель отдела Data Science в Visiology...
Современная мета управления продуктом подразумевает управление на основании данных.Все хотят аналитического подхода и способности принимать решения на куче данных о проду...
Всем привет. Уже почти два года назад я приобрел на aliexpress китайский набор, состоящий из отладочной платы EasyFPGA A2.2, с Cyclone IV EP4CE6E22C8N на борту, ИК пульта SE-020401, про...
Введение В первой части статьи мы дали краткое описание механизма encrypted SNI (eSNI). Показали каким образом на его основе можно уклоняться от детектирования современными DPI-системами (на при...