A primeira pergunta para os desenvolvedores que estão apenas começando a usar Go geralmente é a seguinte: "Qual estrutura deve ser usada para resolver o problema X". Embora essa seja uma pergunta perfeitamente normal quando feita com aplicativos da web e servidores escritos em muitos outros idiomas em mente, no caso do Go, há muitas sutilezas a serem consideradas ao responder a esta pergunta. Existem fortes argumentos a favor e contra o uso de estruturas em projetos Go. Enquanto trabalho em artigos desta série, vejo meu objetivo como um estudo objetivo e versátil deste assunto.
Uma tarefa
Para começar, quero dizer que parto do pressuposto de que o leitor está familiarizado com o conceito de "servidor REST". Se você precisar de uma atualização, dê uma olhada neste bom material (mas existem muitos outros artigos semelhantes). De agora em diante, assumirei que você entenderá o que quero dizer quando uso os termos "caminho", "cabeçalho HTTP", "código de resposta" e outros semelhantes.
Em nosso caso, o servidor é um sistema de back-end simples para um aplicativo que implementa a funcionalidade de gerenciamento de tarefas (como Google Keep, Todoist e similares). O servidor fornece a seguinte API REST aos clientes:
POST /task/ : ID GET /task/<taskid> : ID GET /task/ : DELETE /task/<taskid> : ID GET /tag/<tagname> : GET /due/<yy>/<mm>/<dd> : ,
Observe que esta API foi criada especificamente para nosso exemplo. Nas próximas partes desta série, falaremos sobre uma abordagem mais estruturada e padronizada para o design de API.
Nosso servidor oferece suporte a solicitações GET, POST e DELETE, algumas delas com a capacidade de usar vários caminhos. O que é mostrado entre colchetes angulares (
<...>
) na descrição da API denota parâmetros que o cliente fornece ao servidor como parte de uma solicitação. Por exemplo, a solicitação é
GET /task/42
direcionada para receber uma tarefa do servidor com
ID
42
.
ID
São identificadores de tarefas exclusivos.
Os dados são codificados no formato JSON. Ao executar um pedido
POST /task/
o cliente envia uma representação JSON da tarefa a ser criada para o servidor. E, da mesma forma, as respostas a essas solicitações, cuja descrição diz que "retornam" algo, contêm dados JSON. Em particular, eles são colocados no corpo das respostas HTTP.
O código
A seguir, trataremos da gravação do código do servidor em Go, passo a passo. A versão completa pode ser encontrada aqui . É um módulo Go independente que não usa dependências. Após clonar ou copiar o diretório do projeto para o computador, o servidor pode imediatamente, sem instalar nada adicional, executar:
$ SERVERPORT=4112 go run .
Observe que
SERVERPORT
você pode usar qualquer porta que escute no servidor local enquanto espera por conexões. Depois que o servidor é iniciado, usando uma janela de terminal separada, você pode trabalhar com ele usando, por exemplo, um utilitário
curl
. Você também pode interagir com ele usando alguns outros programas semelhantes. Exemplos de comandos usados para enviar solicitações ao servidor podem ser encontrados neste script . O diretório que contém este script contém ferramentas para teste automatizado de servidor.
Modelo
Vamos começar discutindo o modelo (ou "camada de dados") do nosso servidor. Você pode encontrá-lo no pacote
taskstore
(
internal/taskstore
no diretório do projeto). Esta é uma abstração simples que representa um banco de dados que armazena tarefas. Aqui está sua API:
func New() *TaskStore
// CreateTask .
func (ts *TaskStore) CreateTask(text string, tags []string, due time.Time) int
// GetTask ID. ID -
// .
func (ts *TaskStore) GetTask(id int) (Task, error)
// DeleteTask ID. ID -
// .
func (ts *TaskStore) DeleteTask(id int) error
// DeleteAllTasks .
func (ts *TaskStore) DeleteAllTasks() error
// GetAllTasks .
func (ts *TaskStore) GetAllTasks() []Task
// GetTasksByTag , ,
// .
func (ts *TaskStore) GetTasksByTag(tag string) []Task
// GetTasksByDueDate , , ,
// .
func (ts *TaskStore) GetTasksByDueDate(year int, month time.Month, day int) []Task
Aqui está uma declaração de tipo
Task
:
type Task struct {
Id int `json:"id"`
Text string `json:"text"`
Tags []string `json:"tags"`
Due time.Time `json:"due"`
}
O pacote
taskstore
implementa essa API usando um dicionário simples
map[int]Task
e armazena os dados na memória. Mas não é difícil imaginar uma implementação dessa API baseada em banco de dados. Em um aplicativo real
TaskStore
, provavelmente será uma interface que pode ser implementada por diferentes back-ends. Mas para nosso exemplo simples, esta API é o suficiente. Se você quiser praticar, implemente
TaskStore
usando algo como MongoDB.
Preparando o servidor para o trabalho
A função do
main
nosso servidor é bastante simples:
func main() {
mux := http.NewServeMux()
server := NewTaskServer()
mux.HandleFunc("/task/", server.taskHandler)
mux.HandleFunc("/tag/", server.tagHandler)
mux.HandleFunc("/due/", server.dueHandler)
log.Fatal(http.ListenAndServe("localhost:"+os.Getenv("SERVERPORT"), mux))
}
Vamos dedicar algum tempo à equipe
NewTaskServer
e, em seguida, falaremos sobre o roteador e os manipuladores de caminho.
NewTaskServer
É um construtor para nosso servidor, do tipo
taskServer
. O servidor inclui o
TaskStore
que é seguro em termos de acesso simultâneo aos dados .
type taskServer struct {
store *taskstore.TaskStore
}
func NewTaskServer() *taskServer {
store := taskstore.New()
return &taskServer{store: store}
}
Roteamento e manipuladores de caminho
Agora vamos voltar ao roteamento. Ele usa o multiplexador HTTP padrão incluído no pacote
net/http
:
mux.HandleFunc("/task/", server.taskHandler)
mux.HandleFunc("/tag/", server.tagHandler)
mux.HandleFunc("/due/", server.dueHandler)
O multiplexador padrão tem recursos bastante modestos. Esta é sua força e sua fraqueza. Seu ponto forte é que é muito fácil lidar com isso, pois não há nada difícil em seu trabalho. E a fraqueza do multiplexador padrão é que às vezes seu uso torna a solução do problema de correspondência de solicitações com os caminhos disponíveis no sistema um tanto tedioso. O que, de acordo com a lógica das coisas, seria bom estar em um lugar, você tem que colocar em lugares diferentes. Falaremos mais sobre isso em breve.
Como o multiplexador padrão só oferece suporte à correspondência exata de solicitações com prefixos de caminho, somos praticamente forçados a confiar apenas nos caminhos raiz no nível superior e delegar a tarefa de encontrar o caminho exato aos manipuladores de caminho.
Vamos examinar o manipulador de caminho
taskHandler
:
func (ts *taskServer) taskHandler(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/task/" {
// "/task/", ID.
if req.Method == http.MethodPost {
ts.createTaskHandler(w, req)
} else if req.Method == http.MethodGet {
ts.getAllTasksHandler(w, req)
} else if req.Method == http.MethodDelete {
ts.deleteAllTasksHandler(w, req)
} else {
http.Error(w, fmt.Sprintf("expect method GET, DELETE or POST at /task/, got %v", req.Method), http.StatusMethodNotAllowed)
return
}
Começamos verificando se há uma correspondência exata do caminho com
/task/
(o que significa que não há no final
<taskid>
). Aqui, precisamos entender qual método HTTP está sendo usado e chamar o método de servidor correspondente. A maioria dos manipuladores de caminho são wrappers de API bastante simples
TaskStore
. Vejamos um destes manipuladores:
func (ts *taskServer) getAllTasksHandler(w http.ResponseWriter, req *http.Request) {
log.Printf("handling get all tasks at %s\n", req.URL.Path)
allTasks := ts.store.GetAllTasks()
js, err := json.Marshal(allTasks)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(js)
}
Ele resolve duas tarefas principais:
- Recebe dados do modelo (
TaskStore
). - Gera uma resposta HTTP para o cliente.
Ambas as tarefas são bastante simples e diretas, mas se você examinar o código de outros manipuladores de caminho, poderá notar que a segunda tarefa tende a se repetir - consiste em empacotar dados JSON, preparar o cabeçalho de resposta HTTP correto e em realizando outras ações semelhantes. ... Levantaremos esse problema novamente mais tarde.
Vamos voltar agora para
taskHandler
. Até agora, vimos apenas como ele lida com solicitações que têm uma correspondência de caminho exata
/task/
. E o caminho
/task/<taskid>
? É aqui que entra a segunda parte da função:
} else {
// ID, "/task/<id>".
path := strings.Trim(req.URL.Path, "/")
pathParts := strings.Split(path, "/")
if len(pathParts) < 2 {
http.Error(w, "expect /task/<id> in task handler", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(pathParts[1])
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.Method == http.MethodDelete {
ts.deleteTaskHandler(w, req, int(id))
} else if req.Method == http.MethodGet {
ts.getTaskHandler(w, req, int(id))
} else {
http.Error(w, fmt.Sprintf("expect method GET or DELETE at /task/<id>, got %v", req.Method), http.StatusMethodNotAllowed)
return
}
}
Quando a consulta não corresponde ao caminho exatamente
/task/
, esperamos que o
ID
problema numérico siga a barra . O código acima analisa este
ID
e chama o manipulador apropriado (com base no método de solicitação HTTP).
O resto do código é mais ou menos semelhante ao que já abordamos, deve ser fácil de entender.
Melhoria do servidor
Agora que temos uma versão básica de trabalho do servidor, é hora de pensar sobre os possíveis problemas que podem surgir com ele e como melhorá-lo.
Uma das construções de programação que usamos e que obviamente precisa de melhorias, e sobre a qual já falamos, é o código repetitivo para preparar dados JSON ao gerar respostas HTTP. Criei uma versão separada do servidor, stdlib-factorjson , que resolve esse problema. Separei esta implementação de servidor em uma pasta separada para tornar mais fácil compará-la com o código do servidor original e analisar as alterações. A principal inovação deste código é representada pela seguinte função:
// renderJSON 'v' JSON , , w.
func renderJSON(w http.ResponseWriter, v interface{}) {
js, err := json.Marshal(v)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(js)
}
Usando esta função, podemos reescrever o código de todos os manipuladores de caminho, encurtando-o. Por exemplo, esta é a aparência do código agora
getAllTasksHandler
:
func (ts *taskServer) getAllTasksHandler(w http.ResponseWriter, req *http.Request) {
log.Printf("handling get all tasks at %s\n", req.URL.Path)
allTasks := ts.store.GetAllTasks()
renderJSON(w, allTasks)
}
Uma melhoria mais fundamental seria tornar o código de mapeamento de solicitação para caminho mais limpo e, se possível, coletar esse código em um só lugar. Embora a abordagem atual para correspondência de solicitações e caminhos torne a depuração mais fácil, o código por trás disso é difícil de entender à primeira vista, pois está espalhado por várias funções. Por exemplo, suponha que estejamos tentando descobrir como uma solicitação
DELETE
é direcionada a um
/task/<taskid>
. Para fazer isso, siga estas etapas:
- - —
main
,/task/
taskHandler
. - ,
taskHandler
,else
, ,/task/
.<taskid>
. - —
if
, , , , ,DELETE
deleteTaskHandler
.
Você pode colocar todo esse código em um só lugar. Será muito mais fácil e conveniente trabalhar com ele. É exatamente para isso que os roteadores HTTP de terceiros se destinam. Falaremos sobre eles na segunda parte desta série de artigos.
❒ Esta é a primeira parte de uma série sobre o desenvolvimento de servidores Go. Você pode ver a lista de artigos no início do original deste material.