Blogical

AWS/Salesforceを中心に様々な情報を配信していきます(/・ω・)/

Step Functions で並列処理をする

こんにちは、ロジカル・アーツの井川です。

以前 Step Functions についての記事を書きましたが、そこでは扱わなかった並列処理を扱ってみたいと思います。

なお、記事の最後の方に今回作成するリソースを CloudFormation テンプレート化したものを載せてあります。必要であればご活用ください。

はじめに

今回はテーマとして、入力として 1 以上の数(10 進数)が与えられたとき、その数がある数学的な数(特殊数)かどうかを判定したいと思います。

ここでは次の 3 つの数を対象とします。これらの判定を、Step Functions の並列処理で行います。

ハッピー数

各桁の数の平方数の総和をとることを繰り返したとき、1 になる数のこと。


79

\begin{align} 79 \rightarrow 7^{2} + 9^{2} = 130 \\ 130 \rightarrow 1^{2} + 3^{2} + 0^{2} = 10 \\ 10 \rightarrow 1^{2} + 0^{2} = 1 \end{align}

ハーシャッド数

各桁の数の総和が自身を割り切る数のこと。


111 \begin{align}1 + 1 + 1 = 3|111\end{align}

a|bab を割り切ることを表す。

ナルシシスト

n 桁の数で、各桁の数の n 乗の総和が自身に一致する数のこと。


370

\begin{align}3^{3} + 7^{3} + 0^{3} = 370\end{align}

余談ですが、これらの数の選定基準はおおよそ次の通りです:

  • 名前が面白い(気がする)
  • 十分大きな数まで存在する*1
  • 判定がそれほど複雑でない

Step Functions の使い方

ある程度は前回扱ったのもあって、今回は ParallelResultSelector のみ、記事中での用途に関して説明を加えたいと思います。その他については、以前の記事の該当箇所を参照ください。

Step Functions でループ処理をしてみた - Blogical

ステート(状態)

ステートは受け取った入力に対して特定の処理を行い、その結果を別のステートに渡すことができます。全てのステートは Type(ステートの実行の種類を表す)フィールドを持っています。

Type: Parallel

並列ブランチを作成することができます。 特徴として、以下があります。

  • Parallel ステートでは、各ブランチが実行され、全てのブランチが終了状態に達するまでは次のステートに移行しない
  • 各ブランチの処理は、ブランチ内で完結する必要がある(そのブランチで定義されていないステートに移行することはできない)
  • 各ブランチの入力は、Parallel ステートの入力データのコピーが渡され、出力は各ブランチの出力を要素に持つ配列となる

Parallel ステートの持つフィールドには次のものがあります。

Branches(必須)
並列に実行する内容を定義した配列。
この配列の各要素は、ちょうど大元のステートマシンが持つように StatesStartAt フィールドを持つ必要があります。
ResultSelector(オプション)
下記の「入出力処理」の項を参照。

入出力処理

ステートの入出力は、Parameters フィールドや ResultPath フィールドなどを使用して、フィルタリング及び制御できます。以下の図は公式ドキュメントからの引用ですが、どういった流れでフィルタ及び制御がされるかを分かり易く表しています。

f:id:logicalarts:20201223173939p:plain Step Functions の入出力処理 - AWS Step Functions より引用

上で述べた通り、ResultSelector のみ説明します。

ResultSelector
キーと値のペアを ResultPath に渡します。値は静的、もしくはステートの結果から選択できます。ステートの結果を使用する場合、その JSON キーの末尾に .$ が必要です。値は $.field_name で参照します。静的な値を追加する場合は、単にキーと値のペアを追記します。

Lambda 関数を使った並列処理

「はじめに」でも述べた通り、入力として与えられた 1 以上の数が特殊数であるかを判定します。

以下に各ステートの概要と定義コードを載せてあります。出来上がるワークフローは次の図のようになります。点線で囲まれた枠にある文字列が各ステート(の名前)を表し、矢印がステートの移行先を表しています。

f:id:logicalarts:20210908102410p:plain

実装ステート一覧

IsPositive
概要:

一番最初に実行されるステートです。ステートマシンに対する入力が、このステートの入力になります。入力は {"number": n} の形とします。
number の値 n が正の整数でないなら Invalid ステートに移行し、 そうでなければ IsSpecialNumbers ステートに移行します。

Type:Choice
Next:Invalid または IsSpecialNumbers
Amazon ステート言語:
"IsPositive": {
  "Type": "Choice",
  "Choices": [
    {
      "Variable": "$.number",
      "NumericLessThanEquals": 0,
      "Next": "Invalid"
    }
  ],
  "Default": "IsSpecialNumbers"
}
IsSpecialNumbers
概要:

次の形の入力を持ちます:{"number": n}
Branches フィールドで定義されている各タスクを実行します。各タスクは number の値 n がその特殊数であるかに応じて True または False を返します。
ResultSelector を用いてこのステートの出力を {"IsHappy": IsHappy の実行結果, "IsHarshad": IsHarshad の実行結果, "IsNarcissistic": IsNarcissistic の実行結果} のようにします。
Result ステートに移行します。

Type:Parallel
Branches:
IsHappy
概要:

次の形の入力を持ちます:{"number": n}
Lambda 関数 IsHappy を呼び出します。この Lambda 関数は number の値 n がハッピー数であれば True を、そうでなければ False を返します。

Type:Task
Amazon ステート言語:
{
    "StartAt": "IsHappy",
    "States": {
        "IsHappy": {
            "Type": "Task",
            "Resource": "${IsHappy の ARN}",
            "End": true
        }
    }
}
IsHarshad
概要:

次の形の入力を持ちます:{"number": n}
Lambda 関数 IsHarshad を呼び出します。この Lambda 関数は number の値 n がハーシャッド数であれば True を、そうでなければ False を返します。

Type:Task
Amazon ステート言語:
{
    "StartAt": "IsHarshad",
    "States": {
        "IsHarshad": {
            "Type": "Task",
            "Resource": "${IsHarshad の ARN}",
            "End": true
        }
    }
}
IsNarcissistic
概要:

次の形の入力を持ちます:{"number": n}
Lambda 関数 IsNarcissistic を呼び出します。この Lambda 関数は number の値 n がナルシシスト数であれば True を、そうでなければ False を返します。

Type:Task
Amazon ステート言語:
{
    "StartAt": "IsNarcissistic",
    "States": {
        "IsNarcissistic": {
            "Type": "Task",
            "Resource": "${IsNarcissistic の ARN}",
            "End": true
        }
    }
}
Next:Result
Amazon ステート言語:
"IsSpecialNumbers": {
    "Type": "Parallel",
    "Branches": [
        {
            "StartAt": "IsHappy",
            "States": {
                "IsHappy": {
                    "Type": "Task",
                    "Resource": "${IsHappy の ARN}",
                    "End": true
                }
            }
        },
        {
            "StartAt": "IsHarshad",
            "States": {
                "IsHarshad": {
                    "Type": "Task",
                    "Resource": "${IsHarshad の ARN}",
                    "End": true
                }
            }
        },
        {
            "StartAt": "IsNarcissistic",
            "States": {
                "IsNarcissistic": {
                    "Type": "Task",
                    "Resource": "${IsNarcissistic の ARN}",
                    "End": true
                }
            }
        }
    ],
    "ResultSelector": {
        "IsHappy.$": "$[0]",
        "IsHarshad.$": "$[1]",
        "IsNarcissistic.$": "$[2]"        
    },
    "Next": "Result"
}
Result
概要:

ステートマシンの実行を正常に終了します。

Type:Succeed
Amazon ステート言語:
"Result": {
  "Type": "Succeed"
}
Invalid
概要:

ステートマシンの実行を停止し、失敗として記録します。

Type:Fail
Cause:初期値が正の整数ではありませんでした。
Error:NotPositiveNumberError
Amazon ステート言語:
"Invalid": {
  "Type": "Fail",
  "Cause": "初期値が正の整数ではありませんでした。",
  "Error": "NotPositiveNumberError"      
}

Lambda 関数の作成

Step Functions から Lambda 関数を呼び出すときに、その ARN が必要になりますので、あらかじめ Lambda 関数を作成します。

作成手順は次の通りです。

Lambda のコンソール画面に移動し、「関数の作成」をクリックします。

次の画面に移行したら、「一から作成」を選択し、「関数名」は作成する関数に応じて「IsHappy」、「IsHarshad」または「IsNarcissistic」を入力します。(以下の画像は「IsHappy」関数作成時のものです。)ランタイムは「Python 3.9」を選択し、「関数の作成」をクリックします。

f:id:logicalarts:20210907135509p:plain

画面が遷移したら、lambda_function.py のコードを作成した関数に応じた、以下のコードに全て置き換えます。

f:id:logicalarts:20210907162040p:plain

IsHappy

def lambda_handler(event, context):
    return is_happy(event["number"])

def is_happy(n):
    """与えられた数がハッピー数がどうか判定する。

    Args:
        n (int): 自然数(10進数)

    Returns:
        bool: ハッピー数であれば `True`
    """
    sn = str(n)
    appeared_nums = set()
    while n > 1 and (n not in appeared_nums):
        appeared_nums.add(n)
        n = sum(map(lambda x: int(x) ** 2, sn))
        sn = str(n)
    return n == 1

