LoginSignup
16
11

More than 5 years have passed since last update.

Promiseとasync-awaitの話、Mongooseをasync-awaitで使う話

Last updated at Posted at 2017-10-21

話したいこと

  1. 非同期メソッド実行での苦痛
  2. Promise
  3. async-awaitの仕組み
  4. async-awaitでmongooseを扱う

特にmongooseを扱うときにハマったので、このことがメイン。
でも、それに至るまでにはいろんな概念を理解しなければならないので、道は長いという。

コールバック地獄がイヤな件について

Javascriptでは頻繁に現れるのがメソッドの引数に含まれるコールバック関数。
.NETとかではデリゲート、Javaでは関数インターフェースなんてものが出てくるけれども、Javascriptの依存具合はすさまじいところがあるわけで。
何かあるたびにJavascriptではコールバック関数を書かなければならない!
特にJavascriptでは非同期処理のコールバックとして使うことが多くて、バグの温床にもなるという、「なくてはならないけれども扱いが難しい」という、面倒極まりないものでして。

コールバック地獄!

こんな感じ.js
somefunction(param, function(){
        var a = 10;
        fuinction1(a, function(input,output){

            function2(function(result){
                console.log("コールバックのネスト多くね?");
            });
        });
    }
});

かなりの悪意を持ってサンプリを書いてみたけど、とにかく分かりづらくてしょうがない。

まずは説明のベースとするサンプルコードを。

説明に使うコードはこんな感じ。わざわざコールバックにする必要もないけれども……
Typescriptでのコードを前提としているけれども、このレベルであればJavascriptでもほとんど同じかな。

サンプルコード.ts
called(inputValue: number, calback: (result: boolean, error: Error | undefined) => void) {

    let result = false;
    let error = null;

    if (typeof indexValue == "number") {
        if (inputValue == 10) {
            result = true;
        } else {
            result = false
        }
    } else {
        error = new Error("Invalid inputValue");
    }

    callback(result, error);

}

main() {

    called(12, function(result, error) {

        if (error != null) {
            console.log("エラー");
        }

        if(result) {
            console.log("TRUE");
        } else {
            console.log("FAAAAAAAAAALSE");
        }

    });
}

ちなみに同期的なコードにするとこんな感じ。

サンプルコード.同期的.ts
called(inputValue) {
    var result = false;

    if (typeof indexValue == "number") {
        if (inputValue == 10) {
            result = true;
        } else {
            result = false
        }
    } else {
        throw new Error("Invalid inputValue");
    }

    return result;
}

main() {

    try {
        called(12);

        if(result) {
            console.log("TRUE");
        } else {
            console.log("FAAAAAAAAAALSE");
        }

    } catch(e) {
        console.log("エラー");
    }
}

ここに颯爽とPromiseがやってくる!

「コールバック地獄やめね?」という人々が世界中にいたらしく、Promiseという概念が登場したのがECMAScript 6(ECMAScript 2015)。

Typescriptの場合、tsconfig.jsonのCompilerOptions.libに es6 を指定しないと有効にならないので注意。

tsconfig.json
{
  "compilerOptions": {
    〜略〜
    "lib": [
      "es6"
    ]
  },
  〜略〜
}

Promiseを使用する場合の約束事はこの通り:

  1. 関数はPromise<T> オブジェクトを戻り値としなければならない。
  2. Promise<T>の初期化にあたり、(resolve: (value?: T | PromiseLike | undefined) => void, reject: (reason?: any) => void ) の関数を渡す。
  3. コールバック関数の引数resolveは処理の正常終了時に呼び出す。
  4. コールバック関数の引数rejectは処理の異常終了時に呼び出す。
  5. Promise<T>.then(value?: T | PromiseLike | undefined) => void)関数で、『次の処理』を実装する。
  6. Promise<T>.catch(reason?: any) => void)関数で、『エラーハンドリング』を実装する。

このルールのもとサンプルコードを改造するとこうなる:

サンプルコード.Promise.ts
called(inputValue): Promise<boolean> {
    return new Promise<boolean>((resolve, reject) => {

        let result = false;

        if (typeof indexValue == "number") {
            if (inputValue == 10) {
                result = true;
            } else {
                result = false
            }

                resolve(result);

        } else {

            reject(new Error("Invalid inputValue"));
        }
    });
}

