25
24

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.

JavaScript/TypeScript で Promise を直列実行する

Last updated at Posted at 2019-05-07

2019/05/19 追記

目的

  • 複数の Promise を直列実行したい
    • 同期的実行・シーケンシャル・シリアルなど呼び方色々あるが、とにかく Not 並列に Promise[] を実行したい
  • Promise.all のような書き心地がいい
  • 技術的な正しさを追求した記事ではなく、雰囲気で使いこなせることを優先した記事である

動作イメージ

test.gif

この記事に出てくるサンプルコードについて

  • Promise, async/await はなんとなくわかってる人向け
  • axios で書いている
    • 標準の fetch は、ボイラープレートコードが読みづらい・この記事の本質から外れそうなのでやめた
  • よって、試す場合は jsfiddle などで実行のこと
    • やりすぎると API(GitHub) に対するちょっとしたDOS攻撃になりかねないため、節度を持ってご利用ください
      • そうなる前に rate limit で弾かれるけども

tl;dr

tl;dr
/**
 * @param {(() => Promise<T>)[]} promises
 * @returns {Promise<T[]>}
 * @template T
 */
const sequential = async (promises) => {
  const first = promises.shift()
  if (first == null) {
    return []
  }

  const results = []
  await promises
    // 末尾に空のPromiseがないと、最後のPromiseの結果をresultsにpushできないため
    .concat(() => Promise.resolve())
    .reduce(async (prev, next) => {
      const res = await prev
      results.push(res)
      return next()
    }, Promise.resolve(first()))

  return results
}

// 使う側
const main = async () => {
  const promises = [
    () => axios.get("https://api.github.com/search/users?q=siro"),
    () => axios.get("https://api.github.com/search/users?q=yamato"),
    () => axios.get("https://api.github.com/search/users?q=kiso"),
  ]

  const results = await sequential(promises)

  console.log(results)
}

main()
  • 以下、上記コードに至るまでの過程

実装過程

Lv.1 : べた書き

  • まぁそりゃできるでしょうね
Lv.1
const main = async () => {
  const results = []
  results.push(await axios.get("https://api.github.com/search/users?q=siro"))
  results.push(await axios.get("https://api.github.com/search/users?q=yamato"))
  results.push(await axios.get("https://api.github.com/search/users?q=kiso"))

  console.log(results)
}

main()

Lv.2 : for-of パターン

  • eslint-config-airbnb に怒られるやつ
    • ちなみにそのメッセージは「iterators/generators require regenerator-runtime, which is too heavyweight for this guide to allow them. Separately, loops should be avoided in favor of array iterations.」
    • 要するに「変換に必要なruntimeが重い」らしい
  • results.push で mutable なのが残念だが、割と誰でも読みやすい・直感的だと思うので嫌いじゃない
Lv.2
const main = async () => {
  const promises = [
    () => axios.get("https://api.github.com/search/users?q=siro"),
    () => axios.get("https://api.github.com/search/users?q=yamato"),
    () => axios.get("https://api.github.com/search/users?q=kiso"),
  ]

  const results = []
  for (const p of promises) {
    results.push(await p())
  }

  console.log(results)
}

main()

Lv.2- : ダメなパターン Array​.prototype.forEach

  • これは並列実行になってしまう
  • forEach の処理自体は await されず実行され、 results.push だけが await 後に実行される
Lv.2-
const main = async () => {
  const promises = [
    () => axios.get("https://api.github.com/search/users?q=siro"),
    () => axios.get("https://api.github.com/search/users?q=yamato"),
    () => axios.get("https://api.github.com/search/users?q=kiso"),
  ]

  const results = []
  promises.forEach(async (p) => {
    results.push(await p())
  })

  console.log(results)
}

main()
  • ちょっと Babel(トランスパイラ) の気持ちになってみよう
  • async/awaitthen に変換する場合、きっとこんなコードになるはずだ
  • これなら確かに並列実行になるだろうと予想できる
Lv.2-_kimoti
const main = async () => {
  const promises = [
    () => axios.get("https://api.github.com/search/users?q=siro"),
    () => axios.get("https://api.github.com/search/users?q=yamato"),
    () => axios.get("https://api.github.com/search/users?q=kiso"),
  ]

  const results = []
  promises.forEach((p) => {
    p().then((res) => {
      results.push(res)
    })
  })

  console.log(results)
}

