nodejsでWeb予約サイトを構築した備忘録

仕事でイベント予約のためにWebサイトを構築したので備忘録。
予約フロー自体はいたってシンプルに特に工夫した点はない。
単純にnodejsでの構築手順。


##要件
1. Linux上に構築。
2. nodeはインストール済みである。
3. mySQLをインストール済みである。


##nodeプロジェクト生成
任意な場所で以下のコマンドをたたく。

```
express --view=pug [プロジェクト名]
```

プロジェクト名でフォルダが作られてnodejsプロジェクトのひな形ファイルが出来上がる。
次に必要なパッケージをインストール。

```
npm install
```

この時点で既に実行ができる。

```
npm start
```

これで起動してブラウザで起動ポートにアクセスするとダミー画面が表示される。
起動するポートは以下で定義。

```sh:bin/www
#!/usr/bin/env node

/**
 * Module dependencies.
 */

var app = require('../app');
var debug = require('debug')('myqpp:server');
var http = require('http');

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

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

/**
 * Create HTTP server.
 */

var server = http.createServer(app);
(以下略)
```
の「process.env.PORT」がそうだ。

後は目的に合わせて修正していく。


##プロジェクトの構造

```
├── app.js
├── bin
│   └── www
├── node_modules
│
├── package.json
├── public
│   ├── images
│   ├── javascripts
│   └── stylesheets
│       └── style.css
├── routes
│   ├── index.js
│   └── users.js
└── views
    ├── error.pug
    ├── index.pug
    └── layout.pug
```

|リソース名|内容|
|-----------------|---|
|app.js         | アプリケーション定義 |
|bin/www| npm start で実行されるスクリプト。ルーティングなどを定義する|
|node_modules        | npm installでインストールされる依存パッケージ群 |　
|package.json| 使用する依存パッケージとバージョンの定義 |　
|public| サイトリソース。 publicフォルダがドキュメントルートとなる。 |　
|routes| バックエンドスクリプト。app.jsで宣言したURIにアクセスした時の挙動を記述する。 |　
|views| HTMLテンプレート。routesスクリプトからこの中のテンプレートを指定してレンダリング指示する。好みに合わせて pug/ejs/jade の形式から選べる。 |　


##ここからは実装内容を備忘録

###app.js
実装した点は以下。
1. ルーティング
2. HTMLエンジンをejsに変更。


```diff:app.js
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

var indexRouter = require('./routes/index');
var fkdeventRouter = require('./routes/fkdevent');
var workshopRouter = require('./routes/workshop');
var resultRouter = require('./routes/result');
var checkinRouter = require('./routes/checkin');
var manageRouter = require('./routes/manage');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
-app.set('view engine', 'jade');
+app.set('view engine', 'ejs');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

//ルーティング
+app.use('/workshop', workshopRouter);
+app.use('/yoyaku-result', resultRouter);
+app.use('/yoyaku-checkin', checkinRouter);
+app.use('/yoyaku-manage', manageRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  res.status(err.status || 404);
  res.render('err404', { error: err });
});

// error handler
app.use(function(err, req, res, next) {
  res.status(err.status || 500);
  res.render('err500', { error: err });
});
module.exports = app;
```         

###bin/www
実装した点は以下。
1. ルーティング
2. HTMLエンジンをejsに変更。
3. https有効。
4. ポートを外部定義化。

```diff:bin/www
#!/usr/bin/env node

/**
 * Module dependencies.
 */

var app = require('../app');
+var define = require('../define');
var debug = require('debug')('reserve-app:server');
+var https = require('https');
var fs = require('fs');
+var ssl_server_key = '/etc/letsencrypt/live/fan-technology.com/privkey.pem';
+var ssl_server_crt = '/etc/letsencrypt/live/fan-technology.com/cert.pem';
+var options = {
+  key: fs.readFileSync(ssl_server_key),
+  cert: fs.readFileSync(ssl_server_crt)
+};

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

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

/**
 * Create HTTP server.
 */

var server = https.createServer(options, app);
(以下略)
```

###define.js
以下の形で宣言すると外部から読み込んで参照できる。
統一したい部分を外出しする。

```
module.exports = Object.freeze({
  PORT: 8089
});
```

###public以下
特にnodeだからでやることはない。いつも通り実装していく。


###routes
実装したのは以下。
1. 画面ごとにmysqlと連携させる。
2. HTMLテンプレートを指定して画面表示。
3. メールを飛ばす。

以下要点だけ記載。

####API宣言
GETならこう

```
router.get('/', function(req, res, next) {
・・・
}
```

POSTならこう

