Step Functions para no morir. Parte 4: Creación de Step.

Giuliana Olmos - Feb 22 '22 - - Dev Community

Holis!
HOY... Hoy es el día en que vamos a crear nuestra stepFunction.

Elmo on fire

En el capítulo de hoy vamos a estar trabajando en nuestra máquina de estado. Y lo vamos a hacer desde el código, usando Serverless Framework.

Va a estar dividido en partes.

  • Iniciar proyecto
  • Orquestación
  • Lambdas
  • SQS
  • Eventos
  • Roles
  • Voilà

Iniciar proyecto

Vamos a comenzar iniciando nuestro proyecto Serverless.
Para eso vamos a pararnos en la carpeta en la cual queremos guardar nuestro repo (supongamos la carpeta contratarWifiPlan) y abrimos la consola, ahí vamos a correr el siguiente comando.

sls create --template aws-nodejs

Enter fullscreen mode Exit fullscreen mode

Esto va a crear un template de Serverless para nuestro proyecto utilizando obviamente node.

Comando por consola

Una vez creado el template vamos a abrir esa carpeta con nuestro IDE y vamos a contar con un boilerplate que cuenta con 3 archivos creados.

Boilerplate serverless

serverless.yml => Es donde va a estar toda nuestra configuración’
handler.js => El ejemplo de una lambda
.npmignore => En este archivo vas los documentos que queremos que se ignoren cuando corremos el npm.

Para nuestro proyecto vamos a hacer algunos cambios.
1 - Creamos una carpeta llamada src.
2 - Dentro de esta vamos a crear otras 3 carpetas llamadas lambdas, resources y stepFunctions.

Carpetas del src

En el archivo serverless.yml, vamos a hacer los siguientes cambios:

  • En service va a ir el nombre de nuestro stack.
service: contratarwifiplan

Enter fullscreen mode Exit fullscreen mode
  • Y en provider debemos especificar el perfil con el que vamos a estar trabajando.

Serverless campo provider

Orquestación

Queremos crear esta Step Function...

StepFunction modelo

Una vez iniciado, vamos a comenzar con lo que se conoce como orquestación,y vamos a trabajar en el archivo asl.
Entonces, en la carpeta de stepFunctions vamos a crear un archivo llamado contratarServicioWifi.asl.json

En este archivo vamos a orquestar la máquina de estado.

