Go.dev /blog/contex…

In many Go apis, especially new ones, the first argument to a function or method is usually context.context. Context. A context can pass signals between different apis, such as a deadline, a caller’s cancellation signal, or data within the scope of the request. This is used when a library needs to interact directly or indirectly with a database, remote API, and other remote services.

The Context documentation says that the Context should only be passed when each function needs it, not stored in a struct.

This article will use some examples to show why you should pass the context directly rather than store it in another type. We’ll also show you a rare case of storing a context securely in a struct and explain why.

Take context as an argument

Let’s look at an example of passing context as an argument to see the advantages of passing context as an argument:

// Worker adds work to a remote service run
type Worker struct { / *... * / }

type Work struct { / *... * / }

func New(a) *Worker {
  return &Worker{}
}

func (w *Worker) Fetch(ctx context.Context) (*Work, error) {
  _ = ctx // A pre-passed CTX controls the deadline, cancellation, and metadata of the request
}

func (w *Worker) Process(ctx context.Context, work *Work) error {
  _ = ctx // A pre-passed CTX controls the deadline, cancellation, and metadata of the request
}
Copy the code

Here (*Worker).fetch and (*Worker).process both take context directly as their first argument. The user can set deadline, cancel, and metadata for each call. And it makes it very clear how the context is used in each method, so that a context passed to one method is not called in another method. This is because Conetext’s scope is limited to where it is really needed so that the context is useful and clear.

Putting a context in a struct is misleading

Let’s go back to the example above and make a little change, put the context inside the struct. The problem with this is that it obscures the life cycle of the caller or, worse, muddles the scopes of the two.

type Worker struct {
  ctx context.Context
}

func New(ctx context.Context) *Worker {
  return &Worker{ctx: ctx}
}

func (w *Worker) Fetch(a) (*Work, error) {
  _ = w.ctx // The shared w.tx is used to control the deadline, cancellation, and metadata of requests
}

func (w *Worker) Process(work *Work) error {
  _ = w.ctx // The shared w.tx is used to control the deadline, cancellation, and metadata of requests
}
Copy the code

Here (*Worker).fetch and (*Worker).process share a context stored in the Worker. This will make it impossible for the callers of Fetch and Process to specify a deadline, cancel the request, or retrieve metadata because there may be different contexts in a request. For example, you cannot specify a deadline just for (*Worker).fetch, nor can you cancel (*Worker).process. The caller’s lifecycle is disrupted by a shared context, and the context’s lifecycle is the same as the Worker’s.

It’s a little bit more confusing than the way we wrote it before. Users might ask themselves:

  • When creating a new context, how do you know if you need to cancel the request or set a deadline
  • Can the context continue at (*Worker).fetch and (*Worker).process, neither of them? One yes and one no

In this API, we need to explicitly tell the user in the documentation what the context is for. Users may have to read the code to determine the purpose of the context, rather than looking directly at the structure of the API.

Exception: Backward compatibility

When Go1.17 was released, a large number of apis required context additions to ensure API backward compatibility. For example, the Client method in NET/HTTP, Get and Do need to add context. For each external request sent through these methods, the context can be used to pass the deadline, cancel the request, and pass metadata.

There are two backward compatible ways to add support for a context: the first way is to put the context in a struct, as we saw earlier, and the other way is to rewrite a method with a different name and add context parameters. As we discussed in how to ensure module compatibility, the second approach should be superior to the first. But in some cases, this is not possible: your API exposes a large number of methods and then rewrites them all, which can clutter up the code.

The net/ HTTP package takes the first approach, and a good example is provided here. Let’s take a look at the Do method, which is defined like this before adding the context:

// Do sends the HTTP request and returns the HTTP response
func (c *Client) Do(req *Request) (*Response, error)
Copy the code

After Go1.17, if we were to ignore backward compatibility, the definition of Do might look something like this:

// Do sends the HTTP request and returns the HTTP response
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error)
Copy the code

But to protect compatibility, it is important to follow Go’s guarantee of library compatibility. Therefore, the maintainer chose to add a context to http.Request to ensure backward compatibility of the API:


type Request struct {
  ctx context.Context
  // ...
}

// This context is used for the lifecycle of the request
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
  // Simplified for brevity of this article.
  return &Request{
    ctx: ctx,
    // ...}}func (c *Client) Do(req *Request) (*Response, error) 
Copy the code

When you add context support to your API, you can choose to add a context to a struct. However, without breaking the usability and readability of the code, to ensure backward compatibility of the code, you should create a new method like this:


func (c *Client) Call(a) error {
  return c.CallContext(context.Background())
}

func (c *Client) CallContext(ctx context.Context) error{}Copy the code

summary

Context is useful for passing information across libraries or apis in a call stack. But for readability, debuggability, and effectiveness, it must be concise and coherent.

When the context is passed as an argument rather than stored in the context, the user can take full advantage of its extensibility to construct a tree of cancellation, deadline, and metadata information in the call stack, and their scope is very clear when passed as an argument. This makes the code very readable and debuggable.

One thing to keep in mind when designing an API with context: pass the context as an argument, not as a struct.

The text/Rayjun