Minimal go webapp
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.