{
    "Comment": "State Machine para contratar servicio de Wifi",
    "StartAt": "Medios de Pago",
    "States": {
        "Medios de Pago": {
            "Type": "Choice",
            "Choices": [
                {
                    "Variable": "$.medioDePago",
                    "StringEquals": "Debito",
                    "Next": "Pago Debito"
                }
            ],
            "Default": "Pago Credito"
        },
        "Pago Debito": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": {
                "FunctionName": {
                    "Fn::GetAtt": [
                        "pagoDebito",
                        "Arn"
                    ]
                },
                "Payload": {
                    "Input.$": "$"
                }
            },
            "Next": "Respuesta SQS",
            "Catch": [
                {
                    "ErrorEquals": [
                        "Error"
                    ],
                    "Next": "Lambda Error"
                }
            ]
        },
        "Pago Credito": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": {
                "FunctionName": {
                    "Fn::GetAtt": [
                        "pagoCredito",
                        "Arn"
                    ]
                },
                "Payload": {
                    "Input.$": "$"
                }
            },
            "Next": "Respuesta SQS",
            "Catch": [
                {
                    "ErrorEquals": [
                        "Error"
                    ],
                    "Next": "Lambda Error"
                }
            ]
        },
        "Lambda Error": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": {
                "FunctionName": {
                    "Fn::GetAtt": [
                        "formatError",
                        "Arn"
                    ]
                },
                "Payload": {
                    "Input.$": "$"
                }
            },
            "Next": "Respuesta SQS"
        },
        "Respuesta SQS": {
            "Type": "Task",
            "Resource": "arn:aws:states:::aws-sdk:sqs:sendMessage",
            "Parameters": {
                "MessageBody.$": "$.Payload",
                "QueueUrl": "no hay"
            },
            "End": true
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Vamos a explicar algunas partes...

   "Comment": "State Machine para contratar servicio de wifi",
   "StartAt": "Medios de Pago",
Enter fullscreen mode Exit fullscreen mode
  • Comment, va a ir una pequeña descripción de lo que hace nuestra máquina de estado.
  • StartAt : Hace referencia a con qué task empieza nuestra máquina de estado.
  • States: Acá van a estar todos los steps de nuestra máquina:

Medios de Pago

        "Medios de Pago": {
            "Type": "Choice",
            "Choices": [
                {
                    "Variable": "$.medioDePago",
                    "StringEquals": "Debito",
                    "Next": "Pago Debito"
                }
            ],
            "Default": "Pago Credito"
        },

Enter fullscreen mode Exit fullscreen mode

El primer state es de tipo Choice, porque dependiendo del medio de pago, elige un flujo o el otro.
Tenemos el caso en el que, si el medio de pago dice Débito, se seguirá por el state Pago Debito, caso contrario elige Pago Crédito.

Disclaimer: Este state (y toda la máquina) fue creado con la suposición de que el json que va a recibir sigue este esqueleto

{
   "servicio":{
      "plan":"String",
      "precio":"Number"
   },
   "medioDePago":"String",
   "tarjeta":{
      "cantCuotas":"String",
      "nroTarjeta":"String"
   }
}

Enter fullscreen mode Exit fullscreen mode

Pago Debito

"Pago Debito": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": {
                "FunctionName": {
                    "Fn::GetAtt": [
                        "pagoDebito",
                        "Arn"
                    ]
                },
                "Payload": {
                    "Input.$": "$"
                }
            },
            "Next": "Respuesta SQS",
            "Catch": [
                {
                    "ErrorEquals": [
                        "Error"
                    ],
                    "Next": "Lambda Error"
                }
            ]
        },

Enter fullscreen mode Exit fullscreen mode

Tenemos un step de tipo Task, que va a tener como recurso una lambda que va a hacer el trabajo de procesar el pago con débito.

FunctionName": {
    "Fn::GetAtt": [
        "pagoDebito",
        "Arn"
    ]
},
Enter fullscreen mode Exit fullscreen mode

La lambda aún no está creada, pero en ciertos casos es cómodo tener referenciado el nombre de la función que vamos a crear.

También tenemos un Catch que va a manejar los errores que recibimos y los va a redirigir a la lambda que procese los errores.

Pago Crédito

 "Pago Credito": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": {
                "FunctionName": {
                    "Fn::GetAtt": [
                        "pagoCredito",
                        "Arn"
                    ]
                },
                "Payload": {
                    "Input.$": "$"
                }
            },
            "Next": "Respuesta SQS",
            "Catch": [
                {
                    "ErrorEquals": [
                        "Error"
                    ],
                    "Next": "Lambda Error"
                }
            ]
        },
Enter fullscreen mode Exit fullscreen mode

El orden de los States Pago Débito o Pago Crédito puede estar invertido y no cambiaría la ejecución.
Al igual que el anterior, escribí el nombre que voy a querer que tenga la función de la lambda. Y también tiene un manejo de error que se maneja con el Catch.

Cualquiera sea el método de pago, si funciona, el siguiente state es Respuesta SQS.

Lambda Error

"Lambda Error": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": {
                "FunctionName": {
                    "Fn::GetAtt": [
                        "formatError",
                        "Arn"
                    ]
                },
                "Payload": {
                    "Input.$": "$"
                }
            },
            "Next": "Respuesta SQS"
        },
Enter fullscreen mode Exit fullscreen mode

Este state también es de tipo Task. Igual que las anteriores, inventé el nombre de la función de la lambda.
Cómo state siguiente tiene Respuesta SQS porque una vez que ya fue manejado el error queremos devolverlo al cliente.

Respuesta SQS

 "Respuesta SQS": {
            "Type": "Task",
            "Resource": "arn:aws:states:::aws-sdk:sqs:sendMessage",
            "Parameters": {
                "MessageBody.$": "$.Payload",
                "QueueUrl": "no esiste"
            },
            "End": true
        }

Enter fullscreen mode Exit fullscreen mode

State de tipo Task, la diferencia es que en el resource no tenemos la invocación de una lambda, sino que el envío de un mensaje a una cola de mensajes, usando el servicio de SQS.

Al final tenemos el End: true porque es donde finaliza la máquina de estado que habíamos creado.


Una vez orquestada nuestra stepFunction tenemos que registrarla en el archivo Serverless.yml

Para ellos debemos:

  • Instalar el siguiente plugin.
npm install --save-dev serverless-step-functions

Enter fullscreen mode Exit fullscreen mode
  • Registrarlo en los plugins del archivo serverless.
plugins:
 - serverless-step-functions

Enter fullscreen mode Exit fullscreen mode
  • Debajo de provider tenemos que escribir las siguientes 4 líneas.
stepFunctions: 
  stateMachines: 
    contratarServicioWifi: 
      name: ${self:service}-stateMachine
      definition: ${file(./src/stepFunctions/contratarServicioWifi.asl.json)}

Enter fullscreen mode Exit fullscreen mode

El nombre de la step Function va a ser
contratarServicioWifi-stateMachine la variable ${self:service} hace referencia al nombre de nuestro stack, nombrado previamente en el serverless.yml

Lambdas

Vamos a comenzar entonces a crear las lambdas que vamos a necesitar.
En principio, vamos a tener 3 lambdas que pertenezcan a nuestra máquina de estado.

Dentro de la carpeta lambdas vamos a crear tres archivos llamados formatError.js, pagoCredito.js y pagoDebito.js

tres lambdas

PAGO DEBITO

Esta lambda va contener el siguiente código.

const pagoDebito = (event)  => {
    console.log('event: ', JSON.stringify(event, null, 2)); 

    const inputData = event.Input;

    validarPago(inputData);

    return {
        status: 200,
        servicio: {
            plan: inputData.servicio.plan,
            precio: inputData.servicio.precio
        },
        estado: 'Pagado',
        cantCuotas: inputData.tarjeta.cantCuotas
    }

}

const validarPago = (data) => {
    const {medioDePago} = data;
    const {nroTarjeta} = data.tarjeta;
    if(nroTarjeta.length > 17 || nroTarjeta.length < 16) throw new Error('Numero de tarjeta invalido');
    if(medioDePago !== 'Debito') throw new Error('Metodo de pago invalido');
}

exports.handler = (event, context, callback) => {
    callback(null, pagoDebito(event));
}
Enter fullscreen mode Exit fullscreen mode

Por convención las lambdas exportan una función llamada handler, es con la cual se referencia la función en el serverless.yml

El handler debe funcionar como una función asincrónica, en el caso de no serlo, se va a trabajar con un callback.

En este caso, en handler llamamos a la función pagoDébito, es la función que hace las validaciones y “procesa” el pago.

En este caso las validaciones fueron:

  • Validar que el medio de pago sea correcto,
  • El número de tarjeta integrado debe tener la longitud correcta.

Data importante mencionada en otro capítulo, las lambdas trabajan con eventos, te recomiendo realizar el console log como en el ejemplo para poder entender que está recibiendo nuestra lambda.

PAGO CRÉDITO

La lambda que procesa el código para pago con crédito es la siguiente.

const pagoCredito = (event)  => {
    console.log('event: ', JSON.stringify(event, null, 2)); 

    const inputData = event.Input;

    validarPago(inputData);

    return {
        status: 200,
        servicio: {
            plan: inputData.servicio.plan,
            precio: inputData.servicio.precio
        },
        estado: 'Pagado',
        cantCuotas: inputData.tarjeta.cantCuotas
    }

}

const validarPago = (data) => {
    const {medioDePago} = data;
    const {nroTarjeta, cantCuotas} = data.tarjeta;
    if(nroTarjeta.length > 17 || nroTarjeta.length < 16) throw new Error('Numero de tarjeta invalido');
    if(medioDePago !== 'Debito') throw new Error('Metodo de pago invalido');
    if(!cantCuotas) throw new Error('Se necesita espicificar cantidad de cuotas')
}

exports.handler = (event, context, callback) => {
    callback(null, pagoCredito(event));
}

Enter fullscreen mode Exit fullscreen mode

Al igual que la anterior exportamos la función handler, y a la hora de procesar el pago tenemos un par de validaciones.

FORMAT ERROR

Esta va a ser la lambda que maneje los errores que se reciban del “procesamiento de los pagos”

La información que le va a llegar es distinta a las de las otras lambdas, porque al ser redirigido por el catch solo se envía el error.

Una vez aclarado eso:

const formatError = (event) => {
    console.log('event: ', JSON.stringify(event, null, 2)); 

    return {
        status: 500,
        estado: "Cancelado",
        cause: JSON.parse(event.Input.Cause).errorMessage
    }
}

exports.handler = (event, context, callback) => {
    callback(null, formatError(event));
}

Enter fullscreen mode Exit fullscreen mode

Para cuando falle decidí enviar un status : 500, la descripción del error y la aclaración de que el estado del pago fue cancelado.

Ahora que ya tenemos las lambdas debemos agregarlas al serverless.yml para que cuando hagamos el deploy se creen en la nube.

functions:
  pagoCredito:
    handler: ./src/lambdas/pagoCredito.handler
  pagoDebito:
    handler: ./src/lambdas/pagoDebito.handler
  formatError:
    handler: ./src/lambdas/formatError.handler
Enter fullscreen mode Exit fullscreen mode

Vamos a declarar el apartado functions y debajo vamos a llamar las lambdas con los nombres de las funciones que declaramos en el archivo asl.
Y luego para que serverless sepa qué función debe exportar, en el campo handler declaramos la función que se exporta de nuestro archivos.
Como dije en un principio, por convención, es el handler.

SQS

Vamos a crear nuestra SQS (cola de mensajes) para poder manejar los mensajes recibidos por las lambdas de proceso de pago o el de format error.

Cuando creamos nuestra SQS se recomienda crear también una dead letter queue (DLQ). Esta es la cola a la que van a ir los mensajes que no pudieron ser procesados por la cola de mensajes principal.

Para crear estos servicios vamos a crear un archivo en la carpeta resources, llamado SQS.yml

En ese yml vamos a crear la SQS y su DLQ.

SendQueue: 
  Type: AWS::SQS::Queue
  Properties: 
    RedrivePolicy: 
      deadLetterTargetArn: !GetAtt SendQueueDLQ.Arn
      maxReceiveCount: 3
  DependsOn: SendQueueDLQ

SendQueueDLQ: 
  Type: AWS::SQS::Queue

Enter fullscreen mode Exit fullscreen mode

Una vez creada debemos llevarla al archivo serverless.yml para que podamos usarla para trabajar.

Creamos el campo resources.

Este puede contar con dos campos extras, el campo Resources (con mayúscula) en donde se declaran los recursos que queremos llevar a la nube, y el campo Outputs que nos permite publicar servicios en nuestro stack que luego van a necesitar ser consultados incluso por otros stacks. Es una forma de dejar la data de algunos servicios de forma “pública”.

resources: 
  Resources: 
    SendQueue: ${file(./src/resources/SQS.yml):SendQueue}
    SendQueueDLQ: ${file(./src/resources/SQS.yml):SendQueueDLQ}
Enter fullscreen mode Exit fullscreen mode

Para completar la máquina de estado vamos a necesitar la url de la Queue, es por eso que la vamos a exportar por medio de un output.

  Outputs: 
    SendQueueURL:
      Value: 
        Ref: SendQueue
      Export:
        Name: SendQueue

Enter fullscreen mode Exit fullscreen mode

Con estos datos ya completos vamos a hacer el primer deploy.

sls deploy
Enter fullscreen mode Exit fullscreen mode

Debido a que para consumir la url necesitamos que el stack esté en cloudFormation.

CloudFormation es otro servicio de AWS que vamos a estar utilizando para guardar los stacks y los outputs. Tiene una capa gratuita de 1000 operaciones del controlador por mes por cuenta.

Una vez que ya esté el deploy hecho, vamos a ir a nuestra orquestación de la máquina y vamos a reemplazar el string anterior por una variable de cloudformation de la siguiente manera:

        "Respuesta SQS": {
            "Type": "Task",
            "Resource": "arn:aws:states:::aws-sdk:sqs:sendMessage",
            "Parameters": {
                "MessageBody.$": "$.Payload",
                "QueueUrl": "${cf:contratarwifiplan-${opt:stage, 'dev'}.SendQueueURL}"
            },
            "End": true
        }

Enter fullscreen mode Exit fullscreen mode

Ahora nuestra última task va a mandar todos los mensajes del proceso de pago a una cola de mensajes.

Eventos

Bueno, en este caso vamos a tener un extra para el proceso de datos de la máquina de estado.

Esa cola de sqs recibe los mensajes, pero ¿Qué hacemos con esa cola de mensajes?

Bueno, para eso vamos a crear una lambda por fuera de la stepFunction que procese los mensajes recibidos.

¿Como?

Como dijimos en el capítulo pasado, las lambdas pueden ser invocadas por distintos eventos. Esta lambda que vamos a crear va a ser invocada por cada mensaje que reciba la cola sqs.

En la carpeta lambdas, vamos a crear una lambda que se llame enviarData.js con el siguiente código.

const enviarData = (event) => {
    console.log('event: ', JSON.stringify(event, null, 2)); 

    console.log(JSON.parse(event.Records[0].body))

    return JSON.parse(event.Records[0].body)
}

exports.handler = (event, context, callback) => {
    callback(null, enviarData(event));
}

Enter fullscreen mode Exit fullscreen mode

El código lo que hace es retornar la data del mensaje. Las colas de sqs trabajan con eventos que contienen Records.

Una vez creada la lambda, vamos a registrarla al archivo serverless.js

Debajo de las anteriores agregamos la nueva lambda. Para declarar el evento que la va a despertar agregamos el campo events, y aclaramos el servicio sqs.
El arn que referenciamos es el de nuestra Queue creada anteriormente.

  enviarData: 
    handler: ./src/lambdas/enviarData.handler
    events: 
      - sqs: 
          batchSize: 1
          arn: 
            Fn::GetAtt: [SendQueue, Arn]
Enter fullscreen mode Exit fullscreen mode

Roles

En algunos casos nuestros servicios necesitan permisos específicos para utilizar nuestros servicios.

En el caso de nuestra stepFunction necesitamos dos permisos

  1. Para invocar las lambdas que funcionan dentro de la máquina de estado (lambda:InvokeFunction)
  2. Para que nuestra stepFunction pueda mandar mensajes a la sqs. (sqs:SendMessage)

Para otorgar estos permisos vamos a ir a la carpeta resources y crear el archivo StepFunctionRole.yml

El código es el siguiente:

ContratarServicioWifiMachineRole:
 Type: AWS::IAM::Role
 Properties:
   RoleName: ContratarServicioWifiMachineRole
   AssumeRolePolicyDocument:
     Statement:
     - Effect: Allow
       Principal:
         Service:
           - 'states.amazonaws.com'
       Action:
         - 'sts:AssumeRole'
   Policies:
     - PolicyName: statePolicy
       PolicyDocument:
         Statement:
          - Effect: Allow
            Action: 
              - sqs:SendMessage
            Resource: 
              - Fn::GetAtt: [SendQueue, Arn]
          - Effect: Allow
            Action: 
              - 'lambda:InvokeFunction'
            Resource: 
              - !Join ['-', [ !Join [':', ['arn:aws:lambda',!Ref 'AWS::Region', !Ref 'AWS::AccountId' ,'function', !Ref 'AWS::StackName']], 'pagoCredito' ]]
              - !Join ['-', [ !Join [':', ['arn:aws:lambda',!Ref 'AWS::Region', !Ref 'AWS::AccountId' ,'function', !Ref 'AWS::StackName']], 'pagoDebito' ]]
              - !Join ['-', [ !Join [':', ['arn:aws:lambda',!Ref 'AWS::Region', !Ref 'AWS::AccountId' ,'function', !Ref 'AWS::StackName']], 'formatError' ]]


Enter fullscreen mode Exit fullscreen mode

El formato de los permisos es

  • Effect : Hace referencia a la acción de autorización que queremos.
  • Action: Cual es la acción que va a ser afectada por el efecto anterior.
  • Resources: Recomiendo siempre poner el recurso específico que queremos afectar, aunque existe la posibilidad de usar el “*”, que hace referencia a todos.

Cuando tenemos los permisos creados vamos a importarlos al archivo serverless.yml

En Resources, debajo de la declaración de las SQS vamos a escribir…

 ContratarServicioWifiMachineRole: ${file(./src/resources/StepFunctionsRole.yml):ContratarServicioWifiMachineRole}
Enter fullscreen mode Exit fullscreen mode

Y ya referenciado, le asignamos el rol a nuestra máquina de estado.

stepFunctions: 
  stateMachines: 
    contratarServicioWifi: 
      name: ${self:service}-stateMachine
      definition: ${file(./src/stepFunctions/contratarServicioWifi.asl.json)}
      role: 
        Fn::GetAtt: [ContratarServicioWifiMachineRole, Arn]

Enter fullscreen mode Exit fullscreen mode

Una vez que esta el role asignado.

Corremos

sls deploy  
Enter fullscreen mode Exit fullscreen mode

y…

Voilà

Tenemos nuestra máquina de estado en la nube.

Les dejo el link al repo

Para probar la máquina de estado debemos hacer click en Start Execution.

Start Execution

Tenemos dos json de ejemplos, uno de éxito y el otro de fracaso.

Éxito

{
    "servicio": {
        "plan": "1MB",
        "precio": 1000
    },
    "medioDePago": "Debito",
    "tarjeta": {
        "cantCuotas": "06",
        "nroTarjeta": "1234567898745896"
    }
}

Enter fullscreen mode Exit fullscreen mode

Execution Success

Las máquinas de estados generan logs, que son un detalle de los inputs y outputs de cada task .
Las lambdas también general logs, que se guardan en el servicio de CloudWatch, ahí podemos revisar los resultados de nuestros console.logs()

Fracaso

{
    "servicio": {
        "plan": "1MB",
        "precio": 1000
    },
    "medioDePago": "Debito",
    "tarjeta": {
        "cantCuotas": "06",
        "nroTarjeta": "123545646544567898745896"
    }
}

Enter fullscreen mode Exit fullscreen mode

Execution Failed

Este es el caso en el que falla y el error es atrapado en el lambda error.

Tenemos el ejemplo del input que recibe la respuesta de SQS con el error que formatea el Lambda Error.

Input Format Error

EL FIN

Por fin tenemos nuestra stepFunction lista y funcionando :D
Espero que se hayan divertido y hayan renegado.
En los próximos capítulos vamos a tener agregados extras para aumentar la complejidad de nuestra StepFunction, y algunas nuevas explicaciones y funcionalidades.

Recuerden que si este post les fue útil, pueden invitarme un cafecito.

Invitame un café en cafecito.app

No duden en escribirme si tienen alguna duda.

Nos vemos la semana que viene.

. . . . . . . . . . . . .