eFusion 28 мая 2020

💫 Инструментирование в Go

Рассказываем о методах инструментирования Go-кода, контекстной трассировке и специальном средстве лаконичного и гибкого инструментирования gtrace.
💫 Инструментирование в Go

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

Проблема

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

        package lib

type Client struct {
	conn net.Conn
}

func (c *Client) Request(ctx context.Context) error {
	if err := c.ping(ctx); err != nil {
		return err
	}
	// Some logic here.
}

func (c *Client) ping(ctx context.Context) error {
	return doPing(ctx, c.conn)
}
    

Что если нам нужно создать несколько логов до и после пинга? Один из способов состоит в том, чтобы встроить в Client логгер (или интерфейс с логгером) :

        package lib

type Client struct {
	Logger Logger

	conn net.Conn
}

func (c *Client) ping(ctx context.Context) (err error) {
	c.Logger.Info("ping started")
	err = doPing(ctx, c.conn)
	c.Logger.Info("ping done (err is %v)", err)
	return
}
    

Обычно это необходимо, когда мы добавляем расчёт метрик в Client:

        package lib

type Client struct {
	Logger  Logger
	Metrics Registry

	conn net.Conn
}

func (c *Client) ping(ctx context.Context) (err error) {
	start := time.Now()
	c.Logger.Info("ping started")

	err = doPing(ctx, c.conn)

	c.Logger.Info("ping done (err is %v)", err)
	metric := c.Metrics.Get("ping_latency")
	metric.Send(time.Since(start))

	return err
}
    

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

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

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

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

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

Решение

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

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

Такой подход используется, например, пакетом httptrace из стандартной библиотеки.

Давайте представим почти ту же механику, но с одним изменением. Вместо того чтобы предоставлять хуки OnPingStart() и OnPingDone(), введем один OnPing(), который будет вызван непосредственно перед ping и вернет обратный вызов, вызванный после ping. Таким образом, мы можем хранить некоторые переменные в замыкании, чтобы получить к ним доступ после завершения ping (например, для расчета его задержки).

Давайте взглянем, как изменится Client при таком подходе:

        package lib

type Client struct {
	OnPing func() func(error)
	conn net.Conn
}

func (c *Client) ping(ctx context.Context) (err error) {
	done := c.OnPing()
	err = doPing(ctx, c.conn)
	done(err)
	return
}
    

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

        func (c *Client) ping(ctx context.Context) (err error) {
	var done func(error)
	if fn := c.OnPing; fn != nil {
		done = fn()
	}
	err = doPing(ctx, c.conn)
	if done != nil {
		done(err)
	}
	return
}
    

Теперь и с гибкостью всё хорошо, и в норме принцип SRP, но код перестал быть простым.

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

Создание хуков

Как пользователь сможет создать несколько prob-ов? Упомянутый ранее пакет httptrace включает в себя ClientTrace.compose(), который объединяет две структуры трассировки в третью. Таким образом, каждая probe-функция из результирующей трассировки вызовет соответствующие prob-ы предыдущих трассировок.

Поэтому сначала попробуем сделать то же самое вручную и без рефлексии. Для этого мы перемещаем OnPing из клиента в отдельную структуру ClientTrace:

        package lib

type Client struct {
	Trace ClientTrace
	conn net.Conn
}

type ClientTrace struct {
	OnPing func() func(error)
}
    

А объединение двух трассировок будет выглядеть следующим образом:

        func (a ClientTrace) Compose(b ClientTrace) (c ClientTrace) {
	switch {
	case a.OnPing == nil:
		c.OnPing = b.OnPing
	case b.OnPing == nil:
		c.OnPing = a.OnPing
	default:
		c.OnPing = func() func(error) {
			doneA := a.OnPing()
			doneB := b.OnPing() 
			switch {
			case doneA == nil:
				return doneB
			case doneB == nil:
				return doneA
			default:
				return func(err error) {
					doneA(err)
					doneB(err)
				}
			}
		}
	}
	return c
}
    

Столько кода для одного хука? Пока двинемся вперед, вернёмся к этому позже. Теперь пользователь может самостоятельно настроить или изменить любой метод инструментирования:

        package main

import (
	"log"
	
	"some/path/to/lib"
)

func main() {
	var trace lib.ClientTrace

	// Logging hooks.
	trace = trace.Compose(lib.ClientTrace{
		OnPing: func() func(error) {
			log.Println("ping start")
			return func(err error) {
				log.Println("ping done", err)
			}
		},
	})

	// Some metrics hooks.
	trace = trace.Compose(lib.ClientTrace{
		OnPing: func() func(error) {
			start := time.Now()
			return func(err error) {
				metric := stats.Get("ping_latency")
				metric.Send(time.Since(start))
			}
		},
	})

	c := lib.Client{
		Trace: trace,
	}
}
    

Контекстная трассировка

Ещё одна штука, которую мы можем предоставить юзерам – контекстная трассировка. Это примерно то же самое, что и в пакете httptrace – возможность связывать хуки с context.Context, передаваемым в Client.Request().

        package lib

type clientTraceContextKey struct{}

func ClientTrace(ctx context.Context) ClientTrace {
	t, _ := ctx.Value(clientTraceContextKey{})
	return t
}

func WithClientTrace(ctx context.Context, t ClientTrace) context.Context {
	prev := ContextClientTrace(ctx)
	return context.WithValue(ctx,
		clientTraceContextKey{},
		prev.Compose(t),
	)
}
    

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

Остаётся одно «но»: писать весь этот код для каждой структуры – рутина. Конечно, можно написать vim-макросы, но давайте рассмотрим альтернативы.

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

Инструмент gtrace

Инструмент командной строки gtrace генерирует код Go для описанной выше трассировки. Он предлагает структуры с полями хуков (помеченные ниже / / gtrace:gen), а также генерирует вспомогательные функции вокруг них, так что мы можем объединять данные структуры и вызывать хуки без каких-либо проверок. Он также умеет создавать контекстно-зависимых хелперы для вызова связанных с контекстом хуков.

Пример сгенерированного кода находится на официальном гитхабе.

Мы можем отбросить весь «лишний» код, написанный выше, и оставить только следующий блок:

        package lib

//go:generate gtrace

//gtrace:gen
//gtrace:set context
type ClientTrace struct {
	OnPing func() func(error)
}

type Client struct {
	Trace ClientTrace
	conn net.Conn
}

func (c *Client) ping(ctx context.Context) (err error) {
	done := c.Trace.onPing(ctx)
	err = doPing(ctx, c.conn)
	done(err)
	return
}
    

После запуска go generate можно использовать сгенерированные неэкспортированные версии хуков трассировки, которые мы определили в ClientTrace.

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

Заключение

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

Источники

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