Создаём укорачивалку URL на Golang с Couchbase NoSQL

March 2017

Разработка: Создаём укорачивалку URL на Golang с Couchbase NoSQL
Раз­ра­бот­ка сер­ви­са по уко­ра­чи­ва­нию ссы­лок (та­ко­го как TinyURL или Bitly) на Go, ду­маю, бу­дет очень кру­тым при­ме­ром для на­чи­на­ю­щих. Итак, при­сту­пим!

Под­го­тов­ка

Для на­ча­ла про­верь­те, что у вас уста­нов­лен Go вер­сии не ни­же 1.7 и уста­но­ви­те Couchbase Server 4.1+

На­ше при­ло­же­ние бу­дет ис­поль­зо­вать за­про­сы N1QL — SQL за­про­сы к ба­зе дан­ных Couchbase NoSQL.

Под­го­тов­ка ба­зы дан­ных, со­зда­ние мо­де­ли дан­ных

Для хра­не­ния ин­фор­ма­ции о длин­ных и ко­рот­ких URL нам нуж­на ба­за дан­ных. Для на­шей неслож­ной за­да­чи луч­шим ва­ри­ан­том бу­дет вы­бор NoSQL ба­зы, по­это­му оста­но­вим­ся на БД с от­кры­тым ис­ход­ным ко­дом Couchbase.

Ска­чай­те и уста­но­ви­те нуж­ную вер­сию для ва­шей опе­ра­ци­он­ной си­сте­мы. Во вре­мя уста­нов­ки необ­хо­ди­мо вклю­чить служ­бу за­про­сов (query service).

Для ра­бо­ты нам необ­хо­ди­мо со­здать и на­стро­ить хра­ни­ли­ще дан­ных в Couchbase.

Разработка: couchbase-create-bucket.gif
Так как мы бу­дем ис­поль­зо­вать за­про­сы N1QL, нам по­на­до­бит­ся как ми­ни­мум один ин­декс в хра­ни­ли­ще. Его мож­но со­здать несколь­ки­ми спо­со­ба­ми: с по­мо­щью Couchbase Query Workbench или че­рез обо­лоч­ку CBQ. За­прос со­зда­ния ин­дек­са бу­дет при­мер­но та­ким:

CREATE PRIMARY INDEX ON `bucket-name` USING GSI;

Для боль­ших при­ло­же­ний мож­но со­здать несколь­ко ин­декс­ных по­лей.

Разработка: couchbase-create-primary-index.gif
Пе­рей­дём к мо­де­ли дан­ных. На­ше при­ло­же­ние бу­дет при­ни­мать длин­ный URL и от­да­вать со­от­вет­ству­ю­щий ко­рот­кий URL. Оба URL бу­дут хра­нит­ся в ба­зе дан­ных. Вот как при­мер­но мо­жет вы­гля­деть мо­дель дан­ных:

{
    "id": "5Qp8oLmWX",
    "longUrl": "https://www.thepolyglotdeveloper.com/2016/08/using-couchbase-server-golang-web-application/",
    "shortUrl": "http://localhost:3000/5Qp8oLmWX"
}

id — это уни­каль­ный ко­рот­кий хэш, при­вя­зан­ный к кон­крет­но­му URL, он бу­дет вы­да­вать­ся ав­то­ма­ти­че­ски для лю­бо­го ад­ре­са.

Те­перь нач­нём раз­ра­бот­ку при­ло­же­ния.

Со­зда­ние RESTful при­ло­же­ния на Golang

Мы со­зда­дим RESTful API, но пе­ред этим нуж­но опре­де­лить­ся с ло­ги­кой каж­дой ко­неч­ной точ­ки, а та­к­же по­за­бо­тить­ся о на­дёж­ной ра­бо­те при­ло­же­ния.

Со­зда­дим но­вый про­ект Go. Я на­зо­ву его про­сто main.go и он бу­дет рас­по­ло­жен в $GOPATH/src/github.com/nraboy/shorturl. До­бавь­те сле­ду­ю­щий код в файл $GOPATH/src/github.com/nraboy/shorturl/main.go:

package main

import (
    "net/http"
    "github.com/couchbase/gocb"
    "github.com/gorilla/mux"
)

var bucket *gocb.Bucket
var bucketName string

func ExpandEndpoint(w http.ResponseWriter, req *http.Request) { }

func CreateEndpoint(w http.ResponseWriter, req *http.Request) { }

func RootEndpoint(w http.ResponseWriter, req *http.Request) { }

func main() {
    router := mux.NewRouter()
    cluster, _ := gocb.Connect("couchbase://localhost")
    bucketName = "example"
    bucket, _ = cluster.OpenBucket(bucketName, "")
    router.HandleFunc("/{id}", RootEndpoint).Methods("GET")
    router.HandleFunc("/expand/", ExpandEndpoint).Methods("GET")
    router.HandleFunc("/create", CreateEndpoint).Methods("PUT")
    log.Fatal(http.ListenAndServe(":12345", router))
}

