Golang + Iris + Bolt 自制短链接生成器

如果你常逛微博或是贴吧这类的社交网站,你可能会注意到,在我们发布长链接的时候,系统会自动将这个长的链接转换为短链接,这样做美观且便于记忆

例如一个短链接 它长这样:http://t.cn/A6Uzwo1C

拿自己的域名生成属于自己的短链接难道不香吗?

http://qwqaq.com/balaballl(然而并没有钱买短域名)

让我们首先回顾一下基础知识:

只是纯粹的工具

编程语言只是我们的工具,但我们需要安全、快速和跨平台的编程语言来帮助我们编写程序。

Go 是一种快速发展的开源编程语言,旨在构建简单、快速和可靠的程序。

有哪些NB的公司在用 Go?传送门

安装 Go

关于 Go 语言环境的下载和安装方法可以在互联网上轻松找到,详细过程这里就先略过了。

官网传送门 | Ubuntu 16.04 安装 Go - YouTube | Windows | macOS

友情提示:本文不包含介绍 Go 语言基础知识的内容,如果你是一个新手并对基础知识不太了解的话,强烈建议首先将这篇文章放入收藏夹,学习完基础后再来阅读~~

使用框架

可能很多文章或评论会写,反对人们使用 Web 框架,认为用框架是种弱鸡行为,或者指出框架的各种缺点,导致不少人对框架有抵触的心理。

是否使用框架是你自己的意愿。在工作层面,通常,我们不能花太多时间来编写一些无关于目标的代码,用框架就能帮我们高效地完成目标,并且更有助于团队协作,降低成员的学习成本,统一的标准让团队间进行的 Coding 少了一大堆不必要的麻烦。

简而言之:好框架对于任何开发者、公司而言就是很有帮助的工具,烂框架则是浪费时间。简单明了

Good frameworks are helpful tools for any developer, company or startup and bad are waste of time, clear and simple.

所需依赖

制作一个短链接生成器需要用到的 Go 依赖

安装以上依赖通过执行以下命令就能轻松完成,你仅需将其粘贴到你的终端执行即可:

1
2
3
go get -u github.com/kataras/iris/v12@latest
go get -u github.com/etcd-io/bbolt
go get -u github.com/satori/go.uuid

编写程序

优秀!如果你我意见一致,那是时候学习用 Golang + Iris 框架 + Bolt 数据库 来编写一个易部署、易扩展的 URL 短链接服务程序了。

为了生成短链接,我们会生成随机的字符串,并用 Bolt 数据库来储存其对应的原始链接。用户生成短链接,就是得到一串随机字符串,然后再拿这串字符作为 key 去数据库中查询,得到原始的链接。简而言之,这就是一个 encode 和 decode 的过程。如果用短链接在数据库中查询,能找到原始链接,那我们就执行重定向,否者我们会给出相应的错误提示。

假定项目位于:$GOPATH/src/you/shortener,并且 package 的名字为 main

前端代码就随便写写,只包含一个 index 页面和一点点 CSS 代码。

模板文件统一放到 ./templates/ 目录下,CSS 文件放到 ./resources/css 目录。

./templates/index.html

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
<html>

<head>
<meta charset="utf-8">
<title>Golang 短链接生成器</title>
<link rel="stylesheet" href="/static/css/style.css" />
</head>

<body>
<h2>Golang 短链接生成器</h2>
<h3>{{ .FORM_RESULT}}</h3>
<form action="/shorten" method="POST">
<input type="text" name="url" style="width: 35em;" />
<input type="submit" value="Shorten!" />
</form>
{{ if IsPositive .URL_COUNT }}
<p>已帮助创建 {{ .URL_COUNT }} 个短链接</p>
{{ end }}

<form action="/clear_all" method="POST">
<input type="submit" value="Clear DB" />
</form>
</body>

</html>

./resources/css/style.css

1
2
3
body{
background-color:silver;
}

让我们直接切入到数据库代码编写的环节,创建一个简单的实例,它能够保存 短链接(key)原始/完整的链接(value)。过后,可以通过 key 来获取完整链接,可以查询数据库中短链接的总数

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
package main

