非同期APIを同期的に使いたい人のためのPromise/async/await

JavaScriptで非同期APIを使わなくてはならなくなったが、

「非同期APIの完了を待って次に記述した処理をする」

「非同期APIの完了時に取得される値を次の処理で利用する」

など、非同期APIを同期的に呼び出して使いたい、といった状況に直面した人が、「Promise」「async」「await」といったキーワードで対応するためのメモです。

同期/非同期関連構文としては「Promise」が導入された後に「async」「await」が導入されたため、

「Promiseは古いやり方だろうから、すっ飛ばしてasync/awaitを知りたい」

という方もおられるかもしれません(私がそうでした)。しかし正しく理解する(誤った使い方をして悩まない)ためには、Promiseの理解が必要でした。またこの記事では触れませんが、Promise.all()やPromise.race()など現時点では代替構文の無いPromiseの有用な使い方もあります。

この記事ではまずasync/awaitの前提となるPromiseの使い方について記述し、その後にasync/awaitについて記述を行います。

目次

前提および用語

この記事は以下の前提あるいは用語を用いています。

本記事における「非同期」「同期」の定義

「非同期」
関数呼び出し先から制御が戻ってきても、呼び出し先で他の処理が継続または起動待ちしている場合が存在すること。

「同期」
関数呼び出し先から制御が戻ってきたら、呼び出し先では他に処理が継続または起動待ちしないこと。完了復帰。

なお当記事では「コールバック地獄」に関する内容はありません。

以降のコードで用いるJavaScript構文

トラディショナルな環境で開発をされてきた人が戸惑わないよう、ベタな書き方をしており、また比較的モダンな規格の構文(アロー関数など)はなるべく用いないようにしています。ただし以下のECMAScript 6(2015)構文は利用しています。

‘use strict’;
未宣言変数への代入など、不具合が生じる可能性があると考えられる処理記述について、実行時にエラーとなるようにします。

'use strict';
var foo = 10;
foo2 = 20; // 実行時にエラー

use strictの効果例

