Menjadi Real-Time dengan Redis Pub/Sub
Diterbitkan: 2022-03-11Menskalakan aplikasi web hampir selalu merupakan tantangan yang menarik, terlepas dari kerumitannya. Namun, aplikasi web waktu nyata menimbulkan masalah skalabilitas yang unik. Misalnya, untuk dapat menskalakan aplikasi web perpesanan secara horizontal yang menggunakan WebSockets untuk berkomunikasi dengan kliennya, aplikasi tersebut perlu menyinkronkan semua node servernya. Jika aplikasi tidak dibangun dengan mempertimbangkan hal ini, maka menskalakannya secara horizontal mungkin bukan pilihan yang mudah.
Pada artikel ini, kita akan membahas arsitektur aplikasi web berbagi gambar dan perpesanan real-time yang sederhana. Di sini, kita akan fokus pada berbagai komponen, seperti Redis Pub/Sub, yang terlibat dalam pembuatan aplikasi waktu nyata dan melihat bagaimana semuanya memainkan perannya dalam arsitektur keseluruhan.
Dari segi fungsionalitas, aplikasi ini sangat ringan. Ini memungkinkan untuk mengunggah gambar dan komentar waktu nyata pada gambar-gambar itu. Selanjutnya, setiap pengguna dapat mengetuk gambar dan pengguna lain akan dapat melihat efek beriak di layar mereka.
Seluruh kode sumber aplikasi ini tersedia di GitHub.
Hal yang Kami Butuhkan
Pergi
Kami akan menggunakan bahasa pemrograman Go. Tidak ada alasan khusus mengapa kami memilih Go untuk artikel ini, selain itu sintaks Go bersih dan semantiknya lebih mudah diikuti. Dan tentu saja ada bias penulis. Namun, semua konsep yang dibahas dalam artikel ini dapat dengan mudah diterjemahkan ke bahasa pilihan Anda.
Memulai dengan Go itu mudah. Distribusi binernya dapat diunduh dari situs resminya. Jika Anda menggunakan Windows, ada penginstal MSI untuk Go di halaman unduhan mereka. Atau, jika sistem operasi Anda (untungnya) menawarkan manajer paket:
Linux Lengkungan:
pacman -S go
Ubuntu:
apt-get install golang
MacOSX:
brew install go
Yang ini hanya akan berfungsi jika kita menginstal Homebrew.
MongoDB
Mengapa menggunakan MongoDB jika kami memiliki Redis, Anda bertanya? Seperti disebutkan sebelumnya, Redis adalah penyimpanan data dalam memori. Meskipun dapat menyimpan data ke disk, menggunakan Redis untuk tujuan itu mungkin bukan cara terbaik. Kami akan menggunakan MongoDB untuk menyimpan metadata dan pesan gambar yang diunggah.
Kami dapat mengunduh MongoDB dari situs web resmi mereka. Di beberapa distribusi Linux, ini adalah cara yang lebih disukai untuk menginstal MongoDB. Itu masih dapat diinstal menggunakan sebagian besar manajer paket distribusi.
Linux Lengkungan:
pacman -S mongodb
Ubuntu:
apt-get install mongodb
MacOSX:
brew install mongodb
Dalam kode Go kami, kami akan menggunakan paket mgo (diucapkan mangga). Tidak hanya diuji dalam pertempuran, paket driver menawarkan API yang sangat bersih dan sederhana.
Jika Anda bukan ahli MongoDB, jangan khawatir sama sekali. Penggunaan layanan database ini minimal dalam aplikasi sampel kami, dan hampir tidak relevan dengan fokus artikel ini: Arsitektur Pub/Sub.
Amazon S3
Kami akan menggunakan Amazon S3 untuk menyimpan gambar yang diunggah pengguna. Tidak banyak yang bisa dilakukan di sini, kecuali memastikan bahwa kami memiliki akun siap Amazon Web Services dan ember sementara dibuat.
Menyimpan file yang diunggah ke disk lokal bukanlah pilihan karena kami tidak ingin bergantung pada identitas node web kami dengan cara apa pun. Kami ingin pengguna dapat terhubung ke salah satu node web yang tersedia dan masih dapat melihat konten yang sama.
Untuk berinteraksi dengan bucket Amazon S3 dari kode Go kami, kami akan menggunakan AdRoll/goamz, cabang dari paket goamz Canonical dengan beberapa perbedaan.
Redis
Terakhir, tetapi tidak sedikit: Redis. Kami dapat menginstalnya menggunakan manajer paket distribusi kami:
Linux Lengkungan:
pacman -S redis
Ubuntu:
apt-get install redis-server
MacOSX:
brew install redis
Atau, ambil kode sumbernya dan kompilasi sendiri. Redis tidak memiliki dependensi selain GCC dan libc untuk membangunnya:
wget http://download.redis.io/redis-stable.tar.gz tar xvzf redis-stable.tar.gz cd redis-stable make
Setelah Redis diinstal dan dijalankan, mulai terminal dan masukkan CLI Redis:
redis-cli
Coba masukkan perintah berikut dan lihat apakah Anda mendapatkan hasil yang diharapkan:
SET answer 41 INCR answer GET answer
Perintah pertama menyimpan "41" terhadap kunci "jawaban", perintah kedua menambah nilai, perintah ketiga mencetak nilai yang disimpan terhadap kunci yang diberikan. Hasilnya harus membaca "42".
Anda dapat mempelajari lebih lanjut tentang semua perintah yang didukung Redis di situs web resmi mereka.
Kami akan menggunakan redigo paket Go untuk terhubung ke Redis dari dalam kode aplikasi kami.
Intip di Redis Pub/Sub
Pola publish-subscribe adalah cara menyampaikan pesan ke sejumlah pengirim yang berubah-ubah. Pengirim pesan ini (penerbit) tidak secara eksplisit mengidentifikasi penerima yang ditargetkan. Sebaliknya, pesan dikirim melalui saluran di mana sejumlah penerima (pelanggan) dapat menunggu mereka.
Dalam kasus kami, kami dapat memiliki sejumlah node web yang berjalan di belakang penyeimbang beban. Pada saat tertentu, dua pengguna yang melihat gambar yang sama mungkin tidak terhubung ke node yang sama. Di sinilah Redis Pub/Sub berperan. Setiap kali node web perlu mengamati perubahan (misalnya pesan baru dibuat oleh pengguna), ia akan menggunakan Redis Pub/Sub untuk menyiarkan informasi tersebut ke semua node web yang relevan. Yang, pada gilirannya, akan menyebarkan informasi ke klien yang relevan sehingga mereka dapat mengambil daftar pesan yang diperbarui.
Karena pola publish-subscribe memungkinkan kita untuk mengirimkan pesan pada saluran bernama, kita dapat membuat setiap node web terhubung ke Redis, dan berlangganan hanya ke saluran yang diminati oleh pengguna yang terhubung. Misalnya, jika dua pengguna sama-sama melihat gambar yang sama tetapi terhubung ke dua node web yang berbeda dari banyak node web, maka hanya dua node web tersebut yang perlu berlangganan ke saluran yang sesuai. Pesan apa pun yang dipublikasikan di saluran itu hanya akan dikirimkan ke dua node web tersebut.
Kedengarannya terlalu bagus untuk menjadi kenyataan? Kita bisa mencobanya menggunakan CLI Redis. Mulai tiga contoh redis-cli
. Jalankan perintah berikut pada contoh pertama:
SUBSCRIBE somechannel
Jalankan perintah berikut dalam instance Redis CLI kedua:
SUBSCRIBE someotherchannel
Jalankan perintah berikut dalam contoh ketiga Redis CLI:
PUBLISH somechannel lorem PUBLISH someotherchannel ipsum
Perhatikan bagaimana contoh pertama menerima "lorem" tetapi tidak "ipsum", dan bagaimana contoh kedua menerima "ipsum" tetapi tidak "lorem".
Perlu disebutkan bahwa begitu klien Redis memasuki mode pelanggan, ia tidak dapat lagi melakukan operasi apa pun selain berlangganan ke lebih banyak saluran atau berhenti berlangganan dari yang berlangganan. Ini berarti bahwa setiap node web perlu memelihara dua koneksi ke Redis, satu untuk terhubung ke Redis sebagai pelanggan dan yang lainnya untuk memublikasikan pesan di saluran sehingga setiap node web yang berlangganan saluran tersebut dapat menerimanya.
Real-Time dan Terukur
Sebelum kita mulai menjelajahi apa yang terjadi di balik layar, mari kita mengkloning repositori:
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 ./...
… dan kompilasi:
go build ./cmd/tonesad
Untuk menjalankan aplikasi, pertama-tama buat file bernama .env (sebaiknya dengan menyalin file env-sample.txt):
cp env-sample.txt .env
Isi file .env dengan semua variabel lingkungan yang diperlukan:
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}
Akhirnya jalankan biner yang dibangun:
PORT=9091 ./tonesad -env-file=.env
Node web sekarang harus berjalan dan dapat diakses melalui http://localhost:9091.
Untuk menguji apakah masih berfungsi saat diskalakan secara horizontal, Anda dapat memutar beberapa node web dengan memulainya dengan nomor port yang berbeda:
PORT=9092 ./tonesad -env-file=.env
PORT=9093 ./tonesad -env-file=.env
… dan mengaksesnya melalui URL yang sesuai: http://localhost:9092 dan http://localhost:9093.
Di balik layar
Alih-alih melalui setiap langkah dalam pengembangan aplikasi, kami akan fokus pada beberapa bagian terpenting. Meskipun tidak semua ini 100% relevan dengan Redis Pub/Sub dan implikasinya secara real-time, mereka masih relevan dengan keseluruhan struktur aplikasi dan akan membuatnya lebih mudah untuk diikuti setelah kita menyelam lebih dalam.
Untuk menjaga hal-hal sederhana, kami tidak akan repot-repot tentang otentikasi pengguna. Unggahan akan bersifat anonim dan tersedia untuk semua orang yang mengetahui URL-nya. Semua pemirsa dapat mengirim pesan, dan akan memiliki kemampuan untuk memilih alias mereka sendiri. Mengadaptasi mekanisme otentikasi yang tepat dan kemampuan privasi seharusnya sepele, dan berada di luar cakupan artikel ini.
Data Tetap
Yang satu ini mudah.
Setiap kali pengguna mengunggah gambar, kami menyimpannya di Amazon S3 dan kemudian menyimpan jalur ke sana di MongoDB terhadap dua ID: satu ID Objek BSON (favorit MongoDB), dan ID panjang 8 karakter pendek lainnya (agak enak dipandang). Ini masuk ke koleksi "upload" database kami dan memiliki struktur seperti ini:
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"` }
Bidang Jenis digunakan untuk menunjukkan jenis media yang berisi "unggahan" ini. Apakah ini berarti kami mendukung media selain gambar? Sayangnya tidak ada. Tetapi bidang tersebut telah ditinggalkan di sana untuk bertindak sebagai pengingat bahwa kita tidak harus terbatas pada gambar di sini.

