Şu tarihte

Redis ile Pub/Sub Uygulaması Nasıl Yapılır?

TL;DR

Burası biraz uzun gelebilir, özetle Pub/Sub tekniği ile Redis’i kullanarak örnek bir NodeJS uygulaması nasıl geliştirilir onu anlattım, kodları Github’ta bulabilirsiniz, ana uygulama için: kerimkaan/redis-pubsub-main worker uygulama için: kerimkaan/redis-pubsub-worker

Redis Nedir?

Redis, bellek tabanlı bir anahtar-değer veritabanıdır ya da bazı kaynaklarda geçtiği gibi veri deposudur. Bu terimler pek Türkçe oturmuyor gibi o yüzden İngilizcesiyle in-memory key-value database/datastore diyelim kendisine. Kullanım amacı genel olarak önbellekleme (caching) ve dağıtık sistemlerde veri yapılarının uç noktalarca kullanılabilmesini sağlamaktır. Ancak hepimiz ağırlıklı olarak önbellekleme için kullanmaktayız. Önbellekleme ise kabaca sıkça kullanılan değerlerin/objelerin bellekte tutularak uygulamamızın daha hızlı cevap dönmesi için kullanılan yöntemlerdir.

Redis

Örnek vermek gerekirse bir websitesine girdiğinizde sizin kullanıcı adınızla size "Hoşgeldiniz, X" dendiğini düşünün. Kullanıcı adınız yani X uygulamamız tarafından veritabanında tutulan bir değer olsun; siz sayfayı her yenilediğinizde arayüz uygulamaya gidip bu kullanıcı kimdir? diye soracaktır. Uygulamamızda veritabanına gidip kullanıcının kim olduğunu sorgulayacaktır. Bu işlemleri her kullanıcı için her sayfa yenilenmesinde yapıldığını düşünürsek veritabanının IOPS (Input-Output per Second yani Saniye başına Giriş-Çıkış) gücünü gereksiz yere kullanmış oluruz. IOPS değeri kullandığımız donanımın gücü ile sınırlı olacağından kaynakların sınırlı ama ihtiyaçların sınırsız olduğu durumlarda (yani hemen hemen her koşulda) uygulamanın geç cevap vermesi sizin web sitesinde yaşadığınız deneyimi kötü etkileyecektir.

Tam olarak bu sorunlardan müzdarip geliştiriciler bir çok yöntem ve uygulama geliştirmişlerdir. LocalStorage, IndexedDB, Bellek tabanlı veritabanları gibi çözümler ilk akla gelenler. Redis’te tam olarak bununla ün salmış bir arkadaşımız diğer bir kardeşi Memcached adında bir uygulama da bulunmakta ve Redis ile benzer ama farklı yönleri de bulunmaktadır, kendisini merak edenler araştırabilir. Redis ile siz uygulamaya giriş yaptığınızda uygulamamız bak "X giriş yaptı, ismini cismini belle, sorucam sonra" diyip hafızasına atıyoruz, bunun için tanımından da anlaşıldığı gibi key-value yani anahtar-değer ikilileri ile bellekte adresliyoruz.

Örnek olması açısından X kullanıcısının ismini hafızaya atalım dersek: userid:1234 adında bir keyimiz olsun, bunun da value’su X olsun. Tamamiyle örnek olması açısından – siz böyle yapmayın – uygulamadan ID’sini bildiğimiz kullanıcının ismini ID’si ile Redis’te sorgulayarak alabiliriz, Redis çok hızlı cevap döneceğinden size de sayfa açılış hızına, mobil bir uygulama ise yüklenme/açılış hızına ciddi etkileri olacaktır.

Aşağıda görüldüğü gibi Redis, hem çok hızlı cevap vermekte (latency) hem de saniyede gerçekleştirdiği işlem sayısıyla (throughput per second) veritabanlarından çok daha fazla işlem gerçekleştirmektedir.

redis benchmark

Ama aslında bu yazının konusu bu değil, bir yazılım mimarisi konusu olarak Publisher/Subscriber yöntemini Redis ile gerçeğe dönüştürebiliyoruz. Birazdan Redis kurulumundan, NodeJS ile örnek bir Pub/Sub implementasyonuna kadar olan süreci birlikte yapacağız.

"Ne tantana yaptın bize icraat göster" dediyseniz şimdi geliyor o da.

Bizi Redis’le Üstat

