ほりひログ

所属組織の製品 (Azure とか) に関連する内容が多めだけど、個人の見解であって、所属組織を代表する公式情報ではないです。

Azure Functions & Node.js で async 関数の中で context.done を呼ぶのはやめた方がいいよ、という話

Azure Functions V4 が Public Preview になりました🎉

azure.microsoft.com

で、このエントリーは V4 とは全く関係ないですが、Azure Functions での関数実装について、最近気になったことをいくつかのところで目にしたので書いてみました。

context オブジェクト

Node.js を使った Azure Functions では、各関数の引数に context というオブジェクトが渡ってきます。
これは、context.bindings を通したバインディングや、context.log を通した Azure 基盤側へのログ記録などが行えるオブジェクトです。

その中で、context.done というメソッドがあります。

context.done メソッド

これは、Azure Functions が V1 だった頃は、関数の処理が終わったことを Function Worker 側に伝える必須のメソッドです。

docs.microsoft.com

バインディングのログの情報を一通り詰めて、context.done を呼ぶと、Function Worker から Function Host 側に context オブジェクトが転送され、バインディングの実行やログの記録などが実行される仕組みです。

Azure Functions での Promise/async 関数と context.done

一方で、関数が Promise を返す場合、または V2 以降でサポートされた Javascript の async 関数を使った場合 (Promise を返す) は、関数コード内に明示的に context.done を書かなくてもよくなりました。
関数の戻り値が Promise (厳密には Thenable ) の場合は、Node.js Worker のこの辺り で、context.done が自動的に実行されます。

ですが、V1 から Azure Functions を使っている開発者の中には、context.done オブジェクトを呼ぶことが習慣になっている方がいるようです。

もちろん、Promise を返す関数や async 関数内で context.done を実行すること自体は問題ありません。
context.done は、2 回目の実行時に警告だけログに記録し、その他の処理をスキップするようになっています。

github.com

ただ気を付けてほしい点としては、context.done を実行した時点で関数の処理が終了するわけではない、ということです。
context.done を実行した時点で context オブジェクトにかかわる情報を Function Host に転送しますが、以降の処理はそのまま実行されます。

一方で、バインディングやログなど context オブジェクトにかかわる情報は、context.done の時点で Function Worker から Function Host に転送されて、バインディング処理/ログ記録等々が行われるので、context.done 以降に関連する処理を書いても反映されません。
これが、一見すると、関数処理が終わっているように誤認させる*1一因ですが、以降の関数コードもそのまま実行されるので、気を付けましょう。

例えば、context.done 以降に自分で SDK などを使って外部を呼び出ししている、などは、関数自体が終わっていないので、その呼び出しは実行されてしまい、書き込み用の呼び出しであれば書き込み自体も行われます。

どうするといいの?

async 関数内では、可能な限り context.done の利用はやめましょう。
既存の関数で既に context.done を書いていているのであれば、context.done とセットで return; を使いましょう。

(9/25 追記)
return してもダメなケースもあるので、ちゃんと書きます。

例1: エラー処理の中で「関数終了」を意図して context.done を使っている場合

こんな感じ。

module.exports = async (context) => {
  
  // ︙
  if (error) { // 何かの処理のエラー判定
    // エラー時の処理
    // ︙
    context.done(); // 「ここで終るよね?」 -> いえ、終わりません。
  }
  // 正常時の処理。context.done 以降も実行。
  // ︙
}

context.done のタイミングで関数を終了させたければ、単純に return してしまいましょう。

module.exports = async (context) => {
  // ︙
  if (error) { // 何かの処理のエラー判定
    // エラー時の処理
    // ︙
    return; // 「ここで終るよね?」-> はい、終わります
  }
  // 正常時の処理
  // ︙
}

例2: コールバック関数の中のエラー処理後に context.done を使っている場合

何かの処理をする非同期関数 someFunc のコールバック関数の中で、エラー時の関数終了として context.done を使っていたとします。

module.exports = async (context) => {
  someFunc((err) => {
    if (error) { // `someFunc` のエラー判定
      // エラー時の処理
      // ︙
      context.done(); // 「ここで終るよね?」 -> いえ、終わりません。
    }
    // 処理 A context.done 以降も実行。
    // ︙
  }); 
  // 処理 B someFunc 実行前に実行
  // ︙
}

この場合、処理 A も B も実行されます。
処理 A は context.done 実行後に、処理 B は someFunc のコールバック関数実行前に、それぞれ実行されます。

もしエラー時には処理 B も実行させたくなければ、下記のように Promise でくるんで try - catch すると見通しもよくなります。

module.exports = async (context) => {
  try {
    await new Promise ((resolve, reject) => {
      someFunc((err) => {
        if (error) { // `someFunc` のエラー判定
          // エラー時の処理
          // ︙
          reject(error); // 「ここで終るよね?」 -> 終らないけど、return した時に catch ブロックに飛ばします。
          return;
        }
        // 処理 A
        // ︙
        resolve();
      }) 
    });
    // 処理 B
    // ︙
  } catch {
    // エラー時の処理
    // ︙
  } 
}

(9/25 追記ここまで)

取り急ぎ。

*1: context.done 実行により Function Host へのログ送信を打ち切っているので以降のログが出ません。