Desenvolvimento de servidores REST em Go. Parte 1: a biblioteca padrão

Este é o primeiro de uma série de artigos sobre o desenvolvimento de servidores REST em Go. Nestes artigos, pretendo descrever uma implementação de servidor REST simples usando várias abordagens diferentes. Como resultado, essas abordagens podem ser comparadas entre si, será possível entender suas vantagens relativas umas sobre as outras.



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:



  1. Recebe dados do modelo ( TaskStore



    ).
  2. 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:



  1. - — main



    , /task/



    taskHandler



    .
  2. , taskHandler



    , else



    , , /task/



    . <taskid>



    .
  3. 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.








All Articles