Minimal go webapp

Cover Image for Minimal go webapp
Tomas
Tomas

Go is an open source programming language designed at Google. It is statically typed, compiled, easy to read and performant language. For full language features check out golang docs. Minimal Fedora 32 installation is used for setting up development environment.

Steps followed to create simple hello-go web app:

  • Install golang
  • Prepare go workspace
  • Create main.go and main_test.go
  • Test and install hello-go web app
  • Setup and start systemd service to run hello-go web app
  • Test hello-go web app locally using curl

On fedora go is available from golang package, use dnf to install golang package:

👾[go-webapp]$ sudo dnf install golang -y
Last metadata expiration check: 0:55:47 ago on Wed 14 Oct 2020 09:04:33 PM UTC.
Package golang-1.14.9-1.fc32.x86_64 is already installed.
Dependencies resolved.
Nothing to do.
Complete!

By default, go code sources and binaries are kept in workspace under GOPATH. Go automatically sets the GOPATH to $HOME/go, more on gopath. Verify go env GOPATH is set and is $HOME/go and create the workspace:

👾[go-webapp]$ go env GOPATH
/home/ansible/go
👾[go-webapp]$ GOPATH=`go env GOPATH`
👾[go-webapp]$ mkdir -pv $GOPATH/{src,pkg,bin}
mkdir: created directory '/home/ansible/go'
mkdir: created directory '/home/ansible/go/src'
mkdir: created directory '/home/ansible/go/pkg'
mkdir: created directory '/home/ansible/go/bin'

Create hello-go package and change directory:

👾[go-webapp]$ mkdir -pv $GOPATH/src/hello-go
mkdir: created directory '/home/ansible/go/src/hello-go'
👾[go-webapp]$ cd $GOPATH/src/hello-go

Create hello-go main to handle http requests and respond with a simple Hello message

👾[go-webapp]$ cat > main.go
package main

import (
    "log"
    "net/http"
    "fmt"
)

func HelloServer(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello")
}

