"일꾼이 일을 잘하려면 먼저 도구를 갈고 닦아야 한다." - 공자, 『논어』.
첫 장 > 프로그램 작성 > LocalStack 및 Docker: 비용 없이 로컬 개발의 생산성을 높이는 방법

LocalStack 및 Docker: 비용 없이 로컬 개발의 생산성을 높이는 방법

2024-11-03에 게시됨
검색:834

LocalStack e Docker: Como aumentar a produtividade no desenvolvimento LOCAL sem custos

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 novo User, 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 pelo User baseado no email que ele recebeu como parâmetro
  • GetAllUsers: retorna todos os Users 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": "[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]

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/[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"
     }
]

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! ??‍?

릴리스 선언문 이 기사는 https://dev.to/rflpazini/localstack-e-docker-como-aumentar-a-produtividade-no-desenvolvimento-local-sem-custos-m2c?1에서 복제됩니다. 침해가 있는 경우, 문의 Study_golang@163 .comdelete
최신 튜토리얼 더>

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

Copyright© 2022 湘ICP备2022001581号-3