🔄

Node.js 非同期処理・超入門 -- Promiseとasync/await

2019/03/17に公開

この記事の目標

  • 別の言語の経験のある人が
  • Node.js の非同期処理について
  • 全体像をざっくり
  • この記事だけで

理解することを目標とします。

扱う内容

  • 非同期処理の概念
  • Promise の使い方概要
    • thencatch
  • async/awaitの使い方概要

扱わない内容

  • Promise の作り方(コンストラクター)
  • Promise.allPromise.race
    • Promise.all vs await連打

この記事を読む上での注意

  • setTimeout のことはいったん忘れてください。
  • setTimeout のことはいったん忘れてください。
  • 一般的に、コールバック関数を渡す引数には無名関数をその場に書いて渡しますが、
    慣れないうちの可読性を考慮し、変数へ格納してから渡しています。
無名関数直渡し
arr.forEach(item => {
  console.log(item);
});
変数へ格納してから渡す
const outputItem = item => {
  console.log(item);
};

arr.forEach(outputItem);

導入

そもそも非同期処理って何よ

端的に言えば「よそに処理を依頼したときに、その場で完了を待たない」処理のことです。
よそ」とは、別のサーバーだったり、I/O のライブラリだったり。

待たない」とはどういうことか。
「よそ」である I/O ライブラリの代表としてテキストファイルの読み込みを取り上げ、
同期処理の代表として Ruby をお迎えして比較してみます。

Ruby

ソースコード

sync.rb
p "読み込み前"
txt = File.open("./readme.txt").read # ファイルを読みこんで、その内容を返す
p txt # 出力
p "読み込み後"

出力結果

-> % ruby sync.rb
"読み込み前"
"鹿島臨海鉄道大洗鹿島線"
"読み込み後"

上から順番に出ています。

Node.js

ソースコード

async.js
const fs = require('fs'); // ライブラリの読み込み

// 内容を出力するための関数 (コールバック関数) の定義
const output = (err, txt) => {
  console.log(txt); //出力
};

console.log('読み込み前');
fs.readFile('./readme.txt', 'utf8', output); // ファイルを読み込んで、その内容をコールバック関数に渡す
console.log('読み込み後');

出力結果

-> % node async.js
読み込み前
読み込み後
鹿島臨海鉄道大洗鹿島線

コード上では ファイル読み込み&出力「読み込み後」 の順番で書いたのに、 「読み込み後」 が先に出力されました。
Node.js は「よそに処理を依頼したときに、その場で完了を待たない」で、その先の処理を進めてしまいます。

どういうことか。
イメージをつかむため、現実世界の「同期処理」と「非同期処理」の代表として、
学食」と「フードコート」での注文から受け取りまでを例に見てみましょう。

「学食(=同期処理)」と「フードコート(=非同期処理)」

学食

学食の注文は同期処理なので、注文(つくるひとに調理を依頼)してから受け取るまでにその場で待つ必要があります。

たべるひと やりとり つくるひと
🐵「特盛カレーおーくれ」 注文 → 「あいよーまっとってなー」🐱
🐵……(その場で待つ) - カレーをつくる (同期処理)🐱
🐵「🍛 ゲットだぜ」 ← 🍛 「できたよー」🐱
rb
p "特盛カレーおーくれ"
meal = order("特盛カレー") # 「オーダー: 注文内容」で標準出力→できた料理を返す
p meal + "ゲットだぜ"  # 出力
-> % ruby sync.rb
"特盛カレーおーくれ"
"オーダー: 特盛カレー"
"🍛ゲットだぜ"

フードコート

一方、フードコートの注文は非同期処理なので、注文してから受け取るまでその場で完了を待たないです。
調理が済むまで別のことができます。

たべるひと やりとり つくるひと
🐵「全マシおーくれ」 注文 → 「あいよーゲームでもしてまっとってなー」🐱
🐵 席でゲームをする(待たない) - ラーメンをつくる (非同期処理)🐱
🐵「🍜 ゲットだぜ」 ← 🍜 「できたよー」(コールバック)🐱
js
// 料理を受け取るための関数 (コールバック関数) の定義
const receiveMeal = (err, meal) => {
  console.log(`${meal}ゲットだぜ`);
};

