Çok Odalı Node.js Chat Uygulaması

İçindekiler tablosu yükleniyor...

Not: Bu yazı benim node.js'i ilk kez pratik olarak denememle beraber oluşturduğum notlardan ve deneylerden oluşmaktadır.

Uygulama Grunt, Redis ve promise kısımları dışında (her ne kadar 2001 yılında lisede şu harika siteyi yaptıysam da yıllar içinde tasarım becerim köreldi) tasarım da dahil olmak üzere Sachin Bhatnagar'ın müthiş Udemy Course'undan baz alınmıştır.

Kodları ve teknolojileri kafama göre değiştirdim ve size bu yazıyı hazırladım. Çaldığım (esinlendiğim :)) kısımlar için kendisinden özür diliyorum ancak en azından yazı sadece Türkçe olarak yayınlanacaktır. İngilizceniz varsa şiddetle üstteki linkten dersi satın almanızı öneririm.

Kullanılacak teknolojiler:

  • Node.js
  • Express framework
  • Grunt build manager
  • Redis
  • Mongo, mongoose
  • Q, Bluebird promise library'leri
  • Passport.js
  • socket.io

Başlarken

Node.js Linux / Mac OSX / Windows gibi daha bir çok platformda çalışabilen bir yazılımdır. Eğer Linux / OS X terminaline aşina biriyseniz Node.js kullanmak gerçekten çok kolay ve keyifli oluyor. Ancak ülkemizde ağırlıklı olarak Windows üzerinde yazılım geliştirildiğini bildiğimden sadece bu kısımda olmak üzere Windows üzerinde nasıl gerekli yazılımları kurup çalıştırırsınız ona gireceğim.

Eğer Linux'tan korkmayan ve farklı bir dünyaya dalmak isteyen cesur bir yazılımcıysanız, sanal olarak bir Ubuntu Linux kurup işinizi kolaylaştırabilirsiniz.

Öncelikle gereksinimlerimiz:

  • Node.js
  • NPM (node paket yöneticisi)
  • Git (SVN, TFS gibi ama daha iyisi)
  • Text Editor / IDE

Node node.js uygulamalarını çalıştıracağımız program. node dosya.js şeklinde çalıştırabileceğimiz gibi, diğer kuracağımız programcıklar üzerinden de çalıştırılabiliyor. npm node kurulumu ile birlikte geliyor ve node paketlerini kurmamızı sağlayacak.

Peki neden node.js?

  • Çünkü Javascript!
  • Google'ın chrome'da da kullandığı V8 Javascript motoru üzerinde çalışır
  • Event-driven (olay güdümlü), non-blocking (engellenmeyen) I/O (giriş çıkış) modelini kullanarak verimli ve hızlı çalışır: concurrency
  • Web uygulamaları geliştirmek için uygundur
  • Scale edilmesi (ölçeklenmesi) kolaydır
  • Müthiş kütüphane ve topluluk desteği,
  • Modüler, genişletilebilir yapıda olması: extensibility

İngilizce şu video'dan izlemenizi tavsiye ederim: What is Node.js?. Başka kaynaklardan da araştırabilirsiniz.

NPM, Node package manager, https://www.npmjs.com/ üzerinde de yayımlanan paketleri indirmenize yarar. package.json dosyası üzerinden projenizin paketlerinin takibini yapar.

Git'i benim projemi indirmek için kullanacağız. Ayrıca git branch ile projenin farklı bölümlerinin kodlarına geçiş yapabilirsiniz.

Atom benim son dönemde kullanmaktan keyif aldığım, github'ın bir ürünü. Her işletim sisteminde çalışması da cabası. Çıplak haliyle de yeterince iyi olan bu editör, tıpkı npm gibi apm adında bir paket yönetim programına sahip. İster atom'un arayüzünden ister komut satırından kullanabilirsiniz. Arayüzden popüler paketleri inceleyip yükleyebilirsiniz.

Şimdi reklamlar: https://www.youtube.com/watch?time_continue=133&v=Y7aEiVwBAdk

Windows

Komut satırı cmd'yi administrator olarak açmanız gerekebilir. Yüklenen programların tanınması için komut satırını yeniden açmanız gerekebilir

  1. Chocalatey kurun. Ubuntu apt-get benzeri kurulum yapmanızı sağlayacak. Bu sayede windows cmd üzerinden kurulum yapabileceksiniz:
  2. choco install git
  3. choco install node
  4. choco install atom

Linux (Ubuntu)

Linux'unu sanal olarak kullanmak isteyenler için:

  1. Download VirtualBox
  2. Download Ubuntu
  3. Altından kalkamadınız mı sizi google yada şuraya alalım https://mylifemypc.wordpress.com/appserv/oracle-virtualbox-ile-ubuntu-kurulum

Terminal açıp komut yazacağız, yönetici hakkı gerektiren işlemde komutların başına sudo koyuyoruz ve parolamızı giriyoruz.

1sudo apt-get update
2sudo apt-get install nodejs

Kurulumlarla ilgili problem yaşarsanız şuradan bakabilirsiniz: https://www.digitalocean.com/community/tutorials/how-to-install-node-js-on-an-ubuntu-14-04-server

Mac OS X

OS X'te de Homebrew kullanalım. Kurulum için:

1ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

Ardından brew ile kurulum yapıyoruz:

1brew install node
2brew install git

Atom kurun: https://atom.io/download/mac

Proje Başlasın

Şimdi node.js'te yeni bir proje nasıl oluşturulur görelim:

1npm init

package.json dosyasını oluşturmak için bir takım sorular sorar

Kullanacağımız node paketlerini kuralım:

1npm install express --save
2npm install hogan-express --save

Uygulamamızın genel yapısı şu şekilde olacak:

1project/
2    public/
3        css/
4        images/
5    views/   
6    routes/
7    Gruntfile.js
8    app.js
9    package.json

Uygulama ile ilgili her dosyayı buradan paylaşamıyorum. O yüzden ister ara ara bakmak için kullanın, isterseniz de indirip üzerinden devam edin, şu linkten uygulamanın ilk halini görebilirsiniz: https://github.com/dhalsim/chatcat. Tavsiyem bir branch'ini bilgisayarınıza kurup devam etmeniz:

1git clone https://github.com/dhalsim/chatcat.git
2cd chatchat
3npm install
4npm instal grunt-cli -g
5grunt dev

Browser'dan http://localhost:3000 açıp test edin.

Geliştirme

Meteor.js'te uygulama geliştirirken çok büyük kolaylık sağlayan bir sistem var. Gavurların hot-code reloading dedikleri bu sistem, sizin uygulama dosyalarınızı izliyor ve bir değişiklik olduğunda node.js'i baştan başlatıyor. Üstelik açık olan browser'da kendini yeniliyor. Yani siz editörünüzde çalışıp Ctrl+S'ye basıp browser'a döndüğünüzde yeni kodu çalışmış halde buluyorsunuz. Development'ı çok hızlandıran bir durum.

Node.js için bu sistemi yapabilmek için grunt.js denen bir build sistemi kullanacağım. Grunt kısaca sizin javascript ile bir takım işleri otomatikleştirmenizi sağlıyor. Örneğin less dosyalarını css dosyalarına çevirmenizi, javascriptleri minify etmenizi sağlayabilir. Üstelik birçok grunt-contrib hazır paketi var.

Grunt.js'i tanıyalım

Grunt.js aslında bir task manager'dır. Veriler taskları komut satırından çalıştırabilirsiniz.

1grunt dev

Çalıştırılan directory içinde Gruntfile.js'yi arar ve içinde verilen tasklardan dev ismindekini çakıştırır.

