Dominoを離れてNode.jsだけでチャットの実装をしてみる

前回の記事Dominoアクセスになんちゃって認証を追加する - Chiburu Systemsのブログでは、Node.jsに預けた認証情報でDominoにアクセスすることで、あたかもNode.jsでDominoの認証をしているかのような「なんちゃって認証」を実装しました。ここまではどちらかというとNode.jsとDominoをいかにしてくっつけるか、というような話でした。

今回からは、当初の予定通り、チャットの機能にフォーカスしていきます。しかし、いきなりDominoまで絡めてしまうと、話がややこしくなるので、今回はいったんDominoのことは置いておき、チャット作りに専念します。

前回までのプロジェクトに似ていますが、ここではプロジェクトごと作り直します。とはいえ、Expressのジェネレータでまずは枠組みを作ってしまいます。

> express --view=pug domtest2
# express-generatorでdomtest2プロジェクトのひな形ができあがります。

> cd domtest2

> npm install --save connect-flash connect-mongo mongoose passport passport-local
# domtest2プロジェクトにモジュールが追加されます。

ここまで実行すると、package.jsonは以下のようになります。

{
  "name": "domtest2",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "node ./bin/www"
  },
  "dependencies": {
    "body-parser": "~1.18.2",
    "connect-flash": "^0.1.1",
    "connect-mongo": "^2.0.0",
    "cookie-parser": "~1.4.3",
    "debug": "~2.6.9",
    "express": "~4.15.5",
    "express-session": "^1.15.6",
    "mongoose": "^4.12.2",
    "morgan": "~1.9.0",
    "passport": "^0.4.0",
    "passport-local": "^1.0.0",
    "pug": "2.0.0-beta11",
    "serve-favicon": "~2.4.5",
    "socket.io": "^2.0.3"
  }
}

まず、エントリーポイントである/bin/wwwファイルを見ていきます。変更箇所は前半に集中しています。

#!/usr/bin/env node

/**
 * Module dependencies.
 */

/**
 * 認証系とMongoDBについて初期化します。
 */
var auth = require('../auth');

/**
 * セッションの初期設定をします。
 * セッションの保存先にもMongoDBを使用します。
 */
var session = require('express-session');
var MongoStore = require('connect-mongo')(session);
var sessionStore = new MongoStore({
  mongooseConnection: auth.currentDb
});

/**
 * Webアプリケーションサーバーの初期設定をします。
 */
var app = require('../app')(session, sessionStore, auth.passport);

var debug = require('debug')('domtest2:server');
var http = require('http');

/**
 * Get port from environment and store in Express.
 */

var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);

/**
 * Create HTTP server.
 */

var server = http.createServer(app);

/**
 * ソケットの初期設定をします。
 */
require('../mySocket')(server, sessionStore);

/**
 * Listen on provided port, on all network interfaces.
 */

server.listen(port);
server.on('error', onError);
server.on('listening', onListening);

/**
 * Normalize a port into a number, string, or false.
 */

function normalizePort(val) {
  var port = parseInt(val, 10);

  if (isNaN(port)) {
    // named pipe
    return val;
  }

  if (port >= 0) {
    // port number
    return port;
  }

  return false;
}

/**
 * Event listener for HTTP server "error" event.
 */

function onError(error) {
  if (error.syscall !== 'listen') {
    throw error;
  }

  var bind = typeof port === 'string'
    ? 'Pipe ' + port
    : 'Port ' + port;

  // handle specific listen errors with friendly messages
  switch (error.code) {
    case 'EACCES':
      console.error(bind + ' requires elevated privileges');
      process.exit(1);
      break;
    case 'EADDRINUSE':
      console.error(bind + ' is already in use');
      process.exit(1);
      break;
    default:
      throw error;
  }
}

/**
 * Event listener for HTTP server "listening" event.
 */

function onListening() {
  var addr = server.address();
  var bind = typeof addr === 'string'
    ? 'pipe ' + addr
    : 'port ' + addr.port;
  debug('Listening on ' + bind);
}

主にセッションの保存先を、これまでデフォルトのメモリストアにしていたのを、MongoDBに置き換えているところと、チャットで使う「リアルタイム通信」の要であるソケットの初期設定などが出てきます。

次は/auth.jsです。これは前回同様認証に関する初期設定のモジュールです。

var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;
var mongoose = require('mongoose');

// MongoDBに接続。
var db = mongoose.createConnection(
  "mongodb://localhost/domtest"
  , function(error, res){}
);

// ユーザ情報のスキーマ。
var UserSchema = new mongoose.Schema({
  username: {type: String, require: true}
  , password: {type: String, require: true}
});

// ユーザ情報のモデル。
var User = db.model("User", UserSchema);

// パスワード検証用メソッド。
User.prototype.validPassword = function(pass)
{
  return this.password === pass;
};

