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.
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:
- gain a deeper level of understanding of how web servers operate
- have more control over how your application works
- be able to optimise it to your liking, building custom features and keeping it as simple or complex as you need it
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)
}
HandleFunc()
is used to declare a function that will handle a request to an endpoint on the server (in this case the root/
)w
is used to write the response,r
is a struct containing the requestListenAndServe()
starts a TCP network at a given port
tip: you can also callListen
andServe
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:
- no concept of HTTP methods
- no handling for route/path parameters.
- won’t automatically log requests and their responses.
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:
method
, the HTTP method that it expectspattern
, a regex pattern to match the request path withinnerHandler
, the actual resolverparamKeys
, the name of all path parameters in the order they appear in the path
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
:
route.pattern
will be/foo/([^/]+)/([^/]+)
route.paramKey
will be["paramone",“paramtwo”]
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:
- If the path and HTTP methods match the router’s → it adds all path parameters to the request context (the path parameter name is wrapped in
ContextKey
to avoid key collisions). Then, it calls the handler defined on the route, which fulfills the request. - If the path matches but not the HTTP method → it appends the method to a list of allowed methods and skips to the next route
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 ✨