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

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

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

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

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

> cd domtest2

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

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

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

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

#!/usr/bin/env node

/**
 * Module dependencies.
 */

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

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

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

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

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

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

/**
 * Create HTTP server.
 */

var server = http.createServer(app);

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

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

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

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

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

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

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

  return false;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

cdnjs.com

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

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

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

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

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

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

})()

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

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

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

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

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

module.exports = router;

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

extends layout

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

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

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

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

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

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

a {
  color: #00B7FF;
}

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

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

div#console {
  width: 100%;
}

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

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

// /routes/index.js

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

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

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

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

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

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

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

extends layout

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  return app;
};

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

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

www.dropbox.com

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

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で扱うモジュールになります。

www.mongodb.com

Mongoose ODM v4.12.0

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」を使って、認証機能を設定しています。

passportjs.org

Passportは認証機能を単純化するだけでなく、その魅力はFacebookTwitterなど多くの認証サイトのストラテジ(認証方式)を持っていることです。今回は基本的なローカルストラテジ(ユーザ名、パスワード方式)を使います。このユーザ情報の保管、検索先が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サーバも立ち上げておいて下さい。

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

未認証の場合はこのようになります。リンクをクリックして、ログイン画面に遷移します。

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

例えば、ログインに失敗すると次のようになります。

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

ログインに成功すれば、トップページのタイトルが、NotesDBのタイトルに変化します。

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

さいごに

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

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

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

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リファレンスより)

これとは別に、次のような不具合が存在します。

この関数、一般的な文書では問題なく機能するのですが、実はこれ、「同名アイテムが存在する文書では、コールバック関数が呼び出される数と、アイテム数とで一致しない」ことが分かりました。以下の、画面ショットをご覧下さい。

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

これは、同じ文書を展開したときの、NSFItemScan関数のデバッグ用出力結果(左)と、NSFinderの表示結果(中央)です。同じ文書を、Notesクライアントの文書プロパティで見ると、bodyフィールドは7つあるのがわかります。

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

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アプリケーションのデモを考えています。来週に間に合うかな?間に合うといいな!乞うご期待でございます。

もちろん、その間にリメイクプロジェクトもパワーアップする予定です。

NSFItemScan関数の限界(その1) - Noteクラス - 「NotesPeek」をQtでリメイク

今回は、リッチテキストから少し離れて、文書からアイテム(フィールド)データを取得するプロセスについての考察をします。

Notes文書(Note)には、いろいろな種類のアイテムが格納されます。文書のプロパティやNotesPeekのように、文書内のアイテムをすべて読み込むためには、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);

事前にNSFITEMSCANPROCのような引数を取るコールバック関数を用意しておき、アイテムごとにその関数を呼び出すことで、すべてのアイテムに対して処理をすることができます。なお、NSFItemScanではアイテムの変更処理はできないので、アイテムデータの更新をしたい場合は、NSFItemScan関数の呼び出しを終えてからということになります。

さて、タイトルにも書いたNSFItemScan関数の限界とは何か、その前置きとして、重複アイテムについて軽く触れておきます。

Notesの文書には通常、アイテムの名前は重複しない作りのように見えます。フォームの設計をしているときなどは当たり前の話ですが、技術的には同名アイテムが重複することは可能です。理由の1つに、1アイテム当たり64キロバイトの壁の突破があります。文書にアイテムを追加する関数に「NSFItemAppend 」というのがあります。

// nsfnote.h

STATUS LNPUBLIC NSFItemAppend (
    NOTEHANDLE hNote, WORD ItemFlags,
    const char far *Name, WORD NameLength,
    WORD DataType,
    const void far *Value, DWORD ValueLength);

ValueLengthという引数の型がDWORDなので、4GBまでいけるのではと錯覚を起こしそうですが、APIのリファレンスマニュアルには次のように明確に書かれています。

The length of the item’s value (excluding the data type word). The length must not exceed 64K bytes. If the ITEM_SUMMARY flag is set in item_flags, then value_len must not exceed 32K.

(訳) (ValueLengthは)項目の値の長さです(データタイプを表すWORD型を除く)。長さは64Kバイトを超えてはなりません。item_flagsにITEM_SUMMARYフラグが設定されている場合、value_len(ValueLength)は32Kを超えてはなりません。

