トップ 追記

2018-10-07 [長年日記]

_ JavaScriptのPromiseでretryするいくつかの方法

JavaScript で Promise はそこそこ扱えるようになったけど、 retry をどうするか曖昧なままだったのでちょっと整理する。

まずダメなやつ

普通に同期的なコードの場合、やり方はいろいろあると思うけど、素朴にはこんな感じに書けると思う。

function s() {
  return Math.random() > 0.5
}

let retry  = 3
let result
while ( retry > 0 ) {
  result = s()

  if ( result ) {
    break
  } else {
    retry--
  }
}
console.log(result)

実際には retry 付きで呼ぶ function に閉じると思うけど、だいたいこんな感じかなーと思う。

で、Promise でもこれを元になんとかしてみようとすると、うまくいかない。s() を Promise を返すようにしてみたとして retry 部分を書き換えるとこんな感じになると思う。

while ( retry > 0 ) {
  result = s()

  s()
 .then(() => {
    break
  }).catch(() => {
    retry--
  })
}
console.log(result)

しかし上のコードを Node.js 6 で実行すると以下のように怒られる。

   break
   ^^^^^

SyntaxError: Illegal break statement
そりゃそうだ

Promise は callback 地獄を解消するものと思われているけど、実際には引数に対してではないけど結果に対して callback を与えて処理をチェインする仕組みであり、ということは上に書いた break の scope は Promise を呼んでいる外側とは異なる、while ループと異なるものになっている。

つまり、break するものがない場所で break しようとしている

Promiseのchainで書く特徴を生かしてシンプルに呼び出す側がretryする例

retry 回数などの抽象化を一切無視すると以下の形になる。