```
router.post('/', function(req, res, next) {
・・・
  {
    最終結果の返却
    res.end();
  }
とりあえず200を返却
res.status(200);
}
```
※注意点はPOSTの場合は非同期で最終結果を返す場合でも、いったん同期的には200を返して
非同期の最終結果でres.end()でクローズする。

####mysqlとの連携
dbコネクション

```javascript
var connection = mysql.createConnection({
  host     : 'localhost',
  user     : 'xxxxxxx',
  password : 'yyyyyyy',
  database : 'zzzzzzz'
});
connection.connect(function(err) {
  if (err) {
    console.error('error connecting: ' + err.stack);
    return;
  }
   console.log('connected as id ' + connection.threadId);
});

・・・ここで処理を行う。

用が済んだらコネクション切断
connection.end(function() {});
```

select

```javascript
connection.query({
  sql: 'select * from table',
  timeout: 40000, // 40s
}, function (error, rows, fields) {
  value = rows[0]['value']+1;
});
```

update

```javascript
connection.query({
  sql: 'update table set columns = true where x=?',
  timeout: 40000, // 40s
  values: [req.body.hash]
}, function (error, rows, fields) {
  if(error) {
    console.log(error);
  } else {
    console.log('done.');
  }
});
```

insert

```javascript
var data = {
  column1: value1,
  column2: value2
};

connection.query("insert into table set ?", data, function(error,results,fields) {
  connection.end(function() {});
  if(error) {
    console.lo
  }
}
```

###db処理の同期
クエリーが非同期で実行されるので複数の実行終了後に行いたい場合はPromiseで。

```javascript
  var dbTask1 = new Promise(function(resolve, reject) {
    connection.query({
      sql: 'select * from table1',
      timeout: 40000, // 40s
    }, function (error, rows, fields) {
      value1 = rows[0]['value']+1;
      resolve();
    });
  });
  var dbTask2 = new Promise(function(resolve, reject) {
    connection.query({
      sql: 'select * from table2',
      timeout: 40000, // 40s
    }, function (error, rows, fields) {
      value2 = rows[0]['value']+1;
      resolve();
    });
  });

  Promise.all([dbTask1, dbTask2]).then(function () {
    ここに実行する処理をかく。
  }
```

###画面レンダリング
必要な情報をjsonで渡してHTMLテンプレートを指定する。

```javascript
var data = {
  data1: value1,
  data2: value2
};
res.render('[テンプレートファイル名].ejs', data);
```


テンプレートの中身

```html
<!DOCTYPE html>
<html>

<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <meta property="og:url" content="https://www.your-domain.com/your-page.html" />
  <meta property="og:type" content="website" />
  <meta property="og:title" content="Your Website Title" />
  <meta property="og:description" content="Your description" />
  <meta property="og:image" content="https://www.your-domain.com/path/image.jpg" />
  <link rel="icon" href="/images/favicon.ico" />
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
    integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
  <link rel="stylesheet" href="/css/common.css" crossorigin="anonymous">
  <link href="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.7.1/css/lightbox.css" rel="stylesheet">
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
    integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
    crossorigin="anonymous"></script>
  <script src="/js/lib/jquery.js"></script>
  <script src="/js/index.js" type="text/javascript"></script>

  <title>イベントサイトトップぺージ</title>
</head>

<body>
  <input type="hidden" id="fkdeveurl" value="<%= fkdeveurl %>" />
  <input type="hidden" id="workshopurl" value="<%= workshopurl %>" />
  <div id="fb-root"></div>
  <script async defer crossorigin="anonymous"
    src="https://connect.facebook.net/ja_JP/sdk.js#xfbml=1&version=v3.2"></script>

</body>
</html>
```

テンプレート上で置き換えるのは以下のようにかく。

```
<input type="hidden" id="fkdeveurl" value="<%= fkdeveurl %>" />
```

if文

```html
<% if(item2.reserve_id>0) { %>
<td style="min-width: 350px;"><span class="<%= item2.checkin %>"><a href="<%= item2.url %>"
      target="_blank"><%= item2.p_name %>(<%= item2.p_furi %>)様<br /><%= item2.c_name %>(<%= item2.c_furi %>)さん(<%= item2.gakunen %>)</a></span>
</td>
<% } else { %>
<td class="none">(空き)</td>
<% } %>
```


for文

```html
<% cad_time.forEach(function (item, key) { %>
<label class="control control--radio wd100">
  <span class="<%= item.attr %> time-label"><%- item.label %></span>
  <span class="zanseki"><%= item.zanseki %></span>
  <input type="radio" name="class_time" value="<%= item.value %>" <%= item.attr %> />
  <div class="control__indicator <%= item.attr %>"></div>
</label>
<% }); %>
```


###javascript側で画面遷移
routesのバックエンドAPIの戻り価でURLが含まれたJSONを返す。
JavaScript側でURLを変える。

