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

私はこれらを公式サイトからインストールしました。

Node.js

定番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を開くと次のようになります。

f:id:takahide-kondoh:20171001160713p:plain

express-generatorによってできたフォルダ構造は次のようになっています。

f:id:takahide-kondoh:20171001161829p:plain

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チャンピオンの御代さんによる解説が大変わかりやすいので、リンクを拝借します。

guylocke.blogspot.jp

先程の/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」がページに反映されました。

f:id:takahide-kondoh:20171001171149p:plain

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を使わない場合、どのように実装していけばいいのでしょうか。楽しく勉強していきます。