MongooseでのCRUDと謎挙動

const Mongoose = require("mongoose");
const Schema = Mongoose.Schema;
Mongoose.connect("mongodb://localhost:27017/sample");
const Person = Mongoose.model("person",  // <-この"person"が後で問題になる
    new Schema({
        created: {type: Date, default: Date.now},
        name: String
    })
);
export {Person};

こんな風に適当なPersonスキーマを作って、

const Express = require("express");
import {Person} from "./Models";
const APIRoute = Express.Router();
APIRoute.post("/person", (req, res) => {
    let upsertPerson = new Person(req.body);
    Person.findOneAndUpdate(
        {_id: upsertPerson._id},
        upsertPerson,
        {upsert: true},
        (error) => { /*割愛。*/ res.sendStatus(200);}
    );
});

APIRoute.delete("/person", (req, res) => {
    let deletePerson = new Person(req.body);
    deletePerson.remove((error) => { /*割愛。*/ res.sendStatus(200);});
});

APIRoute.get("/person", (req, res) => {
    Person.find().limit(10).sort({_id: -1})
        .exec((error, list) => {/*割愛。*/ res.send(list);});
});

export default APIRoute;

expressの本体は以下。

const Express = require("express");
const bodyParser = require("body-parser");
import APIRoute from "./routes/APIRoute";
app.use(bodyParser.json());
app.use("/api", APIRoute);
app.listen(8080);

送信側は、Reactのコンポーネントの中から抜粋。

//前略
    changePerson(person, remove) {
        const method = remove ? "del" : "post";
        SuperAgent[method]("/api/person")
            .set("Content-Type", "application/json")
            .send(person)
            .end((err, res) => {
                if (res.statusCode == 200) {
                    this.loadList();
                } else {
                    this.setState({error: err});
                }
            });
    },

    loadList() {
        SuperAgent.get("/api/people")
            .end((err, res) => {
                if (res.statusCode == 200) {
                    this.setState({list: res.body});
                } else {
                    this.setState({error: err});
                }
            });
    },
//後略

これでちゃんとデータのCRUDができますが。。。謎挙動が一つ。こんだけ"person"を連呼しているのに、Mongo上に作られるCollectionの名前が複数形の"people"になる!これが仕様なのですが、わざわざ何でこんなことをするのか、mongooseの実装ポリシーがわからない。あまりにも謎すぎてググったら、やはり皆が頭を悩ませていました。例えば"Activity"を指定したら"Activities"になるし、単数形と複数形が同じ単語の場合は変換されないという無駄に芸が細かい。

ということで、冒頭のスキーマコードではCollection名を設定する以下の箇所を正すべき?正さないべき?直さなくても動きますし、mongoの実際のコレクション名と設計は別のことですけどね。直さない方がいいのかなぁ。

