DEV Community

Cover image for Building a Basic HTTP Server in Go: A Step-by-Step Tutorial
Andy Jessop
Andy Jessop

Posted on • Updated on

Building a Basic HTTP Server in Go: A Step-by-Step Tutorial

Introduction

Go (Golang) is a compiled language with a rich standard library, and is popular for web servers due to its readability and concurrency model. It's statically-typed and garbage-collected, which only adds to its desirability for web servers and microservices.

I've been reading a lot about Go recently, and decided it was time to dive in. This tutorial is part learning, part teaching, so join me in exploring this wonderful language with an application that is relevant and hopefully useful!

You can find the full code on GitHub, so if you get lost along the way, you will have a full working reference available.

Together, we'll build a web server using only the standard library of Go - no frameworks - so we can really understand what's going on under the hood. It will be a JSON server that manages posts, and we can create, delete, and fetch our posts. I've omitted updating posts for brevity.

It's not going to be production ready, but will introduce both you and I to some of the core concepts in Go.

I can see that you're on the edge of your seat, so without further ado, let's dive in!

Setting Up Your Environment

If you haven't already installed Go, please follow the instructions here and get it downloaded onto your machine.

Make sure to add go to your path:

export GOPATH=$HOME/go
export PATH=$PATH:$GOPATH/bin
Enter fullscreen mode Exit fullscreen mode

And either restart your terminal, or source your .bashrc.

source ~/.bashrc  # Or ~/.zshrc or equivalent, depending on your shell
Enter fullscreen mode Exit fullscreen mode

Let's get the workspace setup. We'll create the folder that will contain all our files, and ask go to initialise a module for us.

mkdir go-server
cd go-server
go mod init go-server
Enter fullscreen mode Exit fullscreen mode

This will create a go.mod file in our folder with the following contents.

module go-server

go 1.22.0
Enter fullscreen mode Exit fullscreen mode

I think we're ready to go; time to code...

Writing the HTTP Server Code (main.go)

Initial Setup

Now create a file main.go at the root of the folder - this is going to contain all the code for our server. Let's add some boilerplate and the imports we'll need:

package main

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "strconv"
    "sync"
)

type Post struct {
    ID   int    `json:"id"`
    Body string `json:"body"`
}

var (
    posts   = make(map[int]Post)
    nextID  = 1
    postsMu sync.Mutex
)
Enter fullscreen mode Exit fullscreen mode

After the imports, we've added a Post struct to define our post data, and defined some global variables:

  • posts - a map that will hold our posts in memory (no DB in this tutorial)
  • nextID - a variable that will help us create unique post ids when creating a new post
  • postsMu - a mutex that allows us to lock the program while we make modifications to the posts map. If we didn't have this, then concurrent requests could in theory cause a race condition.

Implementing the Server

Below the declaration of the those variables, add the main function, which is the entry point for our module.