// セッションサポート用の直列化、非直列化。

passport.serializeUser(function(user, done)
{
  done(null, { id: user._id, username: user.username });
});
passport.deserializeUser(function(user, done)
{
  User.findById(user.id, function(err, user)
  {
    done(err, user);
  });
});

// ユーザ検証戦略の設定。
passport.use(new LocalStrategy(
  function (username, password, done)
  {
    User.findOne({ username: username }, function(err, user)
    {
      if (err) { return  done(err); }
      if (!user) {
        return done(null, false, {message: 'ユーザID/パスワードが違います。'});
      }
      if (!user.validPassword(password))
      {
        return done(null, false, {message: 'ユーザID/パスワードが違います。'});
      }
      return done(null, user);
    })
  }
));

module.exports = {
  passport: passport
  , currentDb: db
};

前回と比べ、エクスポートするオブジェクトにMongoDB接続情報が追加されました。

次は新しいカスタムモジュール、/mySocket.jsです。

var Socket = require('socket.io');
var cookie = require('cookie');
var cookieParser = require('cookie-parser');
var io;

module.exports = function(server, sessionStore)
{
  io = new Socket(server);

  /**
   * ソケットサーバーにミドルウェアを追加します。
   */
  io.use(function(socket, next)
  {
    // ソケットにセッション情報を追加します。
    var signedCookies = cookie.parse(socket.handshake.headers.cookie);
    var cookies = cookieParser.signedCookies(signedCookies, 'domtest2');
    return sessionStore.get(cookies['connect.sid'], function(error, session)
    {
      if (error != null)
        return next(error);
      if (session.passport == null || !session.passport.user == null)
        return next('Guest');
      socket.session = session;
      return next();
    });
  });

  /**
   * ソケットサーバーに接続要求が来たら処理します。
   */
  io.on('connect', function(socket)
  {
    console.log(socket.id);

    // ソケットクライアントに'chat'リスナーを追加します。
    socket.on('chat', function(data)
    {
      // データに、セッションに紐付いたユーザー名を追加します。
      data.username = socket.session.passport.user.username;

      // 接続先全体にチャットデータを送信します。
      io.sockets.emit('chat', data);
    });
  });
};

Socket.IOはNode.jsでは広く使われている人気のあるWebSocket実装モジュールです。サーバー用はNPMでインストールし、クライアント用はダウンロードしてHTMLに組み込むか、CDNサービスを利用して実行時にダウンロードします。

cdnjs.com

ここでのポイントは、io.on('connect')とsocket.on('chat')の使い分けでしょう。このモジュールの中では、ioがチャットサーバーを表し、socketが各クライアントと接続を結ぶ「チャンネル」を表しています。io.on('connect')では、クライアント(Webブラウザ)から接続要求があるとこのリスナーが呼び出されます。引数のsocketにchatイベントリスナーを追加して、クライアントからの通信に待機できるようにします。ここで実際にしていることは、セッションデータからユーザー名を取り出して、送られてきたメッセージデータとともに、接続中の全ソケットにチャットデータを送信します。

では、クライアント側の実装はどのようになるのでしょう。/public/javascripts/chat.jsを見ていきます。このスクリプトは、jQueryとSocket.io(クライアント版)がロードされていることを前提とします。

(function()
{
  // クライアント側のソケットを初期化します。
  var socket = io.connect('http://localhost:3000');

  // 送信ボタンをクリックしたときの操作を定義します。
  $('button#send').click(function()
  {
    var msg = $('#message').val();
    console.log('Message', msg);
    socket.emit('chat', {
      message: msg
    });
  });

  // Enterキーにも対応させる。
  $('input#message').keypress(function(event)
  {
    if (event.which == 13)
      $('button#send').click();
  });

  // チャットメッセージを受信します。
  socket.on('chat', function(data)
  {
    $('#output').append(
      '<p>'
        + '<strong>' + data.username + '</strong>: '
      + data.message + '</p>'
    );
  });

})()

クライアント側にもioがありますが、これはモジュールそのものと考えた方がいいでしょう。実際にはio.connectで得られた接続の実体(ここではsocket変数)で実装していきます。

チャットには通常、メッセージを書く欄と、送信ボタン、送られてきたメッセージを表示する欄があります。まず送信ボタンに、メッセージ欄のデータをチャットサーバーに送る機能を実装します($('button#send').clickのところ)。メッセージを書き終わったあとのエンターキーにも対応させます($('input#message').keypressのところ)。あとはチャットサーバーから送られてくるメッセージを表示するためにsocket.on('chat')のところがあります。

このチャットのやり取りを実装しているルーティングが次の/routes/dashboard.jsです。

var express = require('express');
var router = express.Router();

/* GET users listing. */
router.get('/', function(req, res)
{
  // ユーザー名をテンプレートに渡します。
  res.render('dashboard', { username: req.user.username });
});

