Mat Ryer · 12 Feb 2020
Mat Ryer · 12 Feb 2020
main
functionI love how simple Go’s entry point main
function is. It is where your code will start when someone runs your program.
package main
func main() {
// stuff
}
However, main
is difficult to test, and it’s not clear how we access the environmental dependencies our program has, such as stdin, stdout, the command line args, the environment variables themselves, etc.
The os
package gives us os.Args []string
, os.Stdin io.Reader
, os.Stdout io.Writer
, os.Stderr io.Writer
, and os.Environ() []string
among others. We can use these variables and functions to wire our program up to the operating system. For example, we’ll write output to os.Stdout
, errors to os.Stderr
and use the environment variables from os.Environ()
(or more likely the os.Getenv
helper).
If we want to access the arguments that were passed in when our program was started, we have the global os.Args
slice.
If you run your myapp
program like this:
myapp arg1 arg2 arg3
The os.Args
slice will be:
[]string{"myapp", "arg1", "arg2", "arg3"}
This is all well and good until you want to write tests for your main
function. You might be tempted to directly manipulate the os.Args
slice, or else you’ll be forced to use exec.Command
to actually execute your program (the latter being a more sensible option).
It might have been nice if the main
function took the args, readers, and writers in as function arguments, so you wouldn’t need to remember where they were kept, and they would be easier to test.
It might allow us to write code like this:
func main(args []string, stdin io.Reader, stdout, stderr io.Writer) {
// use the arguments as normal
}
Remember that we aren’t allowed to call main
from our test code, so we would be stuck here even if Go was designed this way.
Is there anything more useful our main
function could return?
Programs exit with an exit code, a number that informs the operating system whether our program was a success (zero) or otherwise (non-zero).
Some have suggested that it would be good if the main
function returned an int
; the exit code. So you’d return a zero if everything was ok, otherwise some other number, which is hopefully described in your docs.
It might look like this:
package main
const (
exitOK = 0
exitFail = 1
)
func main() int {
ok := doSomething()
if !ok {
return exitFail
}
return exitOK
}
Others have suggested, that since this is Go code after all, we should return an error
.
In this world, nil
would be translated into a zero exit code, and a non-nil error would be some non-zero value.
Then we could write code like this:
func main() error {
if err := doSomething(); err != nil {
return err
}
return nil
}
This would be nice, especially when we have lots of exit points in our main program. Consider how bloated the following code would get if we have to handle the error each time rather than just return it.
if err := doSomething(); err != nil {
// todo: handle error
}
if err := doSomethingElse(); err != nil {
// todo: handle error
}
db, err := setupDatabase()
if err != nil {
// todo: handle error
}
defer db.Close()
We can build our own small abstraction by adding a little run
function, and immediately calling out to that in main
.
We will take in any environmental dependencies we have as arguments, and we’ll return an error.
Let’s imagine a little greeter program takes in names via arguments, writing the greetings to os.Stdout
.
const (
// exitFail is the exit code if the program
// fails.
exitFail = 1
)
func main() {
if err := run(os.Args, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(exitFail)
}
}
func run(args []string, stdout io.Writer) error {
if len(args) < 2 {
return errors.New("no names")
}
for _, name := range args[1:] {
fmt.Fprintf(stdout, "Hi %s", name)
}
return nil
}
Here, our main
function just calls run
, in all the things our program needs; the args []string
and the stdout io.Writer
.
If run
returns an error
, we write it to os.Stderr
and exit with code 1
, otherwise if run
returns nil
, we exit peacefully.
Now that our run
function is isolated from main
, we can test it just by calling it like a regular method.
For io.Writer
we can use a bytes.Buffer
which will allow us to peek inside what would normally be written to stdout and make assertions about it:
import (
"testing"
"github.com/matryer/is"
)
func Test(t *testing.T) {
is := is.New(t)
args := []string{"greeter", "David", "Kat", "Jon", "Natalie", "Mark"}
var stdout bytes.Buffer
err := run(args, &stdout)
is.NoErr(err)
out := stdout.String()
is.True(strings.Contains(out, "Hi David"))
is.True(strings.Contains(out, "Hi Kat"))
is.True(strings.Contains(out, "Hi Jon"))
is.True(strings.Contains(out, "Hi Natalie"))
is.True(strings.Contains(out, "Hi Mark"))
}
func TestNoNames(t *testing.T) {
is := is.New(t)
args := []string{"greeter"}
var stdout bytes.Buffer
err := run(args, &stdout)
is.True(err != nil)
}
We can use flags inside the run
function using the flag.NewFlagSet
function and avoid using global flags altogether.
flags := flag.NewFlagSet(args[0], flag.ExitOnError)
var (
verbose = flags.Bool("v", false, "verbose logging")
format = flags.String("f", "Hi %s", "greeting format")
)
if err := flags.Parse(args[1:]); err != nil {
return err
}
Test code can set any flags they like when calling run
by passing in different args
:
err := run([]string{"program", "-v", "-debug=true", "-another=2"})
This allows you to write tests covering different flag usage too.
I don’t think that’s necessary. Using normal Go code, we can write these tiny abstractions ourselves. Doing so also helps storytelling around your code; it’s obvious what your run
function needs to do its job.
Consider rolling your own tiny abstraction each time to write a Go program.
It allows us to:
run
like a normal functionstdin io.Reader
it’s obvious that our program is going to read from stdin - and in test code, we can just use strings.NewReader
to spoof stdin content)A lot of our blog posts come out of the technical work behind a project we're working on called Pace.
We were frustrated by communication and project management tools that interrupt your flow and overly complicated workflows turn simple tasks, hard. So we decided to build Pace.
Pace is a new minimalist project management tool for tech teams. We promote asynchronous communication by default, while allowing for those times when you really need to chat.
We shift the way work is assigned by allowing only self-assignment, creating a more empowered team and protecting the attention and focus of devs.
We're currently live and would love you to try it and share your opinions on what project management tools should and shouldn't do.
What next? Start your 14 day free trial to see if Pace is right for your team
or you can share the URL directly:
https://pace.dev/blog/2020/02/12/why-you-shouldnt-use-func-main-in-golang-by-mat-ryer.html
Thank you, we don't do ads so we rely on you to spread the word.
If you have any questions about our tech stack, working practices, or how project management will work in Pace, tweet us any time. :)
— pace.dev (@pacedotdev) February 25, 2020
Tiny abstractions with functions in Go #golang #concepts #coding #patterns
Respond to Ctrl+C interrupt signals gracefully #Golang #Patterns
How code generation wrote our API and CLI #codegen #api #cli #golang #javascript #typescript