loading...

A URL Shortener Service using Go, Iris and Bolt

kataras profile image Gerasimos (Makis) Maropoulos Updated on ・9 min read

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:)

Discussion

pic
Editor guide
Collapse
chromadream profile image
Jonathan Nicholas

Pretty cool tutorial, I like it.

However, Iris should be avoided tbh, not because Go devs mostly hated frameworks, but because the author has a tendency to rewrite Git history, removing contributors' contributions.

Read more: reddit.com/r/golang/comments/b481q...

Edit: I messed up, and I'm sorry.

Collapse
kataras profile image
Gerasimos (Makis) Maropoulos Author

Hello, I am glad you liked it!

About your comments, I am honesty believe that you are one of those people that don't really want to deflame Iris, you just fell into a scam in the middle of a personal attack. When you have time, Check the repo by yourself, including the history, the prs and the code and write down your results, please.

Also, check out one of my newest projects, Iris depends on that for its routing: github.com/kataras/muxie, I am waiting for your feedback if you really care so much about my development process.

Another good article to read is the "top 6 web frameworks for go" by usejournal.com: blog.usejournal.com/top-6-web-fram...

Sincerely,

Collapse
chromadream profile image
Jonathan Nicholas

Thanks for clarifying.

I got sucked into the Reddit flaming, and I'm sorry for it.

Collapse
theodesp profile image
Theofanis Despoudis

Jonathan, You are talking to the author of Iris!

Collapse
anaganisk profile image
Sai Kiran

You just replied to the author of Iris my man.

Collapse
rhymes profile image
rhymes

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

shouldn't the short link be some sort of hash of the URL?

Collapse
kataras profile image
Gerasimos (Makis) Maropoulos Author

You are right, however, this tutorial was for the very beginners. Devs with experience can change the algorithm with ease, it lives in a single spot in the source code. Hashing wouldn't allow to push duplicates as well, the method we use here is by giving an UUID to the link, so the link can be after linked to a database per user for example. The article was mostly written to learn how you can link a database with the logic and the actual routes by creating something all of us know, url short links. However, I am 100% open to continue by uploading a new post (when I finish the new release of Iris) which will describe more advanced features, not only hashing as tring (which is easy enough with Go) if you wanna so. Thank you for your feedback @rhymes !

Collapse
rhymes profile image
rhymes

I think it's fine, thanks! :)

Thread Thread
kataras profile image
Gerasimos (Makis) Maropoulos Author

You're welcome. You already gave me a push for writing more articles, including hashing, request authentication/verification and etc, make sure you follow me up :)