Promiseとasync/awaitをちゃんと理解する

Kazuki Koide

October 1, 2018

JavaScriptといえばコールバックだが、最近は基本的に非同期関数はPromiseで実装してasync/awaitするのがトレンドらしい。もし使いたい関数がPromiseをサポートしていない場合は、自分でPromiseにラップするのが良いとのこと。この辺の知識が若干怪しかったので、実際に一からやってみた。

コールバック方式

まずは、適当に従来のコールバック方式の非同期関数を書いてみる。

// コールバック方式による非同期関数
function sum (x, y, callback) {
  setTimeout(() => {
    if (typeof(x) !== 'number' || typeof(y) !== 'number') {
      callback(new Error('Invalid parameter!'), null)
      return
    }
    callback(null, x + y)
  }, 1000)
}

// 実行
sum(1, 2, (err, result) => {
  if (err) {
    console.log(err)
    return
  }
  console.log('result:', result)
})

sum関数は足し算をするだけの関数だが、1秒もかかってしまう。時間がかかるので非同期で動くようになっている…ということにする。これを実行すると1秒後にresult: 3が出力される。引数に数値以外が指定された場合は以下のエラーが出力される。

Error: Invalid parameter!
    at Timeout.setTimeout [as _onTimeout] (/xxx/sandbox/nodejs/1.callback.js:4:16)
    at ontimeout (timers.js:498:11)
    at tryOnTimeout (timers.js:323:5)
    at Timer.listOnTimeout (timers.js:290:5)

Promise方式

次に、先ほどのsum関数をPromiseでラップする。

// コールバック方式による非同期関数(さっきと同じもの)
function sum (x, y, callback) {
  setTimeout(() => {
    if (typeof(x) !== 'number' || typeof(y) !== 'number') {
      callback(new Error('Invalid parameter!'), null)
      return
    }
    callback(null, x + y)
  }, 1000)
}

// Promiseでラップ
function promiseSum (x, y) {
  return new Promise((resolve, reject) => {
    sum(x, y, (err, result) => {
      if (err) {
        reject(err)
        return
      }
      resolve(result)
    })
  })
}

// 実行
promiseSum(1, 2).then(result => {
  console.log('result:', result)
}).catch(err => {
  console.log(err)
})

これがいわゆる「Promiseでラップする」ということ。これを実行しても先ほどと同様に1秒後にresult: 3が出力される。

コールバック方式とPromise方式の比較

コールバック方式とPromise方式の実行コードを並べてみる。

// コールバック方式
sum(1, 2, (err, result) => {
  if (err) {
    console.log(err)
    return
  }
  console.log('result:', result)
})

// Promise方式
promiseSum(1, 2).then(result => {
  console.log('result:', result)
}).catch(err => {
  console.log(err)
})

コールバック方式の場合は、関数の仕様に合わせて例外処理する必要がある。今回だと「コールバック関数の第一引数でエラーを受け取る」という仕様だ。それがPromiseでラップすることにより、どんな関数でも同じ方法(catch)で例外処理ができる。これはPromise方式のメリットだと感じた。可読性についてはどちらも変わらないと感じる。

非同期関数を順番に実行する

次に、1から4の数字を全て足し合わせてみる。sum関数は2つの数字しか受け取れないので、1,2の足し算の結果と3を足して、その結果と4を足すという風に順次処理を行う。それぞれ以下のようなコードになる。

コールバック方式

// コールバック方式による非同期関数
function sum (x, y, callback) {
  setTimeout(() => {
    if (typeof(x) !== 'number' || typeof(y) !== 'number') {
      callback(new Error('Invalid parameter!'), null)
      return
    }
    callback(null, x + y)
  }, 1000)
}

// 実行
sum(1, 2, (err, result) => {
  if (err) {
    console.log(err)
    return
  }
  sum(result, 3, (err, result) => {
    if (err) {
      console.log(err)
      return
    }
    sum(result, 4, (err, result) => {
      if (err) {
        console.log(err)
        return
      }
      console.log('result:', result)
    })
  })
})

これを実行すると、足し算を3回行なっているので、3秒待ったあとにresult: 10が出力される。良くコールバック地獄やネスト地獄と言われて問題になっているが、この程度だとコールバック方式でもそこまで地獄感は感じない。地獄を味わっている人たちはどれくらい激しいネストをしているんだろうか。

Promise方式

