Back-End

17 abr, 2017

Criando um microsserviço usando Go e Docker

Publicidade

Recentemente, durante o desenvolvimento de uma funcionalidade do projeto CompuFácil, nos deparamos com um requisito relativamente comum a uma série de sistemas comerciais: para a geração da nota fiscal eletrônica, é necessária a consulta de uma base de dados com informações de produtos e impostos. Ao analisarmos o requisito com mais detalhes, percebemos algumas características:

  • Os dados da tabela são atualizados apenas algumas vezes por ano;
  • Esses dados devem ser acessados pela interface web, aplicativos móveis, aplicativos desktop e por scripts;
  • Os dados são independentes, não sendo influenciados por outras áreas do sistema.

Essas características nos levaram à decisão de implementar essa funcionalidade como um microsserviço. Segundo James Lewis, parafraseando Martin Fowler:

“[…] o estilo de arquitetura de microsserviços é uma abordagem que desenvolve um aplicativo único como uma suíte de pequenos serviços, cada um executando seu próprio processo e se comunicando através de mecanismos leves, muitas vezes em uma API com recursos HTTP. Esses serviços são construídos em torno de capacidades de negócios e funcionam através de mecanismos de deploy independentes totalmente automatizados. Há o mínimo possível de gerenciamento centralizado desses serviços, que podem ser escritos em diferentes linguagens de programação e utilizam diferentes tecnologias de armazenamento de dados”.

Com essa decisão validada, o próximo passo foi definir a linguagem de programação para a implementação. Seguindo o exemplo de um grande número de empresas, como Uber e Google, a nossa escolha foi Go. Dentre alguns fatos que pesaram na escolha podemos citar: boa documentação, linguagem moderna e com evolução rápida e curva de aprendizado moderada, pois possui diversas similaridades com C.

Neste artigo, vamos apresentar um passo a passo de como implementamos a solução usando TDD, Go e Docker.

Vamos começar criando uma estrutura de diretórios para nosso projeto, no nosso GOPATH:

export GOPATH=`pwd`
mkdir -p src/coderockr

Nesse diretório, vamos salvar os códigos do projeto. Seguindo o conceito de TDD, vamos começar com um teste simples, criando o arquivo main_test.go:

package main
import "testing"
func TestHelloWorld(t *testing.T) {
	var expected = "World"
	var result = hello()
	if result != expected {
		t.Error(
			"expected", expected,
			"got", result,
		)
	}
}

Ao executarmos o primeiro teste, ele deve falhar, como esperado:

cd src/coderockr
go test

# coderockr
./main_test.go:7: undefined: hello
FAIL coderockr [build failed]

Vamos agora fazer o teste passar, escrevendo o código no main.go:

package main

func hello() string {

	return "World"
}

Executando novamente o teste:

go test
PASS
ok coderockr 0.008s

Passado o primeiro (e didático) teste, vamos ao código do nosso microsserviço. Vamos criar um novo teste para implementarmos a busca por um produto:

 func TestGetProduto(t *testing.T) {
	var expected = Produto{"70101000", "SC", 0, "Ampolas de
vidro,p/transporte ou embalagem", 13.45, 17.00, 18.02, 0.00, "0",
"01/01/2017", "30/06/2017", "W7m9E1", "17.1.A", "IBPT"}
	var result = getProduto("70101000", "SC", "0")
if result != expected {
		t.Error(
			"expected", expected,
			"got", result,
		)
	}
}

Vamos criar a função getProduto, e esta deve retornar uma struct com os dados do produto pesquisado. O código mínimo que precisamos incluir no main.go para que o teste passe é:

func getProduto(codigo string, uf string, ex string) Produto { 	return Produto{"70101000", "SC", 0, "Ampolas de
vidro,p/transporte ou embalagem", 13.45, 17.00, 18.02, 0.00, "0",
"01/01/2017", "30/06/2017", "W7m9E1", "17.1.A", "IBPT"}
}

Executando novamente o go test, podemos ver que agora os testes estão passando e, com isso, podemos refatorar o código para incluir nossa lógica.

Como o nosso microsserviço vai precisar de um banco de dados, a melhor opção para esse cenário é usarmos o SQLite para gerenciar as informações. Para isso, vamos precisar instalar o pacote para conexão com o banco de dados, que é uma dependência externa ao nosso projeto. Vamos aproveitar para incluir um novo conceito que é o gerenciamento de pacotes, algo comum a todas as linguagens modernas como PHP, Python, JavaScript etc. Como em Go temos mais de uma opção, precisamos optar por uma solução – nesse caso, o Glide.

