AWS Step FunctionsでJSONataを使っていくための心得
こんにちは。サービス開発室の武田です。
AWS Step Functions便利ですよね(挨拶)。
2024年11月にAWS Step Functions(以下、SFn)で変数とJSONataがサポートされました。すでにプロダクション環境でも使い始めている方も多いのではないでしょうか。私も最近使い始めまして、これまでとはそもそも考え方を変える、パラダイムシフトが必要だと感じた部分がありました。
まだ「使い込む」と言えるレベルまで使っていませんが、気付いたことなどをまとめておきます。
このエントリではSFnでJSONataを使っていくという方向性で考えます。またステートマシンの定義は主にCDK(TypeScript)を使うことを想定しています。余談ですが、JSONataは「ジェイ ソナタ」と発音するんですね。おしゃれですね。
JSONataとは
前提知識の確認として、そもそもJSONataとはなんなのかを確認します。
JSONata自体はオープンソースの式言語です。そしてSFnでは、従来のJSONPathではなくJSONataを使用する場合、ステートの内部的な処理が簡略化されます。
出典: Step Functions での JSONata を使用したデータの変換
SFnの初学者がつまずくポイントとして、ResultSelector、ResultPath、OutputPathがよくわからないというのはよく聞く話です。これらがシンプルになるのは素直にうれしいですね。
Inputに結果を追加しない
従来のJSONPath形式ですと、後続のタスクに値を引き継ごうとした場合、Inputに結果を追加するか、DynamoDBなど外部のデータストアを使用することになりました。この状況はSFnの変数が登場したことで変わりつつあります。
変数はステートのAssign
プロパティに指定することで保存でき、後続のステートでは$var
の書式で参照できます。$.var
とドットが入ってしまうと全然意味が変わってしまうため気をつけてください!
JSONataではInputにタスクの結果を追加する操作は、自明ではありません。一方、JSONPath形式では次のように記述することで、結果を簡単に追加できました。
resultPath: '$.taskresult'
同じことをJSONata形式で実現する場合、次のように記述します。
output: '{% $merge([$states.input, {"taskresult": $states.result }]) %}'
なんだかややこしいですね。というわけで Inputに結果を追加するのはやめましょう 。代わりに次のシンプルなルールを考えました。
- 「直後のタスクのみ」使用する場合は、
Output
に指定する- あまり複雑な変換はしない。やるなら次のタスクの
Arguments
で行う
- あまり複雑な変換はしない。やるなら次のタスクの
- 「直後のタスク以外」の後続のタスクで使用する場合は、
Assign
に指定する(変数に保存する)
ステートマシンのInputは$states.context.Execution.Input
でいつでも参照できます。これと直前のタスクの結果、変数の値を組み合わせて、適宜Arguments
でタスクに必要な入力を操作してあげるイメージとなります。
文字列の連結は&と$joinを適宜使用する
JSONPath形式の場合、文字列の組み立てってものすごく面倒でした。具体的には次のようにわざわざFormat
関数を使用する必要があります。
'month.$': "States.Format('{}-{}', '2025', '05')"
JSONataは&
という連結用のオペレーターが用意されています。
"month": '{% "2025" & "-" & "05" %}'
また$join
関数を使っても同じことが可能です。
"month": '{% $join(["2025", "-", "05"]) %}'
2つか3つの値の連結では&
を、それ以上なら$join
を使うのが今のところベターかなという感想です。
デフォルト値を取る
「変数が定義されていればその値を、なければデフォルト値を使う」という場面はプログラミングでもよく遭遇するユースケースのひとつです。JSONPath形式の場合、これがとてつもなく面倒でした。なぜなら存在しないパスを指定した段階でエラーになってしまうためです。
そのため「事前にPassステートなどを駆使してデフォルト値を設定する」や「Choiceを使っての場合分け」といった複雑なステートマシンを組む必要がありました。
JSONataではこれも簡単に実現できます。プログラミング言語でもたびたび見る 3項演算子 がサポートされているので、これを使用します。
'name': '{% $exists($states.input.name) ? $states.input.name : "anonymous" %}'
個人的におもしろいなと思ったのは、$states.input.name
という式が、 nameが未定義でもエラーにならない ということです。仕様書では、 If it evaluates to nothing (no match or empty array), then the result of the operator expression is nothing と書かれています。
この仕様と$exists
関数を組み合わせることで上記の記述はうまく動作します。また、ややトリッキーな書き方ですが、次の書き方でもうまく動作するようです。
'name': '{% [$states.input.name, "anonymous"][0] %}'
セットする値がBoolean型でデフォルト値がfalse
の場合は、次の書き方も可能です。
'flag': '{% $exists($states.input.flag) and $boolean($states.input.flag) %}'
JSONを組み立てる際の挙動を理解する
特にこれはLambda関数に値を渡す際のPayloadの指定などで知っておくと便利だと思いました。一例として、count
とlimit
という2つの値をLambda関数の引数に指定する例を考えます。この値の指定方法として次の2つの方法が採用できます。
- パターン1:オブジェクトのプロパティそれぞれにJSONataで指定する
payload: sfn.TaskInput.fromObject({
count: '{% $count %}',
limit: '{% $limit %}',
}),
- パターン2:payloadに指定する値全体をJSONataで指定する
payload: sfn.TaskInput.fromText(`{
"count": $count,
"limit": $limit
}`),
基本的にはパターン1と2は同じ結果を生成します。ではどういうケースで差が出るかというと、式中で使っている変数がない場合です(今回ではcountかlimitのどちらかまたは両方)。
パターン1では変数が未定義だった場合エラーになってしまいます。そのため事前に変数が必ず存在しておくようにするか、ない場合はデフォルト値を使用するように宣言を変更する必要があります。
しかしパターン2では仮に変数が未定義でもエラーにはなりません。たとえば$limit
が未定義だと、次の値がLambda関数に渡されます(countの値は適当です)。
{
"count": 10
}
JSONataの式中でJSONオブジェクトを組み立てる場合、指定した変数がない場合、そもそもそのプロパティ自体が未定義にされます。そのため、Lambda関数の中で値にアクセスする際には気をつける必要があります。
個人的にはLambda関数に渡す値に関しては、SFnでデフォルト値などの面倒を見るより、Lambda関数内で対応をする方が好みです(つまりパターン2の方法がよさそう)。とはいえ、これもケースバイケースですので、ある程度柔軟に対応していくのがよいですね。今後システムを構築していく中で知見を貯めていきましょう。
まとめ
SFnを使用していて、まだJSONataに触れていない方はぜひ触ってみてください。世界が変わります。