IsHarshad

def lambda_handler(event, context):
    return is_harshad(event["number"])

def is_harshad(n):
    """与えられた数がハーシャッド数がどうか判定する。

    Args:
        n (int): 自然数(10進数)

    Returns:
        bool: ハーシャッド数であれば `True`
    """
    factor = sum([int(x) for x in str(n)])
    return n % factor == 0

IsNarcissistic

def lambda_handler(event, context):
    return is_narcissistic(event["number"])

def is_narcissistic(n):
    """与えられた数がナルシシスト数がどうか判定する。

    Args:
        n (int): 自然数(10進数)

    Returns:
        bool: ナルシシスト数であれば `True`
    """
    sn = str(n)
    digit_num = len(sn)
    return sum(map(lambda x: int(x) ** digit_num, sn)) == n

ステートマシンの作成

Step Functions のコンソール画面に行き、「ステートマシンの作成」をクリックします。

コードでワークフローを記述」を選択し、タイプは「標準」を選択します。

f:id:logicalarts:20210907135515p:plain

そのまま下にスクロールし、左側の赤枠で囲ったところに以下のコードを貼り付けます。ARN のところは作成した Lambda 関数のものに置き換えて下さい。定義に不備がなければ、右側の状態遷移図が画像のようになります。確認できたら「次へ」をクリックします。

f:id:logicalarts:20210924141711p:plain

{
    "Comment": "与えられた数が、特殊数かどうかを判定するステートマシン。",
    "StartAt": "IsPositive",
    "States": {
        "IsPositive": {
            "Type": "Choice",
            "Choices": [
                {
                    "Variable": "$.number",
                    "NumericLessThanEquals": 0,
                    "Next": "Invalid"
                }
            ],
            "Default": "IsSpecialNumbers"
        },
        "IsSpecialNumbers": {
            "Type": "Parallel",
            "Branches": [
                {
                    "StartAt": "IsHappy",
                    "States": {
                        "IsHappy": {
                            "Type": "Task",
                            "Resource": "${IsHappy の ARN}",
                            "End": true
                        }
                    }
                },
                {
                    "StartAt": "IsHarshad",
                    "States": {
                        "IsHarshad": {
                            "Type": "Task",
                            "Resource": "${IsHarshad の ARN}",
                            "End": true
                        }
                    }
                },
                {
                    "StartAt": "IsNarcissistic",
                    "States": {
                        "IsNarcissistic": {
                            "Type": "Task",
                            "Resource": "${IsNarcissistic の ARN}",
                            "End": true
                        }
                    }
                }
            ],
            "ResultSelector": {
                "IsHappy.$": "$[0]",
                "IsHarshad.$": "$[1]",
                "IsNarcissistic.$": "$[2]"        
            },
            "Next": "Result"
        },
        "Result": {
            "Type": "Succeed"
        },
        "Invalid": {
            "Type": "Fail",
            "Cause": "初期値が正の整数ではありませんでした。",
            "Error": "NotPositiveNumberError"      
        }
    }
}

名前」に 「SpecialNumberChecker」 と入力します。他のところはそのままにして、 画面下部の「ステートマシンの作成」 をクリックします。

f:id:logicalarts:20210907135521p:plain f:id:logicalarts:20210907135525p:plain

作成されたらさっそく実行してみましょう。「実行の開始」をクリックすると、二枚目の画像の画面が出てきます。入力を {"number": 7} のような形にして、「実行の開始」をクリックすればステートマシンが実行されます。

f:id:logicalarts:20210907162044p:plain f:id:logicalarts:20210907162037p:plain

しばらく待つと結果が確認できます。グラフ(ビジュアルワークフロー)の Result ステートの箇所をクリックすると、特殊数の判定結果が確認できます。全て true になっているので、7 はハッピー数であり、ハーシャッド数であり、なおかつナルシシスト数であることが分かります*2

f:id:logicalarts:20210924141716p:plain

おわりに

並列処理の用途としては、複数の処理を同時に実行し、それらすべてが完了してから次の処理を行う必要がある場合になると思います。今回は例として単に数値の判定を行うだけのもでしたが、より複雑なユースケースにも対応できると思います。

Appendix: CloudFormation テンプレート

今回作成したリソースを CloudFormation テンプレート化したものを載せておきます。リソースを作成するのに十分な権限さえあれば、こちらのテンプレートを使用して記事中のステートマシンを作成できると思います。

AWSTemplateFormatVersion: 2010-09-09
Description: >-
  Step Functions 並列処理のブログ記事用テンプレート。