O primeiro passo é instalarmos o Glide, o que pode ser feito de diversas formas, de acordo com a documentação oficial. Como estamos usando MacOS neste exemplo, optamos pela forma mais simples que é executar:

brew install glide

Vamos agora refatorar nosso main.go para incluir as dependências necessárias, incluindo:

package main
import (
	"database/sql"
	_ "github.com/mattn/go-sqlite3"
)

Se executarmos o go test, agora vamos receber uma mensagem de erro indicando a falta do pacote go-sqlite3. Para resolver, isso vamos usar o Glide, executando:

glide init

Com esse comando, o glide varre o nosso código localizando as dependências não instaladas e faz uma série de perguntas sobre as versões encontradas e decisões de instalação. Para fins didáticos, respondi Y para as perguntas e, ao final, é criado um arquivo chamado glide.yml com as informações que ele encontrou:

package: coderockr
import:
- package: github.com/mattn/go-sqlite3
	version: ~1.2.0

Você pode fazer alterações, caso ache necessário, para mudar alguma versão ou definição. Agora, basta executar o comando a seguir para que o Glide instale as dependências:

glide install

Ao final da instalação, é criado um diretório chamado vendor com as dependências e um arquivo chamado glide.lock. O arquivo glide.lock possui os detalhes das versões instaladas e deve ser salvo dentro do repositório para que todas as instalações tenham as mesmas versões. O diretório vendor pode ser incluído no seu .gitignore, pois ele não precisa ser salvo no repositório. Para quem vem do mundo PHP, o Glide tem um comportamento muito parecido com o Composer e mais detalhes podem ser vistos na documentação do projeto.

Uma dica para melhorar a performance dos testes locais é executar o comando:

go install -v

Como o Glide faz o download das fontes das dependências, cada vez que executarmos go test, elas são compiladas, e isso se torna um pouco lento devido ao tamanho do pacote go-sqlite3. Executando o go install, os pacotes ficam pré-compilados, o que melhora bastante a performance dos testes.

Vamos agora refatorar nosso código para fazer uso do novo pacote:

