W64APIにまたしても!DNParseの落とし穴
いつぞやのNSFItemInfoNext関数に続き、またしてもW64で正常に動かないC APIを発見しました。今回は、DNParse関数です。まず、DNParse関数とはなんぞや?というところから。
DNParse関数の接頭辞、「DN」は「Distinguished Name」の略で、識別名を表します。Notesでいうところの、
CN=Taro Yamada/O=Acme/C=JP
というID名を指します。ユーザ名、サーバ名などがそれにあたります。実際に捌ける要素はこれだけにとどまらないようですが、ここでは割愛します。ここでのポイントは、「DN」を冠した関数は識別名を操作する関数ということです。
DNParseは、その識別名をパーツごとに分解することができます。例えば、前出の例を元に分解すると、
Common Name => Taro Yamada Org Name => Acme Country Name => JP
となります。ヘッダーでは、DNParse関数は次のように定義されています。
#include <dname.h> STATUS LNPUBLIC DNParse( DWORD Flags, const char far *TemplateName, const char far *InName, DN_COMPONENTS far *Comp, WORD CompSize);
FlagsとTemplateNameは0を渡します。 InNameに分解したい識別名へのポインタを入力します。 続く「DN_COMPONENTS」という構造体に、分解された情報が入ってくるので、事前にDN_COMPONENTS変数を用意して、そのポインタをCompに、変数サイズをCompSizeに与えれば取得できます。 問題なく分解できれば、NOERRORを返します。
DN_COMPONENTS dn; STATUS result = DNParse(0, 0, "CN=admin/O=acme", &dn, sizeof(dn));
ヘッダーでDN_COMPONENTSを見ると、Ver3はここまで、Ver4はここから、みたいなコメントがあり、拡張が繰り返されてきたことが伺えます。
#include <dname.h> typedef struct { DWORD Flags; /* Parsing flags */ /* (中略) */ WORD CLength; /* Country name length */ char far *C; /* Country name pointer */ WORD OLength; /* Organization name length */ char far *O; /* Organization name pointer */ WORD OULength[DN_OUNITS]; /* Org Unit name lengths */ /* OULength[0] is rightmost org unit */ char far *OU[DN_OUNITS]; /* Org unit name pointers */ /* OU[0] is rightmost org unit */ WORD CNLength; /* Common name length */ char far *CN; /* Common name pointer */ WORD DomainLength; /* Domain name length */ char far *Domain; /* Domain name pointer */ /* Original V3 structure ended here. The following fields were added in V4 */ WORD PRMDLength; /* Private management domain name length */ char far *PRMD; /* Private management domain name pointer */ /* (中略) */ } DN_COMPONENTS;
実際にDNParseをDomino Win32bitで動かすと、DN_COMPONENTSには以下のような情報が返ってきます。
ハイフンを挟んで左が文字数、右が文字列へのポインタです。Country name、Org name、Common nameのそれぞれに文字数2、文字数4、文字数11(0x0B)と、文字列が格納されているポインタが格納され、Org Unit name0〜3にはデータがないのがわかります。
全く同じコードをWin64用にコンパイルして動かすと以下のようになります。
「あれれ〜おかしいなあ〜。ポインタっぽい値が文字数変数に入り込んでいるぞ〜。」という感じになっています。
このズレは何なのか、いろいろ考えてみました。単純に、メモリ配置的に、ポインタが4バイトから8バイトになったというだけでは説明が付きませんでした。試行錯誤の結果、国名(C)、組織名(O)共通名(CN)までの変数であれば、次のようにパディングすることで解決しました。
#ifdef W64 typedef struct { DWORD Flags; /* Parsing flags */ /* (中略) */ WORD CLength; /* Country name length */ WORD c_padding; // パティング char far *C; /* Country name pointer */ WORD OLength; /* Organization name length */ WORD o_padding[3]; // パティング char far *O; /* Organization name pointer */ WORD OULength[DN_OUNITS]; /* Org Unit name lengths */ /* OULength[0] is rightmost org unit */ char far *OU[DN_OUNITS]; /* Org unit name pointers */ /* OU[0] is rightmost org unit */ WORD CNLength; /* Common name length */ WORD cn_padding[3]; // パティング char far *CN; /* Common name pointer */ WORD DomainLength; /* Domain name length */ char far *Domain; /* Domain name pointer */ /* Original V3 structure ended here. The following fields were added in V4 */ WORD PRMDLength; /* Private management domain name length */ char far *PRMD; /* Private management domain name pointer */ /* (中略) */ } DN_COMPONENTS; #else /* (オリジナルのDN_COMPONENTS定義をここに) */ #endif
CLengthとCの間にWORD一つ分、OLengthとOの間にWORD3つ分、CNLengthとCNの間も同じく3つ分パディングすると、以下のように正しく変数に格納されたのが確認できました。
Domain以下の変数に対しては試せていませんが、ズレているのであれば、逐次直していくしかないでしょう。
まとめ
Notes/Dominoは登場初期からマルチプラットフォーム指向で、C APIも早い段階から提供され、Notes/Dominoのバージョンとともに更新されてきました。ただ残念なことに、C APIに目を向ける人口が少ないせいか、時々このようにお粗末なところが放置され、それを指摘するような利用者サイドも皆無となっています。 また、こういう関数が放置されているのにWin64 Dominoサーバがほぼ問題なく動いているのは、Undocumentedな関数で実装されていることは想像に難くなく、Windows ToolのDependency Walkerにかければそれっぽい関数名が浮かび上がります。 C APIのドキュメントもきちんと整備し、Documentableになった関数からユーザに開放してくれることを切に願うばかりです。
注意: コードの利用においてチブル・システムズは一切の責任を負いません。自己責任でご利用をお願いいたします。
DominoサーバにOAuth2認証機能をアドインする「DOAP」
先日、Facebookページで開発を表明したDominoのOAuth2アドインですが、一部の機能を残して0.1.0-betaバージョンが完成しました。
名称は「DOAP」Domino OAuth2 Providerの略です。
このDOAPは、Dominoにアカウントを持っているユーザが、他のWebサービスからDominoのリソースにアクセスしたいときに、OAuth2の仕組みでシームレスに「認可」することができます。
通常、DominoはBasic認証かフォーム認証で、REST APIでさえこの認証が必要になります。 Basic認証の場合は常にユーザ名、パスワードをどこかに保持しておかなければなりません。 フォーム認証の場合も同様で、さらにセッションが切れたとき、ログイン画面をHTMLで返してきます。 このとき厄介なのが「HTTP/1.1 200 OK」と、認証が切れていることをリターンコードだけでは判断できないのです。
これを一気に解決する手立てがOAuth2認証であり、その実装が「DOAP」です。
この問題が解決されると、OAuth2に対応したWebサイト構築言語の間口が途端に開けることになります。 Node.js、PHP、Ruby、Python、.NETなどなど。 Dominoを「認可サーバ」「リソースサーバ」という形で、言語にとらわれずに活用することが可能になります。
構想こそ長くボヤッとしていたものの、「もしやできるのは?」と思い立ったのが、このGW前。 1ヶ月ほどでこのような形になったのも、応援して下さった方々のおかげです。
Lotus Notes R3.0Jに出会い、およそ25年。 C APIやC/C++で開発する人が周りにいない中、C APIで初めて製品を構築したのが約5年前。 それからも2つの製品開発に応え、このOAuth2開発に至りました。
現在、関係者とDOAPの公開方法について模索しているところです。 広く使ってもらいたいところですが、認証というセンシティブな一面もあるので、なかなか頭を悩ませています。 正式な発表は別のところからになりますが、その折にはここでもリンクさせていただきます。
NSFItemInfoNext関数を64bit Windows Dominoサーバで使用するとクラッシュする
追記 2019-11-24
- 記事のリンクを追加しました。
- ソースコードにcpp指定をしました。
本編
私が、とあるプロジェクトでDominoサーバのアドインを作ったとき、奇妙な現象に悩まされた。
その現象というのは、NSFItemInfoNextというNotes C APIの関数を使うと、32bit WindowsのNotesクライアントとDominoサーバ、64bitのMacOS、32bit Linuxではまったく問題がないのに、64bit WindowsのDominoサーバでのみクラッシュするという事象である。
そもそも「NSFItemInfoNext」とは何なのか。
NSFItemInfoNext関数は、通常NSFItemInfo関数とペアで使用する。「NSFItem○○」という関数はアイテム(フィールドと同義)に関する操作を行う種類のもので、NSFItemInfoはアイテムの情報を取得するのに使用する。アイテムは、名前、型、値、属性の4つで構成されていて、NSFItemInfoは属性以外の情報を取得できる。アイテムは文書中名前(フィールド名)で区別されるため、通常1文書に1つだが、1アイテム中に保持できる値の大きさが限られているため、同名アイテムが複数保管されることがある。その時、2つめ以降の同名アイテムを取得するのに使われるのが、NSFItemInfoNext関数というわけである。
例えば、次のようにコーディングになる。
STATUS error = NSFItemInfo(hNote, "Body", strlen("Body"), &bItem, &wType, &bValue, &dwLength); if (ERR(error) != ERR_ITEM_NOT_FOUND) { if (error) return; bPrevItem = bItem; error = NSFItemInfoNext(hNote, bPrevItem, "Body", strlen("Body"), &bItem, &wType, &bValue, &dwLength); }
バイト数が決まっている数値型(NUMBER)、日時型(TIMEDATE)やシンプルなテキストデータくらいであれば、同名アイテムを2つ以上に分けて保存することはめったにないが、64キロバイトを超えるデータは文書に保存できないため、同名アイテム保存を使用する。Notesクライアントは、約40キロバイトを目安に同名アイテムを2つ以上に分けて保存する。
このように、NSFItemInfoNext関数は何か特殊な関数というわけではなく、APIとしては極めてベーシックな存在の関数なのだが、それだけにDominoサーバx64Winでクラッシュし、その原因がNSFItemInfoNextだとわかった時は、首をひねるばかりだった。
Notes C APIを使用したコードをコンパイルする場合、Windows 32ビットでは、少なくとも次の3つを指定する。
-DW -DW32 -DNT
Windows 64ビットでは、これらに加えて、以下の3つも必要になる。
-DW -DW32 -DNT -DW64 -DND64 -D_AMD64_
これら以外に識別子の過不足がないか調べてみたが、特に問題ない。
ネット上で調べているうちに、興味深い記述を見つけた。NSFItemInfoNext関数は以下のような引数を取る。
STATUS LNPUBLIC NSFItemInfoNext ( NOTEHANDLE hNote, BLOCKID NextItem, const char far *Name, WORD NameLength, BLOCKID far *retbhItem, WORD far *retDataType, BLOCKID far *retbhValue, DWORD far *retValueLength );
その記事では、この関数をLotusScript内から呼び出した時に、BLOCKID型の引数を倍精度小数点数(double型)の幅8バイトで指定する必要があるということだった。
C APIの世界では、BLOCKIDはプールハンドル(4バイト)とブロックハンドル(2バイト)の2つの値を持つ6バイト構造体だ。これを8バイトにするには、2バイトのブロックハンドルを4バイトにすることになる。
Notes C APIでブロックハンドルを定義しているのは、global.hヘッダーファイルだ。
// global.hの1076行目 typedef WORD BLOCK;
これを次のように書き換える。
#ifdef W64 typedef DWORD BLOCK; #else typedef WORD BLOCK; #endif
こうすることで、NSFItemInfoNext関数はクラッシュしなくなり、 BLOCKIDを使用するほかの関数も問題なく動作している(ように見える)。この手法が他の関数に本当に影響はないのか予断を許さないが、64bit WindowsのDominoサーバでクラッシュに困ったら、NSFItemInfoNext関数に起因していないか調べ、上記方法を試してみるのもよいかもしれない。
この修正による影響事案
この記事で提案した修正について、私自身以下の事案に遭遇しました。よろしければこちらもご参照下さい。
画面を更新してもチャットの履歴を表示できるようにする
リアルタイムでやり取りしたチャットメッセージは、通常一過性のもので、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データベースに保存する方法に取り組んでみたいと思います。