ビューなどで使うためにサマリーフラグを立てている場合は、さらに32キロバイトを超えてはいけないのです。しかし、テキストデータは32kBや64kBを超えることは意外にあることだと思いますし、リッチテキストならなおのことです(文書プロパティでBodyアイテムがいくつもあるのを目の当たりにしたことがあると思います)。実は、この壁を越えるために名前重複アイテムが使われています。前出のNSFItemAppend関数は、文書内に同名アイテムがあると、同じ名前だけど特殊なIDを振って文書内に同名アイテムを保存します。なお、1つのアイテムデータをどこで区切るかは関数の呼び出し側に責任があるようです。Notesユーザーがこれを意識することなく使えているのは、Notesクライアントが適当なところで区切って保管してくれているようです。

もう1つ、同名アイテムが存在する理由に、添付ファイルがあります。添付ファイルの本体は「$FILE」というフィールドに保管されますが、添付ファイルがいくつもあると、その数分だけ$FILEアイテムが存在します。添付ファイルがどれほど大きくても、$FILEアイテム1つに1ファイルが保管されます(先程とはまるで違いますね)。ファイルの添付処理はファイルシステムから直接行われるので、呼び出し側は特にサイズを気にする必要はないわけです。

同名アイテムは、添付ファイルであれば1アイテムを1ファイルとして、それ以外であれば、分割されたデータを合成して64キロバイトの壁を越える手段として使われています。

前置きが長くなりましたが、本件のリメイクプロジェクトで文書内のアイテムを取得するに当たり、この同名アイテムの存在が、NSFItemScan関数にある不具合をもたらしていました。次回、その不具合と対処方法について考察していきます。

CDフォントテーブルを列挙 - リッチテキスト - 「NotesPeek」をQtでリメイク

前回、CD(Composite Data、リッチテキスト内の構成要素)のテキスト要素「CDTEXT」に含まれるフォントの修飾データを明示化しました。この中にはもちろんフォントの書体も含まれますが、5種類の書体定義しか存在しません。書体の種類は、パソコンの黎明期ならいざ知らず、現在ではあまたの書体が存在します。

Notesのリッチテキストは、これを専用のリッチテキスト形式フィールドに書体テーブルを作って、書体番号を振り、参照することで解決しています。書体番号は、0~4がデフォルトの書体定義で、5~255(推定)が書体番号になります。同一文書(Note)内の「$Fonts」リッチテキストフィールド内にあるCDFONTTABLEデータと、後続のCDFACE配列を参照すると、同じ書体番号が振られた書体データを取得することができることになります。

typedef struct {
  WSIG  Header;  /* Tag and length */
  WORD  Fonts;   /* Number of CDFACEs following */
} CDFONTTABLE;   /* Now come the CDFACE records... */

typedef struct {
  BYTE Face;    /* ID number of face */
  BYTE Family;  /* Font Family */
  char Name[MAXFACESIZE];
} CDFACE;

CDFONTTABLE::Fontsは、後続のCDFACE配列の数を表すので、CDFONTTABLEを取得した後にFonts分だけCDFACEデータを取得すればよいことになります。

CDFACE::Faceは、CDTEXT::FontID::Faceが参照する書体番号を表します。

CDFACE::Familyは、フォントファミリーを表します。ビットマスクで構成されていて、APIリファレンスによればWindowsの定義を用いているようです。以下に、関連する定義を抜粋しておきます。

/* EnumFonts Masks */
#define RASTER_FONTTYPE     0x0001
#define DEVICE_FONTTYPE     0x0002
#define TRUETYPE_FONTTYPE   0x0004

#define DEFAULT_PITCH           0
#define FIXED_PITCH             1
#define VARIABLE_PITCH          2
#if(WINVER >= 0x0400)
#define MONO_FONT               8
#endif /* WINVER >= 0x0400 */

/* Font Families */
#define FF_DONTCARE         (0<<4)  /* Don't care or don't know. */
#define FF_ROMAN            (1<<4)  /* Variable stroke width, serifed. */
                                    /* Times Roman, Century Schoolbook, etc. */
#define FF_SWISS            (2<<4)  /* Variable stroke width, sans-serifed. */
                                    /* Helvetica, Swiss, etc. */
#define FF_MODERN           (3<<4)  /* Constant stroke width, serifed or sans-serifed. */
                                    /* Pica, Elite, Courier, etc. */
#define FF_SCRIPT           (4<<4)  /* Cursive, etc. */
#define FF_DECORATIVE       (5<<4)  /* Old English, etc. */

素朴な疑問として、マルチプラットフォームであるNotesでは、MacLinuxではどのようになるのか、どこかで検証してみたいと思います。

CDFACE::Nameは、書体名を表します。書体名は最大32バイトで、これもWindows APIの影響を受けています。Shift-JISやUnicodeなら全角は2バイトで済み、16文字まで入りますが、Notesの文字列格納はLMBCS形式なので、全角や半角カナが3バイトになってしまい、すべて全角では10文字しか入りません。心配でなりません。夜も眠れません。

それはさておき、このCDFONTTABLEとCDFACE配列を、ntlx::cd::Baseテンプレートクラスから派生させたntlx::cd::FontTableクラスとして実装した例が以下になります。

// ntlx/cd/fonttable.h

#ifndef NTLX_CD_FONTTABLE_H
#define NTLX_CD_FONTTABLE_H

#include <ntlx/cd/base.h>
#include <QVariant>

#if defined(NT)
#pragma pack(push, 1)
#endif

#include <editods.h>

#if defined(NT)
#pragma pack(pop)
#endif

Q_DECLARE_METATYPE(CDFACE)

namespace ntlx
{
namespace cd
{

class NTLXSHARED_EXPORT FontTable
    : public Base<CDFONTTABLE, _CDFONTTABLE, SIG_CD_FONTTABLE>
{
public:
  FontTable(char** ppRecord);
  FontTable(const FontTable& other);
  FontTable& operator=(const FontTable& other);

  virtual QString toString() const;

private:
  QList<CDFACE> faceList_;
};

} // namespace cd

} // namespace ntlx

#endif // NTLX_CD_FONTTABLE_H
// cd/fonttable.cpp

#include "ntlx/cd/fonttable.h"
#include "ntlx/lmbcs.h"

namespace ntlx
{
namespace cd
{

FontTable::FontTable(char** ppRecord)
  : Base<CDFONTTABLE, _CDFONTTABLE, SIG_CD_FONTTABLE>(ppRecord)
  , faceList_()
{
  for (WORD i = 0; i < record_.Fonts; ++i)
  {
    CDFACE face;
    ODSReadMemory(ppRecord, _CDFACE, &face, 1);
    faceList_.append(face);
  }
}

FontTable::FontTable(const FontTable &other)
  : Base<CDFONTTABLE, _CDFONTTABLE, SIG_CD_FONTTABLE>(other)
  , faceList_(other.faceList_)
{
}

FontTable& FontTable::operator=(const FontTable& other)
{
  Base<CDFONTTABLE, _CDFONTTABLE, SIG_CD_FONTTABLE>::operator=(other);
  if (this != &other)
    faceList_ = other.faceList_;
  return *this;
}

QString FontTable::toString() const
{
  const QString templ("[%1:%2:%3]");
  QStringList result;
  for (auto it = faceList_.constBegin(); it != faceList_.constEnd(); ++it)
  {
    Lmbcs name((*it).Name);
    result.append(
          templ
          .arg((ushort)(*it).Face)
          .arg((ushort)(*it).Family)
          .arg(name.toQString())
          );
  }
  return result.join(",");
}

} // namespace cd

} // namespace ntlx

この実装を使って、NSFinderを拡張した例を見ていきます。サンプルの文書は、以下のようになります。

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

この文書の$Fontsフィールドの、Font Tableを見ると以下のようになります。

【前半】

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

【後半】

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

デフォルトの書体定義を除くと、251種類までしか指定ができない書体の種類。これは多いのか、少ないのか。Notesが本格的な文書作成ツールではないという観点と、そんなに多くの書体が定義された文書は見るに堪えないという観点に立てば、十分すぎるかもしれないですね。ただ、余裕がないようにも見えてしまいます。APIではちょくちょくビット長の限界を見せられます。Notesのアーキテクチャは、バージョンを追うごとに拡張もされていますが、レガシーな部分も多く引きずっています。旧世代の仕様のままでも構いませんが、柔軟な構造を活用して、大胆な構造改革を起こすことも必要かもしれません。

