Even if creating CLI applications is straighforward with Go, there are many libraries to help with that task, specially to handle subcommands and flags. Cobra is likely one of the more popular ones. It’s relatively simple to use and it’s easy to get something working very quickly.
A very basic example looks something like this:
rootCmd := &cobra.Command{
Use: "demo",
Short: "demo shows how to pass a context.Context to a cobra command",
Run: rootFn,
}
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
To get started, you define a command and then execute it. You define your CLI user interface by attaching additional commands and flags to this initial command.
The Run
field in the cobra.Command
type is a function that gets
called when the corresponding command gets executed. The signature of
that function must be:
func(cmd *Command, args []string)
When called, this function gets a pointer to the command and a slice with all the arguments passed on the command line.
Imagine you have a program like curl
or wget
, where you download
something, possibly large, from the network. It would be useful to be
able to pass a context.Context
to this function, as you could use the
signal
package to catch the various interrupt signals from the OS and
then use context.WithCancel
to obtain a cancel function that can be
called if the interrupt signal is received. Your program would interrupt
the download and clean up any intermediate files.
For the above to work, it would be useful if the Run
function received
a context.Context
and the Execute
method received one, too. In other
words, you would call something like:
if err := rootCmd.ExecuteWithContext(ctx); err != nil {
fmt.Println(err)
os.Exit(1)
}
and the type of the Run
field (or RunWithContext
) would be something
like:
func(ctx context.Context, cmd *Command, args []string)
While current versions of cobra do not have support for this (but see
this PR), it’s still possible
to achieve this by adapting a function that does take a
context.Context
argument into one that does not, like this:
func addContext(func(context.Context, *cobra.Command, []string)) func(*cobra.Command, []string)
This function receives a function taking a context.Context argument and returns one that matches the signature that cobra needs. This is a common pattern in Go programs. The implementation uses a closure to capture the function argument, which is then used in the return function. The simplest version would be:
func addContext(fn func(context.Context, *cobra.Command, []string)) func(*cobra.Command, []string) {
return func(cmd *cobra.Command, args []string) {
ctx := context.Background()
fn(ctx, cmd, args)
}
}
Now you can do:
rootCmd := &cobra.Command{
Use: "demo",
Short: "demo shows how to pass a context.Context to a cobra command",
Run: addContext(rootFnWithContext),
}
This gets you there, but it is less than ideal: the context is hardcoded within the adapter. Fixing this is simple:
func addContext(context.Context, func(context.Context, *cobra.Command, []string)) func(*cobra.Command, []string)
Now the command definition would look like this:
rootCmd := &cobra.Command{
Use: "demo",
Short: "demo shows how to pass a context.Context to a cobra command",
Run: addContext(ctx, rootFnWithContext),
}
Now there’s a different problem: because of the way cobra is structured,
it provides an incentive to define commands as package-scoped
variables and to register commands using init
functions (you know you
should avoid
them).
While this is not a requirement, most documentation, examples and actual
code out there will nudge you in that direction. This means that you
don’t have a context to pass to addContext
at the point where you
define a command.
This problem is solved if you do not define your command as a package-scoped variable, and instead create it within a function that you can call after having created your context. Since implementing this might be awkward, specially with already existing code, you can look at another option.
Consider a type like the following:
type ctxAdder struct {
ctx context.Context
}
Before going further, keep in mind that this goes against the recommended practice:
Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it.
But then again, the standard library as a few instances of
context.Context
stored inside a struct type, for reasons quite similar
to the case presented here. The authors have tried to argue that as long
as you are doing something equivalent to the following you are fine:
type someStruct struct {
ctx context.Context
}
// ...
s := someStruct{
ctx: context.Background(),
}
someFunction(s, ...)
in other words, as along as you are passing the struct instance as you
would be passing the context.Context
one, it’s fine. The key concept
here is that the context value should have a lifespan delimited by the
associated request. Since CLI applications handle a single “request”, it
should be OK to do what I’m describing here.
Back to the problem, with the ctxAdder
type, you can do something
like this:
func (c *ctxAdder) withContext(fn commandWithContext) func(cmd *cobra.Command, args []string) {
return func(cmd *cobra.Command, args []string) {
fn(c.ctx, cmd, args)
}
}
and then:
rootCmd := &cobra.Command{
Use: "demo",
Short: "demo shows how to pass a context.Context to a cobra command",
Run: adder.withContext(rootFnWithContext),
}
now in addition to the target function, the closure also captures the
ctxAdder
instance (adder
in the above code, c
in the
implementation of withContext
).
Now it’s possible to set the context just before running
rootCmd.Execute
:
adder.setContext(ctx)
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
With this implementation ctx
does not need to be a package-level
variable, but, depending on the structure of your program, adder
probably does. The difference is that you can delay creating the context
as much as it makes sense in your program.
A full implementation can be found
here. The code follows the
common structure of programs using cobra in order to show that the
solution is workable without introducing too many modifications to
already existing code. Both cmd1
and cmd2
are defined using the same
adapted Run
function that takes a context as argument, with the
difference that cmd1
receives a context that was defined “too early”
and cmd2
receives a context that was defined in the main
function
of the program and is integrated with a signal handler. Try using both
commands and pressing ctrl-C
to see what happens.
Enjoy!