Guia de padrões e boas práticas em Go
Versão do playbook em outros idiomas: Inglês
Este documento define os padrões e boas práticas que adotamos ao escrever código Go na Trybe. Este guia está separados por tema, e priorizados pela necessidade e impacto na qualidade do nosso código.
Observação: Este é um documento vivo e reflete as necessidades da Trybe, que podem mudar com o tempo, assim como as decisões que tomamos para melhor resolver nossos problemas.
Como esse guia está organizado
Arquitetura
Clean Architecture
Organizamos nossos códigos usando a Clean Architecture. Mais sobre a arquitetura e seus conceitos podem ser vistos nos links:
Clean Architecture, 2 anos depois
Arquitetura de software e a Clean Architecture
Arquitetura de software e a Clean Architecture - Hacktoberfest Brasil 2020
Variáveis de configuração
De acordo com as recomendações documentadas no projeto 12 factor armazenamos as configurações que mudam de acordo com o ambiente (staging
, dev
, production
) em variáveis de ambiente. E as configurações que não dependem do ambiente são armazenadas em arquivos no formato TOML dentro do repositório. Para facilitar o gerenciamento das configurações usamos a biblioteca Viper.
Estrutura do repositório
Usamos o conceito de monorepo, com os projetos compartilhando o mesmo repositório no Github.
Vantagens:
- fácil gerenciar as dependências e reaproveitar código. Com a evolução dos projetos vamos criar pacotes que são comuns a vários projetos (métricas, log, erros, etc) e ter tudo no mesmo repositório facilita a reutilização
Desvantagens:
- CI/CD mais complexo pois teríamos que ter opção para gerar o binário e fazer deploy de diferentes apps
Analisamos os prós e contras e chegamos a conclusão que o monorepo é a melhor opção para nossos projetos.
Abaixo um exemplo de como o repositório é organizado, com múltiplos projetos.
├── README.md
├── app1
│ ├── Makefile
│ ├── README.md
│ ├── api
│ │ ├── handler
│ │ │ ├── book.go
│ │ │ ├── book_test.go
│ │ │ ├── loan.go
│ │ │ ├── loan_test.go
│ │ │ ├── user.go
│ │ │ └── user_test.go
│ │ ├── main.go
│ │ ├── middleware
│ │ │ ├── cors.go
│ │ │ └── metrics.go
│ │ └── presenter
│ │ ├── book.go
│ │ └── user.go
│ ├── bin
│ │ ├── api
│ │ └── cmd
│ ├── cmd
│ │ ├── main.go
│ │ └── main_test.go
│ ├── config
│ │ ├── config.toml
│ │ ├── config_dev.toml.example
│ ├── docker-compose.yml
│ ├── entity
│ │ ├── book.go
│ │ ├── book_test.go
│ │ ├── entity.go
│ │ ├── error.go
│ │ ├── user.go
│ │ └── user_test.go
│ ├── infrastructure
│ │ └── repository
│ │ ├── book_mysql.go
│ │ └── user_mysql.go
│ └── usecase
│ ├── book
│ │ ├── inmem.go
│ │ ├── interface.go
│ │ ├── service.go
│ │ └── service_test.go
│ ├── loan
│ │ ├── interface.go
│ │ ├── service.go
│ │ └── service_test.go
│ └── user
│ ├── inmem.go
│ ├── interface.go
│ ├── service.go
│ └── service_test.go
├── app2
│ └── api
│ └── main.go
├── go.mod
└── internal
├── cache
│ ├── interface.go
│ ├── memcache.go
│ └── mock.go
├── clock
│ ├── clock.go
│ ├── fake.go
│ └── interface.go
├── compress
│ └── zip.go
├── errors
│ ├── errors.go
│ └── errors_test.go
├── faker
│ ├── animals.go
│ ├── animals_test.go
│ ├── interface.go
│ └── mock.go
├── metric
│ ├── interface.go
│ └── prometheus.go
├── middleware
│ ├── Cors.go
│ ├── hasJWTAuthentication.go
│ ├── hasJWTAuthentication_test.go
│ ├── isJWTAuthenticated.go
│ ├── isJWTAuthenticated_test.go
│ ├── metrics.go
│ ├── securityHeaders.go
│ ├── validate.go
│ ├── validateMultipart.go
│ ├── validateMultipart_test.go
│ └── validate_test.go
├── password
│ ├── fake.go
│ ├── interface.go
│ └── password.go
├── pubsub
│ ├── inmem.go
│ ├── mock.go
│ ├── pubsub.go
│ └── sns.go
├── queue
│ ├── inmem.go
│ ├── mock.go
│ ├── queue.go
│ └── sqs.go
├── security
│ ├── jwt.go
│ └── jwt_test.go
├── storage
│ ├── fs.go
│ ├── inmem.go
│ ├── interface.go
│ └── s3.go
└── test
├── cache_helper.go
├── postgresql_helper.go
Erros
Quem Consome Nossos Erros
A parte complicada sobre os erros é que eles precisam ser coisas diferentes para consumidores diferentes deles. Em qualquer sistema, temos pelo menos 3 papéis que são consumidores - a aplicação, o usuário final e a operação.
O papel da aplicação
Sua primeira linha de defesa no tratamento de erros é o próprio aplicativo. O código do seu aplicativo pode se recuperar de estados de erro rapidamente e sem chamar ninguém no meio da noite. No entanto, o tratamento de erros do aplicativo é o menos flexível e só pode tratar estados de erro bem definidos.
Um exemplo disso é o seu navegador recebendo um código de redirecionamento 301 e navegando para um novo local. É um processo contínuo que a maioria dos usuários ignora. É capaz de fazer isso porque a especificação HTTP têm códigos de erro bem definidos.
Papel do usuário final
Se a aplicação não for capaz de lidar com a condição de erro, esperamos que o usuário final possa resolver o problema. Seu usuário final pode ver um estado de erro como "Seu cartão de débito foi recusado" e é flexível o suficiente para resolver o problema (ou seja, depositar dinheiro em sua conta bancária).
Ao contrário da função do aplicativo, o usuário final precisa de uma mensagem legível que possa fornecer contexto para ajudá-lo a resolver o erro.
Esses usuários ainda estão limitados a erros bem definidos, pois revelar erros indefinidos pode comprometer a segurança do seu sistema. Por exemplo, um erro postgres pode detalhar informações de consulta ou esquema que podem ser usadas por um invasor. Quando confrontado com um erro indefinido, pode ser apropriado simplesmente dizer ao usuário para entrar em contato com o suporte técnico.
Papel da operação
Finalmente, a última linha de defesa é o operador do sistema, que pode ser um desenvolvedor ou uma pessoa de operações. Essas pessoas entendem os detalhes do sistema e podem trabalhar com qualquer tipo de erro.
Nesta função, você normalmente deseja ver o máximo de informações possível. Além do código de erro e da mensagem legível por humanos, um rastreamento de pilha lógico pode ajudar o operador a entender o fluxo do programa.
Nossa estrutura de erros padrão
Dado o entendimento de que precisamos de códigos de erro, mensagens por humanos e rastreamento de pilha lógico, construímos um tipo de erro para lidar com os casos das nossas aplicações:
package errors
// Application error codes.
const (
ECONFLICT = "conflict" // action cannot be performed
EINTERNAL = "internal" // internal error
EINVALID = "invalid" // validation failed
ENOTFOUND = "not_found" // entity does not exist
EFORBIDDEN = "forbidden" //operation forbidden
EEXPECTED = "expected" //expected error that don't need to be logged
ETIMEOUT = "timeout"
)
// Error defines a standard application error.
type Error struct {
Code string // Machine-readable error code (papel da aplicação)
Message string // Human-readable message (papel do usuário final)
Op string // Logical operation (papel da operação)
Err error // Embedded error (papel da operação)
Detail []byte // JSON encoded data (papel da operação)
}
Gerenciamento de erro pelo papel
Papel da aplicação/operação
Como estamos usando a Clean Architecture, as camadas de UseCase e Framework&Driver (Repositories, Queue, etc) devem seguir as seguintes regras:
-
Devem sempre retornar um erro caso exista, e não fazer log
-
Devem sempre definir os valores para Code, Op e Err, de acordo com as definições a seguir
-
Não há a necessidade de definir valor para o Message pois essa informação não vai chegar até o usuário final
Regras para definir o campo Op
É uma string com um dos formatos:
- package.function. Exemplo: test.CreateServices
- package.receiver.function. Exemplo: address.MongoRepository.Find
Regras para definir o campo Code
Escolher uma das opções a seguir, definidas no arquivo internal/errors/errors.go:
- ECONFLICT // action cannot be performed - exemplo: email duplicado
- EINTERNAL // internal error - Erros internos, referentes a própria linguagem ou ao servidor onde roda o código. Exemplo: salvar um arquivo, fazer o marshal de um json
- EINVALID // validation failed - Erros de lógica criadas por nós. Exemplo: salvar um usuário no banco de dados
- ENOTFOUND // entity does not exist
- EFORBIDDEN //operation forbidden
- EEXPECTED //expected error that don't need to be logged.
Exemplos de erros:
//Find address na camada de repositório
func (r *MongoRepository) Find(id entity.ID) (*entity.Address, error) {
result := entity.Address{}
session := r.pool.Session(nil)
defer session.Close()
coll := session.DB(r.db).C("address")
err := coll.Find(bson.M{"_id": id}).One(&result)
if err != nil {
return nil, &errors.Error{Op: "address.MongoRepository.Find", Err: err, Code: errors.ENOTFOUND}
}
return &result, nil
}
//Find an address na camada de serviço
func (s *Service) Find(id entity.ID) (*entity.Address, error) {
a, err := s.repo.Find(id) //está usando o MongoRepository
if err != nil {
return nil, &errors.Error{Op: "address.Service.Find", Err: err, Code: errors.ErrorCode(err)}
}
return a, nil
}
Papel do usuário final
De acordo com a Clean Architecture, a camada responsável pela interação com agentes externos (a UI neste caso) é a Controller. Desta forma, o campo Message deve ser definido nesta camada. Por exemplo, no arquivo app1/api/handler/address.go
teríamos o código:
a, err := services.Address.Find(entity.StringToID(id))
if err != nil {
err.Message = "Erro lendo endereço"
errorService.Log(err, elog.ERROR)
errorService.RespondWithError(w, http.StatusNotFound, errors.ErrorCode(err), errors.ErrorMessage(err))
return
}
A função errorService.Log
(cujo código está dentro do pacote internal\elog
) faz o log do erro, enviando para o Sentry ou outro destino configurado no ambiente.
A função errorService.RespondWithError
gera uma resposta para o cliente, com a respectiva mensagem de erro. O parâmetro w
pode ser um http.ResponseWriter
ou um io.Writer
, como o os.StdOut
.
Log de erros
Para realizar o log dos erros o pacote elog
usa o logrus
, que vai tratar o envio do log para destinos apropriados, de acordo com as configurações do ambiente (Sentry para staging e prod, stdout para ambiente de desenvolvimento).
A função Log
recebe um error
e um nível de severidade, que deve ser usado para a triagem das mensagens no storage de log. Os valores possíveis são:
DEBUG
Informações úteis para o desenvolvimento do software, que não são salvas, apenas visualizadas em tempo de desenvolvimento.
INFO
Informações relevantes e que devem ser salvas no registro de logs. É considerado um registro de nível de severidade baixo (LOW)
WARNING
Algo que aconteceu e deve ser revisado, mas que não impede o funcionamento do sistema. É considerado um registro de nível de severidade moderado (MODERATE). Exemplo: ao realizar o cadastro do usuário, o e-mail de boas vindas não pode ser enviado. Este registro deve ser enviado para o log como um WARNING
ERROR
Um erro aconteceu no sistema e deve ser revisado o mais rápido possível. É considerado um registro de nível de severidade alto (MAJOR). Exemplo: não foi possível realizar o cadastro do usuário
FATAL
O sistema parou de funcionar por algum motivo não esperado e deve ser revisado imediatamente. É considerado um registro de nível de severidade urgente (CRITICAL)
PANIC
O sistema não consegue iniciar por algum motivo não esperado e deve ser revisado imediatamente. É considerado um registro de nível de severidade urgente (CRITICAL)
Pacotes externos
Router HTTP
Usamos o Chi. Fatores usados na escolha:
- features (middlewares, inline middlewares, route groups and sub-router mounting; Context control);
- atividade do projeto (estrelas e atividade de atualizações do projeto no Github);
- usado por cases grandes, de acordo com o site e pesquisas que realizamos;
- performance
- não possui dependências externas, "plain ol' Go stdlib + net/http".
Testes
Usamos o testify para deixar os códigos dos testes mais legíveis.
Exemplo de teste usando o testify:
package yours
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSomething(t *testing.T) {
// assert equality
assert.Equal(t, 123, 123, "they should be equal")
// assert inequality
assert.NotEqual(t, 123, 456, "they should not be equal")
// assert for nil (good for errors)
assert.Nil(t, object)
// assert for not nil (good when you expect something)
if assert.NotNil(t, object) {
// now we know that object isn't nil, we are safe to make
// further assertions without causing any errors
assert.Equal(t, "Something", object.Value)
}
}
Mocks
Usamos o testify/mock e o mockery como solução para mocks em nossos testes.
Usamos essa referência para tomar a decisão. Este link também serve como introdução as principais features da solução.
ORM X SQL
A ser definido.
IDEs
Recomendamos o uso do Visual Studio Code, por ser usado também por todas as equipes da Trybe.
É necessário a instalação da extensão oficial da linguagem Go.
Sugestão de configuração do VS Code
{
"go.testFlags": [
"-failfast",
"-v"
],
"go.toolsManagement.autoUpdate": true,
"gopls": {
"ui.semanticTokens": true
},
"go.lintOnSave": "file",
"go.lintTool": "golint",
"go.formatTool": "goimports",
"go.useLanguageServer": true,
"[go]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
},
"go.docsTool": "godoc"
}