Pocket Gophers

Exploring alternatives with go run

go1.9

There’s more than one way to do most things. At some point you need to choose which way to do that thing, which means you need to compare the alternatives. At the same time, you can’t spend lots of time building infrastructure to do the comparison.

Quickly explore many alternatives by:

  1. Putting most of your code in main.go
  2. Putting each alternative in a separate file (e.g., alternative1.go)
  3. Running each alternative with go run main.go alternative1.go
  4. Comparing the results

I used this method to survey 10 packages to instrument your Go application and to build examples for my upcoming Pocket Gophers’ Guide to JSON. I will further explain the method with an example from that guide.

The Example

I have a JSON array with heterogeneous contents:

example.json
[
	["Gopher Plush", 5],
	["Gopher Sticker", 77]
]

And want to unmarshal it into an slice of Items:

type Item struct {
	Name     string
	Quantity int
}

Each alternative needs to correctly unmarshal the JSON and will be compared using Go’s benchmarking tools.

This is a good example because it demonstrates the difficult part (unmarshaling from a heterogenous JSON array into a struct) without overwhelming the reader with unnecessary detail. I also know two ways to unmarshal the JSON.

When you are doing this yourself, make sure your test cases are as realistic as possible. This is important as your experience implementing the alternatives along with whatever performance criteria you use will influence your choice.

The code for this example can be gotten with:

go get -d pocketgophers.com/exploring-alternatives-with-go-run

Writing main.go

Most of your code should be in main.go. It should have everything that is not alternative specific.

main.go
package main

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"testing"

	"github.com/google/go-cmp/cmp"
)

type Item struct {
	Name     string
	Quantity int
}

func unmarshal(b []byte) ([]Item, error) {
	var items []Item
	err := json.Unmarshal(b, &items)
	if err != nil {
		return nil, err
	}

	return items, nil
}

func main() {
	log.SetFlags(log.Lshortfile)

	wanted := []Item{
		{Name: "Gopher Plush", Quantity: 5},
		{Name: "Gopher Sticker", Quantity: 77},
	}

	example, err := ioutil.ReadFile("example.json")
	if err != nil {
		log.Fatalln(err)
	}

	// test unmarshal
	object, err := unmarshal(example)
	if err != nil {
		log.Fatalln(err)
	}
	if !cmp.Equal(object, wanted) {
		log.Fatalln(cmp.Diff(object, wanted))
	}

	// benchmark unmarshal
	// need more than one result for benchstat
	for i := 0; i < 5; i++ {
		bResult := testing.Benchmark(func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				_, err := unmarshal(example)
				if err != nil {
					b.Fatal(err)
				}
			}
		})
		fmt.Printf("BenchmarkUnmarshal\t%s\t%s\n",
			bResult, bResult.MemString())
	}
}

This code compiles, but does not run without error:

$ go run main.go
main.go:44: json: cannot unmarshal array into Go value of type main.Item
exit status 1

Your main.go should either not compile without one of the alternatives or error at runtime. I will usually create a skeleton alternative that implements just enough for it and main.go to compile. This skeleton gives a place for documenting what an alternative is expected to do along with giving a starting place for implementing a new alternative.

In this case, my alternatives just need to implement func (item *Item) UnmarshalJSON(b []byte) error, so there is no compile-time check to make that fail. If all the alternatives didn’t use the same unmarshal method, I would move that function to the alternatives.

Implementing the alternatives

The first alternative unmarshals into []interface{} and then assigns the values into the correct struct fields, performing the required type assertions:

messy.go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
)

func (item *Item) UnmarshalJSON(b []byte) error {
	var tmp []interface{}
	err := json.Unmarshal(b, &tmp)
	if err != nil {
		return err
	}

	if got, expected := len(tmp), 2; got != expected {
		return fmt.Errorf("expected length %d, got %d", expected, got)
	}

	// now handle every field

	name, ok := tmp[0].(string)
	if !ok {
		return errors.New("Name is not a string")
	}
	item.Name = name

	quantity, ok := tmp[1].(float64)
	if !ok {
		return errors.New("Quantity is not a number")
	}
	if float64(int(quantity)) != quantity {
		return errors.New("Quantity is not an int")
	}
	item.Quantity = int(quantity)

	return nil
}

The second alternative builds an []interface{} with pointers to the struct fields. This allows encoding/json to handle the type checking.

clean.go
package main

import (
	"encoding/json"
	"fmt"
)

func (item *Item) UnmarshalJSON(b []byte) error {
	tmp := []interface{}{&item.Name, &item.Quantity}
	expected := len(tmp)

	err := json.Unmarshal(b, &tmp)
	if err != nil {
		return err
	}

	got := len(tmp)
	if got != expected {
		return fmt.Errorf("expected length %d, got %d", expected, got)
	}

	return nil
}

Running the alternatives

Run the alternatives is straight-forward. Here I also save the benchmark results so I can use them with benchstat later.

$ go run main.go messy.go | tee messy.txt
BenchmarkUnmarshal	  100000	     14808 ns/op	    1408 B/op	      25 allocs/op
BenchmarkUnmarshal	  100000	     14642 ns/op	    1408 B/op	      25 allocs/op
BenchmarkUnmarshal	  100000	     14613 ns/op	    1408 B/op	      25 allocs/op
BenchmarkUnmarshal	  100000	     14608 ns/op	    1408 B/op	      25 allocs/op
BenchmarkUnmarshal	  100000	     14636 ns/op	    1408 B/op	      25 allocs/op
$ go run main.go clean.go | tee clean.txt
BenchmarkUnmarshal	  100000	     11748 ns/op	    1248 B/op	      19 allocs/op
BenchmarkUnmarshal	  100000	     11659 ns/op	    1248 B/op	      19 allocs/op
BenchmarkUnmarshal	  200000	     11787 ns/op	    1248 B/op	      19 allocs/op
BenchmarkUnmarshal	  200000	     11807 ns/op	    1248 B/op	      19 allocs/op
BenchmarkUnmarshal	  100000	     11650 ns/op	    1248 B/op	      19 allocs/op

Comparing the results

First, the output from running benchstat -html messy.txt clean.txt:

messy.txtclean.txt
time/opdelta
Unmarshal14.7µs ± 1%11.7µs ± 1%−19.99%(p=0.008 n=5+5)
 
alloc/opdelta
Unmarshal1.41kB ± 0%1.25kB ± 0%−11.36%(p=0.008 n=5+5)
 
allocs/opdelta
Unmarshal25.0 ± 0%19.0 ± 0%−24.00%(p=0.008 n=5+5)
 

Benchmarks aren’t the only result. We also have the implementations to compare. Make sure and look closely at the code. In this case, we can see that clean.go is shorter, automatically handles type checking/assertions, and requires fewer changes should fields be added to Item.

If you are comparing packages, pay attention to the APIs. Does the API fit what you needed to do? Or are you fighting it? Which alternative is easier to read?

Dig into the Fundamentals of Go

Subscribe to receive a weekly email covering a Go fundamental. Be it the language, its tooling, or its packages, you will learn what you need to know.