Средства измерения программ на Go

Май, 2020  |  

Гофер смотрит на читателя через лупу

Вступление

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

TL;DR

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

Другими словами, логирование и сбор метрик – это подмножества трассировки.

Шаблонный код трассировки может быть сгенерирован с помощью gtrace.

Проблема

Предположим, что у нас есть пакет lib и некая структура lib.Client. Перед выполнением какого-либо запроса 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)
}

Что делать, если мы хотим делать запись в лог прямо перед и сразу после того, как отправляется ping-сообщение? Первый вариант – это внедрить логгер (или его интерфейс) в 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
}

Если мы захотим собирать какие-либо метрики, мы можем сделать то же самое:

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()).

Количество несвязных (не связанных с основной функциональностью Client) строчек кода это только первая проблема такого подхода.

Что, если в ходе эксплуатации программы вы поймете, например, что имя метрики нужно поменять? Или, например, вы решите поменять логгер или сообщения в логах?

С таким подходом как выше, вам придется править код Client.

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

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

Все эти вопросы указывают на ошибку, которую мы совершили:

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

Решение

На мой взгляд, правильно было бы определить точки трассировки (или хуки), в которых могут быть установлены пользовательские функции (или пробы).

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

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

Давайте предоставим такой же интерфейс, но с одним исключением: вместо хуков OnPingStart() и OnPingDone(), мы определим только OnPing(), который будет возвращать callback. OnPing() будет вызван непосредственно перед отправкой ping-сообщения, а callback – сразу после. Таким образом мы сможем сохранять некоторые переменные в замыкании (например, чтобы посчитать время выполнения doPing()).

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 и его результат на nil. Правильнее было бы сделать следующее:

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 принципа и гибкости, но не так хорошо в плане читаемости.

Прежде чем это исправить, давайте решим еще одну проблему трассировки.

Объединение хуков

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

Давайте попробуем сделать то же самое вручную (и без использования пакета reflect). Для этого мы перенесем хук OnPing из Client в отдельную структуру 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,
	}
}

Трассировка и контекст

Трассировка кода так же может происходить в зависимости от контекста. Давайте предоставим пользователю возможность связать ClientTrace с экземпляром 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 для этого (и на самом деле я делал так какое-то время), но давайте посмотрим на другие варианты.

Хорошая новость состоит в том, что весь код для объединения хуков, проверок на nil и функций для работы с контекстом весьма шаблонный, и мы можем его сгенерировать без использования макросов или пакета reflection.

github.com/gobwas/gtrace

gtrace это инструмент командной строки для генерации кода трассировки из примеров выше. Для описания точек трассировки используются структуры, помеченные директивой //gtrace:gen. В результате вы получаете возможность вызова хуков без каких-либо проверок на nil, а так же функции для их объединения и функции для работы с контекстом.

Пример сгенерированного кода находится здесь.

Теперь мы можем удалить весь рукописный код и оставить только это:

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 берет весь шаблонный код на себя и позволяет вам сфокусироваться на точках трассировки, которые вы хотели бы предоставить пользователям для измерения вашего кода.

Спасибо за внимание!

References