1grunt.registerTask('dev', ['express:dev', 'watch:all']);
  • dev isminde bir task yaratır.
  • Bu task başka iki task'ı çalıştırır.
  • grunt-express-server ile eklenen express task'ının dev target'i ile
  • grunt-contrib-watch ile eklenen watch task'ının all target'ini çalıştırır.
  • target'ler initConfig içinde tanımlanmış olmalıdır (anlatacağım)

Grunt.js kurulum

Öncelikle grunt'ı yükleyelim:

1npm install -g grunt-cli

grunt komut satırı çalıştırabilir programını global olarak yükler

1npm install grunt --save

grunt node.js library'sini uygulama altına yükler

Kullanacağımız diğer paketleri de yukarıdaki gibi npm ile yükleyelim:

1npm install grunt-contrib-watch --save
2npm install grunt-express-server --save
3npm install load-grunt-tasks --save
4npm install connect-livereload --save-dev
  • grunt-contrib-watch: Verdiğiniz ayarlardan file watcher'lar oluşturur, değişikliklerde veriler task'ları çalıştırır
  • grunt-express-server: express.js node uygulamanızı çalıştırır
  • connect-livereload: express.js'de template'lerinize livereload script'ini inject eder.
  • load-grunt-tasks: package.json içindeki dependency'lerinizi okuyup otomatik olarak onları gruntfile.js'te yükler. Ayrıntılı bilgi

Farkettiyseniz bu komutlar package.json dosyasında değişikliklere yol açtılar:

 1{
 2  "name": "chatcat",
 3  "version": "1.0.0",
 4  "description": "",
 5  "main": "app.js",
 6  "scripts": {
 7    "test": "echo \"Error: no test specified\" && exit 1"
 8  },
 9  "author": "",
10  "license": "ISC",
11  "dependencies": {
12    "express": "^4.13.3",
13    "grunt": "^0.4.5",
14    "hogan-express": "^0.5.2"
15  },
16  "devDependencies": {
17    "connect-livereload": "^0.5.3",
18    "grunt-contrib-watch": "^0.6.1",
19    "grunt-express-server": "^0.5.1",
20    "load-grunt-tasks": "^3.3.0"
21  }
22}

Peki dependencies yanında bu devDependencies nedir diye sorarsanız cevabı burada

Gruntfile.js

Bir sonraki yapmamız gerekenlerden Gruntfile.js dosyasını oluşturmak:

 1module.exports = function(grunt) {
 2  require('load-grunt-tasks')(grunt);
 3  var path = require('path');
 4
 5  grunt.initConfig({
 6    express: {
 7      dev: {
 8        options: {
 9          script: path.resolve('./app.js')
10        }
11      }
12    },
13    watch: {
14      options: {
15        livereload: true
16      },
17      all: {
18        files: [
19          'app.js',
20          'Gruntfile.js',
21          'public/**/*.*',
22          'views/**/*.*'
23        ]
24      }
25    }
26  });
27
28  grunt.event.on('watch', function(action, filepath, target) {
29    grunt.log.writeln(target + ': ' + filepath + ' has ' + action);
30  });
31
32  grunt.registerTask('dev', ['express:dev', 'watch:all']);
33}
  • initConfig içine task'ların gerekli parametrelerini belirtiyoruz
  • express task'ına dev, watch task'ına all isminde target'ler tanımlanmış
  • express > dev > options > server ile çalışacak express uygulamasının dosya ismini verdim
  • watch > all > files ile değişikliklerini izlemek istediğim dosya ve klasörleri belirttim

Evet artık grunt dev komutuyla uygulamamızı başlattıktan sonra http://localhost:3000 altından test edebiliriz. Herhangi bir dosyayı değiştirin ve kaydedin. grunt'ın uygulamayı yeniden başladığını ve browser'ın sayfayı yenilediğini göreceksiniz.

Express

Grunt'ın yaptığı bir takım sihirbazlıkları anlamadan önce, express nedir ne işe yarar ona bakalım.

Express, node.js ile birlikte çok basit bir http server yapabilmenize olanak sağlarken, middleware yapısıyla istenidiğiniz esnekliğe sahiptir. İster REST servisi yazabilir isterseniz bir web uygulaması yazabilirsiniz.

Middleware nedir?

Middleware request objesine (req), response objesine (res) ve sonraki middleware'e (next) erişimi olan bir fonksiyondur.

  • Her tür kodu çalıştırabilir
  • Request ve response objelerini değiştirebilir.
  • Request-response çağrımını durdurabilir
  • Sıradaki middleware'i çağırabilir

Application level veya route level tanımlanabilir. Error handling (hata yönetimi) için kullanımı farklıdır. Ayrıntılı bilgi: http://expressjs.com/guide/using-middleware.html

View'lar

Biz hogan (mustache) template engine'i kullanarak bir chat web uygulaması yazacağız.

Hogan ile ayrıntılı bilgi: https://github.com/vol4ok/hogan-express
Mustache: http://mustache.github.io/mustache.5.html

Hadi uygulamamızı inceleyelim:

app.js:

 1var express = require('express');
 2var path = require('path');
 3
 4var app = express();
 5
 6app.set('views', path.join(__dirname, 'views'));
 7app.set('view engine', 'html');
 8
 9// template'ler için .html uzantısını kullan
10app.engine('html', require('hogan-express'));
11
12app.use(express.static(path.join(__dirname, 'public')));
13app.use(require('connect-livereload')());
14
15require('./routes/routes.js')(express, app);
16
17app.listen(3000, function () {
18  console.log('chatcat 3000 portunda çalışıyor');
19});
20
21module.exports = app;
  • İlk üç satır hogan'ı template engine için ayarlamak üzere kullanılıyor
  • sonraki satırda public klasörü statik dosyaların sunumu için ayarlanıyor
  • connect-livereload middleware'i ekleniyor
  • ayrı bir dosyadan (routes.js) route'ların tanımı ayarları yapılıyor
  • 3000 portunda uygulama çalıştırılıyor
  • son satır uygulamanın grunt üzerinden çalışabilmesi için gerekli

Session

Git ile bu kısımdan sonra yapılan değişiklikleri almak için aşağıdaki komutu kullanın. Böylece bu kısımda anlatacağım değişiklikleri elle yapmak zorunda kalmazsınız.

1git checkout iki
2npm install

Uygulamamızı çalıştırdık yalnız express uygulamasının kullanıcı bazında bilgi tutabilmesi için session adı verilen bir yönteme ihtiyacı var bunu da aşağıdaki paketi kurarak sağlayacağım:

1npm install express-session --save

ve artık session'ı kullanabiliriz. session'la birlikte farkettiyseniz cookie paketi de yüklendi. Artık express cookie'de session ID'yi kullanarak kullanıcının bilgisini server tarafında takip edebileceğiz. Çok çeşitli ayarları olmakla birlikte biz hepsini kullanmayacağız. İncelemek isterseniz: https://github.com/expressjs/session

1var session = require('express-session');
2
3app.use(session({
4  secret: 'gizlişey',
5  resave: false,
6  saveUninitialized: true,
7  cookie: { secure: true }
8}));

Not: cookie secure=true değeri HTTPS bağlantıda geçerlidir, HTTP bağlantıda (mesela development ortamında) cookie çalışmayacaktır. Bunu önlemek için kodumuza hangi ortamda (development, production, vb.) çalıştığıyla ilgili kontroller eklemeliyiz. Bu kontrolleri kolay yönetilebilir yapmak için kendi environment modülümüzü yazacağız

Environment Modülü

Şimdi projemize yeni dosyalar ekliyoruz

1project/
2    ...
3    config/
4        development.json
5        production.json
6        environment.js
7    ...

environment.js:

1module.exports = require('./' +
2    (process.env.NODE_ENV || 'development') + '.json');
  • process.env.NODE_ENV üzerinde çalıştığı bilgisayarın işletim sisteminde NODE_ENV ismindeki variable'ı bize verir. Bu variable set edilmemişse undefined olacağından varsayılan olarak development olarak çalışacak.
  • Production ortamında bu variable production olarak setlenmelidir. İlgili yöntemler
  • variable'ın o anki değeri ile birlikte ilgili .json dosyasını okur ve export eder.

NOT: local ortamda production ayarlarını deneyebilmek için test diye bir environment uydurdum. production'dan tek farkı cookie secure değil ama redis cloud üzerinden çalışacak.

development.json:

 1{
 2  "cookie_secret": "gizlişey",
 3  "cookie": {
 4    "secure": false
 5  },
 6  "redis": {
 7    "port": 6379,
 8    "host": "localhost"
 9  }
10}

test.json:

 1{
 2  "cookie_secret": "gizlishey",
 3  "cookie": {
 4    "secure": false
 5  },
 6  "redis": {
 7    "port": 11386,
 8    "host": "pub-redis-11386.us-east-1-2.4.ec2.garantiadata.com",
 9    "pass": "chatcat"
10  }
11}

production:

 1{
 2  "cookie_secret": "gizlishey",
 3  "cookie": {
 4    "secure": true
 5  },
 6  "redis": {
 7    "port": 11386,
 8    "host": "pub-redis-11386.us-east-1-2.4.ec2.garantiadata.com",
 9    "pass": "chatcat"
10  }
11}

app.js'te:

1var config = require('./config/environment');
2
3app.use(session({
4  secret: config.cookie_secret,
5  resave: false,
6  saveUninitialized: true,
7  cookie: config.cookie
8}));

Session (Devam)

Artık session verilerimizi kullanabildiğimize göre, bir sonraki işimiz session store kullanmak olacaktır. Daha önceden de bahsettiğimiz gibi session ile birlikte veri asıl olarak server tarafında tutulmaktadır. Ancak biz store belirtmediğimiz için varsayılan olarak MemoryStore kullanılıyor.

Biz de bu store'u değiştireceğiz. Bunu Redis olarak ayarlayacağız.

Redis, hafızada çalışan bir key-valued NoSQL veritabanı. Daha birçok yararlı kullanım alanları var ancak ilerledikçe bakacağız. Çok merak ediyorsan buradan bakabilirsin.

MemoryStore'da veriler bilgisayarın geçici hafızasında tutulmaktadır. Sunucu restart vb. durumlarda bu verileri kaybedebiliriz. Daha da önemlisi ileride uygulamamızı birden fazla bilgisayar üzerinde -veya cluster'da- çalıştırmak istediğimizde her process'in kendi hafızasında bilgi tutmasından dolayı senkron problemleri yaşanacaktır. Redis'le bu problemlerin üstesinden gelmeye çalışacağız.

1redis-server

Redis server port 6379 üzerinden çalışıyor. Bunu kapatmadan başka bir terminal'de:

1redis-cli

yazıp bir kaç komut deneyelim:

1127.0.0.1:6379> SET my:key 'A value'
2OK
3127.0.0.1:6379> GET my:key
4"A value"
  • İstersek de cloud üzerinden redis'i kullanabiliriz.
  • https://redislabs.com/ üzerinden free cloud database seçeneğini kullanın. Üye olup bir adet redis database'i yaratın.

Artık redis'i localde veya cloud'da çalıştırabilme imkanımız var Şimdi de node uygulamamızdan bağlanmayı deneyelim. Hatta önceki konularda uyguladığımız gibi eğer development modundaysak local redis'e, bağlanamazsa MemoryStore'a, production modundaysak da redislab üzerinden bağlanalım.

Kurmamız gereken paketler:

1npm install redis -save
2npm install connect-redis -save
3npm install q --save

Projemizin yeni yapısı:

1project/
2    ...
3    lib/
4        sessionStore.js
5    ...

sessionStore.js:

 1module.exports = function(session, redis_config){
 2  var Q = require("q");
 3
 4  var environment = (process.env.NODE_ENV || 'development');
 5  function getStore() {
 6    return new Promise(function(resolve, reject) {
 7      var redis = require('redis');
 8      var client = redis.createClient(
 9        redis_config.port,
10        redis_config.host,
11        {no_ready_check: true});
12      var RedisStore = require('connect-redis')(session);
13
14      client.on('error', function (err) {
15        reject(err);
16      });
17
18      if(redis_config.pass){
19        client.auth(redis_config.pass, function (err) {
20            if (err)
21              reject(err);
22        });
23      }
24
25      client.on('ready', function () {
26        resolve(new RedisStore({
27          client: client
28        }));
29      });
30    });
31  }
32
33  var store;
34
35  var promise = Q.fcall(getStore)
36    .then(function (value) {
37      store = value;
38    });
39
40  if(environment === 'development'){
41    promise = promise.fail(function (error) {
42      console.log(error);
43      console.log("falling back to MemoryStore");
44      store = new session.MemoryStore();
45    });
46  }
47
48  promise.done();
49
50  return store;
51};

redis client'ını oluşturmak asenkron çalıştığı için ve callback'lerle uğraşıp durmamak için yapıyı senkron çalışacak şekilde geliştirdim. Promise'lerle ilgili ayrıntılı bilgiye ES6 yazılarımdan bakabilirsiniz. Kabaca callback'lerin işini bitirdikten sonra devam ettiğimizi düşünebilirsiniz.

app.js:

 1//...
 2var session = require('express-session');
 3var store =  require('./lib/sessionStore.js')(session);
 4
 5app.use(session({
 6  secret: config.cookie_secret,
 7  store: store,
 8  resave: false,
 9  saveUninitialized: true,
10  cookie: config.cookie
11}));
12//...

Ayrıca artık grunt script'imiz environment variable'ını bizim için ayarlıyor. dev task'ı yanında yeni bir prod task'ımız da var:

Gruntfile.js:

 1  //...
 2  grunt.initConfig({
 3    setEnvironment:{
 4      dev: 'development',
 5      prod: 'production',
 6      test: 'test'
 7    },
 8  //...
 9  grunt.registerMultiTask('setEnvironment',
10    'sets environment variable to development or production',
11    function () {
12      process.env.NODE_ENV = this.data;
13    }
14  );
15
16  grunt.registerTask('dev', [
17    'setEnvironment:dev',
18    'express:all',
19    'watch:all'
20  ]);
21
22  grunt.registerTask('test', [
23    'setEnvironment:test',
24    'express:all',
25    'watch:all'
26  ]);
27
28  grunt.registerTask('prod', [
29    'setEnvironment:prod',
30    'express:all',
31    'watch:all'
32  ]);
33  //...

grunt dev diyerek uygulamayı başlatırken NODE_ENV'ı da development diye setleyecek.

routes.js dosyasını aşağıdaki şekilde düzenleyelim:

 1router.get('/chatrooms', function (req, res, next) {
 2var session = req.session;
 3
 4if (!session) {
 5  return next(new Error('oh no')) // handle error
 6}
 7
 8session.viewCount = session.viewCount
 9  ? session.viewCount + 1
10  : 1;
11
12res.render('chatrooms', {title: 'Chatrooms ' +
13  session.viewCount});
14});

Her chatrooms ziyaret edildiğinde session bazında gösterim sayısı hesaplanıp title'a yazılıyor

1redis-server
2grunt dev

Şimdi redis'i test edelim ve çalışıp çalışmadığını görelim: http://localhost:3000/chatrooms

1redis-cli
2127.0.0.1:6379> KEYS *
31) "my:key"
42) "sess:s80MXIPbWE5iRxpU8F9j2QlxAJVe9our"
5127.0.0.1:6379> GET sess:s80MXIPbWE5iRxpU8F9j2QlxAJVe9our
6"{\"cookie\":{\"originalMaxAge\":null,\"expires\":null,\"secure\":false,\"httpOnly\":true,\"path\":\"/\"},\"viewCount\":3}"

