こまぶろ

技術のこととか仕事のこととか。

Node.jsのhttpsモジュールを用いた通信処理をPromiseで書き直して解読してみた

JS初心者がよくわからないまま書いたLambda

以前、このような記事を書きました。LINEのMessaging APIを用いた簡単なbotの実装の紹介記事です。実はこちらのブログで最も多くのブクマ(16ブクマ)を集めている記事となっています(2018.07.15現在)。

ky-yk-d.hatenablog.com

表題の通り、この記事を書いたのはJavaScriptを触り始めたころです。全体的に、見よう見まねでコーディングしていたのですが、中でも理解できていなかったのはhttpリクエストを送っている箇所です。この部分については完全にコピペ、意味も「あーなんか本文くっつけて送ってんだなー」くらいの理解でした。

記事の執筆から2ヶ月弱が経ち、この箇所について多少なりともわかってきたので、かつての自分と同レベルの方(来月くらいには自分もまたそこに戻っているかもしれない・・・)のためにメモを残しておくことにしました。httpリクエストを送信する部分を書き換えているので、そこを見ていきます。

書き換え(httpリクエスト送信部)

Before

書き換え前のコード

let https = require('https');
 
exports.handler = (event, context, callback) => {
// ---中略---
    var req = https.request(opts, function(res) {
        res.on('replyData', function(res) {
            console.log(res.toString());
        }).on('error', function(e) {
            console.log('ERROR: ' + e.stack);
        });
    });
    callback(null, replyData);
    req.write(replyData);
    req.end();
}

問題点/当時の理解レベル

  • requireは「httpsを使うためのおまじない」だった(Node.jsのhttpsを利用している、そしてそれがモジュールだという意識はなかった)
  • exportsを「なんか最初に実行されるやつでしょこれ」という認識だった(モジュールがわかっていないので外部公開も当然わかっていなかった)
  • on()でイベント登録をしている箇所で、bodyだったところをreplyDataに書き換えてしまっていた(イベントというものを理解していなかった)
  • 処理の途中でcallbackしていた(最後でなきゃいけないという決まりはないが、通信の結果を返すようにすべきだと今は思う)
  • callback()の第一引数がなぜnullなのかわかっていなかった
  • req.write()は「本文書き込んでいるんしょ」という認識(間違っていないけどWritable Streamということは意識していない)

After

書き換え後のコード

exports.handler = (event, context, callback) => {
// ---中略---
    let promise = sendRequest(opts, replyData).then((res)=>{
        console.log('---DONE---');
        console.log('typeof:', typeof(res));
        callback(null, res);
    },(err)=>{
        console.log('---ERROR---');
        callback(err, 'errorMsg' + err.stack);
    });
    console.log('typeof promise:', typeof(promise));
    console.log('promise:', promise);
}

// リクエスト送信部を切り出した関数
async function sendRequest(opts,replyData){
    return new Promise(((resolve,reject)=>{
        console.log('Promiseの引数の関数開始');
        let req = https.request(opts, (response) => {
            console.log('---response---');
            response.setEncoding('utf8');
            let body = '';
            response.on('data', (chunk)=>{
                console.log('chunk:', chunk);
                body += chunk;
            });
            response.on('end', ()=>{
                console.log('end:', body);
                resolve(body);
            });
        }).on('error', (err)=>{
            console.log('error:', err.stack);
            reject(err);
        });
        req.write(replyData);
        req.end();
        console.log('Promiseの引数の関数終了')
    }));
};

修正箇所

  • リクエスト送信部をハンドラ関数から切り出した。
  • httpsモジュールを用いた通信をPromiseでラップした.
  • 意識的にイベント登録をした。
  • 非同期処理が終わった後にcallbackが呼び出されるようにした。

処理の流れと解読

動作を確認するために余計なコードがたくさん含まれているのですが、このLambda関数の挙動は下記のような流れになっています(成功した場合)。順に解読していきます。ログも載せておきます。

テスト実行ログ

メッセージの場合
データ作成
---START---
Promiseの引数の関数開始
Promiseの引数の関数終了
typeof promise: object
promise: Promise { <pending> }
---response---
chunk: {"message":"Invalid reply token"}
end: {"message":"Invalid reply token"}
---DONE---
typeof: string

1. sendRequest()が呼び出され、引数の処理が行われる