main()

Lv.2-- : ダメなパターン Array​.prototype.map

  • そもそもこれでは results の型が Promise<AxiosResponse>[] になるので、全然ダメ
Lv.2--
const main = async () => {
  const promises = [
    () => axios.get("https://api.github.com/search/users?q=siro"),
    () => axios.get("https://api.github.com/search/users?q=yamato"),
    () => axios.get("https://api.github.com/search/users?q=kiso"),
  ]

  const results = promises.map(async (p) => {
    return await p()
  })
  // Babel の気持ち
  // const results = promises.map(async (p) => {
  //   return p().then()
  // })

  console.log(results)
}

main()

Lv.3 : reduce パターン

  • ググるとよく出てくるやつ
Lv.3
const main = async () => {
  const promises = [
    () => axios.get("https://api.github.com/search/users?q=siro"),
    () => axios.get("https://api.github.com/search/users?q=yamato"),
    () => axios.get("https://api.github.com/search/users?q=kiso"),
  ]

  const results = []
  await promises.reduce((prev, next) => {
    return prev.then((result) => {
      results.push(result)
      return next()
    })
  }, Promise.resolve())

  console.log(results)
}

main()
  • VSCodeを使っていれば、「This may be converted to an async function. ts(80006)」と指摘され、QuickFixで async/await に自動変換できるはず
  • then が消えて少し読みやすくなる
Lv.3_async
const main = async () => {
  const promises = [
    () => axios.get("https://api.github.com/search/users?q=siro"),
    () => axios.get("https://api.github.com/search/users?q=yamato"),
    () => axios.get("https://api.github.com/search/users?q=kiso"),
  ]

  const results = []
  await promises.reduce(async (prev, next) => {
    const res = await prev
    results.push(res)
    return next()
  }, Promise.resolve())

  console.log(results)
}

main()
  • 写経すると reduce の中の return() を書き忘れてうまく動かなかったり、コードが理解できなくてハマる
  • 初めて見た時は「なんで reduce で直列実行になるんだ?」と理解に苦しんだ・・・

Lv.3+ : reduce パターン改

  • 残念ながら、上記の「Lv.3」のコードには2点問題がある
    1. reduce の initialValue の Promise の resolve 結果が空(undefined)
    2. promises[2] の結果が push されない
  • よって、results の期待値と実際の結果が異なる
    • 期待値 : [ AxiosResponse(siro), AxiosResponse(yamato), AxiosResponse(kiso) ]
    • 実結果 : [ undefined, AxiosResponse(siro), AxiosResponse(yamato) ]
  • 「3つの非同期処理を直列に実行する」だけならこれでもよいが、結果が受け取りたい場合はもう一工夫必要なようだ
  • 上記問題を解消してみる
Lv.3+
const main = async () => {
  const promises = [
    () => axios.get("https://api.github.com/search/users?q=siro"),
    () => axios.get("https://api.github.com/search/users?q=yamato"),
    () => axios.get("https://api.github.com/search/users?q=kiso"),
  ]

  const results = []
  await promises
    .concat(() => Promise.resolve())
    .reduce(async (prev, next) => {
      const res = await prev
      // reduce の initialValue の resolve 結果を、結果配列に push させないため undefined チェック
      if (res != null) {
        results.push(res)
      }
      return next()
    }, Promise.resolve())

  console.log(results)
}

main()
  • うーん。汚い(白目)
    • // @ts-check を使ってると型推論結果がうまく合わないのか、エラーが出て更にストレスマッハ
    • reduce 使ってるのに results.push が必要で mutable になっているのもダサい
  • そもそもなぜ「問題2」が .concat(() => Promise.resolve()) で解決できるのか?
    • これを理解するため、ここで JavaScript の気持ちになって reduce をバラしてみる