// コールバック方式による非同期関数
function sum (x, y, callback) {
  setTimeout(() => {
    if (typeof(x) !== 'number' || typeof(y) !== 'number') {
      callback(new Error('Invalid parameter!'), null)
      return
    }
    callback(null, x + y)
  }, 1000)
}

// Promiseでラップ
function promiseSum (x, y) {
  return new Promise((resolve, reject) => {
    sum(x, y, (err, result) => {
      if (err) {
        reject(err)
        return
      }
      resolve(result)
    })
  })
}

// 実行
promiseSum(1, 2).then(result => {
  return promiseSum(result, 3)
}).then(result => {
  return promiseSum(result, 4)
}).then(result => {
  console.log('result:', result)
}).catch(err => {
  console.log(err)
})

このコードを実行した場合も、3秒待ったあとにresult: 10が出力される。Promise方式だと、メソッドチェイン的な書き方で順番に実行でき、ネストは浅くなる。またPromise方式のもうひとつのメリットとして、try catch的な例外処理ができる。個別に例外を拾いたい場合は、以下のようにthen関数の第二引数に例外処理を書くことができる。

// 数値以外を引数に指定して故意にエラーを発生させる
promiseSum('x', 2).then(result => {
  return promiseSum(result, 3)
}, () => {
  console.log('Error handling ...')
}).then(result => {
  return promiseSum(result, 4)
}).then(result => {
  console.log('result:', result)
}).catch(err => {
  console.log(err)
})

この実行結果は以下となる。‘Error handling…‘を出力したあとcatchに飛んでいるのがわかる。

Error handling ...
Error: Invalid parameter!
    at Timeout.setTimeout [as _onTimeout] (/xxx/sandbox/nodejs/5.secencial-promise2.js:4:16)
    at ontimeout (timers.js:498:11)
    at tryOnTimeout (timers.js:323:5)
    at Timer.listOnTimeout (timers.js:290:5)

async/await方式

冒頭に書いた通り、最近はasync/awaitを使うのがトレンドだ。先ほどのPromise方式を async/awaitで書き換えると以下のコードになる。

// コールバック方式による非同期関数
function sum (x, y, callback) {
  setTimeout(() => {
    if (typeof(x) !== 'number' || typeof(y) !== 'number') {
      callback(new Error('Invalid parameter!'), null)
      return
    }
    callback(null, x + y)
  }, 1000)
}

// Promiseでラップ
function promiseSum (x, y) {
  return new Promise((resolve, reject) => {
    sum(x, y, (err, result) => {
      if (err) {
        reject(err)
        return
      }
      resolve(result)
    })
  })
}

// 実行
(async () => {
  try {
    let result;
    result = await promiseSum(1, 2)
    result = await promiseSum(result, 3)
    result = await promiseSum(result, 4)
    console.log('result:', result)
  } catch (err) {
    console.log(err)
  }
})()

このコードを実行した場合も3秒待ったあとにresult: 10が出力される。Promise関数にawaitを付けることで処理の完了を待ってくれる。awaitasyncを付けたfunctionの中だけで使えるというルールがあるので即時関数を使わないといけない点はイマイチだが、コールバックを使わずに同期処理が書けるので可読性が高いと感じる。普通にtry catchで例外処理できるのもポイント高い。ちなみに先ほどのように個別で例外を拾う場合は以下のようになる。

(async () => {
  try {
    let result;
    // 数値以外を引数に指定して故意にエラーを発生させる
    result = await promiseSum('x', 2).catch(err => {
      console.log('Error handling ...')
    })
    result = await promiseSum(result, 3)
    result = await promiseSum(result, 4)
    await console.log('result:', result)
  } catch (err) {
    console.log(err)
  }
})()

このように、catchで個別に例外を拾うことができる。コールバック完全駆逐…とまでは行かないようだ。

async/await方式でのテスト

昔は、非同期処理をテストするにはco-mocha等を使った気がするが、最近はmochaでasync/awaitが使えるようだ。以下のようなテストコードになる。

describe('asunc/await test', () => {
  it('1 + 2 = 3 ', async () => {
    const result = await promiseSum(1, 2)
    assert.strictEqual(result, 3)
  })

  it('数字以外が入力されるとエラー', async () => {
    let f = () => {}
    try {
      const result = await promiseSum('x', 2)
    } catch (err) {
      f = () => {throw err}
    } finally {
      assert.throws(f, 'Invalid parameter!')
    }
  })
})