console.log('全マシおーくれ');
order('全マシ', mealOutput); // 「オーダー: 注文内容」で標準出力→できた料理をコールバック関数に渡す
console.log('ゲームしよ');
-> % node async.js
全マシおーくれ
ゲームしよ
「オーダー: 全マシ」
🍜ゲットだぜ

注文が複数来る場合

1 件の注文だけを見た場合大きな差はないですが、複数件ある場合は大きな差が出てきます。

学食
たべるひと やりとり つくるひと
🐵「特盛カレーおーくれ」
🐔「特盛カレーおーくれ」
🐷「特盛カレーおーくれ」
注文 → 「あいよーまっとってなー」🐱
🐵……(その場で待つ)
🐔……(その場で待つ)
🐷……(その場で待つ)
- 🐵 のカレーをつくる (同期処理)🐱
🐵「🍛 ゲットだぜ」 ← 🍛 「できたよー」🐱
🐔……(その場で待つ)
🐷……(その場で待つ)
- 🐔 のカレーをつくる (同期処理)🐱
🐔「🍛 ゲットだぜ」 ← 🍛 「できたよー」🐱
🐷……(その場で待つ) - 🐷 のカレーをつくる (同期処理)🐱
🐷「🍛 ゲットだぜ」 ← 🍛 「できたよー」🐱
フードコート
たべるひと やりとり つくるひと
🐵「全マシーおーくれ」
🐔「全マシーおーくれ」
🐷「全マシーおーくれ」
注文 → 「あいよーゲームでもしてまっとってなー」🐱
🐵 席でゲームをする(待たない)
🐔 席で本を読む(待たない)
🐷 別の店で別の注文をする(待たない)
- 🐵 のラーメンをつくる (非同期処理)🐱
🐵「🍜 ゲットだぜ」 ← 🍜 「できたよー」(コールバック)🐱
🐔 席で本を読む(待たない)
🐷 デザートを物色する(待たない)
- 🐔 のラーメンをつくる (非同期処理)🐱
🐔「🍜 ゲットだぜ」 ← 🍜 「できたよー」(コールバック)🐱
🐷 あんこう踊りをする(待たない) - 🐷 のラーメンをつくる (非同期処理)🐱
🐷「🍜 ゲットだぜ」 ← 🍜 「できたよー」(コールバック)🐱

導入のまとめ

非同期処理とは「よそに処理を依頼したときに、その場で完了を待たない」処理のこと。

Node.js での非同期処理はどうやって書くのか

その場で完了を待たない」と言いつつも、実際のところ欲しいのは「依頼した処理の結果」です。
依頼した処理の結果を受け取って、さらにそれを処理するための仕組みについて説明します。
これらを正しく扱えないと、意図しない順序で処理を行ってしまう可能性があります(「待たない」ですからね)。

キーワード

  • Promise
  • async / await

