DEV Community

Mark Saward
Mark Saward

Posted on • Originally published at manse.cloud

A simple blog in Go

It has been a hectic two months. My plans to learn rust have been shelved for now, due to far too much work. However, that hasn't stopped me from doing what I originally tried to wisely avoid: building my own blogging platform from scratch. The reason? I wanted a simple blogging platform that would allow me to play around with and demonstrate new interactive features.

In the last four months I've been doing a lot of reflecting and re-evaluating the ways that we build websites at Episub. I'll likely go into these ideas in future posts, but will give a very short overview here. The sorts of websites we tend to build are ones that are useful as tools for our clients and their staff and/or customers. This means very interactive sites, often involving building tools that automate processes that were previously manual. In recent years, we've moved from traditional ways of building websites (server side rendering, minimal javascript) to modern SPA solutions. In our case, this means a GraphQL API on the back-end, and a React frontend that fetches data from the API to render.

I took some time to step back from this, and reflect on what we have gained from transitioning to such ways of building websites, and what we have lost. And it's become clear to me that these SPA type sites impose significant burdens on development that we just don't need. A (what I think is) clear example of this are how many layers/abstractions we have. In the SPA with GraphQL+React way of building sites, data flows like so:

  • Database -> Code to translate between database model and API model -> API (GraphQL) -> Front-end fetch -> Render

In the traditional server-side rendered approach, for simple sites, we only need:

  • Database -> Application fetch from DB -> Render

In this simple case, we can just fetch data directly from the database in whatever shape we please, and skip the whole API translation work. Of course, we can't remove the need for some translation work entirely, unless our application (e.g., a website) is quite simple. Typically that means we still need code that implements our business logic so that we can have a consistent layer no matter which application is connecting. However, there is a significant impedance that comes from working through an API as opposed to calling on the database more directly. Related to this, I've also been exploring 'thick databases', a notion I previously considered anathema (for no good reason). This involves putting business logic in a separate schema in the database, that acts as a window to the underlying database, so that the database model can still differ from the model that we present the world. In this way, every application, regardless of language, can connect to the database and automatically be adhering to our business rules.

I'm optimistic that this will be a simpler setup. It may still involve the same steps for the data flow, but in a much simpler way. Our websites will work much closer to the way the web was build, without trying to replace it as an entire javascript based application. We don't entirely cut out the translation layer, but we do simplify it. I still build the database schema as I always would, and then build a separate API schema on top, inside the database, that uses views to provide the model that makes sense for applications. This 'api' schema implements the business logic, and then we connect to it as we would an ordinary database. Server side rendering websites can talk to the database directly using SQL (a highly valuable ability). And in the case where we need to provide an API, we can use a project like PostgREST, which serves up the api schema we built which implements all the business rules.

What about the interactivity we get from using something like React? Again, a story for another post, but the short of it is I can get most if not all of what I need from a small library like htmx. There are two particularly exciting things about using something like htmx. First, is that for most functionality the site will still function if javascript is disabled. The second is that websites are super quick to load. With Tailwind, htmx, and a splash of other projects where needed, pages can be super slim and the first (as well as all subsequent) load time can be very fast.

Back to my blog. Inspired by returning to this simpler way of building websites, I created a very simple Go server for my blog, less than 200 lines in code (not including html templates), and no longer use Hugo. In addition, I have a very simple postgres schema for now. Using Tailwind CSS and slimming it down to just the classes I need, prism.js for syntax highlighting, and soon htmx for some interactivity, the pages are still <100KB. I now am in a good position to add live demonstrations of things I write about, add some simple interactivity, and expand as I please. Given the simple nature of this blog project, I haven't created a separate api schema and will just work with the database directly for now.

So that's where I am now. A new foundation for my blog, and an optimism about further honing my craft. Still some rough edges (summaries don't mix right with markdown yet, pagination when I have too many posts), but these are minor issues that will be quick to resolve. Here's the code for the blog as it is at this point of time (MIT licence, use it if you please):

package main

import (
    "database/sql"
    "html/template"
    "log"
    "net/http"
    "time"

    "github.com/caarlos0/env/v6"
    "github.com/gofrs/uuid"
    "github.com/gomarkdown/markdown"
    "github.com/jmoiron/sqlx"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    _ "github.com/lib/pq"
)

var dbx *sqlx.DB
// cfg Do not change after initial load, as it may be accessed on multiple threads
var cfg Cfg

var sqlPostFields = "post_id, title, link_name, content, left(content, 200) || '...' AS summary, is_published, published_at"