import (
"bytes"

bolt "github.com/etcd-io/bbolt"
)

// Panic 发生错误时执行
var Panic = func(v interface{}) {
panic(v)
}

// Store 为所有链接提供的存储 interface
// 注: for short 并没有实现删除功能
type Store interface {
Set(key string, value string) error // error 如果存储操作的时候发生了错误
Get(key string) string // empty 若未找到 短链接 对应的 原始链接
Len() int // records/tables/buckets 的数量
Close() // 释放 Store 实例或忽视它
}

var tableURLs = []byte("urls")

// DB 代表一个 Store
// 仅简单拿一个 table/bucket 来保存 urls 就足够了, 所以我们没必要搞一整个数据库
// 我们每次只需获取一个 bucket,因为那就是我们需要获得 urls 数据的全部
type DB struct {
db *bolt.DB
}

var _ Store = &DB{}

// openDatabase 打开数据库文件,创建连接
// return 数据库实例
func openDatabase(stumb string) *bolt.DB {
// 打开数据(库)文件,在当前程序的工作路径下
// 如果文件不存在,会创建一个新的
db, err := bolt.Open(stumb, 0600, nil)
if err != nil {
Panic(err)
}

// 创建 buckets
tables := [...][]byte{
tableURLs,
}

db.Update(func(tx *bolt.Tx) (err error) {
for _, table := range tables {
_, err = tx.CreateBucketIfNotExists(table)
if err != nil {
Panic(err)
}
}

return
})

return db
}

// NewDB 返回新的 DB 实例,它的连接将被自动打开
// DB 就代表 Store
func NewDB(stumb string) *DB {
return &DB{
db: openDatabase(stumb),
}
}

// Set 保存一个短链接数据(key: 短链接(随机字符串), value: 原始链接)
// 注:该函数在创建 key 时被调用
func (d *DB) Set(key string, value string) error {
return d.db.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists(tableURLs)
// 注: 可以为 URL 记录创建 ID 来代替 随机字符串
// 但是,我们想模拟真实的短链接生成器
// 所以我们直接跳过 ID 的生成
// id, _ := b.NextSequence()
if err != nil {
return err
}

k := []byte(key)
valueB := []byte(value)
c := b.Cursor()

found := false
for k, v := c.First(); k != nil; k, v = c.Next() {
if bytes.Equal(valueB, v) {
found = true
break
}
}
// 如果原始链接已存在,我们就不用再保存新的短链接了
if found {
return nil
}

return b.Put(k, []byte(value))
})
}

// Clear 清空短链接的 Bucket,删除所有短链接
func (d *DB) Clear() error {
return d.db.Update(func(tx *bolt.Tx) error {
return tx.DeleteBucket(tableURLs)
})
}

// Get 通过短链接(key)来获取原始链接(value)
//
// Returns 未找到则返回 nli
func (d *DB) Get(key string) (value string) {
keyB := []byte(key)
d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(tableURLs)
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
if bytes.Equal(keyB, k) {
value = string(v)
break
}
}

return nil
})

return
}

// GetByValue 获取指定 原始链接(value) 的 所有短链接(keys)
func (d *DB) GetByValue(value string) (keys []string) {
valueB := []byte(value)
d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(tableURLs)
if b == nil {
return nil
}
c := b.Cursor()
// 不断遍历 bucket 中的所有数据
for k, v := c.First(); k != nil; k, v = c.Next() {
if bytes.Equal(valueB, v) {
keys = append(keys, string(k))
}
}

return nil
})

return
}

// Len 返回全部短链接数量
func (d *DB) Len() (num int) {
d.db.View(func(tx *bolt.Tx) error {
// 假设 bucket 存在,并且有 keys
b := tx.Bucket(tableURLs)
if b == nil {
return nil
}

b.ForEach(func([]byte, []byte) error {
num++
return nil
})
return nil
})
return
}

// Close 关闭数据(文件)的连接
func (d *DB) Close() {
if err := d.db.Close(); err != nil {
Panic(err)
}
}

让我们创建一个 factory,它将为我们生成短链接把!

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
package main

import (
"net/url"

"github.com/iris-contrib/go.uuid"
)

