Скалирање веб апликације је готово увек занимљив изазов, без обзира на сложеност. Међутим, веб апликације у стварном времену представљају јединствене проблеме с скалабилношћу. На пример, да бисте могли да хоризонтално прилагодите веб апликацију за размену порука која користи ВебСоцкетс за комуникацију са клијентима, мораће на неки начин да синхронизује све своје чворове сервера. Ако апликација није основана с обзиром на ово, хоризонтално скалирање можда неће бити лака опција.
У овом чланку ћемо проћи кроз архитектуру једноставне веб апликације за размену слика и размену порука у реалном времену. Овде ћемо се фокусирати на различите компоненте, као што су Редис Пуб / Суб , укључени у изградњу апликације у реалном времену и погледајте како сви они играју своју улогу у целокупној архитектури.
Функционално, апликација је врло лагана. Омогућава отпремање слика и коментаре у стварном времену на тим сликама. Поред тога, сваки корисник може додирнути слику, а други корисници ће на свом екрану моћи видети ефекат таласавања.
Читав изворни код ове апликације је доступно на ГитХуб-у .
Користићемо програмски језик Го. Не постоји посебан разлог зашто смо изабрали Го за овај чланак, осим тога Гоова синтакса је чиста и лакше се прати њена семантика. И ту је наравно пристрасност аутора. Међутим, сви концепти о којима се говори у овом чланку могу се лако превести на језик по вашем избору.
Почетак рада са Гоом је једноставан. Његова бинарна дистрибуција може бити преузето са званичне странице . У случају да сте на оперативном систему Виндовс, на њиховој страници за преузимање налази се МСИ инсталациони програм за Го. Или, у случају да ваш оперативни систем (на срећу) нуди менаџер пакета:
Арцх Линук:
pacman -S go
Убунту:
apt-get install golang
Мац ОС Кс:
brew install go
Овај ће функционисати само ако имамо Хомебрев инсталиран.
Зашто бисте користили МонгоДБ ако имамо Редис, питате се? Као што је раније поменуто, Редис је складиште података у меморији. Иако може задржати податке на диску, употреба Редиса у те сврхе вероватно није најбољи начин. Користићемо МонгоДБ за складиштење отпремљених метаподатака слике и порука.
Ми Можемо преузми МонгоДБ са њихове званичне веб странице. У неким Линук дистрибуцијама ово је преферирани начин инсталирања МонгоДБ-а. И даље би требало да га је могуће инсталирати помоћу већине менаџера пакета већине дистрибуције.
Арцх Линук:
pacman -S mongodb
Убунту:
apt-get install mongodb
Мац ОС Кс:
brew install mongodb
У оквиру нашег Го кода користићемо пакет мго (изговара се манго). Не само да је тестиран у биткама, пакет возача нуди заиста чист и једноставан АПИ.
Ако нисте МонгоДБ експерт , уопште не брините. Употреба ове услуге базе података је минимална у нашој апликацији узорка и готово је ирелевантна за фокус овог чланка: Архитектура Пуб / Суб.
Користићемо Амазон С3 за складиштење слика које су поставили корисници. Овде нема много посла, осим побрините се да имамо Амазон Веб Сервицес спреман рачун и створена привремена сегмента.
Похрањивање отпремљених датотека на локални диск није опција, јер се ни на који начин не желимо ослањати на идентитет наших чворова. Желимо да корисници могу да се повежу на било који од доступних веб чворова и да и даље могу да виде исти садржај.
Користићемо за интеракцију са Амазон С3 сефом из нашег Го кода АдРолл / гоамз , виљушка од Цаноницал’с гоамз пакет са неким разликама.
Последње, али не најмање важно: Редис. Можемо га инсталирати помоћу менаџера пакета наше дистрибуције:
Арцх Линук:
pacman -S redis
Убунту:
apt-get install redis-server
Мац ОС Кс:
brew install redis
Или дохватите његов изворни код и саставите га сами . Редис нема других зависности осим ГЦЦ-а и либц за његову изградњу:
wget http://download.redis.io/redis-stable.tar.gz tar xvzf redis-stable.tar.gz cd redis-stable make
Једном када је Редис инсталиран и покренут, покрените терминал и унесите Редисов ЦЛИ:
redis-cli
Покушајте да унесете следеће наредбе и видите да ли ћете добити очекивани излаз:
SET answer 41 INCR answer GET answer
Прва команда чува „41“ у односу на кључ „одговор“, друга команда увећава вредност, трећа команда штампа вредност ускладиштену у односу на дати кључ. Резултат би требало да гласи „42“.
Можете Сазнајте више о свим командама које Редис подржава на својој званичној веб страници.
Користићемо пакет Го смањење да се повежете са Редисом из нашег кода апликације.
Образац објављивање-претплата начин је преношења порука произвољном броју пошиљалаца. Пошиљаоци ових порука (издавачи) не идентификују изричито циљане примаоце. Уместо тога, поруке се шаљу на каналу на којем их може чекати било који број прималаца (претплатника).
У нашем случају можемо имати било који број веб чворова који раде иза уравнотеживача оптерећења. У сваком тренутку два корисника који гледају исту слику можда неће бити повезани са истим чвором. Овде наступа Редис Пуб / Суб. Кад год веб чвор треба да примети промену (на пример, корисник креира нову поруку), користиће Редис Пуб / Суб за емитовање тих информација на све релевантне веб чворове. Који ће заузврат преносити информације релевантним клијентима како би могли дохватити ажурирану листу порукаредис.
Будући да нам образац објављивање-претплата омогућава слање порука на именоване канале, сваки веб чвор можемо повезати на Редис и претплатити се само на оне канале који су заинтересовани за њихове повезане кориснике. На пример, ако два корисника оба гледају исту слику, али су повезани са два различита веб чвора од многих веб чворова, тада само та два чвора морају да се претплате на одговарајући канал. Свака порука објављена на том каналу биће достављена само на та два веб чвора.
Звучи превише добро да би било истинито? Можемо испробати помоћу Редис-овог ЦЛИ. Покрените три инстанце redis-cli
. Прво извршите следећу наредбу:
SUBSCRIBE somechannel
Извршите следећу наредбу у другој инстанци Редис ЦЛИ:
SUBSCRIBE someotherchannel
Извршите следеће наредбе у трећој инстанци Редис ЦЛИ:
PUBLISH somechannel lorem PUBLISH someotherchannel ipsum
Приметите како је прва инстанца добила „лорем“, али не и „ипсум“, и како је друга инстанца добила „ипсум“, али не и „лорем“.
Вреди напоменути да једном када клијент Редис уђе у претплатнички режим, више не може да врши ниједну операцију осим да се претплати на више канала или да се одјави са претплаћених. То значи да ће сваки веб чвор требати да одржи две везе са Редисом, један за повезивање са Редисом као претплатником, а други за објављивање порука на каналима тако да их може примити било који веб чвор претплаћен на те канале.
Пре него што почнемо да истражујемо шта се дешава иза сцене, клонирајмо спремиште:
mkdir tonesa cd tonesa export GOPATH=`pwd` mkdir -p src/github.com/hjr265/tonesa cd src/github.com/hjr265/tonesa git clone https://github.com/hjr265/tonesa.git . go get ./...
... и саставите га:
go build ./cmd/tonesad
Да бисте покренули апликацију, пре свега креирајте датотеку под називом .енв (по могућности копирањем датотеке енв-сампле.ткт):
cp env-sample.txt .env
Попуните .енв датотеку свим потребним променљивим окружења:
MONGO_URL=mongodb://127.0.0.1/tonesa REDIS_URL=redis://127.0.0.1 AWS_ACCESS_KEY_ID={Your-AWS-Access-Key-ID-Goes-Here} AWS_SECRET_ACCESS_KEY={And-Your-AWS-Secret-Access-Key} S3_BUCKET_NAME={And-S3-Bucket-Name}
На крају покрените изграђену бинарну датотеку:
PORT=9091 ./tonesad -env-file=.env
Веб чвор би сада требало да буде покренут и да му буде доступан путем хттп: // лоцалхост: 9091.
Да бисте тестирали да ли и даље ради када се хоризонтално скалира, можете окретати више веб чворова покретањем различитих бројева портова:
PORT=9092 ./tonesad -env-file=.env
PORT=9093 ./tonesad -env-file=.env
… И приступити им путем одговарајућих УРЛ-ова: хттп: // лоцалхост: 9092 и хттп: // лоцалхост: 9093.
Уместо да пролазимо кроз сваки корак у развоју апликације, фокусираћемо се на неке од најважнијих делова. Иако нису сви ови подаци 100% релевантни за Редис Пуб / Суб и његове импликације у реалном времену, ипак су релевантни за укупну структуру апликације и олакшаће праћење када дубље заронимо.
Да ствари буду једноставне, нећемо се трудити око аутентификације корисника. Отпремања ће бити анонимна и доступна свима који знају УРЛ. Сви гледаоци могу да шаљу поруке и имаће могућност да сами одаберу псеудоним. Прилагођавање одговарајућег механизма за потврду идентитета и могућности приватности требало би да буде тривијално и изван је обима овог чланка.
Ово је лако.
Кад год корисник отпреми слику, ми је складиштимо на Амазон С3, а затим пут до ње чувамо у МонгоДБ насупрот два ИД-а: један ИД БСОН објекта (омиљени МонгоДБ) и други кратак ИД од 8 знакова (донекле угодан за очи). Ово улази у збирку „отпремања“ наше базе података и има структуру попут ове:
type Upload struct { ID bson.ObjectId `bson:'_id'` ShortID string `bson:'shortID'` Kind Kind `bson:'kind'` Content Blob `bson:'content'` CreatedAt time.Time `bson:'createdAt'` ModifiedAt time.Time `bson:'modifiedAt'` } type Blob struct { Path string `bson:'path'` Size int64 `bson:'size'` }
Поље Врста користи се за означавање врсте медија које овај „уплоад“ садржи. Да ли то значи да подржавамо медије који нису слике? Нажалост нема. Али поље је тамо остављено да делује као подсетник да овде нисмо нужно ограничени на слике.
Како корисници међусобно шаљу поруке, они се чувају у другој колекцији. Да, погодили сте: „поруке“.
type Message struct { ID bson.ObjectId `bson:'_id'` UploadID bson.ObjectId `bson:'uploadID'` AuthorName string `bson:'anonName'` Content string `bson:'content'` CreatedAt time.Time `bson:'createdAt'` ModifiedAt time.Time `bson:'modifiedAt'` }
Једино занимљиво мало овде је поље УплоадИД, које се користи за повезивање порука са одређеним отпремањем.
Ова апликација у основи има три крајње тачке.
Руководилац за ову крајњу тачку очекује подношење „мултипарт / форм-дата“ са сликом у пољу „датотека“. Понашање руковаоца је приближно следеће:
func HandleUploadCreate(w http.ResponseWriter, r *http.Request) { f, h, _ := r.FormFile('file') b := bytes.Buffer{} n, _ := io.Copy(&b, io.LimitReader(f, data.MaxUploadContentSize+10)) if n > data.MaxUploadContentSize { ServeBadRequest(w, r) return } id := bson.NewObjectId() upl := data.Upload{ ID: id, Kind: data.Image, Content: data.Blob{ Path: '/uploads/' + id.Hex(), Size: n, }, } data.Bucket.Put(upl.Content.Path, b.Bytes(), h.Header.Get('Content-Type'), s3.Private, s3.Options{}) upl.Put() // Respond with newly created upload entity (JSON encoded) }
Го захтева да се са свим грешкама поступа експлицитно. То је учињено у прототипу, али је изостављено из исечака у овом чланку да би се фокус задржао на критичним деловима.
У обрађивачу ове крајње тачке АПИ-ја у суштини читамо датотеку, али ограничавамо њену величину на одређену вредност. Ако отпремање премаши ову вредност, захтев се одбија. У супротном, генерише се БСОН ИД и користи за отпремање слике на Амазон С3 пре него што настави са ентитетом за отпремање у МонгоДБ.
Постоје начини за и против начина генерирања ИД-ова БСОН објеката. Они се генеришу на крају клијента. Међутим, стратегија коришћена за генерисање ИД-а објекта чини вероватноћу судара толико малом да их је сигурно генерисати на страни клијента. С друге стране, вредности генерисаних ИД-ова објеката су обично секвенцијалне и то је нешто што Амазон С3 јесте није баш наклоњен . Једноставно заобилазно решење овог проблема је префиксирање имена датотеке случајним низом.
Овај АПИ се користи за дохваћање недавних порука и порука које су објављене након одређеног времена.
func ServeMessageList(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) idStr := vars['id'] if !bson.IsObjectIdHex(idStr) { ServeNotFound(w, r) return } upl, _ := data.GetUpload(bson.ObjectIdHex(idStr)) if upl == nil { ServeNotFound(w, r) return } sinceStr := r.URL.Query().Get('since') var msgs []data.Message if sinceStr != '' { since, _ := time.Parse(time.RFC3339, sinceStr) msgs, _ = data.ListMessagesByUploadID(upl.ID, since, 16) } else { msgs, _ = data.ListRecentMessagesByUploadID(upl.ID, 16) } // Respond with message entities (JSON encoded) }
Када се прегледач корисника обавести о новој поруци при отпремању коју корисник тренутно гледа, он преузима нове поруке помоћу ове крајње тачке.
И на крају, руковалац који креира поруке и обавештава све:
func HandleMessageCreate(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) idStr := vars['id'] if !bson.IsObjectIdHex(idStr) { ServeNotFound(w, r) return } upl, _ := data.GetUpload(bson.ObjectIdHex(idStr)) if upl == nil { ServeNotFound(w, r) return } body := Message{} json.NewDecoder(r.Body).Decode(&body) msg := data.Message{} msg.UploadID = upl.ID msg.AuthorName = body.AuthorName msg.Content = body.Content msg.Put() // Respond with newly created message entity (JSON encoded) hub.Emit('upload:'+upl.ID.Hex(), 'message:'+msg.ID.Hex()) }
Овај руковатељ је толико сличан осталим да је готово досадно чак га и овде укључити. Или је то? Приметите како постоји позив функције хуб.Емит () на самом крају функције. Шта је чвориште што кажеш? Ту се дешава сва Пуб / Суб магија.
Хуб је место где лепимо ВебСоцкетс са Редисовим Пуб / Суб каналима. И случајно се зове пакет који користимо за руковање ВебСоцкетс-има унутар наших веб сервера лепак .
Хуб у основи одржава неколико структура података које креирају мапирање између свих повезаних ВебСоцкетс-а на све канале који их занимају. На пример, ВебСоцкет на картици прегледача корисника који је указао на одређену отпремљену слику, природно би требало да буде заинтересован за сва обавештења релевантна томе.
Пакет чворишта имплементира шест функција:
func Subscribe(s *glue.Socket, t string) error { l.Lock() defer l.Unlock() _, ok := sockets[s] if !ok { sockets[s] = map[string]bool{} } sockets[s][t] = true _, ok = topics[t] if !ok { topics[t] = map[*glue.Socket]bool{} err := subconn.Subscribe(t) if err != nil { return err } } topics[t][s] = true return nil }
Ова функција, баш као и већина осталих у овом пакету, држи закључавање на мутеку за читање / писање док се извршава. Ово је тако да можемо безбедно модификовати променљиве примитивних структура података утичнице и теме . Прва променљива, утичнице , пресликава утичнице у називе канала, док друга, теме , мапира имена канала у утичнице. У овој функцији градимо ово мапирање. Кад год видимо да се соцкет претплати на ново име канала, успоставимо Редис везу, субцонн , претплатите се на тај канал на Редису користећи субцонн.Субсцрибе . То чини да Редис прослеђује сва обавештења на том каналу овом веб чвору.
И, такође, у Откажи претплату на све функција, рушимо мапирање:
func UnsubscribeAll(s *glue.Socket) error { l.Lock() defer l.Unlock() for t := range sockets[s] { delete(topics[t], s) if len(topics[t]) == 0 { delete(topics, t) err := subconn.Unsubscribe(t) if err != nil { return err } } } delete(sockets, s) return nil }
Када уклонимо последњу утичницу из структуре података која је заинтересована за одређени канал, одјављујемо се са канала у Редису користећи субцонн.Одјавите се .
func Emit(t string, m string) error { _, err := pubconn.Do('PUBLISH', t, m) return err }
Ова функција објављује поруку м на каналу т користећи везу за објављивање са Редисом.
func EmitLocal(t string, m string) { l.RLock() defer l.RUnlock() for s := range topics[t] { s.Write(m) } }
func InitHub(url string) error { c, _ := redis.DialURL(url) pubconn = c c, _ = redis.DialURL(url) subconn = redis.PubSubConn{c} go func() { for { switch v := subconn.Receive().(type) { case redis.Message: EmitLocal(v.Channel, string(v.Data)) case error: panic(v) } } }() return nil }
У ИнитХуб функцију, креирамо две везе са Редисом: једну за претплату на канале који су заинтересовани за овај веб чвор, а другу за објављивање порука. Једном када се везе успоставе, започињемо нову Го рутину са петљом која се вечно изводи и чека пријем порука путем претплатничке везе на Редис. Сваки пут када прими поруку, емитује је локално (тј. На све ВебСоцкетс повезане на овај веб чвор).
И коначно, ХандлеСоцкет је место где чекамо да поруке дођу преко ВебСоцкетс или се очисте након прекида везе:
func HandleSocket(s *glue.Socket) { s.OnClose(func() { UnsubscribeAll(s) }) s.OnRead(func(data string) { fields := strings.Fields(data) if len(fields) == 0 { return } switch fields[0] { case 'watch': if len(fields) != 2 { return } Subscribe(s, fields[1]) case 'touch': if len(fields) != 4 { return } Emit(fields[1], 'touch:'+fields[2]+','+fields[3]) } }) }
С обзиром да лепак долази са сопственом фронт-енд ЈаваСцрипт библиотеком, много је лакше руковати ВебСоцкетс-ом (или враћањем на КСХР анкетирање када ВебСоцкетс нису доступни):
var socket = glue() socket.onMessage(function(data) { data = data.split(':') switch(data[0]) { case 'message': messages.fetch({ data: since: _.first(messages.pluck('createdAt')) , add: true, remove: false }) break case 'touch': var coords = data[1].split(',') showTouchBubble(coords) break } }) socket.send('watch upload:'+upload.id)
На страни клијента ослушкујемо било коју поруку која стиже путем ВебСоцкет-а. С обзиром да лепак преноси све поруке као низове, све информације у њему кодирамо помоћу специфичних образаца:
Када корисник креира нову поруку, користимо АПИ „ПОСТ / апи / уплоадс / {уплоадИД} / мессагес“ да бисмо креирали нову поруку. Ово се ради помоћу Креирај метода на основној колекцији за поруке:
messages.create({ authorName: $messageAuthorNameEl.val(), content: $messageContentEl.val(), createdAt: '' }, { at: 0 })
Када корисник кликне на слику, израчунавамо положај клика у проценту ширине и висине слике и шаљемо информације директно путем ВебСоцкет-а.
socket.send('touch upload:'+upload.id+' '+(event.pageX - offset.left) / $contentImgEl.width()+' '+(event.pageY - offset.top) / $contentImgEl.height())
Када корисник укуца поруку и притисне тастер ентер, клијент позива АПИ „ПОСТ / апи / уплоадс / {ид} / мессагес“ крајњу тачку. Ово заузврат ствара ентитет поруке у бази података и објављује низ „мессаге: {мессагеИД}“ путем Редис Пуб / Суб-а на каналу „уплоад: {уплоадИД}“ кроз пакет чворишта.
Редис прослеђује овај низ сваком веб чвору (претплатнику) заинтересованом на каналу „уплоад: {уплоадИД}“. Веб чворови који примају овај низ прелазе кроз све ВебСоцкетс релевантне за канал и шаљу низ клијенту преко њихових ВебСоцкет веза. Клијенти који примају овај низ почињу да преузимају нове поруке са сервера помоћу „ГЕТ / апи / уплоадс / {ид} / мессагес“.
Слично томе, за ширење кликова на слици, клијент директно шаље поруку путем ВебСоцкет-а која изгледа отприлике као „отпремање додиром: {уплоадИД} {координата}} {координата}“. Ова порука завршава у пакету чворишта где је објављена на истом каналу канала „уплоад: {уплоадИД}“. Као резултат, низ се дистрибуира свим корисницима који гледају отпремљену слику. Клијент, по примању овог низа, рашчлањује га како би извукао координате и приказује све ближи круг како би тренутно означио локацију клика.
У овом чланку смо видели увид у то како образац објављивање-претплата може у великој мери и са релативно лакоћом решити проблем скалирања веб апликација у стварном времену.
Примерак апликације постоји да би служио као игралиште за експериментисање са Редис Пуб / Суб-ом. Али, као што је раније поменуто, идеје се могу имплементирати у скоро било који други популарни програмски језик.