s().catch((err) => {
  return s()
}).catch((err) => {
  return s()
.then((r) => {
  console.log(r)
})
.catch((err) => {
  console.error(err)
})

Promise が rejected になったらもう一度同じ function を call するだけ。これで 3回試行できている。

途中 Promise をわざわざ return しているのは、こうしないと Node.js で UnhandledPromiseRejection の警告が出るからだけど、確かに返しておく方が理に適っていると思う。

cf. 【JavaScript】Promiseのリトライ処理をちゃちゃっと - エムティーアイ エンジニアブログ

呼び出される側がretry込みで実行する例

上のやり方は呼び出す側の処理に retry を入れてしまっているため、呼び出し箇所が複数あった場合に DRY でなくなってしまう。できれば呼び出される側で retry 込みで処理してほしい。

これは再帰させるとすっきり書ける。

function p(retry = 3) {
  return new Promise((resolve, reject) => {
    let r = Math.random()
    if ( r >= 0.5 ) {
      if ( retry > 0 ) {
        p(--retry).then(resolve).catch(reject)
      } else {
        return reject(false)
      }
    } else {
      resolve(r)
    }
  })
}

こうすると p() を呼ぶ側は p() を普通の Promise のように扱っているにも関わらず、中で任意の回数 retry させることができる。

※ retry 回数を引数に与えて再帰で処理していく部分の説明は割愛する。

キモは.then(resolve).catch(reject)

これがないと結果の「クチ」を「戻す」ことができない。つまり retry に入ったら resolve も reject も外からは分からないままなんとなく終了してしまう。

そう、ここで指定している resolve, reject こそが最初の return new Promise() に渡ってくるものであり、呼び出す側の

p()
.then()
.catch()

を実現するために必要なものなのだ。ということで

.then(resolve).catch(reject)

を与える必要がある。

通常の再帰は自分自身を call する際に return しながら戻ってくるのだが、Promise は渡ってくる resolve, reject を then, catch に与えることで「chainableな状態を維持する」形になるようだ。

気持ち悪いが、よく考えると納得はいく。ただこれはイディオムとして覚えてしまうのが早いように思う。

cf.

参考

Tags: JavaScript

2018-10-01 [長年日記]

_ Promise.reject()で処理は止まるがそのタイミングは「期待」通りとは限らない

まとめ

止まることは止まるが、思ったタイミングで止まるかどうかは分からないので、それ前提で作る必要がある。

例えば以下は思い違い。

  • 最初の一つが rejected になったら、その一つめで必ず止まる

実際には

  • 最初の rejected が Promise.all に伝わる段階ですでにいくつも並列で処理が走っている可能性があるので、rejected 以降にエラーがバンバン発生することはあり得る

もっと言うと rejected が伝わった段階で中の複数の Promise の結果は fulfilled と rejected が混ざっている可能性もあるので、全体の rejected を retry すると fulfiled になった Promise をもう一度実行する場合もある。

ということは例えば何かの POST 処理を Promise.all で回しつつ retry を考慮している場合、すでに成功しているデータが複数回 POST される可能性がある。

もっと言うと、

そこで何かの拍子に unique 制約に引っかかって逆にエラーがあとから起きたりもする。

ということ。

非同期並列面倒くせぇ…。

何で悩んでいたのか

Promise.all(Array.map(e => Promise))

なコードがあった。これを呼び出す function foo() があった。

function foo(data) {
  Promise.all(data.map(e => Promise))
}

この foo() を直接読んでテストしている時は最初の一つが reject になった段階で止まっていた。で、

一つめが rejected になったら止まると思い込んでしまった

しかし、次に

function bar() {
  ..
  ..
  foo()
  .then(() => {
    ..
  })
  .catch((err) => {
    ..
  })
  ..
}

の bar() でテストすると、

大量にエラーが起きてしまったので、「止まらないじゃん!」と、また勘違い

「何かコードの書き方を間違えているから止まらないのでは…」とありもしない原因を探る旅に出てしまったのでした…。

正解は、Promise.all([Promise, ..]) の実行に至るまでに払っている実行コストの間で中の Promise が並列に実行を開始してしまい、結果、

呼び出し元が rejected でストップしたタイミング以降にいくつもエラーが起きて当然の状況になる

つまり

一つめの rejected で止まるのも、まったく止まっていないように見えるのも、どちらも正しい!!!

非同期並列難しい!

何が難しいって同期的な処理を期待しているシステムと繋げるのが難しい!

愚痴まじりで言うと Node.js をこれまで避けてたのはやっぱり正しかったなぁと思った。それは動作が安定しているかどうかとか、高速かどうかとか、そういうことじゃなくて動作の考え方そのものが難しいので、カジュアルな変更を前提にしたコードには向いていないなぁという意味。記法だけを見れば callback 地獄は Promise によって解決されたかのように見えるが、動作については非同期並列という特徴はそのままなので、伝統的な、同期的で排他ロック可能なコードを扱う脳との切り替えおよび整合性の確保が難しい。整合性の確保は複数のサービスの連携が前提となるマイクロサービスにおいては必須の要件になってくる。

今後 FaaS, サーバレス化を進めるに当たってはここはキモになってくるし、

  • スケール、非同期前提のインフラに載せつつ変更の少ない部分に使っていく
  • 同期的な仕組みの側も非同期並列の仕組みから繋げる際のエラーの起き方を知っている必要がある
  • モニタリングの意味も同期的な仕組みとちょっと変わってくる

かなぁということを思っている。


2018-09-29 [長年日記]

_ StreamをEvent Handlerを使ってPromiseに変換して成否を待ったり途中で止めたりする

先日 Stream の練習をして分かった気になっていましたが、ダメでした。何がダメだったか。(そもそも普段 Node.js を書いていないのがダメなんだけど。)

  1. 「結局 EventEmitter」って自分で書いといて結果を同期的に「待って」いた*1
  2. Stream が Node.js 標準 Stream の Event Emitter を実装しているとは限らない

1 については今回は例によって Promise によって解決します。

2 は具体例を挙げておしまいです。ちゃんと調べろ、に尽きます。

StreamのEvent Listenerでエラーを取得する

Node.js Streams: Everything you need to know – freeCodeCamp.org

ここによくまとまっていて、

Stream#on('error', fn)

で書けそうなことが分かる。

(new Reable())
.pipe(new Writable())
.on('error', () => {
  ..
})

で書ける。ただし、取得して中に処理を書けるだけで返せない。ということは外から処理を与える callback 地獄だ。これは困る。

StreamをPromiseにしてエラー処理のクチを外に出す

イベントと callback が困るなら解決策は Promise になる。たぶん雰囲気はこんな感じだ。

stream2promise(data) {
  return new Promise((resolve, reject) => {
    (new Reable())
    .pipe(new Writable())
    .on('error', (err) => {
      return reject(err)
    })
    .on('finish', () => {
      resolve()
    })
  })
}

うん、なんかイケそう。

ところが実際にはすべての Stream でエラーは起きるし、'error' event そのものは stream 上を伝播したりしないし、'error' event が emit されても処理が止まるわけではないので、これでは不十分。

Stream | Node.js v10.12.0 Documentation

The stream is not closed when the 'error' event is emitted.

具体的には Reable Stream で 'error' が起きた場合でも stream 自体を cancel する人がいなければ Writable Stream の 'finish' event は emit されてしまう。ということは 'error' が起きようが何しようが resolve() が呼ばれる、つまり正常終了してしまうということだ。「'error' event は起きているが正常終了する。」これは期待する動作ではない。

Streamをちゃんと止める

そのためには各 Stream に対してきちんとエラー処理が必要ということになる。

ということでできあがりは以下のようになる。

stream2promise(data) {
  return new Promise((resolve, reject) => {
    const reable   = new Reable()
    const writable = new Writable()

    reable.pipe(writable)

    reable.on('error', (err) {
      reable.unpipe()     // 以降の Stream で余計な event が emit されないように
      return reject(err)  // 起きたエラーを reject に渡す
    })

    writable.on('error', (err) {
      return reject(err)
    })

    writable.on('finish', () => {
      resolve()  // ここに何を渡すかは思案のしどころ
    })

    reable.push(data)
    reable.push(null) // これは標準の Stream の API に従うとこうなる
  })
}

こんな感じ。

うーん、Promise は呼ぶ側はまだ比較的マシだけど、中身はアレな感じになりやすいねぇ。まー何はともあれ、これで以下のように Stream 処理を Promise で受け取れるようになる。

stream2promise(data)
.catch((err) => {
  ..
}).then(() => {
  ..
})

StreamのEventEmitter実装状況には注意が必要

例えば今回書いたコードは Node.js 標準の Stream を基本にしている。前回、「readable#push(null) とか普通書かないだろ」みたいなことを言っていたが、あれは間違いだったことが分かった。実は前回書いたコードでは今回のようなことはできないのだ。

前回は

を利用したが、

  • memory-streams.ReadableStream.on('end') は emit されない
    • ということは WritableStream.on('finish') も emit されない*2
  • process.stdout.on('finish') は emit されない

つまり、意図通りに正常終了を判定するのは難しいです。

また memory-streams と似たような機能を持つ

を利用すると stream 処理できない object を String に無理やり変換してしまうので [object Object] が Writable Stream に渡ってしまう。

「メモリの中身を Stream っぽく扱えるコードはすぐできそうじゃね?」と思っていたけど、自分がそんな発想をするくらいなのでやはりカジュアルなコードが多いみたいで、ちゃんと標準の API を使った方がいいなと思い直しましたとさ。

*1 以前 Promise を使って解決したのと同じなのに

*2 readが終わらないとwriteも終わらないようだ。実際の処理が終わっていてもこれを検知することができないし、もしかしたらモノによってはwritable streamの動作に支障が出るかもしれない。


2018-09-25 [長年日記]

_ Node6のテストコードでasync/await

先日からの続きで Node6 でコードを書いてるわけだけど、どうしてもテストの際には同期的に処理されてほしい before, after な辺りがあるわけですよ。

追加したもの

yortus/asyncawait: Callback heaven for Node.js with async/await

他にもいくつか似たようなライブラリはあるが、これを選んだ理由は

async () => {}

const {async} = require('asyncawait')

async(() => {})

と書けて、字面的に構文としての async/await に対していちばん違和感が少ないため。

これで

desribe('..', () => {
  beforeEach(async(() => {
    ..
  }))

  it('..', async(() => {
    ..
  }))
})

のように書ける。

除外したもの

eslint では test/ 以下を対象としないようにしておく。

eslint-plugin-node を使って async/await がコードに混入しないようにしている場合、ライブラリで追加した async/await も弾かれてしまうため。

テストコードに lint ... 要らないよね? ダメすか。

しかしこれ

同期的に処理されてほしいから async/await が欲しいと考えてるわけで、async とは?