27
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

「意図的なawait書き忘れ問題」との戦い方を考える ~JavaScriptと非同期処理の話~

Last updated at Posted at 2019-08-01

はじめに

ここで紹介する「意図的なawait書き忘れ問題との戦い方」は私のオリジナルの戦い方です。(もしかしたら他にもやっている人はいるかも知れませんが・・・)
オリジナルということは、まず一般的な方法ではない、ということですし、
世の中にはもっと素晴らしい方法や、あるいは問題を解決できるOSSライブラリなどが落ちている可能性が非常に高いということです。

けっしてここに書かれていることを鵜呑みにはせず、「ふーん、そんな事を考える人もいるんだ。まあ俺はやらないけどね」ぐらいのスタンスで読むのが良いと思います。
また、もしよりよい「戦い方」をご存じの方がいればぜひ教えて下さい。
よろしくお願いします。

「await書き忘れ」とは?

ES2017から導入されたasync/await。便利ですよね。
今までのJavaScriptの非同期処理関係の構文がゴミクズに見えるぐらい すごく便利で直感的な構文です。

でもこれを使っていると一つの問題に悩まされることがあります。

そう、それは、この構文を使ったことがある人なら誰もがやらかしたことのある問題。「await書き忘れ問題」です。

例えば、以下のような擬似コードを見てください

async function sendRequest(){
  await postA()
  postB()
}

このsendRequest() 関数をawaitして呼び出してみると、postA()関数がきちんと処理完了まで待ってくれますが、postB()は待ってくれなくなります。

await sendRequest() // => postAの完了を待機するが、postB()の完了は待機してくれない
// 本当はpostB()の実行を待って実行したかった処理
doSomething()

これが、「await書き忘れ問題」です。このような関数を作ってしまうと、呼び出し元がpostB()の完了を待つことができなくなり、非同期処理のハンドリングが非常に困難になってしまいます。

しかもこの構文自体はJavaScriptの構文として別に間違っているわけではないので、エラーも吐きません。呼び出し元は自信満々に await sendRequest()を宣言して、sendRequest()内で行われるすべての処理の完了を待とうとしているのですが、残念ながらその願いが聞き届けられることはないのです。悲しいですね。

対策

このawait書き忘れ対策ですが、もしTypeScriptを使うのであれば、TSLintにno-floating-promisesというルールがあります。

このルールを有効にすることによって、awaitの書き忘れをチェックする事ができます。便利ですね

「意図的なawait書き忘れ」問題とは?

さて、上記の「await書き忘れ問題」ですが、状況によっては「意図的に」書き忘れることようなコードを書くことがあります。

以下のコードを見てみてください。

.js
async function getData(){
  const data = await getFromAPI()  // データをAPIから所得する
  await saveCacheAsync(data) // 所得したデータをキャッシュに保存する
  return data
}

APIからデータを所得して、キャッシュに保存するかんたんなコードです。「awaitの書き忘れ」もなくうまく動きそうですね。いい感じです。

ですが、ここでふと思います。「このsaveChacheAsync()は別にawaitしなくてもいいんじゃないか?」と

確かに、この関数の返り値を計算するためには、getFromAPI()関数だけawaitしていれば良さそうです。

それに呼び出し側でも、基本的には以下のような使い方が想定されるでしょう。

.js
const data = await getData()
// dataを使った処理をいっぱい書く。別にキャッシュの中身は気にしない

であれば、以下のようにsaveCacheAsync()関数をawaitしないほうが、パフォーマンスは良くなりそうです。

.js
async function getData(){
  const data = await getFromAPI()  // データをAPIから所得する
  saveCacheAsync(data) // 所得したデータをキャッシュに保存する。awaitはしない
  return data
}

const data = await getData()
// キャッシュへの保存の完了を待たずにdataを使った処理を行うことができる

