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アプリケーションのデモを考えています。来週に間に合うかな?間に合うといいな!乞うご期待でございます。
もちろん、その間にリメイクプロジェクトもパワーアップする予定です。
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では、MacやLinuxではどのようになるのか、どこかで検証してみたいと思います。
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を拡張した例を見ていきます。サンプルの文書は、以下のようになります。
この文書の$Fontsフィールドの、Font Tableを見ると以下のようになります。
【前半】
【後半】
デフォルトの書体定義を除くと、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;
以下のようなリッチテキストを例に、フォント属性について見ていきます。
属性のないテキストは以下のようになります。
Colorは0(黒)、Faceは1(標準ゴシック体)、Sizeは10ポイントになります。
FONTIDFIELDS::PointSizeはフォントのサイズを表します。BYTEは符号なしの8ビット長なので、理論上255ポイントまで表現が可能ということになります。
Colorはフォント色を表します。サイズと同様8ビット長なので、256通りの色を表現できます。しかし、画面をよく見ると、Textの次にCDCOLOR、前後をCDBEGIN、CDENDで挟まれています。256色というのは過去の仕様で、現在フォント色はフルカラーを指定できるようになっています。いずれどこかで、CDCOLORレコードを含めて色について紐解いてみたいと思います。
Faceはフォントの種類を表します。0~4番は既定のフォントタイプで、5以上は別に管理されているフォントテーブルを参照する形式を取ります。0~4番のフォントタイプは、Notesクライアントで見ると以下のフォントが該当します。
標準的なフォントは「Default Sans Serif」(ゴシック系)で1番となります。
明朝系の標準フォントは「Default Serif」で0番です。
「デフォルトのUIフォント」は3番です。
「デフォルトの等幅フォント」は4番です。
「デフォルトのマルチリンガルフォント」は2番です。
例えばこれを、「メイリオ」にすると、最初の定義外番号として5番が割り振られました。
続いて「游ゴシック」にすると、6番が割り振られていました。
今回説明は割愛しますが、デフォルトのフォント以外を設定すると、「$Fonts」というリッチテキストフィールドが生成されます。この中に可変長のフォントテーブルが構成されて、5番以降のフォントが管理されます。
それでは、残りのフォント属性について一部ですが見ていきます。
太字(Bold=True)
斜体(Italic=True)
取り消し線(StrikeOut=True)
今回、この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上には残っているんですね。
CDデータのテンプレート化 - リッチテキスト - 「NotesPeek」をQtでリメイク
前回は、リッチテキストの要素である「CDデータ」を、未定義のものでもバイナリの値を並べて表示できるCdOtherItemクラスと、ライブラリクラスとしてのntlx::cd::Otherクラス、そしてその親クラス(と言うより、インターフェース、純粋仮想関数しか持っていない)となるntlx::Cdクラスを作成しました。定義の手法に関しては、我が座右の書、Notes/Domino APIプログラミング―C++とSTLによる実践的プログラミングを参考にさせていただいています。改めて感謝です。
ここで「CDデータ」と呼んでいるものについて簡単におさらいしておきます。
- Notesデータベースに含まれるもの → Note文書(ドキュメント、設計文書、ACLなど)
- Note文書に含まれるもの → アイテム(文字列、数値、日時、リッチテキスト、画像、ファイル、設計要素(フィールド、列など))
- リッチテキストアイテムに含まれるもの → CDデータ(テキスト、段落、画像、表、色、ホットスポットなど)
細かい点は大目に見ていただき、ここで伝えたいのは、CDデータはリッチテキストアイテムの構成要素であるということです。
リファレンスを見ると、「CD」とは「Composite Data」の略であることが分かるので、「データ」が重複しているのは否めませんが、「CD」という接頭辞が一つの大きなグループという位置づけにもなっており、特定の要素を表さない汎用的な名称として、あえて「CDデータ」と呼ばせていただいています。
さて、前回はCdクラスからcd::Otherクラスを派生させました。今回のゴールはCDTEXTというリッチテキストの中のテキスト要素を専用表示させるため、cd::Textクラスを派生させます。ただし、Cdクラスとは別にCDデータが共通して行う処理も多いので、テンプレートクラスcd::Baseを間に挟みます。
// ntlx/cd/base.h #ifndef NTLX_CD_BASE_H #define NTLX_CD_BASE_H #include <ntlx/cd.h> #if defined(NT) #pragma pack(push, 1) #endif #include <ods.h> #if defined(NT) #pragma pack(pop) #endif namespace ntlx { namespace cd { template <typename CDxxx, WORD _CDxxx, WORD SIG_CD_xxx> class NTLXSHARED_EXPORT Base : public Cd { public: Base(char** ppRecord) { ODSReadMemory(ppRecord, _CDxxx, &record_, 1); } Base(const Base& other) : record_(other.record_) { } Base& operator=(const Base& other) { if (this != &other) record_ = other.record_; return *this; } virtual WORD getSignature() const { return SIG_CD_xxx; } virtual WORD odsLength() const { return record_.Header.Length + record_.Header.Length % 2; } protected: mutable CDxxx record_; }; } // namespace cd } // namespace ntlx #endif // NTLX_CD_BASE_H
純粋仮想メソッドCd::toString以外は、テンプレートとした構造体CDxxxを使って表すことができます。CDxxxに当てはまる構造体は300以上もあるので、テンプレートクラスにするこの方法は、理にかなっていてとてもいいと思います。これはほぼそっくり座右の書のアイデアをお借りしています。
- CDxxx: CDデータの先頭を表す構造体
- _CDxxx: CDデータシンボル(ODSLength用)
- SIG_CD_xxx: CDデータシグネチャ(CD構造体先頭用)
これを親として、cd::Textクラスを定義します。
// 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: Text(char** ppRecord); Text(const Text& other); Text& operator=(const Text& other); QString value() const; virtual QString toString() const; private: QString value_; }; } // namespace cd } // namespace ntlx #endif // NTLX_CD_TEXT_H
// 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_; } QString Text::toString() const { return value(); } } // namespace cd } // namespace ntlx
cd::Text固有の実装としてはQString value_メンバ変数を持っています。テキストのCDデータは、CDTEXT構造体に続いてLmbcs形式のテキストが収められているので、BaseコンストラクタがCDTEXT構造体分のデータを取得したあとに、Lmbcs文字列をvalue_に、QStringに変換しながら格納しています。Baseコンストラクタが呼び出しているODSReadMemoryは、CDデータ構造体を読み込むと、メモリのポインタをそのサイズ分だけ移動してくれるので便利です。
これをNSFinderに実装してみた例は以下の通りです。
前回までの、汎用的なCDデータの出力ではなくて、CDTEXT専用の出力として機能しています。