AWS Step Functions でサーバーレス FizzBuzz を実現する

2021-04-02
日常で楽しむクラウドテクノロジー

Author : 富岡 純

こんにちは、ソリューションアーキテクトの富岡です。

みなさん、関数をオーケストレーションしていますか ? AWS Step Functions (以下 Step Functions) はサーバーレスな関数オーケストレーターで、依存関係のあるワークフローを実行するなどの用途で便利なサービスです。

Step Functions を使ってみると、なかなかどうして、表現力があるので、これだけで様々なことが実現できそうに思えてきますよね。今回はそんな Step Functions だけを使い、FizzBuzz 問題を解いてみようと思います。

このクラウドレシピ (ハンズオン記事) を無料でお試しいただけます »

毎月提供されるクラウドレシピのアップデート情報とともに、クレジットコードを受け取ることができます。 


1. 問題設定

1 ~ 30 までの数について、3 で割り切れるものは Fizz, 5 で割り切れるものは Buzz, 3 でも 5 でも割り切れるものは FizzBuzz, それ以外は数をそのまま文字列として返す StepFunctions のステートマシンを作成します。

期待する Output

[
  "1",
  "2",
  "Fizz",
  "4",
  "Buzz",
  "Fizz",
  "7",
  "8",
  "Fizz",
  "Buzz",
  "11",
  "Fizz",
  "13",
  "14",
  "FizzBuzz",
  "16",
  ...
]

Step Functions だけでどこまでこれを実現できるのでしょうか ? 楽しみですね !


2. 使えそうな Step Functions の機能

基礎的な Step Functions の使い方については、こちら をご参照ください。

Pass (State) 

後述する Parameters を利用して、State 間で受け渡す値の操作だけをするときに多用します。Step Functions 縛りでは基本的にこれを使って input/output を管理していくことになります。

Choice (State) 

条件分岐を可能にする State です。3 で割り切れるならば といったあからさまな条件分岐の他に、ループの終了条件の判定にも使えます。

Map (State) 

Array の各要素に対して操作を行うことができます。1 ~ 30 までの数について の操作ではこれが役に立ちそうですね。

Task (State) 

ステートマシンから別のステートマシンを呼び出すのに使います。サブルーチンのようなイメージです。

Parameters 

input/output の値を操作するのに使います。

Reference Paths 

JsonPath の構文である 「.」 や 「[ ]」 を用いて、オブジェクトや Array の値にアクセスすることができます。値の操作に使います。

[ ]」 の添字にはリテラルしか指定できないので、表現力はそこまで高くありません。

  • The operators @ .. , : ? * aren't supported.
  • Functions such as length() aren't supported.

執筆時点でこれらのオペレータ・機能は未サポートなのでこちらも注意が必要です。これらが使えると表現の幅がかなり広がるのですが、諦めて縛りプレイを楽しむことにしましょう。

Intrinsic Functions 

States.StringToJson は、リテラル以外で number を生成することができる唯一無二の能力を持った機能です。非常に強力な機能ですが、これを使って操作しだすと複雑度が激増することが目に見えていたため今回は使いませんでした。

States.Format も色々使えそうな機能ですが、今回は number を string に変換するためだけに使っています。


3. Solution 概要

さて、これらの機能を使って FizzBuzz を解いていきます。全てを 1 つのステートマシンで表現すると複雑になるので、いくつかのステートマシンに分割します。こうすることで、各ステートマシンの呼び出し毎に State machine data の境界線が作られ、さながらサブルーチンのローカル変数のように使えるようになるため、見通しもよくなります。

今回は以下 3 つのステートマシンを作ります。

  1. IsDivisible - 2 つの数が割り切れるか判定する
  2. FizzBuzzValueForNumber - ある数に対応する文字列 ("Fizz", "5" など) を返す
  3. SolveFizzBuzz - メインのステートマシン

以下より、1 つずつ見ていきます。

完成したものは こちら にアップロードしています。各 State machine は statemachine ディレクトリ以下にあるので、適時参照しながら読み進めていただければと思います。 AWS Serverless Application Model (SAM) のアプリケーションとしてデプロイも可能です。


4. 2 つの数が割り切れるかを判定する (IsDivisible)

  • 期待する入力例 : { "dividend": 9, "divisor": 3 }
  • 期待する出力例 : true

