Node.jsとExpress.jsを使ってみた


NodeとExpressと、ついでにMongoDBを使ってみました。サンプルとして、「保通協の型式試験の実施状況（PDF）をHTMLで表示する」というのを作ってみることにします。あまりに多くアクセスするのはよくないので、一度アクセスしたらDBへ保存し、前回のアクセスから30分経っていない場合は、DBから取得するようになっています。

# 準備

```bash
$ npm install -g express-generator
$ express --view=pug somerset
```

とりあえず、express-generatorの[サンプル](http://expressjs.com/ja/starter/generator.html)通りです。

# 実装

以下、試行錯誤の結果だけを残しておきます。

## エントリポイント

とりあえずエントリポイントは```app.js```でした。

```js: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 index = require('./routes/index');

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);

// 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 : {message:'データが見つかりません'};

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

module.exports = app;
```

最初の```express-generator```で作成されるサンプルは削除しています。また、```app.js```は全体の設定ファイルの役割を持っているような印象でしたので、実装自体は```router/index.js```にまとめることにしました。（もしかしたら作法とは違う・・？）

## Router

エントリポイントである```app.js```から、パスを辿る経路の処理をRouterと呼んでいるようです。

### 優先順位

リクエストされたパスを正規表現で検索し、マッチした場合のみ、その処理を行います。検索はファイルの上から行われ、順に処理されます。つまり、優先順位の高いものがある場合は、上に記述する必要があります。

```js:routes/index.js
  .. 省略 ..

router.get('/setup', function(req, res, next) {
	if ('production' == process.env.NODE_ENV) {
		res.redirect(302, './');
	} else {
		co(function* () {
			yield db.init();
			const data = yield db.setup();
			yield db.fin();

			res.render('index', {
				title : '型式試験の実施状況（初期設定）',
				update: moment(data[0][0].update).format('YYYY年MM月DD日 HH時mm分'),
				list  : data[1],
				id    : [],
			});
		})
		.catch(console.error);
	}
});

  .. 省略 ..
```

上記の処理は、```/```よりも優先して実行したかったので、その処理よりも上に書いています。[routes/index.js](https://github.com/hydrangeas/somerset/blob/master/routes/index.js#L16-L34)で、```/setup```を一番上においていることを確認できます。

### 環境変数

Nodeでの環境変数は```process.env```を参照します。

```js:routes/index.js
const dotenv    = require('dotenv');
dotenv.load();
const node_env  = process.env.NODE_ENV;
```

上記のコードのように、```development```と```production```環境で、環境変数を変える方法として、```.env```ファイルを作成し、読み込む方法採用しました。ただ、__evennodeではWebコンパネ上から環境変数が設定できる__ので、```.env```は作成していません。（ローカルデバッグでのみ使っています）

### リクエストクエリ

リクエストクエリは、routerのcallback関数の引数にセットされます。具体的には```app.use('/', function(req, res, next){...});```と設定した場合、```req.query```にセットされます。

```js:routes/index.js
const query_id = (req.query.id?req.query.id.split(','):[]);
```

/?id=12345,678901のようなクエリを```,```で分割します。クエリがない場合、```req.query.id```が```undefined(falsy)```になるため、空配列を返すようにしています。

```js:routes/index.js
	if (req.query.format && 'json' === req.query.format.toLowerCase()) {	
	}
```
こちらも```req.query.format```が```undefined(falsy)```でない場合、```json```であるかをチェックしています。（大文字小文字は判定しないので、無条件に小文字に変換しています）

### レスポンス（JSON）

```JavaScript
res.header('Content-Type', 'application/json; charset=utf-8');
res.json({ list:jsonData });
```
JSONを返す際にはヘッダーを適切に設定することと、```charset=utf-8```をつけた方が良いようです。（ブラウザなどが誤爆しないように・・）

### レスポンス（PUGでレンダリング）

```js:routes/index.js
res.render('index', {
	title : req.html.title,
	update: req.html.update,
	list  : req.html.list,
	id    : query_id,
});
```

引数である```index```は```views/index.pug```を参照します。続くデータはPUG内に引き渡されます。

### GET/POSTを用いた例

```JavaScript
router.get('/check', function(req, res, next) {
	res.render('check', {});
});
router.post('/check', function(req, res, next) {

	.. 省略 ..

	try {
		data = JSON.parse(req.body.checkData);
		data = (data.list?data.list:[]);
		
		.. 省略 ..
		
		res.render('checkresult', {title:'チェック結果', list:ret});
	} catch (err) {
		res.render('check', {data:req.body.checkData, error:err});
	}
});
```

GETでアクセスした場合はフォームを表示、POSTでデータ送信した場合はDBと送信したデータをチェック、エラーであれば送信したデータを初期値としてフォームを表示します。

## データベース（MongoDB）アクセス

init→(DBの操作)→finという流れにしたかったので、module.exportsにハンドラとメソッド群をまとめました。

```js:routes/db.js
'use strict';

const co     = require('co');
const moment = require('moment');
const pdf    = require('./pdf');

const mongodb= require('mongodb');
const client = mongodb.MongoClient;

const dotenv = require('dotenv');
dotenv.load();
const node_env      = process.env.NODE_ENV;
const mongo_url     = process.env.MONGO_URL;
const mongo_working = process.env.MONGO_COLLECTION_WORKING;
const mongo_finished= process.env.MONGO_COLLECTION_FINISHED;

let _conn = null;
module.exports = {

	// create collections
	create: co.wrap(function* () {
		yield [
			_conn.createCollection(mongo_working),
			_conn.createCollection(mongo_finished),
		];
	}),
	// delete a document
	delete: co.wrap(function* () {
	}),
	// disconnect to MongoDB
	fin: co.wrap(function* () {
		yield _conn.close();
		_conn = null;
		return _conn;
	}),
	// connect to MongoDB
	init: co.wrap(function* () {
		_conn = yield client.connect(mongo_url);
		return _conn;
	}),
	// insert a document to MongoDB
	insert: co.wrap(function* (doc) {
		yield _conn.collection(mongo_working).insertOne(doc);
	}),
	/*
	 * 更新確認用データの存在チェック
	 * true : 更新する
	 * false: 初期設定する
	 */
	isUpdate: co.wrap(function* () {
		const flagData = yield _conn.collection(mongo_working).find({ 'id':0 }).toArray();
		if(flagData[0]) {
			// データ有
			return true;
		}
		// データ無
		return false;
	}),
	// listing data
	list: co.wrap(function* () {
		return yield[
			_conn.collection(mongo_working).find({ 'id':0 }).toArray(),
			_conn.collection(mongo_working).find({ 'id':{'$gt':0} }).toArray(),
		];
	}),

	// get one data
	findOne: co.wrap(function* (_id) {
		return yield[
			_conn.collection(mongo_working).find({ 'id':0 }).toArray(),
			_conn.collection(mongo_working).find({ 'id':Number(_id) }).toArray(),
		];
	}),

	/*
	 * 1 development環境で/setupがGETされた場合
	 * 2 /indexがGETされ、isUpdateの結果がfalseの場合
	 */
	setup: co.wrap(function* () {
		const now = moment();

		// 無条件に全データを削除、初期データを投入する
		yield _conn.collection(mongo_working).remove({});
		yield _conn.collection(mongo_working).insertMany(yield pdf.get(now));
		yield _conn.collection(mongo_working).insertOne({
			id    : 0,
			update: Number(now),
		});

		return yield[
			_conn.collection(mongo_working).find({ 'id':0 }).toArray(),
			_conn.collection(mongo_working).find({ 'id':{'$gt':0} }).toArray(),
		];
	}),

	/*
	 * 30分(development環境では1秒)間隔でPDFデータを取得し、データベースを更新する
	 */
	update: co.wrap(function* () {
		const now = moment();
		const diff = ('production' === node_env?(1000*60*30):1000);

		const flagData = yield _conn.collection(mongo_working).find({ 'id':0 }).toArray();

		//規定更新時間経過チェック
		if (Number(now) - Number(moment(flagData[0].update)) > diff) {
			//データ更新
			const pdfData = yield pdf.get(now);
			//pdfデータがDBに存在する場合は更新し、存在しない場合は挿入する
			let data = null;
			for (let item of pdfData) {
				data = yield _conn.collection(mongo_working).find({ 'id':item.id }).toArray();
				if (data[0]) {
					data = yield pdf.update(data[0], item, now);
				} else {
					data = item;
				}
				yield _conn.collection(mongo_working).update(
						{'id':item.id},
						{$set:data},
						{upsert:true});
			}
			//更新日時を更新
			yield _conn.collection(mongo_working).update(
					{'id':0},
					{$set: {update: Number(now)} },
					{upsert:true});
			//今回更新されなかったデータを削除
			const unupdated = yield _conn.collection(mongo_working).find({ 'update': {'$ne':Number(now)} }).toArray();
			if (unupdated.length > 0) {
				const date = unupdated[0].update;
				yield _conn.collection(mongo_working).remove({ 'update':date });
			}
		}

		return yield[
			flagData,
			_conn.collection(mongo_working).find({ 'id':{'$gt':0} }).toArray(),
		];
	}),
};

```

メンバメソッド```setup```あたりで、削除、挿入、取得を色々と使っています。

### PDF操作

PDF操作用に```pdf2table```を使い、データを抜き出しています。PDF内のテーブルにアクセスしてデータを取得するのですが、空欄の場合、前詰めされてしまうようです。今回は、空欄となるパターンが、プログラム側で判定可能だったため、問題にはなりませんが、少し注意が必要です。

### PUG

少し書いていれば慣れてきました。楽でいいですね。

```pug:views/index.pug
extends layout

block content

  div.container
     .page-header
       .row
         .col-lg-12
           h1= '型式試験の実施状況'
           p= '更新日時:' + update
     table.table.table-striped.table-hover
       thead
         tr
           th 管理番号
           th 設計書等審査
           th 対比照合審査
           th 遊技機の試験
           th 試験終了予定
       tbody
         each item in list
           - var _id = '000000' + String(item.id);
           - _id = _id.substring(_id.length - 6)
           - if (id.length == 0 || (id.length > 0 && id.indexOf(_id) >= 0))
             tr
               td=  _id
               td= (item.test[0].status == 0?'':(item.test[0].status == 1?'試験中':'試験終了'))
               td= (item.test[1].status == 0?'':(item.test[1].status == 1?'試験中':'試験終了'))
               td= (item.test[2].status == 0?'':(item.test[2].status == 1?'試験中':'試験終了'))
               td= item.guess
```

tbodyのところで```id```（GETメソッドで指定されたid）の判定をしています。指定がなければ全件表示、指定があれば一致したものだけ表示しています。

# 成果物

* [DEMO@evennode](http://somerset.eu-4.evennode.com/)
* [ソースコード@GitHub](https://github.com/hydrangeas/somerset/)

# 参考文献
* [Express のアプリケーション生成プログラム](http://expressjs.com/ja/starter/generator.html)