The old way to the modern web services
Yes, modern web development is complex. Frameworks change each other, cognitive load increases, hype-driven development introduces new risks that lead to “the Big Rewrite”, which at the end rarely goes well. But does it have to be this way?
Looking back, we may notice that the websites written in plain HTML+CSS still open and work perfectly. Boring technologies survive. Similarly, web services built with a dull tech stack are surprisingly robust.
Here’s my approach to keeping things simple and boring, while absorbing the advantages of modern tooling where possible.
Go
Go is a nice language. Pragmatic, fast, stable, easy to use. My web services written in Go five years ago still work without any changes. Yes, a language that is only 11 years old can hardly be known as “old”, but it feels very dull, readable, obvious, and productive in many use cases, especially backend development.
Here I will be talking about Go 1.16, which is about to become the newest release (at the moment of writing these lines). It is compatible, as always, with all the past versions, but also introduces the native asset embedding, pluggable file systems, and many other cool features, that will be covered a bit later.
Web Frameworks
First of all, you might not need micro-services - starting with a monolith would probably lead you to a working product much sooner. But even with monoliths you still have to lay the ground for your work and choose a web framework.
There is nothing wrong with using labstack/echo, gin-gonic or similar frameworks. I used both in the past, and they had proven themselves to be very stable, powerful and, pleasant. But if you aim for ultimate minimalism - you can go with just a standard library. I tend to drop a single web.go
file that implements a “good-enough” framework that I can extend to my needs:
// Middleware wraps given http.Handler adding some extra functionality
type Middleware func(next http.Handler) http.Handler
// Logger middleware logs every incoming request
func Logger() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s -- %v", r.Method, r.URL.Path, time.Since(start))
})
}
}
// Recovery middleware handles panics inside handlers
func Recovery() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
w.WriteHeader(http.StatusInternalServerError)
debug.PrintStack()
}
}()
next.ServeHTTP(w, r)
})
}
}
// Hook middleware runs a function on each request, can be used to reload templates, build frontend etc
func Hook(f func()) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f()
next.ServeHTTP(w, r)
})
}
}
// Use applies multiple middleware to the given handler
func Use(h http.Handler, mw ...Middleware) http.Handler {
for i := len(mw) - 1; i >= 0; i-- {
h = mw[i](h)
}
return h
}
If you look in the sources of Gin or Echo, you will find very similar middleware, just with more configurable options. Depending on the service you are building, you might achieve better results with your own middleware.
But of course, if your web service becomes too large and complex - switching to a real battle-tested framework could bring some benefits, but you don’t have to use one only because everybody else does.
Static assets
If you ever used Go in the past - you would know that dealing with static assets is a pain. Go build system does not cover it, so you have written custom build scripts that either recompiled your binary every time you changed a line of CSS, or created a set of interfaces to switch between reading files from disk (“dev” mode) vs using embedded assets.
With Go 1.16 you have this functionality in the standard library:
//go:embed assets
var assets embed.FS
...
mux.Handle("/assets/", http.FileServer(http.FS(assets)))
This includes all files from the ./assets/
directory as the assets
file system in your code. Custom file systems can be used for static file handlers, templates, and every other part of the stdlib. Embedding such assets is actually very fast so you can rebuild your binary on every file change. You may use existing wrappers, like gow
, or create your own one if you use custom a build script anyway:
trap 'exit' INT TERM ; trap 'kill 0' EXIT
while inotifywait -rqe modify . ; do
go1.16beta1 build -o example || continue
kill $PID
./example &
PID=$!
w=$(xdotool getwindowfocus)
xdotool search --onlyvisible --class "Chromium" windowfocus key 'F5'
xdotool windowactivate $w
done
The main loop waits for changes, rebuild the binary, kills the old one, and restarts the web service. It also uses xdotool
to find your browser and reload the page. On macOS there is xdotool
, but you may use the following snippet instead:
osascript -e 'tell application "Google Chrome"
reload active tab of window 1
end tell'
Again, find the right tool for the job, you might use existing solutions or you might want to reload the page manually to keep things simple.
Frontend
As we speak about browsers, we should probably talk about the frontend. Single-page applications are cool, but often not needed. Similarly, you might really need server-side rendering, as most search engines handle javascript just fine.
What I found to be a good compromise - is to use Go templates to fill the HTML page with some JSON data and use JavaScript to render it. In this case, the page loads almost immediately, but you may still use a frontend framework of your choice and split your app into components with proper tests and so on.
My current frontend stack is neither fancy nor large. I use Preact, because I’m used to React philosophy and I like small libraries. I tried both, Preact+JSX and htm bundle. The latter allows you to skip the compilation step and use JavaScript just as you write it.
If you need a compilation step - the recent marvel I discovered is esbuild, which truly is the fastest bundler for the frontend. In fact, it is written in Go, so you may use it as a library and let your service compile/minify the assets on start. Since esbuild itself is blazing fast, and embedding is barely noticeable - you can afford to do both on every file change.
Isn’t it actually sweet when developer tools are made with performance in mind?
Deploy
Finally, your webservice is running locally and you want to expose it to the world. I see two major options if you use Go - deploy a Docker image, or deploy it as a good old system service.
For Docker there’s not much to be said, just put your binary into the Alpine image (or even a “scratch” one if that works for you). Docker option is preferred when your service needs other dependencies like PostgreSQL or Redis - in this case, you may use docker-compose
or something similar.
The system service option is good for smaller self-contained services. In this case, create a typical myservice.service
for systemd to recognize your service, and use commands like service myservice stop
/ service myservice start
to control it and journalctl -u myservice -f
to watch the logs. This is probably as basic as you can get, deploy and backup with rsync
, control with shell scripts, and rely on the OS instead of “yet-another-modern-Go-tool” in between.
Summing up
With all this in mind you can jump from an idea to a working service in a couple of days, and most of the time you would spend actually writing your business logic instead of messing up with the new hot framework or k8s.
Of course, your service could be very unique and might deserve a proper architecture and infrastructure from the very beginning, but I personally find this world complex enough to make every web service complicated.
I hope you’ve enjoyed this article. You can follow – and contribute to – on Github, Mastodon, Twitter or subscribe via rss.
Jan 20, 2021
See also: Linux containers in a few lines of code and more.