Writing a service in Go with multiple HTTP REST servers
A Go service that incorporates multiple HTTP REST servers using just the built in standard library.
Motivation
Most software engineers are likely to need to build HTTP REST services in their day job, or use them to explore ideas in their personal projects. We are likely to pick off the shelf libraries that take care of most of the boiler plate for us — for example where I work I only need to implement the endpoints and business logic, everything else is prepackaged, tested and maintained by another team. In most cases this is what you want — coding at the end of the day is a means to an end, and just like a hammer, you don’t need to know how it’s built, you just need it to work when it’s used.
It is still quite interesting to understand how lower level abstractions are built, which can help us become more aware of what’s going on at those levels, which in turn can influence how we code to get more out of the services we build (mechanical sympathy).
So this is an exploration on how to build a HTTP REST service in Go using just the standard library (so the go.mod
file will not declare any external dependencies).
The RESTful Go Service
To follow along it’s best you have a basic understanding of Go. If you’re new to Go why not check out the following to get a better understanding of it:
- The official Go tour: https://tour.golang.org/welcome/1
- Learning Go with tests (a personal favourite of mine): https://quii.gitbook.io/learn-go-with-tests/
- Concurrency in Go
- Using Goroutines, Channels, Contexts, Timers, WaitGroups and Errgroups: https://medium.com/@ankur_a22/using-goroutines-channels-contexts-timers-waitgroups-and-errgroups-24b6062c1c93
Hello World
Yep, let’s start from the basics where we all start from when we’re learning something new:
Pretty straight forward so far, but it’s missing the ability to serve REST APIs over HTTP.
Listen and Serve
Ok, so now let’s start by creating the basic server which will return 404
when we cURL the address:
Here we’ve created a server that is listening on the loop back IP address 127.0.0.1
(also known as localhost) on port 8080
. Try cURLing the server with:
$ curl 127.0.0.1:8080 -v
* Trying ::1...
* TCP_NODELAY set
* Connection failed
* connect to ::1 port 8080 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 404 Not Found
< Content-Type: text/plain; charset=utf-8
< X-Content-Type-Options: nosniff
< Date: Sat, 24 Apr 2021 16:05:38 GMT
< Content-Length: 19
<
404 page not found
* Connection #0 to host localhost left intact
* Closing connection 0
That’s the basic server. You can shut it down by sending it a sigint
(ctrl+c
).
Hello World Server with a http.Handler
It’s not very friendly at the moment, so let’s make it return a familiar message on a new endpoint:
We’ve created a http.Handler
which implements ServeHTTP
that will respond to a GET
on the /hello
endpoint. All other requests to anything but /hello
, will return a 404
.
This isn’t actually very maintainable, and requires quite a lot of boiler plate as well as having to handle the mapping of the endpoint to the handler code with if
statements.
Later we will take a look at when http.Handler
can be useful — for adding middleware components which can be used to intercept all incoming requests (and outgoing responses) and perform global operations on all requests such as:
- authentication;
- add trace and span ids to the request context (
req.WithContext
); - record duration of requests.
Hello World with http.Handle
We’re only interested in handling the /hello
endpoint and nothing else, so let’s make this simpler and use http.Handle
:
Instead of setting a http.Handler
in ListenAndServe
we’re mapping the endpoint /hello
to the type that handles http.Handle
. This is cleaner, more maintainable and less code than the previous example, it also has a nice side effect of making it clear as to what the endpoints this service handles.
Hello World with http.HandleFunc
We can make it even simpler still, and not have the requirement of having to create a type that implements ServeHTTP
:
The ServeHTTP
method can be renamed to hello
(to help clarify which endpoint it handles) and we need it to match the expected function signature that is required by http.HandleFunc
(which happens to be the same as ServeHTTP
).
Different RESTful Methods
Ok, so we can get a nice message back from our server, but sometimes we want to be able to POST
or use other RESTful method types. We can do this using a switch
statement in our hello
function:
Now we’re getting closer to a fully functioning stateful server! At the moment POST
just responds with another message to differentiate from theGET
. Try it with cURL:
$ curl --request POST localhost:8080/hello
Thanks for posting to me
The Middleware Pattern
Let’s just quickly explore the middleware pattern that we came across earlier. As I mentioned before, it’s a useful pattern where by we can extend the functionality of something without changing the existing implementation — you may recognise this as one of the SOLID principles, the open closed principle.
Let’s simulate a scenario where we’re retrieving the user details given the request, and we also want to record the request duration; we will later get the user information from the context
in the hello
function. The start time will be set in the request context and later retrieved and used to calculate the request duration. This could/should be done in two different middleware types to separate the concerns — another SOLID principle, the single responsibility principle:
You might be wondering what the DefaultServeMux
is and why we need it as a member variable in the middleware
type. A ServeMux
“matches the URL of each incoming request against a list of registered patterns and calls the handler for the pattern that most closely matches the URL” — from the official documentation as they explain things better.
So in our example before with serverHandler
we were essentially creating a custom ServeMux
where we handle the mappings ourselves. The DefaultServeMux
is the default ServeMux
that is used to register new endpoints when we use http.HandleFunc
. By passing ListenAndServe
a http.Handler
we’re letting Go know that we want all requests to go through our handler instead of DefaultServeMux
. What we want is to place a couple of values on the request context, and still use the DefaultServeMux
to handle the endpoint mapping.
Why are we passing DefaultServeMux
in to middleware
and not just retrieving it when we need it with http.DefaultServeMux
?DefaultServeMux
implements http.Handler
, and this means we can chain many different middleware components as long as they all implement http.Handler
, so middleware
doesn’t care or know what’s happening next, and is just concerned about it’s own job — just good bit of decoupling.
Custom HTTP Server
The default http server that is created when we use http.ListenAndServe
is probably good enough for our side projects, but probably not for things we want to productionise. We can create a custom http server by creating a new http.Server
in the main
function:
There’s many more arguments that can be set in the http.Server
type, take a look in the documentation. We’re still using the default ServeMux
but in the next section we’ll create our own.
Multiple RESTful Servers
Ok so now we have our hello service up and running, but what if we wanted to grab diagnostics, metrics and expose an admin endpoint? We could easily add those to endpoints to the existing server, but generally it’s safer to add a new server that listens on a different port and firewall that port to only be accessible locally and not over the public internet — this is a bit far fetched for our simple server, but this is a fun exercise. Let’s do this one step at a time:
Create Our Own ServeMux
Let’s first step away from using the DefaultServeMux
and use our own so that we don’t accidentally mix the hello endpoint code with the admin endpoint code:
Create an Admin ServeMux and Server
Almost a C&P of the above. Let’s change the port to 8081, and create a new endpoint /ping
that will respond with pong, so it’s a simple liveness check:
Depending on the order the servers are declared in the main
function, you might only be able to get a response from one and not the other. I’ve declared the admin server last and I can’t cURL on ping
:
$ curl localhost:8081/ping
curl: (7) Failed to connect to localhost port 8081: Connection refused
Goroutines
Ok, time to bring out the big guns, or rather the tiny goroutines
. Let’s place the two servers in their own goroutine
, that’ll allow both the servers to run:
That’s not going to work — the main goroutine
that started the sub goroutines
exits and forces the service to shutdown. So let’s remove the go
keyword from the last ListenAndServe
.
Great, and now we can cURL both the hello
and ping
endpoints on the two different servers via different ports:
$ curl localhost:8080/hello
Hello unknown
$ curl localhost:8081/ping
pong
WaitGroups
There’s a bit of a smell here — it feels wrong to block on the last ListenAndServe
, although it works, it’s just not explicit or clear enough as to what’s going on. We should place the servers in their own goroutines
and have the main goroutine
wait until they shutdown before shutting the whole service down, we can then also add logs and other cleanup code just before the service shuts down:
Now both servers run, we can cURL the endpoints and when we sigint
it shuts down, but wait, where’s the log line after wg.Wait()
? A quick bit of debugging shows that the there was an exit code 1 when the server shutdown, so ListenAndServe
must be calling os.Exit(1)
somewhere.
$ go run main.go
Starting server
$ $?
zsh: command not found: 1
Signals
We want more control over the shutdown process so that we can log a message before the service shuts down. So we will need to use the signal
package to help us with this:
So we’ve removed the WaitGroup
as it wasn’t actually helping to orchestrate the shutting down of the goroutines
. Instead we’re orchestrating the shutdown with the use of a sigterm
signal and the context. After creating the context
we register the sigint
signal onto the new channel
of type os.Signal
. The service is now registered and hooked into sigterm
signals whereby it can then action on any cleanup code and logging before gracefully shutting down the service — now we’re in charge of actioning on sigterm
.
We’ve deferred the shutdown of the servers so that they are called at the end. The issue with Shutdown
is that it may wait indefinitely for live connections to close. To help prevent this we’ve used the context.WithCancel
to force it to showdown. We could use context.WithDeadline
and give the servers a sensible amount of time to let the live connections to complete what they’re doing and gracefully close.
The Complete File
The completed service implementation:
We’re still missing many parts that may interest you, such as:
- Path parameters;
- Query parameters;
- Reading from the request body;
- Writing to the response body;
- JSON/XML parsing;
- TLS;
- Go HTTP client;
Like with the server code, all this (and more) can be achieved using Go’s standard library.