Saat pengguna mengirim pesan satu sama lain, mereka disimpan dalam koleksi yang berbeda. Ya, Anda telah menebaknya: "pesan".
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"` }
Satu-satunya hal yang menarik di sini adalah bidang UploadID, yang digunakan untuk mengaitkan pesan ke unggahan tertentu.
Titik Akhir API
Aplikasi ini pada dasarnya memiliki tiga titik akhir.
POST /api/upload
Handler untuk titik akhir ini mengharapkan pengiriman "multipart/form-data" dengan gambar di bidang "file". Perilaku pawang kira-kira sebagai berikut:
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) }
Go mengharuskan semua kesalahan ditangani secara eksplisit. Ini telah dilakukan dalam prototipe, tetapi dihilangkan dari cuplikan dalam artikel ini untuk menjaga fokus pada bagian-bagian penting.
Di handler titik akhir API ini, kami pada dasarnya membaca file tetapi membatasi ukurannya ke nilai tertentu. Jika unggahan melebihi nilai ini, permintaan ditolak. Jika tidak, ID BSON dibuat dan digunakan untuk mengunggah gambar ke Amazon S3 sebelum mempertahankan entitas unggahan ke MongoDB.
Ada pro dan kontra dengan cara ID Objek BSON dihasilkan. Mereka dihasilkan di ujung klien. Namun strategi yang digunakan untuk menghasilkan ID Objek membuat kemungkinan tabrakan sangat kecil sehingga aman untuk menghasilkannya di sisi klien. Di sisi lain, nilai ID Objek yang dihasilkan biasanya berurutan dan itu adalah sesuatu yang tidak disukai Amazon S3. Solusi mudah untuk ini adalah dengan mengawali nama file dengan string acak.
DAPATKAN /api/uploads/{id}/messages
API ini digunakan untuk mengambil pesan terbaru, dan pesan yang telah diposting setelah waktu tertentu.
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) }
Saat browser pengguna diberi tahu tentang pesan baru pada unggahan yang sedang dilihat pengguna, itu mengambil pesan baru menggunakan titik akhir ini.
POST /api/uploads/{id}/messages
Dan akhirnya pawang yang membuat pesan dan memberi tahu semua orang:
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()) }
Handler ini sangat mirip dengan yang lain sehingga hampir membosankan untuk memasukkannya di sini. Atau itu? Perhatikan bagaimana ada panggilan fungsi hub.Emit() di akhir fungsi. Apa itu hub yang Anda katakan? Di situlah semua keajaiban Pub/Sub terjadi.
Hub: Tempat WebSockets Bertemu Redis
Hub adalah tempat kami merekatkan WebSockets dengan saluran Pub/Sub Redis. Dan, kebetulan paket yang kami gunakan untuk menangani WebSockets dalam server web kami disebut glue.
Hub pada dasarnya memelihara beberapa struktur data yang membuat pemetaan antara semua WebSocket yang terhubung ke semua saluran yang mereka minati. Misalnya, WebSocket pada tab browser pengguna yang menunjuk ke gambar yang diunggah tertentu secara alami akan tertarik pada semua notifikasi yang relevan untuk itu.
Paket hub mengimplementasikan enam fungsi:
- Langganan
- Berhenti Berlangganan
- Memancarkan
- pancarkan lokal
- InitHub
- Menangani Soket
Berlangganan dan Berhenti BerlanggananSemua
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 }
Fungsi ini, seperti kebanyakan yang lain dalam paket ini, menahan kunci pada mutex baca/tulis saat dijalankan. Ini agar kita dapat dengan aman memodifikasi soket dan topik variabel struktur data primitif. Variabel pertama, sockets , memetakan soket ke nama saluran, sedangkan yang kedua, topik , memetakan nama saluran ke soket. Dalam fungsi ini kami membangun pemetaan ini. Setiap kali kami melihat soket berlangganan ke nama saluran baru, kami membuat koneksi Redis kami, subkoneksi , berlangganan saluran itu di Redis menggunakan subkoneksi.Berlangganan . Ini membuat Redis meneruskan semua notifikasi di saluran itu ke node web ini.
Dan, juga, dalam fungsi UnsubscribeAll , kami meruntuhkan pemetaan:
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 }
Saat kami menghapus soket terakhir dari struktur data yang tertarik pada saluran tertentu, kami berhenti berlangganan saluran di Redis menggunakan subconn.Unsubscribe .
Memancarkan
func Emit(t string, m string) error { _, err := pubconn.Do("PUBLISH", t, m) return err }
Fungsi ini memublikasikan pesan m pada saluran t menggunakan koneksi publikasikan ke Redis.
pancarkan lokal
func EmitLocal(t string, m string) { l.RLock() defer l.RUnlock() for s := range topics[t] { s.Write(m) } }
InitHub
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 }
Dalam fungsi InitHub , kami membuat dua koneksi ke Redis: satu untuk berlangganan saluran yang diminati oleh simpul web ini, dan yang lainnya untuk memublikasikan pesan. Setelah koneksi dibuat, kami memulai rutinitas Go baru dengan loop yang berjalan selamanya menunggu untuk menerima pesan melalui koneksi pelanggan ke Redis. Setiap kali menerima pesan, ia memancarkannya secara lokal (yaitu ke semua WebSockets yang terhubung ke node web ini).
Menangani Soket
Dan terakhir, HandleSocket adalah tempat kita menunggu pesan masuk melalui WebSocket atau membersihkan setelah koneksi ditutup:
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]) } }) }
JavaScript Front-End
Karena glue hadir dengan library JavaScript front-endnya sendiri, maka jauh lebih mudah untuk menangani WebSockets (atau mundur ke polling XHR saat WebSockets tidak tersedia):
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)
Di sisi klien, kami mendengarkan pesan apa pun yang masuk melalui WebSocket. Karena glue mentransmisikan semua pesan sebagai string, kami mengkodekan semua informasi di dalamnya menggunakan pola spesifik:
- Pesan baru: “pesan:{messageID}”
- Klik pada gambar: “sentuh:{coordX},{coordY}”, di mana coordX dan coordY adalah koordinat berbasis persentase dari lokasi klik pengguna pada gambar
Saat pengguna membuat pesan baru, kami menggunakan API “POST /api/uploads/{uploadID}/messages” untuk membuat pesan baru. Ini dilakukan dengan menggunakan metode create pada backbone collection untuk pesan:
messages.create({ authorName: $messageAuthorNameEl.val(), content: $messageContentEl.val(), createdAt: '' }, { at: 0 })
Saat pengguna mengklik gambar, kami menghitung posisi klik dalam persentase lebar dan tinggi gambar dan mengirimkan informasi melalui WebSocket secara langsung.
socket.send('touch upload:'+upload.id+' '+(event.pageX - offset.left) / $contentImgEl.width()+' '+(event.pageY - offset.top) / $contentImgEl.height())
Ikhtisar
Saat pengguna mengetik pesan dan menekan tombol enter, klien memanggil titik akhir API “POST /api/uploads/{id}/messages”. Ini pada gilirannya membuat entitas pesan dalam database dan menerbitkan string “message:{messageID}” melalui Redis Pub/Sub di saluran “upload:{uploadID}” melalui paket hub.
Redis meneruskan string ini ke setiap node web (pelanggan) yang tertarik pada saluran “upload:{uploadID}”. Node web yang menerima string ini beralih ke semua WebSocket yang relevan dengan saluran dan mengirimkan string ke klien melalui koneksi WebSocket mereka. Klien yang menerima string ini mulai mengambil pesan baru dari server menggunakan "GET /api/uploads/{id}/messages".
Demikian pula, untuk menyebarkan peristiwa klik pada gambar, klien secara langsung mengirim pesan melalui WebSocket yang terlihat seperti “touch upload:{uploadID} {coordX} {coordY}”. Pesan ini berakhir di paket hub yang dipublikasikan di saluran yang sama saluran “upload:{uploadID}”. Akibatnya, string didistribusikan ke semua pengguna yang melihat gambar yang diunggah. Klien, setelah menerima string ini mem-parsingnya untuk mengekstrak koordinat dan membuat lingkaran yang semakin memudar untuk menyorot lokasi klik sesaat.
Bungkus
Dalam artikel ini kita telah melihat sekilas bagaimana pola publish-subscribe dapat membantu memecahkan masalah penskalaan aplikasi web real-time secara luas dan dengan relatif mudah.
Aplikasi sampel ada untuk berfungsi sebagai taman bermain untuk bereksperimen dengan Redis Pub/Sub. Tetapi, seperti yang disebutkan sebelumnya, ide-ide tersebut dapat diimplementasikan di hampir semua bahasa pemrograman populer lainnya.