Desenvolver aplicações que interagem com serviços da AWS pode ser um desafio, especialmente quando se trata de configurar e testar recursos sem aumentar em custos ou enfrentar limitações de conectividade. O LocalStack surge como uma solução poderosa para emular serviços da AWS localmente, permitindo que você desenvolva e teste seu código de forma eficiente.
Neste post, vamos explorar como configurar o LocalStack e integrá-lo com aplicações em Golang, fornecendo exemplos práticos que beneficiarão desde desenvolvedores seniores até estagiários.
O LocalStack é uma plataforma que simula serviços da AWS em sua máquina local. Ele permite que você desenvolva e teste funcionalidades que dependem de serviços como S3, DynamoDB, SQS, Lambda e muitos outros, sem precisar acessar a nuvem real da AWS.
Podemos dizer que suas maiores vantagens são as seguintes:
Vamos começar já "colocando a mão na massa". Para isso, iremos construir uma aplicação que cria usuários de forma assíncrona usando DynamoDB e SQS. Iremos utilizar o AWS SDK e o LocalStack, dessa forma o mesmo código funciona para o mundo real e para rodar localmente nossa aplicação.
Antes de começarmos, certifique-se de que o Docker e o Go estão instalados corretamente em sua máquina. Além disso, exporte as credenciais de acesso AWS (mesmo que sejam fictícias), já que o SDK da AWS requer essas informações.
export AWS_ACCESS_KEY_ID=test export AWS_SECRET_ACCESS_KEY=test
Para manter nosso projeto organizado, seguiremos uma estrutura que separa claramente as responsabilidades:
├── cmd │ ├── service │ │ └── main.go │ └── worker │ └── main.go ├── internal │ ├── config │ │ └── config.go │ └── server │ └── server.go ├── pkg │ ├── api │ │ └── handlers │ │ └── user.go │ ├── aws │ │ ├── client.go │ │ ├── dynamo.go │ │ └── sqs.go │ └── service │ ├── models │ │ └── user.go │ └── user.go ├── compose.yml ├── go.mod └── go.sum
Os arquivos do projeto estão disponíveis no github
Explicação da Estrutura:
Como sempre, utilizaremos nosso amigo Docker para facilitar e não termos que instalar nada além de rodar o comando do compose para subir o LocalStack:
# compose.yml services: localstack: image: localstack/localstack:latest container_name: localstack ports: - "4566:4566" environment: - SERVICES=dynamodb,sqs - DEBUG=1
Crie o arquivo user.go dentro de pkg/service/models/:
package models type User struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` Password string `json:"password,omitempty"` // Em um cenário real, nunca armazenar senhas em texto :D Address string `json:"address"` Phone string `json:"phone"` }
Crie o arquivo config.go dentro de internal/config/. Ele será o singleton que carregará as configs para nosso LocalStack. Pense no singleton como um gerente de loja que mantém a mesma estratégia para todas as filiais. Não importa quantas lojas (clientes) existam, a estratégia (configuração) é consistente.:
package config import ( "context" "log" "sync" "github.com/aws/aws-sdk-go-v2/aws" awsConfig "github.com/aws/aws-sdk-go-v2/config" ) const ( UsersTable = "users" UsersQueue = "users_queue" ) var ( cfg aws.Config once sync.Once QueueURL string ) func GetAWSConfig() aws.Config { once.Do(func() { var err error cfg, err = awsConfig.LoadDefaultConfig(context.Background(), awsConfig.WithRegion("us-east-1"), ) if err != nil { log.Fatalf("error during AWS config: %v", err) } }) return cfg }
Crie o arquivo client.go em pkg/aws/. Ele será responsável por passar as configs que carregamos para os clients dos serviços da AWS que estamos emulando:
package aws import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/sqs" ) var ( DynamoDBClient *dynamodb.Client SQSClient *sqs.Client ) func InitClients(cfg aws.Config) { localstackEndpoint := "http://localhost:4566" DynamoDBClient = dynamodb.NewFromConfig(cfg, func(o *dynamodb.Options) { o.BaseEndpoint = aws.String(localstackEndpoint) }) SQSClient = sqs.NewFromConfig(cfg, func(o *sqs.Options) { o.BaseEndpoint = aws.String(localstackEndpoint) }) }
Agora que já fizemos o loading das configurações para os clients dos serviços, chegou a hora de implementar os métodos que serão utilizados para criar fila, publicar mensagem e criar tabela.
Começaremos criando o sqs.go dentro do package pkg/aws/, onde teremos duas funções, a CreateQueue responsável por criar uma fila e a SendMessage responsável por mandar mensagens para a fila que criamos:
package aws import ( "context" "log" "github.com/aws/aws-sdk-go-v2/service/sqs" ) func CreateQueue(queueName string) (string, error) { result, err := SQSClient.CreateQueue(context.Background(), &sqs.CreateQueueInput{ QueueName: &queueName, }) if err != nil { return "", err } return *result.QueueUrl, nil } func SendMessage(ctx context.Context, queueUrl, messageBody string) error { log.Printf("Sending message with body: %s to %s", messageBody, queueUrl) _, err := SQSClient.SendMessage(ctx, &sqs.SendMessageInput{ QueueUrl: &queueUrl, MessageBody: &messageBody, }) return err }
Se você reparar bem, eu preferi criar as funções bem genéricas.
Agora vamos criar o dynamo.go dentro do mesmo package pkg/aws, assim fica tudo centralizado dentro de um mesmo pacote o que é referente aos serviços da AWS.
package aws import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" ) func CreateTable(tableName string) error { _, err := DynamoDBClient.CreateTable(context.Background(), &dynamodb.CreateTableInput{ TableName: aws.String(tableName), AttributeDefinitions: []types.AttributeDefinition{ { AttributeName: aws.String("ID"), AttributeType: types.ScalarAttributeTypeS, }, }, KeySchema: []types.KeySchemaElement{ { AttributeName: aws.String("ID"), KeyType: types.KeyTypeHash, }, }, BillingMode: types.BillingModePayPerRequest, }) if err != nil { var resourceInUseException *types.ResourceInUseException if errors.As(err, &resourceInUseException) { return nil } return err } return nil }
Aqui continuamos no mesmo conceito, criando uma função genérica para ser reutilizada caso precise em outro ponto do código. No dynamo temos apenas que criar uma função que criará a tabela:
Primeiro vamos criar a entidade que User onde teremos as informações do usuário. Para isso crie um arquivo user.go dentro do package pkg/service/model que é onde ficarão todos os models de nossa aplicação.
package models type User struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` Password string `json:"password"` // Em um cenário real, nunca armazenar senhas em texto :D Address string `json:"address"` Phone string `json:"phone"` }
Agora vamos para o service que será responsável por cuidar das regras de negócio relacionadas ao User. Então vamos criar o user.go dentro do package pkg/service.
Teremos 3 funções dentro desse arquivo:
O código dela fica assim:
package service import ( "context" "errors" "fmt" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/google/uuid" "github.com/rflpazini/localstack/internal/config" awsClient "github.com/rflpazini/localstack/pkg/aws" "github.com/rflpazini/localstack/pkg/service/models" ) func CreateUser(ctx context.Context, user *models.User) error { existingUser, err := GetUserByEmail(ctx, user.Email) if err == nil && existingUser != nil { return errors.New("email is already in use by another user") } else if err != nil && err.Error() != "user not found" { return fmt.Errorf("failed to verify if email is already in use: %w", err) } user.ID = uuid.NewString() item := map[string]types.AttributeValue{ "ID": &types.AttributeValueMemberS{Value: user.ID}, "Name": &types.AttributeValueMemberS{Value: user.Name}, "Email": &types.AttributeValueMemberS{Value: user.Email}, "Password": &types.AttributeValueMemberS{Value: user.Password}, "Address": &types.AttributeValueMemberS{Value: user.Address}, "Phone": &types.AttributeValueMemberS{Value: user.Phone}, } _, err = awsClient.DynamoDBClient.PutItem(context.Background(), &dynamodb.PutItemInput{ TableName: aws.String(config.UsersTable), Item: item, }) if err != nil { return fmt.Errorf("failed to create user: %w", err) } return nil } func GetUserByEmail(ctx context.Context, email string) (*models.User, error) { result, err := awsClient.DynamoDBClient.Scan(ctx, &dynamodb.ScanInput{ TableName: aws.String(config.UsersTable), FilterExpression: aws.String("Email = :email"), ExpressionAttributeValues: map[string]types.AttributeValue{ ":email": &types.AttributeValueMemberS{Value: email}, }, }) if err != nil { return nil, fmt.Errorf("failed to scan table: %w", err) } if len(result.Items) == 0 { return nil, errors.New("user not found") } item := result.Items[0] user := &models.User{ ID: item["ID"].(*types.AttributeValueMemberS).Value, Name: item["Name"].(*types.AttributeValueMemberS).Value, Email: item["Email"].(*types.AttributeValueMemberS).Value, Address: item["Address"].(*types.AttributeValueMemberS).Value, Phone: item["Phone"].(*types.AttributeValueMemberS).Value, } return user, nil } func GetAllUsers() ([]*models.User, error) { result, err := awsClient.DynamoDBClient.Scan(context.Background(), &dynamodb.ScanInput{ TableName: aws.String(config.UsersTable), }) if err != nil { return nil, fmt.Errorf("failed to retrieve all users: %w", err) } if len(result.Items) == 0 { return nil, errors.New("no users found") } users := make([]*models.User, 0) for _, item := range result.Items { user := &models.User{ ID: item["ID"].(*types.AttributeValueMemberS).Value, Name: item["Name"].(*types.AttributeValueMemberS).Value, Email: item["Email"].(*types.AttributeValueMemberS).Value, Address: item["Address"].(*types.AttributeValueMemberS).Value, Phone: item["Phone"].(*types.AttributeValueMemberS).Value, } users = append(users, user) } return users, nil }
Crie os handlers que serão responsáveis por receber as requisições HTTP e interagir com os serviços.
Teremos 2 funções nesse handler do User:
package handlers import ( "encoding/json" "net/http" "github.com/labstack/echo/v4" "github.com/rflpazini/localstack/internal/config" awsClient "github.com/rflpazini/localstack/pkg/aws" "github.com/rflpazini/localstack/pkg/service" "github.com/rflpazini/localstack/pkg/service/models" ) func GetUser(c echo.Context) error { ctx := c.Request().Context() email := c.QueryParam("email") if email == "" { users, err := service.GetAllUsers() if err != nil { return err } return c.JSON(http.StatusOK, users) } user, err := service.GetUserByEmail(ctx, email) if err != nil { return c.JSON(http.StatusNotFound, err.Error()) } return c.JSON(http.StatusOK, user) } func CreateUser(c echo.Context) error { ctx := c.Request().Context() user := new(models.User) if err := c.Bind(user); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } message, err := json.Marshal(user) if err != nil { return c.JSON(http.StatusInternalServerError, err.Error()) } err = awsClient.SendMessage(ctx, config.QueueURL, string(message)) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.NoContent(http.StatusCreated) }
Crie o servidor HTTP e configure as rotas em internal/server/server.go:
package server import ( "log" "github.com/aws/aws-sdk-go-v2/aws" "github.com/labstack/echo/v4" "github.com/rflpazini/localstack/internal/config" "github.com/rflpazini/localstack/pkg/api/handlers" awsClients "github.com/rflpazini/localstack/pkg/aws" ) func Start(cfg aws.Config) { e := echo.New() awsClients.InitClients(cfg) initDependencies() e.POST("/user", handlers.CreateUser) e.GET("/user", handlers.GetUser) e.Logger.Fatal(e.Start(":8080")) } func initDependencies() { err := awsClients.CreateTable(config.UsersTable) if err != nil { log.Printf("create table error: %v", err) } else { log.Println("table created") } queueURL, err := awsClients.CreateQueue(config.UsersQueue) if err != nil { log.Printf("create queue error: %v", err) } else { config.QueueURL = queueURL log.Println("sqs queue created") } }
Aqui temos duas funções, o Start e o initDependencies:
Dentro do package cmd criaremos duas pastas. Uma chamada service e outra worker.
A service terá o main.go será responsável por carregar as configurações e chamar nosso Start do server.
package main import ( "github.com/rflpazini/localstack/internal/config" "github.com/rflpazini/localstack/internal/server" ) func main() { cfg := config.GetAWSConfig() server.Start(cfg) }
O worker será a aplicação que consumirá as mensagens da fila. Lembra que criamos um service para salvar o usuário async? É com o worker que vamos consumir e salvar esse usuário no DB.
package main import ( "context" "encoding/json" "log" "time" "github.com/rflpazini/localstack/internal/config" "github.com/rflpazini/localstack/pkg/aws" "github.com/rflpazini/localstack/pkg/service" "github.com/rflpazini/localstack/pkg/service/models" "github.com/aws/aws-sdk-go-v2/service/sqs" ) const ( userQueueName = "users_queue" ) func main() { ctx := context.Background() cfg := config.GetAWSConfig() aws.InitClients(cfg) queueURL := "http://localhost:4566/000000000000/" userQueueName for { messages, err := aws.SQSClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ QueueUrl: &queueURL, MaxNumberOfMessages: 10, WaitTimeSeconds: 5, }) if err != nil { log.Printf("Erro ao receber mensagens: %v", err) time.Sleep(5 * time.Second) continue } for _, msg := range messages.Messages { var user models.User err := json.Unmarshal([]byte(*msg.Body), &user) if err != nil { log.Printf("Erro ao desserializar mensagem: %v", err) continue } err = service.CreateUser(ctx, &user) if err != nil { log.Printf("Create user error: %v", err) } _, err = aws.SQSClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{ QueueUrl: &queueURL, ReceiptHandle: msg.ReceiptHandle, }) if err != nil { log.Printf("Erro ao deletar mensagem: %v", err) } } time.Sleep(1 * time.Second) } }
Ufa, terminamos a aplicação ?
Bora rodar tudo isso e ver como ficou nosso app. A primeira coisa que devemos fazer, é subir o compose para iniciar o LocalStack:
docker compose up -d
[ ] Running 1/1 ✔ Container localstack Started
Caso você tenha dúvida se o container esta ou não rodando, basta usar docker ps e ver se o container com a imagem do localstack aparece :)
Com o container do local stack rodando, vamos iniciar nossa aplicação e o worker.
Primeiro rode o servidor, pois ele irá criar tanto a tabela quanto a fila que precisamos para que tudo funcione corretamente:
go run cmd/service/main.go
Com o servidor rodando, em uma nova janela de terminal, rode o worker que irá consumir nossa fila:
go run cmd/worker/main.go
Pronto, estamos com a aplicação e o worker rodando simultaneamente!
Imagine que você está fazendo um pedido em um restaurante movimentado. Você faz o pedido (envia a requisição), o garçom anota e passa para a cozinha (fila SQS). Enquanto isso, você aguarda na mesa, e a comida é preparada e servida (processamento assíncrono).
Envie uma solicitação para registrar um novo usuário:
curl --location 'http://localhost:8080/user' \ --header 'Content-Type: application/json' \ --data-raw '{ "name": "Carlos Silva", "email": "[email protected]", "password": "senha123", "address": "Rua A, 123", "phone": "123456789" }'
Você receberá um status response 201:
HTTP/1.1 201 Created
Observe o console onde o worker da fila SQS está sendo executado. Você deve ver uma saída indicando que o usuário foi criado:
2024/10/08 11:01:58 creating user: [email protected]
Recupere as informações do usuário para verificar se ele foi criado:
curl --location 'http://localhost:8080/[email protected]'
Você receberá a seguinte resposta, caso ele tenha sido salvo com sucesso:
{ "id": "2a32193a-bcd6-4d8f-87dd-64e65f8a8f22", "name": "Carlos Souza", "email": "[email protected]", "address": "Rua Central, 456", "phone": "999888777" }
Nesse mesmo endpoint se não colocarmos o email do usuário, vamos receber toda a base de volta. Você pode testar isso cadastrando vários usuários e fazendo o request:
curl --location 'http://localhost:8080/user'
Cadastrei um usuário com meu nome para testarmos:
[ { "id": "bdccfced-000f-4daf-82cc-712a8f4af182", "name": "Rafael Pazini", "email": "[email protected]", "address": "Rua A, 123", "phone": "123456789" }, { "id": "2a32193a-bcd6-4d8f-87dd-64e65f8a8f22", "name": "Carlos Souza", "email": "[email protected]", "address": "Rua Central, 456", "phone": "999888777" } ]
Neste guia, construímos uma aplicação Go que cria usuários de forma assíncrona usando DynamoDB e SQS, tudo isso localmente graças ao LocalStack em um contêiner Docker. Implementamos os handlers e serviços relacionados aos usuários, tornando a aplicação completa e funcional. Utilizamos analogias do dia a dia para facilitar a compreensão dos conceitos, como comparar a fila SQS a um garçom que recebe pedidos e os repassa para a cozinha.
Desenvolver e testar serviços AWS localmente com o LocalStack nos permite economizar tempo e recursos, além de facilitar o processo de desenvolvimento. É como ter um laboratório onde podemos experimentar e ajustar nossa aplicação antes de lançá-la no ambiente real.
Caso você queira se desafiar, fica aqui uma lição de casa para deixar a aplicação ainda mais robusta e próxima do mundo real:
É isso galera, espero que vocês gostem e deixem os comentários caso surja alguma dúvida!
Happy coding! ???
Disclaimer: All resources provided are partly from the Internet. If there is any infringement of your copyright or other rights and interests, please explain the detailed reasons and provide proof of copyright or rights and interests and then send it to the email: [email protected] We will handle it for you as soon as possible.
Copyright© 2022 湘ICP备2022001581号-3