StepFunctionsがJSONataに対応したらしいので試してみた

# はじめに
こんにちは。
こちらの記事は[株式会社ラクスのアドベントカレンダー](https://qiita.com/advent-calendar/2024/rakus) 18日目の記事になります。

最近、AWSのStepFunctionsにアップデートが入り、変数が使えるようになったり、JSONataに対応したようです。
今回は、後者のJSONataへの対応について、実際に試してその効果を確かめてみようと思います。

# JSONataについて
近年、JSON形式のデータは、API通信やデータストレージ、イベント駆動アプリケーションなど、多くの分野で標準的なデータ形式として活用されています。しかし、JSONデータを扱う上で、多くのエンジニアが直面するのが「特定のデータを抽出したい」「形式を変換したい」といったニーズです。これを効率的に実現するために開発されたのがJSONataです。

JSONataについては、参考になる記事があるので紹介しておきます。

https://qiita.com/yamachan360/items/91805334890d18ef6e23

こちらの5分程度の動画も、見ておくとイメージが掴めると思います。
英語ですが、音無しでも大体分かります。

<iframe width="560" height="315" src="https://www.youtube.com/embed/ZBaK40rtIBM?si=EpAD_YeOSRny1tAP" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

# 実際に試してみた
この便利なJSONataが、最近StepFunctionsで使えるようになったそうです。
（今後はデフォルトでJSONataの設定になるそう。）
[公式ドキュメント](https://docs.aws.amazon.com/step-functions/latest/dg/transforming-data.html#transforming-reserved-variable-states)

そこで実際にJSONPathとJSONataで同じシナリオを再現してみて、違いを確認してみようと思います。

下記のようなシナリオをStepFunctionsで実装するとします。
### シナリオ: 注文処理ワークフロー
入力データとして以下のような注文情報を受け取ります：
```json:json
{
  "orderId": "12345",
  "customer": {
    "name": "John Doe",
    "email": "john.doe@example.com"
  },
  "items": [
    { "productId": "A1", "quantity": 2, "price": 50 },
    { "productId": "B2", "quantity": 1, "price": 100 }
  ],
  "status": "PENDING"
}
```
### 目的
- 注文金額の計算
- 注文金額が 100 ドル以上の場合は VIP 扱いにする
- 結果を最終的な注文処理結果として出力



## JSONPathでの実装
まず従来のJSONPathを用いたステートマシンを構築します。

<details>
<summary>CDKのコード</summary>

```typescript:typescript
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as sfn from 'aws-cdk-lib/aws-stepfunctions';
import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks';
import { Construct } from 'constructs';

export class SfnJsonPath extends cdk.Stack {
    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);

        // 1. lambdaの定義
        const calculateOrderTotalLambda = new lambda.Function(this, 'CalculateOrderTotalLambda', {
            runtime: lambda.Runtime.NODEJS_18_X,
            code: lambda.Code.fromInline(`
                exports.handler = async (event) => {
                    const items = event.items || [];
                    const totalAmount = items.reduce((sum, item) => sum + item.quantity * item.price, 0);
                    return { totalAmount };
                }
            `),
            handler: 'index.handler',
        });

        // 2. lambdaを呼び出すタスクの定義
        const calculateOrderTotalTask = new tasks.LambdaInvoke(this, 'CalculateOrderTotalTask', {
            lambdaFunction: calculateOrderTotalLambda,
            // lambdaの結果を次のステップに渡すために$.Payloadに格納
            outputPath: '$.Payload',
        })

        // 3. VIPかどうかの判定を行う Choice
        const isVIPTask = new sfn.Choice(this, 'IsVIPTask')
            .when(
                sfn.Condition.numberGreaterThanEquals('$.totalAmount', 100),
                new sfn.Pass(this, 'VipProcessing', {
                    result: sfn.Result.fromObject({
                        status: 'VIP',
                        message: 'Order is proccessed as VIP.',
                    }),
                    resultPath: '$.result',
                }),
            )
            .otherwise(
                new sfn.Pass(this, 'StandardProcessing', {
                    result: sfn.Result.fromObject({
                        status: 'Standard',
                        message: 'Order is processed as standard.',
                    }),
                    resultPath: '$.result',
                }),
            );

        // 4. ステートマシンの定義
        const definition = calculateOrderTotalTask.next(isVIPTask);

        const stateMachine = new sfn.StateMachine(this, 'OrderProcessingStateMachine-JSONPath', {
            definition,
            stateMachineType: sfn.StateMachineType.STANDARD,
        })
    }
}
```
</details>


デプロイされたステートマシンが以下のものになります。
![スクリーンショット 2024-12-15 15.10.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2969543/60bea24b-d7ef-1ffa-5de0-09e0c5d78614.png)

**1. lambdaのステップ**
```javascript:javascript
exports.handler = async (event) => {
    const items = event.items || [];
    const totalAmount = items.reduce(
        (sum, item) => sum + item.quantity * item.price, 0
    );
    return { totalAmount };
}
```
JSONを解析し、合計金額を計算し、totalAmountとして出力する処理を記述。

**2. Choiceのステップ**
![スクリーンショット 2024-12-15 16.14.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2969543/7ed54769-6c5c-a776-49c3-3b371cab8b42.png)
前ステップから`$.totalAmount`を受け取り、100以上かどうかで分岐させる。

**3. PASSのステップ**
![スクリーンショット 2024-12-15 16.16.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2969543/2b280d76-97c1-d279-2b03-a96c0955a2fe.png)
分岐ごとに、JSONの中身を作成し出力


## JSONataでの実装
続いて、今回新しく対応したJSONataを用いて構築してみます。
CDKではまだJSONataの指定ができないようなのでGUIから作成しました。
作成したステートマシンは以下になります。
![スクリーンショット 2024-12-16 18.13.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2969543/74cb18e9-c830-7410-e430-9965ec2b86f3.png)
### なんと、Passステートの一つのみになってしまいました。
&nbsp;
何をしているかというと、Passステートの出力に以下のように書いているのみです。
![スクリーンショット 2024-12-16 18.41.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2969543/90604b4c-8a72-e2c6-e66b-b7edc7bcd272.png)


```json:
{
  "status": "{% $sum($states.input.items.(quantity * price)) >= 100 ? \"VIP\" : \"STANDARD\" %}",
  "message": "{% $sum($states.input.items.(quantity * price)) >= 100 ? \"Order is proccessed as VIP.\" : \"Order is proccessed as STANDARD.\" %}"
}
```

（今回は例が悪く条件が冗長になっていますが。。。）
JSONataの機能で、$sum関数や、条件分岐などを書くことができるようになったことで、
**lambdaとChoiceが必要なくなりました**。

出力も全く問題なくできています。
![スクリーンショット 2024-12-16 18.20.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2969543/2a91dc59-68ca-5927-cc16-bfdc1ee122fc.png)



### 注意点
JSONataでデータの操作を実装する中で、これまでと同じような感覚で書くと躓く点がいくつかあったので、注意点として残しておきます。
- **`.$`や`$.`は使わない**
JSONPathで書いていたときは、前のステップから値を受け渡すときは`.$`, `$.`を用いていましたが、JSONata形式ではこの書き方は使えません。
代わりに`$states`予約語を使い、`$states.input.~`で値を参照することができます。
https://docs.aws.amazon.com/step-functions/latest/dg/transforming-data.html#transforming-reserved-variable-states
&nbsp;
- **JSONata構文を使うときは`{% %}`で囲む**
JSONata構文を書く部分では、`{% %}`で囲む必要があります。
囲まなくてもエラーなく保存できてしまうので、覚えておきましょう。

# まとめ
StepFunctionsがJSONataに対応したことにより、複雑なデータ処理であっても、より少ないステートの数に簡略化することができました。
データの処理が簡潔に書けるようになったことで、よりステートの入出力をシンプルにすることができます。
今回はかなり簡単な例で試しましたが、もっと複雑な処理をしているケースだとより効果を実感できそうです。

しかし、JSONataは強力なデータ処理の選択肢である反面、その自由さからかなりアクロバティックな処理も書けてしまいます、
複雑すぎる処理をJSONataで行なってしまい、結局何をしているのか分からなくなってしまっては本末転倒ですので、脳死でJSONataを使うというよりはあくまで**簡略化しメンテナンス性をあげる**ことを目的として使うのが良さそうです。

ここで紹介した以外にもJSONataの功罪はあると思いますので、気になる方は是非ご自分で色々試してみて下さい！

# 参考
- https://qiita.com/yamachan360/items/91805334890d18ef6e23
- https://docs.aws.amazon.com/step-functions/latest/dg/transforming-data.html#transforming-reserved-variable-states