Декораторы в Go

К молодой осине год который подряд дети лукоморья нам несут шоколад

Оглавление

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

Перехватчики

Концепция middleware появилась, конечно, задолго до go. Будем называть их перехватчиками, что не по фен-шую, но лучше, чем «слой промежуточного программного обеспечения». Однако, именно в go эта концепция достигла своего апогея. Собственно про middleware написано и сказано уже довольно много. Чаще всего рассматриваются перехватчики в контексте http-сервера. В качестве примера можно рассмотреть перехватчик для логирования запросов:

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("request start: %s", r.URL)
        hext(w, r)
        log.Printf("request end: %s", r.URL)
    })
}

Или более сложный пример с инициализацией:

func Logging(logger *log.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            logger.Printf("request start: %s", r.URL)
            hext(w, r)
            logger.Printf("request end: %s", r.URL)
        })
    }
}

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

  • перехват паник;
  • телеметрия (логи / метрики / трейсинг);
  • лимитирование (ограничение количества запросов или прерывание запроса по времени);
  • проверка авторизации и прав доступа по кукам и заголовкам;
  • извлечение информации из запроса;

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

Главным удобством таких функций является общая сигнатура:

type Middleware func(http.Handler) http.Handler

что позволяет легко сделать их объединение (композицию):

func Combine(middlewares ...Middleware) Middleware {
    return func (next http.Handler) http.Handler {
        // let's keep the order as in mathematical functional composition
        for i := len(middlewares) - 1; i >= 0; i-- {
            next = middlewares[i](next)
        }
        return next
    }
}

Концепция таких промежуточных перехватчиков настолько прижилась, что практически каждый сторонний роутер в том или ином виде предоставляет удобные механизмы для их использования. Например, в chi можно объявлять перехватчики глобальные для данного уровня дерева путей с помощью метода Use или специфичные для конкретного пути — With.

Аналогичные механизмы есть и в grpc, только там они называются именно перхватчиками (interceptors). Но можно ли расширить эту концепцию дальше, за пределы http и grpc сервисов?

Общий случай

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

type Operation func(context.Context) error
type Wrapper func(Operation) Operation

На таких функциях также легко реализовать функцию композицию по аналогии с вышеприведённой. Такие декораторы удобно использовать для

  • телеметрии (логи / метрики / трейсинг);
  • обработки ошибок (retry / backoff);
  • ограничения (времени / частоты / конкурентности);

Замыкания

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

func ProcessFile(ctx context.Context, path string) (float64, error) {
    // ...
}

И пусть у нас есть список файлов, которые необходимо обработать, но обработку каждого файла хочется покрыть метриками, прерывать, если прошло больше 5 секунд без результата, а также повторить попытку при ошибке. Если у нас уже есть необходимые декораторы и функция Combine, то код может выглядеть примерно так:

var exposition float64
for _, path := range paths {
    err := Combine(
        // Повторяем 3 раза в случае ошибки
        Retry(3),
        // Логируем время начала и конца обработки
        Logger(log.New(os.Stderr, path, log.LstdFlags)),
        // Ограничиваем время выполнения
        Timout(5 * time.Second),
    )(func(ctx context.Context) error {
        fileExposition, err := ProcessFile(ctx, path)
        if err != nil {
            return err
        }
        exposition += fileExposition
        return nil
    })(context.Background())
    if err != nil {
        log.Fatal(err)
    }
}
log.Printf("average exposition is %.4f", exposition / len(paths))

При этом, для внутренних декораторов можно делать обмен данными через контекст. Например, декоратор Retry может класть в контекст номер попытки, который будет логироваться в декораторе Logger. С другой стороны композицию декораторов можно вообще определить за пределами цикла, в случае если Logger будет брать сообщение из контекста. Предположим, что мы вынесли код всех декораторов и вспомогательный функции в отдельный модуль под названием wrap, тогда код можно переписать следующим образом:

processWrapper := wrap.Combine(
    // Повторяем 3 раза в случае ошибки
    wrap.Retry(3),
    // Логируем время начала и конца обработки
    wrap.Logger(log.New(os.Stderr, "", log.LstdFlags)),
    // Ограничиваем время выполнения
    wrap.Timout(5 * time.Second),
)

var exposition float64
for _, path := range paths {
    err := processWrapper(func(ctx context.Context) error {
        fileExposition, err := ProcessFile(ctx, path)
        if err != nil {
            return err
        }
        exposition += fileExposition
        return nil
    })(wrap.ContextWithTarget(context.Background(), path))
    if err != nil {
        log.Fatal(err)
    }
}
log.Printf("average exposition is %.4f", exposition / len(paths))

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

Прямые вызовы

Интересным оказывается порядок вызова декоратора:

err := Wrapper(opts)(Operation)(ctx)
err := Wrapper(opts)(func(ctx context.Context) error {
    // ...
})(ctx)

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

err := wrap.WithLogging(ctx, logger, func(ctx context.Context) error {
    // ...
})

для чего достаточно определить следующую вспомогательную функцию:

func WithLogging(ctx context.Context, logger *log.Logger, operation Operation) error {
    return Logging(logger)(operation)(ctx)
}

Или по аналогии с http.HandlerFunc определить метод Apply на декораторе:

type Operation func(context.Context) error
type Wrapper func(Operation) Operation

func (wrapper Wrapper) Apply(ctx context.Context, operation Operation) error {
    return wrapper(operation)(ctx)
}

После чего вызов будет выглядеть так:

err := wrap.Logging(logger).Apply(ctx, func(ctx context.Context) error {
    // ...
})

Или пример с обработкой файлов:

var exposition float64
for _, path := range paths {
    ctx := wrap.ContextWithTarget(context.Background(), path)
    err := processWrapper.Apply(ctx, func(ctx context.Context) error {
        fileExposition, err := ProcessFile(ctx, path)
        if err != nil {
            return err
        }
        exposition += fileExposition
        return nil
    })
    if err != nil {
        log.Fatal(err)
    }
}

Что на мой взгляд читает несколько легче.

Насколько абстрактным должен быть набор декораторов?

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

Лучше завязаться на выбранные вами технологии, зато потом использовать эти декораторы с минимальным дополнительным кодом. Например, если вы выбрали в качестве логера zap и используете ctxzap для хранения дополнительных параметров, то не стоит наворачивать дополнительный код с переводом *zap.Logger в стандартный или по передаче дополнительного контекста. Пересмотрите свой код, выявите повторяющиеся части и попробуйте вынести их в обёртки. Поговорите с другими участниками проекта о том как сделать интерфейс таких декораторов лучше.