```diff:バックエンド側
　最終的に返すもののみ。
  res.json({status: 0, next_url: nexturl});
  res.end();
```

```diff:javascript側
$.ajax({
  type:"post",
  url:"/fukudaya-event",
  data:JSON.stringify(data),
  contentType: 'application/json',
  dataType: "json",
  success: function(json_data) {
    location.href=json_data.next_url;
  },
  error: function() {
  },
  complete: function() {
  }
});
```
または画面レンダー時にhiddenにあらかじめ隠しセットしておきJavaScriptで取得してそのまま遷移させる方法もとれる。これだと通信が1往復減らせる。

###メール送信

```javascript
//メールテンプレートを開く
var mailtemplate = fs.readFileSync(require("app-root-path")+"/data/mail.txt", {encoding: "utf-8"});
var nexturl = '/yoyaku-result?key='+hash;
let time = class_time.filter(function(item, index){if (item.value == req.body.class_time) return true;})[0];
var mailbody = mailtemplate.replace("$p_name$", req.body.p_name)
  .replace("$p_kana$", req.body.p_kana)
  .replace("$c_name$", req.body.c_name)
  .replace("$c_kana$", req.body.c_kana)
  .replace("$class_name$", time.label2)
  .replace("$class_time$", time.label3)
  .replace("$url$", 'https://fan-technology.com:'+define.PORT+nexturl)

//メールの内容
var mailOptions = {
  from: 'xxxxxxx',
  to: req.body.email,
  subject: 'cccccccc',
  text: mailbody
};

//SMTPの接続
var smtp = mailer.createTransport({
  host: 'localhost',
  port: 587,
  ssl: false,
  tls:{
    rejectUnauthorized: false
  },
  use_authentication: true,
  user: "aaaaaa",
  pass: "bbbbbb"
});

//メールの送信
smtp.sendMail(mailOptions, function(err, res){
  if(err){
    console.log(err);
  }else{
    console.log('Message sent: ' + res.message);
  }
  smtp.close();
});
```
メールテンプレートは普通のテキストファイル。可変部分を置換して作成して送信する。


###qrコード生成

```javascript
var qr = require('qr-image');
var fs = require('fs');

(中略)

var sha512 = crypt.createHash('sha512');
sha512.update('[シード]');
var hash = sha512.digest('hex');

var code = qr.image(url+hash, { type: 'png'});
var qrfile = require('crypto').randomBytes(8).toString('hex')+'.png';
code.pipe(fs.createWriteStream(require("app-root-path")+'/public/qr/'+qrfile));
```
ここでは受付用の予約ごとのユニークなURLを生成するのにハッシュ生成も行っている。
QRコードに埋め込む情報ができたら生成してファイルに出力して再利用できるようにここではしている。


###入力バリデーション
今回はjQueryのライブラリを使ってクライアント側で行った。
[jQuery Validation Plugin](https://jqueryvalidation.org/)

専用のライブラリをインクルードする。

```html:viewsファイル側
<script src="/js/lib/jquery.validate.js"></script>
・・・
```
細かいところはリファレンスを見てほしいが、ざっくり以下の感じ。
submitするフォームに対してバリデートオブジェクトを当てはめる。
オブジェクトにはチェックする項目と内容、エラー時のメッセージ、
成功時のハンドラ、エラー時の表示処理を実装する。

```javascript:バリデート処理
$('#mainform').validate({
  errorElement: "span",
  errorClass: "validate-err",
  rules: {
    item: {
      required: true,
      maxlength: 50
    },
    ・・・
  },
  messages: {
    item: {
      required: "保護者のお名前を入力してください。",
      maxlength: "50文字以内で入力してください。"
    },
    ・・・
  },
  submitHandler: function() {
    submitReserve();
  },
  invalidHandler: function(event, validator) {
    for(key in validator.errorMap) {
      console.log(key);
      $('#'+key).addClass('validate-err-input');
    }
  },
  errorPlacement: function (err, element) {
    switch(element.attr("name")) {
    case "item":
      err.insertAfter("#time-label");
      break;
    default:
      element.after(err);
      break;
    }
  }
}); 
```

###スマートなアラートウィンドウ
デザインとアニメーションに心打たれて、これを使った。
[sweetalert](https://sweetalert.js.org/guides/)

専用のライブラリをインクルードする。

```html:viewsファイル側
  <script src="https://unpkg.com/sweetalert/dist/sweetalert.min.js"></script>
・・・
```

```javascript:アラート表示処理
成功時ダイアログ
swal("受付完了", json_data.message, "success");

エラー時アラート
swal("エラー発生", "申し訳ありません。時間をおいてから再度行ってください。", "error");
```



以上です。