Görüldüğü gibi viewCount 3 olarak doğru bir şekilde kaydedilmiş.

Peki daha önceden düşündüğümüz local redis'in çalışmaması durumuna bakalım. redis-server'ı durdurun ve tekrar

1grunt dev
1{ [Error: Redis connection to localhost:6379 failed - connect ECONNREFUSED 127.0.0.1:6379]
2  code: 'ECONNREFUSED',
3  errno: 'ECONNREFUSED',
4  syscall: 'connect',
5  address: '127.0.0.1',
6  port: 6379 }
7falling back to MemoryStore

Görüldüğü gibi error callback'ten hatayı Promise reject'ten döndürdüğümüzde catch kısmında yakalayıp gerekli düzeltmeyi yapmış olduk. redis-cli'den başarılı bağlantı geldiğinde de ready callback'iyle Promise resolve çalıştırıp programın devam etmesini sağladık.

Şimdi de production'da kullanmayı düşündüğümüz cloud redis'i localde test ediyoruz.

1grunt test

Şimdi de cloud üzerinden çalışan RedisStore'u test edelim: http://localhost:3000/chatrooms

Farklı yöntemlerle cloud redis'e bağlanabilirsiniz. Ben komut satırından bağlanacağım:

1redis-cli -h pub-redis-11386.us-east-1-2.4.ec2.garantiadata.com -p 11386 -a chatcat
2
3pub-redis-11386.us-east-1-2.4.ec2.garantiadata.com:11386> KEYS *
41) "sess:LFKHQuh7UY8Cnsx8TWmyPcuqBASZbBsh"
5pub-redis-11386.us-east-1-2.4.ec2.garantiadata.com:11386> GET "sess:LFKHQuh7UY8Cnsx8TWmyPcuqBASZbBsh"
6"{\"cookie\":{\"originalMaxAge\":null,\"expires\":null,\"secure\":false,\"httpOnly\":true,\"path\":\"/\"},\"viewCount\":4}"

Kodun her kısmını test ettik cloud redis'imizde de bir problem olmadığına göre redis ve session konusunu burada bitiriyorum :)

Mongo DB

Facebook login'e geçmeden önce, kullanıcılarımızı kaydetmemiz gereken bir database'e ihtiyacımız var. Redis'i bu iş için de kullanabilirdik ama ben burada mongo ve mongoose kullanmak istiyorum. Mongo kullanımı kolay bir Document Database'idir. Ayrıntılı bilgi ve kurumun için buraya bakabilirsiniz.

NOT: Benim burada kullandığım komutlar işletim sistemine veya mongo versiyonuna göre değişebilir. Ben genellikle Mac OS için olan komutları kullanıyorum. Mac OS komutları çoğunlukla GNU/Linux sistemlerle benzer olmakla beraber burada farklı. Windows içinse verdiğim kaynaklara bakabilirsiniz.

Kurulum için:

1brew install mongo
2sudo chown -R $USER /data/db

Kurduktan sonra server'ı çalıştırmak için:

1mongod

Ayrı bir terminalde:

1mongo

Evet burası local mongo'nuz. Hayırlı olsun. Ancak ben yine https://mongolab.com/ üzerinden cloud bir hesap açtım. ilgili environment ayarlarını aynı redis'te yaptığım gibi giriyorum.

Ve fakat mongo'ya node tarafından bağlanabilmek için mongoose kurmalıyız:

1npm install mongoose --save

Kod yazma işlerini sonra göreceğiz. Şimdi biraz daha teori:

Mongoose.js