// Cfg Env config
type Cfg struct {
    DatabaseURL string `env:"DATABASE_URL,required"`
    Debug       bool   `env:"DEBUG" envDefault:"false"`
}

func main() {
    err := env.Parse(&cfg)
    if err != nil {
        log.Fatal(err)
    }

    initDB()
    initRouter()
}

func initDB() {
    var err error
    dbx, err = sqlx.Open("postgres", cfg.DatabaseURL)
    if err != nil {
        log.Panic(err)
    }
}

var csrfSkipRoutes = []string{}

var csrfSkipper = func(c echo.Context) bool {
    for _, v := range csrfSkipRoutes {
        if v == c.Path() {
            return true
        }
    }
    return false
}

func initRouter() {
    fmap := template.FuncMap{
        "markdown": Markdown,
    }
    t := &Template{
        templates: template.Must(template.New("").Funcs(fmap).ParseGlob("web/template/*.html")),
    }
    // Echo instance
    e := echo.New()
    e.Renderer = t
    e.Static("/static", "web/static")

    // Middleware
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    e.Pre(middleware.RemoveTrailingSlash())

    e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
        CookiePath:  "/",
        Skipper:     csrfSkipper,
        TokenLookup: "form:csrf",
    }))

    // Routes
    e.GET("/", indexHandler)
    e.GET("/posts/:name", postViewHandler)

    // Start server
    e.Logger.Fatal(e.Start(":1323"))
}

// templatePayload Returns the provided data under the 'data' tag, along with
// a standard payload of data that may be needed on every page
func templatePayload(c echo.Context, data map[string]interface{}) map[string]interface{} {
    input := make(map[string]interface{})

    if data != nil {
        input["data"] = data
    }

    if csrf, ok := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string); ok {
        input["CSRF"] = csrf
    }
    input["referer"] = c.Request().Referer()
    input["url"] = c.Request().URL

    return input
}

func Markdown(str string) template.HTML {
    md := []byte(str)
    output := markdown.ToHTML(md, nil, nil)

    return template.HTML(output)
}

// post A blog post entry
type post struct {
    PostID      uuid.UUID `db:"post_id"`
    Title       string    `db:"title"`
    LinkName    string    `db:"link_name"`
    Content     string    `db:"content"`
    Summary     string    `db:"summary"`
    IsPublished bool      `db:"is_published"`
    PublishedAt time.Time `db:"published_at"`
}

func indexHandler(c echo.Context) error {
    posts, err := postList()
    if err != nil {
        return err
    }

    data := map[string]interface{}{
        "posts": posts,
    }

    return c.Render(http.StatusOK, "index.html", templatePayload(c, data))
}

func postViewHandler(c echo.Context) error {
    name := c.Param("name")
    p, err := postByName(name)
    if err != nil {
        if err == sql.ErrNoRows {
            return echo.NewHTTPError(http.StatusNotFound)
        }
        return err
    }

    data := map[string]interface{}{
        "post": p,
    }

    return c.Render(http.StatusOK, "post_view.html", templatePayload(c, data))
}

func postList() ([]post, error) {
    var pl []post
    qry := "SELECT " + sqlPostFields + " FROM manse.post"

    if !cfg.Debug {
        qry = qry + " WHERE is_published = true"
    }

    qry = qry + " ORDER BY published_at DESC"

    err := dbx.Select(&pl, qry)
    return pl, err
}

func postByName(name string) (post, error) {
    var p post
    qry := "SELECT " + sqlPostFields + " FROM manse.post WHERE link_name = $1"

    if !cfg.Debug {
        qry = qry + " AND is_published = true"
    }

    qry = qry + " ORDER BY published_at DESC"

    err := dbx.Get(&p, qry, name)
    return p, err
}
Enter fullscreen mode Exit fullscreen mode

Oldest comments (4)

Collapse
 
mark_saward profile image
Mark Saward

The blog is hosted on a raspberry pi at my house, in Australia. I'd be interested to hear for those of you connecting from outside Australia, if your experience is also that it loads quickly (click on the link at the start of the article, "Originally published at manse.cloud").

Collapse
 
wolfhoundjesse profile image
Jesse M. Holmes

It's insanely fast from Annapolis, Maryland, USA. Don't mind me while I poke around in there. 😊

Collapse
 
souksyp profile image
Souk Syp.

Echo is what I need to learn go. Lucky I found and read your post 🥳

Collapse
 
mark_saward profile image
Mark Saward

Glad it helped :) Echo is just a bit more opinionated than the Go standard library http stuff. I wouldn't use echo for everything, but it's handy to bootstrap some common things.