Рас­смот­рим по­дроб­но что мы сде­ла­ли здесь. Мы им­пор­ти­ро­ва­ли Couchbase Go SDK и ути­ли­ту Mux, с по­мо­щью ко­то­рой так лег­ко со­зда­вать RESTful API. Уста­но­вить эти па­ке­ты мож­но так:

go get github.com/couchbase/gocb
go get github.com/gorilla/mux

За­тем нам нуж­ны две пе­ре­мен­ных, ко­то­рые бу­дут до­ступ­ны во всём фай­ле main.go, в них мы бу­дем хра­нить ко­пию от­кры­то­го хра­ни­ли­ща и на­зва­ние это­го хра­ни­ли­ща.

В ме­то­де main мы на­стра­и­ва­ем ро­у­тер, со­еди­ня­ем­ся с ло­каль­ным кла­сте­ром Couchbase и от­кры­ва­ем на­ше хра­ни­ли­ще. В на­шем слу­чае от­кры­ва­ет­ся хра­ни­ли­ще example, ко­то­рое уже есть в кла­сте­ре.

Да­лее мы со­зда­ём три ро­у­та, пред­став­ля­ю­щие ко­неч­ные точ­ки API. Ро­ут /create при­ни­ма­ет длин­ный URL и от­да­ёт ко­рот­кий. /expand де­ла­ет об­рат­ное пре­об­ра­зо­ва­ние. И, на­ко­нец, ро­ут /root при­ни­ма­ет хэш и пе­ре­ки­ды­ва­ет на нуж­ную стра­ни­цу.

Ло­ги­ка API

Пе­ред тем как со­здать ло­ги­ку, опре­де­лим мо­дель дан­ных, это бу­дет струк­ту­ра дан­ных Go:

type MyUrl struct {
    ID       string `json:"id,omitempty"`
    LongUrl  string `json:"longUrl,omitempty"`
    ShortUrl string `json:"shortUrl,omitempty"`
}

В струк­ту­ре MyUrl есть три по­ля, пред­став­ля­ю­щие свой­ством JSON.

До­ба­вим са­мую слож­ную ко­неч­ную точ­ку /create:

func CreateEndpoint(w http.ResponseWriter, req *http.Request) {
    var url MyUrl
    _ = json.NewDecoder(req.Body).Decode(&url)
    var n1qlParams []interface{}
    n1qlParams = append(n1qlParams, url.LongUrl)
    query := gocb.NewN1qlQuery("SELECT `" + bucketName + "`.* FROM `" + bucketName + "` WHERE longUrl = $1")
    rows, err := bucket.ExecuteN1qlQuery(query, n1qlParams)
    if err != nil {
        w.WriteHeader(401)
        w.Write([]byte(err.Error()))
        return
    }
    var row MyUrl
    rows.One(&row)
    if row == (MyUrl{}) {
        hd := hashids.NewData()
        h := hashids.NewWithData(hd)
        now := time.Now()
        url.ID, _ = h.Encode([]int{int(now.Unix())})
        url.ShortUrl = "http://localhost:12345/" + url.ID
        bucket.Insert(url.ID, url, 0)
    } else {
        url = row
    }
    json.NewEncoder(w).Encode(url)
}

Ро­ут /create бу­дет до­сту­пен че­рез за­прос PUT. В этом за­про­се бу­дет ука­зан длин­ный URL в фор­ма­те JSON. Для удоб­ства мы бу­дем хра­нить весь JSON объ­ект в объ­ек­те MyUrl.
Та­к­же необ­хо­ди­мо убе­дит­ся, что мы хра­ним толь­ко уни­каль­ные длин­ные URL, а это зна­чит, что каж­дый ко­рот­кий URL дол­жен быть та­к­же уни­каль­ным. По­это­му вна­ча­ле мы про­ве­ря­ем ба­зу дан­ных на су­ще­ство­ва­ние та­ко­го URL:

var n1qlParams []interface{}
n1qlParams = append(n1qlParams, url.LongUrl)
query := gocb.NewN1qlQuery("SELECT `" + bucketName + "`.* FROM `" + bucketName + "` WHERE longUrl = $1")
rows, err := bucket.ExecuteN1qlQuery(query, n1qlParams)

Здесь мы ис­поль­зу­ем па­ра­мет­ри­зо­ван­ный за­прос N1QL для про­вер­ки. При ошиб­ках в за­про­се мы вы­ве­дем их на экран.

Ес­ли оши­бок не бу­дет, мы по­лу­чим ре­зуль­тат за­про­са и уви­дим, не пу­стой ли он. Ес­ли он пуст, зна­чит нам нуж­но уко­ро­тить URL и со­хра­нить в ба­зе.

Мож­но раз­ра­бо­тать соб­ствен­ный ал­го­ритм хэ­ши­ро­ва­ния, но я пред­по­чи­таю ис­поль­зо­вать па­кет Hashids.

Пе­ред ис­поль­зо­ва­ни­ем, уста­но­вим его:

go get github.com/speps/go-hashids

Для по­лу­че­ния уни­каль­но­го ко­рот­ко­го URL мы бу­дем де­лать хэш из те­ку­ще­го вре­ме­ни:

