Why you shouldn't use func main in Go.

Mat Ryer · 12 Feb 2020

Why you shouldn't use func main in Go.

Mat Ryer · 12 Feb 2020

About the main function

I 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.

Accessing arguments, stdin, stdout, stderr, flags, 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?

The exit code of a program

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()

Solution: A mini abstraction

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.

Testing is easy now

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)
}

Working with flags

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.

Should someone make a third-party package for this?

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.

Conclusion

Consider rolling your own tiny abstraction each time to write a Go program.

It allows us to:

  • Just return errors
  • Pass in only the things we need
  • Write tests that just call run like a normal function
  • Tell good stories around our code (if we have stdin 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)

Learn more about what we're doing at Pace.

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


First published on 12 Feb 2020 by Mat Ryer
#Golang #Patterns

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.

https://pace.dev/blog/2020/02/12/why-you-shouldnt-use-func-main-in-golang-by-mat-ryer.html


You might also like:

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

Subscribe:
Atom RSS JSON