実際に、フロントエンドの高速化はユーザー体験に直結するため非常に重要です。
ユーザーの直帰率はWebサイトの描画完了までの速度と非常に強い相関があることはよく知られています。
優秀な開発者ほど、Webサイトのパフォーマンスチューニングには非常に気を使います。そしてこのような開発者の飽くなき努力によって、このような「意図的なawait忘れ」が作られていくのです。

何が問題か

「意図的なawait忘れ」なコードはたしかにパフォーマンスがよくなりますが、以下のような問題点があります。

  • await をつけていないことが意図的なのか、意図的ではないのかの判断がつかない
  • 呼び出し元がsaveCacheAsync()の実行を待ちたくなったときにその方法が提供されていない

結局の所、以下の2つの要件を同時に満たせないのが問題なのです。

  • 呼び出し元は、async関数内で行われる非同期処理をきちんとハンドリングしたい。非同期処理の完了を待ちたいのに、待てなくなるような事態を避けたい
  • 呼び出し元は、async関数内で行われる非同期処理のすべてを愚直に待つようなことはしたくない。そんなことをすればパフォーマンスが劣化するからだ

いったいどうすればよいのでしょう。

解決法

まず、「意図的なawait忘れ」を行う関数を以下のように作ります。

.js
async function getData(){
  const data = await getFromAPI()  // データをAPIから所得する
  const saveCachePromise = saveCacheAsync(data) // 所得したデータをキャッシュに保存する。awaitはしない
  const saveLogPromise = saveLog(data)              // logを保存する。awaitはしない
  return {
    data,
    doneAll:() => Promise.all([saveCachePromise, saveLogPromise])
  }
}

ポイントは、返り値で本来関数が返したい値(以下ではdataに保持している)以外に、「意図的なawaitわすれ」を行っているPromiseをすべてPromise.all()で待ち受けることのできる関数doneAll()を一緒に返していることです。このようにすることでこの関数の呼び出し元では以下のように、非同期処理をハンドリングすることができます。

.js
  const res = await getData()
  console.log(res.data)    // => このgetDataが本来返したい値を`res.data`で所得することができる

  // === res.dataを使った処理を書く === //

  await res.doneAll()      // => `res.doneAll()`をawaitすることにより、awaitをつけなかった`saveCacheAsync()`や`saveLog()`関数の終了を待機することができる

  // === キャッシュやログの保存が全て終わった後で実行したい処理を書く === //

  console.log('全部終わったよ')

以下に実行可能なサンプルコードを載せておくので、ブラウザのコンソールなどにそのまま貼り付けて、どのように実行されるか試してみてください。multiPromiseReturn()で投げているPromiseがすべて解決したあとに、全部終わったよと表示されると思います。

.js
/**
 * APIを発行する(嘘)
 * 引数で指定した時間経過後にリクエストが解決される
 * 
 * @param {number} msec API実行にかかる時間(msec)
 * @param {string} name APIの名前
 */
function sendRequest(msec, name){
  return new Promise(res => {
    setTimeout(()=>{
      console.log(name + ' request finish!!')
      res('response_of_' + name)
    }, msec)
  })
}

/**
 * APIを3回投げる関数
 * 呼び出し元で各APIの呼び出し終了ごとに処理を行いたいので、
 * 各APIのPromiseをオブジェクトで返す
 * @returns :{
 *  a: Promise<void>;
 *  b: Promise<void>;
 *  c: Promise<void>;
 * }
 */
async function multiPromiseReturn(){
  const a = sendRequest(2000,'a')   // 2秒かかるリクエスト
  const b = sendRequest(10000,'b')    // 10秒かかるリクエスト
  const c = await sendRequest(1000,'c')   // 1秒かかるリクエスト

  return {
    data: c,
    doneAll: () => Promise.all([a, b])
  }
}

// 実行
(async ()=>{
  const res = await multiPromiseReturn()
  console.log(res.data)
  await res.doneAll()
  console.log('全部終わったよ')
})()

multiPromiseReturn()をラップする場合