注意 (#FIXME!)

前章で泣く泣く書いたコールバックによる非同期処理は旧算世界の忌まわしき遺物であるため、
現代では別の仕組みを使用します(後述)。
忘れてください。

コールバック(関数) は忘れないでください。重要な仕組みです。使います。
コールバックによる非同期処理を忘れてください。

2019/03/17 現在、Promiseでファイルシステムを扱えるfs Promises API は Experimentalです。
Node v12 で stable になったら前章の例を Promise で書き直します。。。

実現に必要なもの: 処理が終わったことの「通知」

「よそに依頼した処理」は、一般的にいつ終わるかわからないものです。
なんとなく所要時間の予想はついても、完璧ではありません。
処理の完了は頼まれた側にしかわからないので、
頼まれた側は、依頼者へ処理が完了したことを通知する仕組みを用意しなければなりません。

フードコートでは呼び出しベルを使っていますね。

たべるひと やりとり つくるひと
🐵「全マシおーくれ」 注文 →
← 呼び出しベル
「あいよーこれが鳴ったら取りに来てなー」🐱
🐵 席でゲームをする - ラーメンをつくる 🐱
🐵「お、できたか」 ← 呼び出し(通知) 「できたからベルを鳴らすでよー」 🐱
🐵「🍜 ゲットだぜ」 ← 🍜 「できたよー」🐱

Promise

Node.js では、これを Promise という仕組みを使って実現しています。
Node.js と言いつつ、JavaScript(ECMAScript)全体の仕様として定められているので、ブラウザでも使えますし、MDN でも説明を読むことができます

// 料理を受け取るための関数 (コールバック関数) の定義
const receiveMeal = meal => {
  console.log(`${meal}ゲットだぜ`);
};

// Promise!
order('全マシ').then(receiveMeal);

Promise is 何

Promise は、非同期処理を実現するために用意された、特別なオブジェクトです。
特別とはいえオブジェクトはオブジェクトなので、通常のオブジェクトと同様の特徴を持ちます。

  • 関数の結果として returnできる
  • メソッドを持っている
  • 変数に格納できる

非同期処理を実現するために、主として以下の機能のメソッドを持っています。
また、各メソッドもまたPromise を返すため、メソッドチェーンが使えます。

  • then メソッド
    • やりたい処理(コールバック関数)を事前に受け取り、処理が完了したらそれを実行する
  • catch メソッド
    • 失敗した際の処理(コールバック関数)を事前に受け取り、処理に失敗したらそれを実行する
// 料理を受け取るためのコールバック関数
const receiveMeal = meal => {
  console.log(`${meal}ゲットだぜ`);
};

// 料理が失敗したときのコールバック関数
const failedReceivingMeal = err => {
  console.log(err.message});
};

// order関数はPromiseを返す & 料理を行う。
// 調理が終わったら、thenに渡された関数にラーメンを渡す。
// 調理に失敗したら、catchに渡された関数にエラーを渡す。
order('全マシ').then(receiveMeal).catch(failedReceivingMeal);

メソッドチェーンの恩恵

メソッドチェーンが使えるので、

  1. 全マシを注文して、全マシを受け取る
  2. 写真屋で全マシと一緒に写真を撮って現像が終わったら写真を受け取る
  3. SNS に上げていいねされる
  4. ガッツポーズする

という処理も書くことができます。
「全マシの調理」「写真の現像」「いいね」が非同期処理です。

then に渡す関数が return した値が、次の then に渡したコールバック関数の引数に渡されます。
返却値はthen がいい感じにPromiseにラップしてくれるため、通常の値でも新たなPromiseでも同様に扱えます。

また、いくつthenがあっても、ひとつのcatchですべてのエラーをハンドルすることができます。

order('全マシ')
  .then(takePhotos)
  .then(uploadThePhotosToSnsAndWaitForLike)
  .then(doGutsPose)
  .catch(failedReceivingMeal);

まとめ

  • Promise はオブジェクトである
    • 関数から結果として返ってくる
  • 以下のメソッドを持っている
    • then: 成功したときの処理
    • catch: 失敗したときの処理
  • メソッドチェーンが使える

具体的な使用方法や、更に細かい仕様については、JavaScript Promise の本がおすすめです。

async / await

async / await は、Promise のシンタックスシュガーです。

経緯: やっぱり同期処理みたいに書きたかった

Promise はたいへんイケている仕組みなのですが、書き方が独特なのがイマイチでした。
(Promise の作り方は割愛しますが、なかなか……)
ほかの言語みたいに書きたい!という願いからか、同期処理っぽく書ける構文が策定されました。

async

async は、(無名)関数の前に書くことで、async function という特別な関数を定義します。
以下の特徴を持ちます。

  • ステートメント内で await 演算子が使える (後述)
  • (暗黙的に) Promise を返す
    • 素の Promise のコンストラクターは(上述の通り)なかなか独特ですが、asyncを使うと簡単に書けます。
      • return の内容が、Promise のthenのコールバック関数に渡る
      • throw すると、catchのコールバック関数に渡る
    • あくまでシンタックスシュガーなので、Promise の動きを理解してから使うのをおすすめします。
