Context в golang приложениях

Контекст в golang появился c версии 1.7 и был перенесен с пакета net/context. В дальнейшем другие пакеты начали его использовать например database/sql. Эта библиотека позволяет разрабатывать гибкие приложения, полезна в микросервисах для передачи контекста запроса и тп. В данной статье мы рассмотрим основные примеры использования и как не стоит использовать context. Как обычно статья несет только мнение автора, свой примеры и опыт использования вы можете оставить в комментариях.

Как не стоит использовать context.Context

Рассмотрим как использовать контекст если вам нужно написать приложение которое сложно развивать и поддерживать.

Не используйте при передаче обязательных параметров

package main

import (
    "context"
)

type User struct {
    ID   int64
    Name string
}
type ctxKet uint

const (
    user ctxKet = iota
)

func WithUser(ctx context.Context, user *User) context.Context {
    return context.WithValue(ctx, user, user)
}

func UserFromCtx(ctx context.Context) *User {
    return ctx.Value(user).(*User)
}

type Service struct {
    friends func(ctx context.Context, id int64) ([]User, error)
}

func (s *Service) Friends(ctx context.Context) ([]User, error) {
    user := UserFromCtx(ctx)

    return s.friends(ctx, user.ID)
}

Это только часть кода, к примеру у нас api работает только с авторизированным пользователем и всегда после авторизации добавляет его в контекст. Что может быть проще получить его из контекста в методе Friends(ctx context.Context) ([]User, error) с помощью метода UserFromCtx(ctx). Но это усложняет разработку и поддержку данного приложения, поскольку это как минимум невозможно понять из сигнатуры и чтобы использовать данный метод сервиса необходимо прочитать весь код, также это может привести к неявным ошибкам, к примеру если бы уберем из контекста пользователя или добавим туда другого. В данном примере лучше будет использовать Friends(ctx context.Context, user *User) ([]User, error), как минимум можно получать друзей не только авторизированного пользователя и друзей любого другого, если это необходимо. Даже если у вас метод не должен работать с анонимным пользователем например SharedFriends(ctx context.Context, user *User) ([]User, error) получает общих друзей, то неявная зависимость всегда будет усложняет дальнейшую разработку, лучше использовать SharedFriends(ctx context.Context, from, to *User) ([]User, error).

Не передавайте сервисы и бизнес логику

Еще одним плохим примером может быть передача логики, тут пример из (Dataloaders)[https://gqlgen.com/reference/dataloaders/], это решение проблемы graphql для запроса N+1.

const loadersKey = "dataloaders"

type Loaders struct {
    UserById UserLoader
}

func Middleware(conn *sql.DB, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := context.WithValue(r.Context(), loadersKey, &Loaders{
            UserById: UserLoader{
                maxBatch: 100,
                wait:     1 * time.Millisecond,
                fetch: func(ids []int) ([]*model.User, []error) {
                    placeholders := make([]string, len(ids))
                    args := make([]interface{}, len(ids))
                    for i := 0; i < len(ids); i++ {
                        placeholders[i] = "?"
                        args[i] = i
                    }

                    res := db.LogAndQuery(conn,
                        "SELECT id, name from dataloader_example.user WHERE id IN ("+strings.Join(placeholders, ",")+")",
                        args...,
                    )
                    defer res.Close()

                    userById := map[int]*model.User{}
                    for res.Next() {
                        user := model.User{}
                        err := res.Scan(&user.ID, &user.Name)
                        if err != nil {
                            panic(err)
                        }
                        userById[user.ID] = &user
                    }

                    users := make([]*model.User, len(ids))
                    for i, id := range ids {
                        users[i] = userById[id]
                        i++
                    }

                    return users, nil
                },
            },
        })
        r = r.WithContext(ctx)
        next.ServeHTTP(w, r)
    })
}

func For(ctx context.Context) *Loaders {
    return ctx.Value(loadersKey).(*Loaders)
}

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

Примеры использования

Если не передавать значения то зачем context нужен, есть много примеров его удачного использования.

Отмена выполнения

Отмена выполнения наверное одна из его ключевых особенностей. Например при отмене http запроса от пользователя, отменить запросы в базу или внешним сервисам. Или при параллельной goroutine можно завершить при первой ошибке, такая функциональность реализована в sync/errgroup.

 

Google := func(ctx context.Context, query string) ([]Result, error) {
    g, ctx := errgroup.WithContext(ctx)

    searches := []Search{Web, Image, Video}
    results := make([]Result, len(searches))
    for i, search := range searches {
        i, search := i, search // https://golang.org/doc/faq#closures_and_goroutines
        g.Go(func() error {
            result, err := search(ctx, query)
            if err == nil {
                results[i] = result
            }
            return err
        })
    }
    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}

 

Этот пакет при любой ошибке просто завершает контекст и в других goroutine его надо обработать, и правильно завершить работу.

