00%
blog.info()
← Back to Home
SEQUENCE // Building The Blog

Implementing a Hybrid Blog Engine with Go and SQLite

Author Thorn Hall
0

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 templates directory.
  • Using the .md file in the content directory 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.

View Abstract Syntax Tree (Build-Time Generated)
Document
Blockquote
Paragraph
Text "When you have a hammer, eve..."
Text " nail."
Paragraph
Text "It's no secret that I've be..."
Text ""
Text "an in-memory key-value stor..."
Text " autocompletes"
Text "brackets when coding in Go ..."
Text " it."
Paragraph
Text "I thought, why stop there? ..."
Text " Go?"
Paragraph
Text "I had two requirements for my"
Text " SSG:"
List
ListItem
TextBlock
Text "Likes and Views are dynamic..."
Text " likes)"
ListItem
TextBlock
Text "I can write new posts in a ..."
Text " format"
Heading
Text "Why I Created This"
Text " Blog"
Paragraph
Text "You may have noticed that I..."
Text ". Why create a new"
Text " one?"
List
ListItem
TextBlock
Text "That blog uses a template t..."
Text " page."
ListItem
TextBlock
Text "The other blog also uses Je..."
Text " myself."
ListItem
TextBlock
Text "I wanted dynamic likes and ..."
Text " setup."
Heading
Text "The Tech Stack: Go + SQLite..."
Text " JavaScript"
Paragraph
Text "I wanted to keep my website..."
CodeSpan
Text "SQLite"
Text ", an embedded database. Wha..."
Text " mean?"
Text "It means that the database ..."
Text " process"
Text "that your app communicates ..."
Text " overkill."
Paragraph
Text "Go powers the API that rece..."
Text " now."
Paragraph
Text "Our app is server rendered,..."
Text " Well,"
Text "besides one part of the app..."
Text " up"
Text "load times on low-end devic..."
Text " page."
Heading
Text "The Data"
Text " Model"
Paragraph
Text "Our app is very simple. We ..."
Text " side:"
FencedCodeBlock code: "type Stats struct { "
Paragraph
Text "Slug is an extremely nasty ..."
Text " is"
CodeSpan
Text "https://thorn.sh/go-sqlite/"
Text ". In this case, the "
CodeSpan
Text "go-sqlite"
Text " is the"
Text " slug."
Paragraph
Text "By the way, the syntax high..."
Text " :)"
Heading
Text "The Application"
Text " Structure"
Paragraph
Text "Our server is a Go applicat..."
Text " structure:"
FencedCodeBlock code: "// cmd (the entry..."
Paragraph
Text "Let's walk through a basic ..."
CodeSpan
Text "net/http"
Text " package in the context of ..."
Text " server."
FencedCodeBlock code: "package main "
Paragraph
Text "This is how we create a ser..."
Text " domain."
Text "The domain is its own topic..."
CodeSpan
Text "public"
Text " directory,"
Text "which we know from the file..."
CodeSpan
Text "./public"
Text "."
Paragraph
Text "Note that we also create a ..."
Text " section."
Paragraph
Text "Next is the "
CodeSpan
Text "main"
Text " function of "
CodeSpan
Text "main.go"
Text " for the server. This funct..."
Text " program."
FencedCodeBlock code: "func main() { "
Paragraph
Text "In this code, we start the ..."
Text " thread."
Text "Starting the server in its ..."
Text " this."
Heading
Text "The"
Text " Router"
Paragraph
Text "The next most important par..."
Text " executed"
Text "depending on how you (the c..."
Text " app."
FencedCodeBlock code: " "
Paragraph
Text "This code is telling the se..."
Text " find"
Text "the media (such as pictures..."
Text " site."
Paragraph
Text "Notice that it takes a hand..."
Text " executed"
Text "when the user requests a sp..."
CodeSpan
Text "GET https://thorn.sh/go-sql..."
Text ". That happens to be"
Text ""
Text "default behavior when using a "
CodeSpan
Text "FileServer"
Text ", a standard library packag..."
Emphasis
Text "The Handler is our custom c..."
CodeSpan
Text "router"
Text "."
Heading
Text "The"
Text " Handler"
FencedCodeBlock code: "package handler "
Paragraph
Text "I removed some validation l..."
Text " client"
Text "requests the stats of the a..."
Text " following:"
FencedCodeBlock code: "stats, err := h.r..."
Paragraph
Text "The handler calls the repo,..."
Text " stats,"
Text "the Views count is incremen..."
Text " repo."
Heading
Text "The"
Text " Repo"
Paragraph
Text "The repo is responsible for..."
Text " usage."
FencedCodeBlock code: "package repo "
Paragraph
Text "I didn't want to share ever..."
Text " there's"
Text "a key component I've comple..."
Text " generation."
Heading
Text "Static Site"
Text " Generation"
Paragraph
Text "Up to this point, I've only..."
Emphasis
Text "already-generated"
Text " HTML files. How do we gene..."
Text " this:"
List
ListItem
TextBlock
Text "Grab the Post template from..."
CodeSpan
Text "templates"
Text " directory."
ListItem
TextBlock
Text "Using the "
CodeSpan
Text ".md"
Text " file in the "
CodeSpan
Text "content"
Text " directory matching the slu..."
Text " content."
Paragraph
Text "The template is actually HT..."
Text " page."
Text "These delimeters tell the t..."
Text " post."
Heading
Text "Conclusion"
Paragraph
Text "That's it. That's most of t..."
Text " next."