// 例; async functionなのでPromiseを返す
const order = async mealName => {
  console.log(`オーダー: ${mealName}`);

  if (soup.exists()) {
    // スープがあったら
    return cook(mealName); // 作ってreturn → thenのコールバックに渡る
  } else {
    throw new Error('スープないぜ'); // catchのコールバックに渡る
  }
};

await

await は、async functionの中でawait <Promise>と書くことで、
非同期処理の同期処理的な記述を実現します。
(あくまでもシンタックスシュガーなので、実際に扱っているのは非同期処理である Promise です)

以下の特徴を持ちます。

  • await の後ろに Promise を置くことで、Promise の実行完了をその場で待つ
  • 処理に成功した場合、then のコールバックに渡される内容を返す
  • async function の中でしか使えない (#FIXME!)

先ほどの

  1. 全マシを注文して、全マシを受け取る
  2. 写真屋で全マシと一緒に写真を撮って現像が終わったら写真を受け取る
  3. SNS に上げていいねされる
  4. ガッツポーズする

という処理が、以下のように書けます。

const doAsync = async () => {
  const ramen = await order('全マシ'); // 全マシができるまで待つ
  const photos = await takePhotos(ramen); // 写真が現像できるまで待つ
  const like = await uploadThePhotosToSnsAndWaitForLike(photos); // いいねが来るまで待つ
  doGutsPose();
};
async/awaitのエラーハンドリング

async/awaitは同期処理的に書くのが目的でした。
そのため、馴染み深いtry/catchが使えるようになっています。

const doAsync = async () => {
  let ramen;
  try {
    ramen = await order('全マシ'); // 全マシができるまで待つ
  } catch (err) {
    console.log(err.message);
    throw err;
  }

  console.log(ramen);
};

しかし、

👮「constを使え」

と言われてしまうこともあり、代わりにawait/catchというパターンも使えます。
awaitの後ろに置いているのはあくまで Promise なので、そのメソッドも普通に書くことができる、ということですね。

// エラー用のコールバック関数
const errHandle = err => {
  console.log(err.message);
  throw err;
};

const doAsync = async () => {
  const ramen = await order('全マシ').catch(errHandle);
  console.log(ramen);
};

まとめ

async

  • 関数の定義の前につけることで、特別な関数async functionをつくる
  • ステートメント内で await 演算子が使える
  • Promise を返す
    • return の内容が、Promise のthenのコールバック関数に渡る
    • throw すると、catchのコールバック関数に渡る

await

  • async function の中で使うことができる
  • await の後ろに Promise を置くことで、Promise の実行完了をその場で待つ
  • 処理に成功した場合、then のコールバックに渡される内容を返す

まとめ

  • 非同期処理とは「よそに処理を依頼したときに、その場で完了を待たない」処理のこと
    • よそとは、別のサーバーだったりディスク I/O だったり
  • Node.js ではPromiseという仕組みを使って実現している
  • Promise はオブジェクトである。関数から返されメソッドを持っている
    • then: 成功したとき
    • catch: 失敗したとき
    • それぞれコールバック関数を渡す
    • メソッドチェーンが使える
  • Promise のシンタックスシュガーに async/await がある
    • asyncは、async functionをつくる
      • 中で await が使える
      • (暗黙的に) Promise を返す
    • await は、後ろに Promiseを置いて使う
      • Promise が完了するまで待つ
      • thenコールバック関数に渡される内容を返す
    • エラーハンドリングには、以下が使える
      • try/catchパターン
      • await/catchパターン

おわり

不明瞭な点や誤った記述がありましたら、お手柔らかにお知らせいただけると幸いです 🙇

参考

概要をつかめたら、コードを書きつつ MDN などで詳細な仕様を見つつするとよい気がします。

MDN の Promise の説明は、残念ながらかなりわかりづらいので、、、理解できなくても落ち込まないようにしましょう。

REPL で動かすのもおすすめです。

# 例: async function が Promise を返すって本当?
-> % node
> const hoge = async () => {}
undefined
> hoge()
Promise {
  #略
  }
GitHubで編集を提案

Discussion