Mongo normalde schemaless denilen tipsiz (: bir database'dir. Ancak gerçek hayatta bu bize bazı zorluklara yol açıyor. Mongoose bunları aşmak için güzel bir library.

  • Model'leriniz için schema'lar tanımlayabilirsiniz
  • Bu schema'lar typed field'lar içerir
  • Bu modeller üzerinden database işlemleri (CRUD) yapabilirsiniz.
  • Daha da bilgi: http://mongoosejs.com/

Facebook Login

Yine git kullanarak sıradaki kısımın kodlarını alabilirsiniz:

1git checkout uc

Facebook login gerçekletirebilmek için, bir adet facebook hesabına ihtiyacımız var:

  • Daha sonra https://developers.facebook.com/apps/ adresine girin
  • add new app kısmından yeni bir uygulama tanımlayın.
  • Tip olarak, www web site seçin.
  • Çıkan kutuda bir isim girin ve Create new facebook App ID ye tıklayın.
  • Sağ üstteki Skip Quick Start'a tıklayarak çıkın
  • Settings > Website > Site URL kısmına localhost:3000 olarak girin
  • Save changes diyip, Uygulama kodunu (App ID) ve App Secret'i kopyalayıp,
  • development.json, test.json config dosyalarına şu şekilde ekleyin:
1"fb": {
2  "appId": "<APP ID>",
3  "appSecret": "<APP SECRET>",
4  "callbackURL": "http://localhost:3000/auth/facebook/callback"
5}

Problem: Node.js içindeki local module path'leri

Node.js local module'leri nasıl yüklediğimizi gördünüz. Projenin root directory'sindeki app.js'te şunu yazmıştık:

1var store =  require('./lib/sessionStore.js')(session);

Ancak mesela lib/sessionStore.js içinde model/user.js dosyasındaki modüle ulaşmak için şunu yazmamış gerekecekti:

1var user =  require('../model/user.js');

Günün birinde canımız sessionStore.js dosyasını lib/session/sessionStore.js'e taşımak istese bu sefer require içindeki path'leri şu şekilde tekrar düzeltmemiz gerekecekti:

1var user =  require('../../model/user.js');

require içine yazılan path yazıldığı yere ve istenen modülün yerine göre değişiklik gösteriyor.

Bu problemi çözmek için çeşitli yollar bulunuyor, ancak benim içinden ileride başımızı en az ağrıtacak yöntem şu oldu: https://www.npmjs.com/package/app-module-path. Ancak bu bile bizi bir takım düzenlemeler yapmaktan kurtarmıyor :(

1npm install app-module-path --save

app-module-path bize öneriler sunmuş: local modüllerin node_modules altındaki modüllerden ayrı olduğunun anlaşılması için bu modülleri ortak bir directory altına alınması.

Bu yüzden proje structure'ımız biraz değişecek ve şöyle olacak:

 1project/
 2    src/
 3        lib/
 4        models/
 5        config/
 6        routes/
 7    views/
 8    public/
 9    app.js
10    Gruntfile.js

Ayrıca yine kullanımı kısaltmak amacıyla directory altında tek modül varsa bu modül dosya isimlerini index.js yapıyorum. Bunu config/environment.js ve routes/route.js'te uyguladım.

İlgili düzenlemeleri yaptıktan sonra app.js'te ilk satıra şunu ekliyoruz:

1require('app-module-path').addPath(__dirname);

Ve artık local modüllerimizi şu şekilde yerinden bağımsız bir şekilde kullanabiliriz:

1var config = require('src/config');

Facebook Login (devam)

Facebook login'i passport.js üzerinden sağlayacağız. Kurmak için:

1npm install passport --save
2npm install passport-facebook --save

Passport ister local olarak, isterse birçok external provider'lar üzerinden Authentication (Kimlik Doğrulama) sağlayan bir kütüphane. Biz facebook bağlantısı için kullanacağız. Websitesi üzerinden diğer yöntemlere ve seçeneklere de bakabilirsiniz.

Peki external provider üzerinden bağlanmak nasıl çalışıyor?

Buradaki tüm yapılması gerken işlemleri passport bizim yerimize yapıyor. Peki implementasyonu nasıl yapıyoruz?

src/lib/facebookLogin.js:

 1var passport = require('passport'),
 2  FacebookStrategy = require('passport-facebook').Strategy;
 3
 4module.exports.init = function(config) {
 5  var facebook_config = config.fb,
 6    mongoose = require('mongoose'),
 7    User = require('src/models/user.js')(mongoose),
 8    userModel = mongoose.model('loginUser', User),
 9    mongoConnection = false;
10
11  // user, id property'si ile serialize ediliyor
12  // session'a yazılacak
13  passport.serializeUser(function(user, done) {
14    done(null, user.id);
15  });
16
17  // user, id'si kullanılarak deserialize ediliyor
18  passport.deserializeUser(function(id, done) {
19    userModel.findById(id, function(err, user) {
20      done(err, user);
21    });
22  });
23
24  var verifyCallback = function(accessToken, refreshToken, profile, done) {
25    var mongo_config = config.mongo;
26    var promise;
27
28    if(!mongoConnection){
29      promise = require('src/lib/mongoConnection.js')(mongo_config, mongoose).then(function() {
30        mongoConnection = true;
31      }).catch(function (err) {
32        console.error(err);
33        done(err);
34      });
35    } else {
36      promise = new Promise(function(resolve, reject){ resolve(); });
37    }
38
39    promise.then(function() {
40      return userModel.findOne({profileID: profile.id}).exec();
41    }).then(function(dbUser) {
42      if(!dbUser){
43        dbUser = new userModel();
44        dbUser.profileID = profile.id;
45        dbUser.fullName = profile.displayName;
46        dbUser.profilePictureURL = profile.photos[0].value;
47        dbUser.save();
48      }
49
50      done(null, dbUser);
51    });
52  };
53
54  var strategy = new FacebookStrategy({
55    clientID: facebook_config.appId,
56    clientSecret: facebook_config.appSecret,
57    callbackURL: facebook_config.callbackURL,
58    profileFields: ['id', 'displayName', 'photos']
59  }, verifyCallback);
60
61  passport.use(strategy);
62
63  return passport;
64};

routes/index.js:

 1...
 2router.get('/auth/facebook',
 3  passport.authenticate('facebook', { scope : 'email' })
 4);
 5
 6// facebook authenticate olduktan sonra
 7router.get('/auth/facebook/callback',
 8  passport.authenticate('facebook', { failureRedirect: '/login' }),
 9  function(req, res) {
10    // authentication başarılı
11    res.redirect('/chatrooms');
12  }
13);
14...
  • passport.js'nin FacebookStrategy'sini kullanarak verifyCallback içinde user profilini facebook'tan aldık
  • bu kişi db'de yoksa kaydettik
  • bu bizi callback url'in success function'ına düşürdü, oradan /chatrooms'a yönlendirme yaptık
  • req.user üzerinden kullanıcı bilgilerini kullandık

Mongo bağlantısını da aşağıdaki dosyayla sağlıyoruz:

mongoConnection.js:

 1module.exports = function(mongo_config, mongoose) {
 2  var q = require('q');
 3
 4  var promise = new Promise(function(resolve, reject) {
 5    mongoose.connect(mongo_config.url);
 6    var db = mongoose.connection;
 7
 8    db.on('error', function(error) {
 9      reject(error);
10    });
11    db.once('open', function() {
12      resolve();
13    });
14  });
15
16  return q(promise);
17};

Değişiklik veya ekleme yapılan tüm dosyaları git ile alıp inceleyebilirsiniz.

  • Sonuç olarak Login linkine tıklayıp /auth/facebook/ route'ına düşüyoruz
  • ardından passport bizi facebook'a yönlendiriyor.
  • Facebook login işlemini yaptıktan sonra bizi config ile verdiğimiz callbackURL adresine döndürüyor
  • /auth/facebook/callback route'ına düşüyoruz ve passport facebook'un gönderdiği bilgileri alıp verifyCallback fonksiyonunu kullanıp user'ın database üzerinde olup olmadığını kontrol ediyoruz
  • Yoksa kaydediyoruz, done callback'ini çağırıp işlemi tamamlıyoruz
  • passport done ile gönderdiğimiz user verisini alıp passport.serializeUser ile session'a kaydediyor.
  • artık user.id session'da req.user üzerinden alınıp kullanılabilir durumda
  • passport yine user nesnesini kullanacağımız zaman otomatik olarak passport.deserializeUser ile db'den çekiyor
  • authentication işlemi başarılıysa kullanıcıyı /chatrooms route'ına yönlendiriyoruz.
  • Artık kullanıcının bilgileri elimizde, view üzerinde bu bilgileri gösterebiliyoruz.

NOT: db gibi asenkron işlemlerde, promise adı verilen yapıları then ile bol bol kullandım. Bu yazının konusu olmadığından bunlara değinmiyorum. Javascript'te asenkronron kavramlarını açıkladığım diğer yazılarımdan bakabilirsiniz.

Güvenli sayfalar

Artık bağlanma işlemini kolaylıkla sağlayabildiğimize göre /chatrooms gibi sayfaları kullanıcı login olup olmadı mı gibi kontrolleri de yapmamız lazım.

routes/index.js:

 1...
 2
 3function securePage(req, res, next){
 4  if (req.isAuthenticated()) {
 5    next();
 6  } else {
 7    res.redirect('/');
 8  }
 9}
10
11router.get('/chatrooms', securePage, function (req, res, next) {
12
13...
  • securePage adında bir middleware tanımladık.
  • Bunu ilgili route'a koyduk.
  • Bu route browser tarafından çağrıldığında önce bizim yazdığımız middleware'e düşecektir.
  • passport'un sağladığı req.isAuthenticated fonksiyonu ile kullanıcı login olmuş mu kontrolü yapıyoruz.
  • next ile bir sonraki middleware'e devam et diyoruz
  • aksi durumda ana sayfaya yönlendiriyoruz.

Geliştirme (mongo, redis, nodemon)

Artık dev ortamındayken, mongo ve redis server'larını elle başlatma işini de grunt ile otomatize edebiliriz.

Bu iş için iki yeni grunt kütüphanesi daha kullanacağız (concurrent, exec):

1sudo npm install --save-dev grunt-concurrent
2npm install grunt-exec --save-dev
3npm install grunt-concurrent --save-dev

sudo, root access gerektiren komutlarda kullandığımız bazı Linux/UNIX işletim sistemlerindeki bir ön komuttur. Şifrenizi girmeniz gerekebilir.

initConfig'e iki yeni tanım giriyoruz:

1exec: {
2  mongo: {
3    command: './mongo.sh'
4  },
5  redis: {
6    command: './redis.sh'
7  }
8}

İki yeni dosya oluşturuyoruz:

redis.sh:

1#!/bin/sh
2
3OUTPUT="$(redis-cli ping)"
4
5if [ "$OUTPUT" = "PONG" ]; then
6  echo "redis-server is already running."
7else
8  redis-server
9fi

mongo.sh:

1#!/bin/sh
2
3OUTPUT="$(pgrep mongod)"
4
5if [ "$OUTPUT" = "" ]; then
6  mongod
7else
8  echo "mongo service is already running."
9fi

Dosyalara çalıştırma hakkı veriyoruz:

1chmod a+x redis.sh
2chmod a+x mongo.sh

nodemon

Şimdi de livereload için bir yükseltme yapacağız. Normalde kullandığımız ayarlarla node uygulaması yeniden başlamıyordu. Bu da yenilemelerin view'larla sınırlı olmasına yol açıyordu. Artık grunt-express-server yerine nodemon kullanacağız. nodemon, dosyaları izleyip değişiklik olduğunda node'u yeniden başlatacak.

1npm remove grunt-express-server --save-dev
2sudo npm install -g nodemon

NOT: Gruntfile.js dosyasında birçok değişiklik oldu. git üzerinden bakınız.

NOT: Özellikle exec ile çalıştırdığım script'ler işletim sistemi bağımlı olup gerekli değişiklikleri kendiniz yapmalısınız.


NOT: Bir sonraki chapter kodlarını git ile aşağıdaki gibi alabilirsiniz.

1git checkout dort

Socket.IO

Gerçek zamanlı, iki yönlü, event-based (olay tabanlı) iletişim için kullanılır. Her platformda, tarayıcıda veya cihazda çalışır.

Altında kullanılan teknolojileri, bunu nasıl yaptığı gibi kısımları geçiyorum. Socket.IO kullandığınız tarayıcının yetenekleri dolayısıyla farklı teknolojileri devreye sokarak bunu yapıyor.

Bizim kullanma amacımız da client'lar arasında gerçek zamanlı bilgi akışını sağlamak olacak.

İlk işimiz chat odalarının DB üzerinden kaydedilmesi ve bu bilginin client'lara gönderilmesi şeklinde olacak. Bunun için hem node tarafında kodlar yazacağız hem de client tarafı için yazılacak.

1npm install socket.io --save

Daha sonra da app.js'te bir takım değişiklikler yaptık:

1...
2var server = require('http').createServer(app);
3var io = require('socket.io').listen(server);
4require('src/lib/socket')(io);
5
6server.listen(config.port, function () {
7  console.log('chatcat ' + config.port + ' portunda çalışıyor');
8});

Ayrıca client tarafı için kullanmak istediğimiz template'lerde şu script'leri eklememiz gerekiyor:

1<script src="//code.jquery.com/jquery-1.11.0.min.js"></script>
2<script src="/socket.io/socket.io.js" charset="utf-8"></script>
3<script type="text/javascript">
4    window.socket_host = '';
5</script>
6<script src="../js/chatrooms.js" charset="utf-8"></script>

/socket.io/socket.io.js otomatik olarak express ve socket.io tarafından yakalanıp client'a sunum için hazırlanacaktır. Artık client tarafında socket ile ilgili event'leri kullanabiliriz:

window.socket_host üzerine template'e route'da gönderdiğimiz parametreyi geçiyoruz. Böylece bir alttaki chatrooms.js dosyasında kullanabileceğiz:

public/js/chatrooms.js:

1$(function () {
2  var roomChannel = io.connect(window.socket_host + '/roomlist');
3
4  roomChannel.on('connect', function () {
5    console.log('socket connection established');
6  });
7});
  • kendimize /roomlist diye bir namespace oluşturup buna bağlanıyoruz. Daha sonra bu channel'ın connect event'ini dinliyoruz. Server tarafında bağlantı kabul edildiğinde browser console'unda log'u göreceğiz.

Server tarafında da, src/lib/socket.js:

1module.exports = function (io) {
2  var chatrooms = io.of('/roomlist').on('connection', function (socket) {
3    console.log('socket connection established on server');
4  });
5};
1grunt dev

Herşey yolundaysa şunun gibi bir çıktı almamız lazım:

Basit olarak sistemi kurduk denebilir. Artık bu iletişim çerçevesinde DB üzerinde işlemler yapılacak. mongo üzerinde daha kolay çalışabilmek, kod karmaşasını önleyip düzenlemek amacıyla yine kodumuzda düzenlemelere gidiyoruz, son değişiklikler:

 1...
 2public/
 3    ...
 4    js/
 5        chatRoom.js
 6...
 7src/lib
 8    mongoAdapters/
 9        mongoConnection.js
10        roomAdapter.js
11        userAdapter.js
12    redisAdapters/
13    ...
14    facebookLogin.js
15    socket.js
16    utils.js
17    ...
18...
19src/models
20    user.js
21    room.js
22...

Burada dediğim gibi tüm kodlara tek tek girmeyeceğim. Özellikle mongoConnect ve facebookLogin üzerinde yaptığım değişiklikleri inceleyin.

Şimdi buradaki tüm kodlara girmeden kısaca mongo ve socket.io ile yaptığım oda yaratma kodlarını açıklayacağım. Daha sonra bu kodların bir de redis ile nasıl yazılabileceğine bakacağız.

Server-side

socket.js:

 1var roomAdapter = require('src/lib/adapters/roomAdapter'),
 2  Promise = require('bluebird'),
 3  channel = null,
 4  rooms = null;
 5
 6module.exports = function (io, userId) {
 7  if (!channel) {
 8    channel = io.of('/roomlist').on('connection', function (socket) {
 9      console.log('socket connection established on server');
10
11      function broadcastClient (data) {
12        var stringified = JSON.stringify(data);
13        socket.emit('room_update', stringified);
14        rooms = data;
15      }
16
17      function broadcastAll (data) {
18        var stringified = JSON.stringify(data);
19        socket.broadcast.emit('room_update', stringified);
20        socket.emit('room_update', stringified);
21        rooms = data;
22      }
23
24      var promise = rooms ? Promise.resolve(rooms) : roomAdapter.getRooms();
25      promise.then(broadcastClient);
26
27      socket.on('create_room', function (room) {
28        room.createdBy = userId;
29
30        roomAdapter.createRoom(room).then(function () {
31          return roomAdapter.getRooms();
32        }).then(broadcastAll);
33      });
34    });
35  }
36};
  • server tarafında /roomlist channel'ı üzerinden gelen bağlantıları dinliyoruz.
  • channel'ı birden çok açmamak için kontrol ekledik
  • her client için mongo'ya gidip çekmemek için server tarafında rooms cache'i kullandık.
  • Her bağlanan client için rooms verisini broadcastClient ile gönderdik
  • create_room mesajını dinlemeye aldık
  • mesaj geldiğinde ilgili verileri roomAdapter.createRoom ile mongo'ya kaydedip, tüm client'lara rooms verisini broadcastAll ile gönderdik
  • bu bilgileri client'lara room_update mesajıyla gönderdik.

NOT: socket.broadcast.emit, o anki client dışındaki client'lara bilgi gönderir. socket.emit sadece o client için gönderir.

roomAdapter.js

 1var mongo_config = require('src/config').mongo,
 2  mongoose = require('mongoose'),
 3  init = require('src/lib/adapters/mongoConnection').mongoInit,
 4  Room = require('src/models/room.js')(mongoose),
 5  roomModel = mongoose.model('room', Room),
 6  moment = require('moment');
 7
 8module.exports.createRoom = function (data) {
 9  return init(mongo_config, mongoose).then(function () {
10    var room = new roomModel();
11    room.name = data.name;
12    room.createdBy = data.userId;
13    room.createdAt = moment.utc();
14    room.save();
15
16    return room;
17  });
18};
19
20module.exports.getRooms = function () {
21  return init(mongo_config, mongoose).then(function () {
22    return roomModel.find({}).exec();
23  });
24};

Çok basit bir şekilde rooms bilgisi çeken, ve room kaydeden fonksiyonlar.

Client-side

chatRoom.js:

 1$(function () {
 2  var roomChannel = io.connect(window.socket_host + '/roomlist');
 3
 4  roomChannel.on('connect', function () {
 5    console.log('socket connection established');
 6  });
 7
 8  roomChannel.on('room_update', function(rooms) {
 9    rooms = JSON.parse(rooms);
10
11    var $ul = $('ul.roomlist');
12    $ul.html('');
13
14    rooms.forEach(function(room){
15      var $li = $("<li>");
16      $li.text(room.name);
17      $li.attr('data-id', room._id);
18      $li.attr('data-createdby', room.createdBy);
19      $li.attr('data-cratedat', room.createdAt);
20
21      $ul.append($li);
22    });
23  });
24
25  $('#create').click(function () {
26    var $roomNameInput = $('.newRoom');
27    var roomName = $roomNameInput.val();
28    if(!roomName) {
29      alert('Oda ismini giriniz');
30      return;
31    }
32
33    roomChannel.emit('create_room', { name: roomName });
34    $roomNameInput.val('');
35  });
36});
  • Client tarafında server'dan gelen room_update mesajı dinleniyor
  • Gelen bilgi DOM'a jquery ile basılıyor
  • Oda yarat, butonu ile girilen oda ismi create_room ile server'a gönderiliyor.

Buradaki tek sorun, mongo üzerinde bu oda bilgilerinin paylaşımının socket.io üzerinden gitmesi, yani mongo üzerinde remove işlemi yaptığınızda bu siteye yansımayacak.

Bu tarz işlemlerin bir de redis üzerinden nasıl yapıldığına ve bu sorunu nasıl çözeceğimize bakalım.

1npm install shortid --save
2npm install flat --save
3npm install moment --save

Redis üzerinde mongo'daki gibi otomatik ID oluşturma mekanizması yok. Bu yüzden shortid kullanacağım. node-uuid de seçebilirdik ama URL'de göstermek için fazla uzun.

Flat ise redis'e veri gönderirken kullanacağımız fayfalı bir kütüphane, redis'e objelerimizi ister JSON olarak koyabiliriz veya bu şekilde gezilebilir objelere döndürerek:

 1var flatten = require('flat')
 2
 3flatten({
 4    key1: {
 5        keyA: 'valueI'
 6    },
 7    key2: {
 8        keyB: 'valueII'
 9    },
10    key3: { a: { b: { c: 2 } } }
11})
12
13// {
14//   'key1.keyA': 'valueI',
15//   'key2.keyB': 'valueII',
16//   'key3.a.b.c': 2
17// }

Bununla ilgili kodlarımızı utils.js'de görebilirsiniz.

Tabii toplamda çok kod değişti ama ben en ilginç olanlara burada değineceğim. Diğerleri kodu inceleyerek anlaşılabilir.

Neden Redis

Şimdi daha önceden session için redis kullandık fakat bu sefer kullanma mantığımız biraz daha farklı. O yüzden konuya biraz daha derinlemesine girelim.

Şimdi NoSQL nedir, neden kullanılır, çeşitleri nelerdir, hangisi hangi durumda seçilmelidir, avantajları, dezavantajları nelerdir gibi bu konuda gırla bilgi vermek gerekir. Hem ben bu konuları bu kadar profesyonel şekilde açıklayamam hem de konu çok dağılır. Bu yüzden burada hangi amaçla kullandık onun üzerinden kısaca açıklayacağım.

Şimdi mongo yerine redis kullanmamızın ana sebebi, chat gibi bir uygulamanın çok hızlı çalışması gereğidir. Redis, in-memory DB olmasından kaynaklı buna mongo'dan daha yatkın.

Bir de kullanıcılar arasındaki senkronizasyonu redis üzerinden yapmaya karar verdim. Bu hem kodlamayı kolaylaştıracak, hem de db - server - client arasındaki veri bütünlüğü tek bir yerden sağlanmış olacak. Redis bunu destekliyor.

Şimdi ilk yapacağımız iş redis'in çalıştırılmasındaki ayarın değiştirilmesi olacak. redis-server'ı çalıştırırken bir adet configuration dosyası belirtmemiz gerekecek. Bu dosya redis.conf'tur. Ancak işletim sistemine göre ismi ve yeri değişebilir. Ben OS X'e göre belirteceğim.

Favori editörünüzle dosyayı açın, ben atom ile açıyorum. *NIX'te nano seçeneğini kullanabilirsiniz.

1atom /usr/local/etc/redis.conf

notify-keyspace-events ile başlayan satırı bulup aşağıdaki gibi düzenleyin ve kaydedin.

1notify-keyspace-events "Kgs"

Artık redis.sh'ta çalıştırma komutunu değiştirelim

1redis-server /usr/local/etc/redis.conf

Artık redis verdiğimiz parametrelere göre bir takım event'leri dinliyor. Eğer biz de bu bilgilere talip olursak alabileceğiz. Ama önce redis'le ilgili biraz daha bilgi vermem gerekiyor.

  • Redis key-value şeklinde string tabanlı bir NoSQL veritabanıdır
  • In-memory çalışır, istenirse belli zamanlarda diske snapshot alınabilir
  • Veri yapıları servisidir. String, List, Hash, Set, Bitmap gibi veri yapılarını destekler
  • Master/slave replication ve clustering destekler
  • Üzerinde PUB/SUB mekanizması vardır. Belli key'lere subscribe olan client'lar, publish gerçekleştiği durumda notify olurlar.

Bu özelliklerin socket.io ile birlikte kullanılmasıyla dinamik bir chat uygulaması kullanabilmiş olacağız. Mongo db'den farklı olarak ise:

  • JSON desteklemiyor, bu yüzden javascript'ten kullanırken flat gibi yardımcı fonksiyonlara gerek duydum.
  • Otomatik unique id oluşturamıyor. Uniqueness adına da shortid kullandım.
  • Oda bilgilerini tutmak için iki tip yapı kullandım. İlki room:key şeklinde girdiğim key'i shortid ile ürettiğim hash.
  • İkincisi de bu odaları tuttuğum rooms set'i.

redisAdapter.js:

 1var shortid = require('src/lib/utils').shortid,
 2  objToArray = require('src/lib/utils').objToFlattenArray,
 3  unflatten = require('src/lib/utils').unflatten,
 4  redis = require('redis'),
 5  redisConnection = require('src/lib/redisAdapters/redisConnection'),
 6  client = redisConnection.getClient(),
 7  subscriberClient = redisConnection.getClient(true),
 8  Promise = require('bluebird');
 9
10module.exports.createRoom = function(data) {
11  var key = 'room:' + shortid();
12
13  Promise.promisifyAll(redis.Multi.prototype);
14  var multi = client.multi();
15  multi.hmset(key, objToArray(data));
16  multi.sadd('rooms', key);
17  return multi.execAsync();
18};
19
20module.exports.getRooms = function () {
21  var smembers = Promise.promisify(client.smembers, client);
22  var hgetall = Promise.promisify(client.hgetall, client);
23
24  return smembers('rooms').then(function (keys) {
25    var getJobs = keys.map(function (key) {
26      return hgetall(key);
27    });
28
29    return Promise.all(getJobs);
30  }).then(function (results) {
31    return results.map(function (result) {
32      return unflatten(result);
33    });
34  });
35};
36
37module.exports.subscribeRooms = function (callback) {
38  subscriberClient.subscribe('__keyspace@0__:rooms');
39
40  subscriberClient.on("message", function (key, command) {
41    console.log(key, command);
42    callback();
43  });
44};
  • createRoom ile başlarsak, önce unique bir key oluşturuyoruz
  • redis.Multi'yi promisify ederek thenable hale getirdik
  • multi komutuyla redis'e tek request'te ve transaction içinde birden çok komut gönderebiliyoruz
  • hmset ile hash objemizi setliyoruz. Objemizi array tipinde kaydediyoruz.
1client.hmset(key, ["key1", "value1", "key2", "value2", ...]);
  • sadd ile de rooms key'li set'imize hash'in key'ini ekliyoruz.
  • execAsync ile Promise oluşturup dönüyoruz.
  • getRooms ise yukarıda yaptıklarımızın tam tersini yapıp bize rooms javascript object array'ini alıyoruz.
  • subscribeRooms ile key'leri takip ediyoruz.

Kaynaklar, notlar

Redis notifications ayrıntılı bilgi

Redis komutlar

Redis node client

socket.io-redis: Birbiri arasında haberleşebilen socket.io sunucuları çalıştırabilirsiniz.

Bol bol kullandığım, room ile başlayan key'leri silen bash script:

1redis-cli KEYS "room*" | xargs redis-cli DEL

Redis OK, socket'te ne gibi değişiklikler var:

  • app.js'te socket.io ile express'in aynı session'a ulaşabilmesi sağlandı
  • mongo user kodları userAdapter.js'e taşındı
  • Promise library olarak bluebird denendi

Socket.io (devam)

Sıra chat room'un gösterilmesi ve chat'leşmeye geldi.

routes/index.js:

 1...
 2  router.get('/room/:id', securePage, function (req, res, next) {
 3    var roomId = req.params.id;
 4    roomAdapter.getRoomById(roomId).then(function (room) {
 5      req.session.room = room;
 6
 7      res.render('room', {
 8        user: JSON.stringify(req.user),
 9        room: JSON.stringify(room),
10        socket_host: config.socket_host
11      });
12    });
13  });
14...
  • Gerekli bilgileri çekip, room template'imize gönderiyoruz.
  • room:id dediğimizde req.params.id ile route'ın parametresini alabildik.
  • Bu bilgiyse redis'e gidip ilgili room'u çekiyoruz.

/messages adında yeni bir channel oluşturuyoruz.

soket.js: (server)

 1var messagesChannel = io.of('/messages').on('connection', function (socket) {
 2  if(!socket.request.session.room){
 3    return;
 4  }
 5
 6  socket.on('disconnect', function() {
 7    var roomId = socket.request.session.room.id;
 8    updateUsersList(roomId);
 9  });
10
11  socket.on('joinroom', function (user) {
12    var roomId = socket.request.session.room.id;
13
14    // bu user'la join olacağız, bu bilgi daha sonra clients'dan alınabilir
15    socket.user = user;
16    socket.roomId = roomId;
17    socket.join(roomId);
18
19    updateUsersList(roomId);
20  });
21
22  socket.on('sendMessage', function (data) {
23    var stringified = JSON.stringify(data);
24    socket.broadcast.to(data.roomId).emit('receiveMessage', stringified);
25  });
26
27  function updateUsersList(roomId){
28    var clients = messagesChannel.in(roomId).sockets.filter(function(socket) {
29      return socket.roomId === roomId;
30    });
31    var users = clients.map(function(socket) {
32      return socket.user;
33    });
34    messagesChannel.in(roomId).emit('receiveUsersList', JSON.stringify(users));
35  }
36});
  • socket.io için yeni bir kavram: room
  • socket.broadcast.to(data.roomId).emit ile o odaya ait kullanıcılara mesaj gönderiliyor
  • yalnız socket.io'nun bu versiyonunda eksik bir kısım var, bunu kendi kodumuzla aşacağız:
  • updateUsersList'te ise kısaca yeni bağlantılar ve kopmalar/ayrılmalarda kullanıcı listesinin o room'a bağlı kullanıcılara gönderilmesi sağlanıyor
  • socket, room'a join edilirken user ve roomId bilgileri üzerine setleniyor.
  • filter ile bu socket'lerden o roomId'ye bağlı olanlar seçilip, user bilgileri toplanıyor, yine ilgili client'lara gönderiliyor.

public/js/room.js: (client)

 1function insertMessage(message, me){
 2  var $profilePic = $('<img>');
 3  $profilePic.attr('src', message.profilePictureURL);
 4  $profilePic.attr('alt', message.userName);
 5
 6  var $pic = $('<div>');
 7  if(me) {
 8    $pic.attr('class', 'mypic');
 9  } else {
10    $pic.attr('class', 'pic');
11  }
12
13  $pic.append($profilePic);
14
15  var $msg = $('<div>');
16  if (me) {
17    $msg.attr('class', 'mymsg');
18  } else {
19    $msg.attr('class', 'msg');
20  }
21
22  $msg.append($('<p>')).append(message.message);
23
24  var $msgBox = $('<div>');
25  $msgBox.attr('class', 'msgbox');
26  $msgBox.append($pic).append($msg);
27  $msgBox = $('<li>').append($msgBox);
28
29  $msgBox.hide().prependTo($('ul.messages')).slideDown(100);
30}
31
32function onKeyUp() {
33  $(document).on('keyup', '.newmessage', function (e) {
34    var value = this.value;
35    if(e.which === 13 && value !== ''){
36      var messageData = {
37        userId: user._id,
38        userName: user.fullName,
39        profilePictureURL: user.profilePictureURL,
40        roomId: room.id,
41        message: value
42      };
43
44      this.value = '';
45      insertMessage(messageData, true);
46      roomChannel.emit('sendMessage', messageData);
47    }
48  });
49}
50
51function addUsers(users) {
52  var $usersList = $('ul.users');
53  $usersList.html('');
54
55  users.forEach(function (user) {
56    var $user = $('<span>').text(user.fullName);
57    var $profilePic = $('<img>').attr('src', user.profilePictureURL);
58    var $userName = $('<h5>').text(user.fullName);
59    $usersList.append($('<li>').append($profilePic).append($userName));
60  });
61}
62
63roomChannel.on('connect', function () {
64  console.log('socket connection established');
65  roomChannel.emit('joinroom', user);
66  onKeyUp();
67});
68
69roomChannel.on('receiveMessage', function (data) {
70  data = JSON.parse(data);
71  insertMessage(data);
72});
73
74roomChannel.on('receiveUsersList', function (data) {
75  users = JSON.parse(data);
76  addUsers(users);
77});
78...
  • ilgili socket.io event'lerini yakalayıp, data'yı jquery ile DOM'a basıyoruz
  • onKeyUp ile, kullanıcı enter tuşuna bastığında input'ta yazan mesajı alıp roomChannel.emit ile gönderiyoruz.
  • roomChannel.on('receiveMessage') ile server'dan gelen dönüşü yazıyoruz.
  • roomChannel.on('receiveUsersList') ile server'dan joinroom ve disconnect sonucu değişen oda kullanıcı listesi ilgili client'lara gönderiliyor.

Sonuç

Evet, artık uygulamamamızı yeterince geliştirdik ve çoklu odalı, gerçek zamanlı bir uygulamayı node.js üzerinde nasıl yaparız denemiş olduk.

Bunu yaparken tabii çok düz bir şekilde yapmadık. Kodları incelerseniz az çok kodların ileriye dönük biçimde gelişmeye açık olduğunu görebilirsiniz. Minimum düzeyde kod tekrarı, anlaşılır kodlama, basit mimari ile bunları gerçekleştirdik.

Çıkan sonuç kadar, geliştirme ortamı da burada önemli oldu. Özellikle grunt kullanarak basit ama tekrara dayalı işleri otomatize ettik. Geliştirmeyi hızlandıran hot-code-push özelliğini kullandık.

Geliştirme ortamlarını ayırarak, geliştirme, test ve canlı ortamları için ayrı konfigurasyonlar sağladık.

Passport.js ile facebook bağlantı yapmayı gördük.

Mongo ve mongoose* ile Document-based veritabanları nasıldır, nasıl kullanılır bilgimiz oldu. **Redis ile uygulama arasında çift yönlü bir bağlantı kullandık, DB taraflı data güncellemelerinden haberdar olduk.

Socket.io ile client-server ve client-client arası haberleşmeyi sağladık.

Promise'lerle callback tipi çağrımları dönüştürdük.

Tüm bunları yaparken irili ufaklı birçok kütüphane kullandık. express middleware yapısına aşina olduk. Uygulamayı ihtiyaç duydukça refactor ettik.

Sonuç olarak ortaya başarılı bir web uygulaması çıktı.

Afiyet olsun :)


Published: November 15 2015

blog comments powered by Disqus