main() {

    called(12)
    .then((value: boolean) => {

        if(value) {
            console.log("TRUE");
        } else {
            console.log("FAAAAAAAAAALSE");
        }

    })
    .catch((reason: any) => {

        console.log("エラー");
    })
}

まずはcalled関数から覗いてみる。変化点としては、

  • 関数の引数から callback がなくなった。
  • Promise<boolean> が関数戻り値になった。
  • indexValue がnumber型でない場合は Error をthrowしていたのが、reject関数の呼び出しになった。
  • indexValue の値判定の後、resolve関数が呼び出されている。

といったところ。
Promiseを使った実装の中では、いわゆる return XXX にあたるのが resolve(XXX) であり、 throw new Error(XXX) にあたるのが reject(XXX) になるというわけ。

で、called関数を呼び出すmain関数を見てみれば。

  • called関数から始まるメソッドチェーンでの実装になった。
  • then関数の中でcalled関数が正常終了した後の処理を実装している。
  • catch関数の中でcalled関数が異常終了した際の処理を実装している。

コールバック主体での実装ではコールバック内で正常時と異常時のハンドルをしていたけれど、Promiseを使用すると、

  • 正常終了時の処理 => then関数
  • 異常終了時の処理 => catch関数

となり、可視性が抜群に向上。

Javascript上での解説ではあるものの、Promiseが何なのかをより理解するには下のサイトが役に立ちます。

Promiseで簡単!JavaScript非同期処理入門【前編】
Promiseで簡単!JavaScript非同期処理入門【後編】

非同期だけど同期的? async-awaitの登場

Promiseは非常に便利なんだけれど、どうしてもthen関数とcatch関数のメソッドチェーンを続けることになるので、同期的なコードと比べると全然様子が違うわけで。

今度は「もっと同期的にコードをかけないのかしら? もっと楽に書けないかしら?」と考えるMicrosoftの人が現れてきたわけ。
その結果Typescriptにもたらされたのがasync-awaitの概念。

C#をやったことのある人なら知っているよね、そう、あのasync-await。それに近いものと思ってくれれば。

ルールはこんなところ:

  1. 非同期関数の宣言時にキーワード async を指定する。
  2. async を指定した関数はPromise<T>を戻り値としなければならない。
  3. async を指定した関数はTを return しなければならない。
  4. asyncを指定した関数はキーワード await と共に呼び出す ことができる
  5. 戻り値を受け取らない形で await を指定すると、当該関数が終了するまで待機する。
  6. 戻り値を受け取る形で await を指定すると、戻り値にTが返却される。

実際のコードを見てみるとこんなところ;

サンプリコー.async-await.ts
async called(inputValue): Promise<boolean> {
    let result = false;

    if (typeof indexValue == "number") {
        if (inputValue == 10) {
            result = true;
        } else {
            result = false
        }
    } else {
        throw new Error("Invalid inputValue");
    }

    return result;
}

/*
* awaitを使うパターン
*/
async main1() {

    try {
        let value = await called(12);

        if(value) {
            console.log("TRUE");
        } else {
            console.log("FAAAAAAAAAALSE");
        }
    }catch(error) {
        console.log("エラー");

    }
}

/*
* awaitを使わないパターン
*/
main2() {

    let val = called(12);

    val.then((value: boolean) => {

        if(value) {
            console.log("TRUE");
        } else {
            console.log("FAAAAAAAAAALSE");
        }

    })
    .catch((reason: any) => {

        console.log("エラー");
    })
}

まずは呼び出される側、called関数から。

  • asyncキーワードが追加された。
  • 例外は reject(new Error(XX)) ではなく、 throw new Error(XX) で実装している。
  • 関数の戻り値としてはPromise<boolean>だが、実際にreturnしているのはboolean。

戻り値の扱いがかなーり独特なものの、Promiseを使った時に比べると同期的なコードにきわめて近いかたちでの実装ができる。

