Implementing a Hybrid Blog Engine with Go and SQLite
When you have a hammer, everything looks like a nail.
It's no secret that I've been obsessed with Go lately. This year, I've implemented a Discord chat bot, a Grand Exchange clone, an in-memory key-value store, and a simple authentication service, all in Go. I even created a VSCode extension that autocompletes brackets when coding in Go just to save myself a few keystrokes when writing it.
I thought, why stop there? Why not implement a Static Site Generator, like Jekyll, but in Go?
I had two requirements for my SSG:
- Likes and Views are dynamic and persisted (when a user views a post, the views go up, same with likes)
- I can write new posts in a simple markdown format
Why I Created This Blog
You may have noticed that I already have a blog. Why create a new one?
- That blog uses a template that I didn't make. I wanted more customization over the look and feel of the page.
- The other blog also uses Jekyll + Github Pages. I wanted to write my own static site generator with Go. I also wanted to deploy the app myself.
- I wanted dynamic likes and views, which is not straightforward with the Github Pages setup.
The Tech Stack: Go + SQLite + HTML + JavaScript
I wanted to keep my website as lightweight as possible, so I opted to use SQLite, an embedded database. What does that actually mean?
It means that the database will live on the same machine my code is running on. Nowadays, it's common for the DB to be a separate process
that your app communicates with over the network. For what I built, such a DB would be overkill.
Go powers the API that receives and serves likes and views. Go also serves the HTML files, such as the one you're looking at right now.
Our app is server rendered, which means all of what you're seeing right now was generated before you even visited this site. Well, besides one part of the app: the likes and the views count, which are loaded when you visit the page. Being server rendered can speed up load times on low-end devices as this means the client has less computation to do in order to show you the page.
The Data Model
Our app is very simple. We have Posts, Likes, and Views. This is the only data needed by the app on the client side:
1type Stats struct {
2 Slug string `json:"slug"`
3 Likes int `json:"likes_count"`
4 Views int `json:"views_count"`
5}
Slug is an extremely nasty word, but for some reason that's what we call the bit of the article that goes in the URL. For example, this site is
https://thorn.sh/go-sqlite/. In this case, the go-sqlite is the slug.
By the way, the syntax highlighting you're seeing in the code snippet? It's also server rendered, using my own custom syntax highlighting theme :)
The Application Structure
Our server is a Go application, so we follow general Go patterns for the structure:
1// cmd (the entrypoints to our app)
2// --> builder
3// ----> main.go
4// --> server
5// ----> main.go
6//
7// internal (the majority of the server code)
8// --> db
9// --> handler
10// --> logging
11// --> repo
12// --> router
13//
14// public (the directory where generated HTML is stored)
15//
16// templates (html template code)
17//
18// content (the content of the articles, in markdown .md format)
Let's walk through a basic HTTP server from the standard library's net/http package in the context of this blog's server.
1package main
2
3import (
4 "context"
5 "crypto/tls"
6 "errors"
7 "log"
8 "net/http"
9 "os"
10 "os/signal"
11 "syscall"
12 "time"
13
14 "github.com/thornhall/blog/internal/db"
15 "github.com/thornhall/blog/internal/handler"
16 "github.com/thornhall/blog/internal/logging"
17 "github.com/thornhall/blog/internal/repo"
18 "github.com/thornhall/blog/internal/router"
19 "golang.org/x/crypto/acme/autocert"
20)
21
22func NewServer(publicDir, domain string) *http.Server {
23 logger := logging.New(os.Stdout)
24 database := db.New()
25 rep := repo.New(database)
26 hnd := handler.New(rep, logger, publicDir)
27 mux := router.New(hnd, publicDir)
28
29 return &http.Server{
30 Addr: ":443",
31 Handler: mux,
32 TLSConfig: &tls.Config{
33 GetCertificate: certManager.GetCertificate,
34 MinVersion: tls.VersionTLS12,
35 },
36 ReadTimeout: 10 * time.Second,
37 WriteTimeout: 5 * time.Second,
38 ReadHeaderTimeout: 5 * time.Second,
39 }
40}
This is how we create a server. For our blog's purposes, we need to pass in a public directory string and a domain.
The domain is its own topic that I won't dive into here. The public directory is just the path to the public directory,
which we know from the file structure is ./public.
Note that we also create a logger, database, repo, handler, and router. I'll go over those in the next section.
Next is the main function of main.go for the server. This function is what executes first when someone runs our Go program.
1func main() {
2 domain := os.Getenv("DOMAIN")
3 srv := NewServer("./public", domain)
4
5 // Start the server in a goroutine so that we can listen for shutdown signals on the main thread.
6 go func() {
7 var err error
8 if domain != "" {
9 err = srv.ListenAndServeTLS("", "")
10 } else {
11 err = srv.ListenAndServe()
12 }
13
14 if !errors.Is(err, http.ErrServerClosed) {
15 log.Fatalf("unable to start http server: %v", err)
16 }
17 }()
18
19 // Register listening for shutdown signals
20 shutDownChan := make(chan os.Signal, 1)
21 signal.Notify(shutDownChan, syscall.SIGINT, syscall.SIGTERM)
22 <-shutDownChan // block until a signal is received
23
24 shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
25 defer cancel()
26
27 if err := srv.Shutdown(shutdownCtx); err != nil {
28 log.Fatalf("unable to shutdown server gracefully: %v", err)
29 }
30}
In this code, we start the server in its own thread and listen for shutdown signals on the main thread. Starting the server in its own goroutine is how we accomplish this.
The Router
The next most important part of the application to understand is the router. The router is what determines which code gets executed depending on how you (the client) asks to visit the app.
1
2package router
3
4import (
5 "net/http"
6
7 "github.com/thornhall/blog/internal/handler"
8)
9
10func New(h *handler.Handler, publicDir string) *http.ServeMux {
11 mux := http.NewServeMux()
12 mux.HandleFunc("POST /api/likes/{slug}", h.HandleLike)
13 mux.HandleFunc("GET /api/stats/{slug}", h.HandleGetStats)
14 fs := http.FileServer(http.Dir(publicDir))
15 assetsFs := http.FileServer(http.Dir("./assets"))
16 mux.Handle("GET /assets/", http.StripPrefix("/assets/", assetsFs))
17 mux.Handle("GET /", fs)
18 return mux
19}
This code is telling the server where to find the HTML for the posts and other pages like the homepage. It even tells the server where to find the media (such as pictures) for the site.
Notice that it takes a handler as a parameter. This is important for understanding how the code works. The router determines which code gets executed
when the user requests a specific part of our app. By visiting this page, your browser did a GET https://thorn.sh/go-sqlite/. That happens to be
default behavior when using a FileServer, a standard library package for serving HTML files. But how do we get likes and views? That's where the handler comes in. The Handler is our custom code that we want executed when the user visits a specific route of our app. Viewing the web page is only one of several routes, defined in the router.
The Handler
1package handler
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "log/slog"
8 "net/http"
9 "regexp"
10
11 "github.com/thornhall/blog/internal/repo"
12)
13
14type Handler struct {
15 repo *repo.Repo
16 log *slog.Logger
17 fs http.FileSystem
18}
19
20func New(repo *repo.Repo, log *slog.Logger, publicDir string) *Handler {
21 return &Handler{
22 repo: repo,
23 log: log,
24 fs: http.Dir(publicDir),
25 }
26}
27
28type ErrorResponse struct {
29 Message string `json:"error"`
30}
31
32// Used for all error responses for consistency.
33func HttpErrorResponse(w http.ResponseWriter, message string, statusCode int) {
34 res := ErrorResponse{Message: message}
35 w.Header().Set("Content-Type", "application/json")
36 w.WriteHeader(statusCode)
37 json.NewEncoder(w).Encode(res)
38}
39
40func (h *Handler) HandleGetStats(w http.ResponseWriter, r *http.Request) {
41 slug := r.PathValue("slug")
42
43 if !isValidSlug(slug) {
44 HttpErrorResponse(w, "invalid slug format", http.StatusBadRequest)
45 return
46 }
47
48 stats, err := h.repo.IncrementViews(r.Context(), slug)
49 if err != nil {
50 h.log.Error("error incrementing views", "error", err, "slug", slug)
51 HttpErrorResponse(w, "internal server error", http.StatusInternalServerError)
52 return
53 }
54
55 w.Header().Set("Content-Type", "application/json")
56 json.NewEncoder(w).Encode(stats)
57}
58
59func (h *Handler) HandleLike(w http.ResponseWriter, r *http.Request) {
60 slug := r.PathValue("slug")
61 if !isValidSlug(slug) {
62 HttpErrorResponse(w, "invalid slug format", http.StatusBadRequest)
63 return
64 }
65
66 file, err := h.fs.Open(slug + ".html")
67 if err != nil {
68 HttpErrorResponse(w, "post not found", http.StatusNotFound)
69 return
70 }
71 file.Close()
72
73 stats, err := h.repo.IncrementLikes(r.Context(), slug)
74 if err != nil {
75 if errors.Is(err, sql.ErrNoRows) {
76 HttpErrorResponse(w, "post not found", http.StatusNotFound)
77 return
78 }
79 h.log.Error("error liking post", "error", err)
80 HttpErrorResponse(w, "internal server error", http.StatusInternalServerError)
81 return
82 }
83
84 w.Header().Set("Content-Type", "application/json")
85 json.NewEncoder(w).Encode(stats)
86}
I removed some validation logic for security reasons, but here we handle what happens when we receive a like, or when the client requests the stats of the application. The most important line is the following:
1stats, err := h.repo.IncrementViews(r.Context(), slug) // in HandleGetStats
2stats, err := h.repo.IncrementLikes(r.Context(), slug) // in HandleLikes
The handler calls the repo, incrementing the likes when the HandleLikes handler function is invoked, and when the user requests stats, the Views count is incremented. Now's a perfect time to discuss the repo.
The Repo
The repo is responsible for interaction with the database. It accepts a db that implements the minimal interface we need for our own usage.
1package repo
2
3import (
4 "context"
5 "github.com/thornhall/blog/internal/db"
6)
7
8type Repo struct {
9 db db.DB
10}
11
12func New(db db.DB) *Repo {
13 return &Repo{
14 db: db,
15 }
16}
17
18type Stats struct {
19 Slug string `json:"slug"`
20 Likes int `json:"likes_count"`
21 Views int `json:"views_count"`
22}
23
24func (r *Repo) IncrementViews(ctx context.Context, slug string) (Stats, error) {
25 var s Stats
26 s.Slug = slug
27
28 row := r.db.QueryRowContext(ctx, `
29 INSERT INTO post_stats (slug, views, likes) VALUES (?, 1, 0)
30 ON CONFLICT(slug) DO UPDATE SET views = views + 1 RETURNING views, likes
31 `, slug)
32 err := row.Scan(&s.Views, &s.Likes)
33
34 return s, err
35}
36
37func (r *Repo) IncrementLikes(ctx context.Context, slug string) (Stats, error) {
38 var s Stats
39 s.Slug = slug
40
41 row := r.db.QueryRowContext(ctx, "UPDATE post_stats SET likes = likes + 1 WHERE slug = ? RETURNING views, likes", slug)
42 err := row.Scan(&s.Views, &s.Likes)
43
44 return s, err
45}
46
47func (r *Repo) GetStats(ctx context.Context, slug string) (Stats, error) {
48 var s Stats
49 s.Slug = slug
50
51 row := r.db.QueryRowContext(ctx, "SELECT views, likes FROM post_stats WHERE slug = ?", slug)
52 err := row.Scan(&s.Views, &s.Likes)
53
54 return s, err
55}
I didn't want to share every line of code of the app for the sake of brevity, but that really covers 90% of what powers this app. However, there's a key component I've completely skipped: The static site generation.
Static Site Generation
Up to this point, I've only talked about how the app serves the already-generated HTML files. How do we generate those in the first place? I won't show all the code here for brevity, but the flow for generating a post looks like this:
- Grab the Post template from the
templatesdirectory. - Using the
.mdfile in thecontentdirectory matching the slug the user requested, populate the template with the article content.
The template is actually HTML, CSS, and a little bit of JavaScript. In it, we use delimeters to specify where to render the post content on the page. These delimeters tell the templating engine exactly how to "merge" our template with our actual post.
Conclusion
That's it. That's most of the app. The question of "how is it deployed" I will leave for another time. I had a blast making this app. In the future I plan to write about projects I've worked on. I'm going to go in-depth about the Grand Exchange clone I built next.