ハンドラ関数がsendRequest()を呼び出すと、当然ながらnew Promise()が実行されます。引数に渡した関数の処理は、この時点で実行されるようですね。https.request()ClientRequestオブジェクトを返します(ここで接続=非同期処理が開始されていることになります)。

そして、ClientRequestオブジェクトに対して、on('error', ...)とすることでerrorイベントへのリスナーを登録しています。ClientRequestは、Writable Streamインターフェースを実装した出力ストリームの一つで、EventEmitterでもあります。EventEmitterは、Node.jsの特徴の一つであるイベントループの重要な構成要素と言ってよいと思います。

ちなみに、http(s).request()に第二引数(オプション)としてコールバック関数を渡すことで、responseイベントへのリスナーも登録していることになります。意味的にはonce('response', (...)=>{...})としても同じになるはずです。

The optional callback parameter will be added as a one-time listener for the 'response' event.

(参考)HTTP | Node.js v10.6.0 Documentation

そして、req.write()で本文を送信し、req.end()で出力ストリームを閉じています。いずれも、Writable Streamインタフェースで宣言されているメソッドです。ここまで終わると、Promiseオブジェクトが生成され、呼び出し元に返却されます。

2. 呼び出し元の処理が実行される

Promiseが返ってきたら、呼び出し元の処理が走ります。返却されてきたPromiseに対して、then()メソッドで成功ハンドラと失敗ハンドラを付加しています。Promiseを返した非同期処理の側でresolve()が実行された場合は第一引数の関数が、reject()が実行された場合は第二引数の関数が実行されることになります。

ハンドラの登録まで実行してから、promise変数に代入をし、型と内容を表示しています。この時点では、まだPromiseの中身の処理は行われておらず、Promiseは未解決(pending)となります。通常なら、awaitを付けてasync関数を呼び出し、解決後の値を変数に代入するのでしょうが、今回は動きの確認のためPromiseをそのまま代入しています。

3. 非同期処理の結果を受け取る

ここまでの間に、https.request()で開始した通信が行われています。JavaScriptはシングルスレッドなので、同時に複数の処理を実行することはできないのですが、時間のかかる処理を外部に依頼してしまって、終わるまでは別の処理を実行しておくことはできます。これが非同期処理ということですね。細かい理屈は現在調査中なので、後日まとめられればと思っています。

しばらく経つと、リクエストに対するレスポンスが返ってきます=responseイベントが発生します。このresponseイベントに対するリスナーが、http(s).request()の第二引数で登録したコールバック関数です。コールバック関数の引数(コールバックを渡された側が、呼び出し時に渡して返してくれるもの)は、IncomingMessageオブジェクトで、これはReadable Streamインタフェースを実装した入力ストリームです。送信するリクエストがWritable Streamだったのに対し、こちらはReadableとなるわけですね。

こちらもClientRequestと同様、EventEmitterなので、イベントリスナーを登録することができます。ここでは、dataイベントとendイベントのリスナーを登録しています。なお、これらのイベントは、IncomingMessage固有のものではなく、Readable Streamのものです。また、setEncodeing()エンコーディングを設定していますが、これもReadable Streamのものです。

二つのイベントの意味は、レスポンス本文が送られてきたら(dataイベントが発生したら)、順次body変数に格納し、全て取得し終えたら(endイベントが発生したら)resolve(body)を実行しろ、ということです。resolve()が実行された時点で、呼び出し元でpendingとなっていたPromiseが解決されます。

4. then()で登録しておいた成功ハンドラが実行される

Promiseが解決されると、呼び出し元であらかじめthen()で登録しておいた処理が実行され、解決した値(resolve()の引数)であるbodyの値が、resとして渡されてきます。ログにはresがstring型であると出力されますし、callbackした結果としても下記の通り、レスポンスの本文が表示されます。

f:id:ky_yk_d:20180716010212p:plain

ここまで実行されて、Lambda関数の処理が全て終了したことになります。めでたしめでたし。

今後の課題

書き換えて処理を追ったらずいぶん長文になってしまいました。簡潔にまとめるつもりだったのですが、書きながら裏取りのつもりで調べていたらその過程で学ぶことが多くありすぎ、まとめることを放棄しました(汗)

今回の書き換えと本記事の執筆のなかで、Promiseやasync/await、Node.jsのI/Oの仕組みなどに期せずして接しました。せっかく学ぶきっかけが得られたので、これからもう少し深く勉強して、後日自分なりにまとめてみようと思います!