func main() {
    http.HandleFunc("/posts", postsHandler)
    http.HandleFunc("/posts/", postHandler)

    fmt.Println("Server is running at http://localhost:8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

Let's have a look at what's going on here:

http.HandleFunc("/posts", postsHandler)
http.HandleFunc("/posts/", postHandler)
Enter fullscreen mode Exit fullscreen mode

Here we setup handlers for the /posts and /posts/ routes, handled by some as yet non-existent functions.

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

Fairly straightforward, this is starting the server and listening on port :8080, so when we hit :8080/posts we will receive an array of posts back.

Now we'll add those handlers.

Handling Requests

So the way we've set this up is that each route has a handler, whether it's GET/POST/DELETE/WHATEVER, we will handle everything in these functions. The handler functions will therefore check the method and decide what to do with the request.

In order to do that, add the following code below the main function.

func postsHandler(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case "GET":
        handleGetPosts(w, r)
    case "POST":
        handlePostPosts(w, r)
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

func postHandler(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.URL.Path[len("/posts/"):])
    if err != nil {
        http.Error(w, "Invalid post ID", http.StatusBadRequest)
        return
    }

    switch r.Method {
    case "GET":
        handleGetPost(w, r, id)
    case "DELETE":
        handleDeletePost(w, r, id)
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}
Enter fullscreen mode Exit fullscreen mode

The first thing to notice here is that the handler functions both receive w http.ResponseWriter, r *http.Request as their arguments. These two arguments enable us to read the request, and to respond with our JSON.

NB: I've used single-character variables because that seems to be the convention in Go. Coming from a JavaScript background, I would have preferred to defined these as full words, but I'm trying to fit in, so it is what it is.

The rest is all fairly obvious; we're delegating the task of handling the requests to more specific functions targeted to the respective methods. Probably the only interesting thing here is in the postHandler, where we're extracting the id of the post to handle.

id, err := strconv.Atoi(r.URL.Path[len("/posts/"):])
if err != nil {
    http.Error(w, "Invalid post ID", http.StatusBadRequest)
    return
}
Enter fullscreen mode Exit fullscreen mode

This is taking the path as a string - r.URL.Path - and creating a substring using the str[x:y] syntax where str is the original string x is the starting index of the substring, and y is the end of the substring.

In our case, y is omitted, so it's grabbing a substring of the path from the end of /posts/ to the end of the whole path. Therefore if we have /posts/123, this will return 123 as a string.

NB: Note that we could also do [7:], which would yield the same result, but the 7 is then a magic number that doesn't have an obvious reason to be so.

We're then converting it to an integer with strconv.Atoi, and handling our errors properly too.

CRUD Operations

We've come a long way! We created a server that listens on port 8080, and handles the routes we're interested in. We then implemented those handlers, routing each separate method to its own handler. Let's implement those handlers now.

To make understanding this simpler, I'll post the whole code and then comment all the bits that are interesting. So make sure to give them a good read so that you understand what's going on here.

func handleGetPosts(w http.ResponseWriter, r *http.Request) {
    // This is the first time we're using the mutex.
    // It essentially locks the server so that we can
    // manipulate the posts map without worrying about
    // another request trying to do the same thing at
    // the same time.
    postsMu.Lock()

    // I love this feature of go - we can defer the
    // unlocking until the function has finished executing,
    // but define it up the top with our lock. Nice and neat.
    // Caution: deferred statements are first-in-last-out,
    // which is not all that intuitive to begin with.
    defer postsMu.Unlock()

    // Copying the posts to a new slice of type []Post
    ps := make([]Post, 0, len(posts))
    for _, p := range posts {
        ps = append(ps, p)
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(ps)
}

func handlePostPosts(w http.ResponseWriter, r *http.Request) {
    var p Post

    // This will read the entire body into a byte slice 
    // i.e. ([]byte)
    body, err := ioutil.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Error reading request body", http.StatusInternalServerError)
        return
    }

    // Now we'll try to parse the body. This is similar
    // to JSON.parse in JavaScript.
    if err := json.Unmarshal(body, &p); err != nil {
        http.Error(w, "Error parsing request body", http.StatusBadRequest)
        return
    }

    // As we're going to mutate the posts map, we need to
    // lock the server again
    postsMu.Lock()
    defer postsMu.Unlock()

    p.ID = nextID
    nextID++
    posts[p.ID] = p

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(p)
}

func handleGetPost(w http.ResponseWriter, r *http.Request, id int) {
    postsMu.Lock()
    defer postsMu.Unlock()

    p, ok := posts[id]
    if !ok {
        http.Error(w, "Post not found", http.StatusNotFound)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(p)
}

func handleDeletePost(w http.ResponseWriter, r *http.Request, id int) {
    postsMu.Lock()
    defer postsMu.Unlock()

    // If you use a two-value assignment for accessing a
    // value on a map, you get the value first then an
    // "exists" variable.
    _, ok := posts[id]
    if !ok {
        http.Error(w, "Post not found", http.StatusNotFound)
        return
    }

    delete(posts, id)
    w.WriteHeader(http.StatusOK)
}
Enter fullscreen mode Exit fullscreen mode

Running the Server

Right - we're ready to test our server and see if it works! Drumroll?

go run main.go
Enter fullscreen mode Exit fullscreen mode

This command compiles and runs the server. You should see a message indicating that your server is running and listening on port 8080. You can then test your server by navigating to http://localhost:8080/posts in your browser or using a tool like curl or Postman to make requests.

Conclusion

Congratulations! You and I have successfully built a basic HTTP server in Go. This server handles creating, fetching, and deleting posts without relying on external frameworks, and I think we've demonstrated how intuitive and powerful Go is as a fairly low-level language but one that takes the burden of memory management away with its garbage collector.

While our server is functional, there are many ways it could be expanded or improved. If you're the type who likes to do further learning, consider the following:

  • Add update functionality to allow editing existing posts.
  • Implement data persistence by integrating a database instead of storing posts in memory.
  • Add user authentication to control access to certain endpoints.
  • Implement input validation to improve security.
  • Enhance error handling for more robust and informative responses.

This tutorial only scratches the surface of what's possible with Go. I'm having so much fun learning it, and I don't think this will be the end for my journey with the language.

Top comments (0)