擬似コード dividend % divisor == 0 のような判定をすることを目指します。

いきなり最難関の処理です。これができれば Step Functions での FizzBuzz は解けたといっても過言ではないでしょう。

4-1. 方針

Step Functions には % のような算術演算の機能はないので、別の方法で割り切る判定をする必要があります。

n で割り切れる数というのは、n の倍数です。今回負数は考えないとして、0 から整数を数えて n 個ごとに割り切れる数がやってくるということです。例えば、divisor = 3 のときは以下のようになります。

割られる数 i 0 1 2 3 4 5 6 ...
divisor = 3 で割った余り remainder 0 1 2 0 1 2 0 ...
割り切れる true false false true false false true ...

この考え方を用いると、割り切れる判定は以下のような擬似コードで解けそうです。

function IsDivisible({ dividend, divisor }) {
  let i = 0;
  let remainder = 0;
  while (i != dividend) {
    i++;
    remainder++;
    if (remainder == divisor) {
      remainder = 0;
    }
  }
  return remainder == 0;
}

大枠はこの擬似コードのような方針で解いていきます。

4-2. 整数のインクリメント

方針の擬似コードについて、Step Functions で簡単に実現できないのは ++ の部分です。

現状 Step Functions では、リテラル以外に number を生成する手段は States.StringToJson しかありません(JsonPath の .length() が使えると幅が広がりますが、残念ながらサポートしていません)。

そのため、今回必要な number は 全てリテラルで列挙する ことにします。(1 ~ 30 までの数に対する FizzBuzz では、30 まであれば十分です。)

"integer": [0, 1, 2, 3, 4, 5, 6, 7, 8, ..., 29, 30]

JsonPath の 「:」 がサポートされていれば、 $.integer[1:] を使うことで、先頭の値がインクリメントされた Array ([1, 2, 3, ..., 29, 30]) を得ることができます。しかし残念ながら 「:」 は未サポートのため、今回は 連結リスト の要領で再起的なアクセスを可能にし、整数のインクリメントを実現します。

"integer": [0, [1, [2, [3, [4, [5, [6, ..., [29, [30]] ... ]]]]]]]

構造は { "head": 1, "tail": ... } でも { "car": 1, "cdr": ... } でもなんでもいいのですが、恐らく一番記述量の少ない [0, [1, ...]] という方式を用います。

これで、$.integer[1] のように 1 の添字へのアクセスでインクリメントを実現することができます ([1, [2, [3, ..., [29, [30]] ... ]]])。現在の値は $.integer[0] のように 0 の添字へのアクセスで得ることができます。なお 30 の次を得ようとするとエラーとなります。

今回は定数であることをわかりやすくするため、$.constants.integer にて初期化しました。ノイジーですが、一番左の値  0 を意味します。

"SetConstants": {
  "Type": "Pass",
  "ResultPath": "$.constants",
  "Parameters": {
    "integer": [0, [1, [2, [3, [4, [5, [6, [7, [8, [9, [10, [11, [12, [13, [14, [15, [16, [17, [18, [19, [20, [21, [22, [23, [24, [25, [26, [27, [28, [29, [30]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]
  },
  "Next": "Init"
},

擬似コードの i, remainder をそれぞれ $.state.iterator, $.state.remainder にマッピングします。

まずはそれぞれを 0 で初期化するために、 $.constants.integer をコピーします。

"Init": {
  "Type": "Pass",
  "Parameters": {
    "iterator.$": "$.constants.integer",
    "remainder.$": "$.constants.integer"
  },
  "ResultPath": "$.state",
  "Next": "Iterate"
},

インクリメントする際は以下のように、添字 [1] にアクセスして次の値を得ます。

"UpdateState": {
  "Type": "Pass",
  "Parameters": {
    "iterator.$": "$.state.iterator[1]",
    "remainder.$": "$.state.remainder[1]"
  },
  "ResultPath": "$.state",
  "Next": "ResetRemainderIfRequired"
},

remainder の方は divisor 回ごとに $.constants.integer をマッピングすることで 0 に戻します。

"ResetRemainder": {
  "Type": "Pass",
  "Parameters": {
    "iterator.$": "$.state.iterator",
    "remainder.$": "$.constants.integer"
  },
  "ResultPath": "$.state",
  "Next": "Iterate"
},

4-3. 条件分岐

擬似コードでは、i != dividend, j == divisor という 2 つの判定と条件分岐が必要になります。これは Step Functions の Choice State を用い、以下の Choice Rule で素直に判定することができます。

  • NumericEqualsPath
  • Not

4-4. IsDivisible ステートマシンの完成

以下のようなステートマシンができました。30 までの値しか判定できない制約がありますが、今回の用途では十分でしょう。

$ IS_DIVISIBLE_ARN="<IsDivisible ARN>"
$ EXECUTION_ARN=$(aws stepfunctions start-execution --state-machine-arn $IS_DIVISIBLE_ARN --input '{"dividend":6,"divisor":3}' --query '[executionArn]' --output text)

$ aws stepfunctions describe-execution --execution-arn $EXECUTION_ARN --query '{status:status,input:input,output:output}' --output table
# -------------------------------------------------------
# |                  DescribeExecution                  |
# +-----------------------------+---------+-------------+
# |            input            | output  |   status    |
# +-----------------------------+---------+-------------+
# |  {"dividend":6,"divisor":3} |  true   |  SUCCEEDED  |
# +-----------------------------+---------+-------------+

# execution が完了していない場合は、時間を置いて最後のコマンドをもう一度お試しください。

5. ある数に対応する出力を決める (FizzBuzzValueForNumber)

  • 期待する入力例 : { "subject": 3 }
  • 期待する出力例 : "Fizz"

FizzBuzz プログラムの肝である、ある数に対応する文字列を返すステートマシンです。 IsDivisible によって割り切れる判定ができれば、このステートマシンの実装はそんなに難しくありません。

以下のように IsDivisible を呼び、結果を保持します。

(3 で割る場合)

"SetDivisibleBy3Input": {
  "Type": "Pass",
  "Parameters": {
    "dividend.$": "$.subject",
    "divisor": 3
  },
  "ResultPath": "$.divisibleBy3Input",
  "Next": "DivisibleBy3"
}
"DivisibleBy3": {
  "Type": "Task",
  "Parameters": {
    "StateMachineArn": "<IS_DIVISIBLE_ARN>",
    "Input.$": "$.divisibleBy3Input"
  },
  "Resource": "arn:aws:states:::states:startExecution.sync:2",
  "ResultSelector": {
    "result.$": "$.Output"
  },
  "ResultPath": "$.isDivisibleBy3",
  "Next": "SetDivisibleBy5Input"
},

5 で割る方も同様に行い、以下のような Output ができることを期待します。

{
  "subject": 10,
  "isDivisibleBy3": { "result": false },
  "isDivisibleBy5": { "result": true },
  ...
}

それぞれの IsDivisible の結果を元に、素直に Choice State で条件判定をしていくだけです。3 でも 5 でも割りきれる、という判定には And を使うと良いでしょう。

"Judge": {
  "Type": "Choice",
  "Choices": [
    {
      "And": [
        {
          "BooleanEquals": true,
          "Variable": "$.isDivisibleBy3.result"
        },
        {
          "BooleanEquals": true,
          "Variable": "$.isDivisibleBy5.result"
        }
      ],
      "Next": "FizzBuzz"
    },
    ...
  ]
}

なお 3 でも 5 でも割りきれない場合は、$.subject の数を返しますが、もともと number であるため、string に変換する用途で States.Format() を利用します。

"Number": {
  "Type": "Pass",
  "Parameters": {
    "result.$": "States.Format('{}', $.subject)"
  },
  "OutputPath": "$.result",
  "End": true
}

5-1. FizzBuzzValueForNumber ステートマシンの完成

これで FizzBuzzValueForNumber の完成です。IsDivisible を分離できた場合は、素直に Step Functions の機能を使うだけで実現できます。

$ FIZZ_BUZZ_VALUE_FOR_NUMBER_ARN="<FizzBuzzValueForNumber ARN>"
$ EXECUTION_ARN=$(aws stepfunctions start-execution --state-machine-arn $FIZZ_BUZZ_VALUE_FOR_NUMBER_ARN --input '{"subject":15}' --query '[executionArn]' --output text)

$ aws stepfunctions describe-execution --execution-arn $EXECUTION_ARN --query '{status:status, input:input, output:output}' --output table
# -----------------------------------------------
# |              DescribeExecution              |
# +----------------+--------------+-------------+
# |      input     |   output     |   status    |
# +----------------+--------------+-------------+
# |  {"subject":15}|  "FizzBuzz"  |  SUCCEEDED  |
# +----------------+--------------+-------------+

# execution が完了していない場合は、時間を置いて最後のコマンドをもう一度お試しください。

6. メインのステートマシン (SolveFizzBuzz)

さて、FizzBuzzValueForNumber を使って FizzBuzz 問題を解いていきましょう。

このステートマシンの仕事は、1 ~ 30 までの数について、 FizzBuzzValueForNumber のステートマシンを呼ぶ ことです。

擬似コードにするとこんな感じですね。

range(1, 30).map(subject => StepFunctions.execute('FizzBuzzValueForNumber', { subject }))

6-1. 1 ~ 30 までの数

例によって、Step Functions では数値を動的に作成するのが不得意なので、ここでもリテラルを用いましょう。ただし、今回は Map State を用いて処理を行うために、素直な Array リテラルとして宣言します。

"States": {
  "Init": {
    "Type": "Pass",
    "Parameters": {
      "subjects": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]
    }
    "Next": "MapFizzBuzzValueForNumber"
  }
}

6-2. それぞれに対して FizzBuzzValueForNumber を呼ぶ

Map を使うことで素直に実現できます。

"MapFizzBuzzValueForNumber": {
  "Type": "Map",
  "ItemsPath": "$.subjects",
  "Parameters": {
    "subject.$": "$$.Map.Item.Value"
  },
  "Iterator": {
    "StartAt": "ExecuteFizzBuzzValueForNumber",
    "States": {
      "ExecuteFizzBuzzValueForNumber": {
        "Type": "Task",
        "Parameters": {
          "StateMachineArn": "<FIZZ_BUZZ_VALUE_FOR_NUMBER_ARN>",
          "Input.$": "$"
        },
        "Resource": "arn:aws:states:::states:startExecution.sync:2",
        "OutputPath": "$.Output",
        "End": true
      }
    }
  },
  "End": true
}

これで完成です !

$ SOLVE_FIZZ_BUZZ_ARN="<SolveFizzBuzz ARN>"
$ EXECUTION_ARN=$(aws stepfunctions start-execution --state-machine-arn $SOLVE_FIZZ_BUZZ_ARN --query '[executionArn]' --output text)

$ aws stepfunctions describe-execution --execution-arn $EXECUTION_ARN --query '{status:status, input:input, output:output}' --output table
# ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
# |                                                                                          DescribeExecution                                                                                         |
# +--------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
# |  input |  {}                                                                                                                                                                                       |
# |  output|  ["1","2","Fizz","4","Buzz","Fizz","7","8","Fizz","Buzz","11","Fizz","13","14","FizzBuzz","16","17","Fizz","19","Buzz","Fizz","22","23","Fizz","Buzz","26","Fizz","28","29","FizzBuzz"]   |
# |  status|  SUCCEEDED                                                                                                                                                                                |
# +--------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

# execution が完了していない場合は、時間を置いて最後のコマンドをもう一度お試しください。

無事に FizzBuzz を解くことができました !


7. 最後に

今回は Step Functions を用いて FizzBuzz 問題を解いてみました。整数のリテラルを用意するところなんかは若干無理やりですが、Step Functions だけでもなかなかの表現力があることを感じていただけたのではないでしょうか。

普段の Step Functions の利用に役立てる他、興味があれば是非今回のような縛りプレイで問題を解くことを楽しんでみてください ! (無限ループにはお気をつけて)


builders.flash メールメンバーへ登録することで
AWS のベストプラクティスを毎月無料でお試しいただけます

筆者プロフィール

富岡 純
アマゾン ウェブ サービス ジャパン合同会社
ソリューションアーキテクト

普段はインターネットメディア系のお客様にアーキテクティングなどの技術的なご支援をしています。
関数型プログラミングが好きです。変な方法で FizzBuzz を解くのも好きです。

AWS を無料でお試しいただけます

AWS 無料利用枠の詳細はこちら ≫
5 ステップでアカウント作成できます
無料サインアップ ≫
ご不明な点がおありですか?
日本担当チームへ相談する