Lv.3+_kimoti
const main = async () => {
  const promises = [
    () => axios.get("https://api.github.com/search/users?q=siro"),
    () => axios.get("https://api.github.com/search/users?q=yamato"),
    () => axios.get("https://api.github.com/search/users?q=kiso"),
  ]
  .concat(() => Promise.resolve())

  const results = []

  // 1ループ目
  const res = await Promise.resolve()
  if (res != null) {
    results.push(res)
  }
  const next0 = promises[0]() // siro

  // 2ループ目
  const res0 = await next0
  if (res0 != null) {
    results.push(res0)
  }
  const next1 = promises[1]() // yamato

  // 3ループ目
  const res1 = await next1
  if (res1 != null) {
    results.push(res1)
  }
  const next2 = promises[2]() // kiso

  // 4ループ目
  const res2 = await next2
  if (res2 != null) {
    results.push(res2)
  }
  const next3 = promises[3]() // .concat(() => Promise.resolve())

  console.log(results)
}

main()
  • 「*ループ目」(reduce の中身の処理) は promises.length 回分実行される
  • よって、下記のようになり「問題2」が解決できる
    • promises.length = 3 だった promises.concat(() => Promise.resolve()) で 1回分ループを足す
    • = 「4ループ目」が実行される
    • const next2 = promises[2]() // kiso の await 後の結果の push が実行される
  • さらに Babel の気持ちになって async/await を then に・・・は面倒くさいので省略

Lv.3++ : reduce パターン改二

  • results を作ってる処理を function sequential に切り出す
  • 切り出すことで、 mutable な処理は sequential に押し込まれ、メイン処理から見れば results は immutable となった
  • immutable 万歳!
Lv.3++
const sequential = async (promises) => {
  const results = []

  await promises
    .concat(() => Promise.resolve())
    .reduce(async (prev, next) => {
      const res = await prev
      // reduce の initialValue の resolve 結果を、結果配列に push させないため undefined チェック
      if (res != null) {
        results.push(res)
      }
      return next()
    }, Promise.resolve())

  return results
}

const main = async () => {
  const promises = [
    () => axios.get("https://api.github.com/search/users?q=siro"),
    () => axios.get("https://api.github.com/search/users?q=yamato"),
    () => axios.get("https://api.github.com/search/users?q=kiso"),
  ]

  const results = await sequential(promises)

  console.log(results)
}

main()

Lv.Final

  • だいぶマシにはなったが、まだ null チェックが目障り
  • 無駄な処理を省く・整理する
Lv.Final
const sequential = async (promises) => {
  const first = promises.shift()
  if (first == null) {
    return []
  }

  const results = []

  await promises
    // 末尾に空のPromiseがないと、最後のPromiseの結果をresultsにpushできないため
    .concat(() => Promise.resolve())
    .reduce(async (prev, next) => {
      const res = await prev
      results.push(res)
      return next()
    }, Promise.resolve(first()))

  return results
}

const main = async () => {
  const promises = [
    () => axios.get("https://api.github.com/search/users?q=siro"),
    () => axios.get("https://api.github.com/search/users?q=yamato"),
    () => axios.get("https://api.github.com/search/users?q=kiso"),
  ]

  const results = await sequential(promises)

  console.log(results)
}

main()
  • 改良の余地はあるかもしれないが、とりあえずこれで目的達成

Promise.all との比較

  • 完全に同じシグネチャにはできなかった
    • 宣言した時点 (axios.get("...") の時点) で各非同期処理が開始され、結果的に並列実行になるため
    • 直列にしたい場合は function で包んで、その function が実行されるまで Promise が発火しないよう実装する必要があるため
Promise.all
const main = async () => {
  const promises = [
    axios.get("https://api.github.com/search/users?q=siro"),
    axios.get("https://api.github.com/search/users?q=yamato"),
    axios.get("https://api.github.com/search/users?q=kiso"),
  ]

  const results = await Promise.all(promises)

  console.log(results)
}

main()

TypeScript + UnitTest

(TS + Jest の実行環境を簡単に用意するため、「create-react-app で生成 → 不要なもの削除」で作っている。少し冗長だがご容赦を)

まとめ

  • いちいち定義するのめんどくさいので標準化されないかなー
  • 気が向いたらもうちょい改良して npm package 化してみようと思う
25
24
3

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
25
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?