Raph's Blog

A guide to making a Go web server without a framework

This write-up is the first part in a series detailing the development of BeanGo Messenger, a lightweight messaging app built with Go. I noticed there weren’t many great resources on how to get started on a web server using just standard libraries in Go, so I decided to share my approach.

disclaimer: this guide was writtent before go 1.22 which introduced significant improvements to net/http, making some parts of this guide obsolete. However, it's still good to understand how these things work under the hood

Do web frameworks confuse or annoy you? Or, maybe you just want something simple and lightweight that suits the particular needs of your project? Well, it’s quite easy to set up your own using just net/http.

If you follow this guide you will:

Or, you could just use Gin and miss out. Your choice.

Getting started

What Google or ChatGPT tell you to do

A quick search on how to build a web server using net/http will get you something like this:


package server

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "Hello, World!")
    })
    fmt.Println("Server listening on port 8080...")
    http.ListenAndServe(":8080", nil)
}
        

tip: you can also call Listen() and Serve() separately, which is useful is you want to log that the server is listening once it is actually listening

Why that’s not good enough

The “hello world” example I just showed you wasn’t very helpful to me because it just raised more questions. In fact, it has some big shortcomings:

The first point was what immediately struck me, and answers I found online suggested to let the handlers take care of HTTP methods. I didn’t really like that because I consider the method as part of the routing, and I want all of that done in one place, rather than have a mini-router for each path (in the form of a switch statement most likely).

That’s when I came across this brilliant article by Ben Hoyt, and decided to do the routing myself. In particular, his regex table approach seems the most appropriate.

Notice that nil is passed as the second argument to ListenAndServe() in the earlier example, this means the default server multiplexer is being used. A server multiplexer is what matches the path from a request to its handler (or errors with 404 if none is defined). In other words, it is a router.

Making your own router

Defining some types


type route struct {
    method    string
    pattern   *regexp.Regexp
    innerHandler   http.HandlerFunc
    paramKeys []string
}

type router struct {
    routes []route
}

func newRouter() *router {
    return &router{routes: []route{}}
}
        

Our router will hold in memory all the routes that are defined for our server. Each route has:

Logging the request

The reason the handler is called innerHandler on the route struct is because you want to have a wrapper function to handle things like logging. As my project gets bigger, I might want to introduce the concept of middleware, but it’s not needed at the moment.

Here’s the definition for the handler method which supports logging the request:


// A wrapper around a route's handler, used for logging
func (r *route) handler(w http.ResponseWriter, req *http.Request) {
    requestString := fmt.Sprint(req.Method, " ", req.URL)
    fmt.Println("received ", requestString)
    r.innerHandler(w, req)
}
        

Note: to define a method on a struct in Go, you just define a normal function but use the struct as a receiver. In this instance, the receiver is (r *route).

Defining new endpoints

You’ll define a method on the router to add a new route:


func (r *router) addRoute(method, endpoint string, handler http.HandlerFunc) {
    // handle path parameters
    pathParamPattern := regexp.MustCompile(":([a-z]+)")
    matches := pathParamPattern.FindAllStringSubmatch(endpoint, -1)
    paramKeys := []string{}
    if len(matches) > 0 {
        // replace path parameter definition with regex pattern to capture any string
        endpoint = pathParamPattern.ReplaceAllLiteralString(endpoint, "([^/]+)")
        // store the names of path parameters, to later be used as context keys
        for i := 0; i < len(matches); i++ {
        paramKeys = append(paramKeys, matches[i][1])
        }
    }

    route := route{method, regexp.MustCompile("^" + endpoint + "$"), handler, paramKeys}
    r.routes = append(r.routes, route)
}
        

The bulk of this method extracts route/path parameters from the request path, and replaces them with regex patterns that will capture any string in that location. It then stores the parameter names in an ordered list.

For example, if you pass in "/foo/:paramone/:paramtwo" as your endpoint:

You can also add some convenience methods like this one:


func (r *router) GET(pattern string, handler http.HandlerFunc) {
    r.addRoute(http.MethodGet, pattern, handler)
}
        

Then you can define an endpoint like this:


// this route that will be added:
// route{"GET", "/chat/([^/]+)/user/([^/]+)", someHandler, ["chatid", "userid"]}
router.GET("/chat/:chatid/user/:userid", someHandler)
        

Routing the request

For you to use your router instead of DefaultServerMux, it needs to implement ServeHTTP(http.ResponseWriter, *http.Request), which is the method in charge of handling the request and doing the actual routing:


