Building a blog in Go Part 1

Let's build a blog!

Why a blog? It may sound boring and simple, but when the rubber meets the road, most things are. Complex systems are usually just many simple systems stitched together. I learn best by creating things I would actually use, and what we're building in this series is the same software that runs this blog.

So, what can you learn by building a blog? Surprisingly, quite a lot. Just off the top of my head:

  • Serving HTTP / Servers
  • Routing
  • HTML templates
  • Serving Markdown
  • Data Structures
  • Working with file systems

Note: This guide assumes some basic experience with Go. We'll start slow and move quicker as we work through the basics.

Let's talk in a little more detail about the architecture of this blog. First, we'll get a simple web server running and set up some basic routing and handlers. Then, we'll lay the groundwork for reading markdown files at startup, and finally, we'll put together some templates and render our markdown. A word of warning: don't expect to take this to production—we want a minimum viable product here.

Now, let's get going!

  1. Open the terminal, create a new directory, cd into it, initialize it with go mod init, and create a new main.go file.
1mkdir example_blog && cd example_blog && go mod init github.com/yourname/exampleblog && touch main.go
  1. Open the folder in VS Code or your code editor of choice (this assumes you have code set up to open via code .).
1code .
  1. Open main.go and add the following:
1package main
2
3import "fmt"
4
5func main() {
6 fmt.Println("hello world")
7}
  1. Open the integrated terminal in the code editor or bring up a terminal in the same folder and run:
1go run .

You should see hello world printed in the terminal.

Now, let's move a little quicker and get the basics of a server going. Add the content below to your main function and run it.

 1import (
 2 "fmt"
 3 "net/http"
 4)
 5
 6func main() {
 7 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 8  w.Write([]byte("hello world"))
 9 })
10
11 if err := http.ListenAndServe(":5040", nil); err != nil {
12  fmt.Printf("server crashed with error %v", err)
13 }
14}

We now have a very basic HTTP server that writes hello world on port 5040. The code above registers a handler for the / route and responds with "hello world". We use the parameter w to write and r to interact with our HTTP requests.

Go ahead and test it in your browser or via terminal with curl.

1curl http://localhost:5040/
2# hello world

Let's think about what we need to build the start of a blog. We can deliver some content to a browser at this point, but it's not very useful. Let's work on reading the markdown files.

Create a content folder in the terminal or in the code editor and add this file and its content.

1<!-- firstpost.md -->
2# Hello World
3
4This is a first post! There are many others but this is mine.

Now would be a good time to talk about the structure of this project. For our initial iteration, we are going to serve very basic HTML at the default route / and list out our posts that are read from the content directory. Each post will render a link, and clicking the link will take you to /posts/{slug} with the post name being the slug.

Reading Markdown Content

At the start of the application, we need to read all the markdown files into memory and serve them up to our router. Before we start down that path, let's create a few structs: one to store server-related configuration data and another to store data for our actual markdown post. At the top of the main.go file, create the following:

 1// Stores configuration data for our app at startup
 2type config struct {
 3 port int
 4 path string
 5}
 6
 7// We will pass data into our homepage with this struct
 8type HomePageData struct {
 9 Title string
10 Urls  []string
11}
12
13// This struct contains data related to the post
14type PostPageData struct {
15 Title   string
16 Content template.HTML
17}

Let's populate our configuration struct with some useful data like our port and content paths. Ideally, we don't want to hardcode this type of data into our main function. This 'configuration' structure is a fairly common pattern in use.

Add these lines to the top of our main function

1var cfg config
2
3// assign the passed in parameter as the port & path 
4flag.IntVar(&cfg.port, "port", 5040, "port to listen on")
5flag.StringVar(&cfg.path, "content-path", "./content", "path to markdown content")
6flag.Parse() // don't forget to parse the values

We will create a variable for a config struct and assign values to it at the start of the main function. The flag package provides some useful functions to parse command line options. Both IntVar and StringVar functions parse command line options at runtime into our struct fields. If you do not pass any command line options, the defaults will be used. This actually presents a little bit of a problem with our port variable that we need to fix.

Take a look at this line

1// use the default serve mux to start a server on port 5040 and check for errors
2 if err := http.ListenAndServe(":5040", nil); err != nil {
3  fmt.Printf("server crashed with error %v", err)
4 }

Ideally, we would just add cfg.port to the first parameter of ListenAndServe. Can you spot the issue? Let's try... Make the following change.

