画面を更新してもチャットの履歴を表示できるようにする
リアルタイムでやり取りしたチャットメッセージは、通常一過性のもので、Web画面を更新してしまうと消えてなくなります。幸い、この連載で扱っているチャットデータは、Notesデータベースに記録されています。この情報を再取得すれば、チャットのやり取りを画面に再現することができます。
チャットデータをリロードするタイミングは、画面のロード時になるので、onLoad時にイベントを発行します。
// public/javascripts/chat.js (function() { // クライアント側のソケットを初期化します。 var socket = io.connect('http://localhost:3000'); // (中略) // リフレッシュメッセージを受信します。 socket.on('refresh', function(data) { var range = data.range.match(/items (\d+)-(\d+)\/(\d+)/); var start = parseInt(range[1]); var end = parseInt(range[2]); var allCount = parseInt(range[3]); if (start === 0) $('#output').empty(); data.items.forEach(function(item) { var content = item['$120']; var message = content.match(/(.*) \(/); var username = content.match(/ \((.*)\)/); $('#output').prepend( '<p>' + '<strong>' + username[1] + '</strong>: ' + message[1] + '</p>' ); }); if (end + 1 < allCount) socket.emit('reload', {'start': end + 1, 'count': countUnit}); }); // チャットの履歴を取得します。 var countUnit = 10; socket.emit('reload', {'start': 0, 'count':countUnit}); })()
chat.jsで定義している無名関数がロード時に実行されるので、ここでメッセージの履歴を取得します。最後の方にある「socket.emit('reload', ...)」が、Node.jsのWebSocketに対してメッセージのリロード要求をしています。
前半部の「socket.on('refresh', ...);」については後ほど補足します。
Webクライアントからreloadメッセージが送信されると、Note,js(WebSocket)ではそのままカスタムクラス「NodeChat」の「reload」メソッドを呼び出します。
// mySocket.js // (中略) module.exports = function(server, sessionStore, User) { // (中略) // チャットの内容をリロードします。 socket.on('reload', function(input) { getUser(socket) .then(function(user) { var nodeChat = new NodeChat(user); return nodeChat.reload(input); }) .then(function(data) { socket.emit('refresh', data); }) .catch(function(err) { console.log(err); }); }); }); return io; };
NodeChat.reload処理が無事に終わると、送信元(Webクライアント)に更新データを送ります(socket.emit('refresh', data);)。それでは、肝心のNodeChat.reloadの仕組みを見ていきます。
// NodeChat.js // (中略) // 現在のチャットデータすべてを取得します。 NodeChat.prototype.reload = function(input) { var cliObj = this.client; return new Promise(function(resolve, reject) { var args = { 'parameters': { 'compact': true , 'start': input.start , 'count': input.count } }; cliObj.get( 'http://localhost/NodeChat.nsf/api/data/collections/name/($All)' , args , function(retData, response) { if (retData.code && retData.code !== 200) reject(retData); else { resolve({ items: retData , range: response.headers['content-range'] }); } } ).on('error', reject); }); } module.exports = NodeChat;
チャットデータのすべてを取得する方法はいくつか考えられますが、ここでは、Dominoデータサービスのビューエントリーをすべて取得する方法を考えます。このAPIについては以下のリンクに詳細があります。
IBM Notes and Domino Application Development wiki : IBM Domino Access Services 9.0.1
チャットデータのリロードを要求する際に、「start=0、count=countUnit(=10)」という引数を付けて呼び出しましたが、その指定値が変数「args.parameters.start」と「args.parameters.count」に反映されます。ビューから取得するエントリーの範囲を指定しているわけです。この値を持たせて、データベース「NodeChat.nsf」、ビュー「($All)」にHTTP GETを要求すると、指定したビューから指定した範囲のエントリーデータを取得できるわけです。
また、このGETリクエストのレスポンスには、「このデータは○件中○番目から○件のデータです」という情報が、ヘッダー情報の「content-range」に保存されているので、このデータもリスエスト元に返すことで、次の取得範囲を計算できるようにしてあげます。
ここでようやく「public/javascripts/chat.js」の「socket.on('refresh',~」の説明になります。
最初にしていることは、返ってきたデータのうち、rangeプロパティに含まれる件数情報を取得することです。件数情報を正規表現で分解し、start(開始インデックス)、end(終了インデックス)、allCount(全件数)に分けます。startが0ならば、初回のリクエストと言うことで、画面の出力データをクリーンアップします。2回目以降はあるデータの継ぎ足しになるわけです。
次はメッセージ情報を画面に展開していきます。「data.items」に配列としてメッセージデータが含まれているので、これを順次処理していきます。今回はビューデータの列番号「$120」のデータを元に、メッセージとその作成者を特定し、画面に展開しています。
最後に、今回の取得範囲がまだ最後でない場合、取得範囲を変えて、再度「reload」リクエストをWebSocketサーバに要求します。これを繰り返すことで、1回の要求範囲では完全ではない場合でも、非同期処理でいずれでデータが揃うということになります。
最後に、スタイルシートを一部修正します。
// public/stylesheets/style.css // (中略) div#chat-window { height: 270px; background-color: #dddddd; overflow: scroll; } // (中略)
「overflow: scroll;」を追加しました。これを追加しておかないと、チャットメッセージが増えたときに表示できなくなります。
以上で、約1ヶ月の短期連載は終了です。
DominoとNode.jsの同居サーバ構築 - Chiburu Systemsのブログ
Dominoアクセスになんちゃって認証を追加する - Chiburu Systemsのブログ
Dominoを離れてNode.jsだけでチャットの実装をしてみる - Chiburu Systemsのブログ
Node.jsのチャットデータをNSFに保存してみる - Chiburu Systemsのブログ
Notesで書いたメッセージをチャットに送る - Chiburu Systemsのブログ
この連載の間に、Notes/Dominoのバージョン10に関するニュースも飛び込んできました。Notes/Dominoはまだまだ進化の余地があるテクノロジーです。内側(メーカー)と外側(ベンダー、サードパーティー)で創造と想像を繰り返して、新しい価値を見いだしていけるといいですね。
Notesで書いたメッセージをチャットに送る
前回までに、DominoとNode.jsを同居させ、Node.js上でやり取りされたメッセージをNotesに保存するところまで紹介しました。
今回は、Notesクライアントからのメッセージもチャット上に表示します。最終的には保存されているメッセージをリロードできるようにします。
Notesクライアントで手っ取り早くHTTP送信するには、フォームのPostSaveイベントが使えます。ただし、その場合にはNotesクライアントからDomino+Node.jsへの経路に壁がないことが条件になります。エージェントの保存・更新イベントならその心配はありませんが、今度はリアルタイム性が失われてしまいます。今回はPostSaveイベント方式を使いますが、リアルタイム性があり、通信経路に邪魔が入らなければ、手段は問いません。
Sub Postsave(Source As Notesuidocument) Dim xhr As Variant Dim session As New NotesSession Dim userName As NotesName Set userName = New NotesName(session.UserName) Set xhr = CreateObject("MSXML2.XMLHTTP") xhr.Open "POST", "http://localhost:3000/chat", True xhr.SetRequestHeader "Content-Type", "application/json; charset=utf-8" xhr.Send |{ "username": "| & userName.Abbreviated & |", "message": "| & Replace(Source.FieldGetText("Subject"), |"|, |\"|) & |" }| End Sub
HTTP通信にはWindowsのActiveX経由でMSXML2.XMLHTTPを利用します。送信先はlocalhostですが、Node.jsにたどり着く適切なホスト名を指定します。メッセージの取得元は「Subject」フィールドですが、これは、今回チャットに流用しているディスカッションの仕様によるものなので、データベースとフォームに応じて変更します。
送信側は以上です。次は受信側です。
// routes/chat.js var express = require('express'); var io; module.exports = { setIo: function(socket_io) { io = socket_io; }, router: function() { var router = express.Router(); /* GET home page. */ router.post('/', function(req, res) { io.sockets.emit('chat', req.body); res.send('OK'); }); return router; } };
新しいルーティングモジュール、routes/chat.jsです。/chatでPOSTされたときに、チャットメンバーにメッセージが送られるようになっています。WebSocketのモジュールを使用するため、setIo関数でモジュールを設定できるようにしています。
今回の主旨に関するソースコードは以上ですが、ルーティングとWebSocketモジュールとの兼ね合いから、他のソースコードの変更を余儀なくされたので、念のため紹介しておきます。
// mySocket.js var Socket = require('socket.io'); var cookie = require('cookie'); var cookieParser = require('cookie-parser'); var NodeChat = require('./NodeChat'); var io; module.exports = function(server, sessionStore, User) { io = new Socket(server); // (中略) return io; };
mySocket.jsでは、初期化されたioを呼び出し元に返すようにしました。これでroutes/chat.js側でWebSocketが使えるようになります。
// app.js var express = require('express'); // (中略) // Webアプリケーションサーバー var app = express(); /** * @brief Webアプリケーションサーバーを初期化します。 * @param session セッション * @param sessionStore セッション保管 * @param passport Passport * @param chat Notesからのチャットを受けるルーティング */ module.exports = function(session, sessionStore, passport, chat) { // (中略) // ルーティングを追加します。 app.use('/', index); app.use('/dashboard', dashboard); app.use('/login', login(passport)); app.get('/logout', function(req, res) { req.logout(); res.redirect('/'); }); app.use('/chat', chat); // (中略) return app; };
app.jsではさらに引数を増やし、routes/chat.jsが返したルーティングモジュールを引数で受け取り、ルーティングに組み込みます。
// bin/www // (中略) /** * Webアプリケーションサーバーの初期設定をします。 */ var chat = require('../routes/chat'); var app = require('../app')(session, sessionStore, auth.passport, chat.router()); var debug = require('debug')('domtest2:server'); var http = require('http'); // (中略) var server = http.createServer(app); /** * ソケットの初期設定をします。 */ var io = require('../mySocket')(server, sessionStore, dbInfo.User); chat.setIo(io); // (中略)
bin/wwwでは、routes/chat.jsを初期化して、ルーティングへの組み込みとWebSocketの組み込みを行います。app(Webアプリケーション)、server(Webサーバ)、io(WebSocket)の初期化順だと解釈しています。
以上の変更で、以下のような実行結果が得られます。
kai shidenさんが「こんにちは」と書いたチャットは前回までと同様です。NotesクライアントからHayato Kobayashiさんが「こんにちは、カイさん。」と返した直後にチャットボードにメッセージが表示されます。通常のWebクライアントであれば、ここはリロードするか、ポーリングするしかないところですね。
次回はいよいよ最終回です。ここまでの仕様では、ブラウザをリロードすると、チャットメッセージは消えてしまいます。リロードしたときに、メッセージ履歴もリロードできるようにして、この連載を終えたいと思います。それでは。
Node.jsのチャットデータをNSFに保存してみる
それではいよいよ、リアルタイムの会話をNSFに保存してみます。プログラムコードのベースは、前回のDominoを離れてNode.jsだけでチャットの実装をしてみる - Chiburu Systemsのブログの「domtest2」になります。
チャットデータと、DominoのREST APIを取り持つ、新しいカスタムモジュールを準備します。/domtest2直下に「NodeChat.js」ファイルを作成します。
// RESTクライアントモジュールを使用します。 var Client = require('node-rest-client').Client; // NodeChatクラスを新設します。 function NodeChat(user) { this.user = user; // 基本認証を組み込んで、RESTクライアントを初期化します。 this.client = new Client({ user: user.username , password: user.password }); }
さらに、Chatオブジェクトにchatメソッドを追加します。
// チャットデータをNSFにPOSTします。 NodeChat.prototype.chat = function(data) { var userObj = this.user; var cliObj = this.client; return new Promise(function(resolve, reject) { var args = { 'parameters': { form: 'MainTopic' , computewithform: true } , 'headers': { 'Content-Type': 'application/json' } , 'data': { 'Subject': data.message } }; cliObj.post( 'http://localhost/NodeChat.nsf/api/data/documents' , args , function(retData, response) { if (retData.code && retData.code !== 200) reject(retData); else resolve(userObj); } ).on('error', reject); }); }
Dominoデータサービスによる文書の追加は、以下のリンクに情報があります。
IBM Notes and Domino Application Development wiki : IBM Domino Access Services 9.0.1
URLの指定先は、http(s)://(ホスト)/(NSF)/api/data/documentsとなります。 これに、クエリとしてform=MainTopic&computewithform=trueを付けることで、「フォームはMainTopic、保存時にフォーム全体を計算」を解釈してくれます。 フォームに保存するデータはJSON形式にし、「data」プロパティとして追加します。その時に「Content-Type: application/json」というヘッダー情報を付けるようにします。
締めくくりに、NodeChatクラスをエキスポートしておきます。
module.exports = NodeChat;
以上で、NodeChat.jsの実装は終わりです。次に、NodeChatを使用する側となる、「mySocket.js」にコードを追加していきましょう。
var Socket = require('socket.io'); var cookie = require('cookie'); var cookieParser = require('cookie-parser'); var NodeChat = require('./NodeChat'); var io;
モジュールをロードする場所に、「./NodeChat」を追加しておきます。次に、エキスポートする関数に一つ、Userという引数を追加しておきます。
module.exports = function(server, sessionStore, User) { io = new Socket(server); var getUser = function(socket) { return new Promise(function(resolve, reject) { User.findById(socket.session.passport.user.id, function(err, user) { if (err) reject(err); else resolve(user); }); }); };
前回は必要がなかったユーザーのパスワード情報が必要になるため、「getUser」というPromise対応関数を作ります。JavaScriptの「Promise」については、以下のようなリンクを参照して下さい。乱暴に簡潔に述べるなら、非同期メソッドチェーンを簡単にする技です。
続いて、ミドルウェアを追加するコードのあとに、チャットデータを受け取るとDominoにそのデータを送信するコードを追加します。
/** * ソケットサーバーにミドルウェアを追加します。 */ 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) { getUser(socket) .then(function(user) { var nodeChat = new NodeChat(user); return nodeChat.chat(data); }) .then(function(user) { data.username = user.username; io.sockets.emit('chat', data); }) .catch(function(err) { console.log(err); }); }); }); };
nodeChat.chatメソッドでDominoへのREST API送出に成功すると、チャットクライアントにメッセージを送信するようになります。Node.jsを介して、Socket.ioによるリアルタイム通信とDomino REST APIを使った情報のやり取りは、以上のようなコードで実現できます。
それでは、以下に前回のコードから変更になったソースを、順を追ってみていきます。
// auth.js var passport = require('passport'); var LocalStrategy = require('passport-local').Strategy; module.exports = function(User) { // セッションサポート用の直列化、非直列化。 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); }) } )); return { passport: passport }; };
前回と比べ、Mongoose、MongoDBに関するパートを別のモジュールにし、認証に使う「User」のみ受け取れるようにしてあります。
そのMongoDBに関するモジュールは、dbinfo.jsとして新しくまとめました。
// dbinfo.js module.exports = function(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; }; return { db: db , User: User }; };
Mongooseモジュールは、bin/wwwでロードしておき、mongooseを引数にしてdbinfoをロードするようにします。 その他のbin/wwwの変更点は以下の通りです。
// bin/www ... /** * データベース環境を初期化します。 */ var mongoose = require('mongoose'); var dbInfo = require('../dbInfo')(mongoose); /** * 認証系を初期化します。 */ var auth = require('../auth')(dbInfo.User); /** * セッションの初期設定をします。 * セッションの保存先にもMongoDBを使用します。 */ var session = require('express-session'); var MongoStore = require('connect-mongo')(session); var sessionStore = new MongoStore({ mongooseConnection: dbInfo.db }); ... // (中略) ... /** * ソケットの初期設定をします。 */ require('../mySocket')(server, sessionStore, dbInfo.User); ...
それと、クライアント用JSでも一点だけ変更があります。前回までのコードですと、送信済みのメッセージが残ったままになってしまうので、送信後にメッセージを消去するようにします。
// public/javascript/chat.js (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 }); $('#message').val(''); }); // 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>' ); }); })()
以上がコードの追加、変更部分でした。
では実際に、チャットデータがNSFに保存される様子を見てみましょう。コマンドプロンプトでdomtest2フォルダに移動し、npm startとすればサーバが起動します。Dominoサーバも忘れずに起動しておきます。ブラウザでhttp://localhost:3000にアクセスして、ログインします。
チャットデータを保存することができたので、再ログインしたあとにチャットデータの履歴を表示することができます。次回はDominoからREST APIでデータを受け取り、チャットの履歴を表示してみましょう。
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データベースに保存する方法に取り組んでみたいと思います。
Dominoアクセスになんちゃって認証を追加する
前回「DominoとNode.jsの同居サーバ構築 - Chiburu Systemsのブログ」でNodeからDominoにRESTを通じてデータベースのタイトルを取得し、Webページに反映させました。このとき、認証は埋め込みのユーザ名、パスワードを使いました。今回は、それをWebユーザが認証するところまで持って行きます。
理想を言えば、Dominoで認証してセッションを回すようにしたいところですが、遺憾ながら今回はセキュリティもへったくれもない、非常になんちゃって感の強い方法にしました。その点はご容赦いただきたく。
前回のプロジェクトから追加したパッケージは以下の通りです。
- connect-flash
- crypto
- express-session
- mongoose
- passport
- passport-local
これらを、「npm install --save ****」としてプロジェクトに追加します。追加後のpackage.jsonは以下の通りです。
{ "name": "domtest", "version": "0.0.0", "private": true, "scripts": { "start": "node ./bin/www" }, "dependencies": { "body-parser": "~1.18.2", "connect-flash": "^0.1.1", "cookie-parser": "~1.4.3", "crypto": "^1.0.1", "debug": "~2.6.9", "express": "~4.15.5", "express-session": "^1.15.6", "mongoose": "^4.12.0", "morgan": "~1.9.0", "node-rest-client": "^3.1.0", "passport": "^0.4.0", "passport-local": "^1.0.0", "pug": "2.0.0-beta11", "serve-favicon": "~2.4.5" } }
MongoDB、Mongoose
mongooseはmongodbをNode.jsで扱うモジュールになります。
mongodbはNoSQLデータベースで、データ形式がJSONに似たBSONというもので、JavaScriptと相性もいいので、Node.jsでもよく使われます。今回、mongodbは認証の保管先として使います。最終的に認証をするのはDominoのユーザ情報ですが、Node.jsとDominoはクライアント-サーバの関係なので、Node.js側にログイン情報を持たなければなりません。そこでmongodbを使用して、ユーザが入力したユーザ名、パスワードをお預かりしなければならないので、少しいびつな形をしています。また、mongodbに預けたパスワードは、頑丈なセキュリティで守るか、復号化可能な仕組みで暗号化をかけないと、だだ漏れる危険があります。今回はそのような措置もとらないので、「なんちゃって認証」としてあらかじめお断りしています。
mongodbのインストールやセットアップは他のサイトに譲り、ここでは、mongodbにアカウントを一つ追加するコンソール操作を示します。
> use domtest switched to db domtest > db.users.find() > db.users.insert({username:'*****',password:'*****'}) WriteResult({ "nInserted" : 1 }) > db.users.find() { "_id" : ObjectId("59d9c17b4697f5ca11fc044a"), "username" : "*****", "password" : "*****" }
最初の「use domtest」は「データベースdomtestをデフォルトにします。」という意味で、SQLのように「CREATE DATABASE」する必要はありません。以降の「db」はこのdomtestを表すようになります。
「db.users」というのは、domtestデータベースのusersテーブルというような意味になりますが、これも「CREATE TABLE」せずに使えます。このあたりはNotesDBの「フォーム設計がなくても文書を追加できる」のと似ています。「findメソッド」は文字通り検索コマンドで、引数の指定がなければすべてのレコードを表示します。例えば「db.users.find({username:'John'})とすれば、usernameが「John」であるユーザを検索して表示します。「insertメソッド」はテーブルにアイテムを追加します。テーブル設計なしにアイテムを追加できるところがNoSQLらしさですね。
express-session
express-sessionはExpressのサブモジュールで、Webサーバのセッション機能を司るミドルウェアです。かつてはExpressに同梱されていたようですが、現在は分離しているので、別途インストール必要があります。
connect-flash
connect-flashはセッション機能に「メッセージフラッシュ」機能を追加するモジュールです。通常ページ感のデータ受け渡しはセッションに名前付きのデータを追加することでできますが、メッセージフラッシュは、ログイン時の成功、失敗のような一時的なデータの出し入れを楽にしてくれます。メッセージを追加するには、
req.flash('error', {message:'ログインに失敗しました。'});
とし、取り出すときは、
req.flash('error') // {message: 'ログインに失敗しました。'} を返す。
となり、フラッシュデータの'error'オブジェクトは呼び出し後に削除されます。
crypto
パスワードのハッシュ化のために使いますが、ハッシュ化してしまったパスワードはREST呼び出し時には使えないので、今回は参考として入れています。
app.js
app.jsの役割はExpress全体の初期設定になります。
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 session = require('express-session'); // フラッシュメッセージ var flash = require('connect-flash'); // 【カスタム】認証機能の初期化 var auth = require('./auth.js'); var index = require('./routes/index'); var users = require('./routes/users'); // 【カスタム】ログイン用のルーティング var login = require('./routes/login'); var app = express(); // 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'))); // セッション機能を追加する。 app.use(session({ secret: "notesdomino" , saveUninitialized: false , resave: false })); // フラッシュ機能を追加する。 app.use(flash()); // パスポートモジュールを追加する。 app.use(auth.passport.initialize()); app.use(auth.passport.session()); app.use('/', index); app.use('/users', users); // ログイン用のルーティング追加する。 app.use('/login', login.router(auth.passport)); // 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'); }); module.exports = app;
auth.js
app.js内に追加されているカスタムモジュールは2つです。そのうちの一つがauth.jsで、人気のある認証モジュール「Passport」を使って、認証機能を設定しています。
Passportは認証機能を単純化するだけでなく、その魅力はFacebookやTwitterなど多くの認証サイトのストラテジ(認証方式)を持っていることです。今回は基本的なローカルストラテジ(ユーザ名、パスワード方式)を使います。このユーザ情報の保管、検索先がmongodbになり、mongodbを扱うモジュールがmongooseになります。
var passport = require('passport'); var LocalStrategy = require('passport-local').Strategy; var mongoose = require('mongoose'); // 本来ならパスワードをハッシュ化したいところ。 // var crypto = require('crypto'); // // var secretKey = "NotesDomino"; // var getHash = function(target) // { // var sha = crypto.createHmac("sha256", secretKey); // sha.update(target); // return sha.digest("hex"); // }; // 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; // return this.password === getHash(pass); }; // セッションサポート用の直列化、非直列化。 passport.serializeUser(function(user, done) { done(null, user._id); }); passport.deserializeUser(function(id, done) { User.findById(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 };
routes/login.js
ログイン用のルーティングは、GET用と、POST用に分かれます。GETはログイン画面の表示に、POSTはログインデータの検査に使います。
// ログイン用のルーティング。 var express = require('express'); var router = express.Router(); module.exports = { // Passportオブジェクトを受け取ります。 "router": function(passport) { // GET時はログイン画面の表示。 router.get('/', function(req, res) { res.render('login', { message: req.flash('error') }) }); // POST時はログイン検査。 router.post('/' , passport.authenticate('local' , { successRedirect: '/' , failureRedirect: '/login' , failureFlash: true } ) ); return router; } };
views/login.pug
ログインルーティングにある「res.render('login', ...);」の「login」は、viewsフォルダ内のlogin.pugテンプレートを参照しています。極めて簡単なログイン画面を作ります。
extends layout block content if(message) p= message form(action='/login', method='post') input(type='text', name='username') br input(type='password', name='password') br input(type='submit', value='Login')
messageの部分は、res.renderの第2引数が参照元になります。
views/index.pug
index.pugの方は、ログインへのリンクが追加されただけになります。
extends layout block content h1= title p Welcome to #{title} a(href="/login") Login
routes/index.js
正しくログインした場合、そのログイン情報を持って、DominoにRESTでアクセスし、前回紹介したように目的のデータベースのタイトルを表示するようになります。
var express = require('express'); var router = express.Router(); // RESTクライアントモジュールを使用する。 var Client = require('node-rest-client').Client; /* GET home page. */ router.get('/', function(req, res, next) { // リクエスト中にユーザ情報が含まれているか。 if (req.user) { // 認証オブジェクトを使ってRESTクライアントを作成。 var client = new Client({ user: req.user.username , password: req.user.password }); client.get('http://localhost/api/data', function(data, response) { // レスポンスが配列でない場合はエラー。 if (!(data instanceof Array)) { res.render('index', { title: 'Express(' + data.message + ')' }); console.log(data); } else { var item = data.find(function(elem, index, array) { return elem['@filepath'] === 'domtest.nsf'; }); res.render('index', { title: item['@title'] }); } }) .on('error', function (err) { res.render('index', { title: 'Express(Error)' }); console.log(err); }); } // ユーザ情報がないときの表示。 else res.render('index', { title: 'Domino REST' }); }); module.exports = router;
動作確認
プロジェクトフォルダ上で、「npm start」とすれば、localhost:3000でアクセスできるようになります。データサービスが使える同一マシン上のDominoサーバも立ち上げておいて下さい。
未認証の場合はこのようになります。リンクをクリックして、ログイン画面に遷移します。
例えば、ログインに失敗すると次のようになります。
ログインに成功すれば、トップページのタイトルが、NotesDBのタイトルに変化します。
さいごに
Dominoが持っている認証機能をどのように肩代わりするか、その辺りが課題として残りましたが、パスワードデータの保護方法が確立できれば、DominoのデータをJSONで扱えるようになり、DominoのWebサーバとしての負担を軽減することができます。
ユーザの認証ができるようになったので、次はWebSocketによるリアルタイム通信にチャレンジしてみたいと思います。
DominoとNode.jsの同居サーバ構築
Notes C APIによるNotesPeekリメイクプロジェクトを少しお休みして、Webの話をしばらくします。
DominoをめぐるWebアプリケーション環境は、クラシックWebとXPagesがあります。サーブレットは置いておきます。クラシックWebは、レガシーなNotesアプリケーションにWeb要素を付け足しながら作ることができる昔ながらの手法で、CGIのような開発もできたので、これはこれで自由度の高いアプリケーションを開発できました。XPagesは、JavaServerFaces(JSF)をベースにNotesクライアント、Webブラウザの両方のインターフェースを同時に開発できる強みがあり、コミュニティによる拡張機能の開発が活発で、Notes/Dominoの開発環境としては夢のようでした。
いずれの場合も甲乙付けがたく、また一長一短もあります。ここでその優劣を語るものではありません。
かつて私が、DominoとNode.jsとAngular.js(v1.x)で楽しくアプリを開発していた頃の話の再編集です。
リアルタイムにつなぎたい
チャットアプリケーションを作りたいと思いました。チャットアプリケーションは、二人以上のユーザーがコメントしあうアプリケーションですが、クライアント同士がつながっているわけではなく、サーバーがユーザーからのコメントを預かり、他のユーザーにシェアすることで、あたかも互いにコメントし合っているかのように見せています。
これをHTTPで行う場合、必ずしもリアルタイムに、と言うわけにはいきません。HTTPはユーザー(クライアント)からのリクエストにレスポンスし、そこで接続を切ってしまうプロトコルであるため、ポーリング、クライアントが一定間隔でリクエストし続けることでしか新着情報を取得する手立てがありません。
そんなリアルタイム通信のニーズに応えるべく、WebSocketプロトコルが生まれました。WebSocketプロトコルは、一度つないだコネクションを意図的に切断しない限り接続を続けます。そのため、サーバからのプッシュが可能になり、また通信量も極めて少ないので、リアルタイム双方向通信の担い手となっていきました。
Notes/DominoのWeb環境を見回した場合、Dominoに組み込むタイプのフリーのアドインが海外にありましたが、どうにもうまく導入できません。そこで、DominoとNode.jsを連携させることを考えました。DominoとNode.jsの間はREST(Domino Data Services)を使うことにします。Dominoの環境にもよりますが、認証は基本認証が手軽です。まずはこれでDomino上のデータをNode.jsで取得し、画面に表示することを考えてみます。
JavaScriptでサーバ開発
Node.jsはサーバサイドJavaScript実行環境です。Notes/Dominoエンジニアにとって「サーバサイドJavaScript」はなかなかなじめないものです。しかし、Node.jsがもたらすものは、非同期、イベントドリブン、シングルスレッドといった非常にベーシックなもので、一方で多くのパッケージが公開されているため、ライブラリに困りません。得意なことはリアルタイム通信を使ったアプリケーションです。また、クライアントと同じJavaScriptで開発できる点は見逃せません。
それでは、実際に構築してみましょう。まずNode.jsをDominoサーバと同じ環境にインストールします。
- Node.js(Windows版) v6.11.3
- npm v3.10.10
私はこれらを公式サイトからインストールしました。
定番Webフレームワーク「Express」
次にNode.js上で動く定番のWebアプリフレームワークExpressを導入します。
- express v4.15.5
コマンドラインツールを入れておくと便利です。
> npm install express-generator -g
これらを準備できたら、express-generatorでアプリのひな形を作ります。domtestはプロジェクト名兼フォルダ名です。「--view=pug」はHTMLテンプレートエンジンをPUG https://pugjs.org/ を指定しています。
> express --view=pug domtest
domtestに移動して、次のようにサーバを起動します。
> cd domtest > set npm start
ブラウザでデフォルトポートである3000を開くと次のようになります。
express-generatorによってできたフォルダ構造は次のようになっています。
package.jsonには、このプロジェクトに必要なライブラリや、npmスクリプトの定義などが記述されています。
{ "name": "domtest", "version": "0.0.0", "private": true, "scripts": { "start": "node ./bin/www" }, "dependencies": { "body-parser": "~1.18.2", "cookie-parser": "~1.4.3", "debug": "~2.6.9", "express": "~4.15.5", "morgan": "~1.9.0", "pug": "2.0.0-beta11", "serve-favicon": "~2.4.5" } }
起動スクリプトは/bin/wwwになります。拡張子はありませんが、JavaScriptファイルです。
#!/usr/bin/env node /** * Module dependencies. */ var app = require('../app'); var debug = require('debug')('domtest: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); /** * 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); }
最初の方に書かれている「var app = require('../app');」の部分が、アプリケーションのメインの定義になります。
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 index = require('./routes/index'); var users = require('./routes/users'); var app = express(); // 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'))); app.use('/', index); app.use('/users', users); // 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'); }); module.exports = app;
/bin/wwwがNode.jsのためのブートストラップ、app.jsがExpressのためのブートストラップと考えればいいでしょう。
トップページはルート「/」としてルーティングされています。
var index = require('./routes/index'); (...中略...) app.use('/', index);
トップページは/routes/index.jsとして定義されています。
var express = require('express'); var router = express.Router(); /* GET home page. */ router.get('/', function(req, res, next) { res.render('index', { title: 'Express' }); }); module.exports = router;
Let's カスタマイズ!
今回は、このページのタイトル部分に、Dominoサーバから取得したRESTのJSONデータを使って、表示を変えてみたいと思います。
まずはdomtestアプリにRESTクライアントを実装します。コマンドプロンプトで、domtestフォルダに移動して、次のコマンドを実行します。
> npm install node-rest-client --save
このnode-rest-clientを使って、DominoのRESTを取得します。Domino側では、Webの認証方法を基本認証にしておくとともに、Domino Data Servicesを有効にしておく必要があります。公式リファレンス Domino Data Service User Guide and Reference の他に、IBMチャンピオンの御代さんによる解説が大変わかりやすいので、リンクを拝借します。
先程の/routes/index.jsを次のように変更します。
var express = require('express'); var router = express.Router(); // RESTクライアントモジュールを使用する。 var Client = require('node-rest-client').Client; var options_auth = { user: "username", password: "password" }; var client = new Client(options_auth); /* GET home page. */ router.get('/', function(req, res, next) { client.get('http://localhost/api/data', function(data, response) { // レスポンスが配列でない場合はエラー。 if (!(data instanceof Array)) { res.render('index', { title: 'Express(' + data.message + ')' }); console.log(data); } else { var item = data.find(function(elem, index, array) { return elem['@filepath'] === 'domtest.nsf'; }); res.render('index', { title: item['@title'] }); } }) .on('error', function (err) { res.render('index', { title: 'Express(Error)' }); console.log(err); }); }); module.exports = router;
肝になるのは次の件です。
var item = data.find(function(elem, index, array) { return elem['@filepath'] === 'domtest.nsf'; }); res.render('index', { title: item['@title'] });
データベース名を指定せずにREST APIを呼び出すと、DBの一覧が取得できます。その中から@filepathの値が目的のDBパスを指していれば取得し、その中に含まれる@titleメンバをページタイトルに組み込んでいます。すると、次のようにDominoDBタイトル「Domino Nodejs Test」がページに反映されました。
IoTとDominoをNode.js、RESTでつなぐ事例
昨年、IBMチャンピオンの方々が、Node-REDやIoT、WebSocketを駆使してREST APIを活用している事例が載っています。
IoT と Notes/Domino を組み合わせて何かできないか!?
こんな素晴らしいことの足下も及びませんが、ここで紹介した事例のように、DominoのREST APIを使う環境があれば、Node.jsに限らず、誰でも言語の制約は突破することができます。また、言語ごとに得意分野があるので、それらを活かしたNotes Webアプリの可能性も広がります。Notes/Dominoに新しい言語の風を送り込み、若い人材とともに新しいことができることを切望します。
次回は、つながったDominoとNode.jsの間でWebSocketの実装を試みます。Node-REDを使わない場合、どのように実装していけばいいのでしょうか。楽しく勉強していきます。
NSFItemScan関数の限界(その2) - Noteクラス - 「NotesPeek」をQtでリメイク
前回、同一文書内にある同名アイテムついて少し解説しました。それは、これからお話しするAPI関数、NSFItemScanの挙動に関係があるためです。
NSFItemScan関数は、文書内に含まれるアイテムを巡回し、アイテムごとにコールバック関数を呼び出してくれる関数です。
// nsfnote.h(再掲) typedef STATUS (LNCALLBACKPTR NSFITEMSCANPROC)( WORD Spare, WORD ItemFlags, char far *Name, WORD NameLength, void far *Value, DWORD ValueLength, void far *RoutineParameter); STATUS LNPUBLIC NSFItemScan ( NOTEHANDLE hNote, NSFITEMSCANPROC ActionRoutine, void far *RoutineParameter);
既知の問題として、このコールバック関数でアイテムの内容を変更することはできません。ヘルプにもそう書いてあります。
The action routine can read the contents and properties of each field, but may not modify a field. This is because the action routine is passed the actual memory pointers of the field contents, and these should not be modified.
(機械翻訳) アクションルーチンは、各フィールドの内容とプロパティを読み取ることができますが、フィールドを変更することはできません。 これは、アクションルーチンがフィールドの内容の実際のメモリポインタを渡され、変更されるべきでないためです。 (APIリファレンスより)
これとは別に、次のような不具合が存在します。
この関数、一般的な文書では問題なく機能するのですが、実はこれ、「同名アイテムが存在する文書では、コールバック関数が呼び出される数と、アイテム数とで一致しない」ことが分かりました。以下の、画面ショットをご覧下さい。
これは、同じ文書を展開したときの、NSFItemScan関数のデバッグ用出力結果(左)と、NSFinderの表示結果(中央)です。同じ文書を、Notesクライアントの文書プロパティで見ると、bodyフィールドは7つあるのがわかります。
NotesPeekやNSFinderでは、添付ファイルを除くフィールド(アイテム)は、同名アイテムに分かれていた場合、1つにまとめられます。ところが、NSFItemScan関数をそのまま使ってしまうと、一つにまとめられるわけでもなく、かと言って同名アイテムの数分だけ呼び出してくれるわけでもなく、ここでは3回だけコールバックするという、なんとも不思議な動作結果が得られました。一体どういうことなんでしょうか。
このような結果が得られた以上、NSFItemScan関数に過剰な期待は禁物です。キーバリュー型のコンテナ(Map型など)を使ってアイテム名(フィールド名)だけをコレクションし、同じ名前が2つにならないようにして、集めた重複なしの名前の分だけ処理をするという方針にしなくてはいけないでしょう。
教訓:NSFItemScan関数はアイテム名を集めるだけに使用する
さて、次回以降ですが、Webアプリケーションの観点で短期的な連載を企画しています。Facebookでもつぶやきましたが、去る9月19日の「Notes/Domino Day 2017」でも発表がありました、「Domino Application on Cloud」が使えるようになると、これまでオンプレでしか利用できなかったNotesDBがクラウド上で使えるようになり、おそらくBluemixのようなPaaS環境との相互運用性が高まると予想されます。そこで、そのような環境を想定したWebアプリケーションのデモを考えています。来週に間に合うかな?間に合うといいな!乞うご期待でございます。
もちろん、その間にリメイクプロジェクトもパワーアップする予定です。