I. How HTTP Servers are Put Together in Go
I spent a week staring at the Caddy and Traefik code bases trying to understand how they were put together. While certainly a bunch of things started making sense (who spends a week staring at a code base?!), there were specific questions I couldn't find answers to.
Eventually I got to an understanding where I could ask a coherent question on Caddy's community forum about Caddy.
Matt (creator of Caddy) was generous enough to give me an extremely solid answer, pointing out certain parts of the stdlib http
module that did what I was asking about. It dawned on me that my grasp on how Golang handles HTTP was more tenuous than I thought.
So I set about seeing what I could understand! And I wrote it down. So,here's some ċ̷͍͔o̷̯͓̓̒n̸̨͍̫͒͛t̷̛̫̞̃̀̓̄ë̴͇̜͈̗͉́͗̽ń̸̞̮͈͎t̴̝͉̹̤̖͌͗.
The simplest lil' HTTP server
Golang handles HTTP natively. There's a lot that goes into that - http/1, http/2, h2c, TLS, websockets, trailer headers, upgrading connections - the list goes on!
But we're not there yet. Let's just make a very simple web server:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(writer http.ResponseWriter, r *http.Request) {
// Send a 200 response. This is technically
// superfluous as it implicitly sends a 200
// when we write any response to the writer
writer.WriteHeader(200)
// Here we write "HELLO" to the ResponseWriter
fmt.Fprint(writer, "HELLO")
})
http.ListenAndServe(":80", nil)
}
We used function http.HandleFunc
to pass a URI to match, and a "handler function". The handler function takes a http.ResponseWriter
which you write response data to, and a http.Request
object with the request data.
By default you need to handle GET vs POST (etc) yourself by reading
r.Method
. Fancy libraries such as gorilla/mux help you there.
The writer.WriteHeader
method just takes an HTTP status code. If we omitted it, a 200 response status would have been sent when we wrote anything to the writer
, like our "HELLO" string.
The WriteHeader()
method is just sending the HTTP response header, e.g. HTTP/1.1 200
.
Here's something more interesting: The function passed to HandleFunc
actually handles all requests. What we're passing in for a URI is technically a "URI pattern". Patterns ending in a trailing slash /
are "rooted subtrees", which is Golang's insufferable way of saying that it'll match the given URI and anything after it.
The URI pattern "/"
will handle any URI not otherwise matched. We don't have any other URI patterns defined, so it's a catch-all route!
But why does this work?
I set a catch-all handler, and then just told http
to ListenAndServe
on some port.
But those are two top-level functions defined in the stdlib http
module. There's no obvious connection between those 2 actions! Shouldn't I have had to pass that handler to the server somehow?
// These two top-level functions from http
// don't appear to be "connected" in any
// way at first.
http.HandleFunc("/", func(writer http.ResponseWriter, r *http.Request) {
// snip
});
// In fact, we pass nil to the param
// that normally would take a handler
http.ListenAndServe(":80", nil)
It turns out that the http
stdlib module has default objects and uses them if we don't define those objects ourself.
- The
http.HandleFunc()
function adds a handler to ahttp.DefaultServerMux
object, which is conveniently predefined - Deep in
http/server.go
, aserverHandler{}
struct is created. It has aServeHTTP
method on it. This method checks if a handler (the mux) is defined on the Server object. If not, it uses thehttp.DefaultServerMux
So the simple lil' HTTP server works because of syntactic sugar. There are default objects the http
module uses unless you explicitly define them.
Explicitly defining things
Let's take this exact same setup but make it more complicated. For science!
package main
import (
"fmt"
"net"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(writer http.ResponseWriter, r *http.Request) {
writer.WriteHeader(200)
fmt.Fprint(writer, "HELLO")
})
srv := &http.Server{
Handler: mux,
}
ln, err := net.Listen("tcp", ":80")
if err != nil {
panic(err)
}
srv.Serve(ln)
}
We have more going on here, but with the exact same result. We took the syntactic sugar and made it less sweet.
Previously, the http
module added the route and handler function to http.DefaultServerMux
.
Here we create a Mux ourselves, and then register our route against it. The HandleFunc
method is exactly the same, but one is "global" to the http
module and one is on ServeMux
objects.
// this:
http.HandleFunc("/", func(writer http.ResponseWriter, r *http.Request) {
// snip
})
// versus this:
mux := http.NewServeMux()
mux.HandleFunc("/", func(writer http.ResponseWriter, r *http.Request) {
// snip
})
A Mux is a "HTTP request multiplexor" and is responsible for matching incoming requests against a list of registered routes. It sends requests to the correct handler.
After that, we create an instance of http.Server
, passing it the Mux as its Handler
.
srv := &http.Server{
Handler: mux,
}
That's curious, though.
If the ServeMux
is a Mux, and we pass handler functions to that Mux, why is the ServeMux
object referred to as a Handler
within the http.Server
object?
Interestingly, ServeMux
is actually an http.Handler
, meaning it "satisfies" interface http.Handler
- it has a ServeHTTP()
method on it:
// ServeMux has a method ServeHTTP()!
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
So http.Server
's just wants any http.Handler
. It doesn't actually need to be a ServeMux
, but we usually use one to route specific requests to the code of our choice.
Here's another tidbit: ServeHTTP(http.ResponseWriter, *http.Request)
has an equivalant signature to the handler function we passed to mux.HandleFunc()
. Suspicious! Let's table that for a hot second, but keep it in mind.
// Our handler:
func(writer http.ResponseWriter, r *http.Request) {
// snip
}
// Is basically an unnamed `ServeHTTP` method:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
The
ServeHTTP()
method will come up a lot, thehttp
module leans on thehttp.Handler
interface pretty hard.
Finally, we create a network listener, and pass that to the server. The server will take the listener and Accept()
new connections/data on the defined network socket for HTTP connections (port 80 on all networks, in this case).
So, all of this work boils down to a less-sweet way of doing exactly what our most basic web server did.
Along the way, we learned about the ServeMux
, creating http.Server
instances, and noticing that the mux is actually a Handler.
We also saw that we can create a network listener ourselves and pass it to our server.
I've hinted that Handlers are sort of interesting. Let's get into that next.