Resources:
  IsHappyFn:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          def lambda_handler(event, context):
              return is_happy(event["number"])

          def is_happy(n):
              """与えられた数がハッピー数がどうか判定する。

              Args:
                  n (int): 自然数(10進数)

              Returns:
                  bool: ハッピー数であれば `True`
              """
              sn = str(n)
              appeared_nums = set()
              while n > 1 and (n not in appeared_nums):
                  appeared_nums.add(n)
                  n = sum(map(lambda x: int(x) ** 2, sn))
                  sn = str(n)
              return n == 1

      Description: 与えられた数がハッピー数がどうか判定する関数。
      FunctionName: IsHappy
      Handler: index.lambda_handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Runtime: python3.9

  IsHarshadFn:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          def lambda_handler(event, context):
              return is_harshad(event["number"])

          def is_harshad(n):
              """与えられた数がハーシャッド数がどうか判定する。

              Args:
                  n (int): 自然数(10進数)

              Returns:
                  bool: ハーシャッド数であれば `True`
              """
              factor = sum([int(x) for x in str(n)])
              return n % factor == 0

      Description: 与えられた数がハーシャッド数がどうか判定する関数。
      FunctionName: IsHarshad
      Handler: index.lambda_handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Runtime: python3.9

  IsNarcissisticFn:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          def lambda_handler(event, context):
              return is_narcissistic(event["number"])

          def is_narcissistic(n):
              """与えられた数がナルシシスト数がどうか判定する。

              Args:
                  n (int): 自然数(10進数)

              Returns:
                  bool: ナルシシスト数であれば `True`
              """
              sn = str(n)
              digit_num = len(sn)
              return sum(map(lambda x: int(x) ** digit_num, sn)) == n

      Description: 与えられた数がナルシシスト数がどうか判定する関数。
      FunctionName: IsNarcissistic
      Handler: index.lambda_handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Runtime: python3.9

  ParallelSFN:
    Type: AWS::StepFunctions::StateMachine
    Properties: 
      DefinitionString: |-
        {
            "Comment": "与えられた数が、特殊数かどうかを判定するステートマシン。",
            "StartAt": "IsPositive",
            "States": {
                "IsPositive": {
                    "Type": "Choice",
                    "Choices": [
                        {
                            "Variable": "$.number",
                            "NumericLessThanEquals": 0,
                            "Next": "Invalid"
                        }
                    ],
                    "Default": "IsSpecialNumbers"
                },
                "IsSpecialNumbers": {
                    "Type": "Parallel",
                    "Branches": [
                        {
                            "StartAt": "IsHappy",
                            "States": {
                                "IsHappy": {
                                    "Type": "Task",
                                    "Resource": "${IsHappyFnArn}",
                                    "End": true
                                }
                            }
                        },
                        {
                            "StartAt": "IsHarshad",
                            "States": {
                                "IsHarshad": {
                                    "Type": "Task",
                                    "Resource": "${IsHarshadFnArn}",
                                    "End": true
                                }
                            }
                        },
                        {
                            "StartAt": "IsNarcissistic",
                            "States": {
                                "IsNarcissistic": {
                                    "Type": "Task",
                                    "Resource": "${IsNarcissisticFnArn}",
                                    "End": true
                                }
                            }
                        }
                    ],
                    "ResultSelector": {
                        "IsHappy.$": "$[0]",
                        "IsHarshad.$": "$[1]",
                        "IsNarcissistic.$": "$[2]"        
                    },
                    "Next": "Result"
                },
                "Result": {
                    "Type": "Succeed"
                },
                "Invalid": {
                    "Type": "Fail",
                    "Cause": "初期値が正の整数ではありませんでした。",
                    "Error": "NotPositiveNumberError"      
                }
            }
        }
      DefinitionSubstitutions: 
        IsHappyFnArn : !GetAtt IsHappyFn.Arn
        IsHarshadFnArn : !GetAtt IsHarshadFn.Arn
        IsNarcissisticFnArn : !GetAtt IsNarcissisticFn.Arn
      RoleArn: !GetAtt ParallelSFNExecRole.Arn
      StateMachineName: SpecialNumberChecker

  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: "/"
      Policies:
        - PolicyName: LambdaExecutionRole
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:*
                Resource: arn:aws:logs:*:*:*

  ParallelSFNExecRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: states.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: "/"
      Policies:
        - PolicyName: ParallelSFNExecRole
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - lambda:InvokeFunction
                Resource:
                  - !GetAtt IsHappyFn.Arn
                  - !GetAtt IsHarshadFn.Arn
                  - !GetAtt IsNarcissisticFn.Arn

参考リンク

*1:明確な基準は特にないですが、109 くらいを想定してました。

*2:私の確認方法が間違っていなければ、3 つの特殊数すべてに該当する数は、1(自明な場合)と 7 を除き、全ての自然数の中であとひとつしかありません。