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サービスを利用して実行時にダウンロードします。
ここでのポイントは、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を実行すると以下のようなチャットができるようになります。
Dominoの「ど」の字も出てきませんでしたが、次回からはDominoが復活します。まずはこれらのやりとりをDomino上のNotesデータベースに保存する方法に取り組んでみたいと思います。