Bu iş için bize gereken yalnızca bir Linux makine, tercihen Ubuntu (Debian tabanlı bir distro) olursa tadından yenmez. Redis’in kendisini kurduktan sonra da ya shell ile redis-cli üzerinden ilerleyebilirsiniz ya da Redis’in resmi RedisInsight uygulamasını kullanabilirsiniz. Ben kısaca anlatacağımdan redis-cli üzerinden gideceğim.

Redis kurmak için ise iki seçeneğimiz var biri klasik apt repolarından çekebiliriz, diğeri ise ki benim favorim Docker kullanmak, şu an ilk kez Docker duyduysanız burada onun kurulumunu anlatmayacağım için Google’layabilirsiniz.

APT repolarından nasıl kurulur? Sorusunun cevabını DigitalOcean makalelerinde[^1] güzelce verilmiş, lütfen bakınız.

Docker ile nasıl kurulur? Sorusunun cevabı çok basit:

sudo docker run -p 6379:6379 --name redisKonteynerIsminiz -d redis

Ta-da bu kadar.

6379 portunda bir adet Redis sunucumuz ayağa kalktı ve bizi bekliyor. Konteynera erişmek için aşağıdaki komutu kullanarak direkt redis-cli’a ulaşabiliriz.

sudo docker exec -it redisKonteynerIsminiz redis-cli

İçerdeyseniz PING komutunu yazarak sağlık kontrolü yapınz, PONG cevabını aldıysanız her şey yolundadır. Bitmedi, örnek olması açısından bir key-value girelim ve Redis’e soralım belleği kuvvetlimiymiş?

redis-cli’da aşağıdaki komutu girin:

SET adim kerimkaan

"OK" cevabı aldıysanız başarılı bir şekilde key kaydetmişsinizdir. Şimdi de soralım bu "adim" keyinin değeri nedir diye?

GET adim

"kerimkaan" cevabını aldıysanız (ya da siz ne yazdıysanız artık) doğru cevaba ulaşmışızdır. Bu basit SET/GET operasyonunu birçok dilde bulunan Redis clientları tarafından siz de uygulamanıza implement edebilirsiniz. Bu konunun devamı olarak TTL (Time-to-Live) ve diğer veri tiplerine (hashmap, list, sorted list vd.) değinmiyorum, Redis dokümantasyonundan detaylarına ulaşabilirsiniz.

Pub/Sub Nedir?

Publisher/Subscriber sözcüklerinin açılımı olan bir yazılım geliştirme tekniğine verilen isim. Temelde bir radyo/tv yayınına benzetebiliriz, radyo istasyonu belirli frekanslardaki radyo dalgalarını vericiler ile atmosfere yayınlar ve dinleyicilerde radyo alıcılarından bu belirli frekanstaki radyo dalgalarını cihazları aracılığı ile dinlerler.

Bu minvalde bizim kodumuzda bir şeyler üretilecek ve Redis ile belirli bir kanalda yayınlanacak, yine Redis’te belirli bir kanalı dinleyen dinleyicilerde gelen veriyi işleyecek ve görevlerini yerine getirecek. Alıcı ve vericiler aynı kod tabanı altında olabilir (monolitik bir mimari ise mesela) veya ayrı kod tabanlarında da olabilirler (dağıtık bir mimaride çalışıyorsanız mesela mikroservis gibi) bu konuda bir sorun yok, yeter ki aynı Redis sunucusuna erişebiliyor olsunlar.

Redis-cli üzerinde bir deneyelim derseniz, iki ayrı konsoldan redis-cli’a giriniz ve birinde şu komutu uygulayınız:

PUBLISH test "mesajim budur benim"

Diğer konsolda da şunu uygulayınız:

SUBSCRIBE test

Dikkat edilecek husus eğer önce PUBLISH ederseniz ve sonra SUBSCRIBE ederseniz mesajı göremeyeceksiniz, çünkü yayınlanmış olacak ama alıcı olmadığından boşluğa gitmiş gibi düşünebilirsiniz. Önce dinleyici pozisyonunu alırsanız şöylece mesajı görebilirsiniz:

Redis pubsub

Publisher konsolunda cevaben 2 değeri, bu kanalın açıldığını ve mesajın yayınlandığını ifade etmekte, Subscriber konsolundaki 1 ise kanalın dinlenmeye başlandığını belirtir. Alma-verme işlemi o kadar hızlı oluyor ki neredeyse gerçek zamanlı denilebilir, bu yöntemle mesajlaşma uygulaması yazabilirsiniz ya da CPU-heavy operasyonlarınızı uygulama sunucunuzdan farklı bir yerde yaparak yükü dağıtmak isterseniz yine bu yöntemi kullanabilirsiniz. Bir çok farklı senaryoda Pub/Sub tekniği ile efektif bir uygulama geliştirebilirsiniz, Redis bu noktada da gördüğünüz gibi yardımımıza yetişiyor.

