While the "niceness" of a language is important for development, maybe what's more important is the tooling behind the language and package management. An application isn't worth much if the developers aren't able to reliably build it, test it and deploy it. While I personally like Go's syntax and the fact that you can be productive with just a day's worth of playing with it, I think what makes it a really nice technology is the fact that you can trivially deploy your apps with minimal number of files (ideally 1) and easily set up a reproducible build environment anywhere.
Table of contents
Open Table of contents
Build system
We'll be using a more higher level build system here, rather than the standard Go compiler. We'll use Bazel, and if you'd like some background reading for Bazel, please consider checking out this article. That article is fairly in-depth, though, and is not necessary to follow this article. Bazel's syntax is very straightforward that you can understand the configs we'll be using without any background knowledge.
You may say at this point that the title is misleading because it says "no tooling", but I would argue against that. We can use Bazelisk to do our Bazel builds, which is a single file you can download from Github. It's the official and recommended way to get started with Bazel. Therefore, what I meant by "no tooling" here is that nothing needs to be installed on your system, and you can simply fetch the Bazelisk executable to get started -- this in my view really doesn't count as any tooling.
The reason why Bazelisk is so nice is that you can even ship it with your code repository so that whoever checks out your repo really doesn't need to have anything pre-installed on their system to get started with your code.
The biggest win here is for the automated build and test environments. You could simply add this file to a Docker container you use for your build and test flows, and thing should just work (as long as there is Internet access). You don't even really need to build any special Docker images for this, you can just pass the file through. That said, if you want to go via very safe route, there is an official Docker image too for running Bazel.
If all this sounds interesting to you, then definitely consider reading the longer article about Bazel linked at the beginning of this section.
Speaking of containers, a follow up article will be about how to make your application containers using Bazel, again, no tooling required.
Code repository
You can check out the code repository for this exercise here. I'll be referring to the code from that repo below.
Reproducible Go builds
We'll be using the newly introduced Bazel modules here to set up the Go build. Note: Bazel will handle the whole Go build flow for you, you do not need to install Go on your system and Bazel will not pollute your host with Go installations -- it's all tucked away separately.
Traditionally, Bazel relied on dowloading archives via HTTP, but the syntax for that was a bit clumsy (in my view) and the new Bazel modules are a much more concise way to get things done there.
Our MODULE.bazel file sets up our Go tooling and Bazel "rules" like this:
bazel_dep(name = "rules_go", version = "0.46.0")
bazel_dep(name = "gazelle", version = "0.35.0")
go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.22.0")
go_sdk.host()
That's it! 5 lines to set up your reproducible Go toolchain and rules for Bazel -- when you build this codebase elsewhere now, the experience should be byte-for-byte consistent.
Go server with no external dependencies
I really like the fact that Go requires no external libraries in order to write nice HTTP servers. There are frameworks that build on top of it, of course, and offer considerable comfort, but the standard library does an amazing amount of things already. Let's check out the BUILD file:
load("@rules_go//go:def.bzl", "go_binary")
go_binary(
name = "server",
srcs = [
"server.go",
],
)
And the Go code for the server is:
package main
import (
"flag"
"fmt"
"log"
"net/http"
)
type routeServe struct {}
func (rs *routeServe) ServeHTTP(w http.ResponseWriter, req *http.Request) {
id := req.PathValue("id")
w.Write([]byte(fmt.Sprintf("Hello %s", id)))
}
func main() {
port := flag.Int("port", 12345, "Port number to be used for HTTP")
flag.Parse()
servingHandler := &routeServe{}
httpMux := http.NewServeMux()
httpMux.Handle("GET /hello/{id}", servingHandler)
server := &http.Server{
Addr: fmt.Sprintf(":%d", *port),
Handler: httpMux,
}
if err := server.ListenAndServe(); err != nil {
log.Fatalf("Error running HTTP: %v\n", err)
}
}
Even though Go tooling provides a really nice way to deal with external packages, we didn't even need to use them here. From my software engineering experience, I'd much rather rely on the standard library than an external package, if a choice is reasonable between the two.
Note on cross compilations
I think in the upcoming months and years cross compilations will be more and more important. For people working with embedded systems, this is just another day in the office, but I think in general people that deploy any sort of web apps to public cloud will have to think about this as well.
There are lot of up and coming ARM-based cloud platforms and if we want to target them while building on our x86 machines for example, cross compilation would need to happen in a language like Go. I admit that Bazel's appraoch to cross compilation may be a little verbose, so I will omit it here (you can check how to build Linux initramfs for multiple platforms with Bazel if you're curious, though it may be a bit of an advanced reading for an absolute Bazel beginner), but if using the standard Go tooling, it's just a matter of passing environment variables like GOARCH to the tooling in order to switch your target platform. I was recently able to make a Go binary run even on $5 bucks worth of hardware and really all it took was just passing a few environment variables, a single-line. Contrast that with GCC where you need to get an entirely separate toolchain...
Deployment
Now you can simply build the binary:
bazel build //server
Bazel will give you an output file that is ready to be deployed. By default, I don't expect this file to depend on anything but the standard C library (though I think you can statically link that in as well if you don't mind the extra binary size). This is all you need to deploy your app.
Conclusion
I hope you enjoyed this short article and I hope I managed to convince you that Go + Bazel is a really nice combination for your server workflows.
Please consider following on Twitter/X and LinkedIn to stay updated.