LoginSignup
10
7

More than 5 years have passed since last update.

Node.jsでMongoDBを操作するときにawaitを使う

Last updated at Posted at 2018-06-15

はじめに

登録処理を作る場合、普通に考えると以下のような処理が必要になります。

  1. すでに登録されているかの確認(今回はメールアドレスで一意性を確保する)
  2. 登録されていなければ新しく登録

今回はこれをNode.jsでMongoDBに対して行う場合の検討です。
なお、コード全体は以下に置きました。

とりあえず書いてみた

というか、Node.jsもMongoDBも不慣れなので、とりあえずサンプルとか見ながら以下のように書いてみました。
なお、Expressなのは元ネタがWebサーバだからとかawaitにつなげるためとかの理由です。

index.js抜粋
app.post('/regist', function(req, res) {
    const name = req.body.name;
    const mail = req.body.mail;

    MongoClient.connect(url, function(err, client) {
        if (err) {
            console.log(err);
            res.status(500).send();
            return;
        }
        const db = client.db(dbName);
        const collection = db.collection(collectionName);
        collection.findOne({mail: mail}, function(err, result) {
            if (err) {
                console.log(err);
                res.status(500).send();
                client.close();
                return;
            }
            if (result) {
                res.render('regist', {action: req.path, message: 'already registered'});
                client.close();
            } else {
                collection.insertOne({name: name, mail: mail}, function(err, result) {
                    if (err) {
                        console.log(err);
                        res.status(500).send();
                        client.close();
                        return;
                    }
                    res.render('regist', {action: req.path, message: 'registration is success'});
                    client.close();
                });
            }
        });
    });
});

素敵なコールバック地獄_(´ཀ`」 ∠)_
エラー処理で同じこと何回も書いている(コピペしている)のが非常にいけていません。そもそも初めに接続できない以外のエラーって起こるのかな。まあfindの直前にMongoDBサーバ止めたとかありえるけど。
MongoClientオブジェクトについても、GC時にcloseされるのだろうけど、使い終わったらちゃんとcloseしておく、とすると同じコードが複数個所に出現してしまい微妙です。

Promiseで書き換えてみた

コールバックなどという過去の遺物ではなく、Promiseでナウく書けないのか、とドキュメントを見てみたら「コールバック渡されてないとPromiseオブジェクト返します」とあったので、「これで、勝てる!」とPromise使うように書き換えてみました。

index.js抜粋
app.post('/regist-promise', function(req, res) {
    const name = req.body.name;
    const mail = req.body.mail;

    let client;
    let collection;
    MongoClient.connect(url)
    .then(function(client_) {
        // 1番目に実行
        client = client_;
        const db = client.db(dbName);
        collection = db.collection(collectionName);
        return collection.findOne({mail: mail});
    })
    .then(function(result) {
        // 2番目に実行
        if (result) {
            res.render('regist', {action: req.path, message: 'already registered'});
        } else {
            collection.insertOne({name: name, mail: mail})
            .then(function(result) {
                // 4番目に実行
                res.render('regist', {action: req.path, message: 'registration is success'});
            })
            .catch(function(err) {
                console.log(err);
                res.status(500).send();
                if (client) client.close();
            });
        }
    })
    .then(function() {
        // 3番目に実行
        if (client) client.close();
    })
    .catch(function(err) {
        console.log(err);
        res.status(500).send();
        if (client) client.close();
    });
});

インデントは減ったけど、どうにもまだ読みにくい。
私が不慣れなのもありますが、

  • thenは改行して置くべきかPromise返す呼び出しに続けて書くべきか。catchと合わせると改行すべきに思うがピリオドが頭にあるのがどうにも落ち着かない
  • 他のthenで使うためにclientとconnectionを前方宣言しているのが超ダサい
  • 登録がない場合のみ行われる処理があるのでthenがネストしている。それはいいのだがcatchがコピペでダサい

ちなみにthenの中身ですが、デバッガで確認するとコメントで書いた順番で呼び出されます。closeされる前にinsertOneは終わってる?処理中のオペレーションがあるからclose待たされる?と気になりだすとドキドキします。

await使ってみた

さて、というわけでやっと本題。Promiseもいまいちだったので、await使ってみようと思って書き換えてみました。

index.js抜粋
app.post('/regist-await', async function(req, res) {
    const name = req.body.name;
    const mail = req.body.mail;

    let client;
    try {
        client = await MongoClient.connect(url);
        const db = client.db(dbName);
        const collection = db.collection(collectionName);
        const user = await collection.findOne({mail: mail});
        if (user) {
            res.render('regist', {action: req.path, message: 'already registered'});
        } else {
            await collection.insertOne({name: name, mail: mail});
            res.render('regist', {action: req.path, message: 'registration is success'});
        }
    } catch (err) {
        console.log(err);
        res.status(500).send();
    } finally {
        if (client) client.close();
    }
});

うむシンプル。ポイントはExpressに渡しているコールバックにasyncを付けることです。付けられるのか?(付けてちゃんと動作するのか?)と気になりましたがasyncを付けても問題なく動作するようです。

awaitを使うとエラー処理も一箇所のみ、closeも確実にinsertOneが終わった後に行われます。
やはり同期処理っていいですね。人類に非同期処理は早すぎたのだ(主語が大きい)

まとめ

今回は「登録チェック」→「なければ登録」という処理を書く方法としてコールバック→Promise→awaitと改善してきました。awaitを使うとコードが非常にシンプルになりました。

以上、と言いたいところなのですが、どうにも気になっていることとして、そもそもMongo素人だからこんな苦労してるのではないか、もっとエレガントに「登録チェック&なければ登録」が書けるのではないかと気になっています。「こうすれば書けるよ」という方法がありましたら是非ご教示願いたいです。
Node.js的に、findとinsertの間に別のfindが入ることがありえる(つまりチェックはパスして重複insertされてしまう)のかも気になります。

後日談その1:insertだけで重複チェックもする方法

まとめの余談で書いたことについて、「そもそもメールアドレスで一意性って言ってるのなら、メールアドレスを_idにすれば重複エラーが起こるんじゃね?」と思ったのでやってみました。

まずはshellで実験。

> use mydb
switched to db mydb
> db.user.insertOne({_id: "foo@example.jp", name: "foo"})
{ "acknowledged" : true, "insertedId" : "foo@example.jp" }
> db.user.insertOne({_id: "foo@example.jp", name: "foo"})
2018-06-18T00:00:18.479+0900 E QUERY    [thread1] WriteError: E11000 duplicate key error collection: mydb.user index: _id_ dup key: { : "foo@example.jp" } :
WriteError({
        "index" : 0,
        "code" : 11000,
        "errmsg" : "E11000 duplicate key error collection: mydb.user index: _id_ dup key: { : \"foo@example.jp\" }",
        "op" : {
                "_id" : "foo@example.jp",
                "name" : "foo"
        }
})

いけそう。

というわけでメールアドレスを_idに使うようにプログラムを書き換えてみる。

メールアドレスを_idに(コールバック版)
app.post('/regist', function(req, res) {
    const name = req.body.name;
    const mail = req.body.mail;

    MongoClient.connect(url, function(err, client) {
        if (err) {
            console.log(err);
            res.status(500).send();
            return;
        }
        const db = client.db(dbName);
        const collection = db.collection(collectionName);
        collection.insertOne({_id: mail, name: name}, function(err, result) {
            if (err) {
                console.log(err);
                if (err.code == 11000) {
                    res.render('regist', {action: req.path, message: 'already registered'});
                } else {
                    res.status(500).send();
                }
            } else {
                res.render('regist', {action: req.path, message: 'registration is success'});
            }
            client.close();
        });
    });
});
メールアドレスを_idに(Promise版)
app.post('/regist-promise', function(req, res) {
    const name = req.body.name;
    const mail = req.body.mail;

    let client;
    MongoClient.connect(url)
    .then(function(client_) {
        client = client_;
        const db = client.db(dbName);
        const collection = db.collection(collectionName);
        return collection.insertOne({_id: mail, name: name})
    })
    .then(function(result) {
        res.render('regist', {action: req.path, message: 'registration is success'});
    })
    .catch(function(err) {
        console.log(err);
        if (err.code == 11000) {
            res.render('regist', {action: req.path, message: 'already registered'});
        } else {
            res.status(500).send();
        }
    })
    .then(function() {
        // このthenはエラーが起きても呼ばれる
        if (client) client.close();
    });
});
メールアドレスを_idに(await版)
app.post('/regist-await', async function(req, res) {
    const name = req.body.name;
    const mail = req.body.mail;

    let client;
    try {
        client = await MongoClient.connect(url);
        const db = client.db(dbName);
        const collection = db.collection(collectionName);
        await collection.insertOne({_id: mail, name: name});
        res.render('regist', {action: req.path, message: 'registration is success'});
    } catch (err) {
        console.log(err);
        if (err.code == 11000) {
            res.render('regist', {action: req.path, message: 'already registered'});
        } else {
            res.status(500).send();
        }
    } finally {
        if (client) client.close();
    }
});

いずれもシンプルになりましたね。
まあそれでもawait版が一番わかりやすいかな。

後日談その2:接続の共有

その後いろいろ調べてみるとリクエストごとにMongoに接続しないで一回張った接続を使いまわしているような例も見かけたのでそのように書き換えてみました。

接続使いまわし
app.post('/regist', function(req, res) {
    const name = req.body.name;
    const mail = req.body.mail;

    collection.insertOne({_id: mail, name: name}, function(err, result) {
        if (err) {
            console.log(err);
            if (err.code == 11000) {
                res.render('regist', {action: req.path, message: 'already registered'});
            } else {
                res.status(500).send();
            }
        } else {
            res.render('regist', {action: req.path, message: 'registration is success'});
        }
    });
});

これならPromiseやawait使わなくても十分シンプルですね(というかPromiseやawait使ってもあまり変わりはありません)

collectionを取得するところは以下のような感じ。clientをcloseしないのが気になりますがまあプログラム止めればサーバ側でも接続破棄するだろうし問題ないのでしょう。

接続取得
MongoClient.connect(url, function(err, client) {
    if (err) {
        console.log(err);
        return;
    }
    const db = client.db(dbName);
    collection = db.collection(collectionName);
    app.listen(3000, function() {
        console.log('server started');
    });
});

後日談を踏まえてのまとめ

結局、やり方を工夫すればコールバック地獄にもならないので「あれ?await使わなくてもよくね?」となってしまいましたが(笑)、本当に一回の処理単位中に複数のデータ操作が必要でシーケンシャルに実行する必要がある場合はawaitを使うとシンプルにできると思います。
「シーケンシャルに」って書いたのはデータ操作を並列実行できるのならPromise.allを使った方がいいだろうなと思ったからなわけですが、まだまだNode.jsもMongoDBも理解が浅いですね(まとめになっていない)

10
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
7