Если вы используете другие http api в своем проекте и они могут долго отвечать, можно установить timeout на запрос.
 

func main() {
    request, _ := http.NewRequest(http.MethodGet, "https://4devs.io", nil)
    ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
    defer cancel()
    request = request.WithContext(ctx)

    response, err := http.DefaultClient.Do(request)
    if err != nil {
        log.Fatal(err)
    }
    defer response.Body.Close()
    log.Print(response)
}

Контекст вида context.WithTimeout или context.WithDeadline работают одинаково и возвращают вторым аргументом cancel для освобождения ресурсов если timeout не отработал.

Необходимо не забывать - если вы запускаете фоновую обработку, то контекст ее тоже завершит, даже если вы не будете его обрабатывать, библиотеки его обработают, например `database/sql` откатит транзакцию.
 

package main

import (
    "context"
    "net/http"

    "gitoa.ru/go-4devs/daemon"
)

func Handler(manager daemon.Manager, handler http.Handler) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
        job := daemon.NewJob(func(ctx context.Context) error {
            // do some job in background
            return nil
        }, daemon.WithName("my awesome job"))
        manager.Do(r.Context(), job)

        handler.ServeHTTP(rw, r)
    })
}

 

Передача параметров

Мы уже рассматривали негативные последствия передачи обязательных параметров в context. Но что если например нам для записи логов необходима дополнительная информация, например requestID чтобы как минимум поднять логи в рамках одного запроса и понять что происходит. В этом варианте нам поможет метод context.WithValue:

package main

import (
    "context"
    l "log"
    "net/http"
    "os"

    "gitoa.ru/go-4devs/log"
)

var logger = NewLogger()

type Service struct {
    friends func(ctx context.Context, id int64) ([]User, error)
    logger  *log.Logger
}

func (s *Service) SharedFriends(ctx context.Context, from, to *User) ([]User, error) {
    logger.Info(ctx, "same message")

    return s.friends(ctx, from.ID)
}

func NewLogger() *log.Logger {
    return log.New(
        log.NewStdHandler(l.New(os.Stderr, "", l.LstdFlags), log.LevelInfo),
        func(ctx context.Context, level log.Level, msg string, fields log.Fields, handler log.Handler) {
            if user := UserFromCtx(ctx); user != nil {
                fields = append(fields, log.Field{Key: "user_id", Value: user.ID})
            }
            if requestID, ok := ctx.Value(requestID).(string); ok {
                fields = append(fields, log.Field{Key: "request_id", Value: requestID})
            }
            handler(ctx, level, msg, fields)
        },
    )

}

func MiddlewareRequestID(handler http.Handler) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
        ctx := context.WithValue(r.Context(), requestID, "6a5fa048-7181-11ea-bc55-0242ac130003")

        handler.ServeHTTP(rw, r.WithContext(ctx))
    })
}

func MiddlewareUser(handler http.Handler) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
        // get user
        var user *User

        handler.ServeHTTP(rw, r.WithContext(WithUser(r.Context(), user)))
    })
}

type User struct {
    ID   int64
    Name string
}
type ctxKet uint

const (
    user ctxKet = iota
    requestID
)

func WithUser(ctx context.Context, user *User) context.Context {
    return context.WithValue(ctx, user, user)
}

func UserFromCtx(ctx context.Context) *User {
    return ctx.Value(user).(*User)
}

 

В данном примере мы передаем дополнительные параметры необходимые для отладки в context, и уже сам logger добавляет их, при этом если мы перестанем их передавать у нас просто изменится данные а логах, функциональность приложения при этом останется прежней. Но в самом методе у нас вызывается logger.Info(ctx, "some message"). Если же передавать все варианты в аргументах методов, то получится очень много необязательных параметров во всех методах, что просто невозможно будет поддерживать. Такой же подход используют в opentracing, span создается из контекста span, ctx := opentracing.StartSpanFromContext(ctx, "operation_name") чтобы можно было привязать родительский. Хочу тут напомнить о отмене контекста, так что если вы хотите в фоне запускать команды и при этом привязать родительский span или requestID то необходимо их явно копировать и создавать новый context. Бывают случаи когда приходится в контексте передавать логику, например в приложении или микросервисе у нас стоит уровень логов Warn и выше, а для того чтобы разобраться в одном запросе необходим уровень Info, в этом случае иногда создают логгер для отдельного запроса и передают его в контексте. Возможно это не самый лучший вариант, но позволяет создавать logger в зависимости от контекста и нужно понимать все минусы данного варианта, возможно надо рассмотреть альтернативные варианты.

Выводы

Пакет context позволяет легко развивать и поддерживать приложения если придерживаться некоторых правил:

  • не передавать обязательных параметров
  • это точно не service locator
  • нужно учитывать отмену контекста в фоновые процессах

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

Дополнительные материалы