テンプレート文字列
バッククォート(`)で囲んだ文字列中に${<変数名>}の記述をすることにより、文字列中に変数の値を展開します。

var bar = 10;
console.log(`bar: ${bar}`); // コンソールログに「bar: 10」と出力

テンプレート文字列の利用例

constキーワード
varの代わりにconstで宣言することにより、変数を再代入禁止にし、意図しない値の変化を予防します。

const baz = 10;
baz = 20; // 実行時にエラー

constの効果例

想定フロー

非同期関数を呼び出した後、非同期の処理の「完了」を待ってから、非同期関数呼び出し以降に記述している処理を実行する流れを想定しています。

  1. main()
    1. subroutine()
      1. background_procedure() // 非同期処理登録
        1. callback_at_late() // 非同期処理
      2. foreground_procedure_01() // 非同期処理の完了を待ってから当行以降を実行したい
      3. foreground_procedure_02()
    2. foreground_procedure_03()
    3. foreground_procedure_04()

想定している処理の流れの関数呼出し順(=期待している実行順)

非同期処理完了待ち未対策での実装

未対策実装コード

以下のプログラム(sample-01.js)は前記の想定通りには動きませんが、とりあえず実行したい処理を順に記述したものです

sample-01.js

未対策実装コードの説明

background_procedure()関数(16~18行目)は、バックグラウンドで実行する処理の完了後に呼び出されるコールバック関数(callback_at_late()関数(11~14行目))が登録され、バックグラウンド処理起動が保留あるいは継続されたまま制御が戻る非同期処理をイメージしています。

“foreground_procedure”で始まる名前の関数(20~34行目)はフォアグラウンドで処理が行われ、全てが完了してから制御が戻る同期処理をイメージしています。

background_procedure()関数内では、setTimeout()を用いて非同期処理をエミュレーションしています。setTimeout()の第1パラメタに指定した関数は、第2パラメタに指定したミリ秒後に呼び出されます。sample-01.jsでは、callback_at_late()関数が3,000ミリ秒後に第3パラメタ以降(ここではparam)をパラメタに指定して起動されます。これにより、background_procedure()関数から制御が戻っても、非同期にcallback_at_late()関数がバックグラウンドで呼び出される非同期処理を表しています。

timestamp_log()関数(3~7行目)は、プログラム実行開始時点(start_time)から呼び出し時点までの間の経過時間(ミリ秒)を行先頭に出力することにより、実行順・タイミングを明示するようにしています。

未対策実装での実行結果

以下はsample-01.jsの実行結果(コンソール出力)になります。

[0]: script start
[10]: foreground_procedure_01 param: undefined
[10]: foreground_procedure_02 param: undefined
[11]: foreground_procedure_03 param: subroutine(undefined)
[11]: foreground_procedure_04 param: subroutine(undefined)
[11]: script end
[3019]: callback_at_late param: BACKGROUND

sample-01.js実行結果例(コンソール出力)

実行結果の通り、background_procedure()から呼び出し登録しているcallback_at_late()関数の実行が完了する前に、呼び出し記述としては後にある”foreground_procedure”で始まる名前の関数が実行されます。

未対策実装のシーケンス図

以下にシーケンス図によるsample-01.jsの実行順を示します。

main()関数呼び出し前の状態は左端の<top>レーンとしています。

“foreground_procedure”で始まる名前の関数は、図を簡略化するため”foreground_procedure_*()”という1レーンで表現していますので、それぞれの注釈(01、02…)で読み分けてください。

sample-01.jsシーケンス図
sample-01.jsシーケンス図(クリックで拡大)

以降の実装ベースのコード

以降では順を追って想定フロー通りにcallback_at_late()関数が実行された「後」に”foreground_procedure”で始まる名前の関数が実行されるように変更していきます。より途中の処理の実行タイミングがわかるように、細かくタイムスタンプ出力をするようにした以下のプログラムをベースにします。

Promiseについて

まずPromiseの説明から記述しますが、一読ではおそらく理解できないので、後に提示するサンプルプログラムやシーケンス図まで目を通してから、また説明に戻るなどして理解すると良いと思います。

Promiseオブジェクトの「状態」

Promiseは非同期処理の状態を示し、状態の変化に応じて挙動が発生するオブジェクトです。以下の状態を持ちます。

状態の呼称状態の概要この状態に遷移する契機
pending保留Promiseオブジェクト生成直後
fulfilled正常完了Promise.resolve()を呼び出し
rejected異常中断Promise.reject()を呼び出し

Promiseの「状態」

Promiseを使う側、作る側

Promiseを使う側

モダンな非同期APIでは同期的に制御が返される際の戻り値でPromiseオブジェクトが返されます。まず非同期APIを使う側の立場での説明をします。

const promise = async_api(…);
promise.then(function(value) {
  console.log(value);
});

Promiseを「使う側」の実装例(疑似コード)

async_api()は非同期APIとします。Promiseオブジェクトをすぐに返しますが、バックグラウンドでは非同期処理を行うものとします。

呼び出し側では次のpromise.then()…が処理されます。promise.then()パラメタに記述した無名関数はすぐに実行されるのではなく、Promiseオブジェクトに登録されるだけ(実行完了時にコールバックされる実行予約のようなもの)になります。

一方でバックグラウンドでの非同期処理が(正常)完了すると、非同期処理の内部的にPromise.resolve()が呼ばれます。するとPromiseオブジェクトの状態がpendingからfulfilledに遷移し、promise.then()で登録していた無名関数が起動されます。

このようにしてpromise.then()内に記述した処理が、非同期処理の完了を同期的に待ってから実行されることになります。なおvalueパラメタには非同期処理でのPromise.resolve()に指定されたパラメタ値が渡されます。通常は非同期処理の実行結果が設定されます。

Promiseを作る側

次に非同期APIを作る側(実装側)の観点で説明します。

function async_api(…) {
  const promise = new Promise(function(resolve, reject) {
    // 何らかの非同期処理の登録・起動など(完了時にresolve, rejectパラメタを利用)
  });
  return promise;
}

Promiseを「作る側」の実装例(疑似コード)

Promiseのコンストラクタ内の無名関数はすぐに実行されます。そこでは何らかのイベント(通信処理、遅延実行など)やWeb Workerによる別スレッド処理実行など、呼び出し元の逐次実行とは関係なく行われるバックグラウンド処理の登録や起動が行われます。

resolveやrejectパラメタはバックグラウンド処理によって利用されます。resolveおよびrejectパラメタの実体はPromise.resolve()およびPromise.reject()関数です。バックグラウンド処理の最後でこれらを呼び出すことにより、Promiseオブジェクトの状態遷移が行われます。

Promiseを用いた同期化(その1:background_procedure()呼出し後ブロックの同期化)

Promiseによる対策その1実装コードと実行結果

sample-02.jsをもとにbackground_procedure()をPromise対応したコード(Promise-01.js)とその実行結果(コンソール出力)を以下に示します。

Promise-01.js

[0]: script start
[4]: main start
[5]: subroutine start
[5]: background_procedure start
[5]: Promise constructor function start
[6]: Promise constructor function end
[6]: background_procedure end
[6]: background_promise
[7]: subroutine end
[7]: foreground_procedure_03 start
[7]: foreground_procedure_03 param: undefined
[7]: foreground_procedure_03 end
[7]: foreground_procedure_04 start
[8]: foreground_procedure_04 param: undefined
[8]: foreground_procedure_04 end
[8]: main end
[9]: script end
[3008]: callback_at_late start
[3010]: callback_at_late param: BACKGROUND
[3011]: callback_at_late end
[3013]: background_promise.then() value: callback_at_late(BACKGROUND)
[3015]: foreground_procedure_01 start
[3018]: foreground_procedure_01 param: callback_at_late(BACKGROUND)
[3019]: foreground_procedure_01 end
[3020]: foreground_procedure_02 start
[3021]: foreground_procedure_02 param: callback_at_late(BACKGROUND)
[3022]: foreground_procedure_02 end

Promise-01.js実行結果例(コンソール出力)

Promiseによる対策その1実装のシーケンス図

Promise01-.jsの実行の流れをシーケンス図にすると以下になります(実際にはPromise.then()は別のPromiseオブジェクトを生成していますが、本記事では省略しています)。

Promise-01.jsシーケンス図
Promise-01.jsシーケンス図(クリックで拡大)

Promiseによる対策その1実装コードの説明

subroutine()関数内での実装

subroutine()関数では、background_procedure()関数の戻り値Promiseオブジェクトをbackground_promise変数に格納しています (56行目) 。

次にbackground_promise.then()の中でforeground_procedure_01()およびforeground_procedure_02()関数の呼び出しを記述しています (58~64行目) 。

これによりbackground_procedure()関数のバックグラウンド(非同期)処理完了を待ってからforeground_procedure_01()およびforeground_procedure_02()関数が実行されることを意図しています。

background_procedure()関数内での実装

background_procedure()関数では、Promiseオブジェクトを生成しています(21行目)。

Promiseコンストラクタの無名関数内部では変更前同様にsetTimeout()関数による3000ミリ秒後のcallback_at_late()関数起動を登録しています。変更前と異なるのは、無名関数パラメタのbackground_resolve, background_rejectもパラメタparamの後続パラメタとして渡していることです(23行目)。

そして生成したPromiseオブジェクトを呼び出し元への戻り値として返します(27行目)。

callback_at_late()関数内での実装

callback_at_late()関数では、変更前と異なり最後にパラメタresolveを関数として呼び出し、そのパラメタとして変更前では戻り値としていた値を指定しています(15行目)。

実行時にはこのresolve(実体はPromise.resolve())を呼び出した時点でPromiseオブジェクト(background_promise)の状態がpendingからfulfilledに遷移し、background_promise.then()で登録していた無名関数(内のforeground_procedure_01()およびforeground_procedure_02()関数)が後続として実行されることになります。

Promiseを用いた同期化(その2:subroutine()呼出し後ブロックの同期化)

さて、foreground_procedure_01()およびforeground_procedure_02()関数の実行は当初の想定通りに非同期処理の後に行われるようになりましたが、main()関数に記述したforeground_procedure_03()およびforeground_procedure_04()関数は、引き続き非同期処理の前に実行されています。

修正方法はいくつか考えられますが、ここでは関数の呼び出し構造をできるだけ変えないように、subroutine()関数自体を同じくPromise方式による非同期APIとして実装し、main()関数側ではそのPromiseオブジェクトの.then()ブロックとしてsubroutine()完了後にforeground_procedure_03()およびforeground_procedure_04()関数が実行されるようにするものとします。

Promiseによる対策その2実装コードと実行結果

修正した実装が以下になります。

Promise-02.js

[0]: script start
[5]: main start
[6]: subroutine start
[7]: background_procedure start
[8]: background_procedure end
[10]: subroutine end
[11]: main end
[11]: script end
[3010]: callback_at_late start
[3012]: callback_at_late param: BACKGROUND
[3013]: callback_at_late end
[3016]: foreground_procedure_01 start
[3018]: foreground_procedure_01 param: callback_at_late(BACKGROUND)
[3021]: foreground_procedure_01 end
[3022]: foreground_procedure_02 start
[3023]: foreground_procedure_02 param: callback_at_late(BACKGROUND)
[3025]: foreground_procedure_02 end
[3027]: foreground_procedure_03 start
[3028]: foreground_procedure_03 param: subroutine(callback_at_late(BACKGROUND))
[3029]: foreground_procedure_03 end
[3030]: foreground_procedure_04 start
[3031]: foreground_procedure_04 param: subroutine(callback_at_late(BACKGROUND))
[3032]: foreground_procedure_04 end

Promise-02.js実行結果例(コンソール出力)

Promiseによる対策その2実装のシーケンス図

Promise-02.jsのシーケンス図は以下になります (Promise-01.js同様、実際にはPromise.then()は別のPromiseオブジェクトを生成していますが、本記事では省略しています)。

Promise-02.jsシーケンス図
Promise-02.jsシーケンス図(クリックして拡大)

Promiseによる対策その2実装コードの説明

subroutine()関数ではPromiseオブジェクトを生成し(54行目)、戻り値としています(67行目)。main()関数ではそのPromiseオブジェクトの.then()ブロック内にforeground_procedure_03()およびforeground_procedure_04()関数の呼出しを記述しています(74~79行目)。

これで当初の想定通り、非同期処理background_procedure()の完了後に、foregoround_procedure_01()~foreground_procedure_04()関数が逐次実行されるようになりました。

呼び出し元であるsubroutine()関数、main()関数や大元の<top>は先に終了しているため、すっきりしない方(「プログラム全体の流れがブロックされるんじゃないの…?」など)もおられるかもしれません。実際の実用的な実装では、非同期処理の後でなくてはならない処理を見極め、必要な処理のみをPromise.then()ブロックに入れるなど、プログラム構造の設計を適切に行うことが必要です。それによって性能や、ユーザーインターフェースを有するアプリケーションにおけるユーザーエクスペリエンスの改善につながる場合もあります。

以上のPromiseによる説明を基に、次節以降ではasync/awaitによる方法を説明します。

async/awaitについて

async/awaitは、Promiseによる実装と以下の対応関係にあります。

async

関数宣言の”function”の前に”async”を記述した関数がPromiseによる非同期関数化されます。
関数の戻り値は、returnや最終評価式の記述に関わらず、 自動的に生成されるPromiseオブジェクトとなります。
非同期処理の最後に自動的にPromise.resolve()が呼び出され、そのパラメタとして記述していた戻り値が設定されます。

// 記述上の実装(疑似コード)
async function fn1(...) {
  const a = 10;
  :  // 非同期処理の登録・起動など
  return x;
}

↓

// 等価となる疑似コード
function fn1(...) {
  const promise_fn1 = new Promise(function(resolve_fn1, reject_fn1) {
    const a = 10;
    :  //  非同期処理の登録・起動など (resolve_fn1, reject_fn1パラメタも渡す)
  });
  return promise_fn1;
}
// fn1()内で登録・起動した非同期処理完了時の処理(コールバック関数の場合)
function callback_fn1(..., resolve_fn1, ...) {
  :
  resolve_fn1(x);
}

asyncを記述した実装と、等価に対応するPromiseによる処理(疑似コード)

await

非同期関数呼び出しの左側に記述すると、存在する場合は記述式のawaitの左側(変数への代入など)や、次の記述式以降の同一実行ブロック末尾までが自動的にPromise.then()のパラメタとして扱われます。
つまり非同期関数の非同期処理完了までそれらの実行開始を待ちます。
awaitを記述する関数はasyncを記述する必要があります(非同期関数化)。

 // 記述上の実装(疑似コード) 
async function fn2(...) {
  :
  const x = await fn1(...);
  fn3(...);
  return z;
}

↓

 // 等価となる疑似コード
function fn2(...) {
  const promise_fn2 = new Promise(function(resolve_fn2, reject_fn2) {
    :
    const promise_fn1 = fn1(...);
    promise_fn1.then(x, function() {
      fn3(...);
      resolve_fn2(z);
    }
  }
  return promise_fn2;
}       

awaitを記述した実装(文法的にasyncも必須)と、等価に対応するPromiseによる処理(疑似コード)

以後の説明として、前節までのPromiseによる実装サンプルプログラムを、async/awaitに書き換えることによって裏付けをしていきます。

async/awaitを用いた同期化(その1:background_procedure()呼出し後ブロックの同期化)

async/awaitによる対策その1実装コードと実行結果

以下は「Promiseによる対策その1」で提示したPromise-01.jsを、async/awaitによる記述に変更したものです。

async-await-01.js

[0]: script start
[7]: main start
[8]: subroutine start
[8]: background_procedure start
[9]: background_procedure end
[10]: foreground_procedure_03 start
[10]: foreground_procedure_03 param: [object Promise]
[10]: foreground_procedure_03 end
[10]: foreground_procedure_04 start
[10]: foreground_procedure_04 param: [object Promise]
[10]: foreground_procedure_04 end
[10]: main end
[10]: script end
[3013]: callback_at_late start
[3017]: callback_at_late param: BACKGROUND
[3019]: callback_at_late end
[3026]: foreground_procedure_01 start
[3028]: foreground_procedure_01 param: callback_at_late(BACKGROUND)
[3032]: foreground_procedure_01 end
[3036]: foreground_procedure_02 start
[3038]: foreground_procedure_02 param: callback_at_late(BACKGROUND)
[3042]: foreground_procedure_02 end
[3045]: subroutine end

async-await-01.js実行結果例(コンソール出力)

async/awaitによる対策その1実装コードの説明

subroutine()関数内でbackground_procedure()関数を呼び出している部分でawaitの記述をしています(54行目)。これはPromise-01.jsにおけるPromise.then()と同様の働きをします。

次の行から実行ブロック末尾(subroutine()関数末尾)(55~59行目)までをPromise.then()の中に記述されているのと同じように、background_procedure()関数の非同期処理の完了を待ってから実行します。

また、awaitを記述している式の左辺(background_result)に代入している値はPromiseオブジェクトではなく、Promise.resolve()に指定された値、言い換えればPromise.then()の無名関数のパラメタに渡される値(Promise-01.jsでの変数「value」の値)となります。

つまりawaitは右側に指定した非同期関数が返すPromiseオブジェクトを一旦受けてfulfilled状態になるまで待ち、fulfilled状態になったらPromise.resolve()で指定された値をawaitの評価結果とし、保留していた処理を逐次実行します。

subroutine()関数の宣言(52行目)のfunctionの左側にはasyncを記述しています。構文的な面ではawait記述を含む関数には必須の記述となります。意味的な面については次節で説明します。

動作的にはsample-01.js同様に、foreground_procedure_01()およびforeground_procedure_02()関数が非同期処理完了を待ってから実行されますが、foreground_procedure_03()およびforeground_procedure_04()関数は非同期処理の前に実行されています。Promise.then()同様に非同期処理完了待ちの範囲は上位(呼び出し元)実行ブロックには波及しません。

またforeground_procedure_03()およびforeground_procedure_04()関数で出力しているパラメタ値(subroutine()関数からの戻り値)が[object Promise]になっています。

async/awaitを用いた同期化(その2:subroutine()呼出し後ブロックの同期化)

async/awaitによる対策その2実装コードと実行結果

async/awaitを用いてPromise-02.js同様の修正をしたものが以下になります。

async-await-02.js

[0]: script start
[4]: main start
[5]: subroutine start
[5]: background_procedure start
[6]: background_procedure end
[6]: script end
[3011]: callback_at_late start
[3014]: callback_at_late param: BACKGROUND
[3016]: callback_at_late end
[3025]: foreground_procedure_01 start
[3027]: foreground_procedure_01 param: callback_at_late(BACKGROUND)
[3030]: foreground_procedure_01 end
[3034]: foreground_procedure_02 start
[3037]: foreground_procedure_02 param: callback_at_late(BACKGROUND)
[3040]: foreground_procedure_02 end
[3047]: subroutine end
[3056]: foreground_procedure_03 start
[3059]: foreground_procedure_03 param: subroutine(callback_at_late(BACKGROUND))
[3062]: foreground_procedure_03 end
[3064]: foreground_procedure_04 start
[3068]: foreground_procedure_04 param: subroutine(callback_at_late(BACKGROUND))
[3070]: foreground_procedure_04 end
[3071]: main end

async-await-02.js実行結果例(コンソール出力)

async/awaitによる対策その2実装コードの説明

main()関数内でsubroutine()関数を呼び出している部分でawaitの記述をしています(64行目)。async-await-01.jsの説明でasyncの構文的な面を説明しましたが、asyncは意味的には関数全体をnew Promise()で包んで、記述に関わらず戻り値をPromiseオブジェクトとするものです。またasyncを付けた関数の実行が完了すると、関数コード記述での戻り値をPromise.resolve()のパラメタに指定して呼び出します。

動作的にはsubroutine()関数はasync指定により、Promiseオブジェクトをpending状態で返します。main()関数のawaitは、そのPromiseオブジェクトがfulfilled状態になるまで待ちます。subroutine()関数内の処理が完了して戻り値を返すと、その戻り値によってasyncの内部処理によってPromise.resolve()が行われるためfulfilled状態になり、subroutine_result変数にはその戻り値が設定され、main()関数の次行以降が逐次実行されます。

その結果Promise-02.js同様に、当初の想定通り、非同期処理background_procedure()の完了後に、foregoround_procedure_01()~foreground_procedure_04()関数が逐次実行されるようになりました。

説明の都合上割愛しましたが補足しますと、async-await-01.jsは正確にはPromise-01.jsと完全には対応しておらず、subroutine()関数のPromise化(いわばPromise-01.5.js相当)まで行っていることになります。

あらためてPromise/async/awaitについて

「awaitって非同期処理が完了するまで待つ構文ではないの?なぜasync-await-01.jsでmain()の処理は待たずに先に進むの?」と思われた方がいるかもしれません(私がそうでした)。

これまでの説明で示したようにawaitはPromise.then()のシンタックスシュガーですので、上位実行ブロック(await記述を含む関数の呼び出し元)には同期待ち効果が波及できません。このためawait記述を含む関数も呼び出し元から見れば実質的には非同期関数となるため、構文上asyncが強制となっています(と理解しています)。

Promiseの節の最後で言及しましたように、非同期処理を同期的に待つ(プログラム全体の実行を停止する)という考え方から、非同期処理完了を待たなければならない処理を局所化して、それ以外の部分は束縛されないプログラム構造・設計に転換した方が、性能面・ユーザエクスペリエンス面からも望ましいと思います。

その他補足・参考

Promise.then()によるPromiseオブジェクト

Promise.then()も実際には引数に指定された処理を含むPromiseオブジェクトを生成しています。それによりメソッドチェーンでより複雑な処理を記述したり、複数非同期処理の逐次実行といったことができますが、本記事の段階では理解の妨げになると考え割愛しました。より理解を深めたい場合は他サイトの解説記事に進んでください。

Promise.then()以外の対応メソッド

非同期処理内でPromise.resolve()した場合の正常系対応ハンドラPromise.then()のみを説明しましたが、他にPromise.reject()に対応するPromise.then()の第2引数またはPromise.catch()、正常・異常いずれの場合も必ず対応処理を行うPromise.finally()があります。これらについては他サイトの解説記事に進んでください。

Promise.then()第2引数とPromise.catch()の違い

異常系対応ハンドラとしてPromise.then()の第2引数とPromise.catch()がありますが、どちらで実装しても同じで、両方とも実装した場合には、私が試行した処理系ではPromise.then()第2引数のみが実行されました。いずれにせよ混乱やミスを防ぐため、プロジェクトの中で統一した方が良いと思います。

AWS APIでのPromise

AWSの非同期APIはPromiseを利用していますが、APIの戻り値ではPromiseオブジェクトを返しておらず、API戻り値オブジェクトのpromise()メソッドによって取得する必要があります。

const aws = require('aws-sdk');
aws.config.update({ region: '...' });
const s3 = new aws.S3();
:
// upload()メソッドの戻り値はPromiseオブジェクトではなく、改めてpromise()メソッドで取得
await s3.upload(params).promise()

AWS APIでのPromise(疑似コード)

失敗例(本記事のきっかけ)

本記事のきっかけとなった失敗を晒します。AWS Lamda上で以下の処理をしようとしていました。

  1. 環境変数から暗号化済トークン文字列を取得(encryptedToken = process.env[‘…’])
  2. AWS Key Management Service(KMS)でトークン文字列を復号(decryptByKMS(encryptedToken))
  3. 復号したトークン文字列をHTTPヘッダに設定してHTTP request

まず示すのは期待通りに動いていた実装です。

'use strict';
:
const aws = require('aws-sdk');
const https = require('https');
:
exports.handler = async (event, context, callback) => {
  :
  function01(...);
  :
}
:  
function function01(...) {
  :
  function02(...);
}
:
async function function02(...) {
  const encryptedToken = process.env['...'];
  const accessToken = await decryptByKMS(encryptedToken);
  const authorizationHeader = `token ${accessToken}`;
  const requestOptions = {
    headers: {
      'Authorization': authorizationHeader,
      'User-Agent': '...';
      'Accept': '...'
  };
  const request = https.request('<URL>', requestOptions, (res) => {
    :
  }
  request.end();
}
:
function decryptByKMS(encrypted) {
  aws.config.update({ regiion: '...'});
  const kms = new aws.KMS();
  return kms.decrypt({
    CiphertextBlob: Buffer.from(encrypted, 'base64')
  }).promise().then((data) => {
    return data.Plaintext.toString('ascii');
  );
}

まだ動いていた実装(疑似コード)

decryptByKMS()関数の中で呼び出しているAWS APIのaws.KMS().decrypt()は非同期関数ですので、復号化処理の完了を待たずにdecryptByKMS()関数から呼び出し元に処理が戻ります。function02()の中で呼び出しているdecryptByKMS()の呼出しにawaitを付けることにより、復号化が完了してから復号文字列(data.Plaintext.toString(‘ascii’)の結果)をaccessToken変数に代入しています。

正確に言うと、awaitで完了待ちしているのはaws.KMS().decrypt()によるPromise(仮にPromise Aとします)では無く、そこからメソッドチェーンされた.then()で生成されたPromise(仮にPromise Bとします)です。aws.KMS().decrypt()が完了すると、promise()で返されたPromise Aがfulfilled状態になり、.then()内の無名関数(パラメタがdata)が実行されます。その無名関数が実行完了すると、Promise Bがfulfilled状態になり、awaitの実行待ちが解けてaccessToken変数への代入以降の処理が開始します。

当時は全然理解しておりませんでしたので、decryptByKMS()を呼び出すのに指定したawaitによってfunction02()宣言のasync を仕方なく追記したという意識がありました。そしてfunction02()宣言からasyncを消して、asyncで修飾する対象を局所化するためにdecryptByKMS関数内で実行待ち(await)をしようと以下のように修正しました。

  • decryptByKMS()内の
    return kms.decrypt(…).promise().then(…);

    return await kms. decrypt(…).promise().then(…);
    とawaitを追記。
  • decryptByKMS()関数宣言にasyncを追記。
  • function02()関数内のdecryptByKMS()呼び出しからawaitを削除。
  • function02()関数宣言のasyncを削除。
 'use strict';
:
const aws = require('aws-sdk');
const https = require('https');
:
exports.handler = async (event, context, callback) => {
  :
  function01(...);
  :
}
:  
function function01(...) {
  :
  function02(...);
}
:
/*async */function function02(...) {
  const encryptedToken = process.env['...'];
  const accessToken = /*await */decryptByKMS(encryptedToken);
  const authorizationHeader = `token ${accessToken}`;
  const requestOptions = {
    headers: {
      'Authorization': authorizationHeader,
      'User-Agent': '...',
      'Accept': '...'
  };
  const request = https.request('<URL>', requestOptions, (res) => {
    :
  }
  request.end();
}
:
async function decryptByKMS(encrypted) {
  aws.config.update({ regiion: '...'});
  const kms = new aws.KMS();
  return await kms.decrypt({
    CiphertextBlob: Buffer.from(encrypted, 'base64')
  }).promise().then((data) => {
    return data.Plaintext.toString('ascii');
  );
} 

動かなくなった実装(疑似コード)

当時はasync/awaitの範囲を局所化しようとして無い知恵を絞ってこのように修正したのですが、見当違いでした。

decryptByKMS()関数宣言にasyncを指定していますので、decryptByKMS()関数は内容の処理を更に包んだPromise(仮にPromise Cとします)を返して即時戻ります。一方呼び出し元ではawaitで実行待ちしなくなりましたので、accessToken変数にはPromise Cオブジェクトが代入され、以降の処理が期待通りに動かなくなります。

なおfunction02()側のasync/awaitを元通りにすれば、Promise Cが冗長ではありますが、Promise A→Promise B→Promsie Cの状態遷移連鎖によって期待通りの動きに戻ります。

当時はこの動作が理解できず(「なぜawaitで止まらない?」)、async/awaitからPromiseの理解を進め、本記事のきっかけとなりました。

なお更に蛇足ですが、AWS Lambdaはメイン処理が終了すると、Lambdaで定義した処理全体を打ち切ります。そのためLambda処理中に非同期なAWS API(aws.S3().upload()等)を呼び出す場合、promise().then()やawaitでそれらの実行完了待ちをしないと、AWS APIの処理が実行されていないことがありますので御注意ください。

PlantText UML Editor

https://www.planttext.com/
本記事内のシーケンス図作成に利用しました。所定の書式に沿ったテキストをUML図に変換するPlantUMLをオンラインで利用できるものです。本記事内のシーケンス図の生成ソーステキストはGistに置いています。

Node.jsデザインパターン 第2版

(2019/08/12追記)「Node.jsデザインパターン 第2版」の「第3章 コールバックを用いた非同期パターン」「第4章 ES2015以降の機能を使った非同期パターン」には、本記事で触れているトピックを含め正確な記載がありますので参考にしてください。

おすすめ

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA