Node.jsでDurable Functionsを使うなら、入出力はObjectにした方が安心かもという話


注: 本記事は2020/09/28時点のものであり
https://www.npmjs.com/package/durable-functions の v1.4.3
https://www.nuget.org/packages/Microsoft.Azure.WebJobs.Extensions.DurableTask/ のv2.2.2
https://github.com/Azure/azure-functions-nodejs-worker のv1.2.2
で検証及び再現した事象です。

今後の変更及び修正により挙動が変わる場合があるため、バージョンが変わっていた場合各プロダクトのIssueなどを参照してください。

追記(2020/10/08):

本日リリースされた
https://github.com/Azure/azure-functions-durable-js/pull/217
azure-functions-durable-js v1.4.4で文字列で渡した値は文字列として扱うような変更が入ったため、以下の問題は一部を除いて解消されました。
booleanについてはまだ問題が残っていますが多くのケースでの問題は解消されたでしょう。

明示的に JSON.stringify しても、Activityでは暗黙的な変更がなされて数値として受け取っていた、みたいな使い方をしていた人は挙動が変わっているので注意しましょう。

追記終わり

結論だけ知りたい人向け

  • アクティビティ関数の入出力バインディングに文字列やbooleanなどの値を渡すと意図しない変換が行われる
  • オブジェクトでラップして渡せば基本的には問題ない

とりあえずこれを守っておけばハマることは少ないと思います。

起こる原因

Durable Functionsでは入出力バインディングのデータをJSON文字列としてやり取りしています(ByteArrayを除く)。
ref: Durable Functions のバインド - Azure | Microsoft Docs

関数でバインディングが行われた入力を参照する時は呼び出し元の入力が JSON.stringify されて、更に JSON.parse された値が渡ってくる認識して良いでしょう(厳密には異なります)。

Durable Functionsなどの実行環境とも言えるAzure Functions NodeJS Worker では渡ってきた値に全てに対して JSON.parse を適用します(ByteArrayを除く)。
ref: https://github.com/Azure/azure-functions-nodejs-worker/blob/11303c0dcf2ddcbf876e0cc453dbe3a731b769d9/src/Context.ts#L22

………とここまで内部でだいたいどんな事が行われているか記載しましたが、正直自分も処理を追いきれておらず、各文章の末尾に「っぽいことが行われています」が付きそうな具合です。
どういうこととお思いかと思うので、実際の例を挙げてみます。

不思議な挙動のバインディング

ここでは公式サンプルのSayHelloを例にとってみます。
https://github.com/Azure/azure-functions-durable-js/blob/32f442cd5fc3fe5d79451bc3daf91d5540f21d1d/samples/E1_HelloSequence/index.js
https://github.com/Azure/azure-functions-durable-js/blob/32f442cd5fc3fe5d79451bc3daf91d5540f21d1d/samples/E1_SayHello/index.js

SayHelloを一部改変して

module.exports = function (context) {
  context.log(typeof context.bindings.name);
  context.log(context.bindings.name);
  return context.bindings.name;
};

みたいにして、HelloSequenceからSayHelloに渡す値を変えてみましょう。

まず手始めに正常系で

yield context.df.callActivity("E1_SayHello", "Tokyo")

を実行してみます。
ログには

string
Tokyo

と表示されたと思います。
問題ないですね。

では次に数字を渡してみましょう。

yield context.df.callActivity("E1_SayHello", 123)

を実行してみます。
ログには

number
123

これも想定通りですね。

では数字を文字列型として渡してみます。

yield context.df.callActivity("E1_SayHello", '123')

を実行してみるとログには

number
123


私は文字列を渡したはずだが…?となりますが、これが上記で述べた

渡ってきた値に全てに対して JSON.parse を適用します(ByteArrayを除く)。

の弊害となります。
これが大きな問題となるのが、Number型で表現できる最大最小の値を超えた時などです。
TwitterなどのAPIではidの値が非常に大きな値となっていて、JavaScriptのNumber型では表現の出来ない値になっています。
そのため id_str というidをstr型で表現した値も一緒に送信しているのですが、それをDurable Functionsで扱おうとすると上記の挙動でnumber型にparseされ、表現できない範囲の値になり正常な値が扱えなくなるというものです。

他にも不思議な挙動はいくつかあり

yield context.df.callActivity("E1_SayHello", 'null')

を実行してみるログには

object
null

文字列を渡しているのにnullが渡ってきたり(同様に'{}'を渡すと {}が返ってきたり)

yield context.df.callActivity("E1_SayHello", true)

を実行してみるログには

string
True

と、 JSON.stringify(true) したら true が返ってきているはずでは?と言った挙動が見られます。

このことから JSON.stringify っぽいことをしているけれども、そうじゃない別の何かが行われているということがわかります。

このあたりで一旦grpcでゴニョゴニョする時に何かが怒ってるのかなぁとあたりは付けているのですが、調査しきれていません)

対処法

扱う対象の値によっていくつか選択肢はありますが、入力値に文字列や数値、bool値、null値をそのまま使うのではなく、

オブジェクトの値として渡す

これで解決します。

例えば

yield context.df.callActivity("E1_SayHello", { idStr: '123' })

としてSayHello関数に値を渡して、SayHello関数では

module.exports = function (context) {
  ...
  context.log(context.bindings.name.idStr);
  ...
};

として受け取ると言った具合です。
オブジェクトは JSON.stringifyっぽいことをされるのではなく、JSON.stringifyが行われるため、数値は数値のまま、文字列は文字列のまま渡されるようになります。

かなりエッジケースな話ではありますが、ユーザからの入力値で文字列が来ると思っていたのに何か違うものが渡ってきているぞ、というバグにお悩みの方は上記の方法で対処してみることも考えてみてください。

なお上記現象については
https://github.com/Azure/azure-functions-durable-js/issues/215
にてFB済みです。
今後の対応を期待しましょう。

ByteArrayでやり取りするのも一つの手かもしれませんね