1if err := http.ListenAndServe(cfg.port, nil); err != nil {
2  fmt.Printf("server crashed with error %v", err)
3 }

Your editor should give you a squiggly line or error out when you try to run the program. The issue is that you are assigning an integer to a function that expects a string. Even converting this to a string won't fix the issue entirely, as it expects a string like this :port. Let's convert the port to a string using the fmt.Sprintf function. Make the following change to the function call:

1http.ListenAndServe(fmt.Sprintf(":%d", cfg.port), nil)

The Sprintf function will format the variable for us and return a string. We are basically telling the function to return a string with the colon at the start and replace the %d with the integer variable.

Let's parse those markdown files. Create a function with this name and signature:

1readMarkdown(fSys fs.FS) (map[string][]byte, error)

right under the main function.

readMarkdown() just takes a single parameter, the fs.FS interface. The fs.FS interface is an abstraction that allows us to plug in various different implementations of the filesystem. If that sounds confusing, don't worry about it for now. One thing to remember is that leveraging these interfaces makes the functions easier to test.

Let's continue on...

We will start by creating a "cache": cache := make(map[string][]byte). This creates a map that will hold our slugs by name and our markdown data. We will then return this data later.

Why use a map instead of a slice or array? Using a map data structure allows for constant-time lookup of an entity, versus having to loop through elements.

So, we have our cache. Now let's get our files. Add the following lines:

1files, err := fs.Glob(fSys, "*.md") // read all .md files from the file system
2 if err != nil {
3  return nil, err
4 }

We are using fs.Glob to return a slice of strings of the files that match the extension passed into the function *.md. Now let's iterate through these files, read them, and store them in our cache.

1// iterate through each of the markdown files and read the content into a []byte
2 for _, f := range files {
3  data, err := fs.ReadFile(fSys, f)
4  if err != nil {
5   return nil, err
6  }
7
8  cache[strings.TrimSuffix(f, ".md")] = data
9 }

Here is the order of operations for the code above:

  • Range over each string entry in the slice (e.g., helloworld.md)
  • Use fs.ReadFile to read the file contents into a []byte
  • Lastly, assign the filename as the key in our cache along with the data

This is what main.go should look like now:

 1package main
 2
 3import (
 4 "flag"
 5 "fmt"
 6 "io/fs"
 7 "net/http"
 8 "strings"
 9)
10
11type config struct {
12 port int
13 path string
14}
15
16type HomePageData struct {
17 Title string
18 Urls  []string
19}
20
21type PostPageData struct {
22 Title   string
23 Content template.HTML
24}
25
26func main() {
27 var cfg config
28
29 flag.IntVar(&cfg.port, "port", 5040, "port to listen on")
30 flag.StringVar(&cfg.path, "content-path", "./content", "path to markdown content")
31 flag.Parse()
32
33// Register a handler function for the default route "/" and write the string "hi" in a byte slice
34 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
35  w.Write([]byte("<html><body><h1>hi</h1></body></html>"))
36 })
37
38// Use the default serve mux to start a server on port 5040 and check for errors
39 if err := http.ListenAndServe(fmt.Sprintf(":%d", cfg.port), nil); err != nil {
40  fmt.Printf("server crashed with error %v", err)
41 }
42}
43
44func readMarkdown(fSys fs.FS) (map[string][]byte, error) {
45 cache := make(map[string][]byte)
46 files, err := fs.Glob(fSys, "*.md") // read all .md files from the file system
47 if err != nil {
48  return nil, err
49 }
50
51 for _, f := range files {
52  data, err := fs.ReadFile(fSys, f)
53  if err != nil {
54   return nil, err
55  }
56
57  cache[strings.TrimSuffix(f, ".md")] = data
58 }
59
60 return cache, nil
61}

Time to put this in action and see if it works as expected. Go to your main.go file and add the following right after our calls to flag.Parse():

1markdownCache, err := readMarkdown(os.DirFS(cfg.path))
2 if err != nil {
3  return
4 }
5 fmt.Println("cache", markdownCache)

We are passing in our path via os.DirFS and printing the results. Assuming the markdown file we created is in that directory, you should see something like this when you run the program with go run .:

1
2cache map[firstpost.md:[102 105 114 115 116 112 111 115 116 46 109 100]]

Jackpot! It found our post and its contents are in the map. How can you tell? Because there is some data in that map.

In Part 2, we will work on rendering the content and building a router.