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 que é o LocalStack?
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:
- Custo ZERO: Evita custos associados ao uso dos serviços reais da AWS durante o desenvolvimento.
- Desenvolvimento offline: Você pode trabalhar sem conexão com a internet.
- Ciclo de feedback rápido: Teste suas funcionalidades localmente, acelerando o desenvolvimento.
- Ambiente controlado: Simule diferentes cenários sem afetar ambientes de produção ou teste.
Configurando o ambiente
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
Estrutura do projeto
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:
- cmd/: Contém os executáveis da aplicação.
- service/: O servidor HTTP.
- worker/: O consumidor SQS.
- internal/: Código interno não exposto para outros módulos.
- config/: Gerencia a configuração AWS.
- server/: Configuração do servidor e inicialização dos serviços AWS.
- pkg/: Pacotes reutilizáveis.
- api/handlers/: Handlers das rotas HTTP.
- aws/: Interações com os serviços AWS.
- service/: Lógica de negócios e modelos de dados.
- compose.yml: Configuração do LocalStack.
- go.mod e go.sum: Gerenciamento de dependências Go.
Configurando o LocalStack e a Aplicação
1- Criando o compose.yml
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
2- Definindo o model
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"`
}
3- Configurando a conexão com a AWS
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
}
4- Inicializando os clients
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)
})
}
5- Implementando funções que utilizaremos com os clients
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.
-
CreateQueue
: ela vai receber um nome de uma fila e criará esta fila com o nome que recebeu. -
SendMessage
: recebe a URL da fila onde deve publicar a mensagem e a mensagem que deve ser publicada. Dessa forma temos funções que podem ser reutilizadas sempre que necessário dentro de nosso código.
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:
-
CreateTable
: recebe um nome de uma tabela e cria essa tabela.
6- Implementando o service
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:
-
CreateUser
: que será responsável por receber um novoUser
, validar se existe algum usuário com o mesmo email já salvo no DB e caso não exista, salvar um novo user no DB. -
GetUserByEmail
: busca peloUser
baseado no email que ele recebeu como parâmetro -
GetAllUsers
: retorna todos osUsers
salvos no DB.
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
}
7- Implementando o handler
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:
- GetUser: ele vai listar todos os usuários e caso receba o query param
email
buscará pelo usuário solicitado. - CreateUser: irá publicar na fila um usuário novo com base dados recebidos no request. Essa será uma operação async.
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)
}
8- Configurando o server
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
:
- Start: inicia o servidor HTTP e registra as rotas. Além de chamar o
initDependencies
- initDependencies: inicia os serviços da AWS criando a tabela e a fila que precisamos para rodar nosso aplicativo.
9- Configurando o worker
e o main
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 😅
Executando 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.
Servidor & 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!
Testando as Funcionalidades
1- Registrando um Novo Usuário de Forma Assíncrona
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": "carlos@example.com",
"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: carlos@example.com
2- Verificando a criação do usuário
Recupere as informações do usuário para verificar se ele foi criado:
curl --location 'http://localhost:8080/user?email=carlos%40example.com'
Você receberá a seguinte resposta, caso ele tenha sido salvo com sucesso:
{
"id": "2a32193a-bcd6-4d8f-87dd-64e65f8a8f22",
"name": "Carlos Souza",
"email": "carlos@example.com",
"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": "rflpazini@example.com",
"address": "Rua A, 123",
"phone": "123456789"
},
{
"id": "2a32193a-bcd6-4d8f-87dd-64e65f8a8f22",
"name": "Carlos Souza",
"email": "carlos@example.com",
"address": "Rua Central, 456",
"phone": "999888777"
}
]
Considerações Finais
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.
Por que isso é importante?
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.
O que aprendemos?
- Como configurar o LocalStack em um contêiner Docker.
- Como criar uma aplicação Go que interage com DynamoDB e SQS.
- Como implementar o processamento assíncrono de mensagens.
- Como desenvolver os handlers e serviços relacionados aos usuários.
Próximos passos:
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:
- Implementar autenticação e segurança.
- Adicionar mais funcionalidades, como atualização e exclusão de usuários.
- Integrar outros serviços da AWS conforme necessário.
É isso galera, espero que vocês gostem e deixem os comentários caso surja alguma dúvida!
Happy coding! 👨🏼💻
Top comments (0)