// Generator 用来生成 keys(短链接) 的 type
type Generator func() string

// DefaultGenerator 默认的 地址生成器
var DefaultGenerator = func() string {
id := uuid.NewV4()
return id.String()
}

// Factory 负责生成 keys(短链接)
type Factory struct {
store Store
generator Generator
}

// NewFactory 传递一个地址生成器将会输出一个 Factory
func NewFactory(generator Generator, store Store) *Factory {
return &Factory{
store: store,
generator: generator,
}
}

// Gen 生成 key(短链接)
func (f *Factory) Gen(uri string) (key string, err error) {
// 我们无需返回被解析的 url,因为 #hash 会被转换为 uri-compatible 的形式
// 并且我们一直都不对其进行 encode/decode 操作, 因为没有必要
// 如果 uri 检验通过,我们将按用户的期望保存原始的地址。
_, err = url.ParseRequestURI(uri)
if err != nil {
return "", err
}

key = f.generator()
// 确保随机生成的 key 是唯一的
for {
if v := f.store.Get(key); v == "" {
break
}
key = f.generator()
}

return key, nil
}

我们应该创建一个 main.go 程序主文件,该文件将组合上面的代码,并创建一个 http Server,这个 Server 将为我们的小型短链接生成服务提供驱动。

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
package main

import (
"html/template"

"github.com/kataras/iris/v12"
)

func main() {
// 为数据库分配一个变量,以便以后使用
db := NewDB("shortener.db")
// 将该数据库传递给我们的 app,以便以后可以使用其他数据库测试整个 app
app := newApp(db)

// 释放 "db" 连接,当服务器结束运行
iris.RegisterOnInterrupt(db.Close)

app.Run(iris.Addr(":8080"))
}

func newApp(db *DB) *iris.Application {
app := iris.Default() // 或 app := iris.New()

// 创建我们的 factory, 用于管理 object 的创建
// 在 web app 和 db 之间搭建的桥梁
factory := NewFactory(DefaultGenerator, db)

// 通过 HTML std 模板引擎渲染 "./templates" 目录下的 "*.html"
// Look https://github.com/kataras/iris/wiki/View
tmpl := iris.HTML("./templates", ".html").Reload(true)
// register any template func(s) here.
//
// Look ./templates/index.html#L16
tmpl.AddFunc("IsPositive", func(n int) bool {
if n > 0 {
return true
}
return false
})

app.RegisterView(tmpl)

// Serve 静态文件 (css)
// Look http://topgoer.com/Iris/%E6%96%87%E4%BB%B6%E6%9C%8D%E5%8A%A1.html?h=HandleDir
app.HandleDir("/static", "./resources")

indexHandler := func(ctx iris.Context) {
ctx.ViewData("URL_COUNT", db.Len())
ctx.View("index.html")
}
app.Get("/", indexHandler)

// 通过其短链接(key)查找并执行短链接(重定向到原始链接 value)
// used on http://localhost:8080/u/dsaoj41u321dsa
execShortURL := func(ctx iris.Context, key string) {
if key == "" {
ctx.StatusCode(iris.StatusBadRequest)
return
}

value := db.Get(key)
if value == "" {
ctx.StatusCode(iris.StatusNotFound)
ctx.Writef("短链接 key: '%s' 未找到", key)
return
}

ctx.Redirect(value, iris.StatusTemporaryRedirect)
}
app.Get("/u/{shortkey}", func(ctx iris.Context) {
execShortURL(ctx, ctx.Params().Get("shortkey"))
})

app.Post("/shorten", func(ctx iris.Context) {
formValue := ctx.FormValue("url")
if formValue == "" {
ctx.ViewData("FORM_RESULT", "请输入 URL")
ctx.StatusCode(iris.StatusLengthRequired)
} else {
key, err := factory.Gen(formValue)
if err != nil {
ctx.ViewData("FORM_RESULT", "错误的 URL")
ctx.StatusCode(iris.StatusBadRequest)
} else {
if err = db.Set(key, formValue); err != nil {
ctx.ViewData("FORM_RESULT", "内部错误:无法保存 URL")
app.Logger().Infof("保存 URL 时发生错误: " + err.Error())
ctx.StatusCode(iris.StatusInternalServerError)
} else {
ctx.StatusCode(iris.StatusOK)
shortenURL := "http://" + app.ConfigurationReadOnly().GetVHost() + "/u/" + key
ctx.ViewData("FORM_RESULT",
template.HTML("<pre><a target='_new' href='"+shortenURL+"'>"+shortenURL+" </a></pre>"))
}
}
}

indexHandler(ctx) // 渲染 index 模板,显示 FORM_RESULT
})

app.Post("/clear_all", func(ctx iris.Context) {
db.Clear()
ctx.Redirect("/")
})

return app
}