func main() {
    helloHandler := http.HandlerFunc(HelloServer)
    http.Handle("/", helloHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Create HelloServer unit test to make sure response is Hello:

👾[go-webapp]$ cat > main_test.go
package main

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestHelloServer(t *testing.T) {
    request, _ := http.NewRequest(http.MethodGet, "/", nil)
    response := httptest.NewRecorder()
    HelloServer(response, request)

    t.Run("returns Hello", func(t *testing.T) {
        got := response.Body.String()
        want := "Hello\n"
        if got != want {
            t.Errorf("got %q, want %q", got, want)
        }
    })
}

Run tests:

👾[go-webapp]$ go test -v
=== RUN   TestHelloServer
=== RUN   TestHelloServer/returns_Hello
--- PASS: TestHelloServer (0.00s)
    --- PASS: TestHelloServer/returns_Hello (0.00s)
PASS
ok      hello-go        0.004s

Build and install hello-go binary to $HOME/go/bin/hello-go and create systemd service:

👾[go-webapp]$ go install
👾[go-webapp]$ mkdir -pv ~/.config/systemd/user
mkdir: created directory '/home/ansible/.config/systemd'
mkdir: created directory '/home/ansible/.config/systemd/user'
👾[go-webapp]$ cat > ~/.config/systemd/user/hello-go.service
[Unit]
Description=Hello GO webapp

[Service]
Type=simple 
ExecStart=%h/go/bin/hello-go

Start hello-go service and check service status:

👾[go-webapp]$ systemctl --user start hello-go
👾[go-webapp]$ systemctl --user status hello-go
● hello-go.service - Hello GO webapp
    Loaded: loaded (/home/ansible/.config/systemd/user/hello-go.service; stati>
    Active: active (running) since Wed 2020-10-14 22:02:36 UTC; 8s ago
  Main PID: 4835 (hello-go)
      Tasks: 5 (limit: 2344)
    Memory: 964.0K
        CPU: 3ms
    CGroup: /user.slice/user-1000.slice/user@1000.service/hello-go.service
            └─4835 /home/ansible/go/bin/hello-go

Oct 14 22:02:36 go-webapp systemd[585]: Started Hello GO webapp.

Call app locally:

👾[go-webapp]$ curl -iL http://0.0.0.0:8080
HTTP/1.1 200 OK
Date: Wed, 14 Oct 2020 22:02:52 GMT
Content-Length: 6
Content-Type: text/plain; charset=utf-8

Hello

fmt.Fprintln vs io.WriteString

Currently HelloServer function calls fmt.Fprintln(w, "Hello") to write to http.ResponseWriter, other libraries can do the same, perhaps better. One of those libraries is io containing WriteString method. The following section will show basic workflow for refactoring the code. Code profiling is used to verify the changes improve code performance and can be done in following steps:

  • Profile current code
  • Refactor code
  • Profile new code
  • Compare results
  • Keep/Revert changes

Change working directory:

👾[go-webapp]$ cd ~/go/src/hello-go/
👾[go-webapp]$ ls
main.go  main_test.go

Go allows developers to enable code profiling during tests or directly on the running code using pprof package. When adding profiling to the tests, function names have to start with Benchmark. Add BenchmarkHelloServer to main_test.go:

👾[go-webapp]$ cat >> main_test.go
func BenchmarkHelloServer(b *testing.B) {
    request, _ := http.NewRequest(http.MethodGet, "/", nil)
    response := httptest.NewRecorder()

    b.Run("run bench", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            HelloServer(response, request)
        }
    })
}

Run test and collect benchmark data:

👾[go-webapp]$ go test -cpuprofile cpu.prof -bench .
goos: linux
goarch: amd64
pkg: hello-go
BenchmarkHelloServer/run_bench-2                13711460                83.8 ns/op
PASS
ok      hello-go        1.354s

Show cpu profile for fmt.Fprintln:

👾[go-webapp]$ go tool pprof -list fmt.Fprintln cpu.prof | head
Total: 1.24s
ROUTINE ======================== fmt.Fprintln in /usr/lib/golang/src/fmt/print.go
      60ms      1.06s (flat, cum) 85.48% of Total
         .          .    257:// after the last operand.
         .          .    258:
         .          .    259:// Fprintln formats using the default formats for its operands and writes to w.
         .          .    260:// Spaces are always added between operands and a newline is appended.
         .          .    261:// It returns the number of bytes written and any write error encountered.
      10ms       10ms    262:func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
         .      140ms    263:   p := newPrinter()

Refactor HelloServer function to use io.WriteString:

👾[go-webapp]$ cat > main.go
package main

import (
    "log"
    "net/http"
    "io"
)

func HelloServer(w http.ResponseWriter, r *http.Request) {
    io.WriteString(w, "Hello\n")
}

func main() {
    helloHandler := http.HandlerFunc(HelloServer)
    http.Handle("/", helloHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Delete compiled test and collect benchmark data:

👾[go-webapp]$ rm -v hello-go.test
removed 'hello-go.test'
👾[go-webapp]$ go test -cpuprofile cpu-refactored.prof -bench .
goos: linux
goarch: amd64
pkg: hello-go
BenchmarkHelloServer/run_bench-2                24349323                44.3 ns/op
PASS
ok      hello-go        1.317s

show cpu profile for io.WriteString:

👾[go-webapp]$ go tool pprof -list io.WriteString cpu-refactored.prof | head
Total: 1.20s
ROUTINE ======================== io.WriteString in /usr/lib/golang/src/io/io.go
     150ms      760ms (flat, cum) 63.33% of Total
         .          .    285:
         .          .    286:// WriteString writes the contents of the string s to w, which accepts a slice of bytes.
         .          .    287:// If w implements StringWriter, its WriteString method is invoked directly.
         .          .    288:// Otherwise, w.Write is called exactly once.
         .          .    289:func WriteString(w Writer, s string) (n int, err error) {
      50ms      420ms    290:   if sw, ok := w.(StringWriter); ok {
     100ms      340ms    291:           return sw.WriteString(s)

Original fmt cpu profile graph:

Refactored io cpu profile graph:

io.WriteString calls ResponseRecorder directly taking 0.24s, oposed to fmt.Fprintln which calls ResponseRecorder taking 0.34s. fmt.Fprintln also makes calls to 3 additional fmt functions taking additional 0.71s. However, runtime.convI2I function now takes longer- 0.3s compared to 0.14s when using fmt.Fprintln 🤷

Conclusion

On libvirt vm with kvm hypervisor running at 3392.294MHz io.WriteString takes 0.76s and accounts for 63.33% of the total time compared to 1.06s taken by fmt.Fprintln accounting for 85.48% total time spent (almost 25% improvement). Looking at the memory, version with io.WriteString allocates 24349323B (25MB) compared to 13711460B (13MB) allocated when using fmt.Fprintln.

back