func getProduto(codigo string, uf string, ex string) Produto {
	var result = Produto{}
	db, dbError := sql.Open("sqlite3", "./artigo.db")
	defer db.Close()
	if dbError != nil {
6
		panic(dbError)
	}
	stmt, stmtError := db.Prepare("SELECT * FROM produto where
codigo = ? and uf = ? and ex = ?")
	if stmtError != nil {
		panic(stmtError)
	}
	sqlError := stmt.QueryRow(codigo, uf, ex).Scan(&result.Codigo,
&result.Uf, &result.Ex, &result.Descricao, &result.Nacional,
&result.Estadual, &result.Importado, &result.Municipal,
&result.Tipo, &result.VigenciaInicio, &result.VigenciaFim,
&result.Chave, &result.Versao, &result.Fonte)
	if sqlError != nil {
		panic(sqlError)
	}
	return result
}

Executando o go test novamente, vamos ter um novo erro, pois não existe o banco de dados artigo.db, que vamos criar com o comando:

sqlite3 artigo.db
SQLite version 3.14.0 2016-07-26 15:17:14
Enter ".help" for usage hints.
sqlite> CREATE TABLE `produto` (
	...> `codigo` VARCHAR(10) not null,
	...> `Uf` VARCHAR(100) null,
	...> `Ex` int null,
	...> `Descricao` VARCHAR(100) null,
	...> `Nacional` real null,
	...> `Estadual` real null,
	...> `Importado` real null,
	...> `Municipal` real null,
	...> `Tipo` VARCHAR(100) null,
	...> `VigenciaInicio` VARCHAR(100) null,
	...> `VigenciaFim` VARCHAR(100) null,
	...> `Chave` VARCHAR(100) null,
	...> `Versao` VARCHAR(100) null,
	...> `Fonte` VARCHAR(100) null
	...> );
sqlite> insert into produto values("70101000","SC",0,"Ampolas de
vidro,p/transporte ou
embalagem",13.45,17.00,18.02,0.00,"0","01/01/2017","30/06/2017","W7
m9E1","17.1.A","IBPT");

Com isso, nossos testes voltam a passar. Aqui, podemos fazer um “parêntese” e comentar que essa não é a melhor abordagem para os testes, pois o mais correto seria criarmos um mock da base de dados e não executar a consulta durante os testes. Mas, para fins didáticos, vamos simplificar o exemplo e deixar para o leitor o exercício de melhorar esse teste usando uma solução de mocks como o https://github.com/DATA-DOG/go-sqlmock.

No teste acima, nós estamos validando apenas o “caminho feliz” e precisamos pensar no comportamento do código para o caso de não existir o produto que estamos pesquisando. Para isso, vamos criar um novo teste e refatorar nosso código para que ele fique mais “idiomático”. Dessa forma, nosso main_test.go ficou da seguinte forma:

package main
import "testing"
func TestHelloWorld(t *testing.T) {
	var expected = "World"
	var result = hello()
	if result != expected {
		t.Error(
			"expected", expected,
			"got", result,
		)
	}
}

func TestGetProduto(t *testing.T) {
	var expected = Produto{"70101000", "SC", 0, "Ampolas de
vidro,p/transporte ou embalagem", 13.45, 17.00, 18.02, 0.00, "0",
"01/01/2017", "30/06/2017", "W7m9E1", "17.1.A", "IBPT"}
	var result, _ = getProduto("70101000", "SC", "0")
8
	if result != expected {
		t.Error(
			"expected", expected,
			"got", result,
		)
	}
}

func TestGetProdutoNotFound(t *testing.T) {
	expected := "Produto not found"
	var _, err = getProduto("7010110", "SC", "0")
	if err.Error() != expected {
		t.Error(
			"expected", expected,
			"got", err.Error(),
		)
	}
}

Agora estamos usando um conceito muito aplicado em funções no Go, o retorno de dois valores: o sucesso e o erro, caso este exista. O nosso primeiro teste ignora o valor de erro e testa apenas o sucesso, enquanto que o segundo teste é focado em validar o caso de erro. Para que nossos testes voltem a passar, vamos refatorar nosso main.go:

package main

import (
	"database/sql"
	"errors"
	_ "github.com/mattn/go-sqlite3"
)

func hello() string {
	return "World"
}

type Produto struct {
	Codigo string
	Uf string
	Ex int
	Descricao string
	Nacional float64
	Estadual float64
	Importado float64
	Municipal float64
	Tipo string
	VigenciaInicio string
	VigenciaFim string
	Chave string
	Versao string
	Fonte string
}

func getProduto(codigo string, uf string, ex string) (produto
Produto, err error) {
	var result = Produto{}
	db, dbError := sql.Open("sqlite3", "./artigo.db")
	defer db.Close()
	checkErr(dbError)
	stmt, stmtError := db.Prepare("SELECT * FROM produto where
codigo = ? and uf = ? and ex = ?")
	checkErr(stmtError)
	sqlError := stmt.QueryRow(codigo, uf, ex).Scan(&result.Codigo,
&result.Uf, &result.Ex, &result.Descricao, &result.Nacional,
&result.Estadual, &result.Importado, &result.Municipal,
&result.Tipo, &result.VigenciaInicio, &result.VigenciaFim,
&result.Chave, &result.Versao, &result.Fonte)
	switch {
	case sqlError == sql.ErrNoRows:
		return produto, errors.New("Produto not found")
	default:
		checkErr(sqlError)
	}
	return result, nil
}

func checkErr(err error) {
	if err != nil {
		panic(err)
	}
}

Fizemos a inclusão do pacote errors, mudamos a assinatura da função getProduto para que ela retorne mais de um valor e criamos uma função chamada checkErr para remover algum código duplicado. A função checkErr pode mais tarde ser refatorada para melhorar o tratamento de erros, pois o comando panic nem sempre é a melhor forma a ser usada. Executando o go test novamente, tudo deve passar normalmente.

Com nossa lógica coberta por testes, podemos desenvolver o código que vai transformar nosso projeto em um microsserviço, recebendo requisições HTTP e retornando o valor desejado. Para isso, vamos criar um método main em nosso arquivo main.go:

func main() {
	http.HandleFunc("/", HandleIndex)
	http.ListenAndServe(":8082", nil)
}

Ao executarmos o serviço, ele ficará “ouvindo” na porta 8082 e, a cada requisição, a função HandleIndex será invocada. Essa função deve chamar o getProduto, transformar a struct em um conteúdo JSON e devolver o resultado para o usuário. Vamos começar criando um teste para essa nova função, validando o cenário de erro mais comum:

func TestWithoutParameters(t *testing.T) {
	req, _ := http.NewRequest("POST", "/", nil)
	rr := httptest.NewRecorder()
	
HandleIndex(rr, req)

	if status := rr.Code; status != http.StatusNotFound {
		t.Errorf("handler returned wrong status code: got %v want
%v",
			status, http.StatusNotFound)
		}
}

Lembrando que precisamos também incluir o pacote “net/http/httptest” no começo do main_test.go. Para fins didáticos, vamos incluir aqui todos os testes antes de escrevermos o código final do main.go. Dessa forma, o nosso main_test.go ficou da seguinte forma:

package main
import (
	"net/http"
"net/http/httptest"
	"net/url"
	"strconv"
	"strings"
	"testing"
)

func TestHelloWorld(t *testing.T) {
	var expected = "World"
	var result = hello()
	if result != expected {
		t.Error(
			"expected", expected,
			"got", result,
		)
	}
}
func TestGetProduto(t *testing.T) {
	var expected = Produto{"70101000", "SC", 0, "Ampolas de
vidro,p/transporte ou embalagem", 13.45, 17.00, 18.02, 0.00, "0",
"01/01/2017", "30/06/2017", "W7m9E1", "17.1.A", "IBPT"}
	var result, _ = getProduto("70101000", "SC", "0")
	if result != expected {
		t.Error(
			"expected", expected,
			"got", result,
		)

	}
}

func TestGetProdutoNotFound(t *testing.T) {
	expected := "Produto not found"
	var _, err = getProduto("7010110", "SC", "0")
	if err.Error() != expected {
		t.Error(
			"expected", expected,
			"got", err.Error(),
		)
	}
}

func TestWithoutParameters(t *testing.T) {
	req, _ := http.NewRequest("POST", "/", nil)
	rr := httptest.NewRecorder()

	HandleIndex(rr, req)

	if status := rr.Code; status != http.StatusNotFound {
		t.Errorf("handler returned wrong status code: got %v want
%v",
			status, http.StatusNotFound)
	}
}

func TestNotFound(t *testing.T) {
	data := url.Values{}
	data.Set("codigo", "invalid")
	data.Set("uf", "SC")
	data.Set("ex", "0")

	req, _ := http.NewRequest("POST", "/",
strings.NewReader(data.Encode()))
	req.Header.Add("Content-Type", "application/x-www-formurlencoded")
	req.Header.Add("Content-Length",
strconv.Itoa(len(data.Encode())))
	rr := httptest.NewRecorder()

	HandleIndex(rr, req)
	
	if status := rr.Code; status != http.StatusNotFound {
		t.Errorf("handler returned wrong status code: got %v want
%v",
		status, http.StatusNotFound)
	}
}

func TestFound(t *testing.T) {
	data := url.Values{}
	data.Set("codigo", "70101000")
	data.Set("uf", "SC")
	data.Set("ex", "0")

	req, _ := http.NewRequest("POST", "/",
strings.NewReader(data.Encode()))
	req.Header.Add("Content-Type", "application/x-www-formurlencoded")
	req.Header.Add("Content-Length",
strconv.Itoa(len(data.Encode())))
	rr := httptest.NewRecorder()

	HandleIndex(rr, req)

	if status := rr.Code; status != http.StatusOK {
		t.Errorf("handler returned wrong status code: got %v want
%v",
			status, http.StatusOK)
	}
}

Nós incluímos nele os testes dos principais cenários do projeto e as dependências necessárias. E agora podemos visualizar o código final do main.go:

package main

import (
	"database/sql"
	"encoding/json"
	"errors"
	_ "github.com/mattn/go-sqlite3"
	"io"
	"net/http"
)

func hello() string {
	return "World"
}

type Produto struct {
	Codigo string
	Uf string
	Ex int
	Descricao string
	Nacional float64
	Estadual float64
	Importado float64
	Municipal float64
	Tipo string
	VigenciaInicio string
	VigenciaFim string
	Chave string
	Versao string
	Fonte string
}

func getProduto(codigo string, uf string, ex string) (produto
Produto, err error) {
	var result = Produto{}
	db, dbError := sql.Open("sqlite3", "./artigo.db")
	defer db.Close()
	checkErr(dbError)
	stmt, stmtError := db.Prepare("SELECT * FROM produto where
codigo = ? and uf = ? and ex = ?")
	checkErr(stmtError)
	sqlError := stmt.QueryRow(codigo, uf, ex).Scan(&result.Codigo,
&result.Uf, &result.Ex, &result.Descricao, &result.Nacional,
&result.Estadual, &result.Importado, &result.Municipal,
&result.Tipo, &result.VigenciaInicio, &result.VigenciaFim,
&result.Chave, &result.Versao, &result.Fonte)
	switch {
	case sqlError == sql.ErrNoRows:
		return produto, errors.New("Produto not found")
	default:
	checkErr(sqlError)
	}

	return result, nil
}

func checkErr(err error) {
	if err != nil {
	panic(err)
	}
}

func HandleIndex(w http.ResponseWriter, r *http.Request) {
	r.ParseForm()
	codigo := r.FormValue("codigo")
	uf := r.FormValue("uf")
	ex := r.FormValue("ex")
	w.Header().Add("Access-Control-Allow-Origin", "*")
	w.Header().Set("Content-Type", "application/json")

	produto, err := getProduto(codigo, uf, ex)
	if err != nil {
		w.WriteHeader(http.StatusNotFound)
		returnError := map[string]string{"error": err.Error()}
		errorMessage, errJson := json.Marshal(returnError)
		checkErr(errJson)
		io.WriteString(w, string(errorMessage))
		return
	}
	result, err := json.Marshal(produto)
	checkErr(err)

	io.WriteString(w, string(result))
}
func main() {
	http.HandleFunc("/", HandleIndex)
	http.ListenAndServe(":8082", nil)
}

Agora que os testes passaram, podemos executar o comando:

go build

E o executável coderockr será gerado no diretório atual (src/coderockr). Podemos testá-lo executando:

./coderockr

De outro terminal, podemos usar o comando curl para validar os dados:

curl http://localhost:8082

O resultado deve ser:

{"error":"Produto not found"}

Passando dados:

curl http://localhost:8082 -d "codigo=70101000&uf=SC&ex=0"

E o resultado deve ser o JSON esperado.

Com nosso serviço funcionando, vamos agora passar para a última etapa, que é criarmos um container Docker que será enviado aos ambientes de execução. Para esse exemplo, precisamos ter o comando docker instalado na máquina e uma conta em algum serviço de registro de containers. Vamos iniciar criando o arquivo Dockerfile dentro da raiz do nosso projeto com o conteúdo:

FROM golang:1.7
ENV GOPATH /go
COPY src/coderockr/glide.lock /go/src/coderockr/
COPY src/coderockr/glide.yaml /go/src/coderockr/
COPY src/coderockr/main.go /go/src/coderockr/
COPY src/coderockr/artigo.db /go
RUN cd /go/src/coderockr \
	&& curl https://glide.sh/get | sh \
	&& glide install \
	&& go build -o /go/artigo-imasters main.go

EXPOSE 8082

CMD ["/go/artigo-imasters"]

O arquivo contém todos os comandos necessários para compilar nosso serviço e executá-lo. Para criarmos o container, basta executar:

docker build -t coderockr/exemplo-imasters .

Se tudo deu certo, o container vai ser gerado e estará pronto para ser executado:

docker run --rm -p 8082:8082 coderockr/exemplo-imasters

Podemos usar os comandos do curl acima para validar que o serviço está funcionando como o esperado.

O último passo é enviarmos nosso novo container para um Docker Registry, para que possamos facilmente instalá-lo e em todos os ambientes. Neste exemplo, vamos usar o Docker Hub, sendo necessário criar uma conta no http://hub.docker.com e fazermos o login no terminal usando o comando:

docker login

Agora basta enviar para o registry, usando:

docker push coderockr/exemplo-imasters

Lembrando que é preciso mudar o “coderockr” pelo nome da sua conta no registry. E nos servidores onde o serviço vai executar, podemos usar os comandos:

docker pull coderockr/exemplo-imasters
docker run --rm -p 8082:8082 coderockr/exemplo-imasters

Na documentação oficial do Docker, podemos ver exemplos mais avançados dos comandos docker push e docker pull, como a criação de tags de versão e atualizações incrementais. Também podemos incrementar a solução usando gatilhos do GitHub e Docker Hub para que o container seja atualizado automaticamente a cada commit. E existe uma série de ferramentas que podem ser usadas para realizarmos o deploy automático do container para os ambientes de homologação ou produção, como os fornecidos pela Heroku, Amazon AWS, Gitlab, Getup Cloud etc. Mas essas sugestões ficam como exercícios para os leitores e talvez como assunto para outro artigo.

***

Agradecimentos especiais aos meus colegas das equipes da Coderockr e CompuFácil que me ajudaram na revisão deste artigo.