DEV Community

Cover image for [Go] Understanding net/http - How to run a server like a Pro?
UponTheSky
UponTheSky

Posted on • Edited on

[Go] Understanding net/http - How to run a server like a Pro?

*image credit to Renee French, the source is the one of the official Go blog posts

TL; DR

  • Use ServeMux as the multiplexer
  • Use Serve struct for detailed configurations
  • It’s okay with using Serve::ListenAndServe, but Listen and Serve can be called separately(Listen is in the net package)

Introduction - Stop relying on default functions

At a glance, It looks simple to write an HTTP server in Go, using the standard net/http package. Many of the tutorials and blog posts usually don’t go beyond using default functions like http.HandleFunc or http.ListenAndServe. And there don’t seems to be many issues with it. Who cares as long as it works well?

Well, if you’re a professional software engineer, you would highly disagree with this. We need to take control over every aspect of what we’re wielding as our mighty weapon, if possible. Relying on default functions provided by the package may obscure the details under the hood. As I spend more time learning Go, I wanted to know what’s behind this “default layer”, and be able to write an HTTP server in a more sophisticated way.

In this short article, I want to discuss the ways of starting a server in net/http package, from simple ones to more advanced ones. Before diving into the main part of the article, let me point out the following three points:

  • This article is motivated by a blog post by an engineer at Grafana, which has been quite popular in the Go community recently.
  • I won’t consider any of the third-party libraries like Gin or Echo. It is not because they are not good, but because I want to dig into what is considered as “standard” in Go language and by its developers.
  • Also, it could mean so many things when I say “How to write a server in Go”, because there are a myriads of things to consider - middleware, router, security, network protocols, concurrency issues, to name a few. Therefore, this article only aims to explore how to write and run a "server instance" rather a "web server application".

Various ways to run a server using net/http package

1. Start simple - DefaultServeMux

Many learning materials for beginners in Go(such as Go by Example or Go Web Examples shows how to run an HTTP server in a simple example like:

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(200)
        fmt.Fprint(w, "Hello, Go HTTP Server!")
    })

    log.Fatal(http.ListenAndServe(":8000", nil))
}
Enter fullscreen mode Exit fullscreen mode

In a nutshell, this method simply registers a plain function of two parameters (w http.ResponseWriter, r *http.Request) such that the function is called whenever a client requests the targeted endpoint. In this way, there is no need to fully understand the interface Handler; you just need to write logic to process incoming requests.

However, there is an interesting point to look at here. What is the relationship between http.HandleFunc and http.ListenAndServe, as these two functions have nothing in common. But, as a matter of fact, they DO share a global variable called DefaultServeMux.

If you read the documentation, it says

for HandlerFunc:

HandleFunc registers the handler function for the given pattern in DefaultServeMux

and for ListenAndServe:

The handler is typically nil, in which case DefaultServeMux is used.

Thus, a function registered by HandleFunc is stored in the global variable DefaultServeMux of the package, which is then used by ListenAndServe. So the key now is to understand ServeMux in general.

(REMARK) What is ServeMux?

Let’s look at the documentation once again:

ServeMux is an HTTP request multiplexer. It 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.

As a multiplexer, a ServeMux instance receives multiple HTTP requests and hands each of them over to an appropriate handler. Thus, ListenAndServe starts a server that listens to requests headed to the specified address(in this case, it is "http://localhost:8000"), and then delegates them to its (Default)ServeMux in order to handle(note that ServeMux implements the interface http.Handler).

2. More sophisticated usage - make your own ServeMux

But why is it not a good practice to use DefaultServeMux? Mainly because it is a global object. According to Practical Go by Amit Saha, many unnoticed packages could also access the object, such that related security or concurrency problems could occur. Therefore, it is better to create our own ServeMux instances using http.NewServeMux.

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(200)
        fmt.Fprint(w, "Hello, Go HTTP Server!")
    })

    log.Fatal(http.ListenAndServe(":8000", mux))
}   
Enter fullscreen mode Exit fullscreen mode

Of course, since ListenAndServe accepts the http.Handler interface in general, we can wrap mux with middlewares(the technique is introduced in one of my previous article)

func someMiddleware(h http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // custom logic
        h.ServeHTTP(w, r)
        // custom logic
    }

func main() {
    // …
    mux := http.NewServeMux()
    mux = someMiddleware(mux)
    // …
}
Enter fullscreen mode Exit fullscreen mode

3. Writing your own Server

But ServeMux is only a multiplexer that distributes incoming requests, and the server is bootstrapped at http.ListenAndServe. Obviously, it doesn’t accept any configuration for the server application it sets up. Therefore, if we need to tweak a few configurations for a server, we use http.Server struct in the package.

As you see the documentation, the configurable variables are related to fairly low-level concepts. But if we’re considering a production environment, complicated configuration is inevitable.

Now we have more freedom in writing a web server in Go, only using the net/http package:

func main() {
    addr := os.Getenv("SERVER_ADDRESS")

    if addr == "" {
        addr = ":8080"
       }

    // step 1: make your own `ServeMux`
        mux := http.NewServeMux()

        // add handlers & middlewares
    // …

    // step 2: make your own `Server`
    srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: time.Minute}

    log.Fatal(srv.ListenAndServe())
}
Enter fullscreen mode Exit fullscreen mode

Also, we may go deeper into setting up the TLS support using ListenAndServeTLS instead of plain ListenAndServe, with necessary certificate files. For simplicity, we will keep using ListenAndServe for the rest of the article.

4. Advanced - Splitting ListenAndServe into Listen and Serve

Many of the learning materials and blog posts on writing Go server with net/http simply use the ListenAndServe method. In most of the cases it is fine
since it uses TCP connections under the hood.

So we break down the function to Listen and Serve, instead of the last line log.Fatal(s.ListenAndServe()) in the above code chunk like this:

// step 3: Listen to TCP connections
ln, err := net.Listen("tcp", addr)

if err != nil {
  log.Fatal(err)
} 

defer ln.Close()

log.Fatal(srv.Serve(ln))
Enter fullscreen mode Exit fullscreen mode

As you might have already expected, Listen function is a wrapper of the system call listen at the OS kernel level. And Serve reads request data from the socket buffer and hands over the information(with pre-generated response instance) to its handler. We can go deeper here but it is beyond the scope of this post. However, savor the beauty of the naming "Listen"(the incoming connection from a socket) and "Serve"(the request from the listened connection with enrolled handler) here - how well they are made!

Conclusion

In effect, we have break down "convenient" default functions in the net/http package into several components that we can configure directly. As we go deeper, it goes down to the fundamental part of the network programming - TCP socket programming, as we discussed at Advanced - Splitting ListenAndServe into Listen and Serve. I am not sure whether we need to configure at this level. Nevertheless, I believe it is important to understand how the mechanism works when we write a server program.

Still, it is of course not writing a server instance "as a Pro". There are more details uncovered yet in this post. But "like a Pro", we mimic the way a professional software engineer writes code.

references

Top comments (0)