A URL Shortener Service using Go, Iris and Bolt
If you follow the social media sites like Twitter and Google+, you’ll have noticed that rather than use the full URL we use a nice friendly shortened one like t.co/HurCcP0nn9 and bit.ly/iriscandothat1.
Wouldn’t be great to have your own shortened URLs inside your own domain?
Let’s review the basics first.
The Tools
Programming Languages are just tools for us, but we need a safe, fast and “cross-platform” programming language to power our service.
Go is a rapidly growing open source programming language designed for building simple, fast, and reliable software. Take a look here which great companies use Go to power their services.
Install the Go Programming Language
Extensive information about downloading & installing Go can be found here.
The article does not contain an introduction to the language itself, if you’re a newcomer I recommend you to bookmark this article, learn the language’s fundamentals and come back later on.
The Dependencies
Many articles have been written, in the past, that lead users not to use a web framework because they’re “bad”. I have to tell you that there is no such a thing, it always depends on the (web) framework that you’re going to use.In production level, usually, we don’t have the time to code everything that we wanna use in the big company’s applications. In short terms: good frameworks are helpful tools for any developer, company or startup and bad are waste of time, clear and simple.
You’ll need only two required dependencies and one optional:
Iris , our web framework
Bolt (now bbolt), an embedded key/value database
UUID library, will help us to generate the short urls
Installing using go packages is a simple task, just open your terminal and execute the following commands:
$ go get github.com/kataras/iris/v12@latest
$ go get -u github.com/etcd-io/bbolt
$ go get -u github.com/satori/go.uuid
The Best Part
Good, if we’re all in the same page, it’s time to learn how we can create a URL Shortener server that will be easy to deploy and can be extended even more!
To create a short link, we generate a random string and use Bolt to store the original URL with our random string as the key. When a “GET” request is issued to the shortened URL , we retrieve the original URL from Bolt. If such a value exists, we redirect, otherwise we respond with a corresponding message.
For the sake of simplicity let’s say that the project is located at $GOPATH/src/you/shortener *directory and the package name is *main.
Our front-end is ridiculous simple, it contains just an index page and its “style”, templates are located to ./templates folder and the style at ./resources/css folder.
<html>
<head>
<meta charset="utf-8">
<title>Golang URL Shortener</title>
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<h2>Golang URL Shortener</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 }} URLs shortened</p>
{{ end }}
<form action="/clear_cache" method="POST">
<input type="submit" value="Clear DB" />
</form>
</body>
</html>
body{
background-color:silver;
}
Moving straight to the **database **level, we will create a simple implementation which will be able to **save **a shortened URL(key) and its original/full URL(value), **retrieve **a full URL based on the key and return the total number of all registered URLs to the database.
package main
import (
"bytes"
"github.com/etcd-io/bbolt"
)
// Panic panics, change it if you don't want to panic on critical INITIALIZE-ONLY-ERRORS
var Panic = func(v interface{}) {
panic(v)
}
// Store is the store interface for urls.
// Note: no Del functionality.
type Store interface {
Set(key string, value string) error // error if something went wrong
Get(key string) string // empty value if not found
Len() int // should return the number of all the records/tables/buckets
Close() // release the store or ignore
}
var (
tableURLs = []byte("urls")
)
// DB representation of a Store.
// Only one table/bucket which contains the urls, so it's not a fully Database,
// it works only with single bucket because that all we need.
type DB struct {
db *bbolt.DB
}
var _ Store = &DB{}
// openDatabase open a new database connection
// and returns its instance.
func openDatabase(stumb string) *bbolt.DB {
// Open the data(base) file in the current working directory.
// It will be created if it doesn't exist.
db, err := bbolt.Open(stumb, 0600, nil)
if err != nil {
Panic(err)
}
// create the buckets here
var tables = [...][]byte{
tableURLs,
}
db.Update(func(tx *bbolt.Tx) (err error) {
for _, table := range tables {
_, err = tx.CreateBucketIfNotExists(table)
if err != nil {
Panic(err)
}
}
return
})
return db
}
// NewDB returns a new DB instance, its connection is opened.
// DB implements the Store.
func NewDB(stumb string) *DB {
return &DB{
db: openDatabase(stumb),
}
}
// Set sets a shorten url and its key
// Note: Caller is responsible to generate a key.
func (d *DB) Set(key string, value string) error {
return d.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists(tableURLs)
// Generate ID for the url
// Note: we could use that instead of a random string key
// but we want to simulate a real-world url shortener
// so we skip that.
// 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 value already exists don't re-put it.
if found {
return nil
}
return b.Put(k, []byte(value))
})
}
// Clear clears all the database entries for the table urls.
func (d *DB) Clear() error {
return d.db.Update(func(tx *bbolt.Tx) error {
return tx.DeleteBucket(tableURLs)
})
}
// Get returns a url by its key.
//
// Returns an empty string if not found.
func (d *DB) Get(key string) (value string) {
keyB := []byte(key)
d.db.Update(func(tx *bbolt.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 returns all keys for a specific (original) url value.
func (d *DB) GetByValue(value string) (keys []string) {
valueB := []byte(value)
d.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(tableURLs)
if b == nil {
return nil
}
c := b.Cursor()
// first for the bucket's table "urls"
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 returns all the "shorted" urls length
func (d *DB) Len() (num int) {
d.db.View(func(tx *bbolt.Tx) error {
// Assume bucket exists and has keys
b := tx.Bucket(tableURLs)
if b == nil {
return nil
}
b.ForEach(func([]byte, []byte) error {
num++
return nil
})
return nil
})
return
}
// Close shutdowns the data(base) connection.
func (d *DB) Close() {
if err := d.db.Close(); err != nil {
Panic(err)
}
}
Let’s create our factory which will generate the shortened URLs for us!
package main
import (
"net/url"
"github.com/satori/go.uuid"
)
// Generator the type to generate keys(short urls)
type Generator func() string
// DefaultGenerator is the defautl url generator
var DefaultGenerator = func() string {
id, _ := uuid.NewV4()
return id.String()
}
// Factory is responsible to generate keys(short urls)
type Factory struct {
store Store
generator Generator
}
// NewFactory receives a generator and a store and returns a new url Factory.
func NewFactory(generator Generator, store Store) *Factory {
return &Factory{
store: store,
generator: generator,
}
}
// Gen generates the key.
func (f *Factory) Gen(uri string) (key string, err error) {
// we don't return the parsed url because #hash are converted to uri-compatible
// and we don't want to encode/decode all the time, there is no need for that,
// we save the url as the user expects if the uri validation passed.
_, err = url.ParseRequestURI(uri)
if err != nil {
return "", err
}
key = f.generator()
// Make sure that the key is unique
for {
if v := f.store.Get(key); v == "" {
break
}
key = f.generator()
}
return key, nil
}
We should create a main file which will connect and combine all the components to run an http server which will serve our small URL Shortener service.
package main
import (
"html/template"
"github.com/kataras/iris/v12"
)
func main() {
// assign a variable to the DB so we can use its features later.
db := NewDB("shortener.db")
// Pass that db to our app, in order to be able to test the whole app with a different database later on.
app := newApp(db)
// release the "db" connection when server goes off.
iris.RegisterOnInterrupt(db.Close)
app.Run(iris.Addr(":8080"))
}
func newApp(db *DB) *iris.Application {
app := iris.Default() // or app := iris.New()
// create our factory, which is the manager for the object creation.
// between our web app and the db.
factory := NewFactory(DefaultGenerator, db)
// serve the "./templates" directory's "*.html" files with the HTML std view engine.
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 static files (css)
app.StaticWeb("/static", "./resources")
indexHandler := func(ctx iris.Context) {
ctx.ViewData("URL_COUNT", db.Len())
ctx.View("index.html")
}
app.Get("/", indexHandler)
// find and execute a short url by its key
// 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("Short URL for key: '%s' not found", 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", "You need to a enter a URL")
ctx.StatusCode(iris.StatusLengthRequired)
} else {
key, err := factory.Gen(formValue)
if err != nil {
ctx.ViewData("FORM_RESULT", "Invalid URL")
ctx.StatusCode(iris.StatusBadRequest)
} else {
if err = db.Set(key, formValue); err != nil {
ctx.ViewData("FORM_RESULT", "Internal error while saving the URL")
app.Logger().Infof("while saving 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) // no redirect, we need the FORM_RESULT.
})
app.Post("/clear_cache", func(ctx iris.Context) {
db.Clear()
ctx.Redirect("/")
})
return app
}
Finally… let’s build and run our application!
$ cd $GOPATH/src/github.com/myname/url-shortener
$ go build # build
$ ./url-shortener # run
BONUS section
Testing our apps is always a useful thing to do, therefore I’ll give you the first idea on how you can test these type of web applications with Iris, straight to the code below.
package main
import (
"io/ioutil"
"os"
"testing"
"time"
"github.com/kataras/iris/v12/httptest"
)
// TestURLShortener tests the simple tasks of our url shortener application.
// Note that it's a pure test.
// The rest possible checks is up to you, take it as as an exercise!
func TestURLShortener(t *testing.T) {
// temp db file
f, err := ioutil.TempFile("", "shortener")
if err != nil {
t.Fatalf("creating temp file for database failed: %v", err)
}
db := NewDB(f.Name())
app := newApp(db)
e := httptest.New(t, app)
originalURL := "https://google.com"
// save
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("expected to have 1 key but saved %d short urls", got)
}
// get
e.GET("/u/" + keys[0]).Expect().
Status(httptest.StatusTemporaryRedirect).Header("Location").Equal(originalURL)
// save the same again, it should add a new key
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("expected to have 1 keys even if we save the same original url but saved %d short urls", got)
} // the key is the same, so only the first one matters.
if keys[0] != keys2[0] {
t.Fatalf("expected keys to be equal if the original url is the same, but got %s = %s ", keys[0], keys2[0])
}
// clear db
e.POST("/clear_cache").Expect().Status(httptest.StatusOK)
if got := db.Len(); got != 0 {
t.Fatalf("expected database to have 0 registered objects after /clear_cache but has %d", got)
}
// give it some time to release the db connection
db.Close()
time.Sleep(1 * time.Second)
// close the file
if err := f.Close(); err != nil {
t.Fatalf("unable to close the file: %s", f.Name())
}
// and remove the file
if err := os.Remove(f.Name()); err != nil {
t.Fatalf("unable to remove the file from %s", f.Name())
}
time.Sleep(1 * time.Second)
}
Running the test
$ cd $GOPATH/src/github.com/myname/url-shortener
$ go test -v # test
That’s all, pretty simple. Right?
Share your thoughts about this post and let me know what awesome apps you’re planing to build with Go + Iris!
Thanks for taking the time to read the whole article, I admire you for your patience :)
The full source code is located here.
If you have any further questions please feel free to leave a comment below or ask here!
Thank you, once again
Don't forget to check out my medium profile and twitter as well, I'm posting some stuff there too:)