Redis ile Pub/Sub Uygulama Geliştirelim

redis fast

Şimdi elimizi kirletme zamanı, örnek uygulamamızın senaryosunu yazıyorum şimdi:

  • Uygulama sunucumuz kullanıcıdan bir POST endpointi ile aldığı bazı bilgileri (header’da veya body’den gelecek) alıp Redis’le publish etsin, kanalın adı da userdata olsun.
  • Worker sunucumuz da bu userdata kanalındaki mesajı alıp ek bilgiler ekleyip (dateCreated, id gibi) Redis’te user:id key’iyle bu verileri kaydetsin.
  • Ana uygulamamız da bir GET endpointi ile kullanıcıdan alacağı id parametresine göre Redis’ten kullanıcı bilgilerini edinsin ve JSON olarak bilgileri sunsun.

Böylece ana uygulamamız publisher olacak ve kullanıcı ile etkileşime girecek, worker uygulamamız ise subscriber olacak ve kullanıcı ile etkileşime girmeyecek görevini yapıp kanalı dinlemeye devam edecek. Bunların yanında Redis’in diğer özelliklerini – INCR, SET, GET – de kullanmış olacağız. Siz isterseniz burada senaryoyu değiştirip kullanıcıdan alınacak bir dosyayı S3’e yükleyip worker uygulama ile bunu sıkıştıran daha sonra kullanıcıya mail ile ulaştıran (AWS SES vb. ile olabilir) bir uygulama yazabilirsiniz, örnekler bunlarla sınırlı değil mesela şurada görebileceğiniz üzere MongoDB’de yapılan değişiklikleri takip edip (pub/sub) Redis ile önbellekleme verimini artırabilirsiniz.

Tüm bunları iki ayrı NodeJS uygulaması olarak geliştireceğiz, ana uygulama ExpressJS tabanlı bir REST API şeklinde olacak. Worker uygulamamız da yalnızca Redis ile çalışacağı için ioredis paketinden başka bir şeye ihtiyacımız olmayacak.

Ana Uygulamayı Yazalım

Express ile yapacağız demiştik, NodeJS kurulumunu yaptığınızı varsayıyorum tercihen sürümü LTS 14.18.1 civarı olmalı. Express için ise projeyi express-generator ile oluşturacağız.

mkdir redis-pubsub-main && cd redis-pubsub-main && npx express-generator

Diyerek redis-pubsub-main klasörü oluşturup burada da Express projemizin kullanacağımız komponentlerini hazır bir şekilde görmemiz lazım. Dosyaları görüyorsanız doğru bir şekilde işlemi gerçekleştirmişsinizdir, şimdi de npm i diyerek proje bağımlılıklarını lokalimize kuralım.

Bir IDE ile bu proje klasörünü açalım ve app.js dosyasına göz atalım, aşağıdaki gibi bir takım JS kodları ile karşılaşacağız, burası Express uygulamamızın main methodu diyebiliriz, şimdi NodeJS için Redis client’ı olan ioredis ve .env dosyamızı okuması için dotenv paketlerini npm ile enjekte edelim.

npm i ioredis --save && npm i dotenv --save

Hatasız bir şekilde yüklendiğinden emin olmak için package.json dosyasından kontrol edebilirsiniz. Redis’i NodeJS uygulamamızdan çağırmak için şimdi bir .env dosyası oluşturalım ve içine halihazırda çalışmakta olan Redis konteynerimizin IP adresini girelim. Redis konteynerinin IP adresini öğrenmek için docker inspect konteynerismi şeklinde Networks altındaki IPAddress değerini alabilirsiniz. Lokal ağ durumunuza göre genelde ve başka konteyneriniz yoksa 172.17.0.2 olur.

.env dosyamıza şöyle bir satır ekleyelim:

REDIS_URL=redis://172.17.0.2

Bunu da yaptıktan sonra uygulamamızın index URL’leri ile çalışacağımızı varsayarak routes altındaki index.js’e girip Redis client’ımızı tanıtıp yine burada bir POST endpointi tanımlayarak kullanıcıdan bilgi alıp Pub/Sub kanalına gönderelim:

// Kullanıcıdan aldığı bilgileri `userdata` kanalına gönderir.
router.post('/saveData', (req, res, next) => {
  const { username, name, surname, role } = req.headers
  const userData = { username, name, surname, role }
  redis
    .publish('userdata', JSON.stringify(userData))
    .then((data) => {
      console.log('Kullanıcı bilgileri başarıyla gönderildi..')
      res.json({ message: 'Kullanıcı bilgileri başarıyla gönderildi.' })
    })
    .catch((err) => {
      console.log('Kullanıcı bilgileri gönderilemedi..')
      res.status(500).json({ message: 'Kullanıcı bilgileri iletilemedi', hata: err })
    })
})

Adım adım incelersek, req.headers yani request’in header’ından gelen req.headers.username gibi değerleri tanımlıyoruz. Burada const username = req.headers.username demek yerine daha elegant bir yazım kullandım. Ardından bir objeye bu değerleri tanımlıyoruz. Redis client’ı ile publish edeceğimiz userdata kanalına objemizi string’e çevirerek iletiyoruz. Burada eğer bir hata ile karşılaşılırsa kullanıcıya HTTP 500 ile cevap veriliyor, değilse bir JSON mesajı ile başarılı olduğu iletiliyor.

Son olarak bir de kullanıcı verilerini çekmek isteyeceğiz ve ana uygulamamızda yapacağımız işlemler bitmiş olacak. GET /getUserData/:id endpointini yazalım:

router.get('/getUserData/:id', async (req, res) => {
  const id = req.params.id
  if (!id) {
    res.status(400).json({ message: 'ID parametresi girilmelidir.' })
  } else {
    const redis = new Redis(process.env.REDIS_URL) // İlk Redis bağlantısı publisher için kullanıldığından yeni bağlantı kurulur.
    const getUserFromRedis = await redis.get(`user:${id}`)
    if (getUserFromRedis) {
      res.json(JSON.parse(getUserFromRedis))
    } else {
      res.status(500).json({ message: "İlgili ID'li kullanıcı bulunmamaktadır.", id })
    }
  }
})

Diğer Redis clientını önceki endpointte tükettiğimiz için burada tekrardan bir client oluşturuyoruz, birazdan worker uygulamamızda göreceğiniz user:id keyiyle Redis’e soruyoruz böyle bir key sende var mı diye? Şayet varsa biz de aldığımız cevabı parse ederek kullanıcıya JSON formatında sunuyoruz, değilse 500 dönüyoruz.

Worker Uygulamamızı Yazalım

Ana uygulamanın yapmakla mükellef olmaması gereken ama yapılması gereken bir iş düşünün, bir veriyi manipule etmek, ağır işlem gücü gerektiren işlemler (sıkıştırma, format değiştirme, ölçekleme gibi) kısaca ana uygulamanın yani kullanıcıya dönük olan uygulamanın karşıladığı durumlarda kullanıcıların olumsuz etkileneceği tüm operasyonları başka uygulamalara paslayarak toplam işlem gücünüzü kullanıcınızın en iyi faydalanabileceği şekilde kullanmanızı sağlamaya çalışırız.

Biz de bu senaryoda Worker ile aslında çok basit olan bir işlem yapacağız, fakat bu basitliğin altında zorlu görevleri ve senaryoları nasıl başarıyla gerçekleştirebileceğinize dair ufkunuzu açacağını düşünüyorum.

Worker uygulamamız için gerekenler: ioredis ve dotenv

Uygulamamız basit olduğu için yalnızca bir app.js dosyasından ibaret olacak (.env ana uygulamamız ile aynı olacak)

app.js ise şöyle:

require('dotenv').config
const Redis = require('ioredis')
const redis = new Redis(process.env.REDIS_URL) // .env dosyasında belirttiğimiz adrese Redis bağlantısı kurar.

