以下の技術を組み合わせてWebアプリケーションを作る際に2点問題に直面しました。
解決方法を残しておきます。
技術とバージョン
- AngularJS 1.4.x
- Node 4.4.x
- Express 4.14.x
- mysql 2.11.x
- MySQL 5.6.x
クライアントサイドはAngularJSでSPA、サーバーサイドはNode+MySQLでWebAPIという形で実装し、通信時のデータフォーマットはJSON形式です。
発生した問題
- DB挿入前はBooleanなのに取得後にNumberになる
- JSON経由すると日時が文字列になる
型の比較
実際の内容の前に、各技術間の型の比較を見ておきます。
基本的には自動的に相互変換されます。
真偽値 | 日付と時間 | |
---|---|---|
JavaScript | Boolean | Date |
JSON | true/false | string1 |
MySQL | TINYINT(1) | DATETIME2 |
発生した問題
DB挿入前はBooleanなのに取得後にNumberになる
MySQLはTINYINT(1)のカラムに対してtrue/falseの挿入/更新を問題なく実行できます。
INSERT INTO sample (bool_col) VALUES (true);
一方でドライバに依存する部分も多分にあると思いますが、利用したmysqljs/mysqlではそのまま数値として取得されます。
(TINYINT(1)をBOOL型として扱うオプションがあってもいいかもですね)
SELECT bool_col FROM sample; -- 1
結局以下のような流れになります。
AngularJS | JSON | Node | MySQL | |||
---|---|---|---|---|---|---|
Boolean | → | true/false | → | Boolean | → | |
TINYINT | ||||||
Number | ← | number | ← | Number | ← |
JavaScriptでは暗黙の型変換があるため、if文などでは特に困ることはないのですが、
AngularJSの方でチェックボックスなどへのバインドで問題が起きました。
というわけで、SELECTで取得したデータセット内のTINYINT(1)をBoolean型に変換する必要があります。
(JSONでもBoolean型は扱えるため取得時がベスト)
JSON経由すると日時が文字列になる
JSONには日時型が存在しません。
日時はISO-8601拡張形式3の文字列表現に変換されます。
(タイムゾーンは自動でUTCに直される。日本時間であれば、見た目上は9時間前の時間になる。)
AngularJS | JSON | Node | MySQL | |||
---|---|---|---|---|---|---|
Date | → | string | → | String | → | |
DATETIME | ||||||
String | ← | string | ← | Date | ← |
問題となったのは、MySQLにINSERT/UPDATEする際に、表現内のタイムゾーン(UTCであるZ)を無視してMySQLのタイムゾーンで格納されてしまうことでした。
その結果、登録/更新をするたびに、時間がどんどん9時間前にさかのぼってしまいました。
(MySQLのタイムゾーンはJSTにしていました)
Wed Aug 17 2016 18:00:00 GMT+0900 (JST)
↓ JSON.stringify()
'2016-08-17T09:00:00.000Z'
↓ req.body
'2016-08-17T09:00:00.000Z'
↓ INSERT/UPDATE
2016-08-17 09:00:00
↓ SELECT
Wed Aug 17 2016 09:00:00 GMT+0900 (JST)
↓ JSON.stringify()
'2016-08-17T00:00:00.000Z'
↓ $http.get()
'2016-08-17T00:00:00.000Z'
↓ new Date()
Wed Aug 17 2016 09:00:00 GMT+0900 (JST)
また文字列のままでも概ね問題なく動くのが厄介でしたが、<input type="datetime">
などでは、バインドする際に文字列のままだとエラーになります。
AngularJSで文字列のまま日時表示をすると、(当たり前ですが)UTCで表示されます。
(dateフィルターでタイムゾーンを指定すれば大丈夫ですが、そもそもDate型ならタイムゾーンの指定はいらない)
Booleanと違いJSONからデシリアライズするたびにDateに変換する必要があります。
解決方法
BooleanとTINYINT(1)
これは公式にも書かれていますが、typeCastに処理を設定します。
プールまたはコネクション作成時のオプションで指定できます。
var pool = mysql.createPool({
host : 'localhost',
user : '****',
password: '****',
database: '****',
typeCast: function(field, next) {
if (field.type === 'TINY' && field.length === 1) {
return field.string() === '1'; // '1' = true, '0' = false
}
return next();
}
});
Dateとstring
これはサーバーサイドとクライアントサイド双方で変換処理をする必要があります。
サーバーサイド(Node, Express, lodash)
JSON文字列をオブジェクトに変換するには、組み込みオブジェクトであるJSONオブジェクトが持っている、parseメソッドを利用するのが一般的です。
これは実は第2引数を取ることができて、パース後のオブジェクトに対して独自の変換処理を適用することができます。
JSON.parse(text[, reviver])
ドキュメントに書いてありますが、ネストの深いところから再帰的に関数を適用してくれるので、ISO-8601形式の文字列だったらDate型に変換という処理をしてやればいいことになります。
Expressでは、body-parserを設定するときのオプションでreviverを指定できます。
var express = require('express');
var bodyParser = require('body-parser');
var _ = require('lodash');
var app = express();
var ISO_DATE_REGEXP = /^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)$/;
app.use(bodyParser.json({
reviver: function(key, value) {
return (_.isString(value) && ISO_DATE_REGEXP.test(value)) ? new Date(value) : value;
}
}));
クライアントサイド(AngularJS)
AngularJSでも同じことをしてやりたいところですが、残念ながら簡単にはできません。
内部的に$httpが結果を受け取った時に、angular.fromJson()
でデシリアライズしてるようなんですが、fromJsonはreviverの指定はできない上に、それっぽいオプションも見当たりません。
代わりに、$httpProviderのtransformResponseに同様の処理をpushすることで追加します。
var ISO_DATE_REGEXP = /^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)$/;
function convertDate(obj) {
angular.forEach(obj, function(value, key) {
if (angular.isString(value) && ISO_DATE_REGEXP.test(value)) {
obj[key] = new Date(value);
} else if (angular.isArray(value) || angular.isObject(value)) {
convertDate(value);
}
});
}
angular.module('****').config(function($httpProvider) {
$httpProvider.defaults.transformResponse.push(function(data) {
convertDate(data);
return data;
});
});
まとめ
こういう時にきちんと型指定できる言語だと安心感がありますね。
JavaScriptは型にゆるい言語ですが、うまいことはめられました。
型には気をつけよう!