最后,就可以 buildrun 我们写的短链接生成器了!

1
2
3
cd $GOPATH/src/github.com/myname/url-shortener
go build # build
./url-shortener # run

测试程序

编写用于程序 Testing 的代码,往往是开发过程中值得完成的一个步骤。因此,以下提供了用于测试 Iris web app 的代码示例:

./main_test.go

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
package main

import (
"io/ioutil"
"os"
"testing"
"time"

"github.com/kataras/iris/v12/httptest"
)

// TestURLShortener 简单测试短链接生成器的任务
// 注:这是一个纯粹的测试
// 其余 checks 代码的撰写取决于你,可以作为练习!
func TestURLShortener(t *testing.T) {
// 临时数据库文件
f, err := ioutil.TempFile("", "shortener")
if err != nil {
t.Fatalf("临时数据库文件创建失败: %v", err)
}

db := NewDB(f.Name())
app := newApp(db)

e := httptest.New(t, app)
originalURL := "https://qwqaq.com"

// 保存
e.POST("/shorten").
WithFormField("url", originalURL).Expect().
Status(httptest.StatusOK).Body().Contains("<pre><a target='_new' href=")

keys := db.GetByValue(originalURL)
if got := len(keys); got != 1 {
t.Fatalf("应该仅存在 1 个唯一的 key(短链接),但程序 save 了 %d 个 key", got)
}

// 获取
e.GET("/u/" + keys[0]).Expect().
Status(httptest.StatusTemporaryRedirect).Header("Location").Equal(originalURL)

// 再次用 相同原始地址 来生成,将会得到一个新的短链接
e.POST("/shorten").
WithFormField("url", originalURL).Expect().
Status(httptest.StatusOK).Body().Contains("<pre><a target='_new' href=")

keys2 := db.GetByValue(originalURL)
if got := len(keys2); got != 1 {
t.Fatalf("即使我们 save 了相同的原始链接,也应该仅有 1 个 key,但获取到了 %d 个 key", got)
} // the key is the same, so only the first one matters.

if keys[0] != keys2[0] {
t.Fatalf("若第二次 save 原始链接相同,则 key(短连接) 也不会发生改变,但 %s != %s", keys[0], keys2[0])
}

// 清空数据库
e.POST("/clear_all").Expect().Status(httptest.StatusOK)
if got := db.Len(); got != 0 {
t.Fatalf("请求 /clear_all 清空数据库之后,应该有 0 个对象,但还是有 %d 个对象", got)
}

// 留点时间来关闭数据库
db.Close()
time.Sleep(1 * time.Second)
// 关闭文件连接
if err := f.Close(); err != nil {
t.Fatalf("无法关闭文件: %s", f.Name())
}

// 删除之前创建的临时数据库文件
if err := os.Remove(f.Name()); err != nil {
t.Fatalf("无法删除文件: %s", f.Name())
}

time.Sleep(1 * time.Second)
}

运行测试:

1
2
cd $GOPATH/src/github.com/myname/url-shortener
go test -v # test

结语

完整的代码位于 此处

另:boltdb/bolt 目前已停止维护,官方推荐转而使用基于 bolt 的 go.etcd.io/bbolt 来代替

本文参考:https://medium.com/hackernoon/a-url-shortener-service-using-go-iris-and-bolt-4182f0b00ae7
原作者:Gerasimos Maropoulos
译者:@qwqcode

本站文章除注明外均采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 协议进行许可 ヾ(゚ー゚ヾ)
分享到