さて、「意図的なawait忘れ」を行う関数を更に別の「意図的なawait忘れ」を行う関数の中で呼び出すことを考えてみましょう。その場合でも以下のように書くことで、呼び出し元では同じようにハンドリングすることができます。とても透過的で良いと思いませんか?

.js
async function wrapGetData(){
  const res1 = await getData()         // doneAll()付きのPromise関数
  const res2 = await anotherGetData()  // doneAll()付きのPromise関数
  const res3 = postData()              // 意図的なawaitわすれ
  return {
    data: {
      res1: res1.data,
      res2: res2.data
    },
    doneAll:() => Promise.all([res1.doneAll(), res2.doneAll(), res3])
  }
}
.js

  // 呼び出し元のコード。どれだけ内部がラップされていても同じようにハンドリングすることができる
  const res = await wrapGetData()
  console.log(res.data)
  await res.doneAll()   // ここでのdoneAll()はラップされている関数全てのawait忘れ関数の実行を待つことができる
  console.log('全部終わったよ')

以下に実行可能なサンプルコードを載せておくので、ブラウザのコンソールなどにそのまま貼り付けて、どのように実行されるか試してみてください。

.js
/**
 * APIを発行する(嘘)
 * 引数で指定した時間経過後にリクエストが解決される
 * 
 * @param {number} msec API実行にかかる時間(msec)
 * @param {string} name APIの名前
 */
function sendRequest(msec,name){
  return new Promise(res => {
    setTimeout(()=>{
      console.log(name + ' request finish!!')
      res('response_of_' + name)
    },msec)
  })
}

/**
 * APIを3回投げる関数
 * 呼び出し元で各APIの呼び出し終了ごとに処理を行いたいので、
 * 各APIのPromiseをオブジェクトで返す
 */
async function multiPromiseReturn(){
  const a = sendRequest(2000,'a')   // 2秒かかるリクエスト
  const b = sendRequest(10000,'b')    // 10秒かかるリクエスト
  const c = await sendRequest(1000,'c')   // 1秒かかるリクエスト

  return {
    data: c,
    doneAll: () => Promise.all([a,b])
  }
}

/**
 * multiPromiseReturnをラップして、
 * 他にも非同期処理を行っている関数
 */
async function wrapPromiseReturn(){
  const d = sendRequest(3000,'d')
  const res = await multiPromiseReturn()
  const e = await sendRequest(1500,'e')
  return {
    data: {
      res:res.data,
      e
    },
    doneAll:() => Promise.all([d,res.doneAll()])
  }
}

// 実行
(async ()=>{
  const res = await wrapPromiseReturn()
  console.log(res.data)
  await res.doneAll()
  console.log('全部終わったよ')
})()

まとめ

以上、ここで示したようなパターンで関数を実装することで、より柔軟なawait処理を呼び出し元に提供することができ、パフォーマンスを最大限高めながら非同期処理を適切にハンドリングすることができると思います。awaitのシンプルさをできるだけ殺さずにハンドリングできるので、とっつきやすいのではないでしょうか?

ただ、Promiseには「実行をキャンセルできない」とか「進行状況を所得できない」とか他にも色々な問題点が出てくることがあります。その場合には、RxJSなどの非同期処理用のライブラリの導入を検討することになりそうです。

ちなみに余談ですが、一度このパターンを個人開発プロダクトに使ったことがあるのですが、doneAll()を呼び出してawaitすることは一度もありませんでした

理由は、

  • 私自身が「意図的なawaitわすれ」を行うコードが好きではない
  • こんなものを使わなくても Promise.all()で適切に並列化することで、大抵の場合はなんとかなる
  • そもそも性能劣化が気になるような規模の開発を行わない

あたりです。
なので結局考えたはいいですが、実用する機会は殆ど無かったりします。(そんなの紹介するなよと言われそうですが・・・)
そう考えると、「意図的なawaitわすれ」パターンそのものをアンチパターンとして扱うほうが良いのかもしれませんね。
使うにしてもよっぽどプロダクトの規模が大きくなってからで良いのではないかと思います。

以上

27
13
8

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
27
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?