そして呼び出し側については2種類のコードを用意してみた。main1関数はキーワードawaitを使ったもの、main2関数はawaitを使用していないもの。
ただ、main2関数はあっているかどうか分からないので、雰囲気を感じてもらえれば。

  • [main1]try-catchにて例外処理をしている。
  • [main1]変数valueはbooleanが格納されている。Promise<boolean>ではない。
  • [main2]変数valはPromise<boolean>が格納されている。
  • [main2]Promiseを処理した際のようなコード実装になる。

main1関数を見て欲しい、これ、非同期コードを呼び出しているんだぜ……
見たところ完全に同期的なコードで、awaitがasync関数calledに指定されているぐらい。可読性も同期コードと変わらず。
何しろ、非同期コードでよくあるどのタイミングでどのコードが実行されているか分からない、ということもない。だってawaitで処理を待っているから。awaitよりも後に続くコードはcalled関数が終了するまで実行されないことが保証されているわけ。

main2は普通はやらないと思うけれども、 await の意味を確認するためのもの。
キーワードawaitをつけると、Promise<T>のTが戻ってくるし、つけないとPromise<T>そのものが戻ってくる。なんて便利な。

そう、 Tytpescriptで非同期ならasync-await、これ大事

async-awaitでmongooseを扱う!

いよいよ本題、Typescriptでasync-awaitを使ってmongooseを操作していく。

mongooseの使い方の流れとしては、まず準備として

  1. コレクションのスキーマ、インターフェースを定義

実際使うときには、

  1. コネクション(ConnectionまたはMongooseThenable)を取得
  2. スキーマからモデルを取得
  3. モデルに対してクエリを実行する

1つずつ見ていきますよー。

サンプルデータベース

解説をするにあたってのサンプルデータベースはこの通り。

コレクションは一つ、sampleコレクション
sampleコレクションのドキュメント構造とデータは簡単に。

sampleコレクション.mongodb
{
    sample: [
        {
            _id: ObjectId(1),
            key: "あああああ",
            value: 11111
        },
        {
            _id: ObjectId(2),
            key: "いいいいい",
            value: 22222
        }
    ]
}

ちなmongoose関連のバージョンは以下で。

パッケージ バージョン
mongoose 4.11.11
@types/mongoose 4.7.21

コレクションのスキーマ、インターフェースの定義

Typescriptからmongooseを使う場合、コレクションを表すインターフェースと、実際のコレクションのスキーマを規定するSchemaを用意する。

コレクションとスキーマの用意.ts
import * as Mongoose from "mongoose";

interface ISample extends Mongoose.Document {
    key: string;
    value: number;
}

let sampleSchema = new Mongoose.Schema(
    {
        key : Mongoose.SchemaTypes.String,
        value: Mongoose.SchemaTypes.Number
    },
    {
        collection: "sample"
    }
);

今回のコードでは mongoose はプレフィクスMongooseでインポートしています。
sampleコレクションのインターフェース ISample はmongooseで定義されている Document インターフェースを継承する。
スキーマは Schema オブジェクトの初期化時に、ドキュメントの定義を第1引数に指定しています。第2引数の定義は各種設定、サンプルコードではこのスキーマがMongoDB上ではどのコレクション名になるのかを定義。

では、準備はできたのでTypescriptからMongoDBを操作してみよう。

MongoDBに対するコネクションを取得する。

始めに言ってしまうと、正直、コネクションの生成はよく分かっていないです……
なぜかというと、これから説明するコードは動作するものの、警告が出てしまうんですね。

Db.prototype.authenticate method will no longer be available in the next major release 3.x as MongoDB 3.6 will only allow auth against users in the admin db and will no longer allow multiple credentials on a socket. Please authenticate using MongoClient.connect with auth credentials.

mongooseのマニュアルには新しい接続方法の案内があるものの、Typescriptからはうまく動作がきかないようで、解決ができていないです。
だれか知っている人教えてーーーーーー

さて、と。
コネクションの取得方法については、ConnectionオブジェクトかMongooseThenableオブジェクトを取得する方法の2通りがあるわけですが、今回はConnectionを取得しようと思います。

コネクション取得.ts
let connectionInfo = Utils.GetConfig<IDbConfig>("mongoose");

let connection = Mongoose.createConnection(connectionInfo.server,connectionInfo.database,connectionInfo.port,{
    useMongoClient: true,
    user: connectionInfo.user,
    pass: connectionInfo.password,
    db : {
        native_parser: true
    },
    server : {
        poolSize: 5,
        socketOptions: {
            autoReconnect: true,
            noDelay: true
        }
    }
});

Utils.GetConfig<IDbConfig>("mongoose") はオリジナル実装の処理なので気にしないでOK。
今回接続に使用しているのは以下の情報。

  • サーバホスト
  • ポート
  • 接続先データベース名
  • MongoDBへの接続ユーザ名
  • MongoDBへの接続ユーザのパスワード

この情報を Mongoose.createConnection(サーバ,データベース名,ポート,接続設定) に吸い込ませてコネクションを取得するのだけれど。
引数に設定する接続設定がキモ。詳しい値はmongooseのリファレンスを見て欲しいのだが、最低限必要なのは以下のパラメータ

項目名 説明
useMongoClient MongoClientを使用して接続するかどうか*
user 接続ユーザ名
pass 接続ユーザのパスワード

*上で登場する警告を回避するためには useMongoClienttrue にしなさいと書いていて、実際設定しているのだけれども、どういうわけだか警告が出る……アイエエナンデ?

スキーマからモデルを取得

さて、コネクションを取得できたので、今度はモデルを取得しよう。モデルは各コレクションを操作するための窓口のようなもので、コレがないとMongoDBのCRUD操作ができないのですよ。

モデル取得.ts

let sampleModel = connection.model<ISample>("sample",sampleSchema,"sample");

コレクションモデルの取得は Connection.model<T>(コレクション名, スキーマ, MongoDB上のコレクション名) で行う。
ジェネリクスにコレクションのインターフェースを指定するのは、この後の操作、特にSELECTの際に指定したインターフェースのオブジェクトとして操作ができるようになるため。

クエリを投げてみよう

ここでasync-awaitの話がでてくる。
MongoDBに対する操作はいろいろとできるものの、今回はSELECT、MongoDB的にはfindを実行してみる。mongooseでの操作はリファレンスを見ればいいとして、今回はmongooseとasync-awaitについて説明できれば。

まず、async-awaitを使わないパターン、Promiseを使用したパターンで実装してみる。
(mongooseはイベント駆動で動くので、ほぼ完全に非同期。コールバック地獄は避けたいのでPromiseからの説明)

Promiseでmongoose操作.ts

findData(): Promise<void> {
    return new Promise<void>((resolve, reject) => {

        // コネクション取得、前述参照

        // モデル取得、前述参照

        let condition = {}; // find条件なし

        sampleModel.find(condition,(error: any, res: ISample[]) => {

            if (err) {
                reject(err)
            }

            console.log(res);
            resolve();
        })
    });
}

モデルオブジェクトの関数 find(条件,コールバック(エラー,取得結果[]) => void)を使ってMongoDBからデータを取得する。
実際にはfind関数を実行した段階ではクエリは発行されていなくて、mongooseが制御するタイミングで呼びだされて、コールバックが実行される。
実行した結果、エラーが発生したらerrにオブジェクトが格納され、resにクエリ結果が渡される。
あとは処理結果に応じてrejectやresolveを呼びだせばよい。まあPromiseを使うのならばこんな感じ。

では、async-awaitを使った場合。

async-awaitでmongoose操作.ts
async findData(): Promise<void> {

    // コネクション取得、前述参照

    //コレクションモデル取得、前述参照

    let condition = {}; // find条件なし

    try {
        let res = await sampleModel.find(condition).exec();

        console.log(res);

    } catch(err) {
        throw err;
    }
}

Promiseを使った時とは全くの様変わり。それでいてシンプルな実装。
ここではコールバックを使用しないfind関数を使用して、更にfind関数が返すオブジェクトの exec 関数を使用してクエリを実行させる。
で、そこまでをawaitさせることで ISample[] の配列を受け取る動き。
コールバック内で操作していたエラーはtry-catchで受け取る。
まさに同期的コード!

まとめ

async-awaitは素晴らしい、けど理解するのはめちゃんこ難しい!

参考リンク

Promiseで簡単!JavaScript非同期処理入門【前編】
Promiseで簡単!JavaScript非同期処理入門【後編】
What about Async/Await?
mongoose ODM

16
11
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
16
11