module.exports = router;

ブラウザー側のテンプレートPUGが次の/views/dashboard.jsです。

extends layout

block content
  h1 ダッシュボード
  h2 チャット
  div
    p(style="float: right;")
      a(href='/logout') logout
    p= username
  div#chat-container
    div#chat-window
      div#output
      div#feedback
    div#console
      input#message(type='text',placeholder='Message')
      button#send Send
  script(type='text/javascript', src='/javascripts/chat.js')

/views/layout.pugにはjQueryとSocket.IOをロードするように変更しています。

doctype html
html
  head
    title= title
    link(rel='stylesheet', href='/stylesheets/style.css')
    script(type='text/javascript', src='https://code.jquery.com/jquery-3.2.1.min.js')
    script(type='text/javascript', src='https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js')
  body
    block content

ついでにcssファイル/public/stylesheets/style.cssも見ておきます。

* {
  font-family: "游ゴシック", Helvetica, Arial, sans-serif;
}

body {
  padding: 25px;
  font-size: 18px;
}

a {
  color: #00B7FF;
}

div#chat-container {
  height: 300px;
}

div#chat-window {
  height: 270px;
  background-color: #dddddd;
}

div#console {
  width: 100%;
}

input#message, button#send {
  height: 50px;
  width: 100%;
  font-size: 24px;
}

他のルートモジュールも見ておきます。

// /routes/index.js

var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res) {
  res.render('index', {
    title: 'DomTest 2'
    , message: req.flash('error')
  });
});

module.exports = router;
// /routes.login.js

// ログイン用のルーティング。
var express = require('express');
var router = express.Router();

module.exports = function(passport)
{
  // GET時はログイン画面の表示。
  router.get('/', function(req, res)
  {
    res.redirect('/');
  });

  // POST時はログイン検査。
  router.post('/'
    , passport.authenticate('local'
      , {
        successRedirect: '/dashboard'
        , failureRedirect: '/'
        , failureFlash: true
      }
    )
  );
  return router;
};

次はPUGテンプレート/views/index.pugです。今回ログイン用のテンプレートはこのindex.pugになります。

extends layout

block content
  h1= title
  h2 Login
  if(message)
    p= message
  div
    form(action='/login', method='post')
      div
        span Username:
        input(type='text', name='username')
      div
        span Password:
        input(type='password', name='password')
      div
        button(id='login') Login

最後に、Webアプリケーションサーバー全体の設定となる/app.jsを見ておきます。

var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

// フラッシュメッセージ
var flash = require('connect-flash');

// ルーティング情報
var index = require('./routes/index');
var dashboard = require('./routes/dashboard');
var login = require('./routes/login');

// Webアプリケーションサーバー
var app = express();

/**
 * @brief Webアプリケーションサーバーを初期化します。
 * @param session セッション
 * @param sessionStore セッション保管
 * @param passport Passport
 */
module.exports = function(session, sessionStore, passport)
{
  // view engine setup
  app.set('views', path.join(__dirname, 'views'));
  app.set('view engine', 'pug');

  // uncomment after placing your favicon in /public
  //app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
  app.use(logger('dev'));
  app.use(bodyParser.json());
  app.use(bodyParser.urlencoded({ extended: false }));
  app.use(cookieParser());
  app.use(express.static(path.join(__dirname, 'public')));

  /**
   * Webアプリケーションサーバーにセッションを追加します。
   */
  app.use(session({
    secret: 'domtest2'
    , resave: false
    , saveUninitialized: false
    , store: sessionStore
  }));

  // フラッシュ機能を追加します。
  app.use(flash());

  // パスポートモジュールを初期化、追加します。
  app.use(passport.initialize());
  app.use(passport.session());

  // ルーティングを追加します。
  app.use('/', index);
  app.use('/dashboard', dashboard);
  app.use('/login', login(passport));
  app.get('/logout', function(req, res)
  {
    req.logout();
    res.redirect('/');
  });

  // catch 404 and forward to error handler
  app.use(function(req, res, next) {
    var err = new Error('Not Found');
    err.status = 404;
    next(err);
  });

  // error handler
  app.use(function(err, req, res, next) {
    // set locals, only providing error in development
    res.locals.message = err.message;
    res.locals.error = req.app.get('env') === 'development' ? err : {};

    // render the error page
    res.status(err.status || 500);
    res.render('error');
  });

  return app;
};

なお、Expressのジェネレータで自動的にできあがる/routes/users.jsは削除しています。

以上の実装を完了し、「npm start」でNode.jsを実行すると以下のようなチャットができるようになります。

www.dropbox.com

Dominoの「ど」の字も出てきませんでしたが、次回からはDominoが復活します。まずはこれらのやりとりをDomino上のNotesデータベースに保存する方法に取り組んでみたいと思います。