Andy Jessop
Posted on February 25, 2024
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
And either restart your terminal, or source your .bashrc
.
source ~/.bashrc # Or ~/.zshrc or equivalent, depending on your shell
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
This will create a go.mod
file in our folder with the following contents.
module go-server
go 1.22.0
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
)
After the imports, we've added a Post
struct to define our post data, and defined some global variables:
-
posts
- amap
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 theposts
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))
}
Let's have a look at what's going on here:
http.HandleFunc("/posts", postsHandler)
http.HandleFunc("/posts/", postHandler)
Here we setup handlers for the /posts
and /posts/
routes, handled by some as yet non-existent functions.
log.Fatal(http.ListenAndServe(":8080", nil))
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)
}
}
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
}
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)
}
Running the Server
Right - we're ready to test our server and see if it works! Drumroll?
go run main.go
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.
Posted on February 25, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.