CDテキストのフォント情報 - リッチテキスト - 「NotesPeek」をQtでリメイク

先週の体調不良が、嘘のように回復しました。また気を引き締めてNotesPeekをリメイクしていきます。

前回は、CDデータをテンプレートクラスベースで実装を簡略化し、その手始めとしてCDテキストを「cd::Textクラス」として実装してみました。今回は、CDテキストが持つもう1つの情報について掘り下げます。

CDテキストは、構造的には「CDTEXT構造体」と可変長の「文字列データ(LMBCS)」で構成されています。可変長の文字列データの長さは、CDTEXT::Header::Lengthが持つ全体の長さからODSLength(_CDTEXT)が返す長さを引けば算出できます。

CDTEXT構造体には、Headerの他にFONTID型のメンバを持っています。FONTID自体は単なるDWORD型ですが、同じバイト長を持つFONTIDFIELDSという構造体でも表せます。これは、FIDユニオンとして定義されています。

// #include <global.h>

typedef DWORD FONTID;
// #include <fontid.h>

/*  Font ID sub-fields */

typedef struct {
#ifdef LITTLE_ENDIAN_ORDER
  BYTE Face;       /* Font face (FONT_FACE_xxx) */
  BYTE Attrib;     /* Attributes (ISBOLD,etc - see below) */
  BYTE Color;      /* Color index (FONT_COLOR_xxx) */
  BYTE PointSize;  /* Size of font in points */
#else
  BYTE PointSize;  /* Size of font in points */
  BYTE Color;      /* Color index (FONT_COLOR_xxx) */
  BYTE Attrib;     /* Attributes (ISBOLD,etc - see below) */
  BYTE Face;       /* Font face (FONT_FACE_xxx) */
#endif
} FONTIDFIELDS;

/*  Font Union */

typedef union FID {
  FONTIDFIELDS x;
  FONTID FontID;
}FID;

以下のようなリッチテキストを例に、フォント属性について見ていきます。

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

属性のないテキストは以下のようになります。

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

Colorは0(黒)、Faceは1(標準ゴシック体)、Sizeは10ポイントになります。

FONTIDFIELDS::PointSizeはフォントのサイズを表します。BYTEは符号なしの8ビット長なので、理論上255ポイントまで表現が可能ということになります。

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

Colorはフォント色を表します。サイズと同様8ビット長なので、256通りの色を表現できます。しかし、画面をよく見ると、Textの次にCDCOLOR、前後をCDBEGIN、CDENDで挟まれています。256色というのは過去の仕様で、現在フォント色はフルカラーを指定できるようになっています。いずれどこかで、CDCOLORレコードを含めて色について紐解いてみたいと思います。

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

Faceはフォントの種類を表します。0~4番は既定のフォントタイプで、5以上は別に管理されているフォントテーブルを参照する形式を取ります。0~4番のフォントタイプは、Notesクライアントで見ると以下のフォントが該当します。

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

標準的なフォントは「Default Sans Serif」(ゴシック系)で1番となります。

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

明朝系の標準フォントは「Default Serif」で0番です。

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

「デフォルトのUIフォント」は3番です。

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

「デフォルトの等幅フォント」は4番です。

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

「デフォルトのマルチリンガルフォント」は2番です。

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

例えばこれを、「メイリオ」にすると、最初の定義外番号として5番が割り振られました。

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

続いて「游ゴシック」にすると、6番が割り振られていました。

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

今回説明は割愛しますが、デフォルトのフォント以外を設定すると、「$Fonts」というリッチテキストフィールドが生成されます。この中に可変長のフォントテーブルが構成されて、5番以降のフォントが管理されます。

それでは、残りのフォント属性について一部ですが見ていきます。

太字(Bold=True)

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

斜体(Italic=True)

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

取り消し線(StrikeOut=True)

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

今回、このFONTID(FONTIDFIELDS)を管理するに当たり、cd::Textクラスのインナークラスとしてcd::Text::FontIdクラスを定義しました。以下はその実装例です。

// #include <ntlx/cd/text.h>