const Person = Mongoose.model("people", // <-"person"を"people"に変更?しなくても良い

material-uiのi18n対策のひとつ

Material Designを実現するReactコンポーネントセットであるmaterial-uiは有用な上で、ソースを読んでも大変参考になるものが多くあります。中でもDatePickerのコードはReactでの動作エフェクトの作り方の他、コンポーネントを組み合わせてコンポーネントを作るというReactの基本的なことまで、多様なテクニックの良質なサンプルともなってます。しかしそんなmaterial-uiもL10nについては手が付いてません(v0.14.2現在)。

https://github.com/callemall/material-ui/blob/v0.14.2/src/utils/date-time.js

  warning(locale === 'en-US',
    'Wrong usage of DateTimeFormat. The ' + locale + ' locale is not supported.');

上記リンクの日付フォーマット関数を見れば様子がわかろうというもの。おいおい中身は"en-US"一択かよ。つまりは一応i18nのきっかけは作ってくれてるけど、中身の実装が追いついていないようです。コア開発者は昼間の仕事としてやってる風で日々の進化も早く、磨かれていってる感が週単位ぐらいで見て取れるのでそのうち対応するのでしょう。

こちらはなんちゃってで日本語対応だけしてみました。中国語と英語はライブラリに丸投げです。

const moment = require("moment");
require('moment/locale/ja');
require('moment/locale/zh-tw');

// material-uiのDatePickerコンポーネントの表示を国際化する
function MomentFormat(locale, options) {
    this.format = function(date) {
        var format1 = "ddd, MMM DD";
        var format2 = "MMMM YYYY";
        var format3 = "MMM/DD/YYYY";
        if(locale && locale.startsWith("ja")) {
            format1 = "M月D日, dddd";
            format2 = "YYYY年 M月";
            format3 = "YYYY年M月D日(ddd)"
        }
        let m = moment(date);
        m.locale(locale);
        // 将来への意欲は感じるが、現時点は無意味なmaterial-uiのoptions!
        if(!options) {
            return m.format(format3);
        } else if (options.month === 'short' &&
            options.weekday === 'short' &&
            options.day === '2-digit') {
            return m.format(format1);
        }
        return m.format(format2);
    };
}

export default MomentFormat;

こんなのを書いて、次のように使う。formatDateはプレースホルダ用でDateTimeFormatとlocaleは入力ダイアログ用。

<DatePicker autoOk={true} wordings={{cancel: this.state.captionCancel}}
                      formatDate={new MomentFormat(this.state.locale).format}
                      DateTimeFormat={MomentFormat} locale={this.state.locale}/>

で、こうなる。ここまでやってもダイアログの最上部の年表示は仕込めず数字だけ。「2016年」と年だけの表示でもi18nすべきことは米国人には想像できなかったか。

f:id:masataka_k:20160125054915p:plain

日付のL10nでmoment.jsを使ってますが、こちらのキモはロケールの読み込み方法です。

require('moment/locale/ja');

これをやらないとmoment.locale()が空っぽの関数のまま。requireして初めてlocale("ja")が効くようになります。アプリケーションとして対応するロケール毎に同じことをやってあげれば良いのですね。私は全く読めませんがアラビア語などもmoment.jsは対応しています。

追記 v.0.14.3

さて、後日(1/27)にmaterial-uiをv.0.14.3にアップデートしたらDatePickerもちょっと進化しました。

f:id:masataka_k:20160128152944p:plain

なんと!曜日がi18nされた!ただし相変わらずen-US以外へのL10nはされてない。そのため、以下に書き直し。"narrow"なるOptionが増えてましたので、そちらに対応しました。

"use strict";
const moment = require("moment");
require('moment/locale/ja');
require('moment/locale/zh-tw');
function MuiDateFormat(locale, options) {
    this.format = function(date) {
        var format1 = "ddd, MMM DD";
        var format2 = "MMMM YYYY";
        var format3 = "MMM/DD/YYYY";
        if (locale && locale.startsWith("ja")) {
            format1 = "M月D日 dddd";
            format2 = "YYYY年 M月";
            format3 = "YYYY年M月D日(ddd)"
        }
        let m = moment(date);
        m.locale(locale);
        // ガンバレ、その意気だ!
        if (!options) {
            return m.format(format3);
        } else if (options.month === 'short' &&
            options.weekday === 'short' &&
            options.day === '2-digit') {
            return m.format(format1);
        } else if (options.month === 'long' &&
            options.year === 'numeric') {
            return m.format(format2);
        } else if (options.weekday === 'narrow') {
            return m.format("dd");
        }
    };
}
export default MuiDateFormat;

Reactコンポーネントをファイル分割したら沈黙してしまった件

Reactで大きな画面を作るうちに、人の習性としてReactコンポーネントをファイル分割したくなります。

var React = require('react');

var Item = React.createClass({
    render: function () {
        return <li value={this.props.value}>{this.props.text}</li>;
    }
});

var List = React.createClass({
    render: function() {
        var items = this.props.data.map(function(i) {
            return <Item {...i}/>
        });
        return (
            <ol>{items}</ol>
        );
    }
});

var data = [
    {text: 'One', value: 1},
    {text: 'Four', value: 4},
    {text: 'Five', value: 5},
    {text: 'Seven', value: 7}
];

var ReactDOM = require('react-dom');
ReactDOM.render(<List data={data}/>, document.getElementById('List'));

と、まあこんなサンプルがあったとして、気持ち悪いのはコンポーネントの宣言記述と、その利用が同じファイルでやってること。Reactの説明で多くの場合はコンポーネントの宣言直下でReactDOM.render()を行ってますが、まあ実践では違う作りになるんじゃないかなと。で、以下みたいにするとします。

// List.jsx
var React = require('react');

var Item = React.createClass({
// 省略
});

module.exports = List = React.createClass({
// 省略
});
  • 描画部の別ファイル
var data = [
// 省略
];

var ReactDOM = require('react-dom');
var List = require('./List.jsx');
ReactDOM.render(<List data={data}/>, document.getElementById('List'));

汎用たるコンポーネントと、個別たる描画利用部が分かれていい感じ。requireもreactとreact-domがそれぞれで、なるほどこれがデザインかと。しかしこれらをBrowserifyでまとめたらさっきまで動いていたのに沈黙してしまいました。

しばらく嵌るうちにわかったのは、Listコンポーネントがexportされているけど、その中で使われているItemコンポーネントがexportされていないから。全部同じファイルだったら見えるし、描画-List-Itemと三つのファイルに分割する(もちろんItemも適切にexportする)のならば動きます。ただし描画部で、ReactDOM.render()の引数に渡しているJSXがTranspileした結果、React.createElement(List, {data: data})と変換されているので、Reactをコード上でてこなくてもrequireしなくちゃならなそうだったりと新たな痒さも。

コンパイラ言語だったり、JavaScriptでもクロージャーみたいに、スコープ見えるはずと思っていてもBrowserifyによる力技なコピーかき集め作業の前には通用しなかった。Reactコンポーネントはファイル毎にひとつづつってのがReact-Lintも警告するような常識だったようですが、かといって細かいファイルたくさんは作りたくない。。。はてさて。

document.querySelector

Reactコンポーネントをページに適用する際に、たったこれだけのためにjQueryを入れたくもなかったので、document.getElementByClassNameや〜Idでエレメント取得していましたが、単純名だけかい!という課題に私が知らなかっただけですでに高度な回答がありました。document.querySelector(CSSセレクター式)。

そもそもにCSSセレクターを叩くメソッドがあったのね。ブラウザ適用状況も、大体モダンなやつは行けてます。ただ残念なのはドキュメントの注釈に、getElementsByClassNameや〜Idの方が早いよーと明言されていること。結局は一周して元に戻る。

PassportによるFacebook認証(続いてDBへ保存)

Passportでの認証を実践的に進めてみます。認証情報を永続化するのにMongo DBを利用します。はじめだけMongo DBのJSドライバをそのまま使って書いてましたが、コードブロックをtry-finallyで囲って最後にDB接続を解放するような書き方など2000年ぐらいにJava出始めでJDBCで接続ガーと言ってる頃を思い出しました。懐かしいのは良いことばかりでもなく、何事もイージーにライトでやりたい現代のスピード感にそぐわないのですぐにMongoose通じてアクセスするように。Mongooseを選んだのは単に軽くググったらたくさん記事が出てきたのでメジャー感あったためです。アーキテクチャはいわゆるActive Record的なアレですね。機能は十分に豊富ですが、とは言えシンプルです。

var Mongoose = require('mongoose');
Mongoose.connect('mongodb://localhost:27017/buzzw');
var Account = Mongoose.model('accounts', new Mongoose.Schema({
    provider: String,
    id: String,
    displayName: String,
    accessToken: String,
    lastLogin: Date
}));

var Passport = require('passport');
var Facebook = require('passport-facebook');
Passport.use('fb', new Facebook.Strategy({
        clientID: '1513567018936647',
        clientSecret: '2596-xxxxxxxxxsecretxxxxxxx-d126',
        callbackURL: "http://localhost:8080/auth/facebook/callback"
    },
    function(accessToken, refreshToken, profile, done) {
        Account.findOne({id: profile.id}, function(err, account) {
            if(err) {
                return done(err);
            }
            if(!account) {
                // Active Record的なオブジェクトのコンストラクタにドメインオブジェクトを渡す
                account = new Account(profile);
            }
            account.accessToken = accessToken;
            account.lastLogin = new Date();
            account.save(function(err) {
                return done(err, account);
            });
        });
    }
));

Passport.serializeUser(function(user, done) {
    done(null, user._id);
});

Passport.deserializeUser(function(obj, done) {
    Account.findOne({_id: obj}, function(err, account) {
        done(err, account);
    });
});

var Express = require('express');
var app = Express();
app.disable('x-powered-by');
app.use(Passport.initialize());
app.use(Passport.session());
app.get('/auth/facebook', Passport.authenticate('fb'));
app.get('/auth/facebook/callback', Passport.authenticate('fb',
    {failureRedirect: '/index.html', successRedirect: '/success.html'}));
app.use(Express.static('public'));
app.listen(8080);

Active Record的なオブジェクトを作るのにSchemaを引数に渡しますが、こちらはnew付きの呼び出ししか対応していなかったので注意。最近の多くはnewをつけてもつけなくても同じ動きをするように一手間費やされているライブラリが多いのですが、ここにはそれがなく。 新規時にこのオブジェクトを作る時、つまりレコードをinsertする時にはFacebookから得られたドメインオブジェクトたるユーザー情報(profile)を渡してあげると、そのまま綺麗にDBへ格納してくれます。insertもupdateもコード的には区別なくいい感じ。

Mongooseでちょっと気持ち悪かったのは、connectで返すのがDBインスタンスではなくSocketで、インスタンスはMongoose.connectionというプロパティに入ってるってことかな。例外処理はDomain使った風に以下のように宣言します。

var db = Mongoose.connection;
db.on('error', function(err) {
    console.error(err);
});

APIユースケースとしては、connect呼び出し時にインスタンス返せばいいのにね。

x-powered-byを消す

悪意ある攻撃への対策として、Responseのヘッダから'x-powered-by'を削ると良いという。

Expressでは何もしなければ、サーバ実装を示すこのヘッダ値に'Express'と正直に返してしまいます。そうなると悪い人が「そうかNodeでExpress使ってるのね」とすぐわかってしまうので攻撃前の一手間が省かれてしまうという。いや、しかし、Node+Expressは一定以上使われてるメジャーなプラットフォームだと思うので悪意ある人の前では無駄な努力だとは思うけど...とりあえずおまじないとしてやっておく方がより良いということでしょう。

var app = express();
app.disable('x-powered-by');

これだけ。

そういえば、Node5.1.0にアップデートされていました。$sudo n stableではまだ対応されてなかったので$sudo n 5.1.0で上げました。npmは3.5.0です。

PassportによるFacebook認証(第一歩)

var Passport = require('passport');
var Facebook = require('passport-facebook');
// Facebook Strategyの設定を'fb'で登録。省略すると'facebook'で設定される
Passport.use('fb', new Facebook.Strategy({
        clientID: '1513567018936647',
        clientSecret: '2596-xxxxxxxxxsecretxxxxxxx-d126',
        callbackURL: "http://localhost:8080/auth/facebook/callback"
    },
    function(accessToken, refreshToken, profile, done) {
        // 2)Facebook認証のVerifyイベント
        console.log('Passport.verify');
        return done(null, profile);
    }
));
Passport.serializeUser(function(user, done) {
    // 3)セッションへ認証ユーザーを保存
    console.log('Passport.serializeUser');
    done(null, user);
});
Passport.deserializeUser(function(obj, done) {
    console.log('Passport.deserializeUser');
    done(null, obj);
});

var Express = require('express');
var app = Express();
app.use(Passport.initialize());
app.use(Passport.session());
// 1)認証始め
app.get('/auth/facebook', Passport.authenticate('fb'));
// 4)コールバック
app.get('/auth/facebook/callback',
    Passport.authenticate('fb', { failureRedirect: '/index.html'}),
    function(req, res) {
        console.log('success login');
        res.redirect('/success.html');
    }
);
app.use(Express.static('public'));
app.listen(8080);

Passportを利用してFacebookで認証するのに、ハマリどころは満載ですがコード自体はシンプルに書けます。シンプルにそぎ落としてみて最低限必要なことは以上の通りになりました。一見いらなそうなものも削るとエラー出ます。基本構造としては、1)'/auth/facebook'へのルーティングに仕込まれている、passport.authenticateミドルウェアで認証を始めます。引数に'fb'を渡すことで先に設定したFacebookStrategyを利用することを宣言しています。そしてFacebookに移動してそちらのログイン処理を行う最中に2)Strategyが持つVerifyイベントハンドラがコールされてAccess Tokenが手に入ります。その後に3)Strategyに設定するハンドラで認証ユーザーをセッションへ保存します。

ここまでの処理が成功していると、コールバックが戻ってきます。3)こちらも同じpassport.authenticateミドルウェアで、成功失敗の別に応じてリダイレクト。この後に取得したAccess Tokenを利用してFacebookAPIをコツコツ呼び出すことになります。

設定としてpassportのinitializeミドルウェアとsessionミドルウェアルーター登録するのが必須ですし、認証ユーザーのシリアライズがないとエラーを出しました。

f:id:masataka_k:20151117084100p:plain

Facebook側では、コールバックURLを登録する必要があります。localhost:8080をサンプルでは用いてるので、それはそれとしてFacebookアプリ設定ページにそのまま書いておかないと、指定コールバックURLが既知では無いということですぐに認証エラーを出す、いわば正しい動きをします。