A Simple Blog with Go

I built a simple / basic web blog with Go / GoLang along with a CMS / admin panel to manage its content. Here are the steps I did.

Front-End

It is server-side rendering. That means the back-end (GoLang) is rendering the HTML using the Go template. You can visit this article for more explanation about the Go template.

I created a plain HTML/CSS web without any JavaScript framework or library, just TailwindCSS. You can clone the repository on https://github.com/aristorinjuang/blog-frontend.

I created 6 pages. These pages are enough for a basic blog along with its CMS.

Back-End

I integrated the front-end into the back-end. Powered by GoLang and MySQL. I don't use any framework such as Echo, Mux, etc. Here are the Go modules that I use.

1
2
3
4
5
6
7
8
9
10
11
module github.com/aristorinjuang/blog

go 1.16

require (
    github.com/go-sql-driver/mysql v1.5.0
    github.com/google/uuid v1.2.0
    github.com/gosimple/slug v1.9.0
    github.com/joho/godotenv v1.3.0
    golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83
)

Database

I created two tables for this blog. I think they are enough for a basic blog.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CREATE TABLE `articles` (
  `id` int(11) NOT NULL,
  `image` varchar(255) DEFAULT NULL,
  `slug` varchar(255) NOT NULL,
  `title` varchar(60) NOT NULL,
  `content` text NOT NULL,
  `author` int(11) NOT NULL,
  `created_at` datetime NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `users` (
  `id` int(11) NOT NULL,
  `name` varchar(64) NOT NULL,
  `email` varchar(320) NOT NULL,
  `password` varchar(255) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Assets

The only asset for this project is tailwind.css. I put it in the assets folder.

Helpers

The only helper I had for this project is to validate an email. I put it on the helpers folder.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package helpers

import (
    "errors"
    "regexp"
)

var emailRegexp = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")

// ValidateEmail validates email based on regex format.
func ValidateEmail(email string) error {
    if !emailRegexp.MatchString(email) {
        return errors.New("invalid format")
    }

    return nil
}

Here are the unit tests for it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package helpers

import "testing"

var samples = []struct {
    email   string
    correct bool
}{
    {"florian@carrere.cc", true},
    {"support@g2email.com", true},
    {" florian@carrere.cc", false},
    {"florian@carrere.cc ", false},
    {"test@912-wrong-domain902.com", true},
    {"0932910-qsdcqozuioqkdmqpeidj8793@gemail.com", true},
    {"@gemail.com", false},
    {"test@gemail@gemail.com", false},
    {"test test@gemail.com", false},
    {" test@gemail.com", false},
    {"test@wrong domain.com", false},
    {"é&ààà@gemail.com", false},
    {"admin@busyboo.com", true},
    {"a@gemail.fi", true},
}

func TestValidateEmail(t *testing.T) {
   for _, s := range samples {
        err := ValidateEmail(s.email)
        switch {
        case err != nil && s.correct == true:
            t.Errorf(`"%s" => unexpected error: "%v"`, s.email, err)
        case err == nil && s.correct == false:
             t.Errorf(`"%s" => expected error`, s.email)
        }
    }
}

func BenchmarkValidateEmail(b *testing.B) {
    for i := 0; i < b.N; i++ {
        for _, s := range samples {
            err := ValidateEmail(s.email)
            switch {
            case err != nil && s.correct == true:
                b.Errorf(`"%s" => unexpected error: "%v"`, s.email, err)
            case err == nil && s.correct == false:
                b.Errorf(`"%s" => expected error`, s.email)
            }
        }
    }
}

Views

This project is on Model-View-Controller (MVC) architectural pattern. I already have the HTML pages. I converted them to be Go templates. They can be called View. You can view them on GitHub for more details.

Model

I don't use GORM. I created my Object-Relational Mapping (ORM) itself.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
package models

import (
    "database/sql"
    "log"
    "time"
)

// User is a model for users.
type User struct {
    ID       int
    Name     string
    Email    string
    Password []byte
}

// Article is a model for articles.
type Article struct {
    ID        int
    Image     string
    Slug      string
    Title     string
    Content   string
    Author    User
    CreatedAt time.Time
}

var (
    // Db is a database connection.
    Db *sql.DB

    // Err is an error returned.
    Err error
)

// FindArticle is to print an article.
func FindArticle(slug string) *Article {
    rows, err := Db.Query(`SELECT articles.image, articles.title, articles.content, users.name, articles.created_at FROM articles JOIN users ON users.id = articles.author WHERE slug = ?`, slug)
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()

    var createdAt []byte
    user := &User{}
    article := &Article{}

    for rows.Next() {
        err = rows.Scan(&article.Image, &article.Title, &article.Content, &user.Name, &createdAt)
        if err != nil {
            log.Fatal(err)
        }
        parsedCreatedAt, err := time.Parse("2006-01-02 15:04:05", string(createdAt))
        if err != nil {
            log.Fatal(err)
        }
        article.CreatedAt = parsedCreatedAt
        article.Author = *user
    }

    return article
}

// Articles is a list of all articles.
func Articles() []*Article {
    var articles []*Article

    rows, err := Db.Query(`SELECT articles.id, articles.image, articles.slug, articles.title, articles.content, users.name, articles.created_at FROM articles JOIN users ON users.id = articles.author`)
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()

    for rows.Next() {
        var (
            id        int
            image     string
            slug      string
            title     string
            content   string
            author    string
            createdAt []byte
        )
        err = rows.Scan(&id, &image, &slug, &title, &content, &author, &createdAt)
        if err != nil {
            log.Fatal(err)
        }
        parsedCreatedAt, err := time.Parse("2006-01-02 15:04:05", string(createdAt))
        if err != nil {
            log.Fatal(err)
        }
        user := User{
            Name: author,
        }
        articles = append(articles, &Article{id, image, slug, title, content, user, parsedCreatedAt})
    }

    return articles
}

// Find finds a user by email.
func (user User) Find() *User {
    rows, err := Db.Query(`SELECT * FROM users WHERE email = ?`, user.Email)
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()

    for rows.Next() {
        err = rows.Scan(&user.ID, &user.Name, &user.Email, &user.Password)
        if err != nil {
            log.Fatal(err)
        }
    }

    return &user
}

// FindArticle finds an user article by ID.
func (user User) FindArticle(id int) *Article {
    rows, err := Db.Query(`SELECT image, slug, title, content, created_at FROM articles WHERE id = ? AND author = ?`, id, user.ID)
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()

    var createdAt []byte
    article := &Article{
        ID:     id,
        Author: user,
    }

    for rows.Next() {
        err = rows.Scan(&article.Image, &article.Slug, &article.Title, &article.Content, &createdAt)
        if err != nil {
            log.Fatal(err)
        }
        parsedCreatedAt, err := time.Parse("2006-01-02 15:04:05", string(createdAt))
        if err != nil {
            log.Fatal(err)
        }
        article.CreatedAt = parsedCreatedAt
    }

    return article
}

// FindArticles finds user articles.
func (user User) FindArticles() []*Article {
    var articles []*Article

    rows, err := Db.Query(`SELECT id, image, slug, title, content, created_at FROM articles WHERE author = ?`, user.ID)
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()

    for rows.Next() {
        var (
            id        int
            image     string
            slug      string
            title     string
            content   string
            createdAt []byte
        )
        err = rows.Scan(&id, &image, &slug, &title, &content, &createdAt)
        if err != nil {
            log.Fatal(err)
        }
        parsedCreatedAt, err := time.Parse("2006-01-02 15:04:05", string(createdAt))
        if err != nil {
            log.Fatal(err)
        }
        articles = append(articles, &Article{id, image, slug, title, content, user, parsedCreatedAt})
    }

    return articles
}

// Create creates a user.
func (user User) Create() *User {
    result, err := Db.Exec("INSERT INTO users(name, email, password) VALUES(?, ?, ?)", user.Name, user.Email, user.Password)
    if err != nil {
        log.Fatal(err)
    }
    id, err := result.LastInsertId()
    if err != nil {
        log.Fatal(err)
    }
    if id != 0 {
        user.ID = int(id)
    }

    return &user
}

// CreateArticle creates an article.
func (user User) CreateArticle(article *Article) {
    _, err := Db.Exec(
        "INSERT INTO articles(image, slug, title, content, author, created_at) VALUES(?, ?, ?, ?, ?, ?)",
        article.Image,
        article.Slug,
        article.Title,
        article.Content,
        article.Author.ID,
        article.CreatedAt,
    )
    if err != nil {
        log.Fatal(err)
    }
}

// UpdateArticle updates an article.
func (user User) UpdateArticle(article *Article) {
    _, err := Db.Exec(
        "UPDATE articles SET image = ?, slug = ?, title = ?, content = ? WHERE id = ? AND author = ?",
        article.Image,
        article.Slug,
        article.Title,
        article.Content,
        article.ID,
        user.ID,
    )
    if err != nil {
        log.Fatal(err)
    }
}

// DeleteArticle deletes an article.
func (user User) DeleteArticle(article *Article) {
    _, err := Db.Exec(
        "DELETE FROM articles WHERE id = ? AND author = ?",
        article.ID,
        user.ID,
    )
    if err != nil {
        log.Fatal(err)
    }
}

Controller

The controller handles HTTP requests. Sessions are stored in Cookies. Each action depends on the HTTP method. It's quite messy without any framework.

Conclusion

The main goal here is to demonstrate how GoLang is also cool enough to create a server-side web rendering. Without any library of a framework, ORM, view, or testing. You can clone this project on https://github.com/aristorinjuang/blog.

I think PHP is the best for server-side web rendering. But PHP needs PHPUnit to handle the tests and GoLang can handle it natively.

Related Articles