func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    var allow []string
    for _, route := range r.routes {
        matches := route.pattern.FindStringSubmatch(req.URL.Path)
        if len(matches) > 0 {
        if req.Method != route.method {
            allow = append(allow, route.method)
            continue
        }
        route.handler(
            w, 
            buildContext(req, route.paramKeys, matches[1:])
        )
        return
        }
    }
    if len(allow) > 0 {
        w.Header().Set("Allow", strings.Join(allow, ", "))
        w.WriteHeader(http.StatusMethodNotAllowed)
        return
    }
    http.NotFound(w, req)
    }

    // This is used to avoid context key collisions
    // it serves as a domain for the context keys
    type ContextKey string

    // Returns a shallow-copy of the request with an updated context,
    // including path parameters
    func buildContext(req *http.Request, paramKeys, paramValues []string) *http.Request {
    ctx := req.Context()
    for i := 0; i < len(paramKeys); i++ {
        ctx = context.WithValue(ctx, ContextKey(paramKeys[i]), paramValues[i])
    }
    return req.WithContext(ctx)
}
        

In the above method, it loops through all routes saved in router, and will check if the request path matches the route’s pattern:

Once the loop ends without finding an appropriate route to fulfill the request, then a 405 is returned with the list allowed methods. If no paths matched at all, then a 404 is returned.

Making your own response writer

You’ve addressed the first two issues I mentioned at the start of the article, but you still need to log the response. You can’t read values from http.ResponseWriter, so you’ll need to replace the writer with a struct that can store the data you want to log.


package utils

import (
    "encoding/json"
    "fmt"
    "net/http"
)

type ResponseWriter struct {
    Status int
    Body   string
    Time   int64
    http.ResponseWriter
}

// Converts http.ResponseWriter into *utils.ResponseWriter
func NewResponseWriter(w http.ResponseWriter) *ResponseWriter {
    return &ResponseWriter{ResponseWriter: w}
}

Then, you overwrite some methods so that they store data in the struct’s fields when they are called:


func (w *ResponseWriter) WriteHeader(code int) {
    w.Status = code
    w.ResponseWriter.WriteHeader(code)
}

func (w *ResponseWriter) Write(body []byte) (int, error) {
    w.Body = string(body)
    return w.ResponseWriter.Write(body)
}
        

You can directly define what the response log will look like by overwriting ResponseWriter’s String() method:


func (w *ResponseWriter) String() string {
    out := fmt.Sprintf("status %d (took %dms)", w.Status, w.Time)
    if w.Body != "" {
        out = fmt.Sprintf("%s\n\tresponse: %s", out, w.Body)
    }
    return out
}
        

Remember the handler() method from before? You’ll need to modify it for all this to work:


// A wrapper around a route's handler, used for logging
func (r *route) handler(w http.ResponseWriter, req *http.Request) {
    requestString := fmt.Sprint(req.Method, " ", req.URL)
    fmt.Println("received ", requestString)
    start := time.Now()
    r.innerHandler(utils.NewResponseWriter(w), req)
    w.Time = time.Since(start).Milliseconds()
    fmt.Printf("%s resolved with %s\n", requestString, w)
}
        

You can add some convenience methods to cut down on repetitive code in your resolvers:


func (w *ResponseWriter) StringResponse(code int, response string) {
    w.WriteHeader(code)
    w.Write([]byte(response))
}

func (w *ResponseWriter) JSONResponse(code int, responseObject any) {
    w.WriteHeader(code)
    response, err := json.Marshal(responseObject)
    if err != nil {
        w.StringResponse(http.StatusBadRequest, err.Error())
    }
    w.Header().Set("content-type", "application/json")
    w.Write(response)
}
        

Running the server

Finally, set up your web server:


package server

import (
    "fmt"
    "net"
    "net/http"
    "os"

    "github.com/raphael-p/beango/utils"
)

func Start() {
    router := newRouter()
    router.GET("/for/:id/demonstration/:otherid", func(w *utils.ResponseWriter, r *http.Request) {
        fmt.Println(r.Context().Value(ContextKey("id"))) // logs the first path parameter
        fmt.Println(r.Context().Value(ContextKey("otherid"))) // logs the second path parameter
        fmt.Println(r.FormValue("name")) // logs a query parameter
    })

    l, err := net.Listen("tcp", ":8081")
    if err != nil {
        fmt.Printf("error starting server: %s\n", err)
    }
    fmt.Println("🐱‍💻 BeanGo server started on", l.Addr().String())
    if err := http.Serve(l, router); err != nil {
        fmt.Printf("server closed: %s\n", err)
    }
    os.Exit(1)
}
        

That’s all, thanks ✨