Holis!
HOY... Hoy es el día en que vamos a crear nuestra stepFunction.
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
Esto va a crear un template de Serverless para nuestro proyecto utilizando obviamente
node.
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.
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
.
En el archivo serverless.yml
, vamos a hacer los siguientes cambios:
- En
service
va a ir el nombre de nuestro stack.
service: contratarwifiplan
- Y en
provider
debemos especificar el perfil con el que vamos a estar trabajando.
Orquestación
Queremos crear esta Step Function...
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
}
}
}
Vamos a explicar algunas partes...
"Comment": "State Machine para contratar servicio de wifi",
"StartAt": "Medios de Pago",
- 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"
},
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"
}
}
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"
}
]
},
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"
]
},
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"
}
]
},
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"
},
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
}
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
- Registrarlo en los plugins del archivo serverless.
plugins:
- serverless-step-functions
- 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)}
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
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));
}
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));
}
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));
}
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
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
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}
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
Con estos datos ya completos vamos a hacer el primer deploy.
sls deploy
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
}
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));
}
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]
Roles
En algunos casos nuestros servicios necesitan permisos específicos para utilizar nuestros servicios.
En el caso de nuestra stepFunction necesitamos dos permisos
- Para invocar las lambdas que funcionan dentro de la máquina de estado (
lambda:InvokeFunction
) - 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' ]]
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}
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]
Una vez que esta el role asignado.
Corremos
sls deploy
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
.
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"
}
}
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"
}
}
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.
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.
No duden en escribirme si tienen alguna duda.
Nos vemos la semana que viene.