hd := hashids.NewData()
h := hashids.NewWithData(hd)
now := time.Now()
url.ID, _ = h.Encode([]int{int(now.Unix())})

По­сле по­лу­че­ния уни­каль­но­го хэ­ша со­хра­ним его в MyUrl вме­сте с ко­рот­ким URL. А длин­ный URL уже хра­нит­ся в ней.

Пе­рей­дём к /expand, вот код для ро­у­та:

func ExpandEndpoint(w http.ResponseWriter, req *http.Request) {
    var n1qlParams []interface{}
    query := gocb.NewN1qlQuery("SELECT `" + bucketName + "`.* FROM `" + bucketName + "` WHERE shortUrl = $1")
    params := req.URL.Query()
    n1qlParams = append(n1qlParams, params.Get("shortUrl"))
    rows, _ := bucket.ExecuteN1qlQuery(query, n1qlParams)
    var row MyUrl
    rows.One(&row)
    json.NewEncoder(w).Encode(row)
}

Здесь по­чти та­кой же код, как и у /create, но есть от­ли­чия: вме­сто N1QL за­про­са, мы пе­ре­да­ём ко­рот­кий URL и пе­ре­да­ём па­ра­мет­ры в за­про­се вме­сто то­го, что­бы пе­ре­да­вать пол­ный за­прос.

Те­перь остал­ся ро­ут root. Мы мо­жем рас­смат­ри­вать все за­про­сы к точ­ке root как ко­рот­кие URL:

func RootEndpoint(w http.ResponseWriter, req *http.Request) {
    params := mux.Vars(req)
    var url MyUrl
    bucket.Get(params["id"], &url)
    http.Redirect(w, req, url.LongUrl, 301)
}

По­сле по­ис­ка по id, бу­дет сде­лан 301 ре­ди­рект на длин­ный URL.

Пол­ный код про­ек­та
package main

import (
    "encoding/json"
    "log"
    "net/http"
    "time"

    "github.com/couchbase/gocb"
    "github.com/gorilla/mux"
    "github.com/speps/go-hashids"
)

type MyUrl struct {
    ID       string `json:"id,omitempty"`
    LongUrl  string `json:"longUrl,omitempty"`
    ShortUrl string `json:"shortUrl,omitempty"`
}

var bucket *gocb.Bucket
var bucketName string

func ExpandEndpoint(w http.ResponseWriter, req *http.Request) {
    var n1qlParams []interface{}
    query := gocb.NewN1qlQuery("SELECT `" + bucketName + "`.* FROM `" + bucketName + "` WHERE shortUrl = $1")
    params := req.URL.Query()
    n1qlParams = append(n1qlParams, params.Get("shortUrl"))
    rows, _ := bucket.ExecuteN1qlQuery(query, n1qlParams)
    var row MyUrl
    rows.One(&row)
    json.NewEncoder(w).Encode(row)
}

func CreateEndpoint(w http.ResponseWriter, req *http.Request) {
    var url MyUrl
    _ = json.NewDecoder(req.Body).Decode(&url)
    var n1qlParams []interface{}
    n1qlParams = append(n1qlParams, url.LongUrl)
    query := gocb.NewN1qlQuery("SELECT `" + bucketName + "`.* FROM `" + bucketName + "` WHERE longUrl = $1")
    rows, err := bucket.ExecuteN1qlQuery(query, n1qlParams)
    if err != nil {
        w.WriteHeader(401)
        w.Write([]byte(err.Error()))
        return
    }
    var row MyUrl
    rows.One(&row)
    if row == (MyUrl{}) {
        hd := hashids.NewData()
        h := hashids.NewWithData(hd)
        now := time.Now()
        url.ID, _ = h.Encode([]int{int(now.Unix())})
        url.ShortUrl = "http://localhost:12345/" + url.ID
        bucket.Insert(url.ID, url, 0)
    } else {
        url = row
    }
    json.NewEncoder(w).Encode(url)
}

func RootEndpoint(w http.ResponseWriter, req *http.Request) {
    params := mux.Vars(req)
    var url MyUrl
    bucket.Get(params["id"], &url)
    http.Redirect(w, req, url.LongUrl, 301)
}

func main() {
    router := mux.NewRouter()
    cluster, _ := gocb.Connect("couchbase://localhost")
    bucketName = "example"
    bucket, _ = cluster.OpenBucket(bucketName, "")
    router.HandleFunc("/{id}", RootEndpoint).Methods("GET")
    router.HandleFunc("/expand/", ExpandEndpoint).Methods("GET")
    router.HandleFunc("/create", CreateEndpoint).Methods("PUT")
    log.Fatal(http.ListenAndServe(":12345", router))
}

По­сле за­пус­ка при­ло­же­ния, оно бу­дет при­ни­мать за­про­сы на localhost:12345

Этот же урок в ви­део (на ан­глий­ском): youtu.be/OVBvOuxbpHA

По ма­те­ри­а­лам: «Create A URL Shortener With Golang And Couchbase NoSQL» by Nic Raboy

comments powered by Disqus