async function subscriber() {
  redis.subscribe('userdata', (err, count) => {
    if (err) {
      console.log(err)
    } else {
      console.log(`${count} adet kanal dinleniyor..`)
    }
  })

  redis.on('message', async (channel, message) => {
    const userData = JSON.parse(message)
    const dateCreated = new Date().toISOString()
    // İlk kullanılan Redis bağlantısı yalnızca subscriber için kullanılmak zorunda, o sebeple SET ve INCR için yeni bir Redis bağlantısı kurulur.
    const redisSet = new Redis(process.env.REDIS_URL)
    const id = await redisSet.get('idCounter') // Redis içinde idCounter değerini sorgular.
    if (id && id >= 1) {
      const userDataMerge = Object.assign(userData, { ID: id, dateCreated })
      console.log(userDataMerge) // Kullanıcı verileri ekrana yazdırılır.
      redisSet.incr('idCounter') // idCounter'i bir artırır. Burada Redis'in INCR methodu kullanılmakta, bununla iki uygulama arasında id'leri takip edebiliriz.
      const setUser = `user:${id}` // id'yi setUser'a atar. Redis'in SET methodu kullanılmakta, bu bilgileri Redis'e kaydetmek için key olarak kullanılır.
      redisSet.set(setUser, JSON.stringify(userDataMerge)) // setUser'a userDataMerge'ı string olarak atar.
    } else {
      const userDataMerge = Object.assign(userData, { ID: '0', dateCreated })
      console.log(userDataMerge) // userdata array'i ekrana yazdırılır.
      const setUser = `user:${0}` // id'yi setUser'a atar. Redis'in SET methodu kullanılmakta, bu bilgileri Redis'e kaydetmek için key olarak kullanılır.
      redisSet.set(setUser, JSON.stringify(userDataMerge)) // setUser'a userDataMerge'ı string olarak atar.
      redisSet.incr('idCounter') // Redis'te idCounter değeri olmadığı için 1 olarak atanır.
    }
  })
}

subscriber()

Kısaca şöyle, burada subscriber rolünü üstleniyoruz, en başta belirlediğimiz userdata kanalını dinliyoruz, mesaj varsa redis.on kısmından itibaren methodumuz çalışmaya başlıyoruz, öncelikle string gelen mesajımızı JSON objesine çeviriyoruz, bir alt satırda o anın – zaman – değerini ISO standartlarında string tipinde alıyoruz, daha sonra ilk client’ımız subscriber olduğu için yeni bir Redis client’ı tanımlamak durumundayız, bu client önce idCounter değerini sorguluyor bu değer bizim kullanıcımızın id’si aynı zamanda, ilk çalışmada tahmin edeceğiniz gibi olmadığı için if bloğuna girmeden else bloğuna girecektir. Burada da Object.assign fonksiyonunu kullanarak iki objeyi tek bir obje haline getiriyoruz, ID indeximizi 0’dan başlatıyoruz o sebeple hard-coded olarak giriliyor. Daha sonra konsolda görebilin diye bu değerleri console.log olarak konsolumuza basıyoruz. Sonraki aşamada ID’miz 0 olduğu için Redis’te tutacağımız verinin key değerini user:0 yapacak şekle getiriyoruz, oluşan kullanıcı bilgilerini tuttuğumuz objeyi string’e çevirerek Redis’e SET komutu ile gönderiyoruz. Son olarakta idCounter değerini INCR komutu ile oluşturuyoruz. (bu komut aslında değeri +1 artırmak için kullanılıyor, fakat bu değer yoksa 1 olarak oluşturuyor.)

İlk çalıştırmada yukarıda bahsettiğimiz gibi else bloğu çalışacak, sonraki kullanıcı girişlerinde ise if bloğu çalışacaktır. Orada da yine else bloğunda olduğu gibi objeler birleştirilecek idCounter 1 artırılacak ve Redis’e objemiz yine stringify edilerek SET edilecek.

Bu kadar. Hadi deneyelim, şimdi sırası önemsiz olmak üzere ana ve worker uygulamalarımızı ayağa kaldıralım:

Ana uygulamanın olduğu klasörde:

npm start

Başka bir konsolda da, worker uygulamanın olduğu klasöre geçip:

npm start

Diyerek ayağa kaldıralım, şöyle konsollar görüyor olmalısınız:

Şimdi Insomnia vb. bir HTTP client ile POST isteğimizi yapalım:

Worker konsoluna dikkat ederseniz, kullanıcıdan alınan bilgiler ve worker uygulamasında eklenen veriler (id ve dateCreated) konsola basılıyor, buradan işlemin doğru gerçekleştiğini anlayabiliriz, şimdi bu işlemi Redis’e sorarak edinme zamanı, GET endpointimize istek atalım:

Son olarak Redis’e bağlanıp keyleri kendi gözlerimizle görelim:

idCounter ve iki kullanıcı oluşturulmuş bunları da keys * komutu ile listeleyebilir, GET key ile de görebilirsiniz.

Saded

Redis ile yüksek performanslı uygulamalar geliştirebileceğimizi ve yalnızca önbellekleme değil olay bazlı programlamaya da yardımcı olabileceğini görmüş olduk. Yukarıdaki örnek uygulama ile daha karmaşık uygulamaların temelini atabilirsiniz.

[^1]: How to install and secure Redis on Ubuntu 22.04