#ifndef NTLX_CD_TEXT_H
#define NTLX_CD_TEXT_H

#include <ntlx/cd/base.h>

#if defined(NT)
#pragma pack(push, 1)
#endif

#include <editods.h>

#if defined(NT)
#pragma pack(pop)
#endif

namespace ntlx
{
namespace cd
{

class NTLXSHARED_EXPORT Text
    : public Base<CDTEXT, _CDTEXT, SIG_CD_TEXT>
{
public:
  class NTLXSHARED_EXPORT FontId
  {
  public:
    FontId(FONTID id);
    FontId(const FontId& other);
    FontId& operator=(const FontId& other);

    BYTE size() const;
    BYTE color() const;
    bool isBold() const;
    bool isItalice() const;
    bool isUnderline() const;
    bool isStrikeOut() const;
    bool isSuper() const;
    bool isSub() const;
    bool isEffect() const;
    bool isShadow() const;
    bool isEmboss() const;
    bool isExtrude() const;
    BYTE face() const;

  private:
    FONTID id_;
  };

  Text(char** ppRecord);
  Text(const Text& other);
  Text& operator=(const Text& other);

  QString value() const;
  FontId fontId() const;

  virtual QString toString() const;

private:
  QString value_;
};

} // namespace cd

} // namespace ntlx

#endif // NTLX_CD_TEXT_H

APIで提供される、FONTIDFIELDSから必要な情報と取得するための仕組みは、ほぼマクロなので実装もあまり難しいことはないと思います。

// cd/text.cpp

#include "ntlx/cd/text.h"

#include "ntlx/lmbcs.h"

namespace ntlx
{
namespace cd
{

Text::Text(char** ppRecord)
  : Base<CDTEXT, _CDTEXT, SIG_CD_TEXT>(ppRecord)
  , value_()
{
  WORD len = record_.Header.Length - ODSLength(_CDTEXT);
  Lmbcs lmbcs(*ppRecord, len);
  value_ = lmbcs.toQString();
  nullToCR(value_);
}

Text::Text(const Text &other)
  : Base<CDTEXT, _CDTEXT, SIG_CD_TEXT>(other)
  , value_(other.value_)
{
}

Text& Text::operator=(const Text& other)
{
  Base<CDTEXT, _CDTEXT, SIG_CD_TEXT>::operator=(other);
  if (this != &other)
    value_ = other.value_;
  return *this;
}

QString Text::value() const
{
  return value_;
}

Text::FontId Text::fontId() const
{
  return Text::FontId(record_.FontID);
}

QString Text::toString() const
{
  return value();
}

Text::FontId::FontId(FONTID id)
  : id_(id)
{
}

Text::FontId::FontId(const Text::FontId &other)
  : id_(other.id_)
{
}

Text::FontId& Text::FontId::operator=(const Text::FontId& other)
{
  if (this == &other) return *this;
  id_ = other.id_;
  return *this;
}

BYTE Text::FontId::size() const
{
  return FontGetSize(id_);
}

BYTE Text::FontId::color() const
{
  return FontGetColor(id_);
}

bool Text::FontId::isBold() const
{
  return FontIsBold(id_);
}

bool Text::FontId::isItalice() const
{
  return FontIsItalic(id_);
}

bool Text::FontId::isUnderline() const
{
  return FontIsUnderline(id_);
}

bool Text::FontId::isStrikeOut() const
{
  return FontIsStrikeOut(id_);
}

bool Text::FontId::isSuper() const
{
  return FontIsSuperScript(id_);
}

bool Text::FontId::isSub() const
{
  return FontIsSubScript(id_);
}

bool Text::FontId::isEffect() const
{
  return FontIsEffect(id_);
}

bool Text::FontId::isShadow() const
{
  return FontIsShadow(id_);
}

bool Text::FontId::isEmboss() const
{
  return FontIsEmboss(id_);
}

bool Text::FontId::isExtrude() const
{
  return FontIsExtrude(id_);
}

BYTE Text::FontId::face() const
{
  return FontGetFaceID(id_);
}

} // namespace cd

} // namespace ntlx

Notesの古いバージョンでは、色数の制限がひどく、たいした表現ができなかったことを思い出します。その片鱗が、このようにAPI上には残っているんですね。