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! ???
부인 성명: 제공된 모든 리소스는 부분적으로 인터넷에서 가져온 것입니다. 귀하의 저작권이나 기타 권리 및 이익이 침해된 경우 자세한 이유를 설명하고 저작권 또는 권리 및 이익에 대한 증거를 제공한 후 이메일([email protected])로 보내주십시오. 최대한 빨리 처